Setting the file. One moment.
10 Cleanup Outdated · 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/ 10-cleanup-outdated.ps1
PowerShell · 432 lines · 18 KB
16 have already replied to the thread). This safety guard prevents
17 the script from hiding actionable findings if invoked before
18 the review loop has converged. Override with -Force.
19 - NO comment in the thread is from a non-Copilot, non-authenticated-
20 user author (the "human-in-thread" guard — if a human or another
21 bot has chimed in anywhere in the thread, even past Copilot's
22 opener, the thread is SKIPPED). This guard is NOT overridable
23 by -Force; auto-resolving a thread carrying human signal would
24 silently hide unaddressed concerns.
25
26 The doc claim "threads from human reviewers are never touched"
27 therefore holds for both single-author human threads AND mixed-
28 authorship threads where Copilot opened and a human replied.
29
30 . PARAMETER Owner
31 Repository owner (org or user). Defaults to the current repo's owner
32 (resolved via `gh repo view`).
33
34 . PARAMETER Repo
35 Repository name. Defaults to the current repo's name.
36
37 . PARAMETER PrNumber
38 The pull request number.
39
40 . EXAMPLE
41 pwsh 10-cleanup-outdated.ps1 -PrNumber 122
42
43 . EXAMPLE
44 pwsh 10-cleanup-outdated.ps1 -PrNumber 122 -DryRun
45 #>
46 [ CmdletBinding ()]
47 param (
48 [ string ]$Owner ,
49 [ string ]$Repo ,
50
51 [ Parameter ( Mandatory = $true )]
52 [ int ]$PrNumber ,
53
54 # Print what would be resolved without making any GraphQL
55 # mutation. Uses an explicit switch (not the PowerShell
56 # SupportsShouldProcess / -WhatIf machinery) so the helper's
57 # internal `2>` redirect doesn't inherit a WhatIfPreference and
58 # print cosmetic "Performing the operation Output to File" noise.
59 [ switch ]$DryRun ,
60
61 # Override the safety guard that requires the last commenter on a
62 # thread to be the authenticated user. Without -Force, threads
63 # where Copilot (or anyone other than us) had the last word are
64 # SKIPPED — resolving them would hide an actionable finding the
65 # agent hasn't replied to yet. Pass -Force only when you're
66 # intentionally clearing stale outdated Copilot threads out of
67 # band of the convergence loop.
68 #
69 # -Force does NOT override the human-in-thread guard: threads with
70 # any non-Copilot, non-authenticated-user comment anywhere in the
71 # thread are always skipped regardless of -Force.
72 [ switch ]$Force
73 )
74
75 $ErrorActionPreference = 'Stop'
76 . " $PSScriptRoot /_lib.ps1"
77
78 $coords = Resolve-RepoCoords - Owner $Owner - Repo $Repo
79 $Owner = $coords.Owner
80 $Repo = $coords.Repo
81
82 # Authenticated-user login — needed (unless -Force) so we only resolve
83 # threads where we have actually replied. Mirrors the pattern in
84 # 02-check-review-status.ps1.
85 $meR = Invoke-Gh - GhArgs @ ( 'api' , 'user' , '--jq' , '.login' )
86 if ($meR.ExitCode -ne 0 ) {
87 throw "gh api user failed (exit $( $meR.ExitCode ) ): $( $meR.Stderr ) "
88 }
89 $me = $meR.Stdout.Trim()
90
91 $query = @'
92 query($owner: String!, $repo: String!, $pr: Int!, $after: String) {
93 repository(owner: $owner, name: $repo) {
94 pullRequest(number: $pr) {
95 reviewThreads(first: 100, after: $after) {
96 pageInfo {
97 endCursor
98 hasNextPage
99 }
100 nodes {
101 id
102 isResolved
103 isOutdated
104 firstComment: comments(first: 1) {
105 nodes { author { login } }
106 }
107 lastComment: comments(last: 1) {
108 nodes { author { login } }
109 }
110 # First page of comment authors so we can reject threads where
111 # any non-Copilot, non-$me participant has commented (a human
112 # or different bot chimed in after Copilot's opener). The doc
113 # promise "threads from human reviewers are never touched"
114 # must hold even when the human comment is not the FIRST.
115 # totalCount lets us detect threads that exceed the 100-node
116 # window; for those we paginate the rest via the node(id:)
117 # query below so authorship visibility is always complete.
118 allComments: comments(first: 100) {
119 totalCount
120 pageInfo { endCursor hasNextPage }
121 nodes { author { login } }
122 }
123 }
124 }
125 }
126 }
127 }
128 '@
129
130 $all = [ System.Collections.Generic.List [ object ]]::new()
131 $after = $null
132 do {
133 $ghArgs = @ ( '-f' , "query= $query " , '-f' , "owner= $Owner " , '-f' , "repo= $Repo " , '-F' , "pr= $PrNumber " )
134 if ($after) { $ghArgs = $ghArgs + @ ( '-f' , "after= $after " ) }
135
136 $data = Invoke-GhGraphQL - GhArgs $ghArgs - Context "list outdated threads for $Owner / $Repo PR # $PrNumber "
137 $page = $data.data.repository.pullRequest.reviewThreads
138 foreach ($n in $page.nodes) { $all.Add($n) }
139 $after = $page.pageInfo.endCursor
140 } while ($page.pageInfo.hasNextPage)
141
142 $threads = $all.ToArray()
143
144 # Comment-pagination helper: when a thread's first 100 comments don't
145 # cover the full set (totalCount > nodes.Count), fetch the remaining
146 # pages via node(id:) so authorship visibility is always complete.
147 # Returns the full ordered list of author logins (may include $null
148 # entries for ghost/deleted users — callers must tolerate $null).
149 #
150 # Guarantees:
151 # * Uses a typed List[object] (preserves $null author entries for
152 # ghost / deleted users) to avoid O(n^2) array growth via `+=`.
153 # * Hard upper bound of `(MaxPages + 1) * 100` comments to prevent a
154 # runaway loop if the server ever returns an invalid pageInfo
155 # (cursor that never advances). The outer query is page 0 (first
156 # 100 comments); MaxPages caps the paginated additional pages.
157 # Default MaxPages=200 → up to 201 pages → 20,100 comments, three
158 # orders of magnitude beyond any plausible PR thread.
159 # * Throws with explicit context if the paginated `node(id:)` query
160 # returns $null (e.g., thread deleted mid-pagination) so the failure
161 # bubbles up rather than silently producing partial authorship.
162 function Get-AllThreadAuthors {
163 [ CmdletBinding ()]
164 param (
165 [ Parameter ( Mandatory )] [ string ]$ThreadId ,
166 [ Parameter ( Mandatory )] $FirstPage , # the allComments object from the outer query
167 [ int ]$MaxPages = 200
168 )
169
170 # List[object] (not List[string]) so $null author entries — which
171 # GitHub returns for deleted / ghost users — round-trip as $null
172 # instead of being coerced to ''. Callers rely on `-not $login` to
173 # skip both, but preserving the original shape keeps the contract
174 # honest for any future caller.
175 $authors = [ System.Collections.Generic.List [ object ]]::new()
176
177 $firstNodes = @ ()
178 if ($FirstPage -and $FirstPage.nodes) { $firstNodes = @ ($FirstPage.nodes) }
179 foreach ($n in $firstNodes) {
180 $login = $null
181 if ($n -and $n.author) { $login = $n.author.login }
182 $authors.Add($login)
183 }
184
185 $hasNext = $false
186 $after = $null
187 if ($FirstPage -and $FirstPage.pageInfo) {
188 $hasNext = [ bool ]$FirstPage.pageInfo.hasNextPage
189 $after = $FirstPage.pageInfo.endCursor
190 }
191
192 $pageQuery = @'
193 query($id: ID!, $after: String) {
194 node(id: $id) {
195 ... on PullRequestReviewThread {
196 comments(first: 100, after: $after) {
197 pageInfo { endCursor hasNextPage }
198 nodes { author { login } }
199 }
200 }
201 }
202 }
203 '@
204
205 # First fetched page is labeled 1: $pageIndex initialised to 0 here,
206 # incremented at loop entry. The outer (pre-loop) query is page 0;
207 # this loop fetches additional pages, so MaxPages caps the in-loop
208 # iterations, making the total bound (outer + paginated) = MaxPages + 1.
209 $pageIndex = 0
210 while ($hasNext) {
211 $pageIndex ++
212 if ($pageIndex -gt $MaxPages) {
213 throw "Get-AllThreadAuthors: exceeded MaxPages= $MaxPages for thread $ThreadId — likely a malformed server response (cursor not advancing)."
214 }
215
216 $pageArgs = @ ( '-f' , "query= $pageQuery " , '-f' , "id= $ThreadId " )
217 if ($after) { $pageArgs = $pageArgs + @ ( '-f' , "after= $after " ) }
218 $pageData = Invoke-GhGraphQL - GhArgs $pageArgs - Context "paginate comments for thread $ThreadId (page $pageIndex )"
219
220 $threadNode = $null
221 if ($pageData -and $pageData.data) { $threadNode = $pageData.data.node }
222 if ( -not $threadNode) {
223 throw "Get-AllThreadAuthors: node(id: ' $ThreadId ') returned null on page $pageIndex (thread deleted or inaccessible)."
224 }
225 $pageBody = $threadNode.comments
226 if ( -not $pageBody) {
227 throw "Get-AllThreadAuthors: thread $ThreadId has no comments connection on page $pageIndex ."
228 }
229
230 $pageNodes = @ ()
231 if ($pageBody.nodes) { $pageNodes = @ ($pageBody.nodes) }
232 foreach ($n in $pageNodes) {
233 $login = $null
234 if ($n -and $n.author) { $login = $n.author.login }
235 $authors.Add($login)
236 }
237
238 $prevCursor = $after
239 $hasNext = $false
240 $after = $null
241 if ($pageBody.pageInfo) {
242 $hasNext = [ bool ]$pageBody.pageInfo.hasNextPage
243 $after = $pageBody.pageInfo.endCursor
244 }
245 # Belt-and-suspenders: if the server claims more pages but the
246 # cursor didn't advance, MaxPages would still catch it — but
247 # bail explicitly with a clearer message.
248 if ($hasNext -and $after -eq $prevCursor) {
249 throw "Get-AllThreadAuthors: pagination cursor did not advance for thread $ThreadId on page $pageIndex (server returned same endCursor=' $after ')."
250 }
251 }
252
253 # Return as an array (comma-prefix prevents PowerShell from
254 # unwrapping a single-element list at the call boundary).
255 return , $authors.ToArray()
256 }
257
258 $copilotLoginRegex = $CopilotReviewerLoginRegex # canonical regex defined in _lib.ps1
259
260 # Build $targets via an explicit foreach instead of Where-Object {...}.
261 # The earlier Where-Object predicate did real work (warnings, try/catch,
262 # pagination calls, counter mutation) — Where-Object's script-block runs
263 # in a child scope, which is why those counters had to be $script:. A
264 # plain foreach keeps the predicate semantics readable, lets the
265 # counters be normal locals, and makes error handling easier to follow.
266 [ int ]$skippedAwaitingReply = 0
267 [ int ]$skippedHumanInThread = 0
268 [ int ]$skippedUnknownAuthorInThread = 0
269 [ int ]$skippedPaginationError = 0
270 $targets = New-Object System.Collections.Generic.List[ object ]
271
272 foreach ($thread in $threads) {
273 if ( -not $thread.isOutdated) { continue }
274 if ($thread.isResolved) { continue }
275
276 # Defensive null guard around the GraphQL shape. The reviewer
277 # login can appear as either `copilot-pull-request-reviewer` or
278 # `copilot-pull-request-reviewer[bot]` depending on the GraphQL
279 # surface; match both with the same regex used elsewhere.
280 $firstAuthor = $null
281 if ($thread.firstComment -and $thread.firstComment.nodes -and $thread.firstComment.nodes.Count -gt 0 -and $thread.firstComment.nodes[ 0 ].author) {
282 $firstAuthor = $thread.firstComment.nodes[ 0 ].author.login
283 }
284 if ( -not ($firstAuthor -and ($firstAuthor -match $copilotLoginRegex))) { continue }
285
286 # Human-in-thread guard: if ANY comment in the thread is from a
287 # non-Copilot, non-$me author (i.e., a human or different bot chimed
288 # in after Copilot's opener), refuse to auto-resolve even with -Force.
289 # The doc claim "threads from human reviewers are never touched"
290 # must hold regardless of *position* of the human comment — a thread
291 # with mixed authorship still carries human signal that must not
292 # silently disappear when the loop calls cleanup.
293 #
294 # The outer query fetches the first 100 comment authors; when the
295 # connection's pageInfo.hasNextPage is true, Get-AllThreadAuthors
296 # paginates the rest via node(id:) so authorship visibility is
297 # always complete. hasNextPage is the canonical connection signal
298 # for "more pages exist" — using it directly is more robust than
299 # comparing totalCount vs nodes.Count (totalCount is kept on the
300 # query for diagnostics but not used as the pagination trigger).
301 $hasMore = $false
302 if ($thread.allComments -and $thread.allComments.pageInfo) {
303 $hasMore = [ bool ]$thread.allComments.pageInfo.hasNextPage
304 }
305 $allAuthors = $null
306 if ($hasMore) {
307 # Per-thread try/catch so a transient pagination failure on ONE
308 # thread (cursor not advancing, node(id:) returning null mid-walk,
309 # rate-limit etc.) doesn't abort the rest of the cleanup pass —
310 # mirrors the per-thread isolation already used for the resolve
311 # mutation below. On failure: fail-safe by SKIPPING the thread
312 # (never resolve when authorship is unknown).
313 try {
314 $allAuthors = Get-AllThreadAuthors - ThreadId $thread.id - FirstPage $thread.allComments
315 } catch {
316 $skippedPaginationError ++
317 Write-Warning "Pagination failed for thread $( $thread.id ) — skipping (fail-safe): $( $_ .Exception.Message ) "
318 continue
319 }
320 } else {
321 $allAuthorsList = [ System.Collections.Generic.List [ object ]]::new()
322 if ($thread.allComments -and $thread.allComments.nodes) {
323 foreach ($n in $thread.allComments.nodes) {
324 $login = if ($n.author) { $n.author.login } else { $null }
325 $allAuthorsList.Add($login)
326 }
327 }
328 $allAuthors = $allAuthorsList.ToArray()
329 }
330
331 $humanInThread = $false
332 $unknownAuthorInThread = $false
333 foreach ($login in $allAuthors) {
334 if ( -not $login) {
335 # `$null` author = ghost / deleted user. Authorship is
336 # genuinely unknown, so treat as unsafe (fail-safe): we'd
337 # rather leak an outdated bot thread than auto-resolve a
338 # thread that *might* contain human signal hidden behind
339 # a deleted account. Surfaced separately from
340 # $humanInThread so summaries can distinguish "human
341 # touched this" from "we couldn't tell who touched this".
342 $unknownAuthorInThread = $true
343 continue
344 }
345 if ($login -eq $me) { continue }
346 if ($login -match $copilotLoginRegex) { continue }
347 $humanInThread = $true
348 break
349 }
350 if ($humanInThread) {
351 $skippedHumanInThread ++
352 continue
353 }
354 if ($unknownAuthorInThread) {
355 $skippedUnknownAuthorInThread ++
356 continue
357 }
358
359 # Safety guard: don't resolve threads where Copilot (or anyone
360 # other than us) had the last word — we haven't replied yet, so
361 # resolving would hide an actionable finding. Override with -Force.
362 $lastAuthor = $null
363 if ($thread.lastComment -and $thread.lastComment.nodes -and $thread.lastComment.nodes.Count -gt 0 -and $thread.lastComment.nodes[ 0 ].author) {
364 $lastAuthor = $thread.lastComment.nodes[ 0 ].author.login
365 }
366 if ( -not $Force -and $lastAuthor -ne $me) {
367 $skippedAwaitingReply ++
368 continue
369 }
370
371 $targets.Add($thread)
372 }
373
374 if ($skippedHumanInThread -gt 0 ) {
375 Write-Output "Skipped $skippedHumanInThread outdated Copilot thread(s) where a non-Copilot, non-' $me ' commenter participated (-Force does NOT override this — human signal must not silently disappear)."
376 }
377 if ($skippedUnknownAuthorInThread -gt 0 ) {
378 Write-Output "Skipped $skippedUnknownAuthorInThread outdated Copilot thread(s) with at least one ghost / deleted-user (null author) comment (-Force does NOT override this — authorship is unknown, fail-safe is to skip)."
379 }
380 if ($skippedAwaitingReply -gt 0 ) {
381 Write-Output "Skipped $skippedAwaitingReply outdated Copilot thread(s) where the last comment is not from ' $me ' (pass -Force to override)."
382 }
383 if ($skippedPaginationError -gt 0 ) {
384 Write-Output "Skipped $skippedPaginationError outdated Copilot thread(s) due to authorship-pagination errors (fail-safe: never resolve when authorship is unknown). See warnings above for per-thread detail."
385 }
386
387 if ($targets.Count -eq 0 ) {
388 Write-Output 'No outdated Copilot threads to clean up.'
389 return
390 }
391
392 Write-Output "Found $( $targets.Count ) outdated Copilot thread(s) to resolve."
393
394 $resolveMutation = @'
395 mutation($tid: ID!) {
396 resolveReviewThread(input: { threadId: $tid }) {
397 thread { isResolved }
398 }
399 }
400 '@
401
402 # Per-thread try/catch so a single mutation failure (rate-limit, transient
403 # GraphQL error, thread-disappeared-mid-loop) does NOT abort the whole
404 # cleanup pass and leave the remaining outdated threads unresolved. Track
405 # successes and failures, then summarise at the end with a non-zero exit
406 # code if any thread failed.
407 $resolved = 0
408 $failed = New-Object System.Collections.Generic.List[ object ]
409 foreach ($t in $targets) {
410 if ($DryRun) {
411 Write-Output "Would resolve $( $t.id ) (DryRun)"
412 continue
413 }
414 try {
415 $resolveArgs = @ ( '-f' , "query= $resolveMutation " , '-f' , "tid= $( $t.id ) " )
416 Invoke-GhGraphQL - GhArgs $resolveArgs - Context "resolve outdated thread $( $t.id ) " | Out-Null
417 Write-Output "Resolved $( $t.id ) "
418 $resolved ++
419 } catch {
420 $msg = $_ .Exception.Message
421 Write-Warning "Failed to resolve $( $t.id ) : $msg "
422 $failed.Add([ pscustomobject ] @ { ThreadId = $t.id; Error = $msg })
423 }
424 }
425
426 if ( -not $DryRun) {
427 Write-Output "Cleanup summary: resolved= $resolved failed= $( $failed.Count ) skippedAwaitingReply= $skippedAwaitingReply skippedHumanInThread= $skippedHumanInThread skippedUnknownAuthorInThread= $skippedUnknownAuthorInThread skippedPaginationError= $skippedPaginationError "
428 if ($failed.Count -gt 0 ) {
429 Write-Output ( "Failed threads: " + (($failed | ForEach-Object { " $( $_ .ThreadId ) ( $( $_ .Error ) )" }) -join '; ' ))
430 exit 1
431 }
432 }