PR agent context refresh dispatcher #23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR agent context refresh dispatcher | |
| # Runs on a schedule and dispatches pr-agent-context-refresh for any open | |
| # same-repo PR that had recent review activity but no corresponding in-flight | |
| # or recently-succeeded refresh run. | |
| # | |
| # WHY THIS EXISTS | |
| # --------------- | |
| # When a bot (e.g. Copilot / copilot-pull-request-reviewer[bot]) submits a | |
| # review, the pull_request_review / pull_request_review_comment events fire | |
| # and trigger pr-agent-context-refresh — but the triggered run is immediately | |
| # blocked by GitHub's approval gate for bot/external actors: | |
| # | |
| # • conclusion=startup_failure → workflow could not start (counts as blocked) | |
| # • conclusion=action_required → was approval-gated, later auto-cancelled | |
| # | |
| # None of those outcomes produce a refresh comment. This dispatcher fires | |
| # from the default branch (where it is active), bypasses the approval gate | |
| # by using the schedule's GITHUB_TOKEN, and dispatches a workflow_dispatch | |
| # run that executes with full repo permissions. | |
| # | |
| # DEDUPE CONTRACT | |
| # --------------- | |
| # A dispatch is suppressed only when there is already meaningful coverage for | |
| # the PR's current head SHA: | |
| # • run is in_progress, queued, waiting, or requested → suppress | |
| # • run completed with conclusion=success or =neutral recently → suppress | |
| # | |
| # Blocked / non-executed conclusions (startup_failure, action_required, | |
| # failure, cancelled, timed_out, skipped) do NOT count as coverage and do | |
| # NOT suppress a fallback dispatch. | |
| on: | |
| schedule: | |
| # Every 15 minutes, all day every day. | |
| # Bot reviews can arrive at any hour; restrict to business hours only if | |
| # cost is a concern (e.g. '*/15 7-23 * * 1-5' for Mon-Fri 07-23 UTC). | |
| - cron: '*/15 * * * *' | |
| permissions: | |
| actions: write | |
| pull-requests: read | |
| jobs: | |
| dispatch: | |
| name: Dispatch stalled PR agent context refreshes | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Find and dispatch pending refreshes | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const REFRESH_WORKFLOW = 'pr-agent-context-refresh.yml'; | |
| // Only look at review activity in the last N minutes. | |
| const LOOKBACK_MINUTES = 20; | |
| // A successfully-completed run within this window suppresses redispatch. | |
| const RECENT_SUCCESS_WINDOW_MINUTES = 10; | |
| // Conclusions that mean the run was BLOCKED and never produced a | |
| // refresh comment. These must NOT suppress a fallback dispatch. | |
| const BLOCKED_CONCLUSIONS = new Set([ | |
| 'startup_failure', | |
| 'action_required', | |
| 'failure', | |
| 'cancelled', | |
| 'timed_out', | |
| 'skipped', | |
| ]); | |
| const now = Date.now(); | |
| const since = new Date(now - LOOKBACK_MINUTES * 60 * 1000).toISOString(); | |
| const recentSuccessSince = new Date( | |
| now - RECENT_SUCCESS_WINDOW_MINUTES * 60 * 1000 | |
| ).toISOString(); | |
| const defaultBranch = context.payload.repository.default_branch; | |
| // List ALL open PRs in this repository via pagination (same-repo only). | |
| const pulls = await github.paginate(github.rest.pulls.list, { | |
| ...context.repo, | |
| state: 'open', | |
| per_page: 100, | |
| }); | |
| for (const pr of pulls) { | |
| // Same-repo guard: skip forks. | |
| if (pr.head.repo.full_name !== context.payload.repository.full_name) { | |
| continue; | |
| } | |
| try { | |
| // --- Bounded recent activity check --- | |
| const [{ data: reviews }, { data: reviewComments }] = await Promise.all([ | |
| github.rest.pulls.listReviews({ | |
| ...context.repo, | |
| pull_number: pr.number, | |
| per_page: 10, | |
| }), | |
| github.rest.pulls.listReviewComments({ | |
| ...context.repo, | |
| pull_number: pr.number, | |
| per_page: 10, | |
| }), | |
| ]); | |
| const hasRecentActivity = | |
| reviews.some((r) => r.submitted_at >= since) || | |
| reviewComments.some( | |
| (c) => c.created_at >= since || c.updated_at >= since | |
| ); | |
| if (!hasRecentActivity) continue; | |
| // --- In-flight / recent-success dedupe --- | |
| // Fetch runs for this exact head SHA so stale runs from | |
| // earlier commits don't suppress dispatch for the new SHA. | |
| const { data: { workflow_runs: runs } } = | |
| await github.rest.actions.listWorkflowRunsForWorkflow({ | |
| ...context.repo, | |
| workflow_id: REFRESH_WORKFLOW, | |
| head_sha: pr.head.sha, | |
| per_page: 10, | |
| }); | |
| const hasValidCoverage = runs.some((r) => { | |
| // Actively working toward a refresh — don't interrupt. | |
| if ( | |
| r.status === 'in_progress' || | |
| r.status === 'queued' || | |
| r.status === 'waiting' || | |
| r.status === 'requested' | |
| ) { | |
| return true; | |
| } | |
| // Completed: only suppress if the run actually succeeded | |
| // recently. Blocked / failed conclusions are transparent. | |
| if (r.status === 'completed') { | |
| if (BLOCKED_CONCLUSIONS.has(r.conclusion)) return false; | |
| return ( | |
| (r.conclusion === 'success' || r.conclusion === 'neutral') && | |
| r.updated_at >= recentSuccessSince | |
| ); | |
| } | |
| return false; | |
| }); | |
| if (hasValidCoverage) { | |
| console.log( | |
| `PR #${pr.number}: valid refresh already running or recently succeeded — skipping.` | |
| ); | |
| continue; | |
| } | |
| // --- Dispatch --- | |
| await github.rest.actions.createWorkflowDispatch({ | |
| ...context.repo, | |
| workflow_id: REFRESH_WORKFLOW, | |
| ref: defaultBranch, | |
| inputs: { | |
| pull_request_number: String(pr.number), | |
| pull_request_head_sha: pr.head.sha, | |
| pull_request_base_sha: pr.base.sha, | |
| }, | |
| }); | |
| console.log( | |
| `Dispatched refresh for PR #${pr.number} (head: ${pr.head.sha}).` | |
| ); | |
| } catch (err) { | |
| // Per-PR error isolation: log and continue to the next PR. | |
| console.error(`PR #${pr.number}: dispatch failed — ${err.message}`); | |
| } | |
| } |