Skip to content

Commit 039371d

Browse files
Phlogistiqueclaude
andauthored
Handle merge conflicts with manual resolution workflow (#8)
* Add automatic continuation after manual conflict resolution When a PR has a conflict that requires manual resolution: 1. The action adds the 'autorestack-needs-conflict-resolution' label 2. User manually resolves the conflict and pushes 3. The push triggers a 'synchronize' event 4. The workflow detects the label and triggers the continuation job 5. The action removes the label, posts confirmation, and updates child PRs This ensures the PR stack continues to be updated even after manual intervention is required. * Update README with conflict resolution continuation docs Document the automatic continuation feature after manual conflict resolution, including the updated workflow configuration with the synchronize trigger and continuation job. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f80e3e5 commit 039371d

5 files changed

Lines changed: 310 additions & 19 deletions

File tree

.github/workflows/update-pr-stack.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Optimized Recursive Update of PR Stack on Squash Merge
22

33
on:
44
pull_request:
5-
types: [closed]
5+
types: [closed, synchronize]
66

77
permissions:
88
contents: write
@@ -11,7 +11,7 @@ permissions:
1111

1212
jobs:
1313
update-pr-stack:
14-
if: github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != ''
14+
if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != ''
1515
runs-on: ubuntu-latest
1616
steps:
1717
- name: Checkout repository
@@ -23,3 +23,19 @@ jobs:
2323
uses: ./
2424
with:
2525
github-token: ${{ secrets.GITHUB_TOKEN }}
26+
27+
continue-after-conflict-resolution:
28+
if: github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'autorestack-needs-conflict-resolution')
29+
runs-on: ubuntu-latest
30+
steps:
31+
- name: Checkout repository
32+
uses: actions/checkout@v3
33+
with:
34+
fetch-depth: 0
35+
36+
- name: Continue PR stack update after conflict resolution
37+
uses: ./
38+
with:
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
mode: conflict-resolved
41+
pr-branch: ${{ github.event.pull_request.head.ref }}

README.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ This action tries to fix that in a transparent way. Install it, and hopefully th
2323
5. Updates each PR's base branch to point to trunk
2424
6. Force-pushes updated branches and deletes the merged branch
2525

26+
### Conflict handling
27+
28+
When a merge conflict occurs during the automatic update:
29+
30+
1. The action posts a comment on the affected PR with instructions for manual resolution
31+
2. Adds a `autorestack-needs-conflict-resolution` label to the PR
32+
3. Updates the PR's base branch (but doesn't push the conflicted state)
33+
34+
After you manually resolve the conflict and push:
35+
36+
1. The push triggers the `synchronize` event
37+
2. The action detects the conflict label and removes it
38+
3. Posts a confirmation comment
39+
4. Continues updating any dependent PRs in the stack
40+
2641
---
2742

2843
### Setup
@@ -33,7 +48,7 @@ name: Update Stacked PRs on Squash Merge
3348

3449
on:
3550
pull_request:
36-
types: [closed]
51+
types: [closed, synchronize]
3752

3853
permissions:
3954
contents: write
@@ -42,7 +57,7 @@ permissions:
4257

4358
jobs:
4459
update-pr-stack:
45-
if: github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != ''
60+
if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != ''
4661
runs-on: ubuntu-latest
4762
steps:
4863
- name: Checkout repository
@@ -52,14 +67,30 @@ jobs:
5267

5368
- name: Update PR stack
5469
uses: Phlogistique/autorestack-action@main
55-
env:
56-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
70+
with:
71+
github-token: ${{ secrets.GITHUB_TOKEN }}
72+
73+
continue-after-conflict-resolution:
74+
if: github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'autorestack-needs-conflict-resolution')
75+
runs-on: ubuntu-latest
76+
steps:
77+
- name: Checkout repository
78+
uses: actions/checkout@v3
79+
with:
80+
fetch-depth: 0
81+
82+
- name: Continue PR stack update
83+
uses: Phlogistique/autorestack-action@main
84+
with:
85+
github-token: ${{ secrets.GITHUB_TOKEN }}
86+
mode: conflict-resolved
87+
pr-branch: ${{ github.event.pull_request.head.ref }}
5788
```
5889
5990
### Notes
6091
6192
* Currently only supports squash merges
62-
* If a merge hits a conflict, you'll need to resolve it manually
93+
* If a merge hits a conflict, you'll need to resolve it manually; pushing the resolution automatically continues the stack update
6394
* Very large stacks might hit GitHub rate limits
6495
6596
---

action.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ inputs:
77
description: 'GitHub token for API access'
88
required: true
99
default: ${{ github.token }}
10+
mode:
11+
description: 'Action mode: squash-merge (after PR merge) or conflict-resolved (after manual resolution)'
12+
required: false
13+
default: 'squash-merge'
14+
pr-branch:
15+
description: 'The PR branch that was pushed (required for conflict-resolved mode)'
16+
required: false
17+
default: ''
1018

1119
runs:
1220
using: 'composite'
@@ -19,9 +27,11 @@ runs:
1927
GIT_AUTHOR_EMAIL: github-actions@github.com
2028
GIT_COMMITTER_NAME: github-actions
2129
GIT_COMMITTER_EMAIL: github-actions@github.com
30+
ACTION_MODE: ${{ inputs.mode }}
2231
SQUASH_COMMIT: ${{ github.event.pull_request.merge_commit_sha }}
2332
MERGED_BRANCH: ${{ github.event.pull_request.head.ref }}
2433
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
34+
PR_BRANCH: ${{ inputs.pr-branch }}
2535
run: ${{ github.action_path }}/update-pr-stack.sh
2636

2737
branding:

tests/test_e2e.sh

Lines changed: 154 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@
6767
# - Conflict label "autorestack-needs-conflict-resolution" exists on PR3
6868
# - feature3 branch was NOT updated (still at pre-conflict SHA)
6969
#
70-
# Manual Conflict Resolution (Steps 12-13):
70+
# Manual Conflict Resolution (Steps 12-14):
7171
# - Test simulates user resolving the conflict manually
7272
# - Merge main into feature3, resolve conflict (keep feature3's changes)
7373
# - Push the resolved branch
74-
# - Verify the resolved state is correct
74+
# - The push triggers the 'synchronize' event on PR3
75+
# - The action detects the conflict label and removes it
76+
# - Verify the label is removed and resolution comment is posted
7577
#
7678
# =============================================================================
7779
set -e # Exit immediately if a command exits with a non-zero status.
@@ -163,6 +165,98 @@ merge_pr_with_retry() {
163165
return 1
164166
}
165167

168+
wait_for_synchronize_workflow() {
169+
local pr_number=$1 # PR number that was updated
170+
local branch_name=$2 # The branch name that was pushed
171+
local expected_conclusion=${3:-success} # Expected conclusion (success, failure, etc.)
172+
local max_attempts=20 # ~7 mins max wait
173+
local attempt=0
174+
local target_run_id=""
175+
local start_time=$(date +%s)
176+
177+
echo >&2 "Waiting for workflow '$WORKFLOW_FILE' triggered by synchronize event on PR #$pr_number (branch $branch_name)..."
178+
179+
while [[ $attempt -lt $max_attempts ]]; do
180+
sleep_time=$(( (attempt + 1) * 2 ))
181+
echo >&2 "Attempt $((attempt + 1))/$max_attempts: Checking for workflow run..."
182+
183+
if [[ -z "$target_run_id" ]]; then
184+
echo >&2 "Searching for the specific workflow run..."
185+
# List recent runs for the workflow triggered by pull_request event
186+
candidate_run_ids=$(log_cmd gh run list \
187+
--repo "$REPO_FULL_NAME" \
188+
--workflow "$WORKFLOW_FILE" \
189+
--event pull_request \
190+
--limit 15 \
191+
--json databaseId,createdAt --jq '.[] | select(.createdAt >= "'$(date -d "@$start_time" -Iseconds 2>/dev/null || date -r $start_time +%Y-%m-%dT%H:%M:%S)'") | .databaseId' || echo "")
192+
193+
if [[ -z "$candidate_run_ids" ]]; then
194+
echo >&2 "No recent '$WORKFLOW_FILE' runs found since start. Sleeping $sleep_time seconds."
195+
sleep $sleep_time
196+
attempt=$((attempt + 1))
197+
continue
198+
fi
199+
200+
echo >&2 "Found candidate run IDs: $candidate_run_ids. Checking runs..."
201+
for run_id in $candidate_run_ids; do
202+
echo >&2 "Checking candidate run ID: $run_id"
203+
run_info=$(log_cmd gh run view "$run_id" --repo "$REPO_FULL_NAME" --json headBranch,jobs || echo "{}")
204+
205+
run_head_branch=$(echo "$run_info" | jq -r '.headBranch // ""')
206+
# Check if this run has the continue-after-conflict-resolution job
207+
has_continue_job=$(echo "$run_info" | jq -r '.jobs[] | select(.name == "continue-after-conflict-resolution") | .name' || echo "")
208+
209+
echo >&2 " Run head branch: $run_head_branch, has continue job: $has_continue_job"
210+
211+
if [[ "$run_head_branch" == "$branch_name" && -n "$has_continue_job" ]]; then
212+
echo >&2 "Found matching workflow run ID: $run_id (synchronize with continue job)"
213+
target_run_id="$run_id"
214+
break
215+
fi
216+
done
217+
fi
218+
219+
if [[ -z "$target_run_id" ]]; then
220+
echo >&2 "Target workflow run not found among recent runs. Sleeping $sleep_time seconds."
221+
sleep $sleep_time
222+
attempt=$((attempt + 1))
223+
continue
224+
fi
225+
226+
# Monitor the identified target run
227+
echo >&2 "Monitoring workflow run ID: $target_run_id"
228+
run_info=$(log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --json status,conclusion)
229+
run_status=$(echo "$run_info" | jq -r '.status')
230+
run_conclusion=$(echo "$run_info" | jq -r '.conclusion')
231+
232+
echo >&2 "Workflow run $target_run_id status: $run_status, conclusion: $run_conclusion"
233+
234+
if [[ "$run_status" == "completed" ]]; then
235+
if [[ "$run_conclusion" == "$expected_conclusion" ]]; then
236+
echo >&2 "Workflow $target_run_id completed with expected conclusion: $run_conclusion."
237+
return 0
238+
else
239+
echo >&2 "Workflow $target_run_id completed with unexpected conclusion: $run_conclusion (expected: $expected_conclusion)"
240+
log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --log || echo >&2 "Could not fetch logs for run $target_run_id"
241+
return 1
242+
fi
243+
elif [[ "$run_status" == "queued" || "$run_status" == "in_progress" || "$run_status" == "waiting" ]]; then
244+
echo >&2 "Workflow $target_run_id is $run_status. Sleeping $sleep_time seconds."
245+
else
246+
echo >&2 "Workflow $target_run_id has unexpected status: $run_status. Conclusion: $run_conclusion"
247+
log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --log || echo >&2 "Could not fetch logs for run $target_run_id"
248+
return 1
249+
fi
250+
251+
sleep $sleep_time
252+
attempt=$((attempt + 1))
253+
done
254+
255+
echo >&2 "Timeout waiting for synchronize workflow run to complete."
256+
gh run list --repo "$REPO_FULL_NAME" --workflow "$WORKFLOW_FILE" --limit 10 || echo >&2 "Could not list recent runs."
257+
return 1
258+
}
259+
166260
wait_for_workflow() {
167261
local pr_number=$1 # PR number that was merged
168262
local merged_branch_name=$2 # The head branch name of the merged PR (unused now, but kept for context)
@@ -303,14 +397,15 @@ cat > .github/workflows/"$WORKFLOW_FILE" <<EOF
303397
name: Update Stacked PRs on Squash Merge (E2E Test)
304398
on:
305399
pull_request:
306-
types: [closed]
400+
types: [closed, synchronize]
307401
permissions:
308402
contents: write
309403
pull-requests: write
310404
jobs:
311405
update-pr-stack:
312406
# Only run on actual squash merges initiated by the test script
313407
if: |
408+
github.event.action == 'closed' &&
314409
github.event.pull_request.merged == true &&
315410
github.event.pull_request.merge_commit_sha != ''
316411
runs-on: ubuntu-latest
@@ -327,6 +422,24 @@ jobs:
327422
uses: ./
328423
with:
329424
github-token: \${{ secrets.GITHUB_TOKEN }}
425+
continue-after-conflict-resolution:
426+
# Run when a PR with the conflict label is updated (user pushed conflict resolution)
427+
if: |
428+
github.event.action == 'synchronize' &&
429+
contains(github.event.pull_request.labels.*.name, 'autorestack-needs-conflict-resolution')
430+
runs-on: ubuntu-latest
431+
steps:
432+
- name: Checkout repository
433+
uses: actions/checkout@v4
434+
with:
435+
fetch-depth: 0
436+
token: \${{ secrets.GITHUB_TOKEN }}
437+
- name: Continue PR stack update after conflict resolution
438+
uses: ./
439+
with:
440+
github-token: \${{ secrets.GITHUB_TOKEN }}
441+
mode: conflict-resolved
442+
pr-branch: \${{ github.event.pull_request.head.ref }}
330443
EOF
331444

332445
log_cmd git add action.yml update-pr-stack.sh command_utils.sh .github/workflows/"$WORKFLOW_FILE"
@@ -610,8 +723,44 @@ fi
610723
log_cmd git push origin feature3
611724
echo >&2 "Pushed resolved feature3."
612725

613-
# 13. Verify conflict resolution
614-
echo >&2 "13. Verifying conflict resolution..."
726+
# 13. Wait for continuation workflow triggered by push
727+
echo >&2 "13. Waiting for continuation workflow after conflict resolution push..."
728+
if ! wait_for_synchronize_workflow "$PR3_NUM" "feature3" "success"; then
729+
echo >&2 "Continuation workflow for feature3 conflict resolution did not complete successfully."
730+
exit 1
731+
fi
732+
733+
# 14. Verify continuation workflow effects
734+
echo >&2 "14. Verifying continuation workflow effects..."
735+
736+
# Verify conflict label was removed from PR3
737+
echo >&2 "Checking that conflict label was removed from PR #$PR3_NUM..."
738+
sleep 5 # Give GitHub time to process
739+
CONFLICT_LABEL_AFTER=$(log_cmd gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json labels --jq '.labels[] | select(.name == "autorestack-needs-conflict-resolution") | .name')
740+
if [[ -z "$CONFLICT_LABEL_AFTER" ]]; then
741+
echo >&2 "✅ Verification Passed: Conflict label was removed from PR #$PR3_NUM."
742+
else
743+
echo >&2 "❌ Verification Failed: Conflict label still exists on PR #$PR3_NUM."
744+
exit 1
745+
fi
746+
747+
# Verify resolution acknowledgement comment exists on PR3
748+
echo >&2 "Checking for resolution acknowledgement comment on PR #$PR3_NUM..."
749+
RESOLUTION_COMMENT=$(log_cmd gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[] | select(.body | contains("Conflict resolved")) | .body')
750+
if [[ -n "$RESOLUTION_COMMENT" ]]; then
751+
echo >&2 "✅ Verification Passed: Resolution acknowledgement comment found on PR #$PR3_NUM."
752+
else
753+
echo >&2 "❌ Verification Failed: Resolution acknowledgement comment not found on PR #$PR3_NUM."
754+
echo >&2 "--- Comments on PR #$PR3_NUM ---"
755+
gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[].body' || echo "Failed to get comments"
756+
echo >&2 "-----------------------------"
757+
exit 1
758+
fi
759+
760+
echo >&2 "--- Continuation Workflow Test Completed Successfully ---"
761+
762+
# 15. Verify conflict resolution (content checks)
763+
echo >&2 "15. Verifying conflict resolution content..."
615764
# Fetch the latest state again
616765
log_cmd git fetch origin
617766
log_cmd git checkout feature3
@@ -653,9 +802,6 @@ else
653802
exit 1
654803
fi
655804

656-
# Optional: Verify the conflict label could be removed (manual step usually)
657-
# We won't automate label removal check as the action doesn't do it.
658-
659805
echo >&2 "--- Conflict Scenario Test Completed Successfully ---"
660806

661807

0 commit comments

Comments
 (0)