diff --git a/.github/workflows/pr-bot-review.yml b/.github/workflows/pr-bot-review.yml new file mode 100644 index 0000000..c55977f --- /dev/null +++ b/.github/workflows/pr-bot-review.yml @@ -0,0 +1,189 @@ +name: PR Review (Scheduled) + +on: + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '*/5 * * * *' + workflow_dispatch: {} + # Slash command: a maintainer comments "/review" on a PR to trigger an + # immediate targeted review. issue_comment always runs in the base repo + # with a full-permission GITHUB_TOKEN, so fork PRs work too (unlike the + # pull_request path) and there is no dependency on the best-effort cron. + issue_comment: + types: [created] + +concurrency: + # /review commands get their own group per PR so they neither cancel + # nor get cancelled by the scheduled poller (or other PRs' commands). + group: pr-review-poller-${{ github.event_name == 'issue_comment' && github.event.issue.number || 'poll' }} + cancel-in-progress: true + +permissions: + statuses: write + issues: write + # write needed for the 👀 ack reaction on /review comments — reactions on + # PR comments are pull-request scope (403 with read-only) + pull-requests: write + +jobs: + poll-and-review: + runs-on: ubuntu-latest + timeout-minutes: 5 + # On pull_request events from forks, GITHUB_TOKEN is read-only, so writing + # the commit status 403s and the run fails. Skip the event-driven path for + # fork PRs — the scheduled cron (full permissions) still reviews them. + # schedule/workflow_dispatch always run; same-repo PRs run on the event. + # issue_comment runs only for "/review" comments on PRs from maintainers + # (OWNER/MEMBER/COLLABORATOR) — comment body is never interpolated into + # shell, only the numeric issue number is used. + if: >- + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name != 'issue_comment' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository)) + steps: + - name: Find PRs needing review + id: poll + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + + if [ "${{ github.event_name }}" = "issue_comment" ]; then + # Targeted path: "/review" comment on a PR from a maintainer + # (association gated at the job level). Ack with 👀 so the + # requester knows the command was picked up. + PR_NUMBER=${{ github.event.issue.number }} + FORCE=1 + gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \ + -f content=eyes >/dev/null || true + PRS=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" \ + --jq 'select( + .state == "open" and + .draft == false and + (any(.labels[]; .name == "review-limit-reached") | not) + ) | {number, sha: .head.sha, labels: [.labels[].name]}' | jq -s '.') + if [ "$(echo "$PRS" | jq 'length')" -eq 0 ]; then + echo "PR #${PR_NUMBER}: not eligible (closed, draft, or review-limit-reached)" + fi + else + FORCE=0 + # Collect eligible open PRs (non-draft, no review-limit-reached label) + # Include safe-to-review labeled PRs regardless of author_association + # Note: --paginate with array jq emits one array per page; stream objects then collect + PRS=$(gh api --paginate "repos/${{ github.repository }}/pulls?state=open&per_page=100" \ + --jq '.[] | select( + .draft == false and + (any(.labels[]; .name == "review-limit-reached") | not) and + ( + (.author_association | IN("OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR")) or + any(.labels[]; .name == "safe-to-review") + ) + ) | {number, sha: .head.sha, labels: [.labels[].name]}' | jq -s '.') + fi + + TARGETS="[]" + while IFS= read -r ROW; do + NUMBER=$(echo "$ROW" | jq -r '.number') + SHA=$(echo "$ROW" | jq -r '.sha') + LABELS=$(echo "$ROW" | jq -c '.labels') + + # Get latest status for this SHA (single API call) + STATUS_JSON=$(gh api "repos/${{ github.repository }}/commits/${SHA}/statuses" \ + --jq '[.[] | select(.context == "OpenAB PR Review")] | sort_by(.created_at) | last | {state, created_at} // empty') + STATE=$(echo "$STATUS_JSON" | jq -r '.state // empty') + + # Skip if still pending, UNLESS pending > 30 min (stale) + if [ "$STATE" = "pending" ]; then + PENDING_AT=$(echo "$STATUS_JSON" | jq -r '.created_at // empty') + if [ -n "$PENDING_AT" ]; then + PENDING_TS=$(date -d "$PENDING_AT" +%s 2>/dev/null || date -u +%s) + NOW_TS=$(date -u +%s) + ELAPSED=$(( NOW_TS - PENDING_TS )) + if [ "$ELAPSED" -lt 1800 ]; then + echo "PR #${NUMBER}: still pending (${ELAPSED}s), skipping" + continue + fi + echo "PR #${NUMBER}: pending stale (${ELAPSED}s > 30m), re-triggering" + fi + fi + + # Skip if already reviewed this exact SHA (success or failure) + # failure = CHANGES REQUESTED — only re-review when new commits are pushed (new SHA) + # An explicit /review command (FORCE=1) bypasses this: a maintainer + # asking again means re-review even if this SHA was already reviewed. + if [ "$FORCE" -eq 0 ] && { [ "$STATE" = "success" ] || [ "$STATE" = "failure" ]; }; then + echo "PR #${NUMBER}: already reviewed on HEAD (${STATE}), skipping" + continue + fi + + # Trigger review: no status yet, or error (webhook failure), or stale pending + echo "PR #${NUMBER}: needs review (state=${STATE:-none})" + TARGETS=$(echo "$TARGETS" | jq --argjson row "$ROW" '. + [$row]') + done < <(echo "$PRS" | jq -c '.[]') + + echo "targets=$(echo "$TARGETS" | jq -c '.')" >> "$GITHUB_OUTPUT" + echo "count=$(echo "$TARGETS" | jq 'length')" >> "$GITHUB_OUTPUT" + + - name: Trigger reviews + if: fromJSON(steps.poll.outputs.count) > 0 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WEBHOOK_URL: ${{ secrets.OAB_REVIEW_ACTION_WEBHOOK }} + BOT_UID: ${{ secrets.OAB_REVIEW_ACTION_BOT_UID }} + TARGETS: ${{ steps.poll.outputs.targets }} + run: | + set -eo pipefail + + while IFS= read -r ROW; do + NUMBER=$(echo "$ROW" | jq -r '.number') + SHA=$(echo "$ROW" | jq -r '.sha') + LABELS=$(echo "$ROW" | jq -c '.labels') + + # Circuit breaker check + COUNT=$(gh api --paginate "repos/${{ github.repository }}/pulls/${NUMBER}/commits" \ + --jq '.[].sha' | while read -r C_SHA; do + gh api "repos/${{ github.repository }}/commits/${C_SHA}/statuses" \ + --paginate --jq '[.[] | select(.context == "OpenAB PR Review" and .state == "pending")] | length' + done | awk '{s+=$1} END {print s+0}') + + if [ "${COUNT}" -ge 30 ]; then + gh api "repos/${{ github.repository }}/issues/${NUMBER}/labels" \ + --method POST -f "labels[]=review-limit-reached" + gh api "repos/${{ github.repository }}/statuses/${SHA}" \ + -f state="error" \ + -f context="OpenAB PR Review" \ + -f description="Circuit breaker: exceeded 30 review cycles" + echo "::error::PR #${NUMBER}: circuit breaker triggered" + continue + fi + + # Set pending status + gh api "repos/${{ github.repository }}/statuses/${SHA}" \ + -f state="pending" \ + -f context="OpenAB PR Review" \ + -f description="Review in progress..." \ + -f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # Trigger Discord webhook + PR_URL="https://github.com/${{ github.repository }}/pull/${NUMBER}" + MODE="" + if echo "$LABELS" | jq -e 'any(. == "auto-fix")' > /dev/null 2>&1; then + MODE="\n__mode: auto-fix__" + fi + PAYLOAD=$(printf '<@%s> lead group review %s\n\n__commit: %s__%s' "$BOT_UID" "$PR_URL" "$SHA" "$MODE" | jq -Rs '{content: .}') + if ! curl -sS --fail-with-body --max-time 10 -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" -d "$PAYLOAD"; then + gh api "repos/${{ github.repository }}/statuses/${SHA}" \ + -f state="error" \ + -f context="OpenAB PR Review" \ + -f description="Failed to trigger review — webhook error" + echo "::warning::PR #${NUMBER}: webhook failed" + fi + + echo "PR #${NUMBER}: review triggered" + done < <(echo "$TARGETS" | jq -c '.[]')