mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +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>
319 lines
16 KiB
PowerShell
319 lines
16 KiB
PowerShell
# 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
|