Setting the file. One moment.
Lib · Copilot PR Autopilot · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page 94.10
10 Cleanup
Next
Script 01 Request Review
scripts/ _lib.ps1
PowerShell · 318 lines · 16 KB
15
# referenced via `requestedReviewer.login`) or `copilot-pull-request-reviewer[bot]`
16 # (when referenced via review `author.login`), so callers must accept both.
17 # Centralised here so all step scripts (01 / 02 / 10) stay in sync — if the
18 # canonical login ever changes, change it once.
19 #
20 # Namespaced (`CopilotPrAutopilot_` prefix) + read-only because `_lib.ps1` is
21 # dot-sourced into the caller's scope; a bare name like `$CopilotLoginRegex`
22 # would risk colliding with caller-side variables. `Set-Variable -Force` lets
23 # us re-dot-source in the same session without erroring on the read-only flag.
24 # A back-compat alias `$CopilotReviewerLoginRegex` is preserved so callers
25 # don't have to type the prefix on every read site (and so older snapshots of
26 # 01/02/10 keep working).
27 Set-Variable - Name 'CopilotPrAutopilot_CopilotReviewerLoginRegex' `
28 - Value '(?i)^copilot-pull-request-reviewer(\[bot\])?$' `
29 - Option ReadOnly - Force - Scope Script
30 Set-Variable - Name 'CopilotReviewerLoginRegex' `
31 - Value $CopilotPrAutopilot_CopilotReviewerLoginRegex `
32 - Option ReadOnly - Force - Scope Script
33
34 # Prerequisite check: gh CLI installed AND authenticated.
35 # Fails fast with install/login instructions. Idempotent (once per
36 # PowerShell session).
37 function Assert-GhReady {
38 if ($ script :_GhReady) { return }
39
40 # 1. Installed?
41 $cmd = Get-Command gh - ErrorAction SilentlyContinue
42 if ( -not $cmd) {
43 throw @'
44 copilot-pr-autopilot: prerequisite missing — `gh` CLI is not on PATH.
45
46 Install (one of):
47 - winget install --id GitHub.cli (Windows)
48 - brew install gh (macOS)
49 - sudo apt install gh (Debian/Ubuntu — see https://cli.github.com for other distros)
50 - https://cli.github.com/ (universal installer + download)
51
52 Then `gh auth login` and re-run this command.
53 '@
54 }
55
56 # 2. Authenticated? `gh auth status` exits non-zero when no account
57 # is logged in. Capture stderr to a temp file via the `2>` redirect.
58 $errFile = [ IO.Path ]::GetTempFileName()
59 try {
60 $null = & gh auth status 2> $errFile
61 $ec = $LASTEXITCODE
62 if ($ec -ne 0 ) {
63 $err = ''
64 if ( Test-Path - LiteralPath $errFile) {
65 $err = ( Get-Content - Raw - LiteralPath $errFile - ErrorAction SilentlyContinue)
66 if ( $null -eq $err) { $err = '' }
67 }
68 throw @"
69 copilot-pr-autopilot: prerequisite missing — `` gh `` CLI is not authenticated.
70
71 Run:
72 gh auth login
73
74 Then re-run this command.
75
76 `` gh auth status `` reported:
77 $( $err.Trim () )
78 "@
79 }
80 } finally {
81 if ( Test-Path - LiteralPath $errFile) {
82 Remove-Item - LiteralPath $errFile - ErrorAction SilentlyContinue
83 }
84 }
85
86 $ script :_GhReady = $true
87 }
88
89 # Single-invocation gh wrapper. Captures stdout + stderr separately
90 # via the `2>` redirect to a temp file. Returns ExitCode/Stdout/Stderr
91 # so callers never have to re-invoke `gh` just to recover stderr, and
92 # never feed stderr into `ConvertFrom-Json` on success.
93 #
94 # Note on -WhatIf: PowerShell's `2>` redirect goes through Out-File,
95 # which respects $WhatIfPreference at the caller scope. The bundled
96 # `10-cleanup-outdated.ps1` therefore uses an explicit `-DryRun`
97 # switch instead of [CmdletBinding(SupportsShouldProcess)], so this
98 # helper never sees a leaked WhatIfPreference and never prints
99 # "Performing the operation Output to File" noise.
100 function Invoke-Gh {
101 param ([ Parameter ( Mandatory )][ string []]$GhArgs)
102
103 # Cross-version safety: Windows PowerShell 5.1's native-command
104 # argument passer mangles arguments that contain embedded double-quote
105 # characters (long-standing bug, only fully fixed in PS 7.3+ via
106 # $PSNativeCommandArgumentPassing). GraphQL queries/mutations routinely
107 # embed quoted strings (comments, default values, enum-like literals
108 # such as `["copilot-pull-request-reviewer"]`), so passing them as
109 # command-line values (`-f field=<body>`) round-trips correctly in
110 # pwsh 7 but silently mis-splits under 5.1 (e.g., gh CLI reports
111 # "accepts 1 arg(s), received 7" or 'Expected type "number", but it
112 # was malformed: "-pull"'). To work identically in both runtimes, any
113 # `-f field=<body>` or `-F field=<body>` pair whose body contains `"`
114 # is rewritten to `-F field=@<tempfile>` (the body is written to disk
115 # first; `gh` reads it from the file and the value never appears on
116 # the command line).
117 #
118 # IMPORTANT typing note (verified live with gh api graphql):
119 # * `gh -F field=@<file>` reads the file content and applies type
120 # inference (digit→Number, true/false→Boolean, null→null, else
121 # String).
122 # * `gh -f field=@<file>` does NOT expand `@<file>` — it sends the
123 # literal string `@<file>` as the value (gh's `-f` skips the @
124 # prefix entirely). So `-f` is NOT a viable tempfile carrier;
125 # the rewrite MUST use `-F`.
126 #
127 # Safety of the unconditional rewrite-to-`-F`:
128 # * Query bodies (large GraphQL strings) never look like Number /
129 # Boolean / null after inference, so they round-trip as String.
130 # * Reply bodies typed by humans (08-reply-and-resolve) almost
131 # never look like exactly `"true"`, `"false"`, `"null"`, or a
132 # bare digit run — and if they do AND they also contain `"`
133 # (the rewrite trigger), the resulting coercion would be a
134 # loud GraphQL `String!` type error, not silent data loss.
135 # Tempfiles are cleaned up in `finally`.
136 $rewritten = [ System.Collections.Generic.List [ string ]]::new()
137 $tempFiles = [ System.Collections.Generic.List [ string ]]::new()
138 for ($i = 0 ; $i -lt $GhArgs.Count; $i ++ ) {
139 $a = $GhArgs[$i]
140 # Rewrite both `-f field=<body>` and `-F field=<body>` whose body
141 # contains `"` — same PS 5.1 native-arg splitting bug applies to
142 # both. The rewrite ALWAYS emits `-F` because `gh -f field=@file`
143 # does not expand `@file` (only `-F` does — verified live). The
144 # file content is then sent as a String GraphQL variable for any
145 # body that doesn't look like a Number/Boolean/null (i.e., every
146 # real-world query body and reply body in this skill).
147 if (($a -eq '-f' -or $a -eq '-F' ) -and ($i + 1 ) -lt $GhArgs.Count) {
148 $next = $GhArgs[$i + 1 ]
149 $eqIdx = $next.IndexOf( '=' )
150 if ($eqIdx -gt 0 -and $next.Substring($eqIdx + 1 ).Contains( '"' )) {
151 $field = $next.Substring( 0 , $eqIdx)
152 $body = $next.Substring($eqIdx + 1 )
153 $tf = [ IO.Path ]::GetTempFileName()
154 [ void ]$tempFiles.Add($tf)
155 # UTF-8 without BOM so `gh` reads the body verbatim
156 [ IO.File ]::WriteAllText($tf , $body , [ System.Text.UTF8Encoding ]::new( $false ))
157 [ void ]$rewritten.Add( '-F' )
158 [ void ]$rewritten.Add( " $field =@ $tf " )
159 $i ++
160 continue
161 }
162 }
163 [ void ]$rewritten.Add($a)
164 }
165
166 $errFile = [ IO.Path ]::GetTempFileName()
167 try {
168 $finalArgs = $rewritten.ToArray()
169 # Localise $ErrorActionPreference to 'Continue' around the native
170 # `gh` call. Why: callers set `$ErrorActionPreference = 'Stop'` at
171 # script scope, and under PowerShell 5.1 that combination converts
172 # any line `gh` writes to stderr into a `NativeCommandError` that
173 # aborts the script BEFORE we get to inspect `$LASTEXITCODE`. PS 7+
174 # changed native-stderr handling and is unaffected. By keeping the
175 # native call at 'Continue' we always return the
176 # `@{ExitCode;Stdout;Stderr}` object on both runtimes, so callers
177 # see the same structured error and can emit the same actionable
178 # message (e.g. the "click UI 🔄" guidance in 01-request-review).
179 $prevEAP = $ErrorActionPreference
180 $ErrorActionPreference = 'Continue'
181 try {
182 $out = & gh @finalArgs 2> $errFile
183 $ec = $LASTEXITCODE
184 } finally {
185 $ErrorActionPreference = $prevEAP
186 }
187 $err = ''
188 if ( Test-Path - LiteralPath $errFile) {
189 $err = ( Get-Content - Raw - LiteralPath $errFile - ErrorAction SilentlyContinue)
190 if ( $null -eq $err) { $err = '' }
191 }
192 # Preserve gh's stdout content without PowerShell formatting.
193 # `Out-String` would append a trailing newline and apply console
194 # formatting widths, which can subtly break callers that
195 # regex/JSON-parse the result. `& gh` returns one array entry per
196 # line (with the line terminator already stripped); we re-join with
197 # "`n" and no trailing newline, so the result is content-preserving
198 # but normalized to LF (not byte-identical to the original stream).
199 # Callers add a trailing newline if they need one.
200 $stdout = if ( $null -eq $out) { '' }
201 elseif ($out -is [ string ]) { $out }
202 else { ($out | ForEach-Object { [ string ] $_ }) -join " `n " }
203 [ pscustomobject ] @ { ExitCode = $ec; Stdout = $stdout; Stderr = $err }
204 } finally {
205 if ( Test-Path - LiteralPath $errFile) {
206 Remove-Item - LiteralPath $errFile - ErrorAction SilentlyContinue
207 }
208 foreach ($tf in $tempFiles) {
209 if ($tf -and ( Test-Path - LiteralPath $tf)) {
210 Remove-Item - LiteralPath $tf - ErrorAction SilentlyContinue
211 }
212 }
213 }
214 }
215
216 # Wrap ConvertFrom-Json so a non-JSON / empty stdout failure carries
217 # the calling $Context plus trimmed stdout/stderr — without this
218 # callers see a bare "Unexpected character encountered" exception
219 # that doesn't say which gh command produced the bad output.
220 # Centralised so the preview limits + format stay consistent across
221 # Invoke-GhGraphQL, Resolve-RepoCoords, and any future call sites.
222 function ConvertFrom-GhJson {
223 param (
224 [ Parameter ( Mandatory )][ AllowEmptyString ()][ AllowNull ()][ string ]$Stdout ,
225 [ AllowEmptyString ()][ AllowNull ()][ string ]$Stderr ,
226 [ Parameter ( Mandatory )][ string ]$Context ,
227 [ int ]$PreviewChars = 500
228 )
229 try {
230 # Use -InputObject (not pipeline form `$Stdout | ConvertFrom-Json`):
231 # on Windows PowerShell 5.1, returning the pipeline form from inside
232 # a function preserves the parsed array as a single object rather
233 # than unrolling it. Callers then see `.Count == 1` for a JSON
234 # array of N items, and `$result[0]` is the inner array. The
235 # parameter form returns the same parsed structure but PowerShell
236 # 5.1 unrolls it correctly on function return.
237 return ( ConvertFrom-Json - InputObject $Stdout - ErrorAction Stop)
238 } catch {
239 $stdoutPreview = if ($Stdout) { $Stdout.Substring( 0 , [ Math ]::Min($PreviewChars , $Stdout.Length)) } else { '(empty)' }
240 $stderrPreview = if ($Stderr) { $Stderr.Substring( 0 , [ Math ]::Min($PreviewChars , $Stderr.Length)) } else { '(empty)' }
241 throw " $Context returned non-JSON: $( $_ .Exception.Message ) `n stdout (<= ${PreviewChars} chars): $stdoutPreview `n stderr (<= ${PreviewChars} chars): $stderrPreview "
242 }
243 }
244
245 # Wrapper around Invoke-Gh for `gh api graphql` that throws on either
246 # non-zero exit OR a GraphQL `errors` array in the response body.
247 # Cross-version safety for embedded quotes in queries is handled by
248 # Invoke-Gh's automatic `-f field=<body-with-quotes>` → tempfile rewrite.
249 function Invoke-GhGraphQL {
250 param (
251 [ Parameter ( Mandatory )][ string []]$GhArgs ,
252 [ Parameter ( Mandatory )][ string ]$Context
253 )
254 $r = Invoke-Gh - GhArgs ( @ ( 'api' , 'graphql' ) + $GhArgs)
255 if ($r.ExitCode -ne 0 ) {
256 throw "gh api graphql failed (exit $( $r.ExitCode ) ) [ $Context ]: $( $r.Stderr ) "
257 }
258 $data = ConvertFrom-GhJson - Stdout $r.Stdout - Stderr $r.Stderr - Context "gh api graphql [ $Context ]"
259 if ($data.errors) {
260 # Aggregate type + path + extensions.code alongside .message so
261 # callers see actionable failures without re-running with extra
262 # logging. GitHub commonly returns type=NOT_FOUND / FORBIDDEN /
263 # RATE_LIMITED and extensions.code=undefinedField etc.; dropping
264 # them turns a clear failure ("FORBIDDEN at /repository/pullRequest")
265 # into an opaque message-only string.
266 $msgs = ($data.errors | ForEach-Object {
267 $parts = New-Object System.Collections.Generic.List[ string ]
268 if ( $_ .type) { $parts.Add( "type= $( $_ .type ) " ) }
269 if ( $_ .path) { $parts.Add( "path= $( ( $_ .path ) -join '/' ) " ) }
270 if ( $_ .extensions -and $_ .extensions.code) { $parts.Add( "code= $( $_ .extensions.code ) " ) }
271 $parts.Add( "message= $( $_ .message ) " )
272 ($parts -join ' ' )
273 }) -join '; '
274 throw "GraphQL errors [ $Context ]: $msgs "
275 }
276 $data
277 }
278
279 # Auto-resolve owner/repo from gh's local context when caller didn't pass them.
280 # Both-or-neither contract: passing exactly one of -Owner/-Repo is rejected,
281 # because mixing a caller-supplied owner with a locally-detected repo (or vice
282 # versa) silently constructs a non-existent or unintended `<Owner>/<Repo>` pair.
283 function Resolve-RepoCoords {
284 param ([ string ]$Owner , [ string ]$Repo)
285 if ([ bool ]$Owner -ne [ bool ]$Repo) {
286 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."
287 }
288 if ($Owner -and $Repo) { return @ { Owner = $Owner; Repo = $Repo } }
289 $r = Invoke-Gh - GhArgs @ ( 'repo' , 'view' , '--json' , 'owner,name' )
290 if ($r.ExitCode -ne 0 ) {
291 throw "gh repo view failed (exit $( $r.ExitCode ) ): $( $r.Stderr ) . Pass -Owner and -Repo explicitly, or run from inside a gh-detected repo."
292 }
293 $info = ConvertFrom-GhJson - Stdout $r.Stdout - Stderr $r.Stderr - Context 'gh repo view'
294 if ( -not ($info -and $info.owner -and $info.owner.login -and $info.name)) {
295 throw "gh repo view returned unexpected shape (missing owner.login or name); cannot auto-resolve repo coordinates. Pass -Owner and -Repo explicitly."
296 }
297 @ { Owner = $info.owner.login; Repo = $info.name }
298 }
299
300 # Format-IsoUtcString — centralise the ISO-8601 UTC normalisation that
301 # 01-request-review.ps1 (events.created_at), 02-check-review-status.ps1
302 # (reviews.submittedAt), and 03-list-open-threads.ps1 (comments.createdAt)
303 # all need to perform. `ConvertFrom-Json` auto-deserialises ISO timestamps
304 # to `[datetime]`, whose default `.ToString()` is culture-dependent and
305 # NOT round-trippable as ISO-8601. Calling `.ToUniversalTime().ToString(
306 # 'yyyy-MM-ddTHH:mm:ssZ')` keeps the on-wire JSON contract identical to
307 # the value GitHub originally sent. If the value is already a string
308 # (e.g., gh returned a raw JSON string), we pass it through verbatim. If
309 # it's null or empty, we return ''.
310 function Format-IsoUtcString {
311 param ($Value)
312 if ( $null -eq $Value) { return '' }
313 if ($Value -is [ datetime ]) { return $Value.ToUniversalTime().ToString( 'yyyy-MM-ddTHH:mm:ssZ' ) }
314 return [ string ]$Value
315 }
316
317 # Run the prerequisite check as a side-effect of dot-sourcing.
318 Assert-GhReady