mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 10:53:25 +00:00
chore: publish from main
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Request a Copilot review on a PR and verify the trigger landed.
|
||||
|
||||
.DESCRIPTION
|
||||
Single mechanism: GraphQL `requestReviewsByLogin` with
|
||||
`botLogins:["copilot-pull-request-reviewer"]`. See
|
||||
references/api-quirks.md for the GraphQL surface details.
|
||||
|
||||
Success contract (exit 0, single-line JSON):
|
||||
- Status="InFlight" — Copilot already a requested reviewer.
|
||||
- Status="TriggerLanded" — mutation submitted and verified via a
|
||||
new `copilot_work_started` event id.
|
||||
|
||||
Failure (throw, exit 1): mutation failed, or no new event landed
|
||||
within -VerifySeconds. Caller should push a substantive commit and
|
||||
retry (auto-assign on `synchronize` is the most reliable fallback).
|
||||
|
||||
.PARAMETER PrNumber PR number (required).
|
||||
.PARAMETER Owner
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
|
||||
.PARAMETER Repo
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
.PARAMETER VerifySeconds Verification poll window (1..600, default 45).
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 01-request-review.ps1 -PrNumber 236
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
[ValidateRange(1, 600)]
|
||||
[int]$VerifySeconds = 45
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
function Get-LatestCopilotWorkStartedEvent {
|
||||
$eventsPath = "repos/$Owner/$Repo/issues/$PrNumber/events?per_page=100"
|
||||
$r = Invoke-Gh -GhArgs @('api','-i',$eventsPath)
|
||||
if ($r.ExitCode -ne 0) { throw "events query failed: $($r.Stderr)" }
|
||||
|
||||
$m = [regex]::Match($r.Stdout, '(?s)\A(?<headers>.*?)\r?\n\r?\n(?<body>.*)\z')
|
||||
if (-not $m.Success) { throw 'events query returned an unexpected header/body shape.' }
|
||||
$headers = $m.Groups['headers'].Value
|
||||
$body = $m.Groups['body'].Value
|
||||
|
||||
$lastPage = 1
|
||||
# Link header looks like: `<https://api.github.com/...?per_page=100&page=4>; rel="last"`
|
||||
# Param order is not guaranteed — `page=4` may appear before or after
|
||||
# other query params. Match `page=<n>` inside the URL (allowing `?`
|
||||
# or `&` separator) up to the closing angle bracket, then the
|
||||
# `rel="last"` marker.
|
||||
$lastMatch = [regex]::Match($headers, '<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"')
|
||||
if ($lastMatch.Success) { $lastPage = [int]$lastMatch.Groups[1].Value }
|
||||
if ($lastPage -gt 1) {
|
||||
$r = Invoke-Gh -GhArgs @('api',"repos/$Owner/$Repo/issues/$PrNumber/events?per_page=100&page=$lastPage")
|
||||
if ($r.ExitCode -ne 0) { throw "events last-page query failed: $($r.Stderr)" }
|
||||
$body = $r.Stdout
|
||||
}
|
||||
|
||||
$events = @(ConvertFrom-GhJson -Stdout $body -Context "events page $lastPage")
|
||||
$latest = $events | Where-Object { $_.event -eq 'copilot_work_started' } | Sort-Object id | Select-Object -Last 1
|
||||
if (-not $latest) { return [pscustomobject]@{ Id = 0L; CreatedAt = '' } }
|
||||
$createdAt = Format-IsoUtcString $latest.created_at
|
||||
[pscustomobject]@{ Id = [long]$latest.id; CreatedAt = $createdAt }
|
||||
}
|
||||
|
||||
# ---------- repo resolve ----------
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
# ---------- state: is Copilot currently requested? ----------
|
||||
# Single GraphQL query: requested reviewers + head SHA, followed by
|
||||
# pagination for the full requested-reviewer set.
|
||||
|
||||
$stateQuery = @'
|
||||
query($o:String!,$r:String!,$n:Int!){
|
||||
viewer{login}
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
id
|
||||
headRefOid
|
||||
state
|
||||
author{login}
|
||||
reviews(last:50){nodes{author{login}}}
|
||||
reviewRequests(first:100){nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}} pageInfo{hasNextPage endCursor}}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
$stateData = Invoke-GhGraphQL -GhArgs @('-f',"query=$stateQuery",'-f',"o=$Owner",'-f',"r=$Repo",'-F',"n=$PrNumber") -Context "state query for $Owner/$Repo PR #$PrNumber"
|
||||
$pr = $stateData.data.repository.pullRequest
|
||||
if (-not $pr) { throw "PR #$PrNumber not found in $Owner/$Repo." }
|
||||
if ($pr.state -ne 'OPEN') {
|
||||
throw "PR #$PrNumber is not OPEN (state=$($pr.state))."
|
||||
}
|
||||
|
||||
$viewerLogin = [string]$stateData.data.viewer.login
|
||||
$prAuthorLogin = if ($pr.author) { [string]$pr.author.login } else { '' }
|
||||
$viewerIsAuthor = ($viewerLogin -and $prAuthorLogin -and ($viewerLogin -eq $prAuthorLogin))
|
||||
$copilotHasReviewed = $false
|
||||
if ($pr.reviews -and $pr.reviews.nodes) {
|
||||
foreach ($rev in $pr.reviews.nodes) {
|
||||
if ($rev.author -and $rev.author.login -and ($rev.author.login -match $CopilotReviewerLoginRegex)) {
|
||||
$copilotHasReviewed = $true; break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$headOid = $pr.headRefOid
|
||||
$prNodeId = [string]$pr.id
|
||||
if ([string]::IsNullOrWhiteSpace($prNodeId)) {
|
||||
throw "Failed to resolve PR node id for $Owner/$Repo PR #$PrNumber from state query."
|
||||
}
|
||||
$reviewRequestsList = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($n in @($pr.reviewRequests.nodes)) { $reviewRequestsList.Add($n) }
|
||||
$hasNext = [bool]$pr.reviewRequests.pageInfo.hasNextPage
|
||||
$after = $pr.reviewRequests.pageInfo.endCursor
|
||||
while ($hasNext) {
|
||||
$pageQuery = @'
|
||||
query($o:String!,$r:String!,$n:Int!,$after:String!){
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
reviewRequests(first:100,after:$after){nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}} pageInfo{hasNextPage endCursor}}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
$pageData = Invoke-GhGraphQL -GhArgs @('-f',"query=$pageQuery",'-f',"o=$Owner",'-f',"r=$Repo",'-F',"n=$PrNumber",'-f',"after=$after") -Context "reviewRequests page query for $Owner/$Repo PR #$PrNumber"
|
||||
$page = $pageData.data.repository.pullRequest.reviewRequests
|
||||
foreach ($n in $page.nodes) { $reviewRequestsList.Add($n) }
|
||||
$hasNext = [bool]$page.pageInfo.hasNextPage
|
||||
$after = $page.pageInfo.endCursor
|
||||
}
|
||||
$reviewRequests = $reviewRequestsList.ToArray()
|
||||
$copilotPendingRequests = @($reviewRequests | Where-Object {
|
||||
$_.requestedReviewer -and $_.requestedReviewer.login -and $_.requestedReviewer.login -match $CopilotReviewerLoginRegex
|
||||
})
|
||||
$copilotPending = $copilotPendingRequests.Count -gt 0
|
||||
|
||||
# If Copilot is currently in requested_reviewers, it's in-flight by definition.
|
||||
if ($copilotPending) {
|
||||
@{
|
||||
Status = 'InFlight'
|
||||
PrNumber = $PrNumber
|
||||
HeadOid = $headOid
|
||||
Detail = "Copilot is currently in requested_reviewers; review is in flight."
|
||||
} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}
|
||||
|
||||
# We do NOT short-circuit on AlreadyReviewed — the user wants re-request
|
||||
# as a first-class flow. Re-trigger; the GraphQL mutation handles both
|
||||
# initial-add and re-request identically.
|
||||
|
||||
# ---------- snapshot copilot_work_started before triggering ----------
|
||||
|
||||
# Snapshot the latest copilot_work_started BEFORE triggering. Use the
|
||||
# event's numeric `id` (monotonic) — `created_at` is second-resolution
|
||||
# and would collide if a new event lands in the same second.
|
||||
$beforeEvent = Get-LatestCopilotWorkStartedEvent
|
||||
$beforeId = $beforeEvent.Id
|
||||
|
||||
# ---------- trigger via GraphQL requestReviewsByLogin ----------
|
||||
|
||||
$mut = 'mutation($p:ID!){requestReviewsByLogin(input:{pullRequestId:$p,botLogins:["copilot-pull-request-reviewer"]}){pullRequest{number}}}'
|
||||
# Why this path and not REST or `requestReviews`? Verified end-to-end:
|
||||
# - REST POST /pulls/{n}/requested_reviewers `reviewers:["Copilot"]`
|
||||
# (the bot's REST login per `GET user/175728472`) → 404. The REST
|
||||
# `reviewers` field accepts type=User only; bots are rejected even
|
||||
# when the login resolves to a Bot record.
|
||||
# - GraphQL `requestReviews` rejects bot node IDs ("Could not resolve
|
||||
# to User node with the global id of 'BOT_…'") at schema level.
|
||||
# - `requestReviewsByLogin.botLogins` is the ONLY public path for bot
|
||||
# reviewers; trade-off is that it requires repo Triage/Write.
|
||||
# - The UI 🔄 button uses a github.com Rails endpoint with a session
|
||||
# cookie + CSRF that gh's OAuth token cannot satisfy.
|
||||
# Catch the auth-gated case below and surface the two real workarounds.
|
||||
$r = Invoke-Gh -GhArgs @('api','graphql','-f',"query=$mut",'-f',"p=$prNodeId")
|
||||
|
||||
# Belt-and-suspenders permission-error detection. Empirically `gh api graphql`
|
||||
# exits non-zero AND puts the message in stderr for FORBIDDEN on
|
||||
# requestReviewsByLogin (verified: exit=1, stderr contains "does not have the
|
||||
# correct permissions"). But some GraphQL paths return exit=0 with a top-level
|
||||
# `errors[]` carrying type=FORBIDDEN, so check both surfaces and route both to
|
||||
# the same actionable-error formatter.
|
||||
$permErrInStderr = ($r.ExitCode -ne 0) -and ($r.Stderr -match '(?i)does not have (the )?correct permissions|forbidden|HTTP 403')
|
||||
$permErrInBody = $false
|
||||
$bodyErrors = $null
|
||||
if ($r.Stdout) {
|
||||
try {
|
||||
# Route through the shared ConvertFrom-GhJson helper so the
|
||||
# preview format / context conventions stay consistent. The
|
||||
# helper throws on parse failure; we catch and Write-Warning
|
||||
# (fall through to the authoritative stderr/exit-code path)
|
||||
# rather than abort — the warning makes the fall-through
|
||||
# observable in logs.
|
||||
$parsed = ConvertFrom-GhJson -Stdout $r.Stdout -Stderr $r.Stderr -Context 'requestReviewsByLogin' -PreviewChars 200
|
||||
if ($parsed.errors) {
|
||||
$bodyErrors = $parsed.errors
|
||||
$permErrInBody = [bool]($parsed.errors | Where-Object {
|
||||
($_.type -eq 'FORBIDDEN') -or ($_.message -match '(?i)does not have (the )?correct permissions|forbidden')
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
Write-Warning $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
if ($permErrInStderr -or $permErrInBody) {
|
||||
$rawMsg = if ($permErrInStderr) { $r.Stderr } elseif ($bodyErrors) { ($bodyErrors | ForEach-Object { $_.message }) -join '; ' } else { '(no message)' }
|
||||
if ($viewerIsAuthor) {
|
||||
# External PR author scenario: GitHub's UI 🔄 button uses an internal
|
||||
# endpoint not exposed in the public GraphQL/REST schema. Verified via
|
||||
# schema enumeration: the only public bot-reviewer mutation is
|
||||
# requestReviewsByLogin, which requires Triage/Write on the repo.
|
||||
# PR authors without write permission cannot trigger via any public API.
|
||||
$scenario = if ($copilotHasReviewed) { 're-request' } else { 'initial add' }
|
||||
throw @"
|
||||
Cannot trigger Copilot via public API in this scenario ($scenario):
|
||||
- You are the PR author ($viewerLogin) on $Owner/$Repo PR #$PrNumber.
|
||||
- You lack repo Triage/Write permission, so requestReviewsByLogin returns FORBIDDEN.
|
||||
- GitHub's public GraphQL schema has no other bot-reviewer mutation
|
||||
(verified: requestReviews rejects bot node IDs; no REST ``bot_reviewers`` field).
|
||||
- The UI's '🔄 Re-request review' button uses an internal endpoint not in the public API.
|
||||
|
||||
Use one of these workarounds (both reliably drive Copilot to re-review):
|
||||
1. UI: open the PR in a browser → click 🔄 next to 'copilot-pull-request-reviewer'.
|
||||
2. CLI: push a substantive (non-whitespace) commit. The ``synchronize`` event
|
||||
auto-triggers Copilot with no API call and no permission required.
|
||||
|
||||
After triggering by either means, resume the loop with 02-check-review-status.ps1.
|
||||
|
||||
Raw error: $rawMsg
|
||||
"@
|
||||
}
|
||||
throw @"
|
||||
GraphQL requestReviewsByLogin failed with a permission error: $rawMsg
|
||||
|
||||
Most likely causes:
|
||||
* Authenticated user lacks Triage / Write permission on the repo
|
||||
(run ``gh api repos/$Owner/$Repo --jq .permissions`` to confirm; Read-only
|
||||
collaborators cannot request reviewers).
|
||||
* Copilot Code Review not enabled on the repo / account.
|
||||
"@
|
||||
}
|
||||
|
||||
if ($r.ExitCode -ne 0) {
|
||||
throw @"
|
||||
GraphQL requestReviewsByLogin failed: $($r.Stderr)
|
||||
|
||||
Most likely causes:
|
||||
* Quiet-period after a recent dismissal of Copilot — wait 5-10 min, or push a substantive commit.
|
||||
* Copilot Code Review not enabled on the repo / account.
|
||||
* PR in a state that blocks bot review (draft, conflict, branch protection).
|
||||
"@
|
||||
}
|
||||
|
||||
if ($bodyErrors) {
|
||||
# Non-FORBIDDEN errors[] from a successful exit — surface them directly.
|
||||
$msgs = ($bodyErrors | ForEach-Object { $_.message }) -join '; '
|
||||
throw "GraphQL requestReviewsByLogin returned errors: $msgs"
|
||||
}
|
||||
|
||||
# ---------- verify copilot_work_started event landed ----------
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($VerifySeconds)
|
||||
$afterTs = ''
|
||||
$afterId = 0L
|
||||
$lastErr = ''
|
||||
do {
|
||||
try {
|
||||
$nowEvent = Get-LatestCopilotWorkStartedEvent
|
||||
$lastErr = ''
|
||||
if ($nowEvent.Id -gt $beforeId) {
|
||||
$afterId = $nowEvent.Id
|
||||
$afterTs = $nowEvent.CreatedAt
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
$lastErr = $_.Exception.Message
|
||||
}
|
||||
if ((Get-Date) -ge $deadline) { break }
|
||||
$remaining = [int]($deadline - (Get-Date)).TotalSeconds
|
||||
Start-Sleep -Seconds ([Math]::Min(5, [Math]::Max(1, $remaining)))
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
if (-not $afterId) {
|
||||
$errTail = if ($lastErr) { "`n Last events-query error: $lastErr" } else { '' }
|
||||
throw @"
|
||||
GraphQL mutation returned success but no new copilot_work_started event landed within $VerifySeconds seconds. The server may have silently dropped the request, or the events query kept failing transiently.
|
||||
Latest copilot_work_started event id before trigger: $beforeId
|
||||
HEAD: $headOid$errTail
|
||||
|
||||
Push a substantive commit (auto-assign on synchronize is the most reliable trigger) and retry.
|
||||
"@
|
||||
}
|
||||
|
||||
@{
|
||||
Status = 'TriggerLanded'
|
||||
PrNumber = $PrNumber
|
||||
HeadOid = $headOid
|
||||
WorkStartedAt = $afterTs
|
||||
Detail = "Triggered via GraphQL requestReviewsByLogin; copilot_work_started at $afterTs."
|
||||
} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
@@ -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
|
||||
@@ -0,0 +1,126 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
List unresolved review threads on a pull request (all reviewers).
|
||||
|
||||
.DESCRIPTION
|
||||
Fetches review threads via GraphQL (paginated) and prints every
|
||||
thread that is still `isResolved: false`. Threads from all reviewers
|
||||
(Copilot, humans, other bots) are included; the triage step decides
|
||||
what to do with each.
|
||||
|
||||
Each thread's `comments(first:1)` is the originating review comment
|
||||
— that's where `path`, `line`, and `body` come from. Reply chains
|
||||
on the same thread are intentionally not surfaced here; this script
|
||||
is the input to triage, not to reading conversation history.
|
||||
|
||||
.PARAMETER Owner
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
|
||||
.PARAMETER Repo
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
.PARAMETER PrNumber The pull request number.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 03-list-open-threads.ps1 -PrNumber 122
|
||||
|
||||
.PARAMETER MaxBodyLength
|
||||
Cap the `Body` column at this many characters (default 500; pass 0 to
|
||||
disable). Long Copilot comments otherwise dominate stdout and slow
|
||||
triage; truncated bodies are suffixed with `…`.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
[ValidateRange(0, 100000)]
|
||||
[int]$MaxBodyLength = 500
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
$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
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
author { login }
|
||||
body
|
||||
path
|
||||
line
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$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 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()
|
||||
|
||||
$open = $threads | Where-Object { -not $_.isResolved }
|
||||
|
||||
$resultList = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($t in $open) {
|
||||
$c = if ($t.comments -and $t.comments.nodes -and $t.comments.nodes.Count -gt 0) { $t.comments.nodes[0] } else { $null }
|
||||
if (-not $c) { continue } # malformed thread (no originating comment) — skip rather than crash
|
||||
$body = ($c.body -replace "`r?`n", ' ')
|
||||
if ($MaxBodyLength -gt 0 -and $body.Length -gt $MaxBodyLength) {
|
||||
$body = $body.Substring(0, $MaxBodyLength) + '…'
|
||||
}
|
||||
$path = if ($null -ne $c.line) { "$($c.path):$($c.line)" } else { $c.path }
|
||||
$author = if ($c.author -and $c.author.login) { $c.author.login } else { '(deleted)' }
|
||||
# Normalise createdAt via the shared helper so the format stays
|
||||
# identical across 01/02/03 (Format-IsoUtcString in _lib.ps1).
|
||||
$createdAt = Format-IsoUtcString $c.createdAt
|
||||
$resultList.Add([pscustomobject]@{
|
||||
ThreadId = $t.id
|
||||
Author = $author
|
||||
Path = $path
|
||||
CreatedAt = $createdAt
|
||||
Body = $body
|
||||
})
|
||||
}
|
||||
$result = $resultList.ToArray()
|
||||
|
||||
# Single-line JSON output (matches the contract used by 01/02/08/10).
|
||||
# -Compress so callers can pipe to ConvertFrom-Json or jq directly without
|
||||
# stripping PowerShell's default Format-List rendering / ANSI escapes.
|
||||
[pscustomobject]@{
|
||||
PrNumber = $PrNumber
|
||||
Owner = $Owner
|
||||
Repo = $Repo
|
||||
OpenThreadCount = $result.Count
|
||||
Threads = $result
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
@@ -0,0 +1,97 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Post a reply on a Copilot review thread and resolve it.
|
||||
|
||||
.DESCRIPTION
|
||||
Performs the two GraphQL mutations needed to address a Copilot finding:
|
||||
1. addPullRequestReviewThreadReply — appends a reply comment.
|
||||
2. resolveReviewThread — marks the thread resolved.
|
||||
|
||||
Use this for both accepted-and-fixed findings and for declined-with-
|
||||
rationale findings. See ../templates/reply-fix.md, reply-decline.md,
|
||||
reply-drift.md, and reply-partial.md for body patterns.
|
||||
|
||||
.PARAMETER ThreadId
|
||||
The GraphQL node ID of the review thread (e.g. PRRT_kw...).
|
||||
|
||||
.PARAMETER Body
|
||||
The reply body. Markdown is supported.
|
||||
|
||||
.PARAMETER NoResolve
|
||||
If set, posts the reply only and leaves the thread open. Useful when
|
||||
you want to start a back-and-forth discussion rather than close out the
|
||||
thread.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 08-reply-and-resolve.ps1 -ThreadId PRRT_kw... -Body "Fixed in abc1234."
|
||||
|
||||
.EXAMPLE
|
||||
# Decline with rationale, do not resolve yet
|
||||
pwsh 08-reply-and-resolve.ps1 -ThreadId PRRT_kw... -NoResolve `
|
||||
-Body "Declining: this would require cross-class plumbing for a hypothetical race."
|
||||
|
||||
.NOTES
|
||||
Reply + resolve are TWO separate GraphQL mutations and the script
|
||||
is NOT atomic: if the reply succeeds and the resolve then fails
|
||||
(transient 5xx, rate limit, thread disappeared mid-call), the reply
|
||||
is already posted. The script is also NOT idempotent on reply —
|
||||
re-running it with the same -ThreadId and -Body will post a
|
||||
duplicate reply because -Body is mandatory.
|
||||
|
||||
Recommended retry policy when a call fails:
|
||||
- If "Replied to thread X" did NOT print, the reply step failed;
|
||||
safe to re-run the whole command.
|
||||
- If "Replied to thread X" DID print but "Resolved thread X" did
|
||||
not, ONLY the resolve step failed. Do NOT re-run this script
|
||||
(it would post a duplicate reply). Instead resolve directly:
|
||||
|
||||
gh api graphql `
|
||||
-f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{id}}}' `
|
||||
-f t=$ThreadId
|
||||
|
||||
This raw call bypasses the Invoke-Gh helper but is safe here
|
||||
because the mutation contains no embedded double-quote
|
||||
characters, so the PowerShell-5.1 native-arg splitting bug
|
||||
documented in references/api-quirks.md does not apply.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ThreadId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Body,
|
||||
|
||||
[switch]$NoResolve
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$replyMutation = @'
|
||||
mutation($tid: ID!, $body: String!) {
|
||||
addPullRequestReviewThreadReply(input: {
|
||||
pullRequestReviewThreadId: $tid,
|
||||
body: $body
|
||||
}) {
|
||||
comment { id }
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$replyArgs = @('-f', "query=$replyMutation", '-f', "tid=$ThreadId", '-f', "body=$Body")
|
||||
Invoke-GhGraphQL -GhArgs $replyArgs -Context "reply to thread $ThreadId" | Out-Null
|
||||
Write-Output "Replied to thread $ThreadId"
|
||||
|
||||
if (-not $NoResolve) {
|
||||
$resolveMutation = @'
|
||||
mutation($tid: ID!) {
|
||||
resolveReviewThread(input: { threadId: $tid }) {
|
||||
thread { isResolved }
|
||||
}
|
||||
}
|
||||
'@
|
||||
$resolveArgs = @('-f', "query=$resolveMutation", '-f', "tid=$ThreadId")
|
||||
Invoke-GhGraphQL -GhArgs $resolveArgs -Context "resolve thread $ThreadId" | Out-Null
|
||||
Write-Output "Resolved thread $ThreadId"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deterministically count a PR's Copilot review rounds and flag whether
|
||||
the periodic recap gate is due this round. Single-shot, read-only.
|
||||
|
||||
.DESCRIPTION
|
||||
ONE job: compute the round number N for a PR from observable history —
|
||||
the count of review submissions authored by the Copilot Code Review
|
||||
bot — and report whether the recap circuit-breaker is due.
|
||||
|
||||
Each top-of-loop trigger (01-request-review.ps1) produces exactly one
|
||||
Copilot review submission, so the count of those submissions IS the
|
||||
round number. It is read from the GitHub API on every call, so it
|
||||
never depends on agent working memory or any on-disk state file. The
|
||||
original failure mode this skill was built to survive — an agent
|
||||
losing track of the count across a long run, drifting for 156 rounds —
|
||||
cannot happen when the count is derived, not remembered.
|
||||
|
||||
This script does NOT decide anything. It does not stop the loop, does
|
||||
not enforce a cap, and does not pick a verdict. It reports two facts;
|
||||
the parent agent still owns the recap reasoning and the
|
||||
CONTINUE / REVERT-AND-SHIP / HAND-OFF verdict (see
|
||||
../references/09-convergence.md). "No script decides the cap" stays
|
||||
true — this script only makes the *trigger* (Nth round) deterministic
|
||||
instead of a fallible mental tally.
|
||||
|
||||
Output JSON fields:
|
||||
- PrNumber, Owner, Repo
|
||||
- Round : count of Copilot Code Review submissions on the PR.
|
||||
Full pagination — every Copilot submission, NOT the
|
||||
most-recent-100 window 02-check-review-status.ps1
|
||||
uses to find the latest review. The count must stay
|
||||
correct on exactly the long loops the gate exists
|
||||
to catch (a 100-review window would silently
|
||||
undercount past round 100).
|
||||
- RecapInterval : the recap cadence (default 10; override with
|
||||
-RecapInterval).
|
||||
- RecapDue : true iff Round > 0 AND Round % RecapInterval == 0 —
|
||||
i.e. this is a 10th / 20th / 30th ... round and the
|
||||
parent should RUN THE RECAP GATE before looping back
|
||||
to step 1.
|
||||
|
||||
Parsing the JSON (any PowerShell version, 5.1 + 7.x):
|
||||
|
||||
$snap = pwsh -NoProfile -File 09-review-round.ps1 -PrNumber <n>
|
||||
$round = if ($snap -match '"Round":(\d+)') { [int]$Matches[1] } else { 0 }
|
||||
$recapDue = ($snap -match '"RecapDue":true')
|
||||
|
||||
.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`.
|
||||
|
||||
.PARAMETER RecapInterval
|
||||
Recap cadence in rounds. Default 10 (stop at 10, 20, 30, ...). Must be
|
||||
a positive integer. Exposed as a single named knob so the cadence
|
||||
isn't a magic number duplicated in prose, and so the gate boundary is
|
||||
testable on a real PR without fabricating review history.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./scripts/09-review-round.ps1 -PrNumber 1944
|
||||
|
||||
# {"PrNumber":1944,"Owner":"github","Repo":"awesome-copilot","Round":154,"RecapInterval":10,"RecapDue":false}
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./scripts/09-review-round.ps1 -PrNumber 1944 -RecapInterval 7
|
||||
|
||||
# 154 % 7 == 0 -> {"...","Round":154,"RecapInterval":7,"RecapDue":true}
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
[ValidateRange(1, [int]::MaxValue)]
|
||||
[int]$RecapInterval = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
# Walk ALL reviews (full pagination). 02-check-review-status.ps1 only needs
|
||||
# the most-recent-100 window to find the LATEST Copilot review; the round
|
||||
# COUNT must include EVERY Copilot submission, because the gate exists
|
||||
# precisely for long loops where the count can exceed 100. Counting is
|
||||
# order-independent, so forward pagination is sufficient.
|
||||
$qReviews = @'
|
||||
query($o:String!,$r:String!,$n:Int!,$after:String){
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
reviews(first:100, after:$after){
|
||||
pageInfo{endCursor hasNextPage}
|
||||
nodes{author{login}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$after = $null
|
||||
$round = 0
|
||||
do {
|
||||
$ghArgs = @('-f', "query=$qReviews", '-f', "o=$Owner", '-f', "r=$Repo", '-F', "n=$PrNumber")
|
||||
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
|
||||
$resp = Invoke-GhGraphQL -GhArgs $ghArgs -Context "reviews count for $Owner/$Repo PR #$PrNumber"
|
||||
$pagePr = $resp.data.repository.pullRequest
|
||||
if (-not $pagePr) { throw "PR #$PrNumber not found in $Owner/$Repo (reviews page)." }
|
||||
$round += @($pagePr.reviews.nodes | Where-Object {
|
||||
$_.author -and $_.author.login -and $_.author.login -match $CopilotReviewerLoginRegex
|
||||
}).Count
|
||||
$after = $pagePr.reviews.pageInfo.endCursor
|
||||
} while ($pagePr.reviews.pageInfo.hasNextPage)
|
||||
|
||||
# Gate TRIGGER only — not the verdict. RecapDue says "this is an Nth round,
|
||||
# run the recap"; the parent agent reads the recap and picks
|
||||
# CONTINUE / REVERT-AND-SHIP / HAND-OFF (09-convergence.md).
|
||||
$recapDue = ($round -gt 0) -and (($round % $RecapInterval) -eq 0)
|
||||
|
||||
$result = [ordered]@{
|
||||
PrNumber = $PrNumber
|
||||
Owner = $Owner
|
||||
Repo = $Repo
|
||||
Round = $round
|
||||
RecapInterval = $RecapInterval
|
||||
RecapDue = $recapDue
|
||||
}
|
||||
$result | ConvertTo-Json -Depth 3 -Compress
|
||||
@@ -0,0 +1,432 @@
|
||||
<#
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
# Shared helpers for copilot-pr-autopilot scripts.
|
||||
# Dot-source with: `. "$PSScriptRoot/_lib.ps1"`
|
||||
#
|
||||
# Dot-sourcing runs the prerequisite check below; if `gh` is missing or
|
||||
# unauthenticated the script halts BEFORE doing any work, with a single
|
||||
# actionable error message the calling agent can pattern-match on.
|
||||
#
|
||||
# Compatibility: Windows PowerShell 5.1+ and PowerShell 7+. Uses only
|
||||
# `& gh @args 2>$tempFile` for stdout/stderr separation — avoids
|
||||
# `System.Diagnostics.ProcessStartInfo.ArgumentList` which is .NET
|
||||
# Core / .NET 5+ only and returns $null on .NET Framework.
|
||||
|
||||
# Canonical Copilot Code Review reviewer login regex.
|
||||
# GraphQL exposes the login as either `copilot-pull-request-reviewer` (when
|
||||
# referenced via `requestedReviewer.login`) or `copilot-pull-request-reviewer[bot]`
|
||||
# (when referenced via review `author.login`), so callers must accept both.
|
||||
# Centralised here so all step scripts (01 / 02 / 10) stay in sync — if the
|
||||
# canonical login ever changes, change it once.
|
||||
#
|
||||
# Namespaced (`CopilotPrAutopilot_` prefix) + read-only because `_lib.ps1` is
|
||||
# dot-sourced into the caller's scope; a bare name like `$CopilotLoginRegex`
|
||||
# would risk colliding with caller-side variables. `Set-Variable -Force` lets
|
||||
# us re-dot-source in the same session without erroring on the read-only flag.
|
||||
# A back-compat alias `$CopilotReviewerLoginRegex` is preserved so callers
|
||||
# don't have to type the prefix on every read site (and so older snapshots of
|
||||
# 01/02/10 keep working).
|
||||
Set-Variable -Name 'CopilotPrAutopilot_CopilotReviewerLoginRegex' `
|
||||
-Value '(?i)^copilot-pull-request-reviewer(\[bot\])?$' `
|
||||
-Option ReadOnly -Force -Scope Script
|
||||
Set-Variable -Name 'CopilotReviewerLoginRegex' `
|
||||
-Value $CopilotPrAutopilot_CopilotReviewerLoginRegex `
|
||||
-Option ReadOnly -Force -Scope Script
|
||||
|
||||
# Prerequisite check: gh CLI installed AND authenticated.
|
||||
# Fails fast with install/login instructions. Idempotent (once per
|
||||
# PowerShell session).
|
||||
function Assert-GhReady {
|
||||
if ($script:_GhReady) { return }
|
||||
|
||||
# 1. Installed?
|
||||
$cmd = Get-Command gh -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
throw @'
|
||||
copilot-pr-autopilot: prerequisite missing — `gh` CLI is not on PATH.
|
||||
|
||||
Install (one of):
|
||||
- winget install --id GitHub.cli (Windows)
|
||||
- brew install gh (macOS)
|
||||
- sudo apt install gh (Debian/Ubuntu — see https://cli.github.com for other distros)
|
||||
- https://cli.github.com/ (universal installer + download)
|
||||
|
||||
Then `gh auth login` and re-run this command.
|
||||
'@
|
||||
}
|
||||
|
||||
# 2. Authenticated? `gh auth status` exits non-zero when no account
|
||||
# is logged in. Capture stderr to a temp file via the `2>` redirect.
|
||||
$errFile = [IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$null = & gh auth status 2>$errFile
|
||||
$ec = $LASTEXITCODE
|
||||
if ($ec -ne 0) {
|
||||
$err = ''
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
$err = (Get-Content -Raw -LiteralPath $errFile -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $err) { $err = '' }
|
||||
}
|
||||
throw @"
|
||||
copilot-pr-autopilot: prerequisite missing — ``gh`` CLI is not authenticated.
|
||||
|
||||
Run:
|
||||
gh auth login
|
||||
|
||||
Then re-run this command.
|
||||
|
||||
``gh auth status`` reported:
|
||||
$($err.Trim())
|
||||
"@
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
Remove-Item -LiteralPath $errFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$script:_GhReady = $true
|
||||
}
|
||||
|
||||
# Single-invocation gh wrapper. Captures stdout + stderr separately
|
||||
# via the `2>` redirect to a temp file. Returns ExitCode/Stdout/Stderr
|
||||
# so callers never have to re-invoke `gh` just to recover stderr, and
|
||||
# never feed stderr into `ConvertFrom-Json` on success.
|
||||
#
|
||||
# Note on -WhatIf: PowerShell's `2>` redirect goes through Out-File,
|
||||
# which respects $WhatIfPreference at the caller scope. The bundled
|
||||
# `10-cleanup-outdated.ps1` therefore uses an explicit `-DryRun`
|
||||
# switch instead of [CmdletBinding(SupportsShouldProcess)], so this
|
||||
# helper never sees a leaked WhatIfPreference and never prints
|
||||
# "Performing the operation Output to File" noise.
|
||||
function Invoke-Gh {
|
||||
param([Parameter(Mandatory)][string[]]$GhArgs)
|
||||
|
||||
# Cross-version safety: Windows PowerShell 5.1's native-command
|
||||
# argument passer mangles arguments that contain embedded double-quote
|
||||
# characters (long-standing bug, only fully fixed in PS 7.3+ via
|
||||
# $PSNativeCommandArgumentPassing). GraphQL queries/mutations routinely
|
||||
# embed quoted strings (comments, default values, enum-like literals
|
||||
# such as `["copilot-pull-request-reviewer"]`), so passing them as
|
||||
# command-line values (`-f field=<body>`) round-trips correctly in
|
||||
# pwsh 7 but silently mis-splits under 5.1 (e.g., gh CLI reports
|
||||
# "accepts 1 arg(s), received 7" or 'Expected type "number", but it
|
||||
# was malformed: "-pull"'). To work identically in both runtimes, any
|
||||
# `-f field=<body>` or `-F field=<body>` pair whose body contains `"`
|
||||
# is rewritten to `-F field=@<tempfile>` (the body is written to disk
|
||||
# first; `gh` reads it from the file and the value never appears on
|
||||
# the command line).
|
||||
#
|
||||
# IMPORTANT typing note (verified live with gh api graphql):
|
||||
# * `gh -F field=@<file>` reads the file content and applies type
|
||||
# inference (digit→Number, true/false→Boolean, null→null, else
|
||||
# String).
|
||||
# * `gh -f field=@<file>` does NOT expand `@<file>` — it sends the
|
||||
# literal string `@<file>` as the value (gh's `-f` skips the @
|
||||
# prefix entirely). So `-f` is NOT a viable tempfile carrier;
|
||||
# the rewrite MUST use `-F`.
|
||||
#
|
||||
# Safety of the unconditional rewrite-to-`-F`:
|
||||
# * Query bodies (large GraphQL strings) never look like Number /
|
||||
# Boolean / null after inference, so they round-trip as String.
|
||||
# * Reply bodies typed by humans (08-reply-and-resolve) almost
|
||||
# never look like exactly `"true"`, `"false"`, `"null"`, or a
|
||||
# bare digit run — and if they do AND they also contain `"`
|
||||
# (the rewrite trigger), the resulting coercion would be a
|
||||
# loud GraphQL `String!` type error, not silent data loss.
|
||||
# Tempfiles are cleaned up in `finally`.
|
||||
$rewritten = [System.Collections.Generic.List[string]]::new()
|
||||
$tempFiles = [System.Collections.Generic.List[string]]::new()
|
||||
for ($i = 0; $i -lt $GhArgs.Count; $i++) {
|
||||
$a = $GhArgs[$i]
|
||||
# Rewrite both `-f field=<body>` and `-F field=<body>` whose body
|
||||
# contains `"` — same PS 5.1 native-arg splitting bug applies to
|
||||
# both. The rewrite ALWAYS emits `-F` because `gh -f field=@file`
|
||||
# does not expand `@file` (only `-F` does — verified live). The
|
||||
# file content is then sent as a String GraphQL variable for any
|
||||
# body that doesn't look like a Number/Boolean/null (i.e., every
|
||||
# real-world query body and reply body in this skill).
|
||||
if (($a -eq '-f' -or $a -eq '-F') -and ($i + 1) -lt $GhArgs.Count) {
|
||||
$next = $GhArgs[$i + 1]
|
||||
$eqIdx = $next.IndexOf('=')
|
||||
if ($eqIdx -gt 0 -and $next.Substring($eqIdx + 1).Contains('"')) {
|
||||
$field = $next.Substring(0, $eqIdx)
|
||||
$body = $next.Substring($eqIdx + 1)
|
||||
$tf = [IO.Path]::GetTempFileName()
|
||||
[void]$tempFiles.Add($tf)
|
||||
# UTF-8 without BOM so `gh` reads the body verbatim
|
||||
[IO.File]::WriteAllText($tf, $body, [System.Text.UTF8Encoding]::new($false))
|
||||
[void]$rewritten.Add('-F')
|
||||
[void]$rewritten.Add("$field=@$tf")
|
||||
$i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
[void]$rewritten.Add($a)
|
||||
}
|
||||
|
||||
$errFile = [IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$finalArgs = $rewritten.ToArray()
|
||||
# Localise $ErrorActionPreference to 'Continue' around the native
|
||||
# `gh` call. Why: callers set `$ErrorActionPreference = 'Stop'` at
|
||||
# script scope, and under PowerShell 5.1 that combination converts
|
||||
# any line `gh` writes to stderr into a `NativeCommandError` that
|
||||
# aborts the script BEFORE we get to inspect `$LASTEXITCODE`. PS 7+
|
||||
# changed native-stderr handling and is unaffected. By keeping the
|
||||
# native call at 'Continue' we always return the
|
||||
# `@{ExitCode;Stdout;Stderr}` object on both runtimes, so callers
|
||||
# see the same structured error and can emit the same actionable
|
||||
# message (e.g. the "click UI 🔄" guidance in 01-request-review).
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$out = & gh @finalArgs 2>$errFile
|
||||
$ec = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEAP
|
||||
}
|
||||
$err = ''
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
$err = (Get-Content -Raw -LiteralPath $errFile -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $err) { $err = '' }
|
||||
}
|
||||
# Preserve gh's stdout content without PowerShell formatting.
|
||||
# `Out-String` would append a trailing newline and apply console
|
||||
# formatting widths, which can subtly break callers that
|
||||
# regex/JSON-parse the result. `& gh` returns one array entry per
|
||||
# line (with the line terminator already stripped); we re-join with
|
||||
# "`n" and no trailing newline, so the result is content-preserving
|
||||
# but normalized to LF (not byte-identical to the original stream).
|
||||
# Callers add a trailing newline if they need one.
|
||||
$stdout = if ($null -eq $out) { '' }
|
||||
elseif ($out -is [string]) { $out }
|
||||
else { ($out | ForEach-Object { [string]$_ }) -join "`n" }
|
||||
[pscustomobject]@{ ExitCode = $ec; Stdout = $stdout; Stderr = $err }
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
Remove-Item -LiteralPath $errFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
foreach ($tf in $tempFiles) {
|
||||
if ($tf -and (Test-Path -LiteralPath $tf)) {
|
||||
Remove-Item -LiteralPath $tf -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Wrap ConvertFrom-Json so a non-JSON / empty stdout failure carries
|
||||
# the calling $Context plus trimmed stdout/stderr — without this
|
||||
# callers see a bare "Unexpected character encountered" exception
|
||||
# that doesn't say which gh command produced the bad output.
|
||||
# Centralised so the preview limits + format stay consistent across
|
||||
# Invoke-GhGraphQL, Resolve-RepoCoords, and any future call sites.
|
||||
function ConvertFrom-GhJson {
|
||||
param(
|
||||
[Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Stdout,
|
||||
[AllowEmptyString()][AllowNull()][string]$Stderr,
|
||||
[Parameter(Mandatory)][string]$Context,
|
||||
[int]$PreviewChars = 500
|
||||
)
|
||||
try {
|
||||
# Use -InputObject (not pipeline form `$Stdout | ConvertFrom-Json`):
|
||||
# on Windows PowerShell 5.1, returning the pipeline form from inside
|
||||
# a function preserves the parsed array as a single object rather
|
||||
# than unrolling it. Callers then see `.Count == 1` for a JSON
|
||||
# array of N items, and `$result[0]` is the inner array. The
|
||||
# parameter form returns the same parsed structure but PowerShell
|
||||
# 5.1 unrolls it correctly on function return.
|
||||
return (ConvertFrom-Json -InputObject $Stdout -ErrorAction Stop)
|
||||
} catch {
|
||||
$stdoutPreview = if ($Stdout) { $Stdout.Substring(0, [Math]::Min($PreviewChars, $Stdout.Length)) } else { '(empty)' }
|
||||
$stderrPreview = if ($Stderr) { $Stderr.Substring(0, [Math]::Min($PreviewChars, $Stderr.Length)) } else { '(empty)' }
|
||||
throw "$Context returned non-JSON: $($_.Exception.Message)`nstdout (<=${PreviewChars} chars): $stdoutPreview`nstderr (<=${PreviewChars} chars): $stderrPreview"
|
||||
}
|
||||
}
|
||||
|
||||
# Wrapper around Invoke-Gh for `gh api graphql` that throws on either
|
||||
# non-zero exit OR a GraphQL `errors` array in the response body.
|
||||
# Cross-version safety for embedded quotes in queries is handled by
|
||||
# Invoke-Gh's automatic `-f field=<body-with-quotes>` → tempfile rewrite.
|
||||
function Invoke-GhGraphQL {
|
||||
param(
|
||||
[Parameter(Mandatory)][string[]]$GhArgs,
|
||||
[Parameter(Mandatory)][string]$Context
|
||||
)
|
||||
$r = Invoke-Gh -GhArgs (@('api','graphql') + $GhArgs)
|
||||
if ($r.ExitCode -ne 0) {
|
||||
throw "gh api graphql failed (exit $($r.ExitCode)) [$Context]: $($r.Stderr)"
|
||||
}
|
||||
$data = ConvertFrom-GhJson -Stdout $r.Stdout -Stderr $r.Stderr -Context "gh api graphql [$Context]"
|
||||
if ($data.errors) {
|
||||
# Aggregate type + path + extensions.code alongside .message so
|
||||
# callers see actionable failures without re-running with extra
|
||||
# logging. GitHub commonly returns type=NOT_FOUND / FORBIDDEN /
|
||||
# RATE_LIMITED and extensions.code=undefinedField etc.; dropping
|
||||
# them turns a clear failure ("FORBIDDEN at /repository/pullRequest")
|
||||
# into an opaque message-only string.
|
||||
$msgs = ($data.errors | ForEach-Object {
|
||||
$parts = New-Object System.Collections.Generic.List[string]
|
||||
if ($_.type) { $parts.Add("type=$($_.type)") }
|
||||
if ($_.path) { $parts.Add("path=$(($_.path) -join '/')") }
|
||||
if ($_.extensions -and $_.extensions.code) { $parts.Add("code=$($_.extensions.code)") }
|
||||
$parts.Add("message=$($_.message)")
|
||||
($parts -join ' ')
|
||||
}) -join '; '
|
||||
throw "GraphQL errors [$Context]: $msgs"
|
||||
}
|
||||
$data
|
||||
}
|
||||
|
||||
# Auto-resolve owner/repo from gh's local context when caller didn't pass them.
|
||||
# Both-or-neither contract: passing exactly one of -Owner/-Repo is rejected,
|
||||
# because mixing a caller-supplied owner with a locally-detected repo (or vice
|
||||
# versa) silently constructs a non-existent or unintended `<Owner>/<Repo>` pair.
|
||||
function Resolve-RepoCoords {
|
||||
param([string]$Owner, [string]$Repo)
|
||||
if ([bool]$Owner -ne [bool]$Repo) {
|
||||
throw "Resolve-RepoCoords: pass both -Owner and -Repo, or neither (got Owner='$Owner' Repo='$Repo'). Partial override would silently mix caller and local repo coordinates."
|
||||
}
|
||||
if ($Owner -and $Repo) { return @{ Owner = $Owner; Repo = $Repo } }
|
||||
$r = Invoke-Gh -GhArgs @('repo','view','--json','owner,name')
|
||||
if ($r.ExitCode -ne 0) {
|
||||
throw "gh repo view failed (exit $($r.ExitCode)): $($r.Stderr). Pass -Owner and -Repo explicitly, or run from inside a gh-detected repo."
|
||||
}
|
||||
$info = ConvertFrom-GhJson -Stdout $r.Stdout -Stderr $r.Stderr -Context 'gh repo view'
|
||||
if (-not ($info -and $info.owner -and $info.owner.login -and $info.name)) {
|
||||
throw "gh repo view returned unexpected shape (missing owner.login or name); cannot auto-resolve repo coordinates. Pass -Owner and -Repo explicitly."
|
||||
}
|
||||
@{ Owner = $info.owner.login; Repo = $info.name }
|
||||
}
|
||||
|
||||
# Format-IsoUtcString — centralise the ISO-8601 UTC normalisation that
|
||||
# 01-request-review.ps1 (events.created_at), 02-check-review-status.ps1
|
||||
# (reviews.submittedAt), and 03-list-open-threads.ps1 (comments.createdAt)
|
||||
# all need to perform. `ConvertFrom-Json` auto-deserialises ISO timestamps
|
||||
# to `[datetime]`, whose default `.ToString()` is culture-dependent and
|
||||
# NOT round-trippable as ISO-8601. Calling `.ToUniversalTime().ToString(
|
||||
# 'yyyy-MM-ddTHH:mm:ssZ')` keeps the on-wire JSON contract identical to
|
||||
# the value GitHub originally sent. If the value is already a string
|
||||
# (e.g., gh returned a raw JSON string), we pass it through verbatim. If
|
||||
# it's null or empty, we return ''.
|
||||
function Format-IsoUtcString {
|
||||
param($Value)
|
||||
if ($null -eq $Value) { return '' }
|
||||
if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
|
||||
return [string]$Value
|
||||
}
|
||||
|
||||
# Run the prerequisite check as a side-effect of dot-sourcing.
|
||||
Assert-GhReady
|
||||
Reference in New Issue
Block a user