mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-19 03:59:17 +00:00
chore: publish from main
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
<#
|
||||
.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
|
||||
Reference in New Issue
Block a user