1+ # Requires -Version 7.0
2+ [CmdletBinding ()]
3+ param (
4+ [int ] $PullRequestNumber ,
5+
6+ [string ] $Owner = ' PSModule' ,
7+
8+ [string ] $Repository = ' GoogleFonts' ,
9+
10+ [ValidateRange (5 , 3600 )]
11+ [int ] $PollIntervalSeconds = 30
12+ )
13+
14+ Set-StrictMode - Version Latest
15+ $ErrorActionPreference = ' Stop'
16+
17+ function Invoke-GitHubJson {
18+ <#
19+ . SYNOPSIS
20+ Runs a GitHub CLI command and parses JSON output.
21+ #>
22+ param (
23+ [Parameter (Mandatory )]
24+ [string []] $Arguments
25+ )
26+
27+ $output = & gh @Arguments 2>&1
28+ if ($LASTEXITCODE -ne 0 ) {
29+ $joined = $output -join [Environment ]::NewLine
30+ throw " gh $ ( $Arguments -join ' ' ) failed with exit code $LASTEXITCODE .`n $joined "
31+ }
32+
33+ $text = $output -join [Environment ]::NewLine
34+ if ([string ]::IsNullOrWhiteSpace($text )) {
35+ return $null
36+ }
37+
38+ return $text | ConvertFrom-Json
39+ }
40+
41+ function Get-ItemCount {
42+ <#
43+ . SYNOPSIS
44+ Returns a safe count for null, scalar, or array inputs.
45+ #>
46+ param ([object ] $InputObject )
47+
48+ return @ ($InputObject ).Count
49+ }
50+
51+ function Get-ActivePullRequestNumber {
52+ param ([string ] $Repo )
53+
54+ $pr = Invoke-GitHubJson - Arguments @ (' pr' , ' view' , ' --repo' , $Repo , ' --json' , ' number' )
55+ if ($null -eq $pr -or $null -eq $pr.number ) {
56+ throw ' Could not detect an active pull request. Provide -PullRequestNumber explicitly.'
57+ }
58+
59+ return [int ] $pr.number
60+ }
61+
62+ function Get-UnresolvedCopilotThreads {
63+ param (
64+ [string ] $RepoOwner ,
65+ [string ] $RepoName ,
66+ [int ] $PrNumber
67+ )
68+
69+ $query = ' query($owner:String!, $repo:String!, $pr:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$pr) { reviewThreads(first:100) { nodes { isResolved comments(first:50) { nodes { author { login } body url createdAt } } } } } } }'
70+ $payload = Invoke-GitHubJson - Arguments @ (
71+ ' api' ,
72+ ' graphql' ,
73+ ' -f' , " query=$query " ,
74+ ' -F' , " owner=$RepoOwner " ,
75+ ' -F' , " repo=$RepoName " ,
76+ ' -F' , " pr=$PrNumber "
77+ )
78+
79+ $threads = @ ($payload.data.repository.pullRequest.reviewThreads.nodes )
80+ if (-not $threads ) {
81+ return @ ()
82+ }
83+
84+ $copilotThreads = foreach ($thread in $threads ) {
85+ if ($thread.isResolved ) {
86+ continue
87+ }
88+
89+ $comments = @ ($thread.comments.nodes )
90+ if (-not $comments ) {
91+ continue
92+ }
93+
94+ $hasCopilotComment = $false
95+ foreach ($comment in $comments ) {
96+ $author = $comment.author.login
97+ if ($author -match ' copilot' ) {
98+ $hasCopilotComment = $true
99+ break
100+ }
101+ }
102+
103+ if ($hasCopilotComment ) {
104+ $latestComment = $comments | Sort-Object createdAt - Descending | Select-Object - First 1
105+ [pscustomobject ]@ {
106+ Url = $latestComment.url
107+ CreatedAt = $latestComment.createdAt
108+ Preview = if ($latestComment.body.Length -gt 120 ) { " $ ( $latestComment.body.Substring (0 , 120 )) ..." } else { $latestComment.body }
109+ }
110+ }
111+ }
112+
113+ return @ ($copilotThreads )
114+ }
115+
116+ function Get-PullRequestSnapshot {
117+ param (
118+ [string ] $RepoOwner ,
119+ [string ] $RepoName ,
120+ [int ] $PrNumber
121+ )
122+
123+ $repo = " $RepoOwner /$RepoName "
124+ $pr = Invoke-GitHubJson - Arguments @ (
125+ ' pr' ,
126+ ' view' ,
127+ " $PrNumber " ,
128+ ' --repo' ,
129+ $repo ,
130+ ' --json' ,
131+ ' url,headRefOid,reviewRequests,reviews'
132+ )
133+
134+ $headSha = $pr.headRefOid
135+ $commit = Invoke-GitHubJson - Arguments @ (' api' , " repos/$repo /commits/$headSha " )
136+ $headCommitTime = [DateTimeOffset ]::Parse($commit.commit.committer.date )
137+
138+ $allReviews = @ ($pr.reviews )
139+ $copilotReviews = @ ($allReviews | Where-Object {
140+ $_.author.login -match ' copilot'
141+ })
142+ $latestCopilotReview = $copilotReviews | Sort-Object submittedAt - Descending | Select-Object - First 1
143+
144+ $reviewRequests = @ ($pr.reviewRequests )
145+ $pendingCopilotRequest = @ ($reviewRequests | Where-Object { $_.login -match ' copilot' })
146+
147+ $latestCopilotReviewTime = $null
148+ if ($latestCopilotReview ) {
149+ $latestCopilotReviewTime = [DateTimeOffset ]::Parse($latestCopilotReview.submittedAt )
150+ }
151+
152+ $copilotReviewedCurrentHead = $false
153+ if ($null -ne $latestCopilotReviewTime -and $latestCopilotReviewTime -ge $headCommitTime ) {
154+ $copilotReviewedCurrentHead = $true
155+ }
156+
157+ $unresolvedCopilotThreads = Get-UnresolvedCopilotThreads - RepoOwner $RepoOwner - RepoName $RepoName - PrNumber $PrNumber
158+
159+ $checks = @ ()
160+ try {
161+ $checks = @ (Invoke-GitHubJson - Arguments @ (
162+ ' pr' ,
163+ ' checks' ,
164+ " $PrNumber " ,
165+ ' --repo' ,
166+ $repo ,
167+ ' --json' ,
168+ ' name,state,bucket,workflow,link'
169+ ))
170+ } catch {
171+ # Some repositories can briefly return no checks while workflow data is still being prepared.
172+ $checks = @ ()
173+ }
174+
175+ $pendingStates = @ (' PENDING' , ' IN_PROGRESS' , ' QUEUED' , ' WAITING' , ' REQUESTED' )
176+ $failedStates = @ (' FAILURE' , ' ERROR' , ' TIMED_OUT' , ' ACTION_REQUIRED' , ' CANCELLED' , ' STARTUP_FAILURE' )
177+
178+ $pendingChecks = @ ($checks | Where-Object {
179+ $_.bucket -eq ' pending' -or $pendingStates -contains $_.state
180+ })
181+ $failedChecks = @ ($checks | Where-Object {
182+ $_.bucket -eq ' fail' -or $failedStates -contains $_.state
183+ })
184+
185+ $checkCount = Get-ItemCount - InputObject $checks
186+ $pendingCheckCount = Get-ItemCount - InputObject $pendingChecks
187+ $failedCheckCount = Get-ItemCount - InputObject $failedChecks
188+ $copilotThreadCount = Get-ItemCount - InputObject $unresolvedCopilotThreads
189+ $pendingCopilotRequestCount = Get-ItemCount - InputObject $pendingCopilotRequest
190+
191+ $ciGreen = ($checkCount -gt 0 -and $pendingCheckCount -eq 0 -and $failedCheckCount -eq 0 )
192+ $copilotNoComments = ($copilotThreadCount -eq 0 )
193+ $copilotReviewComplete = ($pendingCopilotRequestCount -eq 0 -and $copilotReviewedCurrentHead )
194+
195+ [pscustomobject ]@ {
196+ PullRequestNumber = $PrNumber
197+ PullRequestUrl = $pr.url
198+ HeadSha = $headSha
199+ HeadCommitTime = $headCommitTime
200+ LatestCopilotReviewTime = $latestCopilotReviewTime
201+ CopilotReviewedCurrentHead = $copilotReviewedCurrentHead
202+ PendingCopilotRequests = @ ($pendingCopilotRequest )
203+ UnresolvedCopilotThreads = @ ($unresolvedCopilotThreads )
204+ Checks = @ ($checks )
205+ PendingChecks = @ ($pendingChecks )
206+ FailedChecks = @ ($failedChecks )
207+ CiGreen = $ciGreen
208+ CopilotNoComments = $copilotNoComments
209+ CopilotReviewComplete = $copilotReviewComplete
210+ Ready = ($ciGreen -and $copilotNoComments -and $copilotReviewComplete )
211+ }
212+ }
213+
214+ $repoRef = " $Owner /$Repository "
215+ if (-not $PullRequestNumber ) {
216+ $PullRequestNumber = Get-ActivePullRequestNumber - Repo $repoRef
217+ }
218+
219+ Write-Host " Watching PR #$PullRequestNumber in $repoRef "
220+ Write-Host " This loop only exits when CI is green and Copilot has no unresolved comments for the latest head commit."
221+
222+ $round = 0
223+ while ($true ) {
224+ $round ++
225+ $now = (Get-Date ).ToString(' u' )
226+ Write-Host ' '
227+ Write-Host " [$now ] Poll round $round "
228+
229+ $snapshot = Get-PullRequestSnapshot - RepoOwner $Owner - RepoName $Repository - PrNumber $PullRequestNumber
230+
231+ Write-Host " PR: $ ( $snapshot.PullRequestUrl ) "
232+ Write-Host " Head SHA: $ ( $snapshot.HeadSha ) "
233+ Write-Host " CI green: $ ( $snapshot.CiGreen ) "
234+ Write-Host " Copilot review complete for head: $ ( $snapshot.CopilotReviewComplete ) "
235+ Write-Host " Copilot unresolved thread count: $ ( $snapshot.UnresolvedCopilotThreads.Count ) "
236+
237+ if ($snapshot.FailedChecks.Count -gt 0 ) {
238+ Write-Host ' Failing checks:'
239+ foreach ($check in $snapshot.FailedChecks ) {
240+ Write-Host " - $ ( $check.name ) [$ ( $check.state ) ]"
241+ }
242+ }
243+
244+ if ($snapshot.PendingChecks.Count -gt 0 ) {
245+ Write-Host ' Pending checks:'
246+ foreach ($check in $snapshot.PendingChecks ) {
247+ Write-Host " - $ ( $check.name ) [$ ( $check.state ) ]"
248+ }
249+ }
250+
251+ if (-not $snapshot.CopilotReviewComplete ) {
252+ if ($snapshot.PendingCopilotRequests.Count -gt 0 ) {
253+ Write-Host ' Copilot review is still requested and pending.'
254+ }
255+
256+ if (-not $snapshot.CopilotReviewedCurrentHead ) {
257+ if ($null -eq $snapshot.LatestCopilotReviewTime ) {
258+ Write-Host ' Copilot has not posted a review yet for this PR.'
259+ } else {
260+ Write-Host " Latest Copilot review: $ ( $snapshot.LatestCopilotReviewTime.ToString (' u' )) "
261+ Write-Host " Latest head commit: $ ( $snapshot.HeadCommitTime.ToString (' u' )) "
262+ }
263+ }
264+ }
265+
266+ if ($snapshot.UnresolvedCopilotThreads.Count -gt 0 ) {
267+ Write-Host ' Unresolved Copilot threads:'
268+ foreach ($thread in $snapshot.UnresolvedCopilotThreads ) {
269+ Write-Host " - $ ( $thread.Url ) "
270+ }
271+ }
272+
273+ if ($snapshot.Ready ) {
274+ Write-Host ' '
275+ Write-Host ' All gates are green. CI is successful and Copilot has no unresolved comments.'
276+ break
277+ }
278+
279+ Write-Host " Waiting $PollIntervalSeconds seconds before checking comments and pipeline status again..."
280+ Start-Sleep - Seconds $PollIntervalSeconds
281+ }
0 commit comments