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/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 --- 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