mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
d47a6c93b7
* 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>
363 lines
17 KiB
PowerShell
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
|