Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 94 additions & 11 deletions .github/scripts/Find-RegressionFixPRs.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ BeforeAll {
'Get-UsableCandidateCount',
'Invoke-GhJson',
'Get-MergedRegressionFixPRs',
'Get-IssueAuthorAssociation',
'Get-FixPrContext',
'Get-IssueContext',
'Get-RegressionAttributionContext',
'Get-OpenRegressionCorpusPrTags',
'Get-ExistingRegressionPrTags',
'Get-IntroducingPrDetails',
Expand Down Expand Up @@ -413,7 +414,8 @@ Describe 'Get-MergedRegressionFixPRs' {
Should -Invoke -CommandName Invoke-GhJson -Times 1 -Exactly -ParameterFilter {
-not $AllowFailure -and
($GhArgs -join ' ') -match 'sort:created-asc' -and
($GhArgs -join ' ') -match 'number,body,mergeCommit,mergedAt'
($GhArgs -join ' ') -match 'number,mergeCommit,mergedAt' -and
($GhArgs -join ' ') -notmatch '\bbody\b'
}
}
}
Expand Down Expand Up @@ -476,33 +478,114 @@ Describe 'Get-IssueContext' {
}
}

Describe 'Get-IssueAuthorAssociation' {
It 'reads the association from the issue REST resource' {
Describe 'Get-FixPrContext' {
It 'reads the body and association atomically from the issue REST resource' {
Mock Invoke-GhJson {
param([string[]]$GhArgs)
$script:authorAssociationGhArgs = $GhArgs
return [PSCustomObject]@{ author_association = 'MEMBER' }
$script:fixPrContextGhArgs = $GhArgs
return [PSCustomObject]@{
number = 35803
body = "Fixes #35756`nRegression from #31931"
author_association = 'MEMBER'
}
}

Get-IssueAuthorAssociation -Owner 'dotnet' -Repo 'maui' -Number 35803 |
Should -Be 'MEMBER'
$script:authorAssociationGhArgs | Should -Be @('api', 'repos/dotnet/maui/issues/35803')
$context = Get-FixPrContext -Owner 'dotnet' -Repo 'maui' -Number 35803

$context.Body | Should -Be "Fixes #35756`nRegression from #31931"
$context.AuthorAssociation | Should -Be 'MEMBER'
$context.IsTrustedAttribution | Should -BeTrue
$script:fixPrContextGhArgs | Should -Be @('api', 'repos/dotnet/maui/issues/35803')
Should -Invoke -CommandName Invoke-GhJson -Times 1 -Exactly -ParameterFilter {
$AllowFailure -and $GhArgs[0] -eq 'api'
}
}
It 'treats an unavailable fix PR as untrusted' {
Mock Invoke-GhJson { $null }

$association = Get-IssueAuthorAssociation -Owner 'dotnet' -Repo 'maui' -Number 35803
$context = Get-FixPrContext -Owner 'dotnet' -Repo 'maui' -Number 35803

($null -eq $association) | Should -BeTrue
($null -eq $context) | Should -BeTrue
Should -Invoke -CommandName Invoke-GhJson -Times 1 -Exactly -ParameterFilter {
$AllowFailure -and $GhArgs[0] -eq 'api'
}
}
}

Describe 'Get-RegressionAttributionContext' {
It 'uses the REST fix body when structural search results omit body' {
Mock Invoke-GhJson {
param([string[]]$GhArgs)
if ($GhArgs[0] -eq 'pr') {
return @([PSCustomObject]@{
number = 35768
mergeCommit = [PSCustomObject]@{ oid = 'fix-sha' }
mergedAt = '2026-07-17T00:00:00Z'
})
}
return [PSCustomObject]@{
number = 35768
body = "Fixes #35756`nRegression from #31931"
author_association = 'MEMBER'
}
}
Mock Get-IssueContext {
return [PSCustomObject]@{
Number = 35756
Labels = @('regressed-in-10.0.70')
Body = ''
CommentText = ''
IsTrustedAttribution = $false
}
}

$fixPr = @(Get-MergedRegressionFixPRs -Owner dotnet -Repo maui -LookbackDays 60 -Limit 1)[0]
$fixPr.PSObject.Properties.Name | Should -Not -Contain 'body'
$fixContext = Get-FixPrContext -Owner dotnet -Repo maui -Number $fixPr.number

$attribution = Get-RegressionAttributionContext `
-Owner dotnet `
-Repo maui `
-FixPr $fixPr.number `
-FixPrContext $fixContext

$attribution.LinkedIssueNumbers | Should -Be @(35756)
$attribution.IntroducingPr | Should -Be 31931
$attribution.AttributionSource | Should -Be 'pr-body'
$attribution.RegressionIssues.Count | Should -Be 1
Should -Invoke -CommandName Get-IssueContext -Times 1 -Exactly -ParameterFilter {
$Number -eq 35756
}
}

It 'uses an untrusted REST body for issue discovery but not attribution' {
$fixContext = [PSCustomObject]@{
Body = "Fixes #35756`nRegression from #31931"
AuthorAssociation = 'CONTRIBUTOR'
IsTrustedAttribution = $false
}
Mock Get-IssueContext {
return [PSCustomObject]@{
Number = 35756
Labels = @()
Body = ''
CommentText = ''
IsTrustedAttribution = $false
}
}

$attribution = Get-RegressionAttributionContext `
-Owner dotnet `
-Repo maui `
-FixPr 35768 `
-FixPrContext $fixContext

$attribution.LinkedIssueNumbers | Should -Be @(35756)
$attribution.IntroducingPr | Should -BeNullOrEmpty
$attribution.AttributionSource | Should -BeNullOrEmpty
}
}

Describe 'Get-OpenRegressionCorpusPrTags' {
It 'includes tags from pending scanner draft PRs' {
Mock Invoke-GhJson {
Expand Down
139 changes: 85 additions & 54 deletions .github/scripts/Find-RegressionFixPRs.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -279,19 +279,26 @@ function Get-MergedRegressionFixPRs {
$prs = Invoke-GhJson -GhArgs @(
'pr', 'list', '--repo', "$Owner/$Repo",
'--state', 'merged', '--search', $search, '--limit', "$Limit",
'--json', 'number,body,mergeCommit,mergedAt'
'--json', 'number,mergeCommit,mergedAt'
)
return @($prs | Where-Object { $null -ne $_ } | Sort-Object mergedAt, number)
}

function Get-IssueAuthorAssociation {
# REST exposes author_association for both issues and PRs (which are issues).
function Get-FixPrContext {
# REST exposes a PR through the issue resource. Fetch the editable body and its
# trust signal together so attribution never combines differently filtered data.
param([string]$Owner, [string]$Repo, [int]$Number)
$issue = Invoke-GhJson -GhArgs @(
'api', "repos/$Owner/$Repo/issues/$Number"
) -AllowFailure
if (-not $issue) { return $null }
return [string]$issue.author_association
$association = [string]$issue.author_association
return [PSCustomObject]@{
Number = $issue.number
Body = [string]$issue.body
AuthorAssociation = $association
IsTrustedAttribution = Test-IsTrustedAssociation $association
}
}

function Get-IssueContext {
Expand Down Expand Up @@ -323,6 +330,70 @@ function Get-IssueContext {
}
}

function Get-RegressionAttributionContext {
# Deterministic orchestration for linked-issue expansion and trusted-text
# attribution. Text stays internal; only structural fields reach candidates.
param(
[string]$Owner,
[string]$Repo,
[int]$FixPr,
$FixPrContext
)

$fixPrBody = if ($FixPrContext) { [string]$FixPrContext.Body } else { '' }
$linkedIssues = @(Get-LinkedIssueNumbers -PRBody $fixPrBody -Owner $Owner -Repo $Repo)
$commentSources = New-Object System.Collections.Generic.List[object]
$bodySources = New-Object System.Collections.Generic.List[object]
if ($FixPrContext -and $FixPrContext.IsTrustedAttribution -and $fixPrBody) {
$bodySources.Add([PSCustomObject]@{ Source = 'pr-body'; Text = $fixPrBody }) | Out-Null
}

$regressionIssues = New-Object System.Collections.Generic.List[object]
foreach ($issueNum in $linkedIssues) {
$ctx = Get-IssueContext -Owner $Owner -Repo $Repo -Number $issueNum
if (-not $ctx) { continue }
$regressedIn = @(Get-RegressedInLabels $ctx.Labels)
$regressionIssues.Add([PSCustomObject]@{
number = $ctx.Number
regressedInLabels = $regressedIn
}) | Out-Null
if ($ctx.CommentText) {
$commentSources.Add([PSCustomObject]@{ Source = 'issue-comment'; Text = $ctx.CommentText }) | Out-Null
}
if ($ctx.IsTrustedAttribution) {
$bodySources.Add([PSCustomObject]@{ Source = 'issue-body'; Text = $ctx.Body }) | Out-Null
}
}

$attributionSources = New-Object System.Collections.Generic.List[object]
foreach ($source in $commentSources) {
$attributionSources.Add($source) | Out-Null
}
foreach ($source in $bodySources) {
$attributionSources.Add($source) | Out-Null
}

$introducingPr = $null
$attributionSource = $null
foreach ($entry in $attributionSources) {
$references = @(Get-IntroducingPrReferences $entry.Text | Where-Object {
$_ -ne $FixPr -and $_ -notin $linkedIssues
})
if ($references.Count -gt 0) {
$introducingPr = [int]$references[0]
$attributionSource = $entry.Source
break
}
}

return [PSCustomObject]@{
LinkedIssueNumbers = @($linkedIssues)
RegressionIssues = $regressionIssues
IntroducingPr = $introducingPr
AttributionSource = $attributionSource
}
}

function Get-OpenRegressionCorpusPrTags {
# Draft corpus PRs are not in main yet, so include their tags in deduplication.
param([string]$Owner, [string]$Repo)
Expand Down Expand Up @@ -436,56 +507,16 @@ foreach ($pr in $fixPRs) {
if ((Get-UsableCandidateCount -Candidates $candidates) -ge $MaxPRs) { break }

$fixNumber = [int]$pr.number
$fixPrAssociation = Get-IssueAuthorAssociation -Owner $Owner -Repo $Repo -Number $fixNumber
$linkedIssues = Get-LinkedIssueNumbers -PRBody $pr.body -Owner $Owner -Repo $Repo

# Keep only maintainer-authored editable bodies as attribution. The fix body
# still drives linked-issue discovery above, regardless of author association.
$commentSources = New-Object System.Collections.Generic.List[object]
$bodySources = New-Object System.Collections.Generic.List[object]
if (Test-IsTrustedAssociation $fixPrAssociation) {
$bodySources.Add([PSCustomObject]@{ Source = 'pr-body'; Text = [string]$pr.body }) | Out-Null
}

$regressionIssues = New-Object System.Collections.Generic.List[object]
foreach ($issueNum in $linkedIssues) {
$ctx = Get-IssueContext -Owner $Owner -Repo $Repo -Number $issueNum
if (-not $ctx) { continue }
$regressedIn = @(Get-RegressedInLabels $ctx.Labels)
$regressionIssues.Add([PSCustomObject]@{
number = $ctx.Number
regressedInLabels = $regressedIn
}) | Out-Null
if ($ctx.CommentText) {
$commentSources.Add([PSCustomObject]@{ Source = 'issue-comment'; Text = $ctx.CommentText }) | Out-Null
}
if ($ctx.IsTrustedAttribution) {
$bodySources.Add([PSCustomObject]@{ Source = 'issue-body'; Text = $ctx.Body }) | Out-Null
}
}

# An explicit trusted maintainer comment takes precedence over editable bodies.
$attribSources = New-Object System.Collections.Generic.List[object]
foreach ($source in $commentSources) {
$attribSources.Add($source) | Out-Null
}
foreach ($source in $bodySources) {
$attribSources.Add($source) | Out-Null
}

# Find the introducing PR reference, excluding the fix PR and its linked issues.
$introducingPr = $null
$attributionSource = $null
foreach ($entry in $attribSources) {
$refs = @(Get-IntroducingPrReferences $entry.Text | Where-Object {
$_ -ne $fixNumber -and $_ -notin $linkedIssues
})
if ($refs.Count -gt 0) {
$introducingPr = [int]$refs[0]
$attributionSource = $entry.Source
break
}
}
$fixPrContext = Get-FixPrContext -Owner $Owner -Repo $Repo -Number $fixNumber
$attribution = Get-RegressionAttributionContext `
-Owner $Owner `
-Repo $Repo `
-FixPr $fixNumber `
-FixPrContext $fixPrContext
$linkedIssues = @($attribution.LinkedIssueNumbers)
$regressionIssues = $attribution.RegressionIssues
$introducingPr = $attribution.IntroducingPr
$attributionSource = $attribution.AttributionSource

if (-not (Test-CandidateIsNew -IntroducingPr $introducingPr -FixPr $fixNumber -ExistingNumbers @($existingNumbers))) {
Write-Host " ⏭️ PR #$fixNumber → introducing #$introducingPr already in corpus; skipping."
Expand Down
Loading