Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions .github/workflows/pr-bot-review.yml
Original file line number Diff line number Diff line change
@@ -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 '.[]')
Loading