Skip to content

Copilot Review Poller #1147

Copilot Review Poller

Copilot Review Poller #1147

name: Copilot Review Poller
# Polls open [Agent] PRs for Copilot reviews and dispatches Claude to handle them.
# This exists because GitHub blocks event-triggered workflows from bot actors
# (copilot[bot]) with "action_required" approval gates. Scheduled workflows run
# as a trusted actor, bypassing this limitation entirely.
#
# Also cleans up stale "action_required" workflow runs that accumulate from
# pull_request_review events fired by Copilot.
on:
schedule:
- cron: '*/10 * * * *' # Every 10 minutes
workflow_dispatch: # Manual trigger for testing/forcing a poll
permissions:
contents: read
pull-requests: read
actions: write # Needed to dispatch claude-code.yml and delete stale runs
jobs:
cleanup:
name: Clean up stale action_required runs
runs-on: ubuntu-latest
steps:
- name: Delete stale action_required workflow runs
env:
GH_TOKEN: ${{ github.token }}
run: |
stale_runs=$(gh api "repos/${{ github.repository }}/actions/runs?status=action_required&per_page=100" \
--jq '.workflow_runs[].id' 2>/dev/null || true)
[ -z "$stale_runs" ] && echo "No stale action_required runs." && exit 0
deleted=0
for run_id in $stale_runs; do
gh api "repos/${{ github.repository }}/actions/runs/$run_id" \
--method DELETE 2>/dev/null && deleted=$((deleted + 1)) || true
done
echo "Deleted $deleted stale action_required run(s)."
poll:
name: Poll Copilot reviews on Agent PRs
runs-on: ubuntu-latest
steps:
- name: Check Copilot reviews and dispatch Claude
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Checking open [Agent] PRs for Copilot reviews..."
prs=$(gh api "repos/${{ github.repository }}/pulls?state=open&per_page=100" \
--jq '.[] | select(.title | startswith("[Agent]")) | .number' 2>/dev/null || true)
[ -z "$prs" ] && echo "No open [Agent] PRs found." && exit 0
dispatched=0
for pr_number in $prs; do
echo "--- Checking PR #$pr_number ---"
# Get the latest review from any Copilot bot account
latest_copilot_review=$(gh api "repos/${{ github.repository }}/pulls/$pr_number/reviews" \
--jq '[.[] | select(
.user.login == "copilot-pull-request-reviewer[bot]" or
.user.login == "copilot-pull-request-reviewer" or
.user.login == "copilot[bot]"
)] | sort_by(.submitted_at) | last' 2>/dev/null || true)
if [ -z "$latest_copilot_review" ] || [ "$latest_copilot_review" = "null" ]; then
echo " No Copilot review found."
continue
fi
review_state=$(echo "$latest_copilot_review" | jq -r '.state')
review_id=$(echo "$latest_copilot_review" | jq -r '.id')
review_time=$(echo "$latest_copilot_review" | jq -r '.submitted_at')
echo " Latest Copilot review: state=$review_state id=$review_id time=$review_time"
# Skip if Claude already posted a marker for this review ID
already_processed=$(gh api "repos/${{ github.repository }}/issues/$pr_number/comments?per_page=100" \
--jq "[.[] | select(.body | contains(\"copilot-review-processed:$review_id\"))] | length" \
2>/dev/null || echo "0")
if [ "$already_processed" -gt 0 ]; then
echo " Already processed (marker comment found). Skipping."
continue
fi
# Skip if commits were pushed after the review (likely already addressed)
latest_commit_time=$(gh api "repos/${{ github.repository }}/pulls/$pr_number/commits?per_page=100" \
--jq '[.[].commit.committer.date] | sort | last' 2>/dev/null || true)
if [ -n "$latest_commit_time" ] && [ "$latest_commit_time" \> "$review_time" ]; then
echo " Commits exist after review ($latest_commit_time > $review_time). Skipping."
continue
fi
# Determine what action to take based on review state
action=""
case "$review_state" in
CHANGES_REQUESTED)
action="fix_copilot_review"
echo " -> Dispatching: fix_copilot_review"
;;
COMMENTED)
# Check the REVIEW-ATTACHED comments (not top-level PR comments)
# This is the correct endpoint for inline review suggestions
inline_count=$(gh api "repos/${{ github.repository }}/pulls/$pr_number/reviews/$review_id/comments" \
--jq 'length' 2>/dev/null || echo "0")
if [ "$inline_count" -gt 0 ]; then
action="fix_copilot_review"
echo " -> COMMENTED with $inline_count review comments. Dispatching: fix_copilot_review"
else
# Also check review body for "generated N comments"
generated=$(echo "$latest_copilot_review" | jq -r '.body' | grep -oP 'generated \K\d+' || echo "0")
if [ "$generated" -gt 0 ]; then
action="fix_copilot_review"
echo " -> Review body says $generated comments generated. Dispatching: fix_copilot_review"
else
echo " COMMENTED with no inline comments — clean review."
continue
fi
fi
;;
APPROVED)
action="copilot_approved"
echo " -> Dispatching: copilot_approved"
;;
*)
echo " Review state '$review_state' — no action needed."
continue
;;
esac
# Loop protection: after 4 Copilot fix cycles, escalate to human review
if [ "$action" = "fix_copilot_review" ]; then
cycle_count=$(gh api "repos/${{ github.repository }}/pulls/$pr_number/reviews" \
--jq '[.[] | select(
(.user.login == "copilot-pull-request-reviewer[bot]" or
.user.login == "copilot-pull-request-reviewer" or
.user.login == "copilot[bot]") and
(.state == "CHANGES_REQUESTED" or .state == "COMMENTED")
)] | length' 2>/dev/null || echo "0")
if [ "$cycle_count" -ge 4 ]; then
echo " Loop protection: $cycle_count Copilot review cycles. Escalating to human review."
action="copilot_approved"
fi
fi
# Dispatch the Claude Code Agent workflow
gh workflow run claude-code.yml \
-f pr_number="$pr_number" \
-f action="$action" \
--repo "${{ github.repository }}"
dispatched=$((dispatched + 1))
echo " Dispatched successfully (action=$action)."
done
echo "---"
echo "Done. Dispatched $dispatched workflow(s)."