mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 10:01:06 +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>
433 lines
18 KiB
PowerShell
433 lines
18 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Batch-resolve outdated Copilot review threads on a PR.
|
|
|
|
.DESCRIPTION
|
|
After a review loop converges, the PR may still show old `isOutdated`
|
|
Copilot threads listed as open. They were addressed by later commits
|
|
but never explicitly resolved. This script finds them and resolves them
|
|
in bulk.
|
|
|
|
Only acts on threads where:
|
|
- isOutdated: true
|
|
- isResolved: false
|
|
- the first comment's author is copilot-pull-request-reviewer
|
|
- the LAST comment's author is the authenticated user (i.e. we
|
|
have already replied to the thread). This safety guard prevents
|
|
the script from hiding actionable findings if invoked before
|
|
the review loop has converged. Override with -Force.
|
|
- NO comment in the thread is from a non-Copilot, non-authenticated-
|
|
user author (the "human-in-thread" guard — if a human or another
|
|
bot has chimed in anywhere in the thread, even past Copilot's
|
|
opener, the thread is SKIPPED). This guard is NOT overridable
|
|
by -Force; auto-resolving a thread carrying human signal would
|
|
silently hide unaddressed concerns.
|
|
|
|
The doc claim "threads from human reviewers are never touched"
|
|
therefore holds for both single-author human threads AND mixed-
|
|
authorship threads where Copilot opened and a human replied.
|
|
|
|
.PARAMETER Owner
|
|
Repository owner (org or user). Defaults to the current repo's owner
|
|
(resolved via `gh repo view`).
|
|
|
|
.PARAMETER Repo
|
|
Repository name. Defaults to the current repo's name.
|
|
|
|
.PARAMETER PrNumber
|
|
The pull request number.
|
|
|
|
.EXAMPLE
|
|
pwsh 10-cleanup-outdated.ps1 -PrNumber 122
|
|
|
|
.EXAMPLE
|
|
pwsh 10-cleanup-outdated.ps1 -PrNumber 122 -DryRun
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$Owner,
|
|
[string]$Repo,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[int]$PrNumber,
|
|
|
|
# Print what would be resolved without making any GraphQL
|
|
# mutation. Uses an explicit switch (not the PowerShell
|
|
# SupportsShouldProcess / -WhatIf machinery) so the helper's
|
|
# internal `2>` redirect doesn't inherit a WhatIfPreference and
|
|
# print cosmetic "Performing the operation Output to File" noise.
|
|
[switch]$DryRun,
|
|
|
|
# Override the safety guard that requires the last commenter on a
|
|
# thread to be the authenticated user. Without -Force, threads
|
|
# where Copilot (or anyone other than us) had the last word are
|
|
# SKIPPED — resolving them would hide an actionable finding the
|
|
# agent hasn't replied to yet. Pass -Force only when you're
|
|
# intentionally clearing stale outdated Copilot threads out of
|
|
# band of the convergence loop.
|
|
#
|
|
# -Force does NOT override the human-in-thread guard: threads with
|
|
# any non-Copilot, non-authenticated-user comment anywhere in the
|
|
# thread are always skipped regardless of -Force.
|
|
[switch]$Force
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
. "$PSScriptRoot/_lib.ps1"
|
|
|
|
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
|
$Owner = $coords.Owner
|
|
$Repo = $coords.Repo
|
|
|
|
# Authenticated-user login — needed (unless -Force) so we only resolve
|
|
# threads where we have actually replied. Mirrors the pattern in
|
|
# 02-check-review-status.ps1.
|
|
$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 = @'
|
|
query($owner: String!, $repo: String!, $pr: Int!, $after: String) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $pr) {
|
|
reviewThreads(first: 100, after: $after) {
|
|
pageInfo {
|
|
endCursor
|
|
hasNextPage
|
|
}
|
|
nodes {
|
|
id
|
|
isResolved
|
|
isOutdated
|
|
firstComment: comments(first: 1) {
|
|
nodes { author { login } }
|
|
}
|
|
lastComment: comments(last: 1) {
|
|
nodes { author { login } }
|
|
}
|
|
# First page of comment authors so we can reject threads where
|
|
# any non-Copilot, non-$me participant has commented (a human
|
|
# or different bot chimed in after Copilot's opener). The doc
|
|
# promise "threads from human reviewers are never touched"
|
|
# must hold even when the human comment is not the FIRST.
|
|
# totalCount lets us detect threads that exceed the 100-node
|
|
# window; for those we paginate the rest via the node(id:)
|
|
# query below so authorship visibility is always complete.
|
|
allComments: comments(first: 100) {
|
|
totalCount
|
|
pageInfo { endCursor hasNextPage }
|
|
nodes { author { login } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
'@
|
|
|
|
$all = [System.Collections.Generic.List[object]]::new()
|
|
$after = $null
|
|
do {
|
|
$ghArgs = @('-f', "query=$query", '-f', "owner=$Owner", '-f', "repo=$Repo", '-F', "pr=$PrNumber")
|
|
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
|
|
|
|
$data = Invoke-GhGraphQL -GhArgs $ghArgs -Context "list outdated threads for $Owner/$Repo PR #$PrNumber"
|
|
$page = $data.data.repository.pullRequest.reviewThreads
|
|
foreach ($n in $page.nodes) { $all.Add($n) }
|
|
$after = $page.pageInfo.endCursor
|
|
} while ($page.pageInfo.hasNextPage)
|
|
|
|
$threads = $all.ToArray()
|
|
|
|
# Comment-pagination helper: when a thread's first 100 comments don't
|
|
# cover the full set (totalCount > nodes.Count), fetch the remaining
|
|
# pages via node(id:) so authorship visibility is always complete.
|
|
# Returns the full ordered list of author logins (may include $null
|
|
# entries for ghost/deleted users — callers must tolerate $null).
|
|
#
|
|
# Guarantees:
|
|
# * Uses a typed List[object] (preserves $null author entries for
|
|
# ghost / deleted users) to avoid O(n^2) array growth via `+=`.
|
|
# * Hard upper bound of `(MaxPages + 1) * 100` comments to prevent a
|
|
# runaway loop if the server ever returns an invalid pageInfo
|
|
# (cursor that never advances). The outer query is page 0 (first
|
|
# 100 comments); MaxPages caps the paginated additional pages.
|
|
# Default MaxPages=200 → up to 201 pages → 20,100 comments, three
|
|
# orders of magnitude beyond any plausible PR thread.
|
|
# * Throws with explicit context if the paginated `node(id:)` query
|
|
# returns $null (e.g., thread deleted mid-pagination) so the failure
|
|
# bubbles up rather than silently producing partial authorship.
|
|
function Get-AllThreadAuthors {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory)] [string]$ThreadId,
|
|
[Parameter(Mandatory)] $FirstPage, # the allComments object from the outer query
|
|
[int]$MaxPages = 200
|
|
)
|
|
|
|
# List[object] (not List[string]) so $null author entries — which
|
|
# GitHub returns for deleted / ghost users — round-trip as $null
|
|
# instead of being coerced to ''. Callers rely on `-not $login` to
|
|
# skip both, but preserving the original shape keeps the contract
|
|
# honest for any future caller.
|
|
$authors = [System.Collections.Generic.List[object]]::new()
|
|
|
|
$firstNodes = @()
|
|
if ($FirstPage -and $FirstPage.nodes) { $firstNodes = @($FirstPage.nodes) }
|
|
foreach ($n in $firstNodes) {
|
|
$login = $null
|
|
if ($n -and $n.author) { $login = $n.author.login }
|
|
$authors.Add($login)
|
|
}
|
|
|
|
$hasNext = $false
|
|
$after = $null
|
|
if ($FirstPage -and $FirstPage.pageInfo) {
|
|
$hasNext = [bool]$FirstPage.pageInfo.hasNextPage
|
|
$after = $FirstPage.pageInfo.endCursor
|
|
}
|
|
|
|
$pageQuery = @'
|
|
query($id: ID!, $after: String) {
|
|
node(id: $id) {
|
|
... on PullRequestReviewThread {
|
|
comments(first: 100, after: $after) {
|
|
pageInfo { endCursor hasNextPage }
|
|
nodes { author { login } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
'@
|
|
|
|
# First fetched page is labeled 1: $pageIndex initialised to 0 here,
|
|
# incremented at loop entry. The outer (pre-loop) query is page 0;
|
|
# this loop fetches additional pages, so MaxPages caps the in-loop
|
|
# iterations, making the total bound (outer + paginated) = MaxPages + 1.
|
|
$pageIndex = 0
|
|
while ($hasNext) {
|
|
$pageIndex++
|
|
if ($pageIndex -gt $MaxPages) {
|
|
throw "Get-AllThreadAuthors: exceeded MaxPages=$MaxPages for thread $ThreadId — likely a malformed server response (cursor not advancing)."
|
|
}
|
|
|
|
$pageArgs = @('-f', "query=$pageQuery", '-f', "id=$ThreadId")
|
|
if ($after) { $pageArgs = $pageArgs + @('-f', "after=$after") }
|
|
$pageData = Invoke-GhGraphQL -GhArgs $pageArgs -Context "paginate comments for thread $ThreadId (page $pageIndex)"
|
|
|
|
$threadNode = $null
|
|
if ($pageData -and $pageData.data) { $threadNode = $pageData.data.node }
|
|
if (-not $threadNode) {
|
|
throw "Get-AllThreadAuthors: node(id: '$ThreadId') returned null on page $pageIndex (thread deleted or inaccessible)."
|
|
}
|
|
$pageBody = $threadNode.comments
|
|
if (-not $pageBody) {
|
|
throw "Get-AllThreadAuthors: thread $ThreadId has no comments connection on page $pageIndex."
|
|
}
|
|
|
|
$pageNodes = @()
|
|
if ($pageBody.nodes) { $pageNodes = @($pageBody.nodes) }
|
|
foreach ($n in $pageNodes) {
|
|
$login = $null
|
|
if ($n -and $n.author) { $login = $n.author.login }
|
|
$authors.Add($login)
|
|
}
|
|
|
|
$prevCursor = $after
|
|
$hasNext = $false
|
|
$after = $null
|
|
if ($pageBody.pageInfo) {
|
|
$hasNext = [bool]$pageBody.pageInfo.hasNextPage
|
|
$after = $pageBody.pageInfo.endCursor
|
|
}
|
|
# Belt-and-suspenders: if the server claims more pages but the
|
|
# cursor didn't advance, MaxPages would still catch it — but
|
|
# bail explicitly with a clearer message.
|
|
if ($hasNext -and $after -eq $prevCursor) {
|
|
throw "Get-AllThreadAuthors: pagination cursor did not advance for thread $ThreadId on page $pageIndex (server returned same endCursor='$after')."
|
|
}
|
|
}
|
|
|
|
# Return as an array (comma-prefix prevents PowerShell from
|
|
# unwrapping a single-element list at the call boundary).
|
|
return ,$authors.ToArray()
|
|
}
|
|
|
|
$copilotLoginRegex = $CopilotReviewerLoginRegex # canonical regex defined in _lib.ps1
|
|
|
|
# Build $targets via an explicit foreach instead of Where-Object {...}.
|
|
# The earlier Where-Object predicate did real work (warnings, try/catch,
|
|
# pagination calls, counter mutation) — Where-Object's script-block runs
|
|
# in a child scope, which is why those counters had to be $script:. A
|
|
# plain foreach keeps the predicate semantics readable, lets the
|
|
# counters be normal locals, and makes error handling easier to follow.
|
|
[int]$skippedAwaitingReply = 0
|
|
[int]$skippedHumanInThread = 0
|
|
[int]$skippedUnknownAuthorInThread = 0
|
|
[int]$skippedPaginationError = 0
|
|
$targets = New-Object System.Collections.Generic.List[object]
|
|
|
|
foreach ($thread in $threads) {
|
|
if (-not $thread.isOutdated) { continue }
|
|
if ($thread.isResolved) { continue }
|
|
|
|
# Defensive null guard around the GraphQL shape. The reviewer
|
|
# login can appear as either `copilot-pull-request-reviewer` or
|
|
# `copilot-pull-request-reviewer[bot]` depending on the GraphQL
|
|
# surface; match both with the same regex used elsewhere.
|
|
$firstAuthor = $null
|
|
if ($thread.firstComment -and $thread.firstComment.nodes -and $thread.firstComment.nodes.Count -gt 0 -and $thread.firstComment.nodes[0].author) {
|
|
$firstAuthor = $thread.firstComment.nodes[0].author.login
|
|
}
|
|
if (-not ($firstAuthor -and ($firstAuthor -match $copilotLoginRegex))) { continue }
|
|
|
|
# Human-in-thread guard: if ANY comment in the thread is from a
|
|
# non-Copilot, non-$me author (i.e., a human or different bot chimed
|
|
# in after Copilot's opener), refuse to auto-resolve even with -Force.
|
|
# The doc claim "threads from human reviewers are never touched"
|
|
# must hold regardless of *position* of the human comment — a thread
|
|
# with mixed authorship still carries human signal that must not
|
|
# silently disappear when the loop calls cleanup.
|
|
#
|
|
# The outer query fetches the first 100 comment authors; when the
|
|
# connection's pageInfo.hasNextPage is true, Get-AllThreadAuthors
|
|
# paginates the rest via node(id:) so authorship visibility is
|
|
# always complete. hasNextPage is the canonical connection signal
|
|
# for "more pages exist" — using it directly is more robust than
|
|
# comparing totalCount vs nodes.Count (totalCount is kept on the
|
|
# query for diagnostics but not used as the pagination trigger).
|
|
$hasMore = $false
|
|
if ($thread.allComments -and $thread.allComments.pageInfo) {
|
|
$hasMore = [bool]$thread.allComments.pageInfo.hasNextPage
|
|
}
|
|
$allAuthors = $null
|
|
if ($hasMore) {
|
|
# Per-thread try/catch so a transient pagination failure on ONE
|
|
# thread (cursor not advancing, node(id:) returning null mid-walk,
|
|
# rate-limit etc.) doesn't abort the rest of the cleanup pass —
|
|
# mirrors the per-thread isolation already used for the resolve
|
|
# mutation below. On failure: fail-safe by SKIPPING the thread
|
|
# (never resolve when authorship is unknown).
|
|
try {
|
|
$allAuthors = Get-AllThreadAuthors -ThreadId $thread.id -FirstPage $thread.allComments
|
|
} catch {
|
|
$skippedPaginationError++
|
|
Write-Warning "Pagination failed for thread $($thread.id) — skipping (fail-safe): $($_.Exception.Message)"
|
|
continue
|
|
}
|
|
} else {
|
|
$allAuthorsList = [System.Collections.Generic.List[object]]::new()
|
|
if ($thread.allComments -and $thread.allComments.nodes) {
|
|
foreach ($n in $thread.allComments.nodes) {
|
|
$login = if ($n.author) { $n.author.login } else { $null }
|
|
$allAuthorsList.Add($login)
|
|
}
|
|
}
|
|
$allAuthors = $allAuthorsList.ToArray()
|
|
}
|
|
|
|
$humanInThread = $false
|
|
$unknownAuthorInThread = $false
|
|
foreach ($login in $allAuthors) {
|
|
if (-not $login) {
|
|
# `$null` author = ghost / deleted user. Authorship is
|
|
# genuinely unknown, so treat as unsafe (fail-safe): we'd
|
|
# rather leak an outdated bot thread than auto-resolve a
|
|
# thread that *might* contain human signal hidden behind
|
|
# a deleted account. Surfaced separately from
|
|
# $humanInThread so summaries can distinguish "human
|
|
# touched this" from "we couldn't tell who touched this".
|
|
$unknownAuthorInThread = $true
|
|
continue
|
|
}
|
|
if ($login -eq $me) { continue }
|
|
if ($login -match $copilotLoginRegex) { continue }
|
|
$humanInThread = $true
|
|
break
|
|
}
|
|
if ($humanInThread) {
|
|
$skippedHumanInThread++
|
|
continue
|
|
}
|
|
if ($unknownAuthorInThread) {
|
|
$skippedUnknownAuthorInThread++
|
|
continue
|
|
}
|
|
|
|
# Safety guard: don't resolve threads where Copilot (or anyone
|
|
# other than us) had the last word — we haven't replied yet, so
|
|
# resolving would hide an actionable finding. Override with -Force.
|
|
$lastAuthor = $null
|
|
if ($thread.lastComment -and $thread.lastComment.nodes -and $thread.lastComment.nodes.Count -gt 0 -and $thread.lastComment.nodes[0].author) {
|
|
$lastAuthor = $thread.lastComment.nodes[0].author.login
|
|
}
|
|
if (-not $Force -and $lastAuthor -ne $me) {
|
|
$skippedAwaitingReply++
|
|
continue
|
|
}
|
|
|
|
$targets.Add($thread)
|
|
}
|
|
|
|
if ($skippedHumanInThread -gt 0) {
|
|
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)."
|
|
}
|
|
if ($skippedUnknownAuthorInThread -gt 0) {
|
|
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)."
|
|
}
|
|
if ($skippedAwaitingReply -gt 0) {
|
|
Write-Output "Skipped $skippedAwaitingReply outdated Copilot thread(s) where the last comment is not from '$me' (pass -Force to override)."
|
|
}
|
|
if ($skippedPaginationError -gt 0) {
|
|
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."
|
|
}
|
|
|
|
if ($targets.Count -eq 0) {
|
|
Write-Output 'No outdated Copilot threads to clean up.'
|
|
return
|
|
}
|
|
|
|
Write-Output "Found $($targets.Count) outdated Copilot thread(s) to resolve."
|
|
|
|
$resolveMutation = @'
|
|
mutation($tid: ID!) {
|
|
resolveReviewThread(input: { threadId: $tid }) {
|
|
thread { isResolved }
|
|
}
|
|
}
|
|
'@
|
|
|
|
# Per-thread try/catch so a single mutation failure (rate-limit, transient
|
|
# GraphQL error, thread-disappeared-mid-loop) does NOT abort the whole
|
|
# cleanup pass and leave the remaining outdated threads unresolved. Track
|
|
# successes and failures, then summarise at the end with a non-zero exit
|
|
# code if any thread failed.
|
|
$resolved = 0
|
|
$failed = New-Object System.Collections.Generic.List[object]
|
|
foreach ($t in $targets) {
|
|
if ($DryRun) {
|
|
Write-Output "Would resolve $($t.id) (DryRun)"
|
|
continue
|
|
}
|
|
try {
|
|
$resolveArgs = @('-f', "query=$resolveMutation", '-f', "tid=$($t.id)")
|
|
Invoke-GhGraphQL -GhArgs $resolveArgs -Context "resolve outdated thread $($t.id)" | Out-Null
|
|
Write-Output "Resolved $($t.id)"
|
|
$resolved++
|
|
} catch {
|
|
$msg = $_.Exception.Message
|
|
Write-Warning "Failed to resolve $($t.id): $msg"
|
|
$failed.Add([pscustomobject]@{ ThreadId = $t.id; Error = $msg })
|
|
}
|
|
}
|
|
|
|
if (-not $DryRun) {
|
|
Write-Output "Cleanup summary: resolved=$resolved failed=$($failed.Count) skippedAwaitingReply=$skippedAwaitingReply skippedHumanInThread=$skippedHumanInThread skippedUnknownAuthorInThread=$skippedUnknownAuthorInThread skippedPaginationError=$skippedPaginationError"
|
|
if ($failed.Count -gt 0) {
|
|
Write-Output ("Failed threads: " + (($failed | ForEach-Object { "$($_.ThreadId) ($($_.Error))" }) -join '; '))
|
|
exit 1
|
|
}
|
|
}
|