Files
Gordon Lam d47a6c93b7 Add copilot-pr-autopilot skill (#1944)
* Add copilot-pr-autopilot skill

Skill that drives any GitHub pull request through repeated rounds of
Copilot Code Review until the agent has either resolved every thread
or explicitly escalated it to the human. Triggered via GraphQL (no
@copilot mention needed), triages every open thread with a fix /
decline / escalate rubric, replies and resolves each thread citing
the pushed SHA, then re-triggers until HEAD is reviewed with zero
threads awaiting the agent's reply.

Includes step scripts (01 request-review, 02 check-review-status,
03 list-open-threads, 08 reply-and-resolve, 10 cleanup-outdated),
shared library (_lib.ps1) with gh-CLI wrappers (Invoke-Gh,
Invoke-GhGraphQL, ConvertFrom-GhJson, Assert-GhReady), reply
templates, and reference docs for each step.

Repo-agnostic. Requires gh CLI on PATH and repo Triage/Write for
full autopilot; external PR authors get single-iteration mode with
manual re-trigger via the UI re-request button or a substantive
push.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: split per-step references + add recap-gate circuit breaker

- Fix #1: give steps 1/7/10 their own reference files
  (01-request-review.md, 07-commit-push.md, 10-cleanup.md); trim the
  inline bodies out of orchestration.md so it stays cross-cutting only.
- Fix #3: add a recurring round-cap & recap gate to 09-convergence.md —
  default STOP every 10th round, recap all prior rounds, detect drift
  (out-of-scope / over-engineering / wrong-direction / belongs-in-separate-PR)
  with CONTINUE / REVERT-AND-SHIP / HAND-OFF verdicts. Agent reasoning,
  no new script.
- Surface the gate from SKILL.md and orchestration.md; regenerate
  docs/README.skills.md. Markdown-only change; scripts unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(copilot-pr-autopilot): surface recap gate in decision pseudo-code; clarify Copilot+human convergence and round definition

- Inject the round-cap recap gate into the '## Decision: loop back or exit'
  pseudo-code else-branch so an agent following the code block (not just the
  prose) runs the STOP-every-10th-round check before looping.
- Broaden the 'never terminal' paragraph: non-convergence is driven by a
  Copilot finding OR a human review comment (this skill handles both); the
  loop ends only when there are no new comments from either source AND every
  open thread (Copilot or human) has an agent reply/escalation.
- Define a 'round' explicitly as one execution of step 1 (01-request-review),
  i.e. one Copilot-review trigger — the cap counts review rounds, not tool
  calls or fix edits.

Markdown-only; no script changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* copilot-pr-autopilot: make recap-gate round count deterministic

Add 09-review-round.ps1: counts Copilot Code Review submissions straight
from the PR's API history (full GraphQL pagination), so the recap-gate
trigger is a derived number, not a fallible agent mental tally. This
removes the exact failure mode the skill exists to survive — a count
drifting across a long run (the real 156-round case).

The script reports Round + RecapDue (Round % RecapInterval == 0) only;
it never stops the loop or picks the verdict. CONTINUE / REVERT-AND-SHIP
/ HAND-OFF stays agent reasoning. 09-convergence.md updated to reference
the deterministic count while preserving 'no script stops the loop' and
'non-convergence = Copilot finding OR human comment'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Regenerate docs/README.skills.md for copilot-pr-autopilot (add 09-review-round.ps1)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 11:09:58 +10:00

363 lines
17 KiB
PowerShell

<#
.SYNOPSIS
Snapshot the current Copilot review state of a PR. Single-shot, no waiting.
.DESCRIPTION
ONE job: return a JSON snapshot of the PR's current Copilot
review state. The agent (caller) decides what to do with it —
including how long to wait between snapshots when polling for a
new review to land. THIS SCRIPT DOES NOT WAIT.
Output JSON fields:
- PrNumber, Owner, Repo
- HeadOid : current PR HEAD SHA
- State : PR state (OPEN/CLOSED/MERGED)
- LatestCopilotReview: {state, submittedAt, commitOid, bodyHead}
or null if no Copilot review is present
in the most recent 100 reviews (very long
PRs may have an older Copilot review outside
this window — treat null as "no recent
review", not "never reviewed")
- ReviewAtHead : true iff latest Copilot review's commit.oid == HeadOid
- NoNewComments : true iff the latest review body matches
"generated no new comments" / "generated 0 comments"
- OpenThreadCount : number of unresolved review threads (from all
reviewers); informational — convergence does
NOT require this to be zero
- OpenThreadsAwaitingReply: number of open threads where the
LAST comment is NOT from the authenticated
user (`gh api user`). "Ball-in-court"
model: a Copilot/human comment with no
reply from us OR a re-raise after our
earlier reply both count as awaiting. A
thread where WE are the latest commenter
counts as "done from our side" (the human
merge owner decides next).
- CopilotPending : true iff the Copilot reviewer bot is currently
listed in `requested_reviewers` on the PR (a
review is in flight; the caller should wait
rather than re-trigger)
- Converged : true iff the agent has done its job.
- When a Copilot review is at HEAD:
ReviewAtHead && NoNewComments &&
OpenThreadsAwaitingReply == 0.
- When no Copilot review has been observed
on this PR (LatestCopilotReview is null
AND CopilotPending is false): just
OpenThreadsAwaitingReply == 0. Note this
ALSO fires for a brand-new PR with zero
threads — meaning "nothing to do yet";
the agent should still trigger a Copilot
review via 01-request-review.ps1 if
Copilot is enabled on the repo. Single-
iteration mode (skip the trigger) is the
agent's decision after 01 fails with a
specific Copilot-disabled error, NOT an
auto-detected state from this script.
Open threads may remain in either case —
those are explicit hand-offs to the human
merge owner.
Canonical agent loop (see ../references/orchestration.md and the per-step files):
1. Call this script → capture LatestCopilotReview.submittedAt as
baseline AND read CopilotPending.
2. If CopilotPending is true, skip the trigger step — Copilot is
already reviewing. Otherwise, call 01-request-review.ps1.
If 01 throws with a Copilot-disabled error (e.g. the bot
isn't a valid reviewer on this repo), the agent may fall
back to single-iteration mode: skip the wait, jump to
03-list-open-threads.ps1, triage + reply to whatever exists,
done.
3. Wait sub-agent polls this script until either submittedAt
advances past baseline AND ReviewAtHead is true, OR Converged.
4. On convergence end the loop; otherwise fetch threads via
03-list-open-threads.ps1, triage, fix, push, reply, repeat.
Parsing the JSON: timestamps are emitted as plain ISO-8601 UTC
strings (e.g. `"2026-06-08T02:02:44Z"`). Extract via regex on the
raw JSON to avoid PowerShell's auto re-binding of ISO strings to
`[datetime]` (which renders local culture on string interpolation
and silently breaks lexicographic baseline compares):
$snap = pwsh -NoProfile -File 02-check-review-status.ps1 -PrNumber <n>
$baseline = if ($snap -match '"submittedAt":"([^"]+)"') { $Matches[1] } else { '' }
$copilotPending = ($snap -match '"CopilotPending":true')
$converged = ($snap -match '"Converged":true')
Works on any PowerShell version (5.1 + 7.x). No `[datetime]`
rebinding, no version-specific parameters.
.PARAMETER PrNumber
The pull request number. The only required parameter.
.PARAMETER Owner
Repository owner. OPTIONAL — auto-resolved from `gh repo view`.
.PARAMETER Repo
Repository name. OPTIONAL — auto-resolved from `gh repo view`.
.EXAMPLE
pwsh 02-check-review-status.ps1 -PrNumber 236
# Output (converged):
# {"HeadOid":"abc...","State":"OPEN","LatestCopilotReview":{...},"ReviewAtHead":true,"NoNewComments":true,"OpenThreadCount":0}
# Output (not converged — new findings):
# {"HeadOid":"abc...","ReviewAtHead":true,"NoNewComments":false,"OpenThreadCount":3,...}
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[int]$PrNumber,
[string]$Owner,
[string]$Repo,
# When set, the agent has decided to drive this PR as a single
# iteration (typically because 01-request-review.ps1 failed with a
# Copilot-disabled error). In this mode, convergence ignores the
# stale-review checks (ReviewAtHead / NoNewComments) — those can
# never become true when the trigger is intentionally skipped —
# and depends solely on OpenThreadsAwaitingReply == 0.
[switch]$SingleIteration
)
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot/_lib.ps1"
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
$Owner = $coords.Owner
$Repo = $coords.Repo
# Identity of the currently-authenticated gh user. Used below to
# detect "the agent has already replied to this thread" and therefore
# count it as our work-completed (the thread may still be open
# deliberately as a human hand-off).
$meR = Invoke-Gh -GhArgs @('api','user','--jq','.login')
if ($meR.ExitCode -ne 0) {
throw "gh api user failed (exit $($meR.ExitCode)): $($meR.Stderr)"
}
$me = $meR.Stdout.Trim()
# Query A (once): PR head/state/reviews. Reviews are not paginated
# here — `reviews(last:100)` is the most recent 100 reviews, sufficient
# for finding the latest Copilot review.
$qHead = @'
query($o:String!,$r:String!,$n:Int!){
repository(owner:$o,name:$r){
pullRequest(number:$n){
headRefOid
state
reviews(last:100){nodes{author{login} state submittedAt body commit{oid}}}
}
}
}
'@
$d = Invoke-GhGraphQL -GhArgs @('-f',"query=$qHead",'-f',"o=$Owner",'-f',"r=$Repo",'-F',"n=$PrNumber") -Context "head query for $Owner/$Repo PR #$PrNumber"
$pr = $d.data.repository.pullRequest
if (-not $pr) { throw "PR #$PrNumber not found in $Owner/$Repo." }
# Query B (paginated): reviewThreads — fetch isResolved AND the last
# comment's author per thread so we can compute
# "is this open thread awaiting our reply, or have we already handed
# it off?" The loop converges when WE have nothing more to do, not
# when the open-thread count drops to zero (some threads stay open
# deliberately as human hand-offs / escalated declines).
$qThreads = @'
query($o:String!,$r:String!,$n:Int!,$after:String){
repository(owner:$o,name:$r){
pullRequest(number:$n){
reviewThreads(first:100, after:$after){
pageInfo{endCursor hasNextPage}
nodes{
isResolved
comments(last:1){nodes{author{login}}}
}
}
}
}
}
'@
$after = $null
$allThreadsList = [System.Collections.Generic.List[object]]::new()
do {
$ghArgs = @('-f', "query=$qThreads", '-f', "o=$Owner", '-f', "r=$Repo", '-F', "n=$PrNumber")
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
$threadResp = Invoke-GhGraphQL -GhArgs $ghArgs -Context "threads query for $Owner/$Repo PR #$PrNumber"
$pagePr = $threadResp.data.repository.pullRequest
if (-not $pagePr) { throw "PR #$PrNumber not found in $Owner/$Repo (threads page)." }
foreach ($n in $pagePr.reviewThreads.nodes) { $allThreadsList.Add($n) }
$after = $pagePr.reviewThreads.pageInfo.endCursor
} while ($pagePr.reviewThreads.pageInfo.hasNextPage)
$allThreads = $allThreadsList.ToArray()
# Query C (paginated): reviewRequests — typical PRs have <100 requested
# reviewers, but pagination is required for correctness so we never
# falsely report CopilotPending=false on a PR with >100 requested
# reviewers (which would cause the wait sub-agent to re-trigger a
# review that's actually already in flight).
$qReviewRequests = @'
query($o:String!,$r:String!,$n:Int!,$after:String){
repository(owner:$o,name:$r){
pullRequest(number:$n){
reviewRequests(first:100, after:$after){
pageInfo{endCursor hasNextPage}
nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}}
}
}
}
}
'@
$after = $null
$allReviewRequestsList = [System.Collections.Generic.List[object]]::new()
do {
$ghArgs = @('-f', "query=$qReviewRequests", '-f', "o=$Owner", '-f', "r=$Repo", '-F', "n=$PrNumber")
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
$rrResp = Invoke-GhGraphQL -GhArgs $ghArgs -Context "reviewRequests query for $Owner/$Repo PR #$PrNumber"
$rrPagePr = $rrResp.data.repository.pullRequest
if (-not $rrPagePr) { throw "PR #$PrNumber not found in $Owner/$Repo (reviewRequests page)." }
foreach ($n in $rrPagePr.reviewRequests.nodes) { $allReviewRequestsList.Add($n) }
$after = $rrPagePr.reviewRequests.pageInfo.endCursor
} while ($rrPagePr.reviewRequests.pageInfo.hasNextPage)
$allReviewRequests = $allReviewRequestsList.ToArray()
# M1 tie-break: when multiple Copilot reviews share the same
# submittedAt (server-side clock collision is rare but possible
# under burst re-triggers), pick the one whose commit.oid matches
# HEAD if any; otherwise the original sort order is deterministic
# enough (PowerShell Sort-Object is stable since 5.1).
# M3 pagination: reviews(last:100) returns the MOST RECENT 100
# reviews. If a PR has 100+ reviews more recent than the last
# Copilot review (essentially impossible in normal use, but
# theoretically possible on heavily-bot-reviewed PRs), the latest
# Copilot review would be cut off. Emit a soft warning to stderr
# when we hit the boundary so the caller knows to inspect manually.
if ($pr.reviews.nodes.Count -ge 100) {
[Console]::Error.WriteLine("WARNING: reviews(last:100) boundary hit on PR #$PrNumber — if there are 100+ non-Copilot reviews more recent than the latest Copilot review, LatestCopilotReview may be stale. Inspect via 'gh pr view $PrNumber --comments' if convergence behaves unexpectedly.")
}
$copilotReviews = @($pr.reviews.nodes | Where-Object {
$_.author -and $_.author.login -and $_.author.login -match $CopilotReviewerLoginRegex
})
$latest = if ($copilotReviews.Count -gt 0) {
$atHead = $copilotReviews | Where-Object { $_.commit -and $_.commit.oid -eq $pr.headRefOid } | Sort-Object submittedAt -Descending | Select-Object -First 1
if ($atHead) { $atHead } else { $copilotReviews | Sort-Object submittedAt -Descending | Select-Object -First 1 }
} else { $null }
$reviewAtHead = $false
$noNewComments = $false
$bodyHead = $null
$latestCommitOid = $null
if ($latest) {
if ($latest.commit -and $latest.commit.oid) {
$latestCommitOid = $latest.commit.oid
$reviewAtHead = ($latestCommitOid -eq $pr.headRefOid)
}
$bodyText = if ($latest.body) { $latest.body } else { '' }
# NoNewComments covers both successful zero-finding reviews AND the
# "Copilot wasn't able to review any files in this pull request"
# body that Copilot returns for empty / pure-whitespace / line-ending-
# only diffs. Both are terminal for the loop: there is nothing for
# the agent to address and re-triggering will produce the same body.
# Matches Copilot's "no findings" terminal phrases. Anchored on
# \b (word boundary, blocks "regenerated") and on a following
# sentence-end (./!/EOL/EOS) so the regex does NOT false-positive
# on substrings like "generated no comments yet but..." or
# "with 0 comments outstanding". Tested against the 4 known
# negative inputs and 6 known positive Copilot body templates.
$noNewComments = ($bodyText -match '(?im)\b(?:generated|had|with)\s+(?:no|0|zero)\s+(?:new\s+)?comments\s*(?:[\.\!]|$)|wasn''t\s+able\s+to\s+review\s+any\s+files\s+in\s+this\s+pull\s+request|was\s+not\s+able\s+to\s+review\s+any\s+files\s+in\s+this\s+pull\s+request')
$bodyHead = if ($bodyText.Length -gt 300) { $bodyText.Substring(0, 300) } else { $bodyText }
}
$openThreads = @($allThreads | Where-Object { -not $_.isResolved })
$openCount = $openThreads.Count
# OpenThreadsAwaitingReply: open threads where the LAST comment is
# NOT from the authenticated user. "Ball is in our court" model:
# - Copilot/human posts a finding → last=them → awaiting our reply.
# - We reply → last=us → ball passes back → not awaiting.
# - Copilot re-raises after our reply → last=them again → awaiting.
# Using "last comment" (not "any comment by us in window") is what
# correctly handles re-raised threads. Threads we've replied to but
# the reviewer hasn't yet acted on count as "done from our side" —
# the human merge owner decides what to do with them next.
$awaitingReply = @($openThreads | Where-Object {
$thread = $_
$lastAuthor = $null
if ($thread.comments -and $thread.comments.nodes -and $thread.comments.nodes.Count -gt 0) {
$lastComment = $thread.comments.nodes[$thread.comments.nodes.Count - 1]
if ($lastComment -and $lastComment.author -and $lastComment.author.login) {
$lastAuthor = $lastComment.author.login
}
}
$lastAuthor -ne $me
})
$awaitingCount = $awaitingReply.Count
# CopilotPending: is the Copilot reviewer bot currently in
# `requested_reviewers`? Canonical signal for "review is in flight";
# the wait sub-agent (workflow step 2) consults this so the trigger
# step (01-request-review.ps1) can be skipped when already pending.
$copilotPending = @($allReviewRequests | Where-Object {
$_.requestedReviewer -and $_.requestedReviewer.login -and $_.requestedReviewer.login -match $CopilotReviewerLoginRegex
}).Count -gt 0
# Force submittedAt to a stable ISO-8601 UTC string. ConvertFrom-Json
# auto-converted the gh response's ISO string into [datetime], and
# ConvertTo-Json would otherwise emit it with .NET's "o" format
# (`2026-06-07T18:06:59.0000000Z`) — but more importantly, downstream
# callers that pipe our JSON through `ConvertFrom-Json` again would
# get another [datetime] which renders local culture on string
# interpolation, silently breaking lexicographic baseline comparisons.
# Emit a plain string so the round-trip is identity.
$submittedAtIso = if ($latest -and $latest.submittedAt) { Format-IsoUtcString $latest.submittedAt } else { $null }
$result = [ordered]@{
PrNumber = $PrNumber
Owner = $Owner
Repo = $Repo
HeadOid = $pr.headRefOid
State = $pr.state
LatestCopilotReview = if ($latest) {
[ordered]@{
state = $latest.state
submittedAt = $submittedAtIso
commitOid = $latestCommitOid
bodyHead = $bodyHead
}
} else { $null }
ReviewAtHead = $reviewAtHead
NoNewComments = $noNewComments
OpenThreadCount = $openCount
OpenThreadsAwaitingReply = $awaitingCount
CopilotPending = $copilotPending
# Converged = "the agent has nothing more to do".
# PR State guard: a CLOSED / MERGED PR can never be the target of
# a productive review loop — the agent cannot push, the loop
# cannot iterate. Force Converged = false so the parent surfaces
# the PR-state change to the user instead of silently calling
# task_complete on a non-OPEN PR.
# - SingleIteration (agent decision; Copilot unavailable or
# trigger intentionally skipped): just OpenThreadsAwaitingReply
# == 0. Ignores ReviewAtHead / NoNewComments because those will
# never advance without a new Copilot review.
# - Copilot review exists or pending: ReviewAtHead &&
# NoNewComments && OpenThreadsAwaitingReply == 0.
# - No Copilot review has ever been observed: just
# OpenThreadsAwaitingReply == 0 (also fires for brand-new PRs
# with zero findings; agent should still trigger via
# 01-request-review.ps1 if Copilot is enabled).
Converged = if ($pr.state -ne 'OPEN') {
$false
} elseif ($SingleIteration) {
$awaitingCount -eq 0
} elseif ($latest -or $copilotPending) {
$reviewAtHead -and $noNewComments -and $awaitingCount -eq 0
} else {
$awaitingCount -eq 0
}
}
$result | ConvertTo-Json -Depth 5 -Compress