diff --git a/.github/workflows/pr-auto-review-reusable.yml b/.github/workflows/pr-auto-review-reusable.yml new file mode 100644 index 0000000..e1ffbd2 --- /dev/null +++ b/.github/workflows/pr-auto-review-reusable.yml @@ -0,0 +1,213 @@ +# Reusable PR Auto-Review — Ready Check workflow. +# Single source of truth for the org: all readiness-gate logic lives here. +# Repo-level pr-auto-review.yml files are thin caller stubs. +# Standard: https://github.com/petry-projects/.github/blob/main/standards/ci-standards.md +# +# Fires a review-agent dispatch when a PR meets ALL of the following criteria: +# 1. PR is open and not a draft +# 2. All CI checks are completed and passing (no pending / failing) +# 3. Effective review decision is not CHANGES_REQUESTED +# 4. No unresolved review threads +# +# Triggered by (events forwarded from the thin caller): +# workflow_run:completed — a named CI workflow finished green +# check_suite:completed — a third-party check suite finished green +# pull_request:opened/reopened/synchronize/ready_for_review +# pull_request_review:submitted/dismissed +# +# Requires: +# GH_PAT_WORKFLOWS — org secret, classic PAT (repo scope) for API calls and dispatch +name: PR Auto-Review — Ready Check (Reusable) + +on: + workflow_call: + secrets: + GH_PAT_WORKFLOWS: + description: "Classic PAT with repo scope used for API calls and dispatching the review agent" + required: true + +jobs: + check-and-dispatch: + runs-on: ubuntu-latest + permissions: + pull-requests: read + checks: read + actions: read + + steps: + - name: Resolve PR URL + id: pr + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + run: | + set -euo pipefail + case "${{ github.event_name }}" in + workflow_run) + # workflow_run fires when a named CI workflow completes (Actions-aware). + CONCLUSION="${{ github.event.workflow_run.conclusion }}" + if [ "$CONCLUSION" != "success" ]; then + echo "Workflow run conclusion is '$CONCLUSION' — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + HEAD_SHA="${{ github.event.workflow_run.head_sha }}" + PR_URL=$(gh api "/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \ + --jq '[.[] | select(.state == "open" and .draft == false)][0].html_url // empty' \ + 2>/dev/null || true) + ;; + check_suite) + # check_suite covers third-party CI (e.g. SonarCloud). + # NOTE: this event does NOT fire for GitHub Actions runs; use + # workflow_run in the caller for Actions-based CI. + CONCLUSION="${{ github.event.check_suite.conclusion }}" + if [ "$CONCLUSION" != "success" ]; then + echo "Check suite conclusion is '$CONCLUSION' — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + HEAD_SHA="${{ github.event.check_suite.head_sha }}" + PR_URL=$(gh api "/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \ + --jq '[.[] | select(.state == "open" and .draft == false)][0].html_url // empty' \ + 2>/dev/null || true) + ;; + pull_request) + # Skip draft PRs early to avoid unnecessary API calls. + if [ "${{ github.event.pull_request.draft }}" = "true" ]; then + echo "PR is a draft — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + PR_URL="${{ github.event.pull_request.html_url }}" + ;; + pull_request_review) + PR_URL="${{ github.event.pull_request.html_url }}" + ;; + *) + echo "Unhandled event '${{ github.event_name }}' — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + + if [ -z "${PR_URL:-}" ]; then + echo "No open non-draft PR found for this event — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "PR URL: $PR_URL" + + - name: Check PR readiness criteria + id: criteria + if: steps.pr.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + PR_URL: ${{ steps.pr.outputs.pr_url }} + run: | + set -euo pipefail + + # Derive the base repository (owner/repo) from the PR URL. + # gh pr view --json does not expose baseRepository; parsing the URL + # is simpler and works for both same-repo and fork PRs. + REPO=$(echo "$PR_URL" | sed 's|https://github.com/||; s|/pull/.*||') + + # Fetch PR metadata in one call, including the effective review decision. + PR_META=$(gh pr view "$PR_URL" \ + --json state,isDraft,number,reviewDecision) + STATE=$(echo "$PR_META" | jq -r '.state') + IS_DRAFT=$(echo "$PR_META" | jq -r '.isDraft') + PR_NUMBER=$(echo "$PR_META" | jq -r '.number') + REVIEW_DECISION=$(echo "$PR_META" | jq -r '.reviewDecision // ""') + + # 1. PR must be open and not a draft. + if [ "$STATE" != "OPEN" ] || [ "$IS_DRAFT" = "true" ]; then + echo "PR is $STATE (draft=$IS_DRAFT) — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "PR is open and not a draft ✓" + + # 2. All CI checks must be completed and passing. + # gh pr checks --json may exit non-zero when checks are + # failing/pending but still writes the JSON payload to stdout; + # use || true so set -e doesn't discard that output. + CHECKS=$(gh pr checks "$PR_URL" --json bucket,name 2>/dev/null || true) + if [ -z "${CHECKS}" ]; then + CHECKS="[]" + fi + TOTAL=$(echo "$CHECKS" | jq 'length') + if [ "$TOTAL" -eq 0 ]; then + echo "No CI checks found on this PR — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Get the name of this workflow's own check run so it can be excluded + # from the gate — an in-progress run shows as "pending" and would + # otherwise block itself on every trigger. + SELF_CHECK=$(gh api \ + "/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \ + --jq '.jobs[0].name // empty' 2>/dev/null || echo "") + + # Use double-quoted jq expression with \$self so the shell produces + # a literal "$self" for jq without triggering SC2016 (which flags + # shell variables in single-quoted strings). $self is a jq variable. + NOT_PASSING=$(echo "$CHECKS" | jq \ + --arg self "$SELF_CHECK" \ + "map(select(.name != \$self)) | map(select(.bucket != \"pass\" and .bucket != \"skipping\")) | length") + + if [ "$NOT_PASSING" -gt 0 ]; then + echo "$NOT_PASSING of $TOTAL check(s) are not yet passing — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "All $TOTAL CI check(s) passing ✓" + + # 3. Effective review decision must not be CHANGES_REQUESTED. + # reviewDecision reflects the aggregate current state (accounts for + # dismissals and superseding reviews), unlike the REST reviews list + # which returns full history and can produce false positives. + if [ "$REVIEW_DECISION" = "CHANGES_REQUESTED" ]; then + echo "Effective review decision is CHANGES_REQUESTED — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "No CHANGES_REQUESTED review decision ✓" + + # 4. No unresolved review threads. + # REST API has no resolved field on review comments; GraphQL is + # required. \$owner/\$repo/\$number are GraphQL variable references; + # the backslash-dollar escaping prevents shell expansion while + # keeping the literal $ that GraphQL expects. + # TODO: paginate reviewThreads for PRs with >100 threads. + UNRESOLVED=$(gh api graphql \ + -f "query=query(\$owner:String!,\$repo:String!,\$number:Int!){repository(owner:\$owner,name:\$repo){pullRequest(number:\$number){reviewThreads(first:100){nodes{isResolved}}}}}" \ + -f owner="${REPO%%/*}" \ + -f repo="${REPO##*/}" \ + -F number="${PR_NUMBER}" \ + --jq "[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length") + if [ "$UNRESOLVED" -gt 0 ]; then + echo "$UNRESOLVED unresolved review thread(s) — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "No unresolved review threads ✓" + + echo "All readiness criteria met — dispatching review agent" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Dispatch review agent + if: steps.criteria.outputs.ready == 'true' + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + PR_URL: ${{ steps.pr.outputs.pr_url }} + run: | + gh api \ + --method POST \ + --header "Accept: application/vnd.github+json" \ + /repos/petry-projects/.github-private/dispatches \ + --field event_type=pr-review-mention \ + --field "client_payload[pr_url]=$PR_URL" + echo "::notice::Auto-review dispatched for $PR_URL" diff --git a/.github/workflows/pr-auto-review.yml b/.github/workflows/pr-auto-review.yml new file mode 100644 index 0000000..f3e9781 --- /dev/null +++ b/.github/workflows/pr-auto-review.yml @@ -0,0 +1,52 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: petry-projects/.github/standards/workflows/pr-auto-review.yml +# Standard: petry-projects/.github/standards/ci-standards.md +# Reusable: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml +# +# AGENTS — READ BEFORE EDITING: +# • This file is a THIN CALLER STUB. All readiness-gate logic lives in the +# reusable workflow above. +# • You MAY change: nothing in normal use. NOTE: this file intentionally uses +# a LOCAL ref (`./`) instead of a pinned SHA — this repo IS the source of +# truth, so a local ref is always current. Other repos use @v2 +# (see standards/workflows/pr-auto-review.yml). +# • You MUST NOT change: trigger events or the job-level `permissions:` block — +# reusable workflows can be granted no more permissions than the calling job, +# so removing the stanza breaks the reusable's gh API calls. +# • If you need different behaviour, open a PR against the reusable in the +# central repo. +# ───────────────────────────────────────────────────────────────────────────── +# +# PR Auto-Review — thin caller for the org-level reusable. +# To adopt: copy standards/workflows/pr-auto-review.yml to your repo. +# Requires: GH_PAT_WORKFLOWS org secret (already present in petry-projects org). +name: PR Auto-Review — Ready Check + +on: + # workflow_run fires when a named GitHub Actions workflow completes. + # check_suite does NOT trigger for GitHub Actions runs, so this is required + # to catch CI turning green on a PR. + workflow_run: + workflows: ["CI"] + types: [completed] + # check_suite covers third-party CI checks (e.g. SonarCloud, external apps). + check_suite: + types: [completed] + # Re-evaluate readiness after review state changes. + pull_request_review: + types: [submitted, dismissed] + # Re-evaluate when the PR is first opened, updated, or comes out of draft. + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: {} + +jobs: + pr-auto-review: + permissions: + pull-requests: read + checks: read + actions: read + uses: ./.github/workflows/pr-auto-review-reusable.yml # local ref — always current + secrets: + GH_PAT_WORKFLOWS: ${{ secrets.GH_PAT_WORKFLOWS }} diff --git a/standards/workflows/pr-auto-review.yml b/standards/workflows/pr-auto-review.yml new file mode 100644 index 0000000..7409476 --- /dev/null +++ b/standards/workflows/pr-auto-review.yml @@ -0,0 +1,55 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: petry-projects/.github/standards/workflows/pr-auto-review.yml +# Standard: petry-projects/.github/standards/ci-standards.md +# Reusable: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml +# +# AGENTS — READ BEFORE EDITING: +# • This file is a THIN CALLER STUB. All readiness-gate logic lives in the +# reusable workflow above. +# • You MAY change: the tag in the `uses:` line when upgrading the reusable +# workflow version (e.g. bump `@v2` → `@v3` when petry-projects/.github cuts +# a new release), and the workflow name(s) in `workflow_run.workflows` to +# match your repository's CI workflow name(s). +# • You MUST NOT change: trigger event types or the job-level `permissions:` +# block — reusable workflows can be granted no more permissions than the +# calling job, so removing the stanza breaks the reusable's gh API calls. +# • If you need different behaviour, open a PR against the reusable in the +# central repo. +# • When publishing a new version of this reusable, also update this template +# and open a fanout PR across all caller repos. +# ───────────────────────────────────────────────────────────────────────────── +# +# PR Auto-Review — thin caller for the org-level reusable. +# To adopt: copy this file to .github/workflows/pr-auto-review.yml in your repo. +# Requires: GH_PAT_WORKFLOWS org secret (already present in petry-projects org). +name: PR Auto-Review — Ready Check + +on: + # workflow_run fires when a named GitHub Actions workflow completes. + # check_suite does NOT trigger for GitHub Actions runs, so this is required + # to catch CI turning green on a PR. + # TODO: replace "CI" with your repository's CI workflow name(s). + workflow_run: + workflows: ["CI"] + types: [completed] + # check_suite covers third-party CI checks (e.g. SonarCloud, external apps). + check_suite: + types: [completed] + # Re-evaluate readiness after review state changes. + pull_request_review: + types: [submitted, dismissed] + # Re-evaluate when the PR is first opened, updated, or comes out of draft. + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: {} + +jobs: + pr-auto-review: + permissions: + pull-requests: read + checks: read + actions: read + uses: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml@v2 + secrets: + GH_PAT_WORKFLOWS: ${{ secrets.GH_PAT_WORKFLOWS }}