Skip to content

Commit 1389cac

Browse files
feat(skills): recover Detect-CopilotFollowUpPR.ps1 from unmerged PR (#493)
* feat(skills): recover Detect-CopilotFollowUpPR.ps1 from unmerged PR Recovers Copilot follow-up PR detection implementation that was incorrectly closed without merge in PRs #202/#203. The triage bot mistakenly identified the content as "superseded" because the template had inline bash examples, but the actual PowerShell skill script was never delivered to main. ## Recovered Files - Detect-CopilotFollowUpPR.ps1 (268 lines) - Pester tests (12 tests, all pass) - SKILL.md documentation updates ## Features - Detects `copilot/sub-pr-{N}` branch pattern - Categorizes: DUPLICATE, LIKELY_DUPLICATE, POSSIBLE_SUPPLEMENTAL - Returns structured JSON with recommendations - Integrates with GitHubHelpers module ## Improvements Over Original - Uses Resolve-RepoParams for Owner/Repo inference - Single jq filter in Get-CopilotAnnouncement (issue #238) - Proper $LASTEXITCODE checking after gh commands - Updated bot username to copilot-swe-agent[bot] Reopens #238, Reopens #293 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Address gemini-code-assist review comments for Detect-CopilotFollowUpPR Comment-ID: 2650973653 Comment-ID: 2650973655 Comment-ID: 2650973657 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: rjmurillo[bot] <rjmurillo-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c1aa253 commit 1389cac

File tree

3 files changed

+430
-0
lines changed

3 files changed

+430
-0
lines changed

.claude/skills/github/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Need GitHub data?
3434
├─ Unique reviewers → Get-PRReviewers.ps1
3535
├─ Unaddressed bot comments → Get-UnaddressedComments.ps1
3636
├─ PR merged check → Test-PRMerged.ps1
37+
├─ Copilot follow-up PRs → Detect-CopilotFollowUpPR.ps1
3738
├─ Issue info → Get-IssueContext.ps1
3839
└─ Need to take action?
3940
├─ Create issue → New-Issue.ps1
@@ -63,6 +64,7 @@ Need GitHub data?
6364
| `Get-UnaddressedComments.ps1` | Bot comments needing attention | `-PullRequest` |
6465
| `Get-UnresolvedReviewThreads.ps1` | Unresolved thread IDs | `-PullRequest` |
6566
| `Test-PRMerged.ps1` | Check if PR is merged | `-PullRequest` |
67+
| `Detect-CopilotFollowUpPR.ps1` | Detect Copilot follow-up PRs | `-PRNumber`, `-Owner`, `-Repo` |
6668
| `Post-PRCommentReply.ps1` | Thread-preserving replies | `-PullRequest`, `-CommentId`, `-Body` |
6769
| `Resolve-PRReviewThread.ps1` | Mark threads resolved | `-ThreadId` or `-PullRequest -All` |
6870
| `Invoke-PRCommentProcessing.ps1` | Process AI triage output | `-PRNumber`, `-Verdict`, `-FindingsJson` |
@@ -96,6 +98,9 @@ pwsh -NoProfile scripts/pr/Get-PRContext.ps1 -PullRequest 50 -IncludeChangedFile
9698
# Check if PR is merged before starting work
9799
pwsh -NoProfile scripts/pr/Test-PRMerged.ps1 -PullRequest 50
98100
101+
# Detect Copilot follow-up PRs
102+
pwsh -NoProfile scripts/pr/Detect-CopilotFollowUpPR.ps1 -PRNumber 50
103+
99104
# Reply to review comment (thread-preserving)
100105
pwsh -NoProfile scripts/pr/Post-PRCommentReply.ps1 -PullRequest 50 -CommentId 123456 -Body "Fixed."
101106
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
<#
2+
.SYNOPSIS
3+
Detect and analyze Copilot follow-up PR patterns.
4+
5+
.DESCRIPTION
6+
Identifies when Copilot creates follow-up PRs after PR comment replies.
7+
Categorizes follow-ups as DUPLICATE, SUPPLEMENTAL, or INDEPENDENT.
8+
Returns structured data for decision-making.
9+
10+
.PARAMETER PRNumber
11+
The original PR number to check for follow-ups.
12+
13+
.PARAMETER Owner
14+
Repository owner. Inferred from git remote if not provided.
15+
16+
.PARAMETER Repo
17+
Repository name. Inferred from git remote if not provided.
18+
19+
.EXAMPLE
20+
./Detect-CopilotFollowUpPR.ps1 -PRNumber 32
21+
./Detect-CopilotFollowUpPR.ps1 -PRNumber 32 -Owner rjmurillo -Repo ai-agents
22+
23+
.NOTES
24+
Pattern: Copilot creates PR with branch copilot/sub-pr-{original_pr}
25+
Targets original PR's base branch (not main)
26+
Posts announcement: "I've opened a new pull request, #{number}"
27+
28+
Exit Codes: 0=Success, 1=Invalid params, 3=API error, 4=Not authenticated
29+
#>
30+
31+
[CmdletBinding()]
32+
param(
33+
[Parameter(Mandatory = $true)]
34+
[int]$PRNumber,
35+
36+
[string]$Owner,
37+
38+
[string]$Repo
39+
)
40+
41+
Set-StrictMode -Version Latest
42+
$ErrorActionPreference = 'Stop'
43+
44+
Import-Module (Join-Path $PSScriptRoot ".." ".." "modules" "GitHubHelpers.psm1") -Force
45+
46+
Assert-GhAuthenticated
47+
$resolved = Resolve-RepoParams -Owner $Owner -Repo $Repo
48+
$script:Owner = $resolved.Owner
49+
$script:Repo = $resolved.Repo
50+
51+
function Test-FollowUpPattern {
52+
<#
53+
.SYNOPSIS
54+
Check if a PR matches Copilot follow-up pattern.
55+
#>
56+
param(
57+
[Parameter(Mandatory = $true)]
58+
[object]$PR
59+
)
60+
61+
$headRef = $PR.headRefName
62+
$pattern = "copilot/sub-pr-\d+"
63+
64+
return $headRef -match $pattern
65+
}
66+
67+
function Get-CopilotAnnouncement {
68+
<#
69+
.SYNOPSIS
70+
Find Copilot's announcement comment on the original PR.
71+
#>
72+
param(
73+
[Parameter(Mandatory = $true)]
74+
[int]$PRNumber
75+
)
76+
77+
# Single jq filter for efficiency (addresses issue #238)
78+
$announcement = gh api "repos/$script:Owner/$script:Repo/issues/$PRNumber/comments" `
79+
--jq '.[] | select(.user.login == "copilot-swe-agent[bot]" and (.body | contains("opened a new pull request"))) | {id: .id, body: .body, created_at: .created_at}' 2>$null
80+
81+
if ($LASTEXITCODE -ne 0 -or $null -eq $announcement -or $announcement -eq '') {
82+
return $null
83+
}
84+
85+
return $announcement
86+
}
87+
88+
function Get-FollowUpPRDiff {
89+
<#
90+
.SYNOPSIS
91+
Get unified diff for follow-up PR.
92+
#>
93+
param(
94+
[Parameter(Mandatory = $true)]
95+
[int]$FollowUpPRNumber
96+
)
97+
98+
$diff = gh pr diff $FollowUpPRNumber --repo "$script:Owner/$script:Repo" 2>$null
99+
if ($LASTEXITCODE -ne 0) {
100+
return ''
101+
}
102+
return $diff
103+
}
104+
105+
function Get-OriginalPRCommits {
106+
<#
107+
.SYNOPSIS
108+
Get commits from original PR for comparison.
109+
#>
110+
param(
111+
[Parameter(Mandatory = $true)]
112+
[int]$PRNumber
113+
)
114+
115+
$prJson = gh pr view $PRNumber --repo "$script:Owner/$script:Repo" --json commits,baseRefName,headRefName 2>$null
116+
if ($LASTEXITCODE -ne 0 -or $null -eq $prJson) {
117+
return @()
118+
}
119+
120+
$pr = $prJson | ConvertFrom-Json
121+
if ($null -eq $pr) {
122+
return @()
123+
}
124+
125+
$commits = @()
126+
try {
127+
$commitData = gh api "repos/$script:Owner/$script:Repo/commits" `
128+
--jq ".[] | select(.commit.message | contains(\"PR $PRNumber\") or contains(\"Comment-ID\"))" 2>$null
129+
if ($LASTEXITCODE -eq 0 -and $commitData) {
130+
$commits = @($commitData | ConvertFrom-Json -ErrorAction SilentlyContinue)
131+
}
132+
}
133+
catch {
134+
# No specific commits found, continue
135+
}
136+
137+
return $commits
138+
}
139+
140+
function Compare-DiffContent {
141+
<#
142+
.SYNOPSIS
143+
Compare follow-up diff to original changes.
144+
Returns likelihood percentage of being duplicate.
145+
#>
146+
param(
147+
[Parameter(Mandatory = $true)]
148+
[AllowEmptyString()]
149+
[string]$FollowUpDiff,
150+
151+
[Parameter(Mandatory = $true)]
152+
[AllowEmptyCollection()]
153+
[object[]]$OriginalCommits
154+
)
155+
156+
if ([string]::IsNullOrWhiteSpace($FollowUpDiff)) {
157+
# Empty or whitespace-only diff = duplicate (no changes to add)
158+
return @{similarity = 100; category = 'DUPLICATE'; reason = 'Follow-up PR contains no changes' }
159+
}
160+
161+
# Count file changes in follow-up
162+
$followUpFiles = @($FollowUpDiff -split '^diff --git' | Where-Object { $_.Trim() } | Measure-Object).Count
163+
164+
# If follow-up has 1 file and original also modified that file, likely duplicate
165+
if ($followUpFiles -eq 1 -and $OriginalCommits.Count -gt 0) {
166+
return @{similarity = 85; category = 'LIKELY_DUPLICATE'; reason = 'Single file change matching original scope' }
167+
}
168+
169+
# If follow-up has no file changes but adds comments/replies
170+
171+
# Multiple files or complex diff = might be supplemental
172+
return @{similarity = 40; category = 'POSSIBLE_SUPPLEMENTAL'; reason = 'Multiple file changes suggest additional work' }
173+
}
174+
175+
function Invoke-FollowUpDetection {
176+
<#
177+
.SYNOPSIS
178+
Main detection logic.
179+
#>
180+
181+
Write-Verbose "Detecting Copilot follow-up PRs for PR #$PRNumber..."
182+
183+
# Step 1: Query for follow-up PR matching pattern
184+
$followUpPRQuery = "head:copilot/sub-pr-$PRNumber"
185+
Write-Verbose "Searching for: $followUpPRQuery"
186+
187+
$followUpPRs = @()
188+
try {
189+
$prJson = gh pr list --repo "$script:Owner/$script:Repo" --state open --search $followUpPRQuery `
190+
--json number,title,body,headRefName,baseRefName,state,author,createdAt 2>$null
191+
if ($LASTEXITCODE -eq 0 -and $prJson) {
192+
$prData = $prJson | ConvertFrom-Json
193+
if ($prData -is [array]) {
194+
$followUpPRs = @($prData)
195+
}
196+
elseif ($null -ne $prData) {
197+
$followUpPRs = @($prData)
198+
}
199+
}
200+
}
201+
catch {
202+
Write-Verbose "Info: No follow-up PRs found (query may not match any results)"
203+
}
204+
205+
if ($followUpPRs.Count -eq 0) {
206+
return @{
207+
found = $false
208+
followUpPRs = @()
209+
announcement = $null
210+
analysis = $null
211+
recommendation = 'NO_ACTION_NEEDED'
212+
message = 'No follow-up PRs detected'
213+
}
214+
}
215+
216+
Write-Verbose "Found $($followUpPRs.Count) follow-up PR(s)"
217+
218+
# Step 2: Verify Copilot announcement
219+
$announcement = Get-CopilotAnnouncement -PRNumber $PRNumber
220+
if ($null -eq $announcement -or $announcement -eq '') {
221+
Write-Warning " No Copilot announcement found, but follow-up PR exists"
222+
}
223+
else {
224+
Write-Verbose "Verified Copilot announcement"
225+
}
226+
227+
# Step 3: Analyze each follow-up PR
228+
$analysis = @()
229+
foreach ($followUp in $followUpPRs) {
230+
$prNum = $followUp.number
231+
Write-Verbose "Analyzing follow-up PR #$prNum..."
232+
233+
$diff = Get-FollowUpPRDiff -FollowUpPRNumber $prNum
234+
$originalCommits = Get-OriginalPRCommits -PRNumber $PRNumber
235+
236+
$comparison = Compare-DiffContent -FollowUpDiff $diff -OriginalCommits $originalCommits
237+
238+
$analysisResult = @{
239+
followUpPRNumber = $prNum
240+
headBranch = $followUp.headRefName
241+
baseBranch = $followUp.baseRefName
242+
createdAt = $followUp.createdAt
243+
author = $followUp.author.login
244+
category = $comparison.category
245+
similarity = $comparison.similarity
246+
reason = $comparison.reason
247+
recommendation = $null
248+
}
249+
250+
# Determine recommendation based on category
251+
switch ($comparison.category) {
252+
'DUPLICATE' {
253+
$analysisResult.recommendation = 'CLOSE_AS_DUPLICATE'
254+
}
255+
'LIKELY_DUPLICATE' {
256+
$analysisResult.recommendation = 'REVIEW_THEN_CLOSE'
257+
}
258+
'POSSIBLE_SUPPLEMENTAL' {
259+
$analysisResult.recommendation = 'EVALUATE_FOR_MERGE'
260+
}
261+
default {
262+
$analysisResult.recommendation = 'MANUAL_REVIEW'
263+
}
264+
}
265+
266+
$analysis += $analysisResult
267+
}
268+
269+
return @{
270+
found = $true
271+
originalPRNumber = $PRNumber
272+
followUpPRs = $followUpPRs
273+
announcement = $announcement
274+
analysis = $analysis
275+
recommendation = if ($analysis.Count -eq 1) { $analysis[0].recommendation } else { 'MULTIPLE_FOLLOW_UPS_REVIEW' }
276+
timestamp = (Get-Date -Format 'O')
277+
}
278+
}
279+
280+
# Execute detection
281+
$result = Invoke-FollowUpDetection
282+
283+
# Output as JSON for script consumption
284+
$result | ConvertTo-Json -Depth 5

0 commit comments

Comments
 (0)