From e5c3475457fd76418d0cf1b622531c410162ea55 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 08:43:52 +0000 Subject: [PATCH 1/2] 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. --- .github/workflows/update-pr-stack.yml | 20 +++- action.yml | 10 ++ tests/test_e2e.sh | 162 ++++++++++++++++++++++++-- update-pr-stack.sh | 96 ++++++++++++++- 4 files changed, 274 insertions(+), 14 deletions(-) diff --git a/.github/workflows/update-pr-stack.yml b/.github/workflows/update-pr-stack.yml index 14ff485..a48a0e4 100644 --- a/.github/workflows/update-pr-stack.yml +++ b/.github/workflows/update-pr-stack.yml @@ -2,7 +2,7 @@ name: Optimized Recursive Update of PR Stack on Squash Merge on: pull_request: - types: [closed] + types: [closed, synchronize] permissions: contents: write @@ -11,7 +11,7 @@ permissions: jobs: update-pr-stack: - if: github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != '' + if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != '' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -23,3 +23,19 @@ jobs: uses: ./ with: github-token: ${{ secrets.GITHUB_TOKEN }} + + continue-after-conflict-resolution: + if: github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'autorestack-needs-conflict-resolution') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Continue PR stack update after conflict resolution + uses: ./ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + mode: conflict-resolved + pr-branch: ${{ github.event.pull_request.head.ref }} diff --git a/action.yml b/action.yml index b580b6c..0d40ef5 100644 --- a/action.yml +++ b/action.yml @@ -7,6 +7,14 @@ inputs: description: 'GitHub token for API access' required: true default: ${{ github.token }} + mode: + description: 'Action mode: squash-merge (after PR merge) or conflict-resolved (after manual resolution)' + required: false + default: 'squash-merge' + pr-branch: + description: 'The PR branch that was pushed (required for conflict-resolved mode)' + required: false + default: '' runs: using: 'composite' @@ -19,9 +27,11 @@ runs: GIT_AUTHOR_EMAIL: github-actions@github.com GIT_COMMITTER_NAME: github-actions GIT_COMMITTER_EMAIL: github-actions@github.com + ACTION_MODE: ${{ inputs.mode }} SQUASH_COMMIT: ${{ github.event.pull_request.merge_commit_sha }} MERGED_BRANCH: ${{ github.event.pull_request.head.ref }} TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + PR_BRANCH: ${{ inputs.pr-branch }} run: ${{ github.action_path }}/update-pr-stack.sh branding: diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh index bf165fc..61223ad 100755 --- a/tests/test_e2e.sh +++ b/tests/test_e2e.sh @@ -67,11 +67,13 @@ # - Conflict label "autorestack-needs-conflict-resolution" exists on PR3 # - feature3 branch was NOT updated (still at pre-conflict SHA) # -# Manual Conflict Resolution (Steps 12-13): +# Manual Conflict Resolution (Steps 12-14): # - Test simulates user resolving the conflict manually # - Merge main into feature3, resolve conflict (keep feature3's changes) # - Push the resolved branch -# - Verify the resolved state is correct +# - The push triggers the 'synchronize' event on PR3 +# - The action detects the conflict label and removes it +# - Verify the label is removed and resolution comment is posted # # ============================================================================= set -e # Exit immediately if a command exits with a non-zero status. @@ -163,6 +165,98 @@ merge_pr_with_retry() { return 1 } +wait_for_synchronize_workflow() { + local pr_number=$1 # PR number that was updated + local branch_name=$2 # The branch name that was pushed + local expected_conclusion=${3:-success} # Expected conclusion (success, failure, etc.) + local max_attempts=20 # ~7 mins max wait + local attempt=0 + local target_run_id="" + local start_time=$(date +%s) + + echo >&2 "Waiting for workflow '$WORKFLOW_FILE' triggered by synchronize event on PR #$pr_number (branch $branch_name)..." + + while [[ $attempt -lt $max_attempts ]]; do + sleep_time=$(( (attempt + 1) * 2 )) + echo >&2 "Attempt $((attempt + 1))/$max_attempts: Checking for workflow run..." + + if [[ -z "$target_run_id" ]]; then + echo >&2 "Searching for the specific workflow run..." + # List recent runs for the workflow triggered by pull_request event + candidate_run_ids=$(log_cmd gh run list \ + --repo "$REPO_FULL_NAME" \ + --workflow "$WORKFLOW_FILE" \ + --event pull_request \ + --limit 15 \ + --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 "") + + if [[ -z "$candidate_run_ids" ]]; then + echo >&2 "No recent '$WORKFLOW_FILE' runs found since start. Sleeping $sleep_time seconds." + sleep $sleep_time + attempt=$((attempt + 1)) + continue + fi + + echo >&2 "Found candidate run IDs: $candidate_run_ids. Checking runs..." + for run_id in $candidate_run_ids; do + echo >&2 "Checking candidate run ID: $run_id" + run_info=$(log_cmd gh run view "$run_id" --repo "$REPO_FULL_NAME" --json headBranch,jobs || echo "{}") + + run_head_branch=$(echo "$run_info" | jq -r '.headBranch // ""') + # Check if this run has the continue-after-conflict-resolution job + has_continue_job=$(echo "$run_info" | jq -r '.jobs[] | select(.name == "continue-after-conflict-resolution") | .name' || echo "") + + echo >&2 " Run head branch: $run_head_branch, has continue job: $has_continue_job" + + if [[ "$run_head_branch" == "$branch_name" && -n "$has_continue_job" ]]; then + echo >&2 "Found matching workflow run ID: $run_id (synchronize with continue job)" + target_run_id="$run_id" + break + fi + done + fi + + if [[ -z "$target_run_id" ]]; then + echo >&2 "Target workflow run not found among recent runs. Sleeping $sleep_time seconds." + sleep $sleep_time + attempt=$((attempt + 1)) + continue + fi + + # Monitor the identified target run + echo >&2 "Monitoring workflow run ID: $target_run_id" + run_info=$(log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --json status,conclusion) + run_status=$(echo "$run_info" | jq -r '.status') + run_conclusion=$(echo "$run_info" | jq -r '.conclusion') + + echo >&2 "Workflow run $target_run_id status: $run_status, conclusion: $run_conclusion" + + if [[ "$run_status" == "completed" ]]; then + if [[ "$run_conclusion" == "$expected_conclusion" ]]; then + echo >&2 "Workflow $target_run_id completed with expected conclusion: $run_conclusion." + return 0 + else + echo >&2 "Workflow $target_run_id completed with unexpected conclusion: $run_conclusion (expected: $expected_conclusion)" + log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --log || echo >&2 "Could not fetch logs for run $target_run_id" + return 1 + fi + elif [[ "$run_status" == "queued" || "$run_status" == "in_progress" || "$run_status" == "waiting" ]]; then + echo >&2 "Workflow $target_run_id is $run_status. Sleeping $sleep_time seconds." + else + echo >&2 "Workflow $target_run_id has unexpected status: $run_status. Conclusion: $run_conclusion" + log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --log || echo >&2 "Could not fetch logs for run $target_run_id" + return 1 + fi + + sleep $sleep_time + attempt=$((attempt + 1)) + done + + echo >&2 "Timeout waiting for synchronize workflow run to complete." + gh run list --repo "$REPO_FULL_NAME" --workflow "$WORKFLOW_FILE" --limit 10 || echo >&2 "Could not list recent runs." + return 1 +} + wait_for_workflow() { local pr_number=$1 # PR number that was merged local merged_branch_name=$2 # The head branch name of the merged PR (unused now, but kept for context) @@ -303,7 +397,7 @@ cat > .github/workflows/"$WORKFLOW_FILE" <&2 "Pushed resolved feature3." -# 13. Verify conflict resolution -echo >&2 "13. Verifying conflict resolution..." +# 13. Wait for continuation workflow triggered by push +echo >&2 "13. Waiting for continuation workflow after conflict resolution push..." +if ! wait_for_synchronize_workflow "$PR3_NUM" "feature3" "success"; then + echo >&2 "Continuation workflow for feature3 conflict resolution did not complete successfully." + exit 1 +fi + +# 14. Verify continuation workflow effects +echo >&2 "14. Verifying continuation workflow effects..." + +# Verify conflict label was removed from PR3 +echo >&2 "Checking that conflict label was removed from PR #$PR3_NUM..." +sleep 5 # Give GitHub time to process +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') +if [[ -z "$CONFLICT_LABEL_AFTER" ]]; then + echo >&2 "✅ Verification Passed: Conflict label was removed from PR #$PR3_NUM." +else + echo >&2 "❌ Verification Failed: Conflict label still exists on PR #$PR3_NUM." + exit 1 +fi + +# Verify resolution acknowledgement comment exists on PR3 +echo >&2 "Checking for resolution acknowledgement comment on PR #$PR3_NUM..." +RESOLUTION_COMMENT=$(log_cmd gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[] | select(.body | contains("Conflict resolved")) | .body') +if [[ -n "$RESOLUTION_COMMENT" ]]; then + echo >&2 "✅ Verification Passed: Resolution acknowledgement comment found on PR #$PR3_NUM." +else + echo >&2 "❌ Verification Failed: Resolution acknowledgement comment not found on PR #$PR3_NUM." + echo >&2 "--- Comments on PR #$PR3_NUM ---" + gh pr view "$PR3_URL" --repo "$REPO_FULL_NAME" --json comments --jq '.comments[].body' || echo "Failed to get comments" + echo >&2 "-----------------------------" + exit 1 +fi + +echo >&2 "--- Continuation Workflow Test Completed Successfully ---" + +# 15. Verify conflict resolution (content checks) +echo >&2 "15. Verifying conflict resolution content..." # Fetch the latest state again log_cmd git fetch origin log_cmd git checkout feature3 @@ -653,9 +802,6 @@ else exit 1 fi -# Optional: Verify the conflict label could be removed (manual step usually) -# We won't automate label removal check as the action doesn't do it. - echo >&2 "--- Conflict Scenario Test Completed Successfully ---" diff --git a/update-pr-stack.sh b/update-pr-stack.sh index de98105..0d93d16 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -12,6 +12,8 @@ set -ueo pipefail # Exit on error, undefined var, or pipeline failure SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/command_utils.sh" +CONFLICT_LABEL="autorestack-needs-conflict-resolution" + # Allow replacing git and gh [ -v GIT ] && git() { "$GIT" "$@"; } [ -v GH ] && gh() { "$GH" "$@"; } @@ -91,8 +93,8 @@ update_direct_target() { echo '```' } | log_cmd gh pr comment "$BRANCH" -F - # Create the label if it doesn't exist, then add it to the PR - gh label create autorestack-needs-conflict-resolution --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true - log_cmd gh pr edit "$BRANCH" --add-label autorestack-needs-conflict-resolution + gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true + log_cmd gh pr edit "$BRANCH" --add-label "$CONFLICT_LABEL" else log_cmd git merge --no-edit -s ours "$SQUASH_COMMIT" log_cmd git update-ref MERGE_RESULT "HEAD^{tree}" @@ -129,6 +131,81 @@ update_branch_recursive() { done } +# Check if a PR has the conflict resolution label +pr_has_conflict_label() { + local BRANCH="$1" + local LABELS + LABELS=$(gh pr view "$BRANCH" --json labels --jq '.labels[].name' 2>/dev/null || echo "") + echo "$LABELS" | grep -q "^${CONFLICT_LABEL}$" +} + +# Continue processing after user manually resolved conflicts +continue_after_resolution() { + check_env_var "PR_BRANCH" + + echo "Checking if $PR_BRANCH needs continuation after conflict resolution..." + + # Check if the PR has the conflict label + if ! pr_has_conflict_label "$PR_BRANCH"; then + echo "✓ $PR_BRANCH does not have conflict label; nothing to do" + return + fi + + echo "Found conflict label on $PR_BRANCH, continuing stack update..." + + # Remove the conflict label + log_cmd gh pr edit "$PR_BRANCH" --remove-label "$CONFLICT_LABEL" + + # Post a comment acknowledging the resolution + echo "✅ Conflict resolved! Continuing to update dependent PRs..." | log_cmd gh pr comment "$PR_BRANCH" -F - + + # Find and update child PRs (PRs based on this branch) + CHILD_BRANCHES=$(log_cmd gh pr list --base "$PR_BRANCH" --json headRefName --jq '.[].headRefName') + + if [[ -z "$CHILD_BRANCHES" ]]; then + echo "✓ No child PRs to update" + return + fi + + ALL_CHILDREN=() + for CHILD_BRANCH in $CHILD_BRANCHES; do + echo "Updating child branch $CHILD_BRANCH based on $PR_BRANCH" + log_cmd git checkout "$CHILD_BRANCH" + if ! log_cmd git merge --no-edit "origin/$PR_BRANCH"; then + echo "⚠️ Merge conflict updating $CHILD_BRANCH" + log_cmd git merge --abort + # Add conflict label to the child PR + gh label create "$CONFLICT_LABEL" --description "PR needs manual conflict resolution" --color "d73a4a" 2>/dev/null || true + log_cmd gh pr edit "$CHILD_BRANCH" --add-label "$CONFLICT_LABEL" + { + echo "### ⚠️ Automatic update blocked by merge conflicts" + echo + echo "I tried to merge \`origin/$PR_BRANCH\` into this branch while continuing the PR stack update and hit conflicts." + echo + echo "#### How to resolve" + echo '```bash' + echo "git fetch origin" + echo "git switch $CHILD_BRANCH" + echo "git merge origin/$PR_BRANCH" + echo "# ..." + echo "# fix conflicts, for instance with \`git mergetool\`" + echo "# ..." + echo "git commit" + echo "git push" + echo '```' + } | log_cmd gh pr comment "$CHILD_BRANCH" -F - + continue + fi + ALL_CHILDREN+=("$CHILD_BRANCH") + update_branch_recursive "$CHILD_BRANCH" + done + + # Push all updated branches + if [[ "${#ALL_CHILDREN[@]}" -gt 0 ]]; then + log_cmd git push origin "${ALL_CHILDREN[@]}" + fi +} + main() { # Check required environment variables check_env_var "SQUASH_COMMIT" @@ -154,7 +231,18 @@ main() { log_cmd git push origin ":$MERGED_BRANCH" "${INITIAL_TARGETS[@]}" "${ALL_CHILDREN[@]}" } -# Only run main() if the script is executed directly (not sourced) +# Only run if the script is executed directly (not sourced) if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main + case "${ACTION_MODE:-squash-merge}" in + squash-merge) + main + ;; + conflict-resolved) + continue_after_resolution + ;; + *) + echo "Error: Unknown ACTION_MODE: $ACTION_MODE" >&2 + exit 1 + ;; + esac fi From 0f6cfe9e83b30ea96a799204bfaf2f99b33dec37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 08:49:21 +0000 Subject: [PATCH 2/2] 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. --- README.md | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 737b9a2..ad83229 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,21 @@ This action tries to fix that in a transparent way. Install it, and hopefully th 5. Updates each PR's base branch to point to trunk 6. Force-pushes updated branches and deletes the merged branch +### Conflict handling + +When a merge conflict occurs during the automatic update: + +1. The action posts a comment on the affected PR with instructions for manual resolution +2. Adds a `autorestack-needs-conflict-resolution` label to the PR +3. Updates the PR's base branch (but doesn't push the conflicted state) + +After you manually resolve the conflict and push: + +1. The push triggers the `synchronize` event +2. The action detects the conflict label and removes it +3. Posts a confirmation comment +4. Continues updating any dependent PRs in the stack + --- ### Setup @@ -33,7 +48,7 @@ name: Update Stacked PRs on Squash Merge on: pull_request: - types: [closed] + types: [closed, synchronize] permissions: contents: write @@ -42,7 +57,7 @@ permissions: jobs: update-pr-stack: - if: github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != '' + if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.merge_commit_sha != '' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -52,14 +67,30 @@ jobs: - name: Update PR stack uses: Phlogistique/autorestack-action@main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + continue-after-conflict-resolution: + if: github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'autorestack-needs-conflict-resolution') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Continue PR stack update + uses: Phlogistique/autorestack-action@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + mode: conflict-resolved + pr-branch: ${{ github.event.pull_request.head.ref }} ``` ### Notes * Currently only supports squash merges -* If a merge hits a conflict, you'll need to resolve it manually +* If a merge hits a conflict, you'll need to resolve it manually; pushing the resolution automatically continues the stack update * Very large stacks might hit GitHub rate limits ---