diff --git a/.github/scripts/check_ci_status.sh b/.github/scripts/check_ci_status.sh index e7dcdeb2c..66f1d8f45 100755 --- a/.github/scripts/check_ci_status.sh +++ b/.github/scripts/check_ci_status.sh @@ -9,7 +9,7 @@ PR_NUMBER="$1" REPO="$2" gh pr checks "$PR_NUMBER" --repo "$REPO" --json name,bucket --jq ' - [.[] | select(.name | test("^(validate-triggers|evaluate)$") | not)] | + [.[] | select(.name | test("^(validate-triggers|evaluate|ready-for-review-trigger)$") | not)] | if length == 0 then "no_checks" elif all(.bucket == "pass" or .bucket == "skipping") then "all_passed" elif any(.bucket == "fail") then "has_failures" diff --git a/.github/workflows/ready-for-review-trigger.yml b/.github/workflows/ready-for-review-trigger.yml new file mode 100644 index 000000000..3529c82ad --- /dev/null +++ b/.github/workflows/ready-for-review-trigger.yml @@ -0,0 +1,25 @@ +name: Ready for Review Trigger + +# Fork pull requests run pull_request and pull_request_review events with a +# read-only token and no secrets, so they cannot add or remove labels. This +# workflow does nothing but complete, which fires a workflow_run event that the +# privileged "Ready for Review Label" workflow reacts to with a write token. +on: + pull_request: + types: [ready_for_review, labeled, converted_to_draft] + pull_request_review: + types: [submitted, dismissed] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + relay: + name: ready-for-review-trigger + runs-on: ubuntu-latest + steps: + - name: Relay to applier + run: echo "Relaying pull request event to the ready-for-review applier via workflow_run." diff --git a/.github/workflows/ready-for-review.yml b/.github/workflows/ready-for-review.yml index cc02490e2..6f23183a9 100644 --- a/.github/workflows/ready-for-review.yml +++ b/.github/workflows/ready-for-review.yml @@ -1,27 +1,33 @@ name: Ready for Review Label +# Runs only on workflow_run so it holds a write-capable token even for pull +# requests opened from forks. Events tied to the fork head (pull_request, +# pull_request_review) get a read-only token and no secrets, so they cannot +# mutate labels themselves. The companion "Ready for Review Trigger" workflow +# relays those events here by completing and firing this workflow_run. on: - pull_request: - types: [ready_for_review, labeled] - pull_request_review: - types: [submitted, dismissed] workflow_run: - workflows: ["Continuous integration", "Pre-commit Checks", "Sanitizer"] + workflows: + - "Continuous integration" + - "Pre-commit Checks" + - "Sanitizer" + - "Ready for Review Trigger" types: [completed] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }} cancel-in-progress: true permissions: + contents: read pull-requests: write checks: read + issues: write jobs: # Ensure all pull_request-triggered workflows are monitored by workflow_run validate-triggers: runs-on: ubuntu-latest - if: github.event_name == 'workflow_run' steps: - uses: actions/checkout@v4 with: @@ -34,10 +40,7 @@ jobs: SELF=".github/workflows/ready-for-review.yml" # Extract workflow names from the workflow_run trigger list in this file - MONITORED=$(grep -A1 'workflows:' "$SELF" \ - | grep '\[' \ - | tr ',' '\n' \ - | sed 's/.*"\([^"]*\)".*/\1/') + MONITORED=$(grep -E '^\s+- "' "$SELF" | sed 's/.*"\([^"]*\)".*/\1/') MISSING="" for f in .github/workflows/*.yml; do @@ -73,14 +76,6 @@ jobs: evaluate: runs-on: ubuntu-latest - permissions: - pull-requests: write - checks: read - issues: write - # Skip non-CodeRabbit reviews - if: >- - github.event_name != 'pull_request_review' || - github.event.review.user.login == 'coderabbitai[bot]' steps: - uses: actions/checkout@v4 with: @@ -90,54 +85,42 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} - EVENT_NAME: ${{ github.event_name }} - EVENT_ACTION: ${{ github.event.action }} - REVIEW_STATE: ${{ github.event.review.state }} - PR_NUMBER_DIRECT: ${{ github.event.pull_request.number }} - LABEL_NAME: ${{ github.event.label.name }} - PULL_REQUESTS_JSON: ${{ toJson(github.event.workflow_run.pull_requests) }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} run: | - # On review dismissal or changes requested, remove label and exit - if [ "$EVENT_NAME" = "pull_request_review" ]; then - if [ "$EVENT_ACTION" = "dismissed" ] || [ "$REVIEW_STATE" = "changes_requested" ]; then - echo "CodeRabbit review dismissed or changes requested, removing label." - gh pr edit "$PR_NUMBER_DIRECT" --repo "$REPO" --remove-label "ready-for-review" || true - exit 0 - fi - fi - - # On merge-conflict label, remove ready-for-review and exit - if [ "$EVENT_NAME" = "pull_request" ] && [ "$EVENT_ACTION" = "labeled" ]; then - if [ "$LABEL_NAME" = "merge-conflict" ]; then - echo "Merge conflict detected, removing ready-for-review label." - gh pr edit "$PR_NUMBER_DIRECT" --repo "$REPO" --remove-label "ready-for-review" || true - fi - exit 0 - fi - - # Collect PR numbers depending on the event type - if [ "$EVENT_NAME" = "workflow_run" ]; then - PR_NUMBERS=$(echo "$PULL_REQUESTS_JSON" | jq -r '.[].number') - else - PR_NUMBERS="$PR_NUMBER_DIRECT" - fi + # Resolve the PR(s) for the head commit. github.event.workflow_run's + # pull_requests array is empty for fork PRs, and the commit-to-PR + # endpoint does not index fork heads, so match the head sha against + # the list of open PRs, which works regardless of where the branch + # lives. Matching the exact sha also ignores stale runs whose commit + # the PR has already moved past. + PR_NUMBERS=$(gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" \ + --jq ".[] | select(.head.sha == \"$HEAD_SHA\") | .number") if [ -z "$PR_NUMBERS" ]; then - echo "No associated pull requests found, skipping." + echo "No open pull request found for $HEAD_SHA, skipping." exit 0 fi for PR_NUMBER in $PR_NUMBERS; do echo "=== Checking PR #$PR_NUMBER ===" - # Skip draft PRs - IS_DRAFT=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json isDraft --jq '.isDraft') - if [ "$IS_DRAFT" = "true" ]; then - echo "PR is a draft. Skipping." + PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json isDraft,labels) + + if [ "$(echo "$PR_JSON" | jq -r '.isDraft')" = "true" ]; then + echo "PR is a draft. Removing ready-for-review label if present." + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "ready-for-review" || true continue fi - # Check if CodeRabbit has approved + # A merge conflict disqualifies the PR regardless of the other gates. + if [ "$(echo "$PR_JSON" | jq -r '[.labels[].name] | index("merge-conflict") != null')" = "true" ]; then + echo "Merge conflict present. Removing ready-for-review label if present." + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "ready-for-review" || true + continue + fi + + # Check if CodeRabbit has approved. A dismissed or changes-requested + # review leaves the latest state != APPROVED and removes the label. CODERABBIT_STATE=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews" | jq -rs ' add | map(select(.user.login == "coderabbitai[bot]")) @@ -148,7 +131,8 @@ jobs: echo "CodeRabbit review state: $CODERABBIT_STATE" if [ "$CODERABBIT_STATE" != "APPROVED" ]; then - echo "CodeRabbit has not approved. Skipping." + echo "CodeRabbit has not approved. Removing label if present." + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "ready-for-review" || true continue fi