Skip to content

PR agent context refresh dispatcher #71

PR agent context refresh dispatcher

PR agent context refresh dispatcher #71

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}`);
}
}