diff --git a/.github/scripts/Find-RegressionFixPRs.Tests.ps1 b/.github/scripts/Find-RegressionFixPRs.Tests.ps1 index 0369edf44c8d..3bfb1812b00c 100644 --- a/.github/scripts/Find-RegressionFixPRs.Tests.ps1 +++ b/.github/scripts/Find-RegressionFixPRs.Tests.ps1 @@ -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', @@ -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' } } } @@ -476,17 +478,24 @@ 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' } @@ -494,15 +503,89 @@ Describe 'Get-IssueAuthorAssociation' { 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 { diff --git a/.github/scripts/Find-RegressionFixPRs.ps1 b/.github/scripts/Find-RegressionFixPRs.ps1 index cfff87b5928a..2bfebf4a9b8b 100644 --- a/.github/scripts/Find-RegressionFixPRs.ps1 +++ b/.github/scripts/Find-RegressionFixPRs.ps1 @@ -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 { @@ -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) @@ -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."