Setting the file. One moment.
02 Check Review Status · Copilot PR Autopilot · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page 94.10
10 Cleanup
(opens in a new tab)
scripts/ 02-check-review-status.ps1
PowerShell · 362 lines · 17 KB
16 or null if no Copilot review is present
17 in the most recent 100 reviews (very long
18 PRs may have an older Copilot review outside
19 this window — treat null as "no recent
20 review", not "never reviewed")
21 - ReviewAtHead : true iff latest Copilot review's commit.oid == HeadOid
22 - NoNewComments : true iff the latest review body matches
23 "generated no new comments" / "generated 0 comments"
24 - OpenThreadCount : number of unresolved review threads (from all
25 reviewers); informational — convergence does
26 NOT require this to be zero
27 - OpenThreadsAwaitingReply: number of open threads where the
28 LAST comment is NOT from the authenticated
29 user (`gh api user`). "Ball-in-court"
30 model: a Copilot/human comment with no
31 reply from us OR a re-raise after our
32 earlier reply both count as awaiting. A
33 thread where WE are the latest commenter
34 counts as "done from our side" (the human
35 merge owner decides next).
36 - CopilotPending : true iff the Copilot reviewer bot is currently
37 listed in `requested_reviewers` on the PR (a
38 review is in flight; the caller should wait
39 rather than re-trigger)
40 - Converged : true iff the agent has done its job.
41 - When a Copilot review is at HEAD:
42 ReviewAtHead && NoNewComments &&
43 OpenThreadsAwaitingReply == 0.
44 - When no Copilot review has been observed
45 on this PR (LatestCopilotReview is null
46 AND CopilotPending is false): just
47 OpenThreadsAwaitingReply == 0. Note this
48 ALSO fires for a brand-new PR with zero
49 threads — meaning "nothing to do yet";
50 the agent should still trigger a Copilot
51 review via 01-request-review.ps1 if
52 Copilot is enabled on the repo. Single-
53 iteration mode (skip the trigger) is the
54 agent's decision after 01 fails with a
55 specific Copilot-disabled error, NOT an
56 auto-detected state from this script.
57 Open threads may remain in either case —
58 those are explicit hand-offs to the human
59 merge owner.
60
61 Canonical agent loop (see ../references/orchestration.md and the per-step files):
62 1. Call this script → capture LatestCopilotReview.submittedAt as
63 baseline AND read CopilotPending.
64 2. If CopilotPending is true, skip the trigger step — Copilot is
65 already reviewing. Otherwise, call 01-request-review.ps1.
66 If 01 throws with a Copilot-disabled error (e.g. the bot
67 isn't a valid reviewer on this repo), the agent may fall
68 back to single-iteration mode: skip the wait, jump to
69 03-list-open-threads.ps1, triage + reply to whatever exists,
70 done.
71 3. Wait sub-agent polls this script until either submittedAt
72 advances past baseline AND ReviewAtHead is true, OR Converged.
73 4. On convergence end the loop; otherwise fetch threads via
74 03-list-open-threads.ps1, triage, fix, push, reply, repeat.
75
76 Parsing the JSON: timestamps are emitted as plain ISO-8601 UTC
77 strings (e.g. `"2026-06-08T02:02:44Z"`). Extract via regex on the
78 raw JSON to avoid PowerShell's auto re-binding of ISO strings to
79 `[datetime]` (which renders local culture on string interpolation
80 and silently breaks lexicographic baseline compares):
81
82 $snap = pwsh -NoProfile -File 02-check-review-status.ps1 -PrNumber <n>
83 $baseline = if ($snap -match '"submittedAt":"([^"]+)"') { $Matches[1] } else { '' }
84 $copilotPending = ($snap -match '"CopilotPending":true')
85 $converged = ($snap -match '"Converged":true')
86
87 Works on any PowerShell version (5.1 + 7.x). No `[datetime]`
88 rebinding, no version-specific parameters.
89
90 . PARAMETER PrNumber
91 The pull request number. The only required parameter.
92
93 . PARAMETER Owner
94 Repository owner. OPTIONAL — auto-resolved from `gh repo view`.
95
96 . PARAMETER Repo
97 Repository name. OPTIONAL — auto-resolved from `gh repo view`.
98
99 . EXAMPLE
100 pwsh 02-check-review-status.ps1 -PrNumber 236
101
102 # Output (converged):
103 # {"HeadOid":"abc...","State":"OPEN","LatestCopilotReview":{...},"ReviewAtHead":true,"NoNewComments":true,"OpenThreadCount":0}
104
105 # Output (not converged — new findings):
106 # {"HeadOid":"abc...","ReviewAtHead":true,"NoNewComments":false,"OpenThreadCount":3,...}
107 #>
108 [ CmdletBinding ()]
109 param (
110 [ Parameter ( Mandatory = $true )]
111 [ int ]$PrNumber ,
112
113 [ string ]$Owner ,
114 [ string ]$Repo ,
115
116 # When set, the agent has decided to drive this PR as a single
117 # iteration (typically because 01-request-review.ps1 failed with a
118 # Copilot-disabled error). In this mode, convergence ignores the
119 # stale-review checks (ReviewAtHead / NoNewComments) — those can
120 # never become true when the trigger is intentionally skipped —
121 # and depends solely on OpenThreadsAwaitingReply == 0.
122 [ switch ]$SingleIteration
123 )
124
125 $ErrorActionPreference = 'Stop'
126 . " $PSScriptRoot /_lib.ps1"
127
128 $coords = Resolve-RepoCoords - Owner $Owner - Repo $Repo
129 $Owner = $coords.Owner
130 $Repo = $coords.Repo
131
132 # Identity of the currently-authenticated gh user. Used below to
133 # detect "the agent has already replied to this thread" and therefore
134 # count it as our work-completed (the thread may still be open
135 # deliberately as a human hand-off).
136 $meR = Invoke-Gh - GhArgs @ ( 'api' , 'user' , '--jq' , '.login' )
137 if ($meR.ExitCode -ne 0 ) {
138 throw "gh api user failed (exit $( $meR.ExitCode ) ): $( $meR.Stderr ) "
139 }
140 $me = $meR.Stdout.Trim()
141
142 # Query A (once): PR head/state/reviews. Reviews are not paginated
143 # here — `reviews(last:100)` is the most recent 100 reviews, sufficient
144 # for finding the latest Copilot review.
145 $qHead = @'
146 query($o:String!,$r:String!,$n:Int!){
147 repository(owner:$o,name:$r){
148 pullRequest(number:$n){
149 headRefOid
150 state
151 reviews(last:100){nodes{author{login} state submittedAt body commit{oid}}}
152 }
153 }
154 }
155 '@
156
157 $d = Invoke-GhGraphQL - GhArgs @ ( '-f' , "query= $qHead " , '-f' , "o= $Owner " , '-f' , "r= $Repo " , '-F' , "n= $PrNumber " ) - Context "head query for $Owner / $Repo PR # $PrNumber "
158 $pr = $d.data.repository.pullRequest
159 if ( -not $pr) { throw "PR # $PrNumber not found in $Owner / $Repo ." }
160
161 # Query B (paginated): reviewThreads — fetch isResolved AND the last
162 # comment's author per thread so we can compute
163 # "is this open thread awaiting our reply, or have we already handed
164 # it off?" The loop converges when WE have nothing more to do, not
165 # when the open-thread count drops to zero (some threads stay open
166 # deliberately as human hand-offs / escalated declines).
167 $qThreads = @'
168 query($o:String!,$r:String!,$n:Int!,$after:String){
169 repository(owner:$o,name:$r){
170 pullRequest(number:$n){
171 reviewThreads(first:100, after:$after){
172 pageInfo{endCursor hasNextPage}
173 nodes{
174 isResolved
175 comments(last:1){nodes{author{login}}}
176 }
177 }
178 }
179 }
180 }
181 '@
182
183 $after = $null
184 $allThreadsList = [ System.Collections.Generic.List [ object ]]::new()
185 do {
186 $ghArgs = @ ( '-f' , "query= $qThreads " , '-f' , "o= $Owner " , '-f' , "r= $Repo " , '-F' , "n= $PrNumber " )
187 if ($after) { $ghArgs = $ghArgs + @ ( '-f' , "after= $after " ) }
188 $threadResp = Invoke-GhGraphQL - GhArgs $ghArgs - Context "threads query for $Owner / $Repo PR # $PrNumber "
189 $pagePr = $threadResp.data.repository.pullRequest
190 if ( -not $pagePr) { throw "PR # $PrNumber not found in $Owner / $Repo (threads page)." }
191 foreach ($n in $pagePr.reviewThreads.nodes) { $allThreadsList.Add($n) }
192 $after = $pagePr.reviewThreads.pageInfo.endCursor
193 } while ($pagePr.reviewThreads.pageInfo.hasNextPage)
194 $allThreads = $allThreadsList.ToArray()
195
196 # Query C (paginated): reviewRequests — typical PRs have <100 requested
197 # reviewers, but pagination is required for correctness so we never
198 # falsely report CopilotPending=false on a PR with >100 requested
199 # reviewers (which would cause the wait sub-agent to re-trigger a
200 # review that's actually already in flight).
201 $qReviewRequests = @'
202 query($o:String!,$r:String!,$n:Int!,$after:String){
203 repository(owner:$o,name:$r){
204 pullRequest(number:$n){
205 reviewRequests(first:100, after:$after){
206 pageInfo{endCursor hasNextPage}
207 nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}}
208 }
209 }
210 }
211 }
212 '@
213
214 $after = $null
215 $allReviewRequestsList = [ System.Collections.Generic.List [ object ]]::new()
216 do {
217 $ghArgs = @ ( '-f' , "query= $qReviewRequests " , '-f' , "o= $Owner " , '-f' , "r= $Repo " , '-F' , "n= $PrNumber " )
218 if ($after) { $ghArgs = $ghArgs + @ ( '-f' , "after= $after " ) }
219 $rrResp = Invoke-GhGraphQL - GhArgs $ghArgs - Context "reviewRequests query for $Owner / $Repo PR # $PrNumber "
220 $rrPagePr = $rrResp.data.repository.pullRequest
221 if ( -not $rrPagePr) { throw "PR # $PrNumber not found in $Owner / $Repo (reviewRequests page)." }
222 foreach ($n in $rrPagePr.reviewRequests.nodes) { $allReviewRequestsList.Add($n) }
223 $after = $rrPagePr.reviewRequests.pageInfo.endCursor
224 } while ($rrPagePr.reviewRequests.pageInfo.hasNextPage)
225 $allReviewRequests = $allReviewRequestsList.ToArray()
226
227 # M1 tie-break: when multiple Copilot reviews share the same
228 # submittedAt (server-side clock collision is rare but possible
229 # under burst re-triggers), pick the one whose commit.oid matches
230 # HEAD if any; otherwise the original sort order is deterministic
231 # enough (PowerShell Sort-Object is stable since 5.1).
232 # M3 pagination: reviews(last:100) returns the MOST RECENT 100
233 # reviews. If a PR has 100+ reviews more recent than the last
234 # Copilot review (essentially impossible in normal use, but
235 # theoretically possible on heavily-bot-reviewed PRs), the latest
236 # Copilot review would be cut off. Emit a soft warning to stderr
237 # when we hit the boundary so the caller knows to inspect manually.
238 if ($pr.reviews.nodes.Count -ge 100 ) {
239 [ 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." )
240 }
241 $copilotReviews = @ ($pr.reviews.nodes | Where-Object {
242 $_ .author -and $_ .author.login -and $_ .author.login -match $CopilotReviewerLoginRegex
243 })
244 $latest = if ($copilotReviews.Count -gt 0 ) {
245 $atHead = $copilotReviews | Where-Object { $_ .commit -and $_ .commit.oid -eq $pr.headRefOid } | Sort-Object submittedAt - Descending | Select-Object - First 1
246 if ($atHead) { $atHead } else { $copilotReviews | Sort-Object submittedAt - Descending | Select-Object - First 1 }
247 } else { $null }
248
249 $reviewAtHead = $false
250 $noNewComments = $false
251 $bodyHead = $null
252 $latestCommitOid = $null
253 if ($latest) {
254 if ($latest.commit -and $latest.commit.oid) {
255 $latestCommitOid = $latest.commit.oid
256 $reviewAtHead = ($latestCommitOid -eq $pr.headRefOid)
257 }
258 $bodyText = if ($latest.body) { $latest.body } else { '' }
259 # NoNewComments covers both successful zero-finding reviews AND the
260 # "Copilot wasn't able to review any files in this pull request"
261 # body that Copilot returns for empty / pure-whitespace / line-ending-
262 # only diffs. Both are terminal for the loop: there is nothing for
263 # the agent to address and re-triggering will produce the same body.
264 # Matches Copilot's "no findings" terminal phrases. Anchored on
265 # \b (word boundary, blocks "regenerated") and on a following
266 # sentence-end (./!/EOL/EOS) so the regex does NOT false-positive
267 # on substrings like "generated no comments yet but..." or
268 # "with 0 comments outstanding". Tested against the 4 known
269 # negative inputs and 6 known positive Copilot body templates.
270 $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' )
271 $bodyHead = if ($bodyText.Length -gt 300 ) { $bodyText.Substring( 0 , 300 ) } else { $bodyText }
272 }
273
274 $openThreads = @ ($allThreads | Where-Object { -not $_ .isResolved })
275 $openCount = $openThreads.Count
276
277 # OpenThreadsAwaitingReply: open threads where the LAST comment is
278 # NOT from the authenticated user. "Ball is in our court" model:
279 # - Copilot/human posts a finding → last=them → awaiting our reply.
280 # - We reply → last=us → ball passes back → not awaiting.
281 # - Copilot re-raises after our reply → last=them again → awaiting.
282 # Using "last comment" (not "any comment by us in window") is what
283 # correctly handles re-raised threads. Threads we've replied to but
284 # the reviewer hasn't yet acted on count as "done from our side" —
285 # the human merge owner decides what to do with them next.
286 $awaitingReply = @ ($openThreads | Where-Object {
287 $thread = $_
288 $lastAuthor = $null
289 if ($thread.comments -and $thread.comments.nodes -and $thread.comments.nodes.Count -gt 0 ) {
290 $lastComment = $thread.comments.nodes[$thread.comments.nodes.Count - 1 ]
291 if ($lastComment -and $lastComment.author -and $lastComment.author.login) {
292 $lastAuthor = $lastComment.author.login
293 }
294 }
295 $lastAuthor -ne $me
296 })
297 $awaitingCount = $awaitingReply.Count
298
299 # CopilotPending: is the Copilot reviewer bot currently in
300 # `requested_reviewers`? Canonical signal for "review is in flight";
301 # the wait sub-agent (workflow step 2) consults this so the trigger
302 # step (01-request-review.ps1) can be skipped when already pending.
303 $copilotPending = @ ($allReviewRequests | Where-Object {
304 $_ .requestedReviewer -and $_ .requestedReviewer.login -and $_ .requestedReviewer.login -match $CopilotReviewerLoginRegex
305 }).Count -gt 0
306
307 # Force submittedAt to a stable ISO-8601 UTC string. ConvertFrom-Json
308 # auto-converted the gh response's ISO string into [datetime], and
309 # ConvertTo-Json would otherwise emit it with .NET's "o" format
310 # (`2026-06-07T18:06:59.0000000Z`) — but more importantly, downstream
311 # callers that pipe our JSON through `ConvertFrom-Json` again would
312 # get another [datetime] which renders local culture on string
313 # interpolation, silently breaking lexicographic baseline comparisons.
314 # Emit a plain string so the round-trip is identity.
315 $submittedAtIso = if ($latest -and $latest.submittedAt) { Format-IsoUtcString $latest.submittedAt } else { $null }
316
317 $result = [ ordered ] @ {
318 PrNumber = $PrNumber
319 Owner = $Owner
320 Repo = $Repo
321 HeadOid = $pr.headRefOid
322 State = $pr.state
323 LatestCopilotReview = if ($latest) {
324 [ ordered ] @ {
325 state = $latest.state
326 submittedAt = $submittedAtIso
327 commitOid = $latestCommitOid
328 bodyHead = $bodyHead
329 }
330 } else { $null }
331 ReviewAtHead = $reviewAtHead
332 NoNewComments = $noNewComments
333 OpenThreadCount = $openCount
334 OpenThreadsAwaitingReply = $awaitingCount
335 CopilotPending = $copilotPending
336 # Converged = "the agent has nothing more to do".
337 # PR State guard: a CLOSED / MERGED PR can never be the target of
338 # a productive review loop — the agent cannot push, the loop
339 # cannot iterate. Force Converged = false so the parent surfaces
340 # the PR-state change to the user instead of silently calling
341 # task_complete on a non-OPEN PR.
342 # - SingleIteration (agent decision; Copilot unavailable or
343 # trigger intentionally skipped): just OpenThreadsAwaitingReply
344 # == 0. Ignores ReviewAtHead / NoNewComments because those will
345 # never advance without a new Copilot review.
346 # - Copilot review exists or pending: ReviewAtHead &&
347 # NoNewComments && OpenThreadsAwaitingReply == 0.
348 # - No Copilot review has ever been observed: just
349 # OpenThreadsAwaitingReply == 0 (also fires for brand-new PRs
350 # with zero findings; agent should still trigger via
351 # 01-request-review.ps1 if Copilot is enabled).
352 Converged = if ($pr.state -ne 'OPEN' ) {
353 $false
354 } elseif ($SingleIteration) {
355 $awaitingCount -eq 0
356 } elseif ($latest -or $copilotPending) {
357 $reviewAtHead -and $noNewComments -and $awaitingCount -eq 0
358 } else {
359 $awaitingCount -eq 0
360 }
361 }
362 $result | ConvertTo-Json - Depth 5 - Compress