From ac3b1945a47dc7582a23a2f5c2ec2eaf75f2a2d1 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Mon, 19 Oct 2026 11:20:31 +0200 Subject: [PATCH 01/13] Add nightly extensibility request backports Backport eligible merged main PRs to the latest release with linked tracking issues, draft PRs and idempotent status reconciliation. Reuse the existing extensibility App with read-only preparation and opt-in scheduled publication. AB#649354 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 457 ++++++++++++ .../bc-extrequest-backport/Backport.psm1 | 656 ++++++++++++++++++ .../scripts/bc-extrequest-backport/README.md | 128 ++++ .github/workflows/bc-extrequest-backport.yml | 188 +++++ 4 files changed, 1429 insertions(+) create mode 100644 .github/scripts/bc-extrequest-backport/Backport.Test.ps1 create mode 100644 .github/scripts/bc-extrequest-backport/Backport.psm1 create mode 100644 .github/scripts/bc-extrequest-backport/README.md create mode 100644 .github/workflows/bc-extrequest-backport.yml diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 new file mode 100644 index 00000000000..70df247bc7e --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -0,0 +1,457 @@ +using module .\Backport.psm1 + +Describe 'Extensibility backport policy' { + InModuleScope Backport { + BeforeAll { + function New-TestSource { + param([string[]] $Labels = @('event-request'), [string] $Merged = '2026-10-18T12:00:00Z') + @{ + number = 42; title = 'Expose an event'; merged_at = $Merged; updated_at = $Merged + base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } + merge_commit_sha = 'a' * 40 + labels = @($Labels | ForEach-Object { @{ name = $_; color = 'ffffff'; description = '' } }) + } + } + } + + It 'accepts either allowed category or both, with unrelated labels' -ForEach @( + @{ labels = @('event-request') } + @{ labels = @('request-for-external') } + @{ labels = @('event-request', 'request-for-external', 'Team: Finance', 'approved') } + ) { + Test-BackportCategory $labels | Should -BeTrue + } + + It 'rejects other or mixed request categories and generated backports' -ForEach @( + @{ labels = @('enum-request') } + @{ labels = @('event-request', 'enum-request') } + @{ labels = @('event-request', 'extensibility-enhancement') } + @{ labels = @('request-for-external', 'extensibility-bug') } + @{ labels = @('event-request', 'another-request') } + @{ labels = @('event-request', 'ext-backporting') } + @{ labels = @('Team: Finance') } + ) { + Test-BackportCategory $labels | Should -BeFalse + } + + It 'uses a rolling seven-day inclusive cutoff and has no November cutoff' { + $now = [DateTimeOffset]'2026-10-19T12:00:00Z' + Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-12T12:00:00Z') $now | Should -BeTrue + Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-12T11:59:59Z') $now | Should -BeFalse + Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-19T12:00:01Z') $now | Should -BeFalse + } + + It 'rejects unmerged PRs and release-targeted PRs' { + $source = New-TestSource + $source.merged_at = $null + Test-BackportAutomaticSource $source | Should -BeFalse + $source = New-TestSource + $source.base.ref = 'releases/29.x' + Test-BackportAutomaticSource $source | Should -BeFalse + } + + It 'selects the highest numerical .x branch, not a patch release or lexical maximum' { + Get-BackportLatestTarget @('releases/9.x', 'releases/29.9', 'releases/29.x', + 'releases/30.x-preview', 'main', 'releases/28.x') | Should -Be 'releases/29.x' + } + + It 'supports an existing release override and refuses missing or malformed targets' { + Get-BackportLatestTarget @('releases/28.x', 'releases/29.x') 'releases/28.x' | Should -Be 'releases/28.x' + { Get-BackportLatestTarget @('releases/29.x') 'releases/30.x' } | Should -Throw + { Get-BackportLatestTarget @('main') } | Should -Throw + { Assert-BackportTarget 'releases/29.1' } | Should -Throw + } + + It 'unions labels from every main issue and excludes only the readiness guard' { + $issues = @( + @{ labels = @{ nodes = @(@{ name = 'event-request' }, @{ name = 'Team: Finance' }, @{ name = 'ext-ready-to-implement' }) } } + @{ labels = @{ nodes = @(@{ name = 'request-for-external' }, @{ name = 'Team: Finance' }, @{ name = 'approved' }) } } + ) + $labels = @(Get-BackportLabels -Issues $issues -PrLabels @(@{ name = 'unrelated-pr-label' })) + @($labels.name).Count | Should -Be 5 + $labels.name | Should -Contain 'ext-backporting' + $labels.name | Should -Contain 'event-request' + $labels.name | Should -Contain 'request-for-external' + $labels.name | Should -Contain 'approved' + $labels.name | Should -Not -Contain 'ext-ready-to-implement' + $labels.name | Should -Not -Contain 'unrelated-pr-label' + } + + It 'uses source PR labels only when there are no linked main issues' { + $labels = @(Get-BackportLabels -Issues @() -PrLabels @( + @{ name = 'event-request' }, @{ name = 'ext-ready-to-implement' } + )) + $labels.name | Should -Contain 'event-request' + $labels.name | Should -Contain 'ext-backporting' + $labels.name | Should -Not -Contain 'ext-ready-to-implement' + } + + It 'pins an existing request to its original release after a new release appears' { + $source = New-TestSource + $tracking = @{ body = Get-BackportMarker 42 'releases/29.x' $source.merge_commit_sha } + Get-BackportPinnedTarget $source $tracking 'releases/30.x' | Should -Be 'releases/29.x' + } + + It 'retains target identity from an interrupted issue-creation intent' { + $source = New-TestSource @('event-request', 'ext-backport-pending:29.x') + Get-BackportPinnedTarget $source $null 'releases/30.x' | Should -Be 'releases/29.x' + } + + It 'rejects changed source identities and multiple target states' { + $source = New-TestSource + $tracking = @{ body = Get-BackportMarker 42 'releases/29.x' ('b' * 40) } + { Get-BackportPinnedTarget $source $tracking 'releases/30.x' } | Should -Throw + $source = New-TestSource @('event-request', 'ext-backport-created:29.x', 'ext-backport-created:30.x') + { Get-BackportPinnedTarget $source $null 'releases/30.x' } | Should -Throw + } + + It 'rejects ambiguous markers and ignores ordinary issue bodies' { + Read-BackportMarker 'A normal issue' | Should -BeNullOrEmpty + $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) + { Read-BackportMarker "$marker`n$marker" } | Should -Throw + } + + It 'includes the main PR, all main issues, and only closes the new backport issue' { + $state = @{ + repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 + issues = @(@{ url = 'https://github.com/microsoft/BCApps/issues/10' }, + @{ url = 'https://github.com/microsoft/BCApps/issues/11' }) + result = @{ outcome = 'applied' } + } + $body = Get-BackportBody $state 99 + $body | Should -Match 'https://github.com/microsoft/BCApps/pull/42' + $body | Should -Match 'https://github.com/microsoft/BCApps/issues/10' + $body | Should -Match 'https://github.com/microsoft/BCApps/issues/11' + $body | Should -Match 'Fixes microsoft/BCApps#99' + $body | Should -Not -Match 'Fixes microsoft/BCApps#1[01]' + } + + It 'rejects non-source paths, symlinks, executable files and traversal' -ForEach @( + @{ path = '.github/workflows/evil.yml'; old = '100644'; new = '100644' } + @{ path = 'src/../file.al'; old = '100644'; new = '100644' } + @{ path = 'src/link'; old = '000000'; new = '120000' } + @{ path = 'src/tool'; old = '000000'; new = '100755' } + @{ path = 'src/.git/config'; old = '100644'; new = '100644' } + ) { + $raw = ":$old $new $('a' * 40) $('b' * 40) M$([char]0)$path$([char]0)" + { Assert-BackportPaths $raw } | Should -Throw + } + + It 'accepts normal src paths including spaces and multiple file types' { + $raw = ":100644 100644 $('a' * 40) $('b' * 40) M$([char]0)src/My App/Foo.al$([char]0)" + $raw += ":000000 100644 $('0' * 40) $('c' * 40) A$([char]0)src/My App/data.json$([char]0)" + @(Assert-BackportPaths $raw).Count | Should -Be 2 + } + } +} + +Describe 'Backport discovery and recovery' { + InModuleScope Backport { + BeforeEach { + Mock Get-BackportApiPages { @(@{ name = 'main' }, @{ name = 'releases/29.x' }) } + } + + It 'allows a manual PR regardless of category and age and validates its numeric input' { + Mock Invoke-BackportApi { throw 'Manual discovery must not run the automatic PR query.' } + $requests = @(Find-ExtRequestBackport microsoft/BCApps -SourcePr '42') + $requests.Count | Should -Be 1 + $requests[0].source_pr | Should -Be 42 + { Find-ExtRequestBackport microsoft/BCApps -SourcePr '42;whoami' } | Should -Throw + Should -Invoke Invoke-BackportApi -Times 0 + } + + It 'paginates discovery without a search index or a 1000-result search cap' { + Mock Invoke-BackportApi { + if ($Path -like '*page=1') { + return @(1..100 | ForEach-Object { + @{ merged_at = $null; updated_at = [DateTimeOffset]::UtcNow.ToString('o'); base = @{ ref = 'main' } } + }) + } + return @(@{ + number = 42; merged_at = [DateTimeOffset]::UtcNow.AddDays(-1).ToString('o') + updated_at = [DateTimeOffset]::UtcNow.ToString('o'); base = @{ ref = 'main' } + labels = @(@{ name = 'event-request' }) + }) + } + $requests = @(Find-ExtRequestBackport microsoft/BCApps) + $requests.Count | Should -Be 1 + $requests[0].source_pr | Should -Be 42 + Should -Invoke Invoke-BackportApi -Times 2 + } + + It 'refuses manually selected unmerged PRs too' { + Mock Invoke-BackportApi { @{ merged_at = $null; base = @{ ref = 'main' } } } + { Get-BackportSource microsoft/BCApps 42 } | Should -Throw + } + + It 'refuses objects created by another actor or pointing at another branch' { + $state = @{ repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 } + $object = @{ body = Get-BackportMarker 42 'releases/29.x' ('a' * 40); user = @{ login = 'someone' } } + { Assert-BackportObject $object $state 'test-app[bot]' } | Should -Throw + $object.user.login = 'test-app[bot]' + $object.head = @{ ref = 'wrong'; repo = @{ full_name = 'microsoft/BCApps' } } + $object.base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } + { Assert-BackportObject $object $state 'test-app[bot]' } | Should -Throw + } + + It 'repairs labels and links for an existing PR instead of creating another issue or PR' { + $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) + $state = @{ + repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 + labels = @(@{ name = 'event-request' }, @{ name = 'ext-backporting' }) + } + $tracking = @{ number = 100; body = $marker; user = @{ login = 'test-app[bot]' }; state = 'open'; html_url = 'issue-url' } + $pull = @{ + number = 101; body = $marker; user = @{ login = 'test-app[bot]' }; merged_at = $null + head = @{ ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } + state = 'open'; html_url = 'pr-url' + } + Mock Ensure-BackportLabels {} + Mock Add-BackportStatus {} + Mock Remove-BackportIntents {} + Mock Invoke-BackportApi {} + Complete-BackportPull $state $tracking $pull 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { $Path -like '*/labels' -and $Method -eq 'POST' } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Path -match '/(issues|pulls)$' -and $Method -eq 'POST' } + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'created' } + } + + It 'closes the tracking issue and marks the original PR when a backport merges' { + $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) + $state = @{ repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 } + $tracking = @{ + number = 100; body = "$marker`nBackport PR: pr-url"; user = @{ login = 'test-app[bot]' } + state = 'open'; html_url = 'issue-url'; labels = @(@{ name = 'ext-backporting' }) + } + $pull = @{ + number = 101; body = $marker; user = @{ login = 'test-app[bot]' }; merged_at = '2026-10-19T00:00:00Z' + head = @{ ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } + state = 'closed'; html_url = 'pr-url' + } + Mock Ensure-BackportLabels {} + Mock Add-BackportStatus {} + Mock Remove-BackportIntents {} + Mock Invoke-BackportApi {} + Complete-BackportPull $state $tracking $pull 'test-app[bot]' + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'merged' } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Body.state -eq 'closed' } + } + } +} + +Describe 'Native Git backport integration' { + InModuleScope Backport { + BeforeEach { + $script:directory = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory $directory + $null = Invoke-BackportGit $directory @('init', '--quiet') + [IO.File]::WriteAllText((Join-Path $directory '.git/empty-config'), '') + $null = New-Item -ItemType Directory (Join-Path $directory 'src') + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "base`n") + $null = Invoke-BackportGit $directory @('add', 'src/Foo.al') + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-m', 'base') + $script:base = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "source`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'source') + $script:source = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + } + + It 'backports the complete squash commit onto the pinned target with cherry-pick provenance' { + $result = Invoke-BackportApply $directory $source $base + $result.outcome | Should -Be 'applied' + (Invoke-BackportGit $directory @('rev-parse', 'HEAD^')).text.Trim() | Should -Be $base + (Invoke-BackportGit $directory @('show', 'HEAD:src/Foo.al')).text | Should -Be "source`n" + (Invoke-BackportGit $directory @('log', '-1', '--format=%B')).text | Should -Match "cherry picked from commit $source" + } + + It 'proves an already-applied patch instead of creating an empty PR' { + $result = Invoke-BackportApply $directory $source $source + $result.outcome | Should -Be 'already-present' + } + + It 'recomputes exactly the same commit when publication is interrupted' { + $first = Invoke-BackportApply $directory $source $base + Start-Sleep -Milliseconds 1100 + $second = Invoke-BackportApply $directory $source $base + $second.commit | Should -Be $first.commit + $second.tree | Should -Be $first.tree + } + + It 'reports a conflict without leaving a partial backport commit' { + $null = Invoke-BackportGit $directory @('checkout', '--detach', $base) + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "release-only`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'release') + $target = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $result = Invoke-BackportApply $directory $source $target + $result.outcome | Should -Be 'conflict' + (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() | Should -Be $target + (Invoke-BackportGit $directory @('show', 'HEAD:src/Foo.al')).text | Should -Be "release-only`n" + } + + It 'does not run repository hooks' { + $hook = Join-Path $directory '.git/hooks/post-checkout' + [IO.File]::WriteAllText($hook, "#!/bin/sh`ntouch ran-hook`n") + if (-not $IsWindows) { + & chmod +x $hook + if ($LASTEXITCODE -ne 0) { throw 'Could not make the test hook executable.' } + } + $result = Invoke-BackportApply $directory $source $base + $result.outcome | Should -Be 'applied' + Test-Path (Join-Path $directory 'ran-hook') | Should -BeFalse + } + } + + Describe 'Backport publication contract' { + InModuleScope Backport { + BeforeEach { + $script:statePath = Join-Path $TestDrive 'state.json' + $script:source = @{ + number = 42; title = 'Expose an event'; merged_at = '2020-01-01T00:00:00Z' + base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } + merge_commit_sha = 'a' * 40; labels = @(@{ name = 'enum-request' }) + } + $script:state = @{ + schema = 1; repository = 'microsoft/BCApps'; source_pr = 42; source_sha = 'a' * 40 + target = 'releases/29.x'; manual = $true; source_title = 'Expose an event' + result = @{ outcome = 'applied'; commit = 'b' * 40; tree = 'c' * 40; target_sha = 'd' * 40 } + } + $script:tracking = $null + $script:pull = $null + $script:issues = @(@{ + number = 10; url = 'https://github.com/microsoft/BCApps/issues/10' + labels = @{ nodes = @( + @{ name = 'enum-request'; color = 'ffffff'; description = '' } + @{ name = 'Team: SCM'; color = 'ffffff'; description = '' } + @{ name = 'ext-ready-to-implement'; color = 'ffffff'; description = '' } + ) } + }) + Mock Get-BackportSource { $script:source } + Mock Get-BackportSourceIssues { $script:issues } + Mock Get-BackportTracking { $script:tracking } + Mock Get-BackportPull { $script:pull } + Mock Ensure-BackportLabels {} + Mock Add-BackportStatus {} + Mock Remove-BackportIntents {} + Mock Invoke-BackportGit { + switch ($Arguments -join ' ') { + 'rev-parse HEAD' { return @{ text = 'b' * 40 } } + 'rev-parse HEAD^{tree}' { return @{ text = 'c' * 40 } } + 'rev-parse HEAD^' { return @{ text = 'd' * 40 } } + default { + if ($Arguments[0] -eq 'ls-remote') { return @{ text = "$('b' * 40)`trefs/heads/bc-extrequest-backport/pr-42/29.x" } } + throw "Unexpected Git invocation: $($Arguments -join ' ')" + } + } + } + Mock Invoke-BackportApi { + if ([string]::IsNullOrEmpty($Method)) { $Method = 'GET' } + if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues') { + $script:tracking = @{ + number = 100; title = $Body.title; body = $Body.body; state = 'open'; labels = @() + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' + } + return $script:tracking + } + if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { return $script:tracking } + if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls') { + $script:pull = @{ + number = 101; body = $Body.body; draft = $Body.draft; merged_at = $null; state = 'open' + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/101' + head = @{ sha = 'b' * 40; ref = $Body.head; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } } + } + return $script:pull + } + if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/pulls/101') { return $script:pull } + if ($Path -like '*/compare/*' -and $Method -eq 'GET') { return @{ status = 'ahead' } } + if ($Method -eq 'POST' -and $Path -match '/issues/(100|101)/labels$') { return } + if ($Method -eq 'PATCH' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { return } + throw "Unexpected API invocation: $Method $Path" + } + } + + It 'publishes a Task and draft PR for a manual old PR with a normally excluded category' { + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + Publish-ExtRequestBackport $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and + $Body.type -eq 'Task' -and $Body.labels -contains 'ext-backporting' -and + $Body.labels -contains 'Team: SCM' -and $Body.labels -contains 'enum-request' -and + $Body.labels -notcontains 'ext-ready-to-implement' + } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' -and + $Body.draft -and $Body.base -eq 'releases/29.x' -and + $Body.head -eq 'bc-extrequest-backport/pr-42/29.x' -and + $Body.body.Contains('https://github.com/microsoft/BCApps/pull/42') -and + $Body.body.Contains('https://github.com/microsoft/BCApps/issues/10') -and + $Body.body.Contains('Fixes microsoft/BCApps#100') + } + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'pending' } + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'publishing' } + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'created' } + } + + It 'creates the tracking issue on a conflict but publishes no branch or PR' { + $state.result.outcome = 'conflict' + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + Publish-ExtRequestBackport $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + Should -Invoke Invoke-BackportGit -Times 0 + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'needs-attention' } + } + + It 'closes an already-present tracking issue without creating an empty PR' { + $state.result.outcome = 'already-present' + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + Publish-ExtRequestBackport $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'PATCH' -and $Body.state -eq 'closed' } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + } + + It 'does not repeat an uncertain tracking-issue POST' { + $source.labels += @{ name = 'ext-backport-pending:29.x' } + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } + } + + It 'does not repeat an uncertain release-PR POST' { + $source.labels += @{ name = 'ext-backport-publishing:29.x' } + $script:tracking = @{ + number = 100; body = Get-BackportMarker 42 'releases/29.x' ('a' * 40); state = 'open' + user = @{ login = 'test-app[bot]' } + } + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*previous PR creation is unconfirmed*' + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + } + + It 'refuses changed automatic source categories before any writes' { + $state.manual = $false + $source.merged_at = [DateTimeOffset]::UtcNow.AddHours(-1).ToString('o') + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*eligibility changed*' + Should -Invoke Invoke-BackportApi -Times 0 + } + + It 'does not overwrite an existing branch with different content' { + Mock Invoke-BackportGit { + switch ($Arguments -join ' ') { + 'rev-parse HEAD' { return @{ text = 'b' * 40 } } + 'rev-parse HEAD^{tree}' { return @{ text = 'c' * 40 } } + 'rev-parse HEAD^' { return @{ text = 'd' * 40 } } + default { return @{ text = "$('e' * 40)`trefs/heads/bc-extrequest-backport/pr-42/29.x" } } + } + } + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*never overwrite*' + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Path -eq 'repos/microsoft/BCApps/pulls' -and $Method -eq 'POST' } + } + } + } +} diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 new file mode 100644 index 00000000000..53dc9f26c62 --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -0,0 +1,656 @@ +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +function Invoke-BackportProcess { + param( + [string] $Command, + [string[]] $Arguments, + [string] $Directory = (Get-Location).Path, + [string] $InputText = '', + [hashtable] $Environment = @{}, + [switch] $AllowFailure + ) + + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $Command + $info.WorkingDirectory = $Directory + $info.UseShellExecute = $false + $info.RedirectStandardInput = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.StandardInputEncoding = [Text.UTF8Encoding]::new($false) + foreach ($argument in $Arguments) { [void]$info.ArgumentList.Add($argument) } + foreach ($key in $Environment.Keys) { + if ($null -eq $Environment[$key]) { [void]$info.Environment.Remove($key) } + else { $info.Environment[$key] = $Environment[$key] } + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + try { + if (-not $process.Start()) { throw "Unable to start $Command." } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.StandardInput.Write($InputText) + $process.StandardInput.Close() + if (-not $process.WaitForExit(600000)) { + $process.Kill($true) + throw "$Command exceeded its ten-minute deadline." + } + $text = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + if ($process.ExitCode -ne 0 -and -not $AllowFailure) { + throw "$Command failed (exit $($process.ExitCode)): $errorText" + } + return @{ exit_code = $process.ExitCode; text = $text } + } + finally { $process.Dispose() } +} + +function Invoke-BackportApi { + param([string] $Path, [string] $Method = 'GET', [hashtable] $Body) + + $arguments = @('api', '--hostname', 'github.com', '--method', $Method, $Path) + $inputText = '' + if ($null -ne $Body) { + $arguments += @('--input', '-') + $inputText = ConvertTo-Json $Body -Depth 30 -Compress + } + $result = Invoke-BackportProcess -Command gh -Arguments $arguments -InputText $inputText + if (-not [string]::IsNullOrWhiteSpace($result.text)) { + return ConvertFrom-Json $result.text -AsHashtable -Depth 50 + } +} + +function Get-BackportApiPages { + param([string] $Path) + + $separator = if ($Path.Contains('?')) { '&' } else { '?' } + for ($page = 1; ; $page++) { + $items = @(Invoke-BackportApi "$Path${separator}per_page=100&page=$page") + foreach ($item in $items) { $item } + if ($items.Count -lt 100) { break } + } +} + +function Assert-BackportRepository { + param([string] $Repository) + if ($Repository -cne 'microsoft/BCApps') { throw 'Backports are supported only in microsoft/BCApps.' } +} + +function Assert-BackportTarget { + param([string] $Target) + if ($Target -cnotmatch '^releases/([1-9][0-9]*)\.x$') { + throw "Invalid release branch '$Target'; expected releases/.x." + } +} + +function Get-BackportLatestTarget { + param([string[]] $Branches, [string] $Override = '') + + if (-not [string]::IsNullOrWhiteSpace($Override)) { + Assert-BackportTarget $Override + if ($Override -cnotin $Branches) { throw "Configured target '$Override' does not exist." } + return $Override + } + $targets = @($Branches | Where-Object { $_ -cmatch '^releases/[1-9][0-9]*\.x$' } | + Sort-Object { [long](($_ -split '/')[1] -split '\.')[0] } -Descending) + if ($targets.Count -eq 0) { throw 'No releases/.x branch exists.' } + return $targets[0] +} + +function Test-BackportCategory { + param([string[]] $Labels) + + $allowed = @('event-request', 'request-for-external') + $other = @($Labels | Where-Object { + $_ -notin $allowed -and + ($_ -match '-request$' -or $_ -like 'request-for-*' -or + $_ -in @('extensibility-enhancement', 'extensibility-bug')) + }) + return @($Labels | Where-Object { $_ -in $allowed }).Count -gt 0 -and + $other.Count -eq 0 -and $Labels -notcontains 'ext-backporting' +} + +function Test-BackportAutomaticSource { + param([hashtable] $Source, [DateTimeOffset] $Now = [DateTimeOffset]::UtcNow) + + if ($null -eq $Source.merged_at -or $Source.base.ref -cne 'main') { return $false } + $merged = [DateTimeOffset]$Source.merged_at + return $merged -ge $Now.AddDays(-7) -and $merged -le $Now -and + (Test-BackportCategory @($Source.labels | ForEach-Object { $_.name })) +} + +function Find-ExtRequestBackport { + [CmdletBinding()] + param([string] $Repository, [string] $SourcePr = '', [string] $TargetOverride = '') + + Assert-BackportRepository $Repository + $branches = @(Get-BackportApiPages "repos/$Repository/branches" | ForEach-Object { $_.name }) + $target = Get-BackportLatestTarget -Branches $branches -Override $TargetOverride + if (-not [string]::IsNullOrWhiteSpace($SourcePr)) { + if ($SourcePr -cnotmatch '^[1-9][0-9]{0,9}$') { throw 'source_pr must be a positive PR number.' } + return @{ source_pr = [long]$SourcePr; target = $target } + } + + $now = [DateTimeOffset]::UtcNow + $sources = [Collections.Generic.List[object]]::new() + for ($page = 1; ; $page++) { + $items = @(Invoke-BackportApi "repos/$Repository/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=$page") + foreach ($source in $items) { + if (Test-BackportAutomaticSource -Source $source -Now $now) { $sources.Add($source) } + } + if ($items.Count -lt 100 -or [DateTimeOffset]$items[-1].updated_at -lt $now.AddDays(-7)) { break } + } + if ($sources.Count -gt 256) { throw 'More than 256 eligible PRs; use manual runs to reduce the backlog.' } + foreach ($source in ($sources | Sort-Object { [DateTimeOffset]$_.merged_at }, number)) { + @{ source_pr = [long]$source.number; target = $target } + } +} + +function Get-BackportSource { + param([string] $Repository, [long] $Number) + + $source = Invoke-BackportApi "repos/$Repository/pulls/$Number" + if ($null -eq $source.merged_at -or $source.base.ref -cne 'main' -or + $source.base.repo.full_name -cne $Repository -or $source.merge_commit_sha -cnotmatch '^[0-9a-f]{40}$') { + throw "PR #$Number must be merged into $Repository main." + } + return $source +} + +function Get-BackportSourceIssues { + param([string] $Repository, [long] $Number) + + $parts = $Repository.Split('/') + $query = @' +query($owner:String!,$repo:String!,$number:Int!,$cursor:String) { + repository(owner:$owner,name:$repo) { + pullRequest(number:$number) { + closingIssuesReferences(first:100,after:$cursor) { + nodes { number url title repository { nameWithOwner } + labels(first:100) { nodes { name color description } pageInfo { hasNextPage } } + } + pageInfo { hasNextPage endCursor } + } + } + } +} +'@ + $cursor = $null + do { + $response = Invoke-BackportApi -Path graphql -Method POST -Body @{ + query = $query + variables = @{ owner = $parts[0]; repo = $parts[1]; number = $Number; cursor = $cursor } + } + if ($response.ContainsKey('errors')) { throw 'Could not read all source issue references.' } + $connection = $response.data.repository.pullRequest.closingIssuesReferences + if ($null -eq $connection) { throw 'Source issue references are unavailable.' } + foreach ($issue in $connection.nodes) { + if ($issue.labels.pageInfo.hasNextPage) { throw "Issue $($issue.url) has more than 100 labels." } + $issue + } + $cursor = $connection.pageInfo.endCursor + } while ($connection.pageInfo.hasNextPage) +} + +function Get-BackportLabels { + param([object[]] $Issues, [object[]] $PrLabels) + + $labels = @{} + $original = if ($Issues.Count -gt 0) { + @($Issues | ForEach-Object { $_.labels.nodes }) + } + else { $PrLabels } + foreach ($label in $original) { + if ($label.name -ne 'ext-ready-to-implement') { $labels[$label.name] = $label } + } + $labels['ext-backporting'] = @{ name = 'ext-backporting'; color = '5319E7'; description = 'Tracks an extensibility backport.' } + return @($labels.Values | Sort-Object name) +} + +function Get-BackportMarker { + param([long] $SourcePr, [string] $Target, [string] $Sha) + "" +} + +function Read-BackportMarker { + param([string] $Body) + $matches = [regex]::Matches($Body, + '(?m)^\r?$') + if ($matches.Count -gt 1) { throw 'Multiple backport identities on one object.' } + if ($matches.Count -eq 0) { return $null } + return @{ source_pr = [long]$matches[0].Groups[1].Value; target = $matches[0].Groups[2].Value; sha = $matches[0].Groups[3].Value } +} + +function Get-BackportTracking { + param([string] $Repository, [long] $SourcePr) + + $matches = @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting" | + Where-Object { + if ($_.ContainsKey('pull_request')) { return $false } + $marker = Read-BackportMarker $_.body + return $null -ne $marker -and $marker.source_pr -eq $SourcePr + }) + if ($matches.Count -gt 1) { throw "Multiple tracking issues for source PR #$SourcePr; reconcile manually." } + if ($matches.Count -eq 1) { return $matches[0] } + return $null +} + +function Get-BackportPinnedTarget { + param([hashtable] $Source, [AllowNull()][hashtable] $Tracking, [string] $Default) + + if ($null -ne $Tracking) { + $marker = Read-BackportMarker $Tracking.body + if ($marker.sha -cne $Source.merge_commit_sha) { throw 'Tracking issue source SHA has changed.' } + return $marker.target + } + $targets = @($Source.labels | ForEach-Object { + if ($_.name -match '^ext-backport-(?:pending|publishing|created|needs-attention|merged|already-present):([1-9][0-9]*\.x)$') { + "releases/$($Matches[1])" + } + } | Sort-Object -Unique) + if ($targets.Count -gt 1) { throw 'Source PR has backport state for multiple targets.' } + if ($targets.Count -eq 1) { return $targets[0] } + return $Default +} + +function Get-BackportBranch { + param([long] $SourcePr, [string] $Target) + "bc-extrequest-backport/pr-$SourcePr/$(($Target -split '/')[1])" +} + +function Get-BackportPull { + param([string] $Repository, [long] $SourcePr, [string] $Target) + + $branch = Get-BackportBranch $SourcePr $Target + $owner = $Repository.Split('/')[0] + $pulls = @(Get-BackportApiPages "repos/$Repository/pulls?state=all&head=$owner`:$branch") + if ($pulls.Count -gt 1) { throw 'Multiple backport PRs for the same branch.' } + if ($pulls.Count -eq 1) { return $pulls[0] } + return $null +} + +function Invoke-BackportGit { + param( + [string] $Directory, [string[]] $Arguments, [string] $InputText = '', + [switch] $AllowFailure, [string] $CommitDate = '' + ) + + $environment = @{ + GIT_CONFIG_NOSYSTEM = '1'; GIT_CONFIG_GLOBAL = (Join-Path $Directory '.git/empty-config') + GIT_DIR = $null; GIT_WORK_TREE = $null; GIT_INDEX_FILE = $null + GIT_OBJECT_DIRECTORY = $null; GIT_ALTERNATE_OBJECT_DIRECTORIES = $null + GIT_CONFIG = $null; GIT_CONFIG_PARAMETERS = $null; GIT_CONFIG_COUNT = '0' + GIT_TERMINAL_PROMPT = '0'; GIT_ATTR_NOSYSTEM = '1' + GIT_AUTHOR_NAME = 'BC Extensibility Backport'; GIT_AUTHOR_EMAIL = 'noreply@github.com' + GIT_COMMITTER_NAME = 'BC Extensibility Backport'; GIT_COMMITTER_EMAIL = 'noreply@github.com' + GIT_AUTHOR_DATE = $null; GIT_COMMITTER_DATE = $null + } + if ($CommitDate) { $environment.GIT_COMMITTER_DATE = $CommitDate } + if (-not [string]::IsNullOrWhiteSpace($env:GH_TOKEN)) { + $authorization = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("x-access-token:$env:GH_TOKEN")) + $environment.GIT_CONFIG_COUNT = '1' + $environment.GIT_CONFIG_KEY_0 = 'http.https://github.com/.extraheader' + $environment.GIT_CONFIG_VALUE_0 = "AUTHORIZATION: basic $authorization" + } + $argumentsForGit = @('-c', 'core.autocrlf=false', '-c', 'commit.gpgSign=false', + '-c', "core.hooksPath=$(Join-Path $Directory '.git/no-hooks')", + '-c', 'credential.helper=', '-c', 'protocol.file.allow=never', '--literal-pathspecs') + $Arguments + return Invoke-BackportProcess -Command git -Directory $Directory -Arguments $argumentsForGit ` + -InputText $InputText -Environment $environment -AllowFailure:$AllowFailure +} + +function Assert-BackportPaths { + param([string] $RawDiff) + + $parts = $RawDiff.Split([char]0, [StringSplitOptions]::RemoveEmptyEntries) + $paths = [Collections.Generic.List[string]]::new() + if ($parts.Count -eq 0 -or $parts.Count % 2 -ne 0) { throw 'Empty or malformed source diff.' } + for ($index = 0; $index -lt $parts.Count; $index += 2) { + if ($parts[$index] -cnotmatch '^:(000000|100644) (000000|100644) [0-9a-f]{40} [0-9a-f]{40} [ADM]$') { + throw 'Backports accept only additions, modifications and deletions of regular non-executable files.' + } + $path = $parts[$index + 1] + if (-not $path.StartsWith('src/', [StringComparison]::Ordinal) -or + $path -match '[\x00-\x1f\x7f\\:*?\[\]]' -or + @($path.Split('/') | Where-Object { $_ -eq '' -or $_ -in @('.', '..') -or $_ -like '.git*' }).Count -gt 0) { + throw "Unsupported source path '$path'; only safe src/ paths may be backported." + } + $paths.Add($path) + } + if ($paths.Count -gt 250) { throw 'The source PR changes more than 250 paths.' } + return $paths.ToArray() +} + +function Invoke-BackportApply { + param([string] $Directory, [string] $SourceSha, [string] $TargetSha) + + $parents = (Invoke-BackportGit $Directory @('rev-list', '--parents', '-n', '1', $SourceSha)).text.Trim().Split(' ') + if ($parents.Count -ne 2) { throw 'Only squash-merged source PRs are supported.' } + $raw = (Invoke-BackportGit $Directory @('diff', '--raw', '--no-abbrev', '--no-renames', '-z', $parents[1], $SourceSha)).text + $paths = @(Assert-BackportPaths $raw) + $patterns = ($paths | ForEach-Object { "/$_" }) -join "`n" + $null = Invoke-BackportGit $Directory @('sparse-checkout', 'set', '--no-cone', '--stdin') -InputText "$patterns`n" + $null = Invoke-BackportGit $Directory @('checkout', '--detach', $TargetSha) + $patchPath = Join-Path $Directory '.git/source.patch' + $null = Invoke-BackportGit $Directory @('diff', '--binary', '--no-ext-diff', '--no-textconv', + "--output=$patchPath", $parents[1], $SourceSha) + $reverse = Invoke-BackportGit $Directory @('apply', '--reverse', '--check', '--binary', $patchPath) -AllowFailure + if ($reverse.exit_code -eq 0) { + return @{ + outcome = 'already-present'; paths = $paths; commit = $null; target_sha = $TargetSha + tree = (Invoke-BackportGit $Directory @('rev-parse', 'HEAD^{tree}')).text.Trim() + } + } + # Recomputing an interrupted publication must produce the same commit ID. + $commitDate = (Invoke-BackportGit $Directory @('show', '-s', '--format=%cI', $SourceSha)).text.Trim() + $picked = Invoke-BackportGit $Directory @('cherry-pick', '-x', $SourceSha) -AllowFailure -CommitDate $commitDate + if ($picked.exit_code -ne 0) { + $conflicts = (Invoke-BackportGit $Directory @('diff', '--name-only', '--diff-filter=U', '-z')).text + if (-not [string]::IsNullOrEmpty($conflicts)) { + $null = Invoke-BackportGit $Directory @('cherry-pick', '--abort') + return @{ outcome = 'conflict'; paths = $paths; commit = $null; tree = $null; target_sha = $TargetSha } + } + throw 'Cherry-pick failed without a proven conflict or already-present patch.' + } + $commit = (Invoke-BackportGit $Directory @('rev-parse', 'HEAD')).text.Trim() + $parent = (Invoke-BackportGit $Directory @('rev-parse', 'HEAD^')).text.Trim() + if ($parent -cne $TargetSha) { throw 'Backport commit does not have the pinned release parent.' } + $changed = (Invoke-BackportGit $Directory @('diff', '--name-only', '--no-renames', '-z', $TargetSha, $commit)).text + foreach ($path in $changed.Split([char]0, [StringSplitOptions]::RemoveEmptyEntries)) { + if ($path -cnotin $paths) { throw 'Backport changed a path outside the source PR.' } + } + return @{ + outcome = 'applied'; paths = $paths; commit = $commit; target_sha = $TargetSha + tree = (Invoke-BackportGit $Directory @('rev-parse', 'HEAD^{tree}')).text.Trim() + } +} + +function Write-BackportSummary { + param([string] $Text) + Write-Host $Text + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value $Text + } +} + +function New-ExtRequestBackport { + [CmdletBinding()] + param([string] $Repository, [long] $SourcePr, [string] $Target, [bool] $Manual, [string] $StatePath) + + Assert-BackportRepository $Repository + $source = Get-BackportSource $Repository $SourcePr + $issues = @(Get-BackportSourceIssues $Repository $SourcePr) + $labels = @(Get-BackportLabels -Issues $issues -PrLabels $source.labels) + if (-not $Manual -and (-not (Test-BackportAutomaticSource $source) -or + @($issues | Where-Object { -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) }).Count -gt 0)) { + throw "Source PR #$SourcePr or one of its issues is no longer automatically eligible." + } + $tracking = Get-BackportTracking $Repository $SourcePr + $target = Get-BackportPinnedTarget -Source $source -Tracking $tracking -Default $Target + Assert-BackportTarget $target + $pull = Get-BackportPull $Repository $SourcePr $target + $result = @{ outcome = 'existing'; target_sha = $null; commit = $null; tree = $null; paths = @() } + $directory = Join-Path (Split-Path -Parent $StatePath) 'git' + if ($null -eq $pull) { + if (Test-Path -LiteralPath $directory) { throw 'Backport work directory already exists.' } + $null = New-Item -ItemType Directory -Path $directory -Force + $null = Invoke-BackportGit $directory @('init', '--quiet') + [IO.File]::WriteAllText((Join-Path $directory '.git/empty-config'), '') + [IO.File]::WriteAllText((Join-Path $directory '.git/info/attributes'), + "* -filter -ident -text !eol !working-tree-encoding !merge`n") + $null = Invoke-BackportGit $directory @('remote', 'add', 'origin', "https://github.com/$Repository.git") + $release = Invoke-BackportApi "repos/$Repository/git/ref/heads/$target" + $comparison = Invoke-BackportApi "repos/$Repository/compare/$($source.merge_commit_sha)...main" + if ($comparison.status -notin @('ahead', 'identical')) { throw 'Source merge is not on current main.' } + $targetSha = $release.object.sha + $branch = Get-BackportBranch $SourcePr $target + $existingHead = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if ($existingHead) { + if ($null -eq $tracking) { throw 'An existing backport branch has no tracking issue; reconcile manually.' } + $existingSha = ($existingHead -split '\s+')[0] + $null = Invoke-BackportGit $directory @('fetch', '--quiet', '--no-tags', '--filter=blob:none', '--depth=2', + 'origin', "$existingSha`:refs/backport/existing") + $targetSha = (Invoke-BackportGit $directory @('rev-parse', "$existingSha^")).text.Trim() + $comparison = Invoke-BackportApi "repos/$Repository/compare/$targetSha...$target" + if ($comparison.status -notin @('ahead', 'identical')) { throw 'Existing backport parent is no longer on the release branch.' } + } + $null = Invoke-BackportGit $directory @('fetch', '--quiet', '--no-tags', '--filter=blob:none', '--depth=2', 'origin', + "$($source.merge_commit_sha):refs/backport/source", "$targetSha`:refs/backport/target") + $result = Invoke-BackportApply $directory $source.merge_commit_sha $targetSha + if ($existingHead -and $result.commit -cne $existingSha) { throw 'Existing backport branch does not match the source; never overwrite it.' } + } + $state = @{ + schema = 1; repository = $Repository; source_pr = $SourcePr; source_sha = $source.merge_commit_sha + target = $target; manual = $Manual; labels = $labels; issues = $issues; source_title = $source.title + result = $result + } + $null = New-Item -ItemType Directory -Path (Split-Path -Parent $StatePath) -Force + $state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM + $issueLinks = ($issues | ForEach-Object { $_.url }) -join ', ' + Write-BackportSummary "Main PR #$SourcePr -> $target`: **$($result.outcome)**. Source issues: $issueLinks" +} + +function Get-BackportStatusLabel { + param([string] $Status, [string] $Target) + "ext-backport-$Status`:$(($Target -split '/')[1])" +} + +function Ensure-BackportLabels { + param([string] $Repository, [object[]] $Labels) + + $existing = @(Get-BackportApiPages "repos/$Repository/labels" | ForEach-Object { $_.name }) + foreach ($label in $Labels) { + if ($label.name -notin $existing) { + $null = Invoke-BackportApi "repos/$Repository/labels" -Method POST -Body @{ + name = $label.name; color = $label.color; description = [string]$label.description + } + $existing += $label.name + } + } +} + +function Add-BackportStatus { + param([string] $Repository, [long] $SourcePr, [string] $Status, [string] $Target) + + $name = Get-BackportStatusLabel $Status $Target + Ensure-BackportLabels $Repository @(@{ name = $name; color = 'C5DEF5'; description = 'Extensibility backport status.' }) + $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels" -Method POST -Body @{ labels = @($name) } +} + +function Remove-BackportIntents { + param([string] $Repository, [long] $SourcePr, [string] $Target) + + $source = Get-BackportSource $Repository $SourcePr + foreach ($status in @('pending', 'publishing', 'needs-attention')) { + $label = Get-BackportStatusLabel $status $Target + if ($label -in @($source.labels | ForEach-Object { $_.name })) { + $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels/$([uri]::EscapeDataString($label))" -Method DELETE + } + } +} + +function Assert-BackportObject { + param([hashtable] $Object, [hashtable] $State, [string] $BotLogin) + + $marker = Read-BackportMarker $Object.body + if ($null -eq $marker -or $marker.source_pr -ne $State.source_pr -or + $marker.target -cne $State.target -or $marker.sha -cne $State.source_sha -or + $Object.user.login -cne $BotLogin) { + throw 'Existing backport object has an unexpected identity or author.' + } + if ($Object.ContainsKey('head') -and + ($Object.base.ref -cne $State.target -or $Object.base.repo.full_name -cne $State.repository -or + $Object.head.ref -cne (Get-BackportBranch $State.source_pr $State.target) -or + $Object.head.repo.full_name -cne $State.repository)) { + throw 'Existing backport PR has an unexpected source or target.' + } +} + +function Get-BackportBody { + param([hashtable] $State, [AllowNull()][long] $TrackingNumber = 0) + + $repository = $State.repository + $lines = @( + (Get-BackportMarker $State.source_pr $State.target $State.source_sha) + '' + "Backport of https://github.com/$repository/pull/$($State.source_pr) to ``$($State.target)``." + '' + "Source merge: ``$($State.source_sha)``" + '' + '## Original main issues' + ) + if ($State.issues.Count -eq 0) { $lines += 'No closing issue references were found on the main PR; labels are inherited from that PR.' } + foreach ($issue in $State.issues) { $lines += "- $($issue.url)" } + $lines += @('', '## Backport', '', "Preparation result: **$($State.result.outcome)**.") + if ($TrackingNumber -gt 0) { + $lines += @('', "Backport tracking issue: https://github.com/$repository/issues/$TrackingNumber", '', + "Fixes $repository#$TrackingNumber", '', + 'Review the complete change and run the normal release-branch CI before merging. No AI resolver was used.') + } + elseif ($State.result.outcome -eq 'conflict') { + $lines += @('', 'The complete source PR could not be cherry-picked cleanly. No partial branch or PR was published.', + 'A developer must resolve the backport manually; automatic runs do not retry this conflict.') + } + return $lines -join "`n" +} + +function Complete-BackportPull { + param([hashtable] $State, [hashtable] $Tracking, [hashtable] $Pull, [string] $BotLogin) + + Assert-BackportObject $Tracking $State $BotLogin + Assert-BackportObject $Pull $State $BotLogin + $repository = $State.repository + $labels = if ($State.ContainsKey('labels')) { $State.labels } else { $Tracking.labels } + Ensure-BackportLabels $repository $labels + foreach ($number in @($Tracking.number, $Pull.number)) { + $null = Invoke-BackportApi "repos/$repository/issues/$number/labels" -Method POST -Body @{ + labels = @($labels | ForEach-Object { $_.name }) + } + } + $body = $Tracking.body + $link = "Backport PR: $($Pull.html_url)" + if (-not $body.Contains($link)) { + $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "$body`n`n$link" } + } + $status = if ($null -ne $Pull.merged_at) { 'merged' } elseif ($Pull.state -eq 'closed') { 'needs-attention' } else { 'created' } + Add-BackportStatus $repository $State.source_pr $status $State.target + if ($status -ne 'needs-attention') { Remove-BackportIntents $repository $State.source_pr $State.target } + if ($status -eq 'merged' -and $Tracking.state -ne 'closed') { + $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } + } + Write-BackportSummary "Main PR #$($State.source_pr): $($Pull.html_url) (**$status**); issue $($Tracking.html_url)." +} + +function Publish-ExtRequestBackport { + [CmdletBinding()] + param([string] $StatePath, [string] $BotLogin) + + $state = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 40 + if ($state.schema -ne 1 -or $BotLogin -cnotmatch '^[a-z0-9-]+\[bot\]$') { throw 'Invalid publication state or App identity.' } + Assert-BackportRepository $state.repository + Assert-BackportTarget $state.target + $repository = $state.repository + $source = Get-BackportSource $repository $state.source_pr + if ($source.merge_commit_sha -cne $state.source_sha) { throw 'Source merge identity changed.' } + if (-not $state.manual -and -not (Test-BackportAutomaticSource $source)) { throw 'Automatic source eligibility changed before publication.' } + $state.issues = @(Get-BackportSourceIssues $repository $state.source_pr) + if (-not $state.manual -and @($state.issues | Where-Object { + -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) + }).Count -gt 0) { throw 'A source issue is no longer automatically eligible.' } + $state.labels = @(Get-BackportLabels -Issues $state.issues -PrLabels $source.labels) + $tracking = Get-BackportTracking $repository $state.source_pr + if ($null -ne $tracking) { Assert-BackportObject $tracking $state $BotLogin } + $pull = Get-BackportPull $repository $state.source_pr $state.target + if ($null -ne $pull) { + if ($null -eq $tracking) { throw 'Existing backport PR has no tracking issue; reconcile manually.' } + Complete-BackportPull $state $tracking $pull $BotLogin + return + } + $labelsOnSource = @($source.labels | ForEach-Object { $_.name }) + if ((Get-BackportStatusLabel publishing $state.target) -in $labelsOnSource) { + throw 'A previous PR creation is unconfirmed. Inspect closed/open PRs before clearing the publishing label.' + } + if ($null -eq $tracking -and @($labelsOnSource | Where-Object { $_ -like 'ext-backport-*' }).Count -gt 0) { + throw 'Source has prior backport state but no tracking issue. Reconcile manually; do not create a duplicate.' + } + if ($null -ne $tracking -and ($tracking.state -eq 'closed' -or + (-not $state.manual -and (Get-BackportStatusLabel needs-attention $state.target) -in $labelsOnSource))) { + Write-BackportSummary "Main PR #$($state.source_pr): retained tracking issue $($tracking.html_url); manual attention is required." + return + } + if ($state.result.outcome -notin @('applied', 'conflict', 'already-present')) { throw 'No publishable preparation result.' } + Ensure-BackportLabels $repository $state.labels + if ($null -eq $tracking) { + # A durable intent makes an uncertain POST fail closed on later runs. + Add-BackportStatus $repository $state.source_pr pending $state.target + $title = "[Backport $(($state.target -split '/')[1])] #$($state.source_pr): $($state.source_title -replace '[\r\n]', ' ')" + if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } + $tracking = Invoke-BackportApi "repos/$repository/issues" -Method POST -Body @{ + title = $title; body = Get-BackportBody $state; type = 'Task' + labels = @($state.labels | ForEach-Object { $_.name }) + } + $tracking = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" + Assert-BackportObject $tracking $state $BotLogin + } + if ($state.result.outcome -eq 'conflict') { + Add-BackportStatus $repository $state.source_pr needs-attention $state.target + Write-BackportSummary "Conflict for main PR #$($state.source_pr); manual backport tracked at $($tracking.html_url)." + return + } + if ($state.result.outcome -eq 'already-present') { + Add-BackportStatus $repository $state.source_pr already-present $state.target + $null = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } + Remove-BackportIntents $repository $state.source_pr $state.target + Write-BackportSummary "Main PR #$($state.source_pr) is already present; closed $($tracking.html_url) without an empty PR." + return + } + $directory = Join-Path (Split-Path -Parent $StatePath) 'git' + $head = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $tree = (Invoke-BackportGit $directory @('rev-parse', 'HEAD^{tree}')).text.Trim() + $parent = (Invoke-BackportGit $directory @('rev-parse', 'HEAD^')).text.Trim() + if ($head -cne $state.result.commit -or $tree -cne $state.result.tree -or $parent -cne $state.result.target_sha) { + throw 'Prepared Git result does not match the publication state.' + } + $comparison = Invoke-BackportApi "repos/$repository/compare/$parent...$($state.target)" + if ($comparison.status -notin @('ahead', 'identical')) { throw 'Pinned release parent is no longer on the release branch.' } + $branch = Get-BackportBranch $state.source_pr $state.target + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if ($remote) { + if (($remote -split '\s+')[0] -cne $head) { throw 'Existing backport branch differs; never overwrite it.' } + } + else { + $null = Invoke-BackportGit $directory @('push', 'origin', "$head`:refs/heads/$branch") + $readback = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if (($readback -split '\s+')[0] -cne $head) { throw 'Backport branch publication was not independently confirmed.' } + } + Add-BackportStatus $repository $state.source_pr publishing $state.target + $title = "[Backport $(($state.target -split '/')[1])] #$($state.source_pr): $($state.source_title -replace '[\r\n]', ' ')" + if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } + $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ + title = $title; body = Get-BackportBody $state $tracking.number + head = $branch; base = $state.target; draft = $true + } + $pull = Invoke-BackportApi "repos/$repository/pulls/$($pull.number)" + Assert-BackportObject $pull $state $BotLogin + if ($pull.head.sha -cne $head) { throw 'Created backport PR does not contain the prepared commit.' } + Complete-BackportPull $state $tracking $pull $BotLogin +} + +function Sync-ExtRequestBackport { + [CmdletBinding()] + param([string] $Repository, [string] $BotLogin) + + Assert-BackportRepository $Repository + foreach ($tracking in @(Get-BackportApiPages "repos/$Repository/issues?state=open&labels=ext-backporting")) { + if ($tracking.ContainsKey('pull_request')) { continue } + $marker = Read-BackportMarker $tracking.body + if ($null -eq $marker) { continue } + $state = @{ repository = $Repository; source_pr = $marker.source_pr; source_sha = $marker.sha; target = $marker.target } + Assert-BackportObject $tracking $state $BotLogin + $pull = Get-BackportPull $Repository $marker.source_pr $marker.target + if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin } + } +} + +Export-ModuleMember -Function Find-ExtRequestBackport, New-ExtRequestBackport, Publish-ExtRequestBackport, Sync-ExtRequestBackport diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md new file mode 100644 index 00000000000..3698d5437a5 --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -0,0 +1,128 @@ +# Extensibility request backports + +`bc-extrequest-backport.yml` runs at 21:10 Europe/Copenhagen. It scans the rolling +last seven days for PRs merged into `main`, with no fixed start date. It includes +`event-request`, `request-for-external`, or both, but rejects other request +categories (`enum-request`, `extensibility-enhancement`, `extensibility-bug`, +other `*-request` / `request-for-*` labels). Linked source issues must also have +only the allowed request categories. Unrelated labels, including team labels, +do not prevent selection. + +The entire merged PR is backported, including all issues in a team batch. +Each source PR gets one tracking issue of type **Task** and, when the complete +change applies cleanly, one **draft** release PR. Both descriptions link the +main PR and every issue in its `closingIssuesReferences`. Both new objects +inherit the deduplicated union of the original issues' labels, excluding only +`ext-ready-to-implement`, and add `ext-backporting`. For a manually selected PR +with no closing issue references, labels come from that PR instead. + +## Configuration + +Use the existing `extensibility-agent` environment and GitHub App: + +| Setting | Purpose | +| --- | --- | +| Repository variable `EXT_REQ_BACKPORT_ENABLED=true` | Explicitly enables scheduled publication. Leave unset or false until deployment is approved. | +| Optional repository variable `EXT_REQ_BACKPORT_TARGET_BRANCH` | Overrides automatic selection, for example `releases/29.x`. Must name an existing `releases/.x` branch. | +| Environment variable `EXTENSIBILITY_CLIENT_ID` | Existing GitHub App client ID. | +| Environment secret `EXTENSIBILITY_TRIAGE_APP_PRIVATE_KEY` | Existing App private key. | + +The App installation must allow **Contents: write**, **Issues: write** and +**Pull requests: write** for BCApps. The workflow's own token is read-only. +The App token is minted only after preparation, is scoped to BCApps, and is +revoked by `actions/create-github-app-token` at job completion. +No Copilot token, Azure login or model access is needed. + +Without an override, branches are paginated and the highest numerical +`releases/.x` is selected. Patch releases and preview names do not +participate. Once an issue or source status exists, that request retains its +original target even when a newer release branch appears. + +## Manual operation + +Run from `main` after the workflow has merged: + +```powershell +# Read-only preview; no App token or remote writes. +gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` + -f source_pr=12345 -f dry_run=true + +# Publish the backport issue and draft PR. +gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` + -f source_pr=12345 -f dry_run=false +``` + +Manual runs accept a **PR number, not an issue number**. They bypass age and +request-category filters but still require a merged `main` PR, safe content, +the original target for an existing request, and duplicate prevention. + +Automatic failures older than seven days need a manual run. Discovery uses +paginated REST data rather than the eventually indexed search API. More than +256 eligible PRs causes an explicit failure, not a silently truncated batch. +The job summary includes the selected target, source issues and outcome. + +## Publication and recovery + +The deterministic branch is `bc-extrequest-backport/pr-/.x`. +Tracking issues and PRs carry a versioned marker with the main PR, target and +source merge SHA. Existing objects must have the expected marker, repository, +branches and GitHub App author. Open and closed PRs are both checked. +Branches are never force-pushed and human edits are never overwritten. + +Source PR status labels are created as needed: + +| Label | Meaning | +| --- | --- | +| `ext-backport-created:29.x` | The corresponding backport PR exists. | +| `ext-backport-needs-attention:29.x` | The source conflicts or its backport PR was closed without merging. | +| `ext-backport-merged:29.x` | The backport PR merged. | +| `ext-backport-already-present:29.x` | The entire patch is already present; the issue closes without an empty PR. | +| `ext-backport-pending:29.x` | Durable intent to create the tracking issue. | +| `ext-backport-publishing:29.x` | Durable intent to create the backport PR. | + +The version suffix follows the pinned release. Labels are display/recovery +state, not the only duplicate check. A retry repairs missing issue/PR labels +and reciprocal links rather than creating another pair. A scheduled +reconciliation also handles older open tracking issues: when their release PR +merges, it closes the issue and marks the source PR, even outside the seven-day +discovery window. Release-targeted PRs do not automatically close GitHub issues, +so reconciliation explicitly closes the new Task. + +API writes are not automatically retried. If issue or PR creation has an +uncertain outcome, its intent label remains. A later run first discovers the +actual objects. If an intent exists but its object cannot be found, publication +stops for operator reconciliation. Before removing an intent label, inspect +all open/closed tracking objects and the deterministic branch and establish +whether creation occurred. Never clear labels simply to bypass duplicate checks. + +Conflicts create a linked tracking issue but no partial branch or PR, and are +not retried automatically. Resolve the backport manually using the recorded +source/target identity, or rerun that main PR manually if the target has changed. +Closed-unmerged PRs and closed tracking issues are not recreated automatically. + +## Content boundaries + +The worker uses native Git, not the issue implementation skill and not an AI +resolver. It adapts the separation of preparation/publication, source identity, +already-applied proof and conservative failure behavior used by the BCAppsTest +backport pilot; it does not import that pilot's label admission, private +deployment identities or experimental AI workflow. + +BCApps currently allows squash merges only. A source commit must have one +parent and be on `main`. Only regular, non-executable files under `src/` are +admitted, at most 250 paths. Renames are handled as delete/add pairs. Changes to +workflow/build instructions, symlinks, submodules and unsafe paths stop before +publication. A temporary sparse Git checkout contains only affected paths. +Global/system Git config, hooks, filters and repository normalization are +disabled, and source content is never executed. A conflict aborts the complete +cherry-pick. Publication verifies the prepared commit/tree/parent and reads back +the pushed ref before creating the PR. + +Normal release CI and developer review remain mandatory. The workflow never +merges a backport PR. Its offline Pester tests run for relevant workflow PRs and +before discovery; they also exercise real local Git cherry-picks without remote +writes. Run them with the repository's Pester tooling: + +```powershell +Invoke-Pester .github/scripts/bc-extrequest-backport/Backport.Test.ps1 +``` diff --git a/.github/workflows/bc-extrequest-backport.yml b/.github/workflows/bc-extrequest-backport.yml new file mode 100644 index 00000000000..c453a32a02f --- /dev/null +++ b/.github/workflows/bc-extrequest-backport.yml @@ -0,0 +1,188 @@ +name: 'Extensibility Request Backport' + +run-name: >- + ${{ github.event_name == 'workflow_dispatch' && + format('Backport main PR #{0} (preview = {1})', inputs.source_pr, inputs.dry_run) || + 'Backport recently merged extensibility requests' }} + +on: + schedule: + - cron: '10 21 * * *' + timezone: 'Europe/Copenhagen' + workflow_dispatch: + inputs: + source_pr: + description: Merged main PR number (bypasses date and request-category filters) + required: true + type: string + dry_run: + description: Preview without creating or changing branches, issues, PRs or labels + required: true + default: true + type: boolean + pull_request: + paths: + - '.github/workflows/bc-extrequest-backport.yml' + - '.github/scripts/bc-extrequest-backport/**' + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +concurrency: + group: extrequest-backport-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'publication' }} + cancel-in-progress: false + +jobs: + tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/scripts/bc-extrequest-backport + - name: Run offline PowerShell tests + run: | + if (-not (Get-Module -ListAvailable Pester | Where-Object Version -eq '5.7.1')) { + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + } + Import-Module Pester -RequiredVersion 5.7.1 + $result = Invoke-Pester ./.github/scripts/bc-extrequest-backport/Backport.Test.ps1 -PassThru + if ($result.Result -ne 'Passed' -or $result.PassedCount -eq 0) { + throw 'Backport tests failed or no tests ran.' + } + + discover: + needs: tests + if: >- + github.repository == 'microsoft/BCApps' && + github.ref == 'refs/heads/main' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'schedule' && vars.EXT_REQ_BACKPORT_ENABLED == 'true')) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + issues: read + pull-requests: read + outputs: + matrix: ${{ steps.discover.outputs.matrix }} + has-work: ${{ steps.discover.outputs.has-work }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts/bc-extrequest-backport + - name: Discover merged main PRs + id: discover + env: + GH_TOKEN: ${{ github.token }} + SOURCE_PR: ${{ inputs.source_pr }} + TARGET_OVERRIDE: ${{ vars.EXT_REQ_BACKPORT_TARGET_BRANCH }} + run: | + Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force + $requests = @(Find-ExtRequestBackport -Repository $env:GITHUB_REPOSITORY ` + -SourcePr $env:SOURCE_PR -TargetOverride $env:TARGET_OVERRIDE) + "has-work=$(($requests.Count -gt 0).ToString().ToLowerInvariant())" | + Add-Content $env:GITHUB_OUTPUT + "matrix=$(@{ include = $requests } | ConvertTo-Json -Depth 5 -Compress)" | + Add-Content $env:GITHUB_OUTPUT + + backport: + needs: discover + if: needs.discover.outputs.has-work == 'true' + runs-on: ubuntu-latest + environment: extensibility-agent + timeout-minutes: 45 + permissions: + contents: read + issues: read + pull-requests: read + strategy: + fail-fast: false + max-parallel: 1 + matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} + concurrency: + group: extrequest-backport-${{ github.repository }}-${{ matrix.source_pr }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts/bc-extrequest-backport + - name: Prepare backport without publication credentials + id: prepare + env: + GH_TOKEN: ${{ github.token }} + SOURCE_PR: ${{ matrix.source_pr }} + TARGET_BRANCH: ${{ matrix.target }} + MANUAL: ${{ github.event_name == 'workflow_dispatch' }} + run: | + Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force + $statePath = Join-Path $env:RUNNER_TEMP 'extrequest-backport/state.json' + New-ExtRequestBackport -Repository $env:GITHUB_REPOSITORY ` + -SourcePr ([long]$env:SOURCE_PR) -Target $env:TARGET_BRANCH ` + -Manual ($env:MANUAL -eq 'true') -StatePath $statePath + - name: Mint fresh GitHub App publication token + if: github.event_name != 'workflow_dispatch' || !inputs.dry_run + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.EXTENSIBILITY_CLIENT_ID }} + private-key: ${{ secrets.EXTENSIBILITY_TRIAGE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: BCApps + permission-contents: write + permission-issues: write + permission-pull-requests: write + - name: Publish or reconcile backport + if: github.event_name != 'workflow_dispatch' || !inputs.dry_run + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BACKPORT_BOT_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot] + run: | + Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force + Publish-ExtRequestBackport -StatePath (Join-Path $env:RUNNER_TEMP 'extrequest-backport/state.json') ` + -BotLogin $env:BACKPORT_BOT_LOGIN + + reconcile: + needs: [discover, backport] + if: always() && github.event_name == 'schedule' && needs.discover.result == 'success' + runs-on: ubuntu-latest + environment: extensibility-agent + timeout-minutes: 15 + permissions: + contents: read + issues: read + pull-requests: read + concurrency: + group: extrequest-backport-reconcile-${{ github.repository }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts/bc-extrequest-backport + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + client-id: ${{ vars.EXTENSIBILITY_CLIENT_ID }} + private-key: ${{ secrets.EXTENSIBILITY_TRIAGE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: BCApps + permission-issues: write + permission-pull-requests: read + - name: Close completed tracking issues and mark source PRs + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BACKPORT_BOT_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot] + run: | + Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force + Sync-ExtRequestBackport -Repository $env:GITHUB_REPOSITORY -BotLogin $env:BACKPORT_BOT_LOGIN From 334f2aae31b86ab14d9f1930881547e25b665e29 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Mon, 19 Oct 2026 11:41:13 +0200 Subject: [PATCH 02/13] Mark processed source PRs for backport exclusion Add ext-backporting to original main PRs once their backport outcome is durably tracked. Exclude processed sources from nightly discovery while preserving creation-intent recovery and manual duplicate checks. Clarify one backport issue/PR pair per source main PR. AB#649354 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 65 +++++++++++++++++++ .../bc-extrequest-backport/Backport.psm1 | 13 +++- .../scripts/bc-extrequest-backport/README.md | 20 ++++-- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index 70df247bc7e..19e775834f7 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -50,6 +50,11 @@ Describe 'Extensibility backport policy' { Test-BackportAutomaticSource $source | Should -BeFalse } + It 'excludes processed original main PRs carrying ext-backporting' { + $source = New-TestSource @('event-request', 'ext-backporting') + Test-BackportAutomaticSource $source ([DateTimeOffset]'2026-10-19T12:00:00Z') | Should -BeFalse + } + It 'selects the highest numerical .x branch, not a patch release or lexical maximum' { Get-BackportLatestTarget @('releases/9.x', 'releases/29.9', 'releases/29.x', 'releases/30.x-preview', 'main', 'releases/28.x') | Should -Be 'releases/29.x' @@ -179,6 +184,57 @@ Describe 'Backport discovery and recovery' { Should -Invoke Invoke-BackportApi -Times 2 } + It 'does not rediscover a processed source PR in the seven-day window' { + Mock Invoke-BackportApi { + @( + @{ + number = 42; merged_at = [DateTimeOffset]::UtcNow.AddDays(-1).ToString('o') + updated_at = [DateTimeOffset]::UtcNow.ToString('o'); base = @{ ref = 'main' } + labels = @(@{ name = 'event-request' }, @{ name = 'ext-backporting' }) + } + @{ + number = 43; merged_at = [DateTimeOffset]::UtcNow.AddDays(-1).ToString('o') + updated_at = [DateTimeOffset]::UtcNow.ToString('o'); base = @{ ref = 'main' } + labels = @(@{ name = 'request-for-external' }) + } + ) + } + $requests = @(Find-ExtRequestBackport microsoft/BCApps) + $requests.Count | Should -Be 1 + $requests[0].source_pr | Should -Be 43 + } + + It 'marks the original PR as processed only after a durable outcome' -ForEach @( + @{ status = 'created' } + @{ status = 'merged' } + @{ status = 'needs-attention' } + @{ status = 'already-present' } + ) { + Mock Ensure-BackportLabels {} + Mock Invoke-BackportApi {} + Add-BackportStatus microsoft/BCApps 42 $status 'releases/29.x' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and $Method -eq 'POST' -and + $Body.labels -contains 'ext-backporting' -and $Body.labels.Count -eq 2 + } + Should -Invoke Ensure-BackportLabels -Times 1 -ParameterFilter { + @($Labels | Where-Object name -eq 'ext-backporting').Count -eq 1 + } + } + + It 'does not hide unfinished issue or PR creation from automatic recovery' -ForEach @( + @{ status = 'pending' } + @{ status = 'publishing' } + ) { + Mock Ensure-BackportLabels {} + Mock Invoke-BackportApi {} + Add-BackportStatus microsoft/BCApps 42 $status 'releases/29.x' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and + $Body.labels -notcontains 'ext-backporting' -and $Body.labels.Count -eq 1 + } + } + It 'refuses manually selected unmerged PRs too' { Mock Invoke-BackportApi { @{ merged_at = $null; base = @{ ref = 'main' } } } { Get-BackportSource microsoft/BCApps 42 } | Should -Throw @@ -419,6 +475,15 @@ Describe 'Native Git backport integration' { Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } } + It 'does not recreate processed work whose tracking issue is missing, even manually' { + $source.labels += @{ name = 'ext-backporting' } + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' + } + } + It 'does not repeat an uncertain release-PR POST' { $source.labels += @{ name = 'ext-backport-publishing:29.x' } $script:tracking = @{ diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index 53dc9f26c62..79b2050dfd0 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -454,8 +454,14 @@ function Add-BackportStatus { param([string] $Repository, [long] $SourcePr, [string] $Status, [string] $Target) $name = Get-BackportStatusLabel $Status $Target - Ensure-BackportLabels $Repository @(@{ name = $name; color = 'C5DEF5'; description = 'Extensibility backport status.' }) - $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels" -Method POST -Body @{ labels = @($name) } + $definitions = @(@{ name = $name; color = 'C5DEF5'; description = 'Extensibility backport status.' }) + $names = @($name) + if ($Status -in @('created', 'merged', 'needs-attention', 'already-present')) { + $definitions += @(Get-BackportLabels -Issues @() -PrLabels @()) + $names += 'ext-backporting' + } + Ensure-BackportLabels $Repository $definitions + $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels" -Method POST -Body @{ labels = $names } } function Remove-BackportIntents { @@ -571,7 +577,8 @@ function Publish-ExtRequestBackport { if ((Get-BackportStatusLabel publishing $state.target) -in $labelsOnSource) { throw 'A previous PR creation is unconfirmed. Inspect closed/open PRs before clearing the publishing label.' } - if ($null -eq $tracking -and @($labelsOnSource | Where-Object { $_ -like 'ext-backport-*' }).Count -gt 0) { + if ($null -eq $tracking -and ($labelsOnSource -contains 'ext-backporting' -or + @($labelsOnSource | Where-Object { $_ -like 'ext-backport-*' }).Count -gt 0)) { throw 'Source has prior backport state but no tracking issue. Reconcile manually; do not create a duplicate.' } if ($null -ne $tracking -and ($tracking.state -eq 'closed' -or diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index 3698d5437a5..17af0921d09 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -6,7 +6,8 @@ last seven days for PRs merged into `main`, with no fixed start date. It include categories (`enum-request`, `extensibility-enhancement`, `extensibility-bug`, other `*-request` / `request-for-*` labels). Linked source issues must also have only the allowed request categories. Unrelated labels, including team labels, -do not prevent selection. +do not prevent selection. Original main PRs carrying `ext-backporting` are +excluded from automatic discovery. The entire merged PR is backported, including all issues in a team batch. Each source PR gets one tracking issue of type **Task** and, when the complete @@ -16,6 +17,10 @@ inherit the deduplicated union of the original issues' labels, excluding only `ext-ready-to-implement`, and add `ext-backporting`. For a manually selected PR with no closing issue references, labels come from that PR instead. +This is not one combined backport per day: three eligible main PRs produce +three tracking issues and three draft backport PRs, assuming all apply cleanly. +A single team-batch main PR still produces only one issue/PR pair. + ## Configuration Use the existing `extensibility-agent` environment and GitHub App: @@ -52,9 +57,10 @@ gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` -f source_pr=12345 -f dry_run=false ``` -Manual runs accept a **PR number, not an issue number**. They bypass age and -request-category filters but still require a merged `main` PR, safe content, -the original target for an existing request, and duplicate prevention. +Manual runs accept a **PR number, not an issue number**. They bypass age, +request-category and processed-label discovery filters but still require a +merged `main` PR, safe content, the original target for an existing request, +and duplicate prevention. A processed source can be reconciled, not duplicated. Automatic failures older than seven days need a manual run. Discovery uses paginated REST data rather than the eventually indexed search API. More than @@ -73,6 +79,7 @@ Source PR status labels are created as needed: | Label | Meaning | | --- | --- | +| `ext-backporting` | Added to the original main PR once a backport PR exists, a conflict is tracked, or the patch is proven already present. Excludes that main PR from future automatic discovery. Also appears on the new backport issue and PR. | | `ext-backport-created:29.x` | The corresponding backport PR exists. | | `ext-backport-needs-attention:29.x` | The source conflicts or its backport PR was closed without merging. | | `ext-backport-merged:29.x` | The backport PR merged. | @@ -80,8 +87,9 @@ Source PR status labels are created as needed: | `ext-backport-pending:29.x` | Durable intent to create the tracking issue. | | `ext-backport-publishing:29.x` | Durable intent to create the backport PR. | -The version suffix follows the pinned release. Labels are display/recovery -state, not the only duplicate check. A retry repairs missing issue/PR labels +The version suffix follows the pinned release. `ext-backporting` is the +automatic processed filter; versioned labels give its detailed status. Labels +are not the only duplicate check. A retry repairs missing issue/PR labels and reciprocal links rather than creating another pair. A scheduled reconciliation also handles older open tracking issues: when their release PR merges, it closes the issue and marks the source PR, even outside the seven-day From 46dfff9f567b506be9e87b1836a1dc7bcf2c0614 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Mon, 19 Oct 2026 11:49:21 +0200 Subject: [PATCH 03/13] Distinguish processed sources from backport work Use ext-backport-processed on original main PRs for automatic exclusion, while keeping ext-backporting on the new backport issue and PR. Preserve manual reconciliation and do not copy the source-only marker onto backport objects. AB#649354 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 19 +++++++++++-------- .../bc-extrequest-backport/Backport.psm1 | 15 +++++++++------ .../scripts/bc-extrequest-backport/README.md | 12 +++++++----- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index 19e775834f7..159e55d699a 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -50,8 +50,8 @@ Describe 'Extensibility backport policy' { Test-BackportAutomaticSource $source | Should -BeFalse } - It 'excludes processed original main PRs carrying ext-backporting' { - $source = New-TestSource @('event-request', 'ext-backporting') + It 'excludes processed original main PRs carrying ext-backport-processed' { + $source = New-TestSource @('event-request', 'ext-backport-processed') Test-BackportAutomaticSource $source ([DateTimeOffset]'2026-10-19T12:00:00Z') | Should -BeFalse } @@ -84,11 +84,13 @@ Describe 'Extensibility backport policy' { It 'uses source PR labels only when there are no linked main issues' { $labels = @(Get-BackportLabels -Issues @() -PrLabels @( - @{ name = 'event-request' }, @{ name = 'ext-ready-to-implement' } + @{ name = 'event-request' }, @{ name = 'ext-ready-to-implement' }, + @{ name = 'ext-backport-processed' } )) $labels.name | Should -Contain 'event-request' $labels.name | Should -Contain 'ext-backporting' $labels.name | Should -Not -Contain 'ext-ready-to-implement' + $labels.name | Should -Not -Contain 'ext-backport-processed' } It 'pins an existing request to its original release after a new release appears' { @@ -190,7 +192,7 @@ Describe 'Backport discovery and recovery' { @{ number = 42; merged_at = [DateTimeOffset]::UtcNow.AddDays(-1).ToString('o') updated_at = [DateTimeOffset]::UtcNow.ToString('o'); base = @{ ref = 'main' } - labels = @(@{ name = 'event-request' }, @{ name = 'ext-backporting' }) + labels = @(@{ name = 'event-request' }, @{ name = 'ext-backport-processed' }) } @{ number = 43; merged_at = [DateTimeOffset]::UtcNow.AddDays(-1).ToString('o') @@ -215,10 +217,11 @@ Describe 'Backport discovery and recovery' { Add-BackportStatus microsoft/BCApps 42 $status 'releases/29.x' Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and $Method -eq 'POST' -and - $Body.labels -contains 'ext-backporting' -and $Body.labels.Count -eq 2 + $Body.labels -contains 'ext-backport-processed' -and + $Body.labels -notcontains 'ext-backporting' -and $Body.labels.Count -eq 2 } Should -Invoke Ensure-BackportLabels -Times 1 -ParameterFilter { - @($Labels | Where-Object name -eq 'ext-backporting').Count -eq 1 + @($Labels | Where-Object name -eq 'ext-backport-processed').Count -eq 1 } } @@ -231,7 +234,7 @@ Describe 'Backport discovery and recovery' { Add-BackportStatus microsoft/BCApps 42 $status 'releases/29.x' Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and - $Body.labels -notcontains 'ext-backporting' -and $Body.labels.Count -eq 1 + $Body.labels -notcontains 'ext-backport-processed' -and $Body.labels.Count -eq 1 } } @@ -476,7 +479,7 @@ Describe 'Native Git backport integration' { } It 'does not recreate processed work whose tracking issue is missing, even manually' { - $source.labels += @{ name = 'ext-backporting' } + $source.labels += @{ name = 'ext-backport-processed' } $state | ConvertTo-Json -Depth 20 | Set-Content $statePath { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index 79b2050dfd0..0b0f2c1de40 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -108,7 +108,8 @@ function Test-BackportCategory { $_ -in @('extensibility-enhancement', 'extensibility-bug')) }) return @($Labels | Where-Object { $_ -in $allowed }).Count -gt 0 -and - $other.Count -eq 0 -and $Labels -notcontains 'ext-backporting' + $other.Count -eq 0 -and $Labels -notcontains 'ext-backporting' -and + $Labels -notcontains 'ext-backport-processed' } function Test-BackportAutomaticSource { @@ -200,7 +201,7 @@ function Get-BackportLabels { $original = if ($Issues.Count -gt 0) { @($Issues | ForEach-Object { $_.labels.nodes }) } - else { $PrLabels } + else { @($PrLabels | Where-Object { $_.name -ne 'ext-backport-processed' }) } foreach ($label in $original) { if ($label.name -ne 'ext-ready-to-implement') { $labels[$label.name] = $label } } @@ -457,8 +458,11 @@ function Add-BackportStatus { $definitions = @(@{ name = $name; color = 'C5DEF5'; description = 'Extensibility backport status.' }) $names = @($name) if ($Status -in @('created', 'merged', 'needs-attention', 'already-present')) { - $definitions += @(Get-BackportLabels -Issues @() -PrLabels @()) - $names += 'ext-backporting' + $definitions += @{ + name = 'ext-backport-processed'; color = 'C5DEF5' + description = 'Source PR handled by extensibility backport automation.' + } + $names += 'ext-backport-processed' } Ensure-BackportLabels $Repository $definitions $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels" -Method POST -Body @{ labels = $names } @@ -577,8 +581,7 @@ function Publish-ExtRequestBackport { if ((Get-BackportStatusLabel publishing $state.target) -in $labelsOnSource) { throw 'A previous PR creation is unconfirmed. Inspect closed/open PRs before clearing the publishing label.' } - if ($null -eq $tracking -and ($labelsOnSource -contains 'ext-backporting' -or - @($labelsOnSource | Where-Object { $_ -like 'ext-backport-*' }).Count -gt 0)) { + if ($null -eq $tracking -and @($labelsOnSource | Where-Object { $_ -like 'ext-backport-*' }).Count -gt 0) { throw 'Source has prior backport state but no tracking issue. Reconcile manually; do not create a duplicate.' } if ($null -ne $tracking -and ($tracking.state -eq 'closed' -or diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index 17af0921d09..b59d1c73a59 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -6,7 +6,7 @@ last seven days for PRs merged into `main`, with no fixed start date. It include categories (`enum-request`, `extensibility-enhancement`, `extensibility-bug`, other `*-request` / `request-for-*` labels). Linked source issues must also have only the allowed request categories. Unrelated labels, including team labels, -do not prevent selection. Original main PRs carrying `ext-backporting` are +do not prevent selection. Original main PRs carrying `ext-backport-processed` are excluded from automatic discovery. The entire merged PR is backported, including all issues in a team batch. @@ -15,7 +15,8 @@ change applies cleanly, one **draft** release PR. Both descriptions link the main PR and every issue in its `closingIssuesReferences`. Both new objects inherit the deduplicated union of the original issues' labels, excluding only `ext-ready-to-implement`, and add `ext-backporting`. For a manually selected PR -with no closing issue references, labels come from that PR instead. +with no closing issue references, labels come from that PR instead, excluding +the source-only `ext-backport-processed` marker. This is not one combined backport per day: three eligible main PRs produce three tracking issues and three draft backport PRs, assuming all apply cleanly. @@ -79,7 +80,7 @@ Source PR status labels are created as needed: | Label | Meaning | | --- | --- | -| `ext-backporting` | Added to the original main PR once a backport PR exists, a conflict is tracked, or the patch is proven already present. Excludes that main PR from future automatic discovery. Also appears on the new backport issue and PR. | +| `ext-backport-processed` | Added to the original main PR once a backport PR exists, a conflict is tracked, or the patch is proven already present. Excludes that main PR from future automatic discovery; does not imply the backport merged. | | `ext-backport-created:29.x` | The corresponding backport PR exists. | | `ext-backport-needs-attention:29.x` | The source conflicts or its backport PR was closed without merging. | | `ext-backport-merged:29.x` | The backport PR merged. | @@ -87,8 +88,9 @@ Source PR status labels are created as needed: | `ext-backport-pending:29.x` | Durable intent to create the tracking issue. | | `ext-backport-publishing:29.x` | Durable intent to create the backport PR. | -The version suffix follows the pinned release. `ext-backporting` is the -automatic processed filter; versioned labels give its detailed status. Labels +The version suffix follows the pinned release. `ext-backport-processed` is the +automatic source filter; `ext-backporting` identifies the backport issue and PR. +Versioned source labels give the detailed status. Labels are not the only duplicate check. A retry repairs missing issue/PR labels and reciprocal links rather than creating another pair. A scheduled reconciliation also handles older open tracking issues: when their release PR From 5dd5c0d7be7a84738fad0e6f95dd36018bf583ed Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Tue, 20 Oct 2026 11:18:57 +0200 Subject: [PATCH 04/13] Batch extensibility backports weekly by team Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 666 ++++++++++++++++- .../bc-extrequest-backport/Backport.psm1 | 681 +++++++++++++++++- .../scripts/bc-extrequest-backport/README.md | 168 +++-- .github/workflows/bc-extrequest-backport.yml | 59 +- 4 files changed, 1481 insertions(+), 93 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index 159e55d699a..6ba19d9712f 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -1,5 +1,536 @@ using module .\Backport.psm1 +BeforeAll { + & (Get-Module Backport) { + function script:New-BatchFixture { + param([string[]] $Outcomes = @('applied', 'applied', 'conflict')) + $request = @{ + mode = 'team'; team = 'Team: Finance'; target = 'releases/29.x'; batch_id = '123' + reference_time = [DateTimeOffset]::UtcNow.ToString('o'); manual = $false + source_prs = @(); sources = @() + concurrency_key = 'team-finance-29' + } + $members = @() + $tip = 'f' * 40 + for ($i = 0; $i -lt $Outcomes.Count; $i++) { + $number = 42 + $i + $sha = ([string][char](97 + $i)) * 40 + $request.source_prs += $number + $request.sources += @{ number = $number; sha = $sha; merged_at = [DateTimeOffset]::UtcNow.AddDays(-10 + $i).ToString('o') } + $result = @{ outcome = $Outcomes[$i]; target_sha = $tip; commit = $null; tree = $null; paths = @("src/$number.al") } + if ($Outcomes[$i] -eq 'applied') { + $result.commit = ([string]($i + 1)) * 40 + $result.tree = ([string]($i + 4)) * 40 + $tip = $result.commit + } + $labels = @( + @{ name = 'event-request'; color = 'ffffff'; description = '' } + @{ name = 'Team: Finance'; color = 'ffffff'; description = '' } + @{ name = "issue-$i"; color = 'ffffff'; description = '' } + @{ name = 'ext-backporting'; color = '5319E7'; description = '' } + ) + $members += @{ + schema = 1; repository = 'microsoft/BCApps'; source_pr = $number; source_sha = $sha + target = $request.target; manual = $false; reference_time = $request.reference_time; source_title = "Source $number" + labels = $labels + issues = @(@{ number = 10; url = 'https://github.com/microsoft/BCApps/issues/10' + repository = @{ nameWithOwner = 'microsoft/BCApps' }; labels = @{ nodes = $labels[0..2] } }) + result = $result + } + } + $module = Get-Module Backport + $request.key = & $module { + param($Row) + 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $Row)).Substring(0, 32) + } $request + $tree = if (@($members | Where-Object { $_.result.outcome -eq 'applied' }).Count -gt 0) { + @($members | Where-Object { $_.result.outcome -eq 'applied' })[-1].result.tree + } else { 'e' * 40 } + @{ schema = 2; repository = 'microsoft/BCApps'; request = $request; members = $members; target_sha = 'f' * 40 + head = $tip; tree = $tree } + } + } +} + +Describe 'Team batch discovery and immutable identity' { + InModuleScope Backport { + BeforeEach { + $script:fixture = New-BatchFixture + $script:sourceData = @{} + $script:issueData = @{} + foreach ($member in $fixture.members) { + $script:sourceData[[string]$member.source_pr] = @{ + number = $member.source_pr; title = $member.source_title; merge_commit_sha = $member.source_sha + merged_at = $fixture.request.sources[$member.source_pr - 42].merged_at + updated_at = [DateTimeOffset]::UtcNow.ToString('o') + base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } + labels = @($member.labels | Where-Object { $_.name -ne 'ext-backporting' }) + } + $script:issueData[[string]$member.source_pr] = $member.issues + } + Mock Get-BackportBatchRecords { @() } + Mock Get-BackportTracking { $null } + Mock Get-BackportSource { $script:sourceData[[string]$Number] } + Mock Get-BackportSourceIssues { $script:issueData[[string]$Number] } + Mock Get-BackportApiPages { @(@{ name = 'releases/29.x' }, @{ name = 'releases/9.x' }) } + Mock Invoke-BackportApi { + if ([string]::IsNullOrEmpty($Method)) { $Method = 'GET' } + if ($Method -ne 'GET') { throw 'Discovery must be read-only.' } + @($script:sourceData.Values) + } + } + + It 'groups whole multi-issue sources by their main PR team in oldest-first order' { + $script:issueData['42'] += @{ + number = 11; url = 'https://github.com/microsoft/BCApps/issues/11' + repository = @{ nameWithOwner = 'microsoft/BCApps' } + labels = @{ nodes = @(@{ name = 'event-request' }, @{ name = 'Team: Finance' }) } + } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) + $rows.Count | Should -Be 1 + $rows[0].mode | Should -Be 'team' + $rows[0].source_prs -join ',' | Should -Be '42,43,44' + $rows[0].team | Should -Be 'Team: Finance' + $rows[0].key | Should -Match '^team-[a-f0-9]{32}$' + Assert-BackportBatchRequest $rows[0] + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' } + } + + It 'keeps missing, multiple or inconsistent teams independent without skipping sources' -ForEach @( + @{ kind = 'missing-pr' }, @{ kind = 'multiple-pr' }, @{ kind = 'missing-issue' }, + @{ kind = 'multiple-issue' }, @{ kind = 'different-issue' }, @{ kind = 'external-issue' } + ) { + switch ($kind) { + 'missing-pr' { $script:sourceData['42'].labels = @(@{ name = 'event-request' }) } + 'multiple-pr' { $script:sourceData['42'].labels += @{ name = 'Team: Other' } } + 'missing-issue' { $script:issueData['42'][0].labels.nodes = @(@{ name = 'event-request' }) } + 'multiple-issue' { $script:issueData['42'][0].labels.nodes += @{ name = 'Team: Other' } } + 'different-issue' { + $script:issueData['42'][0].labels.nodes = @(@{ name = 'event-request' }, @{ name = 'Team: Other' }) + } + 'external-issue' { $script:issueData['42'][0].repository.nameWithOwner = 'other/repo' } + } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) + $rows.Count | Should -Be 2 + @($rows | Where-Object mode -eq 'single')[0].source_prs -join ',' | Should -Be '42' + @($rows | Where-Object mode -eq 'team')[0].source_prs -join ',' | Should -Be '43,44' + } + + It 'uses the sole main PR team when no original issue exists' { + $script:issueData['42'] = @() + @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123).Count | Should -Be 1 + } + + It 'retains category checks on each linked original issue' { + $script:issueData['42'][0].labels.nodes += @{ name = 'enum-request' } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) + $rows[0].source_prs -join ',' | Should -Be '43,44' + } + + It 'keeps legacy requests and interrupted source intents independent and pinned' { + $script:sourceData['42'].labels += @{ name = 'ext-backport-pending:28.x' } + Mock Get-BackportTracking { + if ($SourcePr -eq 43) { + @{ body = Get-BackportMarker 43 'releases/27.x' ('b' * 40) } + } + } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) + $rows.Count | Should -Be 3 + ($rows | Where-Object key -eq 'single-42').target | Should -Be 'releases/28.x' + ($rows | Where-Object key -eq 'single-43').target | Should -Be 'releases/27.x' + } + + It 'manual single bypasses dates, categories and processed filters and yields exactly one source' { + $script:sourceData['42'].merged_at = '2020-01-01T00:00:00Z' + $script:sourceData['42'].labels = @(@{ name = 'enum-request' }, @{ name = 'ext-backport-processed' }) + $script:issueData['42'][0].labels.nodes = @(@{ name = 'enum-request' }) + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 123) + $rows.Count | Should -Be 1 + $rows[0].mode | Should -Be 'single' + $rows[0].manual | Should -BeTrue + $rows[0].source_prs -join ',' | Should -Be '42' + } + + It 'manual full uses the same automatic filtering and fixed discovery clock' { + $script:sourceData['42'].labels += @{ name = 'ext-backport-processed' } + $script:sourceData['43'].merged_at = [DateTimeOffset]::UtcNow.AddDays(-16).ToString('o') + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr '' -BatchId 123) + $rows[0].source_prs -join ',' | Should -Be '44' + $rows[0].manual | Should -BeFalse + [DateTimeOffset]$rows[0].reference_time | Should -BeLessOrEqual ([DateTimeOffset]::UtcNow) + } + + It 'validates queued workers against the fixed discovery clock rather than their current date' { + $clock = [DateTimeOffset]::UtcNow.AddDays(-30) + $fixture.request.reference_time = $clock.ToString('o') + foreach ($proof in $fixture.request.sources) { + $proof.merged_at = $clock.AddDays(-14).AddMinutes($proof.number).ToString('o') + $script:sourceData[[string]$proof.number].merged_at = $proof.merged_at + } + Test-BackportAutomaticSource $script:sourceData['42'] | Should -BeFalse + @(Assert-BackportBatchSources $fixture.request microsoft/BCApps).Count | Should -Be 3 + } + + It 'delegates manual independent preparation and preserves the schema-one publication API' { + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 123)[0] + $path = Join-Path $TestDrive 'single.json' + Mock New-ExtRequestBackport { + @{ schema = 1; source_sha = 'a' * 40 } | ConvertTo-Json | Set-Content $StatePath + } + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $path + Should -Invoke New-ExtRequestBackport -Times 1 -ParameterFilter { + $Manual -and $SourcePr -eq 42 -and $Target -eq 'releases/29.x' -and + $ReferenceTime -eq [DateTimeOffset]$row.reference_time + } + Mock Publish-ExtRequestBackport {} + Publish-ExtRequestBackportBatch $path 'test-app[bot]' + Should -Invoke Publish-ExtRequestBackport -Times 1 + } + + It 'routes the old per-PR prepare API to its immutable existing batch' { + Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } + Mock New-ExtRequestBackportBatch {} + New-ExtRequestBackport microsoft/BCApps 42 releases/99.x $true (Join-Path $TestDrive 'legacy.json') + Should -Invoke New-ExtRequestBackportBatch -Times 1 -ParameterFilter { + ($RequestJson | ConvertFrom-Json).source_prs.Count -eq 3 -and + ($RequestJson | ConvertFrom-Json).target -eq 'releases/29.x' + } + } + + It 'manual batch member returns exactly its frozen batch including already processed members' { + Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } + $script:sourceData['42'].labels += @{ name = 'ext-backport-processed' } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 43 -TargetOverride releases/99.x -BatchId 999) + $rows.Count | Should -Be 1 + $rows[0].key | Should -Be $fixture.request.key + $rows[0].source_prs -join ',' | Should -Be '42,43,44' + $rows[0].target | Should -Be 'releases/29.x' + $rows[0].batch_id | Should -Be '123' + Should -Invoke Invoke-BackportApi -Times 0 + } + + It 'resumes an open immutable batch even when all members are outside discovery' { + Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } + $script:sourceData.Clear() + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 999) + $rows.Count | Should -Be 1 + $rows[0].key | Should -Be $fixture.request.key + } + + It 'rejects changed frozen membership, targets, source proofs and malformed manifests' -ForEach @( + @{ kind = 'target' }, @{ kind = 'membership' }, @{ kind = 'sha' }, @{ kind = 'parent' } + ) { + Assert-BackportBatchManifest $fixture + $marker = Get-BackportBatchMarker $fixture + (Read-BackportBatchMarker $marker).request.key | Should -Be $fixture.request.key + { Read-BackportBatchMarker "$marker`n$marker" } | Should -Throw + { Read-BackportBatchMarker '' } | Should -Throw + switch ($kind) { + 'target' { $fixture.request.target = 'releases/30.x' } + 'membership' { $fixture.request.source_prs[0] = 999 } + 'sha' { $fixture.request.sources[0].sha = 'e' * 40 } + 'parent' { $fixture.members[1].result.target_sha = 'e' * 40 } + } + { Assert-BackportBatchManifest $fixture } | Should -Throw + } + } +} + +Describe 'Batch publication and reconciliation' { + InModuleScope Backport { + BeforeEach { + $script:fixture = New-BatchFixture + $script:statePath = Join-Path $TestDrive 'batch-state.json' + $script:batchTracking = $null + $script:batchPull = $null + $script:singleTracking = @{} + $script:sourceData = @{} + foreach ($member in $fixture.members) { + $script:sourceData[[string]$member.source_pr] = @{ + number = $member.source_pr; title = $member.source_title; merge_commit_sha = $member.source_sha + merged_at = $fixture.request.sources[$member.source_pr - 42].merged_at + base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } + labels = @($member.labels | Where-Object { $_.name -ne 'ext-backporting' }) + } + } + $fixture | ConvertTo-Json -Depth 60 | Set-Content $statePath + Mock Get-BackportBatchRecords { + if ($null -ne $script:batchTracking) { @{ issue = $script:batchTracking; manifest = $script:fixture } } + } + Mock Get-BackportBatchPull { $script:batchPull } + Mock Get-BackportTracking { $script:singleTracking[[string]$SourcePr] } + Mock Get-BackportPull { $null } + Mock Get-BackportSource { $script:sourceData[[string]$Number] } + Mock Get-BackportSourceIssues { @($script:fixture.members | Where-Object source_pr -eq $Number)[0].issues } + Mock Ensure-BackportLabels {} + Mock Remove-BackportIntents {} + Mock Add-BackportStatus { + $script:sourceData[[string]$SourcePr].labels += @{ name = Get-BackportStatusLabel $Status $Target } + if ($Status -in @('created', 'merged', 'already-present', 'needs-attention')) { + $script:sourceData[[string]$SourcePr].labels += @{ name = 'ext-backport-processed' } + } + } + Mock Assert-BackportPreparedBatch {} + Mock Invoke-BackportGit { + if ($Arguments[0] -eq 'ls-remote') { return @{ text = "$($script:fixture.head)`trefs/heads/bc-extrequest-backport/$($script:fixture.request.key)" } } + throw "Unexpected Git write: $($Arguments -join ' ')" + } + Mock Get-BackportApiPages { + if ($null -ne $script:batchTracking) { $script:batchTracking } + foreach ($item in $script:singleTracking.Values) { $item } + } + Mock Invoke-BackportApi { + if ([string]::IsNullOrEmpty($Method)) { $Method = 'GET' } + if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues') { + $object = @{ + number = 100; title = $Body.title; body = $Body.body; state = 'open' + user = @{ login = 'test-app[bot]' }; labels = @() + html_url = 'https://github.com/microsoft/BCApps/issues/100' + } + if ($Body.body.Contains('bc-extrequest-backport:v2')) { $script:batchTracking = $object } + else { + $marker = Read-BackportMarker $Body.body + $object.number = 200 + $marker.source_pr + $object.html_url = "https://github.com/microsoft/BCApps/issues/$($object.number)" + $script:singleTracking[[string]$marker.source_pr] = $object + } + return $object + } + if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls') { + $script:batchPull = @{ + number = 101; body = $Body.body; draft = $Body.draft; state = 'open'; merged_at = $null + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/101' + head = @{ sha = $script:fixture.head; ref = $Body.head; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } } + } + return $script:batchPull + } + if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/pulls/101') { return $script:batchPull } + if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { return $script:batchTracking } + foreach ($object in $script:singleTracking.Values) { + if ($Method -eq 'GET' -and $Path -eq "repos/microsoft/BCApps/issues/$($object.number)") { return $object } + } + if ($Method -eq 'POST' -and $Path -match '/issues/\d+/labels$') { return } + if ($Method -eq 'PATCH' -and $Path -match '/issues/\d+$') { + if ($Path -eq 'repos/microsoft/BCApps/issues/100') { + if ($Body.ContainsKey('body')) { $script:batchTracking.body = $Body.body } + if ($Body.ContainsKey('state')) { $script:batchTracking.state = $Body.state } + } + return + } + throw "Unexpected remote operation: $Method $Path" + } + } + + It 'publishes one team Task and draft PR plus an independent conflict Task with deduplicated links and label union' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Path -eq 'repos/microsoft/BCApps/issues' -and $Method -eq 'POST' -and + $Body.body.Contains('bc-extrequest-backport:v2') -and $Body.type -eq 'Task' -and + $Body.labels -contains 'issue-0' -and $Body.labels -contains 'issue-1' -and + $Body.labels -notcontains 'issue-2' + } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Path -eq 'repos/microsoft/BCApps/pulls' -and $Method -eq 'POST' -and $Body.draft -and + $Body.body.Contains('Fixes microsoft/BCApps#100') -and $Body.base -eq 'releases/29.x' + } + $body = $script:batchPull.body + $body | Should -Match 'pull/42' + $body | Should -Match 'pull/43' + $body | Should -Match ('a' * 40) + $body | Should -Match ('b' * 40) + [regex]::Matches($body, 'https://github.com/microsoft/BCApps/issues/10').Count | Should -Be 1 + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 44 -and $Status -eq 'needs-attention' } + Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $SourcePr -eq 44 -and $Status -in @('created', 'merged') } + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { + $Arguments[0] -in @('cherry-pick', 'checkout', 'fetch') + } + } + + It 'reports actual team, source proofs, original issue links and each prepared outcome' { + Mock Write-BackportSummary {} + Write-BackportBatchSummary $fixture prepared + Should -Invoke Write-BackportSummary -Times 1 -ParameterFilter { + $Text.Contains('pull/42') -and $Text.Contains('Team: Finance') -and + $Text.Contains('issues/10') -and $Text.Contains('**applied**') -and $Text.Contains('a' * 40) + } + Should -Invoke Write-BackportSummary -Times 1 -ParameterFilter { + $Text.Contains('pull/44') -and $Text.Contains('issues/10') -and $Text.Contains('**conflict**') + } + } + + It 'daily reconciliation repairs EVERY included source even when GitHub already closed the merged tracking issue' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $script:batchPull.merged_at = '2026-10-20T09:00:00Z' + $script:batchTracking.state = 'closed' + Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'merged' } + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 43 -and $Status -eq 'merged' } + Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $SourcePr -eq 44 -and $Status -eq 'merged' } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + } + + It 'marks every included source needs-attention when the batch PR closes unmerged' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $script:batchPull.state = 'closed' + Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'needs-attention' } + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 43 -and $Status -eq 'needs-attention' } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'PATCH' -and $Path -eq 'repos/microsoft/BCApps/issues/100' -and + $Body.ContainsKey('state') -and $Body.state -eq 'closed' + } + } + + It 'reconciles an existing batch without duplicating issue, PR or conflict outcomes' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + } + + It 'does not blindly retry an uncertain team issue POST' { + Mock Invoke-BackportApi { throw 'Uncertain POST' } -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Uncertain POST*' + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } + } + + It 'recovers a confirmed team issue after an interrupted response without repeating its POST' { + Mock Invoke-BackportApi { + $script:batchTracking = @{ + number = 100; body = $Body.body; state = 'open'; labels = @() + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' + } + throw 'Lost response' + } -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v2') } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost response*' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v2') + } + $script:batchPull | Should -Not -BeNullOrEmpty + } + + It 'does not blindly repeat an unconfirmed batch PR POST' { + Mock Invoke-BackportApi { throw 'Uncertain PR POST' } -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Uncertain PR POST*' + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + } + + It 'reuses an accepted batch PR when its response was lost' { + Mock Invoke-BackportApi { + $script:batchPull = @{ + number = 101; body = $Body.body; draft = $true; state = 'open'; merged_at = $null + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/101' + head = @{ sha = $script:fixture.head; ref = $Body.head; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } } + } + throw 'Lost PR response' + } -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost PR response*' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'created' } + } + + It 'publishes the clean batch even when independent conflict issue publication fails' { + Mock Invoke-BackportApi { throw 'Independent POST failed' } -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and + $Body.body.Contains('bc-extrequest-backport:v1') + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Independent POST failed*' + $script:batchPull | Should -Not -BeNullOrEmpty + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'created' } + Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $SourcePr -eq 44 -and $Status -eq 'created' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and + $Body.body.Contains('bc-extrequest-backport:v1') + } + } + + It 'never publishes a branch or PR when every source is conflict or already present' { + $script:fixture = New-BatchFixture -Outcomes @('conflict', 'already-present') + foreach ($proof in $fixture.request.sources) { $script:sourceData[[string]$proof.number].merged_at = $proof.merged_at } + $fixture | ConvertTo-Json -Depth 60 | Set-Content $statePath + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'POST' -and ($Path -eq 'repos/microsoft/BCApps/pulls' -or + ($Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v2'))) + } + Should -Invoke Invoke-BackportGit -Times 0 + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 43 -and $Status -eq 'already-present' } + } + + It 'fails closed on modified prepared provenance before any remote write' { + Mock Assert-BackportPreparedBatch { throw 'Provenance changed' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Provenance changed*' + Should -Invoke Invoke-BackportApi -Times 0 + } + + It 'refuses changed source proofs before writes' { + $script:sourceData['43'].merge_commit_sha = '0' * 40 + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*SHA changed*' + Should -Invoke Invoke-BackportApi -Times 0 + } + + It 'refuses a different per-source pinned release instead of silently moving a resumed request' { + $script:sourceData['43'].labels += @{ name = 'ext-backport-pending:28.x' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*different pinned target*' + Should -Invoke Invoke-BackportApi -Times 0 + } + + It 'refuses a batch object by another actor even if its manifest is intact' { + $script:batchTracking = @{ + number = 100; body = Get-BackportBatchMarker $fixture; state = 'open'; user = @{ login = 'human' } + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*author*' + Should -Invoke Invoke-BackportApi -Times 0 + } + + It 'does not overwrite a batch branch with human changes' { + Mock Invoke-BackportGit { @{ text = "$('e' * 40)`trefs/heads/changed" } } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*never overwrite*' + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + } + + It 'defers already-present status until closure and recovers it through daily reconciliation' { + $script:fixture = New-BatchFixture -Outcomes @('already-present') + $script:sourceData['42'].merged_at = $fixture.request.sources[0].merged_at + $fixture | ConvertTo-Json -Depth 60 | Set-Content $statePath + Mock Invoke-BackportApi { throw 'Close failed' } -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Close failed*' + { Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' } | Should -Throw '*Close failed*' + Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $Status -eq 'already-present' } + Should -Invoke Remove-BackportIntents -Times 0 + Mock Invoke-BackportApi { $script:singleTracking['42'].state = 'closed' } -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' + } + Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' + Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { + $SourcePr -eq 42 -and $Status -eq 'already-present' + } + Should -Invoke Remove-BackportIntents -Times 1 -ParameterFilter { $SourcePr -eq 42 } + Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 3 -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' + } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' + } + } + } +} + Describe 'Extensibility backport policy' { InModuleScope Backport { BeforeAll { @@ -34,10 +565,10 @@ Describe 'Extensibility backport policy' { Test-BackportCategory $labels | Should -BeFalse } - It 'uses a rolling seven-day inclusive cutoff and has no November cutoff' { + It 'uses a rolling fifteen-day inclusive cutoff and has no November cutoff' { $now = [DateTimeOffset]'2026-10-19T12:00:00Z' - Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-12T12:00:00Z') $now | Should -BeTrue - Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-12T11:59:59Z') $now | Should -BeFalse + Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-04T12:00:00Z') $now | Should -BeTrue + Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-04T11:59:59Z') $now | Should -BeFalse Test-BackportAutomaticSource (New-TestSource -Merged '2026-10-19T12:00:01Z') $now | Should -BeFalse } @@ -186,7 +717,7 @@ Describe 'Backport discovery and recovery' { Should -Invoke Invoke-BackportApi -Times 2 } - It 'does not rediscover a processed source PR in the seven-day window' { + It 'does not rediscover a processed source PR in the fifteen-day window' { Mock Invoke-BackportApi { @( @{ @@ -349,6 +880,133 @@ Describe 'Native Git backport integration' { (Invoke-BackportGit $directory @('show', 'HEAD:src/Foo.al')).text | Should -Be "release-only`n" } + It 'preserves cumulative same-file changes with one provenance commit per oldest-first source' { + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "source`nsecond`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'second') + $second = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $firstResult = Invoke-BackportApply $directory $source $base + $secondResult = Invoke-BackportApply $directory $second $firstResult.commit + $secondResult.outcome | Should -Be 'applied' + (Invoke-BackportGit $directory @('rev-parse', 'HEAD^^')).text.Trim() | Should -Be $base + (Invoke-BackportGit $directory @('show', 'HEAD:src/Foo.al')).text | Should -Be "source`nsecond`n" + (Invoke-BackportGit $directory @('log', '-2', '--format=%B')).text | + Should -Match "cherry picked from commit $source" + (Invoke-BackportGit $directory @('log', '-1', '--format=%B')).text | + Should -Match "cherry picked from commit $second" + } + + It 'retains a cumulative-tip conflict even when the same source applies cleanly to the original release' { + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "base`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'unselected main revert') + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "other`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'later source') + $later = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $first = Invoke-BackportBatchApply $directory $source $base $base @() + $first.outcome | Should -Be 'applied' + $cumulative = Invoke-BackportBatchApply $directory $later $first.commit $base @() + $cumulative.outcome | Should -Be 'conflict' + $cumulative.commit | Should -BeNullOrEmpty + (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() | Should -Be $first.commit + (Invoke-BackportGit $directory @('show', 'HEAD:src/Foo.al')).text | Should -Be "source`n" + $releaseOnly = Invoke-BackportApply $directory $later $base + $releaseOnly.outcome | Should -Be 'applied' + $cumulative.outcome | Should -Be 'conflict' + } + + It 'prepares and validates a real cumulative batch and resumes its exact immutable release and membership' { + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "source`nsecond`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'second') + $second = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $script:fixture = New-BatchFixture -Outcomes @('applied', 'applied') + $request = $fixture.request + $request.sources[0].sha = $source + $request.sources[1].sha = $second + [void]$request.Remove('key') + $request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $request)).Substring(0, 32) + $script:request = $request + $script:release = $base + $script:durable = $null + $script:remoteHead = '' + $script:actualGit = (Get-Command Invoke-BackportGit).ScriptBlock + Mock Get-BackportBatchRecords { + if ($null -ne $script:durable) { @{ issue = @{ state = 'open' }; manifest = $script:durable } } + } + Mock Get-BackportTracking { $null } + Mock Get-BackportSource { + $proof = @($script:request.sources | Where-Object number -eq $Number)[0] + @{ + number = $Number; title = "Source $Number"; merge_commit_sha = $proof.sha; merged_at = $proof.merged_at + base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } + labels = @(@{ name = 'event-request' }, @{ name = 'Team: Finance' }) + } + } + Mock Get-BackportSourceIssues { @($script:fixture.members | Where-Object source_pr -eq $Number)[0].issues } + Mock Initialize-BackportBatchGit {} + Mock Invoke-BackportApi { + if ([string]::IsNullOrEmpty($Method)) { $Method = 'GET' } + if ($Method -ne 'GET') { throw 'Preparation attempted a remote write.' } + if ($Path -like '*/git/ref/*') { return @{ object = @{ sha = $script:release } } } + if ($Path -like '*/compare/*') { return @{ status = 'ahead' } } + throw "Unexpected API read: $Path" + } + Mock Invoke-BackportGit { + if ($Arguments[0] -eq 'fetch') { return @{ text = ''; exit_code = 0 } } + if ($Arguments[0] -eq 'ls-remote') { return @{ text = $script:remoteHead; exit_code = 0 } } + & $script:actualGit -Directory $Directory -Arguments $Arguments -InputText $InputText ` + -AllowFailure:$AllowFailure -CommitDate $CommitDate + } + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory $root + $preparedDirectory = Join-Path $root 'git' + Move-Item -LiteralPath $directory -Destination $preparedDirectory + $statePath = Join-Path $root 'state.json' + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $request -Depth 60) $statePath + $script:durable = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 + $durable.members.Count | Should -Be 2 + $durable.members[1].result.target_sha | Should -Be $durable.members[0].result.commit + (Invoke-BackportGit $preparedDirectory @('show', "$($durable.head):src/Foo.al")).text | Should -Be "source`nsecond`n" + Assert-BackportPreparedBatch $durable $preparedDirectory + $tampered = $durable | ConvertTo-Json -Depth 60 | ConvertFrom-Json -AsHashtable -Depth 60 + $tampered.members[1].result.paths = @('src/Unexpected.al') + { Assert-BackportPreparedBatch $tampered $preparedDirectory } | Should -Throw '*provenance differs*' + $resumeRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory $resumeRoot + Copy-Item -LiteralPath $preparedDirectory -Destination (Join-Path $resumeRoot 'git') -Recurse + $script:release = '0' * 40 + $script:remoteHead = "$($durable.head)`trefs/heads/bc-extrequest-backport/$($request.key)" + $row = @{} + $request + $row.manifest = $durable + $resumedPath = Join-Path $resumeRoot 'state.json' + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $resumedPath + $resumed = Get-Content $resumedPath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 + (Get-BackportBatchMarker $resumed) | Should -Be (Get-BackportBatchMarker $durable) + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -like '*/git/ref/*' } + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'isolates conflicts, excludes dependent paths and retains a disjoint clean source' { + [IO.File]::WriteAllText((Join-Path $directory 'src/Other.al'), "clean`n") + $null = Invoke-BackportGit $directory @('add', 'src/Other.al') + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-m', 'disjoint') + $clean = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "source`ndependent`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'dependent') + $dependent = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $null = Invoke-BackportGit $directory @('checkout', '--detach', $base) + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "release`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'release') + $release = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $failed = Invoke-BackportApply $directory $source $release + $failed.outcome | Should -Be 'conflict' + $good = Invoke-BackportApply $directory $clean $release -BlockedPaths $failed.paths + $good.outcome | Should -Be 'applied' + $blocked = Invoke-BackportApply $directory $dependent $good.commit -BlockedPaths $failed.paths + $blocked.outcome | Should -Be 'conflict' + (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() | Should -Be $good.commit + (Invoke-BackportGit $directory @('show', 'HEAD:src/Foo.al')).text | Should -Be "release`n" + (Invoke-BackportGit $directory @('show', 'HEAD:src/Other.al')).text | Should -Be "clean`n" + } + It 'does not run repository hooks' { $hook = Join-Path $directory '.git/hooks/post-checkout' [IO.File]::WriteAllText($hook, "#!/bin/sh`ntouch ran-hook`n") diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index 0b0f2c1de40..76e4351ff0c 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -117,13 +117,14 @@ function Test-BackportAutomaticSource { if ($null -eq $Source.merged_at -or $Source.base.ref -cne 'main') { return $false } $merged = [DateTimeOffset]$Source.merged_at - return $merged -ge $Now.AddDays(-7) -and $merged -le $Now -and + return $merged -ge $Now.AddDays(-15) -and $merged -le $Now -and (Test-BackportCategory @($Source.labels | ForEach-Object { $_.name })) } function Find-ExtRequestBackport { [CmdletBinding()] - param([string] $Repository, [string] $SourcePr = '', [string] $TargetOverride = '') + param([string] $Repository, [string] $SourcePr = '', [string] $TargetOverride = '', + [DateTimeOffset] $ReferenceTime = [DateTimeOffset]::UtcNow) Assert-BackportRepository $Repository $branches = @(Get-BackportApiPages "repos/$Repository/branches" | ForEach-Object { $_.name }) @@ -133,14 +134,14 @@ function Find-ExtRequestBackport { return @{ source_pr = [long]$SourcePr; target = $target } } - $now = [DateTimeOffset]::UtcNow + $now = $ReferenceTime $sources = [Collections.Generic.List[object]]::new() for ($page = 1; ; $page++) { $items = @(Invoke-BackportApi "repos/$Repository/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=$page") foreach ($source in $items) { if (Test-BackportAutomaticSource -Source $source -Now $now) { $sources.Add($source) } } - if ($items.Count -lt 100 -or [DateTimeOffset]$items[-1].updated_at -lt $now.AddDays(-7)) { break } + if ($items.Count -lt 100 -or [DateTimeOffset]$items[-1].updated_at -lt $now.AddDays(-15)) { break } } if ($sources.Count -gt 256) { throw 'More than 256 eligible PRs; use manual runs to reduce the backlog.' } foreach ($source in ($sources | Sort-Object { [DateTimeOffset]$_.merged_at }, number)) { @@ -229,6 +230,10 @@ function Get-BackportTracking { $matches = @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting" | Where-Object { if ($_.ContainsKey('pull_request')) { return $false } + $batch = Read-BackportBatchMarker $_.body + if ($null -ne $batch -and @($batch.members | Where-Object { + $_.source_pr -eq $SourcePr -and $_.result.outcome -eq 'applied' + }).Count -gt 0) { throw "Source #$SourcePr belongs to batch $($batch.request.key); use the batch API to reconcile it." } $marker = Read-BackportMarker $_.body return $null -ne $marker -and $marker.source_pr -eq $SourcePr }) @@ -324,12 +329,17 @@ function Assert-BackportPaths { } function Invoke-BackportApply { - param([string] $Directory, [string] $SourceSha, [string] $TargetSha) + param([string] $Directory, [string] $SourceSha, [string] $TargetSha, [string[]] $BlockedPaths = @()) $parents = (Invoke-BackportGit $Directory @('rev-list', '--parents', '-n', '1', $SourceSha)).text.Trim().Split(' ') if ($parents.Count -ne 2) { throw 'Only squash-merged source PRs are supported.' } $raw = (Invoke-BackportGit $Directory @('diff', '--raw', '--no-abbrev', '--no-renames', '-z', $parents[1], $SourceSha)).text $paths = @(Assert-BackportPaths $raw) + # Do not let a later three-way pick silently recover pieces of an excluded source. + # Disjoint paths may still proceed; overlapping paths need independent human review. + if (@($paths | Where-Object { $_ -cin $BlockedPaths }).Count -gt 0) { + return @{ outcome = 'conflict'; paths = $paths; commit = $null; tree = $null; target_sha = $TargetSha } + } $patterns = ($paths | ForEach-Object { "/$_" }) -join "`n" $null = Invoke-BackportGit $Directory @('sparse-checkout', 'set', '--no-cone', '--stdin') -InputText "$patterns`n" $null = Invoke-BackportGit $Directory @('checkout', '--detach', $TargetSha) @@ -377,17 +387,25 @@ function Write-BackportSummary { function New-ExtRequestBackport { [CmdletBinding()] - param([string] $Repository, [long] $SourcePr, [string] $Target, [bool] $Manual, [string] $StatePath) + param([string] $Repository, [long] $SourcePr, [string] $Target, [bool] $Manual, [string] $StatePath, + [DateTimeOffset] $ReferenceTime = [DateTimeOffset]::UtcNow) Assert-BackportRepository $Repository + $batch = @(Get-BackportBatchRecords $Repository | Where-Object { $SourcePr -in $_.manifest.request.source_prs }) + if ($batch.Count -gt 0) { + $row = @{} + $batch[0].manifest.request + $row.manifest = $batch[0].manifest + New-ExtRequestBackportBatch $Repository (ConvertTo-Json $row -Depth 60) $StatePath + return + } $source = Get-BackportSource $Repository $SourcePr $issues = @(Get-BackportSourceIssues $Repository $SourcePr) $labels = @(Get-BackportLabels -Issues $issues -PrLabels $source.labels) - if (-not $Manual -and (-not (Test-BackportAutomaticSource $source) -or + $tracking = Get-BackportTracking $Repository $SourcePr + if (-not $Manual -and $null -eq $tracking -and (-not (Test-BackportAutomaticSource $source $ReferenceTime) -or @($issues | Where-Object { -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) }).Count -gt 0)) { throw "Source PR #$SourcePr or one of its issues is no longer automatically eligible." } - $tracking = Get-BackportTracking $Repository $SourcePr $target = Get-BackportPinnedTarget -Source $source -Tracking $tracking -Default $Target Assert-BackportTarget $target $pull = Get-BackportPull $Repository $SourcePr $target @@ -424,12 +442,15 @@ function New-ExtRequestBackport { $state = @{ schema = 1; repository = $Repository; source_pr = $SourcePr; source_sha = $source.merge_commit_sha target = $target; manual = $Manual; labels = $labels; issues = $issues; source_title = $source.title + reference_time = $ReferenceTime.ToUniversalTime().ToString('o') result = $result } $null = New-Item -ItemType Directory -Path (Split-Path -Parent $StatePath) -Force $state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM - $issueLinks = ($issues | ForEach-Object { $_.url }) -join ', ' - Write-BackportSummary "Main PR #$SourcePr -> $target`: **$($result.outcome)**. Source issues: $issueLinks" + $issueLinks = if ($issues.Count -gt 0) { ($issues | ForEach-Object { $_.url }) -join ', ' } else { '(none)' } + $teams = @($source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team: *' }) + $teamText = if ($teams.Count -gt 0) { ($teams -join ', ') -replace '[\r\n]', ' ' } else { '(none)' } + Write-BackportSummary "Main PR #$SourcePr (https://github.com/$Repository/pull/$SourcePr) -> $target`: **$($result.outcome)**. Main PR teams: $teamText. Source issues: $issueLinks" } function Get-BackportStatusLabel { @@ -554,22 +575,38 @@ function Complete-BackportPull { function Publish-ExtRequestBackport { [CmdletBinding()] - param([string] $StatePath, [string] $BotLogin) + param([string] $StatePath, [string] $BotLogin, [hashtable] $PreparedState) - $state = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 40 + $state = if ($null -ne $PreparedState) { $PreparedState } + else { Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 } + if ($state.schema -eq 2) { Publish-ExtRequestBackportBatch $StatePath $BotLogin; return } if ($state.schema -ne 1 -or $BotLogin -cnotmatch '^[a-z0-9-]+\[bot\]$') { throw 'Invalid publication state or App identity.' } Assert-BackportRepository $state.repository Assert-BackportTarget $state.target $repository = $state.repository $source = Get-BackportSource $repository $state.source_pr if ($source.merge_commit_sha -cne $state.source_sha) { throw 'Source merge identity changed.' } - if (-not $state.manual -and -not (Test-BackportAutomaticSource $source)) { throw 'Automatic source eligibility changed before publication.' } + $tracking = Get-BackportTracking $repository $state.source_pr + $batchException = $false + if ($state.ContainsKey('batch_key')) { + $records = @(Get-BackportBatchRecords $repository | Where-Object { $_.manifest.request.key -ceq $state.batch_key }) + if ($records.Count -ne 1) { throw 'Independent exception has no durable cohort.' } + Assert-BackportBatchObject $records[0].issue $records[0].manifest $BotLogin + $member = @($records[0].manifest.members | Where-Object { $_.source_pr -eq $state.source_pr }) + if ($member.Count -ne 1 -or $member[0].result.outcome -eq 'applied' -or + (ConvertTo-BackportCanonicalJson $member[0].result) -cne (ConvertTo-BackportCanonicalJson $state.result)) { + throw 'Independent exception differs from durable cohort.' + } + $batchException = $true + } + $clock = if ($state.ContainsKey('reference_time')) { [DateTimeOffset]$state.reference_time } else { [DateTimeOffset]::UtcNow } + if (-not $state.manual -and $null -eq $tracking -and -not $batchException -and + -not (Test-BackportAutomaticSource $source $clock)) { throw 'Automatic source eligibility changed before publication.' } $state.issues = @(Get-BackportSourceIssues $repository $state.source_pr) - if (-not $state.manual -and @($state.issues | Where-Object { + if (-not $state.manual -and $null -eq $tracking -and -not $batchException -and @($state.issues | Where-Object { -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) }).Count -gt 0) { throw 'A source issue is no longer automatically eligible.' } $state.labels = @(Get-BackportLabels -Issues $state.issues -PrLabels $source.labels) - $tracking = Get-BackportTracking $repository $state.source_pr if ($null -ne $tracking) { Assert-BackportObject $tracking $state $BotLogin } $pull = Get-BackportPull $repository $state.source_pr $state.target if ($null -ne $pull) { @@ -609,8 +646,8 @@ function Publish-ExtRequestBackport { return } if ($state.result.outcome -eq 'already-present') { - Add-BackportStatus $repository $state.source_pr already-present $state.target $null = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } + Add-BackportStatus $repository $state.source_pr already-present $state.target Remove-BackportIntents $repository $state.source_pr $state.target Write-BackportSummary "Main PR #$($state.source_pr) is already present; closed $($tracking.html_url) without an empty PR." return @@ -647,20 +684,614 @@ function Publish-ExtRequestBackport { Complete-BackportPull $state $tracking $pull $BotLogin } +function ConvertTo-BackportCanonicalJson { + param($Value) + function Convert-Value($Item) { + # ConvertFrom-Json versions differ in whether ISO timestamps stay strings. + if ($Item -is [datetime] -or $Item -is [DateTimeOffset] -or + ($Item -is [string] -and $Item -match '^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$')) { + return ([DateTimeOffset]$Item).UtcDateTime.ToString('o') + } + if ($Item -is [Collections.IDictionary]) { + $sorted = [ordered]@{} + foreach ($key in @($Item.Keys | Sort-Object -CaseSensitive)) { $sorted[$key] = Convert-Value $Item[$key] } + return $sorted + } + if ($Item -is [array]) { return ,@($Item | ForEach-Object { Convert-Value $_ }) } + return $Item + } + ConvertTo-Json -InputObject (Convert-Value $Value) -Depth 60 -Compress +} + +function Get-BackportDigest { + param([string] $Text) + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Text))).ToLowerInvariant() +} + +function Assert-BackportBatchRequest { + param([hashtable] $Request) + foreach ($name in @('key', 'mode', 'source_prs', 'sources', 'target', 'team', 'batch_id', 'reference_time', 'manual')) { + if (-not $Request.ContainsKey($name)) { throw "Missing batch request field $name." } + } + Assert-BackportTarget $Request.target + if ($Request.key -cnotmatch '^(team-[0-9a-f]{32}|single-[1-9][0-9]*)$' -or + $Request.mode -notin @('team', 'single') -or $Request.batch_id -cnotmatch '^[0-9]+$' -or + $Request.manual -isnot [bool] -or + ($Request.reference_time -isnot [datetime] -and + ([DateTimeOffset]$Request.reference_time).Offset -ne [TimeSpan]::Zero)) { + throw 'Invalid batch request identity or discovery clock.' + } + $numbers = @($Request.source_prs) + if ($numbers.Count -eq 0 -or $numbers.Count -gt 256 -or + @($numbers | Sort-Object -Unique).Count -ne $numbers.Count -or + @($Request.sources).Count -ne $numbers.Count) { throw 'Invalid or duplicate batch membership.' } + for ($i = 0; $i -lt $numbers.Count; $i++) { + $proof = $Request.sources[$i] + if ([string]$numbers[$i] -cnotmatch '^[1-9][0-9]*$' -or $proof.number -ne $numbers[$i] -or + $proof.sha -cnotmatch '^[0-9a-f]{40}$') { throw 'Invalid source proof in batch membership.' } + $null = [DateTimeOffset]$proof.merged_at + } + $chronological = @($Request.sources | Sort-Object { [DateTimeOffset]$_.merged_at }, number | ForEach-Object { $_.number }) + if (($chronological -join ',') -cne ($numbers -join ',')) { throw 'Batch sources must be oldest first.' } + if ($Request.mode -eq 'single') { + if ($numbers.Count -ne 1 -or $Request.key -cne "single-$($numbers[0])") { throw 'Invalid independent request.' } + } + else { + if ($Request.team -cnotmatch '^Team: \S[^\r\n]*$' -or $Request.manual) { throw 'Invalid team request.' } + $identity = @{} + $Request + [void]$identity.Remove('key') + [void]$identity.Remove('manifest') + $expected = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $identity)).Substring(0, 32) + if ($Request.key -cne $expected) { throw 'Batch membership or identity has changed.' } + } +} + +function Get-BackportBatchMarker { + param([hashtable] $Manifest) + $json = ConvertTo-BackportCanonicalJson $Manifest + "" +} + +function Read-BackportBatchMarker { + param([string] $Body) + $markers = [regex]::Matches($Body, '(?m)^\r?$') + if ($markers.Count -gt 1) { throw 'Multiple batch manifests on one object.' } + if ($markers.Count -eq 0) { + if ($Body.Contains('bc-extrequest-backport:v2')) { throw 'Malformed batch manifest.' } + return $null + } + $manifest = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($markers[0].Groups[1].Value)) | + ConvertFrom-Json -AsHashtable -Depth 60 + Assert-BackportBatchManifest $manifest + return $manifest +} + +function Assert-BackportBatchManifest { + param([hashtable] $Manifest) + if ($Manifest.schema -ne 2) { throw 'Unsupported batch manifest schema.' } + Assert-BackportRepository $Manifest.repository + Assert-BackportBatchRequest $Manifest.request + if ($Manifest.request.mode -ne 'team' -or $Manifest.request.ContainsKey('manifest') -or + @($Manifest.members).Count -ne @($Manifest.request.source_prs).Count -or + $Manifest.target_sha -cnotmatch '^[0-9a-f]{40}$') { throw 'Invalid complete batch manifest.' } + $tip = $Manifest.target_sha + for ($i = 0; $i -lt $Manifest.members.Count; $i++) { + $member = $Manifest.members[$i] + $proof = $Manifest.request.sources[$i] + if ($member.schema -ne 1 -or $member.repository -cne $Manifest.repository -or + $member.target -cne $Manifest.request.target -or $member.source_pr -ne $proof.number -or + $member.source_sha -cne $proof.sha -or + $member.result.outcome -notin @('applied', 'conflict', 'already-present') -or + $member.result.target_sha -cne $tip) { throw 'Invalid batch member or cumulative parent.' } + if ($member.result.outcome -eq 'applied') { + if ($member.result.commit -cnotmatch '^[0-9a-f]{40}$' -or + $member.result.tree -cnotmatch '^[0-9a-f]{40}$') { throw 'Invalid prepared batch commit.' } + $tip = $member.result.commit + } + } + $applied = @($Manifest.members | Where-Object { $_.result.outcome -eq 'applied' }) + if ($Manifest.head -cne $tip -or + ($applied.Count -gt 0 -and $Manifest.tree -cne $applied[-1].result.tree)) { throw 'Invalid batch tip or tree.' } +} + +function Get-BackportBatchRecords { + param([string] $Repository) + $mapping = @{} + foreach ($issue in @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting")) { + if ($issue.ContainsKey('pull_request')) { continue } + $manifest = Read-BackportBatchMarker $issue.body + if ($null -eq $manifest) { continue } + foreach ($number in $manifest.request.source_prs) { + if ($mapping.ContainsKey([string]$number)) { throw "Multiple batch mappings for source #$number." } + $mapping[[string]$number] = $manifest.request.key + } + @{ issue = $issue; manifest = $manifest } + } +} + +function Get-BackportBatchPull { + param([string] $Repository, [string] $Key) + $owner = $Repository.Split('/')[0] + $pulls = @(Get-BackportApiPages "repos/$Repository/pulls?state=all&head=$owner`:bc-extrequest-backport/$Key") + if ($pulls.Count -gt 1) { throw 'Multiple PRs for one batch branch.' } + if ($pulls.Count -eq 1) { return $pulls[0] } + return $null +} + +function Get-BackportSourceTeam { + param([hashtable] $Source, [object[]] $Issues, [string] $Repository) + $teams = @($Source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team: *' }) + if ($teams.Count -ne 1 -or $teams[0] -cnotmatch '^Team: \S[^\r\n]*$') { return '' } + foreach ($issue in $Issues) { + $issueTeams = @($issue.labels.nodes | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team: *' }) + if ($issueTeams.Count -ne 1 -or $issueTeams[0] -cne $teams[0] -or + -not $issue.ContainsKey('repository') -or $null -eq $issue.repository -or + -not $issue.repository.ContainsKey('nameWithOwner') -or $issue.repository.nameWithOwner -cne $Repository) { return '' } + } + return $teams[0] +} + +function Find-ExtRequestBackportBatches { + [CmdletBinding()] + param([string] $Repository, [string] $SourcePr = '', [string] $TargetOverride = '', [string] $BatchId) + Assert-BackportRepository $Repository + if ($BatchId -cnotmatch '^[0-9]+$') { throw 'BatchId must be the numeric GitHub run_id.' } + $clock = [DateTimeOffset]::UtcNow.ToString('o') + $records = @(Get-BackportBatchRecords $Repository) + $mapped = @{} + $requests = @{} + foreach ($record in $records) { + foreach ($number in $record.manifest.request.source_prs) { $mapped[[string]$number] = $record } + } + if ($SourcePr) { + if ($SourcePr -cnotmatch '^[1-9][0-9]{0,9}$') { throw 'source_pr must be a positive PR number.' } + if ($mapped.ContainsKey($SourcePr)) { + $row = @{} + $mapped[$SourcePr].manifest.request + $row.manifest = $mapped[$SourcePr].manifest + return $row + } + } + $candidates = @(Find-ExtRequestBackport $Repository -SourcePr $SourcePr -TargetOverride $TargetOverride -ReferenceTime ([DateTimeOffset]$clock)) + # Open manifests are authoritative even when some members have aged out or are processed. + if (-not $SourcePr) { + foreach ($record in $records) { + if ($record.issue.state -eq 'open') { + $row = @{} + $record.manifest.request + $row.manifest = $record.manifest + $requests[$row.key] = $row + } + } + } + $groups = @{} + foreach ($candidate in $candidates) { + $number = [long]$candidate.source_pr + if ($mapped.ContainsKey([string]$number)) { continue } + $source = Get-BackportSource $Repository $number + $issues = @(Get-BackportSourceIssues $Repository $number) + if (-not $SourcePr -and @($issues | Where-Object { + -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) + }).Count -gt 0) { continue } + $tracking = Get-BackportTracking $Repository $number + $target = Get-BackportPinnedTarget $source $tracking $candidate.target + $team = Get-BackportSourceTeam $source $issues $Repository + $prior = @($source.labels | Where-Object { $_.name -like 'ext-backport-*' }).Count -gt 0 + $mode = if ($SourcePr -or $null -ne $tracking -or $prior -or -not $team) { 'single' } else { 'team' } + $group = if ($mode -eq 'single') { "single-$number" } else { "$team|$target" } + if (-not $groups.ContainsKey($group)) { + $groups[$group] = @{ + mode = $mode; team = $team; target = $target; batch_id = $BatchId + reference_time = $clock; manual = [bool]$SourcePr; sources = @() + } + } + $groups[$group].sources += @{ number = $number; sha = $source.merge_commit_sha; merged_at = $source.merged_at } + } + foreach ($row in $groups.Values) { + $row.sources = @($row.sources | Sort-Object { [DateTimeOffset]$_.merged_at }, number) + $row.source_prs = @($row.sources | ForEach-Object { $_.number }) + $row.concurrency_key = if ($row.team) { + 'team-' + (Get-BackportDigest "$($row.team)|$($row.target)").Substring(0, 24) + } else { "single-$($row.source_prs[0])" } + $row.key = if ($row.mode -eq 'single') { "single-$($row.source_prs[0])" } + else { 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $row)).Substring(0, 32) } + Assert-BackportBatchRequest $row + $requests[$row.key] = $row + } + if ($requests.Count -gt 256) { throw 'More than 256 requests including resumable batches; reconcile existing work first.' } + foreach ($key in @($requests.Keys | Sort-Object)) { $requests[$key] } +} + +function Initialize-BackportBatchGit { + param([string] $Directory, [string] $Repository) + if (Test-Path -LiteralPath $Directory) { throw 'Backport work directory already exists.' } + $null = New-Item -ItemType Directory -Path $Directory -Force + $null = Invoke-BackportGit $Directory @('init', '--quiet') + [IO.File]::WriteAllText((Join-Path $Directory '.git/empty-config'), '') + [IO.File]::WriteAllText((Join-Path $Directory '.git/info/attributes'), + "* -filter -ident -text !eol !working-tree-encoding !merge`n") + $null = Invoke-BackportGit $Directory @('remote', 'add', 'origin', "https://github.com/$Repository.git") +} + +function Invoke-BackportBatchApply { + param([string] $Directory, [string] $SourceSha, [string] $Tip, [string] $ReleaseSha, [string[]] $BlockedPaths) + $result = Invoke-BackportApply $Directory $SourceSha $Tip -BlockedPaths $BlockedPaths + if ($result.outcome -eq 'already-present' -and $Tip -cne $ReleaseSha) { + $baseline = Invoke-BackportApply $Directory $SourceSha $ReleaseSha + $null = Invoke-BackportGit $Directory @('checkout', '--detach', $Tip) + # A patch present only on an unmerged batch tip is not a completed release no-op. + if ($baseline.outcome -ne 'already-present') { + $result.outcome = 'conflict' + $result.tree = $null + } + } + return $result +} + +function Assert-BackportBatchSources { + param([hashtable] $Request, [string] $Repository, [switch] $Resume) + foreach ($proof in $Request.sources) { + $source = Get-BackportSource $Repository $proof.number + if ($source.merge_commit_sha -cne $proof.sha) { throw 'Batch source SHA changed.' } + if (([DateTimeOffset]$source.merged_at) -ne ([DateTimeOffset]$proof.merged_at)) { throw 'Batch merge metadata changed.' } + if ((Get-BackportPinnedTarget $source $null $Request.target) -cne $Request.target) { + throw 'Batch source has a different pinned target.' + } + $issues = @(Get-BackportSourceIssues $Repository $proof.number) + if (-not $Request.manual -and -not $Resume) { + if (-not (Test-BackportAutomaticSource $source ([DateTimeOffset]$Request.reference_time)) -or + @($issues | Where-Object { + -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) + }).Count -gt 0) { throw 'Batch source or original issue eligibility changed.' } + if ($Request.mode -eq 'team' -and + (Get-BackportSourceTeam $source $issues $Repository) -cne $Request.team) { throw 'Batch team metadata changed.' } + } + @{ source = $source; issues = $issues } + } +} + +function New-ExtRequestBackportBatch { + [CmdletBinding()] + param([string] $Repository, [string] $RequestJson, [string] $StatePath) + Assert-BackportRepository $Repository + if (-not [IO.Path]::IsPathFullyQualified($StatePath)) { throw 'StatePath must be absolute.' } + $request = ConvertFrom-Json $RequestJson -AsHashtable -Depth 60 + Assert-BackportBatchRequest $request + $records = @(Get-BackportBatchRecords $Repository) + $overlap = @($records | Where-Object { + @($_.manifest.request.source_prs | Where-Object { $_ -in $request.source_prs }).Count -gt 0 + }) + if ($overlap.Count -gt 1) { throw 'Request overlaps multiple immutable batches.' } + $resume = $overlap.Count -eq 1 + if ($resume) { + $stored = $overlap[0].manifest + $identity = @{} + $request + [void]$identity.Remove('manifest') + if ($stored.request.key -cne $request.key -or + (ConvertTo-BackportCanonicalJson $stored.request) -cne (ConvertTo-BackportCanonicalJson $identity)) { + throw 'Request differs from the existing immutable batch; rediscover it.' + } + $request = $stored.request + } + elseif ($request.ContainsKey('manifest')) { throw 'Previously discovered batch tracking issue is missing.' } + $metadata = @(Assert-BackportBatchSources $request $Repository -Resume:$resume) + $root = Split-Path -Parent $StatePath + $null = New-Item -ItemType Directory -Path $root -Force + if ($request.mode -eq 'single') { + New-ExtRequestBackport $Repository $request.source_prs[0] $request.target $request.manual $StatePath ` + -ReferenceTime ([DateTimeOffset]$request.reference_time) + $single = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 + if ($single.schema -eq 1 -and $single.source_sha -cne $request.sources[0].sha) { + throw 'Independent source proof changed during preparation.' + } + return + } + if (-not $resume) { + foreach ($item in $metadata) { + $tracking = Get-BackportTracking $Repository $item.source.number + $prior = @($item.source.labels | Where-Object { $_.name -like 'ext-backport-*' }) + if ($null -ne $tracking -or $prior.Count -gt 0) { throw 'Source acquired independent state; rediscover, never re-batch it.' } + } + } + $directory = Join-Path $root 'git' + Initialize-BackportBatchGit $directory $Repository + $targetSha = if ($resume) { $stored.target_sha } + else { (Invoke-BackportApi "repos/$Repository/git/ref/heads/$($request.target)").object.sha } + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/bc-extrequest-backport/$($request.key)")).text.Trim() + if ($remote -and -not $resume) { throw 'Untracked batch branch exists; reconcile manually.' } + $null = Invoke-BackportGit $directory @('fetch', '--quiet', '--no-tags', '--filter=blob:none', '--depth=2', + 'origin', "$targetSha`:refs/backport/target") + $tip = $targetSha + $members = @() + $blocked = @() + for ($i = 0; $i -lt $request.sources.Count; $i++) { + $proof = $request.sources[$i] + $comparison = Invoke-BackportApi "repos/$Repository/compare/$($proof.sha)...main" + if ($comparison.status -notin @('ahead', 'identical')) { throw 'Batch source is not on main.' } + $null = Invoke-BackportGit $directory @('fetch', '--quiet', '--no-tags', '--filter=blob:none', '--depth=2', + 'origin', "$($proof.sha):refs/backport/source-$($proof.number)") + $result = Invoke-BackportBatchApply $directory $proof.sha $tip $targetSha $blocked + if ($result.outcome -eq 'conflict') { $blocked += $result.paths } + $members += @{ + schema = 1; repository = $Repository; source_pr = $proof.number; source_sha = $proof.sha + target = $request.target; manual = $request.manual; reference_time = $request.reference_time + source_title = $metadata[$i].source.title; issues = $metadata[$i].issues + labels = @(Get-BackportLabels $metadata[$i].issues $metadata[$i].source.labels); result = $result + } + if ($result.outcome -eq 'applied') { $tip = $result.commit } + } + $manifest = @{ + schema = 2; repository = $Repository; request = $request; members = $members; target_sha = $targetSha + head = $tip; tree = (Invoke-BackportGit $directory @('rev-parse', "$tip`^{tree}")).text.Trim() + } + if ($resume) { + # Membership, outcomes, source proofs and commit chain cannot change during recovery. + foreach ($index in 0..($members.Count - 1)) { + if ((ConvertTo-BackportCanonicalJson $members[$index].result) -cne + (ConvertTo-BackportCanonicalJson $stored.members[$index].result)) { throw 'Prepared batch differs from durable results.' } + } + $manifest = $stored + } + if ($remote -and ($remote -split '\s+')[0] -cne $manifest.head) { throw 'Existing batch branch differs; never overwrite it.' } + Assert-BackportBatchManifest $manifest + if (@($members | Where-Object { $_.result.outcome -eq 'applied' }).Count -gt 0) { + $null = Get-BackportBatchBody $manifest 9999999999 + } + $manifest | ConvertTo-Json -Depth 60 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM + Write-BackportBatchSummary $manifest 'prepared' +} + +function Get-BackportBatchLabels { + param([hashtable] $Manifest) + $labels = @{} + foreach ($member in @($Manifest.members | Where-Object { $_.result.outcome -eq 'applied' })) { + foreach ($label in $member.labels) { $labels[$label.name] = $label } + } + @($labels.Values | Sort-Object name) +} + +function Get-BackportBatchBody { + param([hashtable] $Manifest, [long] $TrackingNumber = 0) + $lines = @((Get-BackportBatchMarker $Manifest), '', + "Team: $($Manifest.request.team). Target: ``$($Manifest.request.target)``.", '', '## Included main PRs') + $issues = @{} + foreach ($member in $Manifest.members) { + if ($member.result.outcome -ne 'applied') { continue } + $lines += "- https://github.com/$($Manifest.repository)/pull/$($member.source_pr) - source ``$($member.source_sha)``" + foreach ($issue in $member.issues) { $issues[$issue.url] = $issue } + } + $lines += @('', '## Original main issues') + foreach ($url in @($issues.Keys | Sort-Object)) { $lines += "- $url" } + $lines += @('', '## Independent outcomes') + foreach ($member in $Manifest.members) { + if ($member.result.outcome -ne 'applied') { $lines += "- Main PR #$($member.source_pr): $($member.result.outcome); tracked independently." } + } + if ($TrackingNumber -gt 0) { $lines += @('', "Fixes $($Manifest.repository)#$TrackingNumber") } + $lines += @('', 'Review the cumulative change and run normal release CI. No AI resolver was used.') + $body = $lines -join "`n" + if ($body.Length -gt 65000) { throw 'Complete batch manifest exceeds GitHub body limits; reduce the cohort, never truncate membership.' } + return $body +} + +function Write-BackportBatchSummary { + param([hashtable] $Manifest, [string] $Status, [string] $Urls = '') + $applied = @($Manifest.members | Where-Object { $_.result.outcome -eq 'applied' }).Count + $conflicts = @($Manifest.members | Where-Object { $_.result.outcome -eq 'conflict' }).Count + $present = @($Manifest.members | Where-Object { $_.result.outcome -eq 'already-present' }).Count + Write-BackportSummary "Main PRs #$($Manifest.request.source_prs -join ', #'); $($Manifest.request.team) -> $($Manifest.request.target): **$Status**. Included: $applied; conflicted: $conflicts; already-present: $present. $Urls" + if ($Status -eq 'prepared') { + foreach ($member in $Manifest.members) { + $issues = if ($member.issues.Count -gt 0) { + (@($member.issues | ForEach-Object { $_.url } | Sort-Object -Unique)) -join ', ' + } else { '(none)' } + Write-BackportSummary "- Main PR https://github.com/$($Manifest.repository)/pull/$($member.source_pr); $($Manifest.request.team); target ``$($Manifest.request.target)``; source ``$($member.source_sha)``; outcome **$($member.result.outcome)**; original issues: $issues" + } + } +} + +function Assert-BackportBatchObject { + param([hashtable] $Object, [hashtable] $Manifest, [string] $BotLogin) + $found = Read-BackportBatchMarker $Object.body + if ($null -eq $found -or $Object.user.login -cne $BotLogin -or + (Get-BackportBatchMarker $found) -cne (Get-BackportBatchMarker $Manifest)) { + throw 'Batch object has an unexpected author or immutable manifest.' + } + if ($Object.ContainsKey('head') -and + ($Object.head.ref -cne "bc-extrequest-backport/$($Manifest.request.key)" -or + $Object.head.sha -cne $Manifest.head -or $Object.head.repo.full_name -cne $Manifest.repository -or + $Object.base.ref -cne $Manifest.request.target -or $Object.base.repo.full_name -cne $Manifest.repository)) { + throw 'Batch PR branch, target or provenance changed; never adopt human changes.' + } +} + +function Complete-BackportBatchPull { + param([hashtable] $Manifest, [hashtable] $Tracking, [hashtable] $Pull, [string] $BotLogin) + Assert-BackportBatchObject $Tracking $Manifest $BotLogin + Assert-BackportBatchObject $Pull $Manifest $BotLogin + # Reconciliation verifies every source identity, including already processed members. + $null = @(Assert-BackportBatchSources $Manifest.request $Manifest.repository -Resume) + $labels = @(Get-BackportBatchLabels $Manifest) + Ensure-BackportLabels $Manifest.repository $labels + foreach ($number in @($Tracking.number, $Pull.number)) { + $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$number/labels" -Method POST -Body @{ + labels = @($labels | ForEach-Object { $_.name }) + } + } + $link = "Backport PR: $($Pull.html_url)" + if (-not $Tracking.body.Contains($link)) { + $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "$($Tracking.body)`n`n$link" } + } + $status = if ($null -ne $Pull.merged_at) { 'merged' } elseif ($Pull.state -eq 'closed') { 'needs-attention' } else { 'created' } + foreach ($member in $Manifest.members) { + if ($member.result.outcome -ne 'applied') { continue } + Add-BackportStatus $Manifest.repository $member.source_pr $status $Manifest.request.target + if ($status -ne 'needs-attention') { Remove-BackportIntents $Manifest.repository $member.source_pr $Manifest.request.target } + } + if ($status -eq 'merged' -and $Tracking.state -ne 'closed') { + $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$($Tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } + } + Write-BackportBatchSummary $Manifest $status "$($Tracking.html_url) $($Pull.html_url)" +} + +function Publish-BackportBatchExceptions { + param([hashtable] $Manifest, [string] $BotLogin, [switch] $Durable, [switch] $ContinueOnFailure) + foreach ($member in $Manifest.members) { + if ($member.result.outcome -eq 'applied') { continue } + # Preserve the proven cumulative-tip outcome. A release-only reapplication can + # succeed while conflicting with the clean team changes, so never re-prepare here. + $single = @{} + $member + if ($Durable) { $single.batch_key = $Manifest.request.key } + try { Publish-ExtRequestBackport -BotLogin $BotLogin -PreparedState $single } + catch { + if (-not $ContinueOnFailure) { throw } + $failure = "Independent source #$($member.source_pr): $($_.Exception.Message)" + Write-Warning $failure + $failure + } + } +} + +function Assert-BackportPreparedBatch { + param([hashtable] $Manifest, [string] $Directory) + $tip = $Manifest.target_sha + $blocked = @() + foreach ($member in $Manifest.members) { + $result = Invoke-BackportBatchApply $Directory $member.source_sha $tip $Manifest.target_sha $blocked + if ($result.outcome -eq 'conflict') { $blocked += $result.paths } + if ((ConvertTo-BackportCanonicalJson $result) -cne (ConvertTo-BackportCanonicalJson $member.result)) { + throw 'Prepared result/source provenance differs from immutable batch state.' + } + if ($result.outcome -eq 'applied') { $tip = $result.commit } + } + $null = Invoke-BackportGit $Directory @('checkout', '--detach', $tip) + $tree = (Invoke-BackportGit $Directory @('rev-parse', 'HEAD^{tree}')).text.Trim() + if ($tip -cne $Manifest.head -or $tree -cne $Manifest.tree) { throw 'Prepared cumulative tree changed.' } + $comparison = Invoke-BackportApi "repos/$($Manifest.repository)/compare/$($Manifest.target_sha)...$($Manifest.request.target)" + if ($comparison.status -notin @('ahead', 'identical')) { throw 'Pinned batch parent is no longer on release.' } +} + +function Publish-ExtRequestBackportBatch { + [CmdletBinding()] + param([string] $StatePath, [string] $BotLogin) + $manifest = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 + if ($manifest.schema -eq 1) { Publish-ExtRequestBackport $StatePath $BotLogin; return } + if ($BotLogin -cnotmatch '^[a-z0-9-]+\[bot\]$') { throw 'Invalid App identity.' } + Assert-BackportBatchManifest $manifest + $null = Get-BackportBatchBody $manifest 9999999999 + $request = $manifest.request + $repository = $manifest.repository + $records = @(Get-BackportBatchRecords $repository) + $overlap = @($records | Where-Object { + @($_.manifest.request.source_prs | Where-Object { $_ -in $request.source_prs }).Count -gt 0 + }) + if ($overlap.Count -gt 1) { throw 'Multiple durable batch mappings.' } + $tracking = if ($overlap.Count -eq 1) { $overlap[0].issue } else { $null } + if ($null -ne $tracking) { Assert-BackportBatchObject $tracking $manifest $BotLogin } + $metadata = @(Assert-BackportBatchSources $request $repository -Resume:($null -ne $tracking)) + $directory = Join-Path (Split-Path -Parent $StatePath) 'git' + Assert-BackportPreparedBatch $manifest $directory + $applied = @($manifest.members | Where-Object { $_.result.outcome -eq 'applied' }) + $exceptionFailures = @() + $pull = Get-BackportBatchPull $repository $request.key + if ($null -ne $pull -and $null -eq $tracking) { throw 'Batch PR has no tracking issue.' } + if ($null -eq $tracking) { + foreach ($item in $metadata) { + if ($null -ne (Get-BackportTracking $repository $item.source.number) -or + @($item.source.labels | Where-Object { $_.name -like 'ext-backport-*' }).Count -gt 0) { + throw 'Source has prior backport state; do not create a duplicate batch.' + } + } + } + if ($applied.Count -gt 0) { + $labels = @(Get-BackportBatchLabels $manifest) + Ensure-BackportLabels $repository $labels + if ($null -eq $tracking) { + # Reserve included members before the single uncertain POST. A failure here is + # deliberately fail-closed; only a persisted complete manifest permits recovery. + foreach ($member in $applied) { Add-BackportStatus $repository $member.source_pr pending $request.target } + $tracking = Invoke-BackportApi "repos/$repository/issues" -Method POST -Body @{ + title = "[Backport $(($request.target -split '/')[1])] $($request.team) - $($request.key)" + body = Get-BackportBatchBody $manifest; type = 'Task'; labels = @($labels | ForEach-Object { $_.name }) + } + $tracking = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" + Assert-BackportBatchObject $tracking $manifest $BotLogin + } + $exceptionFailures = @(Publish-BackportBatchExceptions $manifest $BotLogin -Durable -ContinueOnFailure) + if ($null -ne $pull) { Complete-BackportBatchPull $manifest $tracking $pull $BotLogin } + elseif ($tracking.state -eq 'closed') { + throw 'Closed batch issue has no PR; manual reconciliation required.' + } + else { + foreach ($item in $metadata) { + if ((Get-BackportStatusLabel publishing $request.target) -in @($item.source.labels | ForEach-Object { $_.name })) { + throw 'Previous batch PR creation is unconfirmed; do not repeat POST.' + } + } + $branch = "bc-extrequest-backport/$($request.key)" + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if ($remote) { + if (($remote -split '\s+')[0] -cne $manifest.head) { throw 'Existing batch branch differs; never overwrite it.' } + } + else { + $null = Invoke-BackportGit $directory @('push', 'origin', "$($manifest.head):refs/heads/$branch") + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if (($remote -split '\s+')[0] -cne $manifest.head) { throw 'Batch branch publication not confirmed.' } + } + foreach ($member in $applied) { Add-BackportStatus $repository $member.source_pr publishing $request.target } + $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ + title = "[Backport $(($request.target -split '/')[1])] $($request.team) - $($request.key)" + body = Get-BackportBatchBody $manifest $tracking.number; head = $branch; base = $request.target; draft = $true + } + $pull = Invoke-BackportApi "repos/$repository/pulls/$($pull.number)" + Complete-BackportBatchPull $manifest $tracking $pull $BotLogin + } + } + if ($applied.Count -eq 0) { $exceptionFailures = @(Publish-BackportBatchExceptions $manifest $BotLogin -ContinueOnFailure) } + $summaryStatus = if ($exceptionFailures.Count -gt 0) { 'partial publication - independent outcomes need attention' } else { 'published' } + Write-BackportBatchSummary $manifest $summaryStatus + if ($exceptionFailures.Count -gt 0) { throw ($exceptionFailures -join "`n") } +} + function Sync-ExtRequestBackport { [CmdletBinding()] param([string] $Repository, [string] $BotLogin) Assert-BackportRepository $Repository - foreach ($tracking in @(Get-BackportApiPages "repos/$Repository/issues?state=open&labels=ext-backporting")) { - if ($tracking.ContainsKey('pull_request')) { continue } - $marker = Read-BackportMarker $tracking.body - if ($null -eq $marker) { continue } - $state = @{ repository = $Repository; source_pr = $marker.source_pr; source_sha = $marker.sha; target = $marker.target } - Assert-BackportObject $tracking $state $BotLogin - $pull = Get-BackportPull $Repository $marker.source_pr $marker.target - if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin } + $failures = @() + # GitHub may auto-close tracking issues when their PR merges, before this job runs. + foreach ($tracking in @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting")) { + try { + if ($tracking.ContainsKey('pull_request')) { continue } + $batch = Read-BackportBatchMarker $tracking.body + if ($null -ne $batch) { + Assert-BackportBatchObject $tracking $batch $BotLogin + $failures += @(Publish-BackportBatchExceptions $batch $BotLogin -Durable -ContinueOnFailure) + $pull = Get-BackportBatchPull $Repository $batch.request.key + if ($null -ne $pull) { Complete-BackportBatchPull $batch $tracking $pull $BotLogin } + continue + } + $marker = Read-BackportMarker $tracking.body + if ($null -eq $marker) { continue } + $state = @{ repository = $Repository; source_pr = $marker.source_pr; source_sha = $marker.sha; target = $marker.target } + Assert-BackportObject $tracking $state $BotLogin + $pull = Get-BackportPull $Repository $marker.source_pr $marker.target + if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin } + elseif ($tracking.body.Contains('Preparation result: **already-present**.')) { + if ($tracking.state -ne 'closed') { + $null = Invoke-BackportApi "repos/$Repository/issues/$($tracking.number)" -Method PATCH -Body @{ + state = 'closed'; state_reason = 'completed' + } + } + Add-BackportStatus $Repository $marker.source_pr already-present $marker.target + Remove-BackportIntents $Repository $marker.source_pr $marker.target + } + } + catch { + $failure = "Tracking issue #$($tracking.number): $($_.Exception.Message)" + Write-Warning $failure + $failures += $failure + } } + if ($failures.Count -gt 0) { throw ($failures -join "`n") } } -Export-ModuleMember -Function Find-ExtRequestBackport, New-ExtRequestBackport, Publish-ExtRequestBackport, Sync-ExtRequestBackport +Export-ModuleMember -Function Find-ExtRequestBackport, New-ExtRequestBackport, Publish-ExtRequestBackport, Sync-ExtRequestBackport, + Find-ExtRequestBackportBatches, New-ExtRequestBackportBatch, Publish-ExtRequestBackportBatch diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index b59d1c73a59..cf14d661570 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -1,7 +1,9 @@ # Extensibility request backports -`bc-extrequest-backport.yml` runs at 21:10 Europe/Copenhagen. It scans the rolling -last seven days for PRs merged into `main`, with no fixed start date. It includes +`bc-extrequest-backport.yml` creates backports on **Sunday at 21:10 +Europe/Copenhagen**. It scans the rolling last **15 days** for PRs merged into +`main`, with no fixed start date. The overlapping window allows a later weekly +run to catch work missed by a previous run. It includes `event-request`, `request-for-external`, or both, but rejects other request categories (`enum-request`, `extensibility-enhancement`, `extensibility-bug`, other `*-request` / `request-for-*` labels). Linked source issues must also have @@ -9,18 +11,48 @@ only the allowed request categories. Unrelated labels, including team labels, do not prevent selection. Original main PRs carrying `ext-backport-processed` are excluded from automatic discovery. -The entire merged PR is backported, including all issues in a team batch. -Each source PR gets one tracking issue of type **Task** and, when the complete -change applies cleanly, one **draft** release PR. Both descriptions link the -main PR and every issue in its `closingIssuesReferences`. Both new objects -inherit the deduplicated union of the original issues' labels, excluding only -`ext-ready-to-implement`, and add `ext-backporting`. For a manually selected PR -with no closing issue references, labels come from that PR instead, excluding -the source-only `ext-backport-processed` marker. - -This is not one combined backport per day: three eligible main PRs produce -three tracking issues and three draft backport PRs, assuming all apply cleanly. -A single team-batch main PR still produces only one issue/PR pair. +## Team batches and individual exceptions + +Team membership comes from the **main PR's `Team:` label**. Eligible PRs for +the same team and target are processed oldest first in one cumulative Git +checkout, keeping one provenance commit per source. The entire source PR is +considered, including every original issue already combined in a main-branch +team PR. + +A nonempty successful team batch creates **one Task tracking issue and one +draft release PR**, not a pair per source. Both descriptions link every included +main PR and every original issue in its `closingIssuesReferences`, without +duplicates. Both new objects inherit the deduplicated union of the original +issues' labels, excluding `ext-ready-to-implement`, and add `ext-backporting`. +When there are no original issue references, labels come from the source PR, +excluding its source-only `ext-backport-processed` marker. + +A source is processed independently when its team is missing, ambiguous, or +inconsistent with a linked original issue's team. For example, a main PR labeled +`Team: SCM` whose original issue has `Team: Finance` does not enter either team's +batch. It retains the individual backport issue/PR behavior rather than being +silently skipped or assigned to a guessed team. + +A conflicting source is also handled independently: its entire cherry-pick is +aborted, a separate needs-attention Task is created, and no partial source branch +or PR is published. Other clean sources can still form the team batch. An +already-present source gets independent no-op tracking rather than an empty +commit or PR. A team with no applied sources gets no empty batch PR. +Later sources that touch paths belonging to a failed source are also held for +attention to avoid publishing an incomplete dependency chain. This is a +conservative path check, not an inference of semantic dependencies. + +For example, three clean Finance PRs create one Finance issue/PR pair; two clean +SCM PRs create one SCM pair. A third SCM PR with a conflict gets its own +needs-attention issue, not another draft PR and not a blocker for the clean SCM +batch. + +Batch membership is fixed for that invocation and retained with its durable +identity. Retries must resume the same sources and target, including members +already marked processed by a partially completed publication. Later weekly +runs create new batches only for work that has not already been handled. +The 15-day selection clock is fixed at discovery, not recalculated as each +queued worker starts. ## Configuration @@ -28,7 +60,7 @@ Use the existing `extensibility-agent` environment and GitHub App: | Setting | Purpose | | --- | --- | -| Repository variable `EXT_REQ_BACKPORT_ENABLED=true` | Explicitly enables scheduled publication. Leave unset or false until deployment is approved. | +| Repository variable `EXT_REQ_BACKPORT_ENABLED=true` | Explicitly enables Sunday publication and daily status reconciliation. Leave unset or false until deployment is approved. | | Optional repository variable `EXT_REQ_BACKPORT_TARGET_BRANCH` | Overrides automatic selection, for example `releases/29.x`. Must name an existing `releases/.x` branch. | | Environment variable `EXTENSIBILITY_CLIENT_ID` | Existing GitHub App client ID. | | Environment secret `EXTENSIBILITY_TRIAGE_APP_PRIVATE_KEY` | Existing App private key. | @@ -49,31 +81,77 @@ original target even when a newer release branch appears. Run from `main` after the workflow has merged: ```powershell -# Read-only preview; no App token or remote writes. +# Preview all eligible team batches, using the same rules as Sunday. +gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` + -f dry_run=true + +# Publish all eligible team batches and independent exceptions. +gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` + -f dry_run=false + +# Preview one merged main PR independently. gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` -f source_pr=12345 -f dry_run=true -# Publish the backport issue and draft PR. +# Publish or reconcile only that selected source's existing request. gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` -f source_pr=12345 -f dry_run=false ``` -Manual runs accept a **PR number, not an issue number**. They bypass age, -request-category and processed-label discovery filters but still require a -merged `main` PR, safe content, the original target for an existing request, -and duplicate prevention. A processed source can be reconciled, not duplicated. +Manual runs accept an **optional PR number, not an issue number**. Leave it +empty to use the automatic 15-day/category/unprocessed selection and team +grouping. Supplying one PR bypasses age, request-category and processed-label +discovery filters, but still requires a merged `main` PR, safe content, the +original target for an existing request, and duplicate prevention. A selected +source already included in a team batch refers back to that batch; it must not +create a second individual backport. + +Both manual modes default to preview and require no App publication token until +`dry_run=false`. Publication still requires the existing protected environment +and App permissions even when scheduled creation is disabled. -Automatic failures older than seven days need a manual run. Discovery uses +Automatic failures older than 15 days need a manual single-PR run. Discovery uses paginated REST data rather than the eventually indexed search API. More than -256 eligible PRs causes an explicit failure, not a silently truncated batch. -The job summary includes the selected target, source issues and outcome. +256 matrix requests causes an explicit failure, not a silently truncated batch. +The job summary includes team, source PRs, original issues, target and individual +outcomes. A PR is never split simply to reduce batch size. + +## Local read-only preview + +From a BCApps checkout containing this branch, use PowerShell 7.4+, Git and an +authenticated `gh` CLI. No merge to `main` is needed for local preparation. + +```powershell +Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force + +# Omit -SourcePr to discover all eligible batches; supply it for one source. +$requests = @(Find-ExtRequestBackportBatches -Repository microsoft/BCApps ` + -SourcePr '11608' -BatchId ([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString())) + +foreach ($request in $requests) { + $folder = Join-Path ([IO.Path]::GetTempPath()) ('backport-' + [guid]::NewGuid().ToString('N')) + New-ExtRequestBackportBatch -Repository microsoft/BCApps ` + -RequestJson ($request | ConvertTo-Json -Depth 15 -Compress) ` + -StatePath (Join-Path $folder 'state.json') +} +``` + +This reads GitHub and creates temporary local results only. Do not call the +publication function using a personal `gh` token: object authorship must match +the expected GitHub App. The existing `Find-ExtRequestBackport` and +`New-ExtRequestBackport` single-PR APIs remain available for earlier local +commands. ## Publication and recovery -The deterministic branch is `bc-extrequest-backport/pr-/.x`. -Tracking issues and PRs carry a versioned marker with the main PR, target and -source merge SHA. Existing objects must have the expected marker, repository, -branches and GitHub App author. Open and closed PRs are both checked. +Independent requests retain the deterministic branch +`bc-extrequest-backport/pr-/.x`. Team requests have their own +stable batch identity and branch, with immutable membership linking each source +PR and merge SHA. Tracking issues and PRs carry versioned identity metadata, +the pinned target and original source identities. Existing objects must have +the expected identity, repository, branches and GitHub App author. Open and +closed PRs are both checked; legacy independent requests are not replayed into +new team batches. Branches are never force-pushed and human edits are never overwritten. Source PR status labels are created as needed: @@ -92,11 +170,15 @@ The version suffix follows the pinned release. `ext-backport-processed` is the automatic source filter; `ext-backporting` identifies the backport issue and PR. Versioned source labels give the detailed status. Labels are not the only duplicate check. A retry repairs missing issue/PR labels -and reciprocal links rather than creating another pair. A scheduled -reconciliation also handles older open tracking issues: when their release PR -merges, it closes the issue and marks the source PR, even outside the seven-day -discovery window. Release-targeted PRs do not automatically close GitHub issues, -so reconciliation explicitly closes the new Task. +and reciprocal links rather than creating another pair. + +A **daily 21:25 Europe/Copenhagen** reconciliation updates existing backports +without creating new backport batches. It also runs after Sunday creation. +When a team PR merges, it closes that batch's issue and updates every included +main PR, even outside the 15-day discovery window. Conflicted/excluded sources +are not marked merged with the clean batch. Independent requests are reconciled +as before. Release-targeted PRs do not automatically close GitHub issues, so +reconciliation explicitly closes the tracking Task. API writes are not automatically retried. If issue or PR creation has an uncertain outcome, its intent label remains. A later run first discovers the @@ -105,10 +187,11 @@ stops for operator reconciliation. Before removing an intent label, inspect all open/closed tracking objects and the deterministic branch and establish whether creation occurred. Never clear labels simply to bypass duplicate checks. -Conflicts create a linked tracking issue but no partial branch or PR, and are -not retried automatically. Resolve the backport manually using the recorded -source/target identity, or rerun that main PR manually if the target has changed. -Closed-unmerged PRs and closed tracking issues are not recreated automatically. +Conflicts create a linked independent tracking issue but no partial branch or +PR, and are not retried automatically. Rerun that main PR manually if the target +has changed. A separate human-created conflict-resolution PR is not adopted +automatically; complete its tracking/status manually. Closed-unmerged PRs and +closed tracking issues are not recreated automatically. ## Content boundaries @@ -120,13 +203,14 @@ deployment identities or experimental AI workflow. BCApps currently allows squash merges only. A source commit must have one parent and be on `main`. Only regular, non-executable files under `src/` are -admitted, at most 250 paths. Renames are handled as delete/add pairs. Changes to -workflow/build instructions, symlinks, submodules and unsafe paths stop before -publication. A temporary sparse Git checkout contains only affected paths. +admitted, at most 250 paths per source. Renames are handled as delete/add pairs. +Changes to workflow/build instructions, symlinks, submodules and unsafe paths +stop before publication. A temporary sparse Git checkout contains only affected paths. Global/system Git config, hooks, filters and repository normalization are disabled, and source content is never executed. A conflict aborts the complete -cherry-pick. Publication verifies the prepared commit/tree/parent and reads back -the pushed ref before creating the PR. +cherry-pick without discarding earlier clean sources. Publication verifies the +prepared chain, membership and resulting tree and reads back the pushed ref +before creating the PR. Normal release CI and developer review remain mandatory. The workflow never merges a backport PR. Its offline Pester tests run for relevant workflow PRs and diff --git a/.github/workflows/bc-extrequest-backport.yml b/.github/workflows/bc-extrequest-backport.yml index c453a32a02f..e7ed2660b83 100644 --- a/.github/workflows/bc-extrequest-backport.yml +++ b/.github/workflows/bc-extrequest-backport.yml @@ -2,18 +2,24 @@ name: 'Extensibility Request Backport' run-name: >- ${{ github.event_name == 'workflow_dispatch' && - format('Backport main PR #{0} (preview = {1})', inputs.source_pr, inputs.dry_run) || - 'Backport recently merged extensibility requests' }} + (inputs.source_pr != '' && + format('Backport main PR #{0} (preview = {1})', inputs.source_pr, inputs.dry_run) || + format('Backport team batches (preview = {0})', inputs.dry_run)) || + (github.event_name == 'schedule' && github.event.schedule == '25 21 * * *' && + 'Reconcile extensibility backports' || 'Backport weekly extensibility team batches') }} on: schedule: - - cron: '10 21 * * *' + - cron: '10 21 * * 0' + timezone: 'Europe/Copenhagen' + - cron: '25 21 * * *' timezone: 'Europe/Copenhagen' workflow_dispatch: inputs: source_pr: - description: Merged main PR number (bypasses date and request-category filters) - required: true + description: Optional merged main PR number; leave empty for all eligible team batches + required: false + default: '' type: string dry_run: description: Preview without creating or changing branches, issues, PRs or labels @@ -38,6 +44,7 @@ concurrency: jobs: tests: + if: github.event_name != 'schedule' || github.event.schedule == '10 21 * * 0' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -62,9 +69,11 @@ jobs: github.repository == 'microsoft/BCApps' && github.ref == 'refs/heads/main' && (github.event_name == 'workflow_dispatch' || - (github.event_name == 'schedule' && vars.EXT_REQ_BACKPORT_ENABLED == 'true')) + (github.event_name == 'schedule' && + github.event.schedule == '10 21 * * 0' && + vars.EXT_REQ_BACKPORT_ENABLED == 'true')) runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 permissions: contents: read issues: read @@ -78,19 +87,20 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: .github/scripts/bc-extrequest-backport - - name: Discover merged main PRs + - name: Discover team batches and independent requests id: discover env: GH_TOKEN: ${{ github.token }} SOURCE_PR: ${{ inputs.source_pr }} TARGET_OVERRIDE: ${{ vars.EXT_REQ_BACKPORT_TARGET_BRANCH }} + BATCH_ID: ${{ github.run_id }} run: | Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force - $requests = @(Find-ExtRequestBackport -Repository $env:GITHUB_REPOSITORY ` - -SourcePr $env:SOURCE_PR -TargetOverride $env:TARGET_OVERRIDE) + $requests = @(Find-ExtRequestBackportBatches -Repository $env:GITHUB_REPOSITORY ` + -SourcePr $env:SOURCE_PR -TargetOverride $env:TARGET_OVERRIDE -BatchId $env:BATCH_ID) "has-work=$(($requests.Count -gt 0).ToString().ToLowerInvariant())" | Add-Content $env:GITHUB_OUTPUT - "matrix=$(@{ include = $requests } | ConvertTo-Json -Depth 5 -Compress)" | + "matrix=$(@{ include = $requests } | ConvertTo-Json -Depth 15 -Compress)" | Add-Content $env:GITHUB_OUTPUT backport: @@ -98,7 +108,7 @@ jobs: if: needs.discover.outputs.has-work == 'true' runs-on: ubuntu-latest environment: extensibility-agent - timeout-minutes: 45 + timeout-minutes: 120 permissions: contents: read issues: read @@ -108,7 +118,7 @@ jobs: max-parallel: 1 matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} concurrency: - group: extrequest-backport-${{ github.repository }}-${{ matrix.source_pr }} + group: extrequest-backport-${{ github.repository }}-${{ matrix.concurrency_key || matrix.key }} cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -120,15 +130,12 @@ jobs: id: prepare env: GH_TOKEN: ${{ github.token }} - SOURCE_PR: ${{ matrix.source_pr }} - TARGET_BRANCH: ${{ matrix.target }} - MANUAL: ${{ github.event_name == 'workflow_dispatch' }} + REQUEST_JSON: ${{ toJSON(matrix) }} run: | Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force $statePath = Join-Path $env:RUNNER_TEMP 'extrequest-backport/state.json' - New-ExtRequestBackport -Repository $env:GITHUB_REPOSITORY ` - -SourcePr ([long]$env:SOURCE_PR) -Target $env:TARGET_BRANCH ` - -Manual ($env:MANUAL -eq 'true') -StatePath $statePath + New-ExtRequestBackportBatch -Repository $env:GITHUB_REPOSITORY ` + -RequestJson $env:REQUEST_JSON -StatePath $statePath - name: Mint fresh GitHub App publication token if: github.event_name != 'workflow_dispatch' || !inputs.dry_run id: app-token @@ -148,12 +155,20 @@ jobs: BACKPORT_BOT_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot] run: | Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force - Publish-ExtRequestBackport -StatePath (Join-Path $env:RUNNER_TEMP 'extrequest-backport/state.json') ` + Publish-ExtRequestBackportBatch -StatePath (Join-Path $env:RUNNER_TEMP 'extrequest-backport/state.json') ` -BotLogin $env:BACKPORT_BOT_LOGIN reconcile: - needs: [discover, backport] - if: always() && github.event_name == 'schedule' && needs.discover.result == 'success' + needs: [tests, discover, backport] + if: >- + always() && + github.repository == 'microsoft/BCApps' && + github.ref == 'refs/heads/main' && + github.event_name == 'schedule' && + vars.EXT_REQ_BACKPORT_ENABLED == 'true' && + needs.tests.result != 'failure' && + needs.tests.result != 'cancelled' && + (github.event.schedule == '25 21 * * *' || needs.discover.result == 'success') runs-on: ubuntu-latest environment: extensibility-agent timeout-minutes: 15 From 2d156620adf961e9b9573641f67a055fcbaaeeb4 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Tue, 20 Oct 2026 12:07:23 +0200 Subject: [PATCH 05/13] Simplify backports to publish-only with one source label Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 404 ++++++++++++++---- .../bc-extrequest-backport/Backport.psm1 | 315 ++++++++------ .../scripts/bc-extrequest-backport/README.md | 122 ++---- .github/workflows/bc-extrequest-backport.yml | 62 +-- 4 files changed, 540 insertions(+), 363 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index 6ba19d9712f..3e6ae528940 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -211,12 +211,24 @@ Describe 'Team batch discovery and immutable identity' { It 'resumes an open immutable batch even when all members are outside discovery' { Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } - $script:sourceData.Clear() + Mock Invoke-BackportApi { @() } $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 999) $rows.Count | Should -Be 1 $rows[0].key | Should -Be $fixture.request.key } + It 'does not poll a completed batch or replay its recorded conflict while its Task stays open' { + Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } + foreach ($number in 42, 43) { $script:sourceData[[string]$number].labels += @{ name = 'ext-backport-processed' } } + Mock Get-BackportTracking { + if ($SourcePr -eq 44) { @{ state = 'open'; body = 'Preparation result: **conflict**.' } } + } + @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 999).Count | Should -Be 0 + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 44 -BatchId 999) + $rows.Count | Should -Be 1 + $rows[0].key | Should -Be $fixture.request.key + } + It 'rejects changed frozen membership, targets, source proofs and malformed manifests' -ForEach @( @{ kind = 'target' }, @{ kind = 'membership' }, @{ kind = 'sha' }, @{ kind = 'parent' } ) { @@ -236,7 +248,7 @@ Describe 'Team batch discovery and immutable identity' { } } -Describe 'Batch publication and reconciliation' { +Describe 'Batch publication and duplicate prevention' { InModuleScope Backport { BeforeEach { $script:fixture = New-BatchFixture @@ -263,12 +275,10 @@ Describe 'Batch publication and reconciliation' { Mock Get-BackportSource { $script:sourceData[[string]$Number] } Mock Get-BackportSourceIssues { @($script:fixture.members | Where-Object source_pr -eq $Number)[0].issues } Mock Ensure-BackportLabels {} - Mock Remove-BackportIntents {} - Mock Add-BackportStatus { - $script:sourceData[[string]$SourcePr].labels += @{ name = Get-BackportStatusLabel $Status $Target } - if ($Status -in @('created', 'merged', 'already-present', 'needs-attention')) { - $script:sourceData[[string]$SourcePr].labels += @{ name = 'ext-backport-processed' } - } + $script:realProcessed = (Get-Command Set-BackportProcessed).ScriptBlock + Mock Set-BackportProcessed { + & $script:realProcessed -Repository $Repository -SourcePr $SourcePr + $script:sourceData[[string]$SourcePr].labels += @{ name = 'ext-backport-processed' } } Mock Assert-BackportPreparedBatch {} Mock Invoke-BackportGit { @@ -332,16 +342,25 @@ Describe 'Batch publication and reconciliation' { } Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -eq 'repos/microsoft/BCApps/pulls' -and $Method -eq 'POST' -and $Body.draft -and - $Body.body.Contains('Fixes microsoft/BCApps#100') -and $Body.base -eq 'releases/29.x' + $Body.body.Contains('Backport tracking issue: https://github.com/microsoft/BCApps/issues/100') -and $Body.base -eq 'releases/29.x' } $body = $script:batchPull.body $body | Should -Match 'pull/42' $body | Should -Match 'pull/43' $body | Should -Match ('a' * 40) $body | Should -Match ('b' * 40) - [regex]::Matches($body, 'https://github.com/microsoft/BCApps/issues/10').Count | Should -Be 1 - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 44 -and $Status -eq 'needs-attention' } - Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $SourcePr -eq 44 -and $Status -in @('created', 'merged') } + [regex]::Matches($body, 'https://github.com/microsoft/BCApps/issues/10(?=\s|$)').Count | Should -Be 1 + $body | Should -Not -Match '(?i)\b(fixes|closes|resolves)\s' + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 43 } + Should -Invoke Set-BackportProcessed -Times 0 -ParameterFilter { $SourcePr -eq 44 } + Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { + $Method -eq 'POST' -and $Path -match '^repos/microsoft/BCApps/issues/(42|43)/labels$' -and + $Body.labels.Count -eq 1 -and $Body.labels[0] -ceq 'ext-backport-processed' + } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -ne 'GET' -and $Path -match '^repos/microsoft/BCApps/issues/(10|44)(?:/|$)' + } Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -in @('cherry-pick', 'checkout', 'fetch') } @@ -359,27 +378,24 @@ Describe 'Batch publication and reconciliation' { } } - It 'daily reconciliation repairs EVERY included source even when GitHub already closed the merged tracking issue' { + It 'repairs processed flags for any existing batch PR without Task lifecycle changes' -ForEach @( + @{ pullState = 'open'; merged = $null }, @{ pullState = 'closed'; merged = $null }, + @{ pullState = 'closed'; merged = '2026-10-20T09:00:00Z' } + ) { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - $script:batchPull.merged_at = '2026-10-20T09:00:00Z' + $script:batchPull.state = $pullState + $script:batchPull.merged_at = $merged $script:batchTracking.state = 'closed' - Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'merged' } - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 43 -and $Status -eq 'merged' } - Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $SourcePr -eq 44 -and $Status -eq 'merged' } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - } - - It 'marks every included source needs-attention when the batch PR closes unmerged' { + $script:sourceData['42'].labels = @(@{ name = 'event-request' }) Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - $script:batchPull.state = 'closed' - Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'needs-attention' } - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 43 -and $Status -eq 'needs-attention' } + Should -Invoke Set-BackportProcessed -Times 2 -ParameterFilter { $SourcePr -eq 42 } + Should -Invoke Set-BackportProcessed -Times 2 -ParameterFilter { $SourcePr -eq 43 } + Should -Invoke Set-BackportProcessed -Times 0 -ParameterFilter { $SourcePr -eq 44 } + Should -Invoke Assert-BackportPreparedBatch -Times 1 Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'PATCH' -and $Path -eq 'repos/microsoft/BCApps/issues/100' -and - $Body.ContainsKey('state') -and $Body.state -eq 'closed' + $Method -eq 'PATCH' -and $Body.ContainsKey('state') } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } } It 'reconciles an existing batch without duplicating issue, PR or conflict outcomes' { @@ -389,13 +405,56 @@ Describe 'Batch publication and reconciliation' { Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } } - It 'does not blindly retry an uncertain team issue POST' { + It 'prepares an existing batch PR resume without local Git or lifecycle polling' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $script:batchPull.state = 'closed' + $script:batchPull.merged_at = '2026-10-20T09:00:00Z' + $row = @{} + $fixture.request + $row.manifest = $fixture + $resumedPath = Join-Path $TestDrive 'resumed-batch.json' + Mock Initialize-BackportBatchGit { throw 'Existing PR must not prepare Git again.' } + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $resumedPath + $resumed = Get-Content $resumedPath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 + (Get-BackportBatchMarker $resumed) | Should -Be (Get-BackportBatchMarker $fixture) + Publish-ExtRequestBackportBatch $resumedPath 'test-app[bot]' + Should -Invoke Initialize-BackportBatchGit -Times 0 + Should -Invoke Assert-BackportPreparedBatch -Times 1 + Should -Invoke Invoke-BackportGit -Times 1 + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('state') + } + } + + It 'attempts an uncertain team Task POST only once within an invocation and never labels sources' { Mock Invoke-BackportApi { throw 'Uncertain POST' } -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Uncertain POST*' - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } + Should -Invoke Set-BackportProcessed -Times 0 + } + + It 'recovers a tracking Task observed by the final REST membership recheck without another Task POST' { + $script:lookupCount = 0 + $script:observed = @{ + number = 100; body = Get-BackportBatchMarker $fixture; state = 'open'; labels = @() + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' + } + Mock Get-BackportBatchRecords { + $script:lookupCount++ + if ($script:lookupCount -ge 2) { + $script:batchTracking = $script:observed + @{ issue = $script:observed; manifest = $script:fixture } + } + } + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and + $Body.body.Contains('bc-extrequest-backport:v2') + } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } } It 'recovers a confirmed team issue after an interrupted response without repeating its POST' { @@ -421,6 +480,36 @@ Describe 'Batch publication and reconciliation' { { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Uncertain PR POST*' { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + (Read-BackportPublicationIntent $script:batchTracking.body) | Should -Be $fixture.head + Should -Invoke Set-BackportProcessed -Times 0 + } + + It 'requires durable intent readback before attempting a batch PR POST' { + Mock Invoke-BackportApi {} -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('body') -and + $Body.body.Contains('bc-extrequest-backport:publication-intent:') + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*intent was not confirmed*' + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + Should -Invoke Set-BackportProcessed -Times 0 + } + + It 'retains an accepted intent PATCH with a lost response and never guesses whether to POST a PR' { + Mock Invoke-BackportApi { + $script:batchTracking.body = $Body.body + throw 'Lost intent response' + } -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('body') -and + $Body.body.Contains('bc-extrequest-backport:publication-intent:') + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost intent response*' + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + Should -Invoke Set-BackportProcessed -Times 0 } It 'reuses an accepted batch PR when its response was lost' { @@ -436,7 +525,7 @@ Describe 'Batch publication and reconciliation' { { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost PR response*' Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'created' } + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } } It 'publishes the clean batch even when independent conflict issue publication fails' { @@ -446,9 +535,8 @@ Describe 'Batch publication and reconciliation' { } { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Independent POST failed*' $script:batchPull | Should -Not -BeNullOrEmpty - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 42 -and $Status -eq 'created' } - Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $SourcePr -eq 44 -and $Status -eq 'created' } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } + Should -Invoke Set-BackportProcessed -Times 0 -ParameterFilter { $SourcePr -eq 44 } Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v1') @@ -465,7 +553,7 @@ Describe 'Batch publication and reconciliation' { ($Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v2'))) } Should -Invoke Invoke-BackportGit -Times 0 - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $SourcePr -eq 43 -and $Status -eq 'already-present' } + Should -Invoke Set-BackportProcessed -Times 0 } It 'fails closed on modified prepared provenance before any remote write' { @@ -486,6 +574,17 @@ Describe 'Batch publication and reconciliation' { Should -Invoke Invoke-BackportApi -Times 0 } + It 'reads legacy source publication intents fail-closed without writing or clearing legacy labels' { + $script:sourceData['42'].labels += @{ name = 'ext-backport-publishing:29.x' } + $script:batchTracking = @{ + number = 100; body = Get-BackportBatchMarker $fixture; state = 'open' + user = @{ login = 'test-app[bot]' } + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Legacy batch PR publication is unconfirmed*' + Should -Invoke Invoke-BackportApi -Times 0 + Should -Invoke Set-BackportProcessed -Times 0 + } + It 'refuses a batch object by another actor even if its manifest is intact' { $script:batchTracking = @{ number = 100; body = Get-BackportBatchMarker $fixture; state = 'open'; user = @{ login = 'human' } @@ -494,6 +593,19 @@ Describe 'Batch publication and reconciliation' { Should -Invoke Invoke-BackportApi -Times 0 } + It 'does not mark sources for an existing PR with changed author or SHA' -ForEach @( + @{ kind = 'author' }, @{ kind = 'sha' } + ) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + if ($kind -eq 'author') { $script:batchPull.user.login = 'human' } + else { $script:batchPull.head.sha = '0' * 40 } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw + Should -Invoke Set-BackportProcessed -Times 2 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + } + It 'does not overwrite a batch branch with human changes' { Mock Invoke-BackportGit { @{ text = "$('e' * 40)`trefs/heads/changed" } } { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*never overwrite*' @@ -501,7 +613,7 @@ Describe 'Batch publication and reconciliation' { Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } } - It 'defers already-present status until closure and recovers it through daily reconciliation' { + It 'reuses a no-op Task after initial closure failure without marking its source or duplicating the Task' { $script:fixture = New-BatchFixture -Outcomes @('already-present') $script:sourceData['42'].merged_at = $fixture.request.sources[0].merged_at $fixture | ConvertTo-Json -Depth 60 | Set-Content $statePath @@ -509,19 +621,14 @@ Describe 'Batch publication and reconciliation' { $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' } { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Close failed*' - { Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' } | Should -Throw '*Close failed*' - Should -Invoke Add-BackportStatus -Times 0 -ParameterFilter { $Status -eq 'already-present' } - Should -Invoke Remove-BackportIntents -Times 0 + Should -Invoke Set-BackportProcessed -Times 0 Mock Invoke-BackportApi { $script:singleTracking['42'].state = 'closed' } -ParameterFilter { $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' } - Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { - $SourcePr -eq 42 -and $Status -eq 'already-present' - } - Should -Invoke Remove-BackportIntents -Times 1 -ParameterFilter { $SourcePr -eq 42 } - Sync-ExtRequestBackport microsoft/BCApps 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 3 -ParameterFilter { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Set-BackportProcessed -Times 0 + Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' } Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { @@ -649,7 +756,7 @@ Describe 'Extensibility backport policy' { { Read-BackportMarker "$marker`n$marker" } | Should -Throw } - It 'includes the main PR, all main issues, and only closes the new backport issue' { + It 'includes main PR and all original issue links without automatic issue-closing keywords' { $state = @{ repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 issues = @(@{ url = 'https://github.com/microsoft/BCApps/issues/10' }, @@ -660,8 +767,8 @@ Describe 'Extensibility backport policy' { $body | Should -Match 'https://github.com/microsoft/BCApps/pull/42' $body | Should -Match 'https://github.com/microsoft/BCApps/issues/10' $body | Should -Match 'https://github.com/microsoft/BCApps/issues/11' - $body | Should -Match 'Fixes microsoft/BCApps#99' - $body | Should -Not -Match 'Fixes microsoft/BCApps#1[01]' + $body | Should -Match 'Backport tracking issue: https://github.com/microsoft/BCApps/issues/99' + $body | Should -Not -Match '(?i)\b(fixes|closes|resolves)\s' } It 'rejects non-source paths, symlinks, executable files and traversal' -ForEach @( @@ -737,36 +844,29 @@ Describe 'Backport discovery and recovery' { $requests[0].source_pr | Should -Be 43 } - It 'marks the original PR as processed only after a durable outcome' -ForEach @( - @{ status = 'created' } - @{ status = 'merged' } - @{ status = 'needs-attention' } - @{ status = 'already-present' } - ) { + It 'writes only the single processed label to the original main PR' { Mock Ensure-BackportLabels {} Mock Invoke-BackportApi {} - Add-BackportStatus microsoft/BCApps 42 $status 'releases/29.x' + Set-BackportProcessed microsoft/BCApps 42 Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and $Method -eq 'POST' -and $Body.labels -contains 'ext-backport-processed' -and - $Body.labels -notcontains 'ext-backporting' -and $Body.labels.Count -eq 2 + $Body.labels.Count -eq 1 } Should -Invoke Ensure-BackportLabels -Times 1 -ParameterFilter { @($Labels | Where-Object name -eq 'ext-backport-processed').Count -eq 1 } } - It 'does not hide unfinished issue or PR creation from automatic recovery' -ForEach @( - @{ status = 'pending' } - @{ status = 'publishing' } - ) { - Mock Ensure-BackportLabels {} - Mock Invoke-BackportApi {} - Add-BackportStatus microsoft/BCApps 42 $status 'releases/29.x' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and - $Body.labels -notcontains 'ext-backport-processed' -and $Body.labels.Count -eq 1 - } + It 'removes the obsolete lifecycle API and accepts only well-formed publication intents' { + (Get-Module Backport).ExportedFunctions.Keys | Should -Not -Contain 'Sync-ExtRequestBackport' + Get-Command Add-BackportStatus, Remove-BackportIntents, Get-BackportStatusLabel -ErrorAction SilentlyContinue | + Should -BeNullOrEmpty + Read-BackportPublicationIntent 'ordinary issue' | Should -BeNullOrEmpty + $marker = "" + Read-BackportPublicationIntent $marker | Should -Be ('a' * 40) + { Read-BackportPublicationIntent "$marker`n$marker" } | Should -Throw + { Read-BackportPublicationIntent '' } | Should -Throw } It 'refuses manually selected unmerged PRs too' { @@ -793,21 +893,20 @@ Describe 'Backport discovery and recovery' { $tracking = @{ number = 100; body = $marker; user = @{ login = 'test-app[bot]' }; state = 'open'; html_url = 'issue-url' } $pull = @{ number = 101; body = $marker; user = @{ login = 'test-app[bot]' }; merged_at = $null - head = @{ ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } + head = @{ sha = 'b' * 40; ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } state = 'open'; html_url = 'pr-url' } Mock Ensure-BackportLabels {} - Mock Add-BackportStatus {} - Mock Remove-BackportIntents {} + Mock Set-BackportProcessed {} Mock Invoke-BackportApi {} Complete-BackportPull $state $tracking $pull 'test-app[bot]' Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { $Path -like '*/labels' -and $Method -eq 'POST' } Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Path -match '/(issues|pulls)$' -and $Method -eq 'POST' } - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'created' } + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } } - It 'closes the tracking issue and marks the original PR when a backport merges' { + It 'marks the original PR for a validated merged PR without closing its tracking Task' { $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) $state = @{ repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 } $tracking = @{ @@ -816,17 +915,18 @@ Describe 'Backport discovery and recovery' { } $pull = @{ number = 101; body = $marker; user = @{ login = 'test-app[bot]' }; merged_at = '2026-10-19T00:00:00Z' - head = @{ ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } + head = @{ sha = 'b' * 40; ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } state = 'closed'; html_url = 'pr-url' } Mock Ensure-BackportLabels {} - Mock Add-BackportStatus {} - Mock Remove-BackportIntents {} + Mock Set-BackportProcessed {} Mock Invoke-BackportApi {} Complete-BackportPull $state $tracking $pull 'test-app[bot]' - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'merged' } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Body.state -eq 'closed' } + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('state') + } } } } @@ -929,8 +1029,11 @@ Describe 'Native Git backport integration' { $script:remoteHead = '' $script:actualGit = (Get-Command Invoke-BackportGit).ScriptBlock Mock Get-BackportBatchRecords { - if ($null -ne $script:durable) { @{ issue = @{ state = 'open' }; manifest = $script:durable } } + if ($null -ne $script:durable) { + @{ issue = @{ state = 'open'; body = Get-BackportBatchMarker $script:durable }; manifest = $script:durable } + } } + Mock Get-BackportBatchPull { $null } Mock Get-BackportTracking { $null } Mock Get-BackportSource { $proof = @($script:request.sources | Where-Object number -eq $Number)[0] @@ -1048,9 +1151,10 @@ Describe 'Native Git backport integration' { Mock Get-BackportSourceIssues { $script:issues } Mock Get-BackportTracking { $script:tracking } Mock Get-BackportPull { $script:pull } + Mock Get-BackportBatchRecords { @() } Mock Ensure-BackportLabels {} - Mock Add-BackportStatus {} - Mock Remove-BackportIntents {} + $script:realProcessed = (Get-Command Set-BackportProcessed).ScriptBlock + Mock Set-BackportProcessed { & $script:realProcessed -Repository $Repository -SourcePr $SourcePr } Mock Invoke-BackportGit { switch ($Arguments -join ' ') { 'rev-parse HEAD' { return @{ text = 'b' * 40 } } @@ -1083,8 +1187,12 @@ Describe 'Native Git backport integration' { } if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/pulls/101') { return $script:pull } if ($Path -like '*/compare/*' -and $Method -eq 'GET') { return @{ status = 'ahead' } } - if ($Method -eq 'POST' -and $Path -match '/issues/(100|101)/labels$') { return } - if ($Method -eq 'PATCH' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { return } + if ($Method -eq 'POST' -and $Path -match '/issues/(42|100|101)/labels$') { return } + if ($Method -eq 'PATCH' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { + if ($Body.ContainsKey('body')) { $script:tracking.body = $Body.body } + if ($Body.ContainsKey('state')) { $script:tracking.state = $Body.state } + return + } throw "Unexpected API invocation: $Method $Path" } } @@ -1104,11 +1212,14 @@ Describe 'Native Git backport integration' { $Body.head -eq 'bc-extrequest-backport/pr-42/29.x' -and $Body.body.Contains('https://github.com/microsoft/BCApps/pull/42') -and $Body.body.Contains('https://github.com/microsoft/BCApps/issues/10') -and - $Body.body.Contains('Fixes microsoft/BCApps#100') + $Body.body.Contains('Backport tracking issue: https://github.com/microsoft/BCApps/issues/100') -and + -not $Body.body.Contains('Fixes ') + } + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } + (Read-BackportPublicationIntent $script:tracking.body) | Should -Be ('b' * 40) + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Path -like 'repos/microsoft/BCApps/issues/10/*' } - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'pending' } - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'publishing' } - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'created' } } It 'creates the tracking issue on a conflict but publishes no branch or PR' { @@ -1118,7 +1229,7 @@ Describe 'Native Git backport integration' { Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } Should -Invoke Invoke-BackportGit -Times 0 - Should -Invoke Add-BackportStatus -Times 1 -ParameterFilter { $Status -eq 'needs-attention' } + Should -Invoke Set-BackportProcessed -Times 0 } It 'closes an already-present tracking issue without creating an empty PR' { @@ -1127,9 +1238,97 @@ Describe 'Native Git backport integration' { Publish-ExtRequestBackport $statePath 'test-app[bot]' Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'PATCH' -and $Body.state -eq 'closed' } Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + Should -Invoke Set-BackportProcessed -Times 0 + } + + It 'reuses recorded conflict and no-op results without Git re-preparation, source labels or duplicate Tasks' -ForEach @( + @{ outcome = 'conflict'; manual = $false }, + @{ outcome = 'already-present'; manual = $false }, @{ outcome = 'already-present'; manual = $true } + ) { + $state.result.outcome = $outcome + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $reusedPath = Join-Path $TestDrive 'reused.json' + New-ExtRequestBackport microsoft/BCApps 42 releases/99.x $manual $reusedPath + $reused = Get-Content $reusedPath -Raw | ConvertFrom-Json -AsHashtable + $reused.target | Should -Be 'releases/29.x' + $reused.result.outcome | Should -Be $outcome + Publish-ExtRequestBackport $reusedPath 'test-app[bot]' + Should -Invoke Invoke-BackportGit -Times 0 + Should -Invoke Set-BackportProcessed -Times 0 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' + } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + } + + It 'allows explicit manual preparation to retry an open standalone conflict' { + $state.result.outcome = 'conflict' + Publish-ExtRequestBackport -BotLogin 'test-app[bot]' -PreparedState $state + Mock Invoke-BackportGit { throw 'Manual preparation attempted' } -ParameterFilter { + $Arguments[0] -eq 'init' + } + { New-ExtRequestBackport microsoft/BCApps 42 releases/99.x $true (Join-Path $TestDrive 'retry.json') } | + Should -Throw '*Manual preparation attempted*' + Should -Invoke Set-BackportProcessed -Times 0 + } + + It 'publishes a clean manual conflict retry using the same Task and refreshes its stale outcome' { + $state.result.outcome = 'conflict' + Publish-ExtRequestBackport -BotLogin 'test-app[bot]' -PreparedState $state + $state.result.outcome = 'applied' + Publish-ExtRequestBackport -StatePath $statePath -BotLogin 'test-app[bot]' -PreparedState $state + $script:tracking.body | Should -Not -Match 'Preparation result: \*\*conflict\*\*' + $script:tracking.body | Should -Match 'Preparation result: \*\*applied\*\*' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' + } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } } - It 'does not repeat an uncertain tracking-issue POST' { + It 'uses only the processed payload after a confirmed standalone PR and never labels original issues' { + Mock Set-BackportProcessed { + if ($null -eq $script:pull -or $script:pull.head.sha -cne $script:state.result.commit) { + throw 'Attempted to mark a source without a confirmed PR.' + } + & $script:realProcessed -Repository $Repository -SourcePr $SourcePr + } + Mock Invoke-BackportApi {} -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues/42/labels' + } + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + Publish-ExtRequestBackport $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and + $Body.labels.Count -eq 1 -and $Body.labels[0] -ceq 'ext-backport-processed' + } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -ne 'GET' -and $Path -match '^repos/microsoft/BCApps/issues/10(?:/|$)' + } + } + + It 'requires confirmed intent before PR creation and leaves source labels untouched on PR POST failure' { + Mock Invoke-BackportApi { + (Read-BackportPublicationIntent $script:tracking.body) | Should -Be ('b' * 40) + throw 'Uncertain PR response' + } -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*Uncertain PR response*' + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' + Should -Invoke Set-BackportProcessed -Times 0 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + } + + It 'fails closed on legacy source state without a tracking record' { $source.labels += @{ name = 'ext-backport-pending:29.x' } $state | ConvertTo-Json -Depth 20 | Set-Content $statePath { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' @@ -1146,14 +1345,37 @@ Describe 'Native Git backport integration' { } It 'does not repeat an uncertain release-PR POST' { - $source.labels += @{ name = 'ext-backport-publishing:29.x' } + $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) $script:tracking = @{ - number = 100; body = Get-BackportMarker 42 'releases/29.x' ('a' * 40); state = 'open' + number = 100; body = "$marker`n"; state = 'open' user = @{ login = 'test-app[bot]' } } $state | ConvertTo-Json -Depth 20 | Set-Content $statePath { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*previous PR creation is unconfirmed*' Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } + Should -Invoke Set-BackportProcessed -Times 0 + } + + It 'reuses open, closed and merged standalone PRs without changing Task lifecycle' -ForEach @( + @{ pullState = 'open'; merged = $null }, @{ pullState = 'closed'; merged = $null }, + @{ pullState = 'closed'; merged = '2026-10-20T09:00:00Z' } + ) { + $state | ConvertTo-Json -Depth 20 | Set-Content $statePath + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $script:pull.state = $pullState + $script:pull.merged_at = $merged + $script:tracking.state = 'closed' + Publish-ExtRequestBackport $statePath 'test-app[bot]' + Should -Invoke Set-BackportProcessed -Times 2 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' + } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + } + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { + $Method -eq 'PATCH' -and $Body.ContainsKey('state') + } } It 'refuses changed automatic source categories before any writes' { diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index 76e4351ff0c..a954d1f876c 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -410,8 +410,17 @@ function New-ExtRequestBackport { Assert-BackportTarget $target $pull = Get-BackportPull $Repository $SourcePr $target $result = @{ outcome = 'existing'; target_sha = $null; commit = $null; tree = $null; paths = @() } + if ($null -eq $pull -and $null -ne $tracking) { + foreach ($outcome in @('conflict', 'already-present')) { + if ($tracking.body.Contains("Preparation result: **$outcome**.") -and + -not ($outcome -eq 'conflict' -and $Manual -and $tracking.state -ne 'closed')) { + $result.outcome = $outcome + } + } + } $directory = Join-Path (Split-Path -Parent $StatePath) 'git' - if ($null -eq $pull) { + $publicationBlocked = $null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body) + if ($null -eq $pull -and $result.outcome -eq 'existing' -and -not $publicationBlocked) { if (Test-Path -LiteralPath $directory) { throw 'Backport work directory already exists.' } $null = New-Item -ItemType Directory -Path $directory -Force $null = Invoke-BackportGit $directory @('init', '--quiet') @@ -453,11 +462,6 @@ function New-ExtRequestBackport { Write-BackportSummary "Main PR #$SourcePr (https://github.com/$Repository/pull/$SourcePr) -> $target`: **$($result.outcome)**. Main PR teams: $teamText. Source issues: $issueLinks" } -function Get-BackportStatusLabel { - param([string] $Status, [string] $Target) - "ext-backport-$Status`:$(($Target -split '/')[1])" -} - function Ensure-BackportLabels { param([string] $Repository, [object[]] $Labels) @@ -472,33 +476,56 @@ function Ensure-BackportLabels { } } -function Add-BackportStatus { - param([string] $Repository, [long] $SourcePr, [string] $Status, [string] $Target) - - $name = Get-BackportStatusLabel $Status $Target - $definitions = @(@{ name = $name; color = 'C5DEF5'; description = 'Extensibility backport status.' }) - $names = @($name) - if ($Status -in @('created', 'merged', 'needs-attention', 'already-present')) { - $definitions += @{ - name = 'ext-backport-processed'; color = 'C5DEF5' - description = 'Source PR handled by extensibility backport automation.' - } - $names += 'ext-backport-processed' +function Set-BackportProcessed { + param([string] $Repository, [long] $SourcePr) + Ensure-BackportLabels $Repository @(@{ + name = 'ext-backport-processed'; color = 'C5DEF5' + description = 'Source PR has a validated extensibility backport PR.' + }) + $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels" -Method POST -Body @{ + labels = @('ext-backport-processed') } - Ensure-BackportLabels $Repository $definitions - $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels" -Method POST -Body @{ labels = $names } } -function Remove-BackportIntents { - param([string] $Repository, [long] $SourcePr, [string] $Target) +function New-BackportTrackingTask { + param([string] $Repository, [hashtable] $Body) + try { + $created = Invoke-BackportApi "repos/$Repository/issues" -Method POST -Body $Body + return Invoke-BackportApi "repos/$Repository/issues/$($created.number)" + } + catch { + throw "Tracking Task creation is unconfirmed. No creation retry was attempted; inspect open/closed objects before another run. $($_.Exception.Message)" + } +} - $source = Get-BackportSource $Repository $SourcePr - foreach ($status in @('pending', 'publishing', 'needs-attention')) { - $label = Get-BackportStatusLabel $status $Target - if ($label -in @($source.labels | ForEach-Object { $_.name })) { - $null = Invoke-BackportApi "repos/$Repository/issues/$SourcePr/labels/$([uri]::EscapeDataString($label))" -Method DELETE - } +function Read-BackportPublicationIntent { + param([string] $Body) + $matches = [regex]::Matches($Body, '(?m)^\r?$') + if ($matches.Count -gt 1 -or ($matches.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:publication-intent:'))) { + throw 'Malformed or ambiguous tracking issue publication intent.' } + if ($matches.Count -eq 1) { return $matches[0].Groups[1].Value } + return $null +} + +function Set-BackportPublicationIntent { + param([hashtable] $State, [hashtable] $Tracking, [string] $Sha, [string] $BotLogin) + $repository = $State.repository + $fresh = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" + if ($State.schema -eq 2) { Assert-BackportBatchObject $fresh $State $BotLogin } + else { Assert-BackportObject $fresh $State $BotLogin } + if ($null -ne (Read-BackportPublicationIntent $fresh.body)) { + throw 'Previous PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' + } + $marker = "" + $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ + body = "$($fresh.body)`n`n$marker" + } + $confirmed = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" + if ($State.schema -eq 2) { Assert-BackportBatchObject $confirmed $State $BotLogin } + else { Assert-BackportObject $confirmed $State $BotLogin } + if ((Read-BackportPublicationIntent $confirmed.body) -cne $Sha) { throw 'Tracking issue publication intent was not confirmed.' } + return $confirmed } function Assert-BackportObject { @@ -536,7 +563,6 @@ function Get-BackportBody { $lines += @('', '## Backport', '', "Preparation result: **$($State.result.outcome)**.") if ($TrackingNumber -gt 0) { $lines += @('', "Backport tracking issue: https://github.com/$repository/issues/$TrackingNumber", '', - "Fixes $repository#$TrackingNumber", '', 'Review the complete change and run the normal release-branch CI before merging. No AI resolver was used.') } elseif ($State.result.outcome -eq 'conflict') { @@ -551,6 +577,11 @@ function Complete-BackportPull { Assert-BackportObject $Tracking $State $BotLogin Assert-BackportObject $Pull $State $BotLogin + $expectedCommit = if ($State.ContainsKey('result')) { $State.result.commit } else { $null } + if (-not $Pull.ContainsKey('head') -or $Pull.head.sha -cnotmatch '^[0-9a-f]{40}$' -or + ($expectedCommit -and $Pull.head.sha -cne $expectedCommit)) { + throw 'Existing backport PR does not contain the expected prepared commit.' + } $repository = $State.repository $labels = if ($State.ContainsKey('labels')) { $State.labels } else { $Tracking.labels } Ensure-BackportLabels $repository $labels @@ -564,13 +595,8 @@ function Complete-BackportPull { if (-not $body.Contains($link)) { $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "$body`n`n$link" } } - $status = if ($null -ne $Pull.merged_at) { 'merged' } elseif ($Pull.state -eq 'closed') { 'needs-attention' } else { 'created' } - Add-BackportStatus $repository $State.source_pr $status $State.target - if ($status -ne 'needs-attention') { Remove-BackportIntents $repository $State.source_pr $State.target } - if ($status -eq 'merged' -and $Tracking.state -ne 'closed') { - $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } - } - Write-BackportSummary "Main PR #$($State.source_pr): $($Pull.html_url) (**$status**); issue $($Tracking.html_url)." + Set-BackportProcessed $repository $State.source_pr + Write-BackportSummary "Main PR #$($State.source_pr): validated backport $($Pull.html_url); issue $($Tracking.html_url)." } function Publish-ExtRequestBackport { @@ -615,43 +641,62 @@ function Publish-ExtRequestBackport { return } $labelsOnSource = @($source.labels | ForEach-Object { $_.name }) - if ((Get-BackportStatusLabel publishing $state.target) -in $labelsOnSource) { - throw 'A previous PR creation is unconfirmed. Inspect closed/open PRs before clearing the publishing label.' + if ($null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body)) { + throw 'Previous PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' + } + if (@($labelsOnSource | Where-Object { $_ -match '^ext-backport-publishing:' }).Count -gt 0) { + throw 'Legacy PR publication is unconfirmed; operator reconciliation is required before retrying.' } if ($null -eq $tracking -and @($labelsOnSource | Where-Object { $_ -like 'ext-backport-*' }).Count -gt 0) { throw 'Source has prior backport state but no tracking issue. Reconcile manually; do not create a duplicate.' } - if ($null -ne $tracking -and ($tracking.state -eq 'closed' -or - (-not $state.manual -and (Get-BackportStatusLabel needs-attention $state.target) -in $labelsOnSource))) { - Write-BackportSummary "Main PR #$($state.source_pr): retained tracking issue $($tracking.html_url); manual attention is required." - return - } if ($state.result.outcome -notin @('applied', 'conflict', 'already-present')) { throw 'No publishable preparation result.' } Ensure-BackportLabels $repository $state.labels if ($null -eq $tracking) { - # A durable intent makes an uncertain POST fail closed on later runs. - Add-BackportStatus $repository $state.source_pr pending $state.target - $title = "[Backport $(($state.target -split '/')[1])] #$($state.source_pr): $($state.source_title -replace '[\r\n]', ' ')" - if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } - $tracking = Invoke-BackportApi "repos/$repository/issues" -Method POST -Body @{ - title = $title; body = Get-BackportBody $state; type = 'Task' - labels = @($state.labels | ForEach-Object { $_.name }) + $cohorts = @(Get-BackportBatchRecords $repository | Where-Object { $state.source_pr -in $_.manifest.request.source_prs }) + if ($cohorts.Count -gt 0 -and (-not $batchException -or $cohorts[0].manifest.request.key -cne $state.batch_key)) { + throw 'Source acquired immutable batch membership; rediscover instead of creating a Task.' + } + # No pre-Task journal exists. Recheck observed objects, issue exactly one POST, + # and leave an absent/unconfirmed Task after a failed POST to the operator. + $tracking = Get-BackportTracking $repository $state.source_pr + $pull = Get-BackportPull $repository $state.source_pr $state.target + if ($null -ne $tracking) { + Assert-BackportObject $tracking $state $BotLogin + if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin; return } + } + elseif ($null -ne $pull) { throw 'Existing backport PR has no tracking issue; reconcile manually.' } + else { + $title = "[Backport $(($state.target -split '/')[1])] #$($state.source_pr): $($state.source_title -replace '[\r\n]', ' ')" + if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } + $tracking = New-BackportTrackingTask $repository @{ + title = $title; body = Get-BackportBody $state; type = 'Task' + labels = @($state.labels | ForEach-Object { $_.name }) + } + Assert-BackportObject $tracking $state $BotLogin } - $tracking = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" - Assert-BackportObject $tracking $state $BotLogin } - if ($state.result.outcome -eq 'conflict') { - Add-BackportStatus $repository $state.source_pr needs-attention $state.target + if ($null -ne (Read-BackportPublicationIntent $tracking.body)) { + throw 'Previous PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' + } + $retryConflict = $state.manual -and -not $batchException -and $state.result.outcome -eq 'applied' -and + $tracking.body.Contains('Preparation result: **conflict**.') + if (($tracking.body.Contains('Preparation result: **conflict**.') -and -not $retryConflict) -or + $state.result.outcome -eq 'conflict') { Write-BackportSummary "Conflict for main PR #$($state.source_pr); manual backport tracked at $($tracking.html_url)." return } - if ($state.result.outcome -eq 'already-present') { - $null = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } - Add-BackportStatus $repository $state.source_pr already-present $state.target - Remove-BackportIntents $repository $state.source_pr $state.target + if ($tracking.body.Contains('Preparation result: **already-present**.') -or $state.result.outcome -eq 'already-present') { + if ($tracking.state -ne 'closed') { + $null = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } + } Write-BackportSummary "Main PR #$($state.source_pr) is already present; closed $($tracking.html_url) without an empty PR." return } + if ($tracking.state -eq 'closed') { + Write-BackportSummary "Main PR #$($state.source_pr): retained closed tracking issue $($tracking.html_url); manual attention is required." + return + } $directory = Join-Path (Split-Path -Parent $StatePath) 'git' $head = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() $tree = (Invoke-BackportGit $directory @('rev-parse', 'HEAD^{tree}')).text.Trim() @@ -671,7 +716,16 @@ function Publish-ExtRequestBackport { $readback = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() if (($readback -split '\s+')[0] -cne $head) { throw 'Backport branch publication was not independently confirmed.' } } - Add-BackportStatus $repository $state.source_pr publishing $state.target + $pull = Get-BackportPull $repository $state.source_pr $state.target + if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin; return } + if ($retryConflict) { + $null = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" -Method PATCH -Body @{ + body = Get-BackportBody $state + } + $tracking = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" + Assert-BackportObject $tracking $state $BotLogin + } + $tracking = Set-BackportPublicationIntent $state $tracking $head $BotLogin $title = "[Backport $(($state.target -split '/')[1])] #$($state.source_pr): $($state.source_title -replace '[\r\n]', ' ')" if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ @@ -852,10 +906,24 @@ function Find-ExtRequestBackportBatches { } } $candidates = @(Find-ExtRequestBackport $Repository -SourcePr $SourcePr -TargetOverride $TargetOverride -ReferenceTime ([DateTimeOffset]$clock)) - # Open manifests are authoritative even when some members have aged out or are processed. + # A durable cohort survives the discovery window. Completed sources need no + # lifecycle polling; resume only missing processed flags or independent records. if (-not $SourcePr) { foreach ($record in $records) { - if ($record.issue.state -eq 'open') { + $unfinished = $false + foreach ($member in $record.manifest.members) { + if ($member.result.outcome -eq 'applied') { + $original = Get-BackportSource $Repository $member.source_pr + if ('ext-backport-processed' -notin @($original.labels | ForEach-Object { $_.name })) { $unfinished = $true } + } + else { + $independent = Get-BackportTracking $Repository $member.source_pr + if ($null -eq $independent -or ($member.result.outcome -eq 'already-present' -and $independent.state -ne 'closed')) { + $unfinished = $true + } + } + } + if ($unfinished) { $row = @{} + $record.manifest.request $row.manifest = $record.manifest $requests[$row.key] = $row @@ -984,6 +1052,12 @@ function New-ExtRequestBackportBatch { } return } + if ($resume -and ($null -ne (Get-BackportBatchPull $Repository $request.key) -or + $null -ne (Read-BackportPublicationIntent $overlap[0].issue.body))) { + $stored | ConvertTo-Json -Depth 60 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM + Write-BackportBatchSummary $stored 'prepared' + return + } if (-not $resume) { foreach ($item in $metadata) { $tracking = Get-BackportTracking $Repository $item.source.number @@ -1064,7 +1138,7 @@ function Get-BackportBatchBody { foreach ($member in $Manifest.members) { if ($member.result.outcome -ne 'applied') { $lines += "- Main PR #$($member.source_pr): $($member.result.outcome); tracked independently." } } - if ($TrackingNumber -gt 0) { $lines += @('', "Fixes $($Manifest.repository)#$TrackingNumber") } + if ($TrackingNumber -gt 0) { $lines += @('', "Backport tracking issue: https://github.com/$($Manifest.repository)/issues/$TrackingNumber") } $lines += @('', 'Review the cumulative change and run normal release CI. No AI resolver was used.') $body = $lines -join "`n" if ($body.Length -gt 65000) { throw 'Complete batch manifest exceeds GitHub body limits; reduce the cohort, never truncate membership.' } @@ -1106,6 +1180,7 @@ function Complete-BackportBatchPull { param([hashtable] $Manifest, [hashtable] $Tracking, [hashtable] $Pull, [string] $BotLogin) Assert-BackportBatchObject $Tracking $Manifest $BotLogin Assert-BackportBatchObject $Pull $Manifest $BotLogin + if (-not $Pull.ContainsKey('head')) { throw 'The batch result is not a validated backport PR.' } # Reconciliation verifies every source identity, including already processed members. $null = @(Assert-BackportBatchSources $Manifest.request $Manifest.repository -Resume) $labels = @(Get-BackportBatchLabels $Manifest) @@ -1119,16 +1194,11 @@ function Complete-BackportBatchPull { if (-not $Tracking.body.Contains($link)) { $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "$($Tracking.body)`n`n$link" } } - $status = if ($null -ne $Pull.merged_at) { 'merged' } elseif ($Pull.state -eq 'closed') { 'needs-attention' } else { 'created' } foreach ($member in $Manifest.members) { if ($member.result.outcome -ne 'applied') { continue } - Add-BackportStatus $Manifest.repository $member.source_pr $status $Manifest.request.target - if ($status -ne 'needs-attention') { Remove-BackportIntents $Manifest.repository $member.source_pr $Manifest.request.target } - } - if ($status -eq 'merged' -and $Tracking.state -ne 'closed') { - $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$($Tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } + Set-BackportProcessed $Manifest.repository $member.source_pr } - Write-BackportBatchSummary $Manifest $status "$($Tracking.html_url) $($Pull.html_url)" + Write-BackportBatchSummary $Manifest 'validated PR' "$($Tracking.html_url) $($Pull.html_url)" } function Publish-BackportBatchExceptions { @@ -1187,43 +1257,63 @@ function Publish-ExtRequestBackportBatch { if ($null -ne $tracking) { Assert-BackportBatchObject $tracking $manifest $BotLogin } $metadata = @(Assert-BackportBatchSources $request $repository -Resume:($null -ne $tracking)) $directory = Join-Path (Split-Path -Parent $StatePath) 'git' - Assert-BackportPreparedBatch $manifest $directory $applied = @($manifest.members | Where-Object { $_.result.outcome -eq 'applied' }) $exceptionFailures = @() $pull = Get-BackportBatchPull $repository $request.key if ($null -ne $pull -and $null -eq $tracking) { throw 'Batch PR has no tracking issue.' } - if ($null -eq $tracking) { - foreach ($item in $metadata) { - if ($null -ne (Get-BackportTracking $repository $item.source.number) -or - @($item.source.labels | Where-Object { $_.name -like 'ext-backport-*' }).Count -gt 0) { - throw 'Source has prior backport state; do not create a duplicate batch.' - } - } + if ($null -ne $pull) { + Assert-BackportBatchObject $pull $manifest $BotLogin + $exceptionFailures = @(Publish-BackportBatchExceptions $manifest $BotLogin -Durable -ContinueOnFailure) + Complete-BackportBatchPull $manifest $tracking $pull $BotLogin + if ($exceptionFailures.Count -gt 0) { throw ($exceptionFailures -join "`n") } + return + } + if ($null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body)) { + throw 'Previous batch PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' } + if (@($metadata | ForEach-Object { $_.source.labels } | Where-Object { + $_.name -match '^ext-backport-publishing:' + }).Count -gt 0) { throw 'Legacy batch PR publication is unconfirmed; operator reconciliation is required before retrying.' } + Assert-BackportPreparedBatch $manifest $directory if ($applied.Count -gt 0) { $labels = @(Get-BackportBatchLabels $manifest) Ensure-BackportLabels $repository $labels if ($null -eq $tracking) { - # Reserve included members before the single uncertain POST. A failure here is - # deliberately fail-closed; only a persisted complete manifest permits recovery. - foreach ($member in $applied) { Add-BackportStatus $repository $member.source_pr pending $request.target } - $tracking = Invoke-BackportApi "repos/$repository/issues" -Method POST -Body @{ - title = "[Backport $(($request.target -split '/')[1])] $($request.team) - $($request.key)" - body = Get-BackportBatchBody $manifest; type = 'Task'; labels = @($labels | ForEach-Object { $_.name }) + $observed = @(Get-BackportBatchRecords $repository | Where-Object { + @($_.manifest.request.source_prs | Where-Object { $_ -in $request.source_prs }).Count -gt 0 + }) + if ($observed.Count -gt 1) { throw 'Multiple durable batch mappings.' } + if ($observed.Count -eq 1) { + $tracking = $observed[0].issue + Assert-BackportBatchObject $tracking $manifest $BotLogin + } + else { + foreach ($item in @(Assert-BackportBatchSources $request $repository)) { + if ($null -ne (Get-BackportTracking $repository $item.source.number) -or + @($item.source.labels | Where-Object { $_.name -like 'ext-backport-*' }).Count -gt 0) { + throw 'Source has prior backport state; do not create a duplicate batch.' + } + } + $pull = Get-BackportBatchPull $repository $request.key + if ($null -ne $pull) { throw 'Batch PR has no tracking issue.' } + # One creation attempt only. Without a pre-Task journal, an absent Task + # after a failed POST is uncertain and requires operator investigation. + $tracking = New-BackportTrackingTask $repository @{ + title = "[Backport $(($request.target -split '/')[1])] $($request.team) - $($request.key)" + body = Get-BackportBatchBody $manifest; type = 'Task'; labels = @($labels | ForEach-Object { $_.name }) + } + Assert-BackportBatchObject $tracking $manifest $BotLogin } - $tracking = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" - Assert-BackportBatchObject $tracking $manifest $BotLogin } $exceptionFailures = @(Publish-BackportBatchExceptions $manifest $BotLogin -Durable -ContinueOnFailure) + $pull = Get-BackportBatchPull $repository $request.key if ($null -ne $pull) { Complete-BackportBatchPull $manifest $tracking $pull $BotLogin } elseif ($tracking.state -eq 'closed') { throw 'Closed batch issue has no PR; manual reconciliation required.' } else { - foreach ($item in $metadata) { - if ((Get-BackportStatusLabel publishing $request.target) -in @($item.source.labels | ForEach-Object { $_.name })) { - throw 'Previous batch PR creation is unconfirmed; do not repeat POST.' - } + if ($null -ne (Read-BackportPublicationIntent $tracking.body)) { + throw 'Previous batch PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' } $branch = "bc-extrequest-backport/$($request.key)" $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() @@ -1235,7 +1325,7 @@ function Publish-ExtRequestBackportBatch { $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() if (($remote -split '\s+')[0] -cne $manifest.head) { throw 'Batch branch publication not confirmed.' } } - foreach ($member in $applied) { Add-BackportStatus $repository $member.source_pr publishing $request.target } + $tracking = Set-BackportPublicationIntent $manifest $tracking $manifest.head $BotLogin $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ title = "[Backport $(($request.target -split '/')[1])] $($request.team) - $($request.key)" body = Get-BackportBatchBody $manifest $tracking.number; head = $branch; base = $request.target; draft = $true @@ -1250,48 +1340,5 @@ function Publish-ExtRequestBackportBatch { if ($exceptionFailures.Count -gt 0) { throw ($exceptionFailures -join "`n") } } -function Sync-ExtRequestBackport { - [CmdletBinding()] - param([string] $Repository, [string] $BotLogin) - - Assert-BackportRepository $Repository - $failures = @() - # GitHub may auto-close tracking issues when their PR merges, before this job runs. - foreach ($tracking in @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting")) { - try { - if ($tracking.ContainsKey('pull_request')) { continue } - $batch = Read-BackportBatchMarker $tracking.body - if ($null -ne $batch) { - Assert-BackportBatchObject $tracking $batch $BotLogin - $failures += @(Publish-BackportBatchExceptions $batch $BotLogin -Durable -ContinueOnFailure) - $pull = Get-BackportBatchPull $Repository $batch.request.key - if ($null -ne $pull) { Complete-BackportBatchPull $batch $tracking $pull $BotLogin } - continue - } - $marker = Read-BackportMarker $tracking.body - if ($null -eq $marker) { continue } - $state = @{ repository = $Repository; source_pr = $marker.source_pr; source_sha = $marker.sha; target = $marker.target } - Assert-BackportObject $tracking $state $BotLogin - $pull = Get-BackportPull $Repository $marker.source_pr $marker.target - if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin } - elseif ($tracking.body.Contains('Preparation result: **already-present**.')) { - if ($tracking.state -ne 'closed') { - $null = Invoke-BackportApi "repos/$Repository/issues/$($tracking.number)" -Method PATCH -Body @{ - state = 'closed'; state_reason = 'completed' - } - } - Add-BackportStatus $Repository $marker.source_pr already-present $marker.target - Remove-BackportIntents $Repository $marker.source_pr $marker.target - } - } - catch { - $failure = "Tracking issue #$($tracking.number): $($_.Exception.Message)" - Write-Warning $failure - $failures += $failure - } - } - if ($failures.Count -gt 0) { throw ($failures -join "`n") } -} - -Export-ModuleMember -Function Find-ExtRequestBackport, New-ExtRequestBackport, Publish-ExtRequestBackport, Sync-ExtRequestBackport, +Export-ModuleMember -Function Find-ExtRequestBackport, New-ExtRequestBackport, Publish-ExtRequestBackport, Find-ExtRequestBackportBatches, New-ExtRequestBackportBatch, Publish-ExtRequestBackportBatch diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index cf14d661570..d987ed175ef 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -60,7 +60,7 @@ Use the existing `extensibility-agent` environment and GitHub App: | Setting | Purpose | | --- | --- | -| Repository variable `EXT_REQ_BACKPORT_ENABLED=true` | Explicitly enables Sunday publication and daily status reconciliation. Leave unset or false until deployment is approved. | +| Repository variable `EXT_REQ_BACKPORT_ENABLED=true` | Explicitly enables Sunday publication. Leave unset or false until deployment is approved. | | Optional repository variable `EXT_REQ_BACKPORT_TARGET_BRANCH` | Overrides automatic selection, for example `releases/29.x`. Must name an existing `releases/.x` branch. | | Environment variable `EXTENSIBILITY_CLIENT_ID` | Existing GitHub App client ID. | | Environment secret `EXTENSIBILITY_TRIAGE_APP_PRIVATE_KEY` | Existing App private key. | @@ -73,7 +73,7 @@ No Copilot token, Azure login or model access is needed. Without an override, branches are paginated and the highest numerical `releases/.x` is selected. Patch releases and preview names do not -participate. Once an issue or source status exists, that request retains its +participate. Once a tracking issue exists, that request retains its original target even when a newer release branch appears. ## Manual operation @@ -81,21 +81,12 @@ original target even when a newer release branch appears. Run from `main` after the workflow has merged: ```powershell -# Preview all eligible team batches, using the same rules as Sunday. -gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` - -f dry_run=true - # Publish all eligible team batches and independent exceptions. -gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` - -f dry_run=false +gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main -# Preview one merged main PR independently. +# Publish one merged main PR, or recover its existing backport. gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` - -f source_pr=12345 -f dry_run=true - -# Publish or reconcile only that selected source's existing request. -gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main ` - -f source_pr=12345 -f dry_run=false + -f source_pr=12345 ``` Manual runs accept an **optional PR number, not an issue number**. Leave it @@ -106,9 +97,9 @@ original target for an existing request, and duplicate prevention. A selected source already included in a team batch refers back to that batch; it must not create a second individual backport. -Both manual modes default to preview and require no App publication token until -`dry_run=false`. Publication still requires the existing protected environment -and App permissions even when scheduled creation is disabled. +**Every manual run publishes.** There is no preview input or dry-run workflow +mode. Publication requires the existing protected environment and App permissions +even when scheduled creation is disabled. Automatic failures older than 15 days need a manual single-PR run. Discovery uses paginated REST data rather than the eventually indexed search API. More than @@ -116,32 +107,6 @@ paginated REST data rather than the eventually indexed search API. More than The job summary includes team, source PRs, original issues, target and individual outcomes. A PR is never split simply to reduce batch size. -## Local read-only preview - -From a BCApps checkout containing this branch, use PowerShell 7.4+, Git and an -authenticated `gh` CLI. No merge to `main` is needed for local preparation. - -```powershell -Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force - -# Omit -SourcePr to discover all eligible batches; supply it for one source. -$requests = @(Find-ExtRequestBackportBatches -Repository microsoft/BCApps ` - -SourcePr '11608' -BatchId ([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString())) - -foreach ($request in $requests) { - $folder = Join-Path ([IO.Path]::GetTempPath()) ('backport-' + [guid]::NewGuid().ToString('N')) - New-ExtRequestBackportBatch -Repository microsoft/BCApps ` - -RequestJson ($request | ConvertTo-Json -Depth 15 -Compress) ` - -StatePath (Join-Path $folder 'state.json') -} -``` - -This reads GitHub and creates temporary local results only. Do not call the -publication function using a personal `gh` token: object authorship must match -the expected GitHub App. The existing `Find-ExtRequestBackport` and -`New-ExtRequestBackport` single-PR APIs remain available for earlier local -commands. - ## Publication and recovery Independent requests retain the deterministic branch @@ -154,44 +119,43 @@ closed PRs are both checked; legacy independent requests are not replayed into new team batches. Branches are never force-pushed and human edits are never overwritten. -Source PR status labels are created as needed: - -| Label | Meaning | -| --- | --- | -| `ext-backport-processed` | Added to the original main PR once a backport PR exists, a conflict is tracked, or the patch is proven already present. Excludes that main PR from future automatic discovery; does not imply the backport merged. | -| `ext-backport-created:29.x` | The corresponding backport PR exists. | -| `ext-backport-needs-attention:29.x` | The source conflicts or its backport PR was closed without merging. | -| `ext-backport-merged:29.x` | The backport PR merged. | -| `ext-backport-already-present:29.x` | The entire patch is already present; the issue closes without an empty PR. | -| `ext-backport-pending:29.x` | Durable intent to create the tracking issue. | -| `ext-backport-publishing:29.x` | Durable intent to create the backport PR. | - -The version suffix follows the pinned release. `ext-backport-processed` is the -automatic source filter; `ext-backporting` identifies the backport issue and PR. -Versioned source labels give the detailed status. Labels -are not the only duplicate check. A retry repairs missing issue/PR labels -and reciprocal links rather than creating another pair. - -A **daily 21:25 Europe/Copenhagen** reconciliation updates existing backports -without creating new backport batches. It also runs after Sunday creation. -When a team PR merges, it closes that batch's issue and updates every included -main PR, even outside the 15-day discovery window. Conflicted/excluded sources -are not marked merged with the clean batch. Independent requests are reconciled -as before. Release-targeted PRs do not automatically close GitHub issues, so -reconciliation explicitly closes the tracking Task. - -API writes are not automatically retried. If issue or PR creation has an -uncertain outcome, its intent label remains. A later run first discovers the -actual objects. If an intent exists but its object cannot be found, publication -stops for operator reconciliation. Before removing an intent label, inspect -all open/closed tracking objects and the deterministic branch and establish -whether creation occurred. Never clear labels simply to bypass duplicate checks. +The only label this workflow adds to an original main PR is +**`ext-backport-processed`**, after it confirms that source's backport PR exists. +In a team batch, only sources actually included in that PR receive the label. +It prevents future automatic selection; it does not mean the backport merged. +No temporary, versioned, or merge-status labels are added to original PRs, +and original issues are never relabeled. + +There is no daily status job or merge-lifecycle automation. Merging or closing +a backport PR does not trigger further source-label updates or closure of its +tracking Task by this workflow. Tracking issues and backport PRs still inherit +the original labels and receive `ext-backporting`. + +Existing tracking identities and open/closed PR lookup prevent normal duplicate +creation. A repeated publishing run repairs missing links or the single processed +label without adding lifecycle statuses. Conflict and already-present sources +receive no processed label when no backport PR exists; their tracking records +prevent duplicate Tasks and automatic conflict retries. + +API writes are not automatically retried within a run. Before creating a PR, +the workflow records a hidden publication-intent marker on its **backport +tracking issue**, not on the original PR or issues. If that marker exists but +no matching open/closed PR can be found, publication stops for operator review. +Do not clear it until the actual outcome has been established. + +If Task creation fails with an uncertain response, inspect the run and existing +tracking issues before rerunning. Without temporary source labels there is no +separate durable issue-creation reservation: object discovery recovers observed +Tasks, but cannot prove the outcome of an unobserved create. Do not blindly rerun +an ambiguous create. Conflicts create a linked independent tracking issue but no partial branch or -PR, and are not retried automatically. Rerun that main PR manually if the target -has changed. A separate human-created conflict-resolution PR is not adopted -automatically; complete its tracking/status manually. Closed-unmerged PRs and -closed tracking issues are not recreated automatically. +PR, and are not retried automatically. An open standalone conflict can be +retried by manually selecting its main PR if the target has changed; the +existing Task is reused. Outcomes recorded in an immutable team cohort remain +frozen and require manual resolution. A separate human-created resolution PR +is not adopted automatically; complete its tracking manually. Closed-unmerged +PRs and closed tracking issues are not recreated automatically. ## Content boundaries diff --git a/.github/workflows/bc-extrequest-backport.yml b/.github/workflows/bc-extrequest-backport.yml index e7ed2660b83..10b2d53f9ad 100644 --- a/.github/workflows/bc-extrequest-backport.yml +++ b/.github/workflows/bc-extrequest-backport.yml @@ -3,17 +3,13 @@ name: 'Extensibility Request Backport' run-name: >- ${{ github.event_name == 'workflow_dispatch' && (inputs.source_pr != '' && - format('Backport main PR #{0} (preview = {1})', inputs.source_pr, inputs.dry_run) || - format('Backport team batches (preview = {0})', inputs.dry_run)) || - (github.event_name == 'schedule' && github.event.schedule == '25 21 * * *' && - 'Reconcile extensibility backports' || 'Backport weekly extensibility team batches') }} + format('Backport main PR #{0}', inputs.source_pr) || 'Backport team batches') || + 'Backport weekly extensibility team batches' }} on: schedule: - cron: '10 21 * * 0' timezone: 'Europe/Copenhagen' - - cron: '25 21 * * *' - timezone: 'Europe/Copenhagen' workflow_dispatch: inputs: source_pr: @@ -21,11 +17,6 @@ on: required: false default: '' type: string - dry_run: - description: Preview without creating or changing branches, issues, PRs or labels - required: true - default: true - type: boolean pull_request: paths: - '.github/workflows/bc-extrequest-backport.yml' @@ -44,7 +35,6 @@ concurrency: jobs: tests: - if: github.event_name != 'schedule' || github.event.schedule == '10 21 * * 0' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -137,7 +127,6 @@ jobs: New-ExtRequestBackportBatch -Repository $env:GITHUB_REPOSITORY ` -RequestJson $env:REQUEST_JSON -StatePath $statePath - name: Mint fresh GitHub App publication token - if: github.event_name != 'workflow_dispatch' || !inputs.dry_run id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -148,8 +137,7 @@ jobs: permission-contents: write permission-issues: write permission-pull-requests: write - - name: Publish or reconcile backport - if: github.event_name != 'workflow_dispatch' || !inputs.dry_run + - name: Publish backport env: GH_TOKEN: ${{ steps.app-token.outputs.token }} BACKPORT_BOT_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot] @@ -157,47 +145,3 @@ jobs: Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force Publish-ExtRequestBackportBatch -StatePath (Join-Path $env:RUNNER_TEMP 'extrequest-backport/state.json') ` -BotLogin $env:BACKPORT_BOT_LOGIN - - reconcile: - needs: [tests, discover, backport] - if: >- - always() && - github.repository == 'microsoft/BCApps' && - github.ref == 'refs/heads/main' && - github.event_name == 'schedule' && - vars.EXT_REQ_BACKPORT_ENABLED == 'true' && - needs.tests.result != 'failure' && - needs.tests.result != 'cancelled' && - (github.event.schedule == '25 21 * * *' || needs.discover.result == 'success') - runs-on: ubuntu-latest - environment: extensibility-agent - timeout-minutes: 15 - permissions: - contents: read - issues: read - pull-requests: read - concurrency: - group: extrequest-backport-reconcile-${{ github.repository }} - cancel-in-progress: false - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - sparse-checkout: .github/scripts/bc-extrequest-backport - - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - id: app-token - with: - client-id: ${{ vars.EXTENSIBILITY_CLIENT_ID }} - private-key: ${{ secrets.EXTENSIBILITY_TRIAGE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: BCApps - permission-issues: write - permission-pull-requests: read - - name: Close completed tracking issues and mark source PRs - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - BACKPORT_BOT_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot] - run: | - Import-Module ./.github/scripts/bc-extrequest-backport/Backport.psm1 -Force - Sync-ExtRequestBackport -Repository $env:GITHUB_REPOSITORY -BotLogin $env:BACKPORT_BOT_LOGIN From 98bacd2aa09e4da6281d543705dc127d53983f25 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 11:00:35 +0200 Subject: [PATCH 06/13] Avoid automatic variable assignments in backport helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.psm1 | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index a954d1f876c..0af89b49e60 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -217,17 +217,17 @@ function Get-BackportMarker { function Read-BackportMarker { param([string] $Body) - $matches = [regex]::Matches($Body, + $markerMatches = [regex]::Matches($Body, '(?m)^\r?$') - if ($matches.Count -gt 1) { throw 'Multiple backport identities on one object.' } - if ($matches.Count -eq 0) { return $null } - return @{ source_pr = [long]$matches[0].Groups[1].Value; target = $matches[0].Groups[2].Value; sha = $matches[0].Groups[3].Value } + if ($markerMatches.Count -gt 1) { throw 'Multiple backport identities on one object.' } + if ($markerMatches.Count -eq 0) { return $null } + return @{ source_pr = [long]$markerMatches[0].Groups[1].Value; target = $markerMatches[0].Groups[2].Value; sha = $markerMatches[0].Groups[3].Value } } function Get-BackportTracking { param([string] $Repository, [long] $SourcePr) - $matches = @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting" | + $trackingIssues = @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting" | Where-Object { if ($_.ContainsKey('pull_request')) { return $false } $batch = Read-BackportBatchMarker $_.body @@ -237,8 +237,8 @@ function Get-BackportTracking { $marker = Read-BackportMarker $_.body return $null -ne $marker -and $marker.source_pr -eq $SourcePr }) - if ($matches.Count -gt 1) { throw "Multiple tracking issues for source PR #$SourcePr; reconcile manually." } - if ($matches.Count -eq 1) { return $matches[0] } + if ($trackingIssues.Count -gt 1) { throw "Multiple tracking issues for source PR #$SourcePr; reconcile manually." } + if ($trackingIssues.Count -eq 1) { return $trackingIssues[0] } return $null } @@ -500,11 +500,11 @@ function New-BackportTrackingTask { function Read-BackportPublicationIntent { param([string] $Body) - $matches = [regex]::Matches($Body, '(?m)^\r?$') - if ($matches.Count -gt 1 -or ($matches.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:publication-intent:'))) { + $intentMatches = [regex]::Matches($Body, '(?m)^\r?$') + if ($intentMatches.Count -gt 1 -or ($intentMatches.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:publication-intent:'))) { throw 'Malformed or ambiguous tracking issue publication intent.' } - if ($matches.Count -eq 1) { return $matches[0].Groups[1].Value } + if ($intentMatches.Count -eq 1) { return $intentMatches[0].Groups[1].Value } return $null } From 95b6fa03c4cdf8a25ee2863dcca02ae274da525a Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 14:47:24 +0200 Subject: [PATCH 07/13] Standardize backport descriptions, Task references and team titles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 115 +++++++++++++++-- .../bc-extrequest-backport/Backport.psm1 | 117 +++++++++++------- .../scripts/bc-extrequest-backport/README.md | 12 ++ 3 files changed, 186 insertions(+), 58 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index 3e6ae528940..9a0ccb7bead 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -342,15 +342,19 @@ Describe 'Batch publication and duplicate prevention' { } Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -eq 'repos/microsoft/BCApps/pulls' -and $Method -eq 'POST' -and $Body.draft -and - $Body.body.Contains('Backport tracking issue: https://github.com/microsoft/BCApps/issues/100') -and $Body.base -eq 'releases/29.x' + $Body.body.Contains('Fixes #100') -and $Body.base -eq 'releases/29.x' -and + $Body.title -ceq $script:batchTracking.title } $body = $script:batchPull.body $body | Should -Match 'pull/42' $body | Should -Match 'pull/43' - $body | Should -Match ('a' * 40) - $body | Should -Match ('b' * 40) + $identity = Read-BackportBatchMarker $body + $identity.members[0].source_sha | Should -Be ('a' * 40) + $identity.members[1].source_sha | Should -Be ('b' * 40) [regex]::Matches($body, 'https://github.com/microsoft/BCApps/issues/10(?=\s|$)').Count | Should -Be 1 - $body | Should -Not -Match '(?i)\b(fixes|closes|resolves)\s' + $body | Should -Match '(?m)^Fixes #100$' + $body | Should -Not -Match '(?m)^Fixes #10$' + $script:batchTracking.body.TrimEnd() | Should -Match '\*Auto-generated content\*$' Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 43 } Should -Invoke Set-BackportProcessed -Times 0 -ParameterFilter { $SourcePr -eq 44 } @@ -438,6 +442,7 @@ Describe 'Batch publication and duplicate prevention' { $script:lookupCount = 0 $script:observed = @{ number = 100; body = Get-BackportBatchMarker $fixture; state = 'open'; labels = @() + title = 'Recovered backport Task title' user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' } Mock Get-BackportBatchRecords { @@ -453,7 +458,8 @@ Describe 'Batch publication and duplicate prevention' { $Body.body.Contains('bc-extrequest-backport:v2') } Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' -and + $Body.title -ceq $script:observed.title } } @@ -461,6 +467,7 @@ Describe 'Batch publication and duplicate prevention' { Mock Invoke-BackportApi { $script:batchTracking = @{ number = 100; body = $Body.body; state = 'open'; labels = @() + title = $Body.title user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' } throw 'Lost response' @@ -638,6 +645,94 @@ Describe 'Batch publication and duplicate prevention' { } } +Describe 'Backport descriptions and titles' { + InModuleScope Backport { + BeforeEach { + $script:presentationState = @{ + schema = 1; repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x' + source_sha = 'a' * 40; source_title = 'Expose an event'; result = @{ outcome = 'applied' } + issues = @(@{ url = 'https://github.com/microsoft/BCApps/issues/10' }, + @{ url = 'https://github.com/microsoft/BCApps/issues/11' }, + @{ url = 'https://github.com/microsoft/BCApps/issues/10' }) + } + $script:expectedCore = @( + 'Backporting of extensibility requests to `releases/29.x`.' + '' + 'Issues:' + '- https://github.com/microsoft/BCApps/issues/10' + '- https://github.com/microsoft/BCApps/issues/11' + '' + 'PRs:' + '- https://github.com/microsoft/BCApps/pull/42' + ) -join "`n" + } + + It 'renders the exact PR layout with a new-Task Fixes reference and Important AI disclaimer' { + $body = Get-BackportBody $presentationState 99 + $visible = [regex]::Replace($body, '(?s)\s*', '').Trim() + $expected = @($expectedCore, '', 'Fixes #99', '', '> [!IMPORTANT]', + '> AI-generated: content may be inaccurate or incomplete. Please review and verify before relying on or merging.') -join "`n" + $visible | Should -BeExactly $expected + $visible | Should -Not -Match 'Source merge:|Original main issues|Preparation result:' + $visible | Should -Not -Match ('a' * 40) + (Read-BackportMarker $body).sha | Should -Be ('a' * 40) + } + + It 'renders the same issue content without Fixes or Important and ends with the italic generation note' { + $body = Get-BackportBody $presentationState + $visible = [regex]::Replace($body, '(?s)\s*', '').Trim() + $visible | Should -BeExactly (@($expectedCore, '', '*Auto-generated content*') -join "`n") + $visible | Should -Not -Match '(?i)\bfixes\s|\[!IMPORTANT\]' + $body | Should -Match '' + } + + It 'uses the same team layout and includes only applied sources with deduplicated original issues' { + $manifest = New-BatchFixture + $body = Get-BackportBatchBody $manifest 99 + $visible = [regex]::Replace($body, '(?s)\s*', '').Trim() + $expected = @( + 'Backporting of extensibility requests for Team: Finance to `releases/29.x`.' + '' + 'Issues:' + '- https://github.com/microsoft/BCApps/issues/10' + '' + 'PRs:' + '- https://github.com/microsoft/BCApps/pull/42' + '- https://github.com/microsoft/BCApps/pull/43' + '' + 'Fixes #99' + '' + '> [!IMPORTANT]' + '> AI-generated: content may be inaccurate or incomplete. Please review and verify before relying on or merging.' + ) -join "`n" + $visible | Should -BeExactly $expected + $visible | Should -Not -Match 'pull/44|Independent outcomes|Source merge:' + $issue = Get-BackportBatchBody $manifest + $issue.TrimEnd() | Should -Match '\*Auto-generated content\*$' + $issue | Should -Not -Match '(?m)^Fixes ' + (Read-BackportBatchMarker $body).members.Count | Should -Be 3 + } + + It 'uses the Copenhagen discovery date for a stable team title' -ForEach @( + @{ reference = '2026-10-20T22:30:00Z'; expectedDate = '2026-10-21' }, + @{ reference = '2026-12-20T23:30:00Z'; expectedDate = '2026-12-21' } + ) { + $state = @{ schema = 2; request = @{ + target = 'releases/29.x'; team = 'Team: SCM'; reference_time = $reference + } } + Get-BackportTitle $state | Should -BeExactly "[Backport 29.x] [Extensibility Requests] [Team: SCM] till $expectedDate" + } + + It 'keeps individual titles bounded and single-line' { + Get-BackportTitle $presentationState | Should -BeExactly '[Backport 29.x] #42: Expose an event' + $presentationState.source_title = "Line one`n" + ('x' * 300) + $title = Get-BackportTitle $presentationState + $title.Length | Should -Be 240 + $title | Should -Not -Match '[\r\n]' + } + } +} + Describe 'Extensibility backport policy' { InModuleScope Backport { BeforeAll { @@ -756,7 +851,7 @@ Describe 'Extensibility backport policy' { { Read-BackportMarker "$marker`n$marker" } | Should -Throw } - It 'includes main PR and all original issue links without automatic issue-closing keywords' { + It 'includes original links and references only the new tracking Task with Fixes' { $state = @{ repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 issues = @(@{ url = 'https://github.com/microsoft/BCApps/issues/10' }, @@ -767,8 +862,8 @@ Describe 'Extensibility backport policy' { $body | Should -Match 'https://github.com/microsoft/BCApps/pull/42' $body | Should -Match 'https://github.com/microsoft/BCApps/issues/10' $body | Should -Match 'https://github.com/microsoft/BCApps/issues/11' - $body | Should -Match 'Backport tracking issue: https://github.com/microsoft/BCApps/issues/99' - $body | Should -Not -Match '(?i)\b(fixes|closes|resolves)\s' + $body | Should -Match '(?m)^Fixes #99$' + $body | Should -Not -Match '(?m)^Fixes #(10|11)$' } It 'rejects non-source paths, symlinks, executable files and traversal' -ForEach @( @@ -1212,8 +1307,8 @@ Describe 'Native Git backport integration' { $Body.head -eq 'bc-extrequest-backport/pr-42/29.x' -and $Body.body.Contains('https://github.com/microsoft/BCApps/pull/42') -and $Body.body.Contains('https://github.com/microsoft/BCApps/issues/10') -and - $Body.body.Contains('Backport tracking issue: https://github.com/microsoft/BCApps/issues/100') -and - -not $Body.body.Contains('Fixes ') + $Body.body.Contains('Fixes #100') -and + $Body.title -ceq $script:tracking.title } Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } (Read-BackportPublicationIntent $script:tracking.body) | Should -Be ('b' * 40) diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index 0af89b49e60..b5a05ea844f 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -519,7 +519,7 @@ function Set-BackportPublicationIntent { } $marker = "" $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ - body = "$($fresh.body)`n`n$marker" + body = "$marker`n`n$($fresh.body)" } $confirmed = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" if ($State.schema -eq 2) { Assert-BackportBatchObject $confirmed $State $BotLogin } @@ -545,33 +545,67 @@ function Assert-BackportObject { } } -function Get-BackportBody { - param([hashtable] $State, [AllowNull()][long] $TrackingNumber = 0) - - $repository = $State.repository - $lines = @( - (Get-BackportMarker $State.source_pr $State.target $State.source_sha) - '' - "Backport of https://github.com/$repository/pull/$($State.source_pr) to ``$($State.target)``." - '' - "Source merge: ``$($State.source_sha)``" - '' - '## Original main issues' +function Get-BackportDescription { + param( + [string] $Repository, [string] $Target, [object[]] $Issues, [long[]] $SourcePrs, + [string] $Team = '', [long] $TrackingNumber = 0, [string] $Outcome = '' ) - if ($State.issues.Count -eq 0) { $lines += 'No closing issue references were found on the main PR; labels are inherited from that PR.' } - foreach ($issue in $State.issues) { $lines += "- $($issue.url)" } - $lines += @('', '## Backport', '', "Preparation result: **$($State.result.outcome)**.") + + $teamText = if ($Team) { " for $Team" } else { '' } + $lines = @("Backporting of extensibility requests$teamText to ``$Target``.", '', 'Issues:') + $issueUrls = @($Issues | ForEach-Object { $_.url } | Sort-Object -Unique) + if ($issueUrls.Count -eq 0) { $lines += 'No linked issues.' } + foreach ($url in $issueUrls) { $lines += "- $url" } + $lines += @('', 'PRs:') + foreach ($number in @($SourcePrs | Select-Object -Unique)) { + $lines += "- https://github.com/$Repository/pull/$number" + } if ($TrackingNumber -gt 0) { - $lines += @('', "Backport tracking issue: https://github.com/$repository/issues/$TrackingNumber", '', - 'Review the complete change and run the normal release-branch CI before merging. No AI resolver was used.') + $lines += @('', "Fixes #$TrackingNumber", '', '> [!IMPORTANT]', + '> AI-generated: content may be inaccurate or incomplete. Please review and verify before relying on or merging.') } - elseif ($State.result.outcome -eq 'conflict') { - $lines += @('', 'The complete source PR could not be cherry-picked cleanly. No partial branch or PR was published.', - 'A developer must resolve the backport manually; automatic runs do not retry this conflict.') + else { + if ($Outcome -eq 'conflict') { + $lines += @('', 'The complete source PR could not be cherry-picked cleanly. No partial branch or PR was published.', + 'A developer must resolve the backport manually; automatic runs do not retry this conflict.') + } + elseif ($Outcome -eq 'already-present') { + $lines += @('', 'The changes are already present on the release branch. No empty backport PR was created.') + } + $lines += @('', '*Auto-generated content*') } return $lines -join "`n" } +function Get-BackportTitle { + param([hashtable] $State) + if ($State.schema -eq 2) { + $version = ($State.request.target -split '/')[1] + $team = $State.request.team -replace '[\r\n]', ' ' + $zone = [TimeZoneInfo]::FindSystemTimeZoneById('Europe/Copenhagen') + $localTime = [TimeZoneInfo]::ConvertTime([DateTimeOffset]$State.request.reference_time, $zone) + $date = $localTime.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) + $title = "[Backport $version] [Extensibility Requests] [$team] till $date" + } + else { + $title = "[Backport $(($State.target -split '/')[1])] #$($State.source_pr): $($State.source_title -replace '[\r\n]', ' ')" + } + if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } + return $title +} + +function Get-BackportBody { + param([hashtable] $State, [AllowNull()][long] $TrackingNumber = 0) + + @( + (Get-BackportMarker $State.source_pr $State.target $State.source_sha) + "" + '' + (Get-BackportDescription -Repository $State.repository -Target $State.target -Issues $State.issues ` + -SourcePrs @($State.source_pr) -TrackingNumber $TrackingNumber -Outcome $State.result.outcome) + ) -join "`n" +} + function Complete-BackportPull { param([hashtable] $State, [hashtable] $Tracking, [hashtable] $Pull, [string] $BotLogin) @@ -593,7 +627,7 @@ function Complete-BackportPull { $body = $Tracking.body $link = "Backport PR: $($Pull.html_url)" if (-not $body.Contains($link)) { - $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "$body`n`n$link" } + $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "`n`n$body" } } Set-BackportProcessed $repository $State.source_pr Write-BackportSummary "Main PR #$($State.source_pr): validated backport $($Pull.html_url); issue $($Tracking.html_url)." @@ -667,10 +701,8 @@ function Publish-ExtRequestBackport { } elseif ($null -ne $pull) { throw 'Existing backport PR has no tracking issue; reconcile manually.' } else { - $title = "[Backport $(($state.target -split '/')[1])] #$($state.source_pr): $($state.source_title -replace '[\r\n]', ' ')" - if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } $tracking = New-BackportTrackingTask $repository @{ - title = $title; body = Get-BackportBody $state; type = 'Task' + title = Get-BackportTitle -State $state; body = Get-BackportBody $state; type = 'Task' labels = @($state.labels | ForEach-Object { $_.name }) } Assert-BackportObject $tracking $state $BotLogin @@ -726,10 +758,8 @@ function Publish-ExtRequestBackport { Assert-BackportObject $tracking $state $BotLogin } $tracking = Set-BackportPublicationIntent $state $tracking $head $BotLogin - $title = "[Backport $(($state.target -split '/')[1])] #$($state.source_pr): $($state.source_title -replace '[\r\n]', ' ')" - if ($title.Length -gt 240) { $title = $title.Substring(0, 240) } $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ - title = $title; body = Get-BackportBody $state $tracking.number + title = $tracking.title; body = Get-BackportBody $state $tracking.number head = $branch; base = $state.target; draft = $true } $pull = Invoke-BackportApi "repos/$repository/pulls/$($pull.number)" @@ -1124,23 +1154,14 @@ function Get-BackportBatchLabels { function Get-BackportBatchBody { param([hashtable] $Manifest, [long] $TrackingNumber = 0) - $lines = @((Get-BackportBatchMarker $Manifest), '', - "Team: $($Manifest.request.team). Target: ``$($Manifest.request.target)``.", '', '## Included main PRs') - $issues = @{} - foreach ($member in $Manifest.members) { - if ($member.result.outcome -ne 'applied') { continue } - $lines += "- https://github.com/$($Manifest.repository)/pull/$($member.source_pr) - source ``$($member.source_sha)``" - foreach ($issue in $member.issues) { $issues[$issue.url] = $issue } - } - $lines += @('', '## Original main issues') - foreach ($url in @($issues.Keys | Sort-Object)) { $lines += "- $url" } - $lines += @('', '## Independent outcomes') - foreach ($member in $Manifest.members) { - if ($member.result.outcome -ne 'applied') { $lines += "- Main PR #$($member.source_pr): $($member.result.outcome); tracked independently." } - } - if ($TrackingNumber -gt 0) { $lines += @('', "Backport tracking issue: https://github.com/$($Manifest.repository)/issues/$TrackingNumber") } - $lines += @('', 'Review the cumulative change and run normal release CI. No AI resolver was used.') - $body = $lines -join "`n" + $included = @($Manifest.members | Where-Object { $_.result.outcome -eq 'applied' }) + $body = @( + (Get-BackportBatchMarker $Manifest) + '' + (Get-BackportDescription -Repository $Manifest.repository -Target $Manifest.request.target ` + -Issues @($included | ForEach-Object { $_.issues }) -SourcePrs @($included | ForEach-Object { $_.source_pr }) ` + -Team $Manifest.request.team -TrackingNumber $TrackingNumber) + ) -join "`n" if ($body.Length -gt 65000) { throw 'Complete batch manifest exceeds GitHub body limits; reduce the cohort, never truncate membership.' } return $body } @@ -1192,7 +1213,7 @@ function Complete-BackportBatchPull { } $link = "Backport PR: $($Pull.html_url)" if (-not $Tracking.body.Contains($link)) { - $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "$($Tracking.body)`n`n$link" } + $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "`n`n$($Tracking.body)" } } foreach ($member in $Manifest.members) { if ($member.result.outcome -ne 'applied') { continue } @@ -1299,7 +1320,7 @@ function Publish-ExtRequestBackportBatch { # One creation attempt only. Without a pre-Task journal, an absent Task # after a failed POST is uncertain and requires operator investigation. $tracking = New-BackportTrackingTask $repository @{ - title = "[Backport $(($request.target -split '/')[1])] $($request.team) - $($request.key)" + title = Get-BackportTitle -State $manifest body = Get-BackportBatchBody $manifest; type = 'Task'; labels = @($labels | ForEach-Object { $_.name }) } Assert-BackportBatchObject $tracking $manifest $BotLogin @@ -1327,7 +1348,7 @@ function Publish-ExtRequestBackportBatch { } $tracking = Set-BackportPublicationIntent $manifest $tracking $manifest.head $BotLogin $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ - title = "[Backport $(($request.target -split '/')[1])] $($request.team) - $($request.key)" + title = $tracking.title body = Get-BackportBatchBody $manifest $tracking.number; head = $branch; base = $request.target; draft = $true } $pull = Invoke-BackportApi "repos/$repository/pulls/$($pull.number)" diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index d987ed175ef..5aed51dfa38 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -27,6 +27,18 @@ issues' labels, excluding `ext-ready-to-implement`, and add `ext-backporting`. When there are no original issue references, labels come from the source PR, excluding its source-only `ext-backport-processed` marker. +The issue and PR share a title. Team batches use +`[Backport 29.x] [Extensibility Requests] [Team: SCM] till 2026-10-21`, +with the actual release, team and discovery date in Europe/Copenhagen. The date +is fixed for retries of the same batch. + +Descriptions begin with "Backporting of extensibility requests", including +the team for a batch, followed by `Issues:` and `PRs:` lists of the original +sources. Source hashes and recovery metadata remain hidden. A backport PR then +contains `Fixes #` and the Important AI-generated +disclaimer. It never uses `Fixes` for the original issues. The Task omits that +reference and disclaimer and ends with italic "Auto-generated content". + A source is processed independently when its team is missing, ambiguous, or inconsistent with a linked original issue's team. For example, a main PR labeled `Team: SCM` whose original issue has `Team: Finance` does not enter either team's From 729f7bdafb597e348ece69168dcc999f8da3fe69 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 15:04:48 +0200 Subject: [PATCH 08/13] Summarize original issues without PR lists in backport Tasks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 46 ++++++++++++++++--- .../bc-extrequest-backport/Backport.psm1 | 26 ++++++++--- .../scripts/bc-extrequest-backport/README.md | 18 ++++---- 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index 9a0ccb7bead..51931549411 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -34,6 +34,7 @@ BeforeAll { target = $request.target; manual = $false; reference_time = $request.reference_time; source_title = "Source $number" labels = $labels issues = @(@{ number = 10; url = 'https://github.com/microsoft/BCApps/issues/10' + title = 'Expose the requested event' repository = @{ nameWithOwner = 'microsoft/BCApps' }; labels = @{ nodes = $labels[0..2] } }) result = $result } @@ -651,9 +652,9 @@ Describe 'Backport descriptions and titles' { $script:presentationState = @{ schema = 1; repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x' source_sha = 'a' * 40; source_title = 'Expose an event'; result = @{ outcome = 'applied' } - issues = @(@{ url = 'https://github.com/microsoft/BCApps/issues/10' }, - @{ url = 'https://github.com/microsoft/BCApps/issues/11' }, - @{ url = 'https://github.com/microsoft/BCApps/issues/10' }) + issues = @(@{ url = 'https://github.com/microsoft/BCApps/issues/10'; title = 'Expose a new event' }, + @{ url = 'https://github.com/microsoft/BCApps/issues/11'; title = 'Make the helper public' }, + @{ url = 'https://github.com/microsoft/BCApps/issues/10'; title = 'Expose a new event' }) } $script:expectedCore = @( 'Backporting of extensibility requests to `releases/29.x`.' @@ -678,14 +679,35 @@ Describe 'Backport descriptions and titles' { (Read-BackportMarker $body).sha | Should -Be ('a' * 40) } - It 'renders the same issue content without Fixes or Important and ends with the italic generation note' { + It 'lists original issues with summaries but no PR list, Fixes or Important in a tracking Task' { $body = Get-BackportBody $presentationState $visible = [regex]::Replace($body, '(?s)\s*', '').Trim() - $visible | Should -BeExactly (@($expectedCore, '', '*Auto-generated content*') -join "`n") - $visible | Should -Not -Match '(?i)\bfixes\s|\[!IMPORTANT\]' + $expected = @( + 'Backporting of extensibility requests to `releases/29.x`.' + '' + 'Issues:' + '- https://github.com/microsoft/BCApps/issues/10 - Expose a new event.' + '- https://github.com/microsoft/BCApps/issues/11 - Make the helper public.' + '' + '*Auto-generated content*' + ) -join "`n" + $visible | Should -BeExactly $expected + $visible | Should -Not -Match '(?i)\bfixes\s|\[!IMPORTANT\]|PRs:|/pull/' $body | Should -Match '' } + It 'uses original issue titles as concise summaries without breaking AL names' -ForEach @( + @{ title = " Make RunModalWith public`r`n on page 20441 "; summary = 'Make RunModalWith public on page 20441.' }, + @{ title = 'Expose the Qlty. Edit Large Text helper!'; summary = 'Expose the Qlty. Edit Large Text helper!' } + ) { + Get-BackportIssueSummary @{ title = $title; url = 'issue-url' } | Should -BeExactly $summary + } + + It 'fails explicitly if an original issue has no title for its summary' { + { Get-BackportIssueSummary @{ url = 'issue-url' } } | Should -Throw '*no title to summarize*' + { Get-BackportIssueSummary @{ url = 'issue-url'; title = ' ' } } | Should -Throw '*no title to summarize*' + } + It 'uses the same team layout and includes only applied sources with deduplicated original issues' { $manifest = New-BatchFixture $body = Get-BackportBatchBody $manifest 99 @@ -708,6 +730,17 @@ Describe 'Backport descriptions and titles' { $visible | Should -BeExactly $expected $visible | Should -Not -Match 'pull/44|Independent outcomes|Source merge:' $issue = Get-BackportBatchBody $manifest + $visibleIssue = [regex]::Replace($issue, '(?s)\s*', '').Trim() + $expectedIssue = @( + 'Backporting of extensibility requests for Team: Finance to `releases/29.x`.' + '' + 'Issues:' + '- https://github.com/microsoft/BCApps/issues/10 - Expose the requested event.' + '' + '*Auto-generated content*' + ) -join "`n" + $visibleIssue | Should -BeExactly $expectedIssue + $visibleIssue | Should -Not -Match 'PRs:|/pull/' $issue.TrimEnd() | Should -Match '\*Auto-generated content\*$' $issue | Should -Not -Match '(?m)^Fixes ' (Read-BackportBatchMarker $body).members.Count | Should -Be 3 @@ -1236,6 +1269,7 @@ Describe 'Native Git backport integration' { $script:pull = $null $script:issues = @(@{ number = 10; url = 'https://github.com/microsoft/BCApps/issues/10' + title = 'Allow extensions to use the requested helper' labels = @{ nodes = @( @{ name = 'enum-request'; color = 'ffffff'; description = '' } @{ name = 'Team: SCM'; color = 'ffffff'; description = '' } diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index b5a05ea844f..c0bc7c35650 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -545,6 +545,16 @@ function Assert-BackportObject { } } +function Get-BackportIssueSummary { + param([hashtable] $Issue) + if (-not $Issue.ContainsKey('title') -or [string]::IsNullOrWhiteSpace($Issue.title)) { + throw "Original issue $($Issue.url) has no title to summarize." + } + $summary = [regex]::Replace($Issue.title, '\s+', ' ').Trim() + if ($summary -notmatch '[.!?]$') { $summary += '.' } + return $summary +} + function Get-BackportDescription { param( [string] $Repository, [string] $Target, [object[]] $Issues, [long[]] $SourcePrs, @@ -553,14 +563,18 @@ function Get-BackportDescription { $teamText = if ($Team) { " for $Team" } else { '' } $lines = @("Backporting of extensibility requests$teamText to ``$Target``.", '', 'Issues:') - $issueUrls = @($Issues | ForEach-Object { $_.url } | Sort-Object -Unique) - if ($issueUrls.Count -eq 0) { $lines += 'No linked issues.' } - foreach ($url in $issueUrls) { $lines += "- $url" } - $lines += @('', 'PRs:') - foreach ($number in @($SourcePrs | Select-Object -Unique)) { - $lines += "- https://github.com/$Repository/pull/$number" + $originalIssues = @($Issues | Sort-Object url -Unique) + if ($originalIssues.Count -eq 0) { $lines += 'No linked issues.' } + foreach ($issue in $originalIssues) { + $entry = "- $($issue.url)" + if ($TrackingNumber -eq 0) { $entry += " - $(Get-BackportIssueSummary -Issue $issue)" } + $lines += $entry } if ($TrackingNumber -gt 0) { + $lines += @('', 'PRs:') + foreach ($number in @($SourcePrs | Select-Object -Unique)) { + $lines += "- https://github.com/$Repository/pull/$number" + } $lines += @('', "Fixes #$TrackingNumber", '', '> [!IMPORTANT]', '> AI-generated: content may be inaccurate or incomplete. Please review and verify before relying on or merging.') } diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index 5aed51dfa38..135c20d8eda 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -20,9 +20,9 @@ considered, including every original issue already combined in a main-branch team PR. A nonempty successful team batch creates **one Task tracking issue and one -draft release PR**, not a pair per source. Both descriptions link every included -main PR and every original issue in its `closingIssuesReferences`, without -duplicates. Both new objects inherit the deduplicated union of the original +draft release PR**, not a pair per source. The Task lists original issues with +short summaries; the PR links every included main PR and its original issues +from `closingIssuesReferences`, without duplicates. Both new objects inherit the deduplicated union of the original issues' labels, excluding `ext-ready-to-implement`, and add `ext-backporting`. When there are no original issue references, labels come from the source PR, excluding its source-only `ext-backport-processed` marker. @@ -33,11 +33,13 @@ with the actual release, team and discovery date in Europe/Copenhagen. The date is fixed for retries of the same batch. Descriptions begin with "Backporting of extensibility requests", including -the team for a batch, followed by `Issues:` and `PRs:` lists of the original -sources. Source hashes and recovery metadata remain hidden. A backport PR then -contains `Fixes #` and the Important AI-generated -disclaimer. It never uses `Fixes` for the original issues. The Task omits that -reference and disclaimer and ends with italic "Auto-generated content". +the team for a batch. Tracking Tasks list only the original issues under +`Issues:`, each with a concise summary derived from its original title. They +do not display a PR list and end with italic "Auto-generated content". +Backport PRs retain the `Issues:` and `PRs:` link lists, followed by +`Fixes #` and the Important AI-generated disclaimer. +They never use `Fixes` for the original issues. Source hashes and recovery +metadata remain hidden. Summaries do not require AI or model credentials. A source is processed independently when its team is missing, ambiguous, or inconsistent with a linked original issue's team. For example, a main PR labeled From 7fb9b7df967deb24a213b4dd5c5017107e216860 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 17:26:58 +0200 Subject: [PATCH 09/13] Share extensibility backport tracking by release milestone Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 2120 ++++++++++------- .../bc-extrequest-backport/Backport.psm1 | 1187 ++++++--- .../scripts/bc-extrequest-backport/README.md | 155 +- .github/workflows/bc-extrequest-backport.yml | 14 +- .../workflows/ownership-classification.yml | 11 +- .../tests/OwnershipClassification.Test.ps1 | 104 + 6 files changed, 2376 insertions(+), 1215 deletions(-) create mode 100644 build/scripts/tests/OwnershipClassification.Test.ps1 diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index 51931549411..ae76cd4d607 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -1,5 +1,1069 @@ using module .\Backport.psm1 +Describe 'Shared milestone journal engine' { + InModuleScope Backport { + BeforeAll { + function New-MilestoneFixture { + param([int] $Start = 42, [string] $Team = 'Team: Finance', + [string[]] $Outcomes = @('applied', 'conflict', 'already-present'), [string] $Mode = 'team') + $release = @{ target = 'releases/29.x'; version = '29.1'; milestone_number = 1; milestone_title = 'Version 29.1' } + $request = @{ + mode = $Mode; team = $Team; target = $release.target; release = $release + batch_id = [string]$Start; reference_time = [DateTimeOffset]::UtcNow.ToString('o') + manual = $Mode -eq 'single'; source_prs = @(); sources = @() + } + $members = @() + $tip = 'f' * 40 + for ($i = 0; $i -lt $Outcomes.Count; $i++) { + $number = $Start + $i + $sha = (Get-BackportDigest "source-$number").Substring(0, 40) + $request.source_prs += $number + $request.sources += @{ number = $number; sha = $sha; merged_at = [DateTimeOffset]::UtcNow.AddDays(-5 + $i).ToString('o') } + $labels = @( + @{ name = 'event-request'; color = 'ffffff'; description = '' } + @{ name = $Team; color = 'ffffff'; description = '' } + @{ name = 'Ownership: External'; color = 'ffffff'; description = '' } + @{ name = "feature-$Start"; color = 'ffffff'; description = '' } + @{ name = 'ext-backporting'; color = '5319E7'; description = '' } + ) + $result = @{ outcome = $Outcomes[$i]; target_sha = $tip; commit = $null; tree = $null; paths = @("src/$number.al") } + if ($Outcomes[$i] -eq 'applied') { + $result.commit = (Get-BackportDigest "commit-$number").Substring(0, 40) + $result.tree = (Get-BackportDigest "tree-$number").Substring(0, 40) + $tip = $result.commit + } + $members += @{ + schema = 1; repository = 'microsoft/BCApps'; source_pr = $number; source_sha = $sha + source_title = "Source $number"; main_team = $Team; manual = $request.manual + reference_time = $request.reference_time; target = $release.target; labels = $labels; result = $result + issues = @(@{ + number = 10; url = 'https://github.com/microsoft/BCApps/issues/10'; title = 'Expose the requested event' + repository = @{ nameWithOwner = 'microsoft/BCApps' }; labels = @{ nodes = $labels[0..3] } + }) + } + } + $request.key = if ($Mode -eq 'single') { "single-$Start" } + else { 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $request)).Substring(0, 32) } + $changed = @($members | Where-Object { $_.result.outcome -eq 'applied' }) + $cohort = @{ + schema = 2; repository = 'microsoft/BCApps'; request = $request; members = $members + target_sha = 'f' * 40; head = $tip; tree = if ($changed.Count) { $changed[-1].result.tree } else { 'e' * 40 } + } + @{ schema = 3; repository = 'microsoft/BCApps'; release = $release; cohort = $cohort } + } + function Register-MilestoneSources([hashtable] $State) { + foreach ($member in $State.cohort.members) { + $script:db.sources[[string]$member.source_pr] = @{ + number = $member.source_pr; title = $member.source_title; merge_commit_sha = $member.source_sha + merged_at = @($State.cohort.request.sources | Where-Object number -eq $member.source_pr)[0].merged_at + updated_at = [DateTimeOffset]::UtcNow.ToString('o') + labels = @($member.labels | Where-Object { $_.name -ne 'ext-backporting' }) + base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } }; issues = $member.issues + } + } + } + function Save-MilestoneFixture([hashtable] $State) { + $State | ConvertTo-Json -Depth 80 | Set-Content -LiteralPath $script:statePath + } + function Select-MilestonePage([object[]] $Items, [string] $Path) { + if ($Path -notmatch '(?:\?|&)page=([1-9][0-9]*)$') { throw 'Pagination was not specified.' } + @($Items) | Select-Object -Skip (([int]$Matches[1] - 1) * 100) -First 100 + } + function Add-MilestoneTracker([string] $Body, [string] $Status = 'open') { + $script:db.issues['100'] = @{ + number = 100; title = Get-BackportSharedTitle $script:state.release; body = $Body; state = $Status + user = @{ login = 'test-app[bot]' }; labels = @(@{ name = 'ext-backporting' }) + milestone = $script:db.milestones[0]; html_url = 'https://github.com/microsoft/BCApps/issues/100' + } + $script:db.nextIssue = 101 + } + } + BeforeEach { + $script:statePath = Join-Path $TestDrive 'milestone.json' + $script:db = @{ + issues = @{}; comments = @{}; pulls = @{}; sources = @{} + nextIssue = 100; nextComment = 1000; nextPull = 200; version = '29.1'; writes = [Collections.Generic.List[object]]::new() + milestones = @(@{ number = 1; title = 'Version 29.1'; state = 'open' }, + @{ number = 2; title = 'Version 29.2'; state = 'open' }, @{ number = 3; title = 'Version 30.1'; state = 'open' }) + } + $script:state = New-MilestoneFixture + Register-MilestoneSources $state + Save-MilestoneFixture $state + Mock Ensure-BackportLabels {} + Mock Assert-BackportPreparedBatch {} + Mock Get-BackportSource { + if (-not $script:db.sources.ContainsKey([string]$Number)) { throw "Unknown source $Number" } + $script:db.sources[[string]$Number] + } + Mock Get-BackportSourceIssues { $script:db.sources[[string]$Number].issues } + Mock Invoke-BackportGit { + if ($Arguments[0] -eq 'ls-remote') { return @{ text = "$($script:state.cohort.head)`trefs/heads/backport" } } + throw "Unexpected Git operation $($Arguments -join ' ')" + } + Mock Invoke-BackportApi { + if (-not $Method) { $Method = 'GET' } + if ($Method -ne 'GET') { $script:db.writes.Add(@{ path = $Path; method = $Method; body = $Body }) } + if ($Method -eq 'GET' -and $Path -like '*/contents/.github/AL-Go-Settings.json?*') { + $json = ConvertTo-Json @{ repoVersion = $script:db.version } + return @{ encoding = 'base64'; content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) } + } + if ($Method -eq 'GET' -and $Path -match '/milestones/(\d+)$') { + $number = [long]$Matches[1] + $found = @($script:db.milestones | Where-Object number -eq $number) + if ($found.Count -ne 1) { throw 'Missing milestone' } + return $found[0] + } + if ($Method -eq 'GET' -and $Path -match '/milestones\?') { + return Select-MilestonePage $script:db.milestones $Path + } + if ($Method -eq 'GET' -and $Path -match '/issues/comments/(\d+)$') { return $script:db.comments[[string]$Matches[1]] } + if ($Method -eq 'PATCH' -and $Path -match '/issues/comments/(\d+)$') { + $script:db.comments[[string]$Matches[1]].body = $Body.body; return + } + if ($Path -match '/issues/(\d+)/comments(?:\?|$)') { + $number = [long]$Matches[1] + if ($Method -eq 'GET') { + return Select-MilestonePage @($script:db.comments.Values | Where-Object issue_number -eq $number | Sort-Object id) $Path + } + if ($Method -eq 'POST') { + $id = $script:db.nextComment++ + $comment = @{ id = $id; issue_number = $number; body = $Body.body; user = @{ login = 'test-app[bot]' } } + $script:db.comments[[string]$id] = $comment + return $comment + } + } + if ($Method -eq 'GET' -and $Path -match '/issues\?') { + $items = @($script:db.issues.Values) + if ($Path -match 'milestone=(\d+)') { + $number = [long]$Matches[1] + $items = @($items | Where-Object { $null -ne $_.milestone -and $_.milestone.number -eq $number }) + } + return Select-MilestonePage @($items | Sort-Object number) $Path + } + if ($Method -eq 'GET' -and $Path -match '/pulls\?') { + $head = (($Path -split 'head=', 2)[1] -split '&')[0] + $head = $head.Substring($head.IndexOf(':') + 1) + return Select-MilestonePage @($script:db.pulls.Values | Where-Object { $_.head.ref -ceq $head }) $Path + } + if ($Method -eq 'GET' -and $Path -match '/pulls/(\d+)$') { return $script:db.pulls[[string]$Matches[1]] } + if ($Method -eq 'GET' -and $Path -match '/issues/(\d+)$') { return $script:db.issues[[string]$Matches[1]] } + if ($Method -eq 'POST' -and $Path -match '/issues$') { + $number = $script:db.nextIssue++ + $issue = @{ + number = $number; title = $Body.title; body = $Body.body; state = 'open' + user = @{ login = 'test-app[bot]' }; labels = @($Body.labels | ForEach-Object { @{ name = $_ } }) + milestone = @($script:db.milestones | Where-Object number -eq $Body.milestone)[0] + html_url = "https://github.com/microsoft/BCApps/issues/$number" + } + $script:db.issues[[string]$number] = $issue + return $issue + } + if ($Method -eq 'POST' -and $Path -match '/pulls$') { + $proof = Read-BackportJournalMarker $Body.body + $number = $script:db.nextPull++ + $pull = @{ + number = $number; title = $Body.title; body = $Body.body; state = 'open'; merged_at = $null; draft = $Body.draft + user = @{ login = 'test-app[bot]' }; milestone = $null; html_url = "https://github.com/microsoft/BCApps/pull/$number" + head = @{ ref = $Body.head; sha = $proof.cohort.head; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } }; labels = @() + } + $script:db.pulls[[string]$number] = $pull + return $pull + } + if ($Method -eq 'PATCH' -and $Path -match '/issues/(\d+)$') { + $number = [string]$Matches[1] + $object = if ($script:db.issues.ContainsKey($number)) { $script:db.issues[$number] } else { $script:db.pulls[$number] } + foreach ($key in $Body.Keys) { + if ($key -eq 'milestone') { $object.milestone = @($script:db.milestones | Where-Object number -eq $Body[$key])[0] } + elseif ($key -eq 'labels') { $object.labels = @($Body[$key] | ForEach-Object { @{ name = $_ } }) } + else { $object[$key] = $Body[$key] } + } + return + } + if ($Method -eq 'POST' -and $Path -match '/issues/(\d+)/labels$') { + $number = [string]$Matches[1] + if ($script:db.sources.ContainsKey($number)) { + if ($Body.labels.Count -ne 1 -or $Body.labels[0] -cne 'ext-backport-processed') { throw 'Unexpected original source label payload.' } + $script:db.sources[$number].labels += @{ name = 'ext-backport-processed' } + } + elseif ($script:db.pulls.ContainsKey($number)) { + $script:db.pulls[$number].labels = @($Body.labels | ForEach-Object { @{ name = $_ } }) + } + else { throw 'Original issues must never be labeled.' } + return + } + throw "Unexpected API operation: $Method $Path" + } + } + + It 'resolves the target branch version exactly instead of the highest open milestone' { + $release = Get-BackportRelease microsoft/BCApps releases/29.x + $release.version | Should -Be '29.1' + $release.milestone_number | Should -Be 1 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { + $Path -like '*AL-Go-Settings.json?ref=releases%2F29.x' -and $Method -ne 'POST' + } + } + + It 'uses invariant numeric repoVersion conversion' { + $script:db.version = 29.1 + $old = [Globalization.CultureInfo]::CurrentCulture + try { + [Globalization.CultureInfo]::CurrentCulture = [Globalization.CultureInfo]'de-DE' + (Get-BackportRelease microsoft/BCApps releases/29.x).version | Should -Be '29.1' + } finally { [Globalization.CultureInfo]::CurrentCulture = $old } + } + + It 'fails explicitly on invalid, missing, closed or ambiguous release metadata' -ForEach @( + @{ kind = 'major' }, @{ kind = 'missing' }, @{ kind = 'closed' }, @{ kind = 'ambiguous' }, + @{ kind = 'invalid' }, @{ kind = 'encoding' } + ) { + switch ($kind) { + 'major' { $script:db.version = '30.1' } + 'missing' { $script:db.milestones = @($script:db.milestones | Where-Object number -ne 1) } + 'closed' { $script:db.milestones[0].state = 'closed' } + 'ambiguous' { $script:db.milestones += @{ number = 4; title = 'Version 29.1'; state = 'open' } } + 'invalid' { $script:db.version = '29.1; whoami' } + 'encoding' { Mock Invoke-BackportApi { @{ encoding = 'raw'; content = '{}' } } } + } + { Get-BackportRelease microsoft/BCApps releases/29.x } | Should -Throw + @($script:db.writes).Count | Should -Be 0 + } + + It 'publishes one milestone Task with an applied link, a conflict and a no-op, and no extra Tasks' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues.Count | Should -Be 1 + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + $tracker = @($db.issues.Values)[0] + $tracker.title | Should -BeExactly '[Backporting][releases/29.x][Extensibility Requests][Version 29.1]' + $tracker.milestone.number | Should -Be 1 + $tracker.labels.name | Should -Not -Contain 'Team: Finance' + $tracker.labels.name | Should -Not -Contain 'Ownership: External' + $tracker.body | Should -Match '\| Original issue \| Original PR \| Team \| Backport PR \|' + $tracker.body | Should -Match 'Conflict - manual resolution needed' + $tracker.body | Should -Match 'Already present' + [Net.WebUtility]::HtmlDecode($tracker.body) | Should -Match 'Expose the requested event\.' + $tracker.body | Should -Match 'pull/42' + $tracker.body.TrimEnd() | Should -Match '\*Auto-generated content\*$' + $visible = [regex]::Replace($tracker.body, '(?s)', '') + $visible | Should -Not -Match $state.cohort.members[0].source_sha + $pull = @($db.pulls.Values)[0] + $pull.title | Should -Be (Get-BackportTitle $state) + $pull.title | Should -Not -Be $tracker.title + $pull.milestone.number | Should -Be 1 + $pull.labels.name | Should -Contain 'Team: Finance' + $pull.body | Should -Match "Fixes #$($tracker.number)" + $pull.body | Should -Match '\[!IMPORTANT\]' + $db.sources['42'].labels.name | Should -Contain 'ext-backport-processed' + $db.sources['43'].labels.name | Should -Not -Contain 'ext-backport-processed' + $db.sources['44'].labels.name | Should -Not -Contain 'ext-backport-processed' + } + + It 'shares one tracker across teams, iterations and an explicit manual singleton' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + foreach ($entry in @(@{ start = 50; team = 'Team: SCM'; mode = 'team' }, @{ start = 60; team = 'Team: Client'; mode = 'single' })) { + $script:state = New-MilestoneFixture -Start $entry.start -Team $entry.team -Mode $entry.mode -Outcomes @('applied') + Register-MilestoneSources $state + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + } + $db.issues.Count | Should -Be 1 + $db.comments.Count | Should -Be 3 + $db.pulls.Count | Should -Be 3 + $body = @($db.issues.Values)[0].body + $body | Should -Match 'pull/42' + $body | Should -Match 'pull/50' + $body | Should -Match 'pull/60' + [Net.WebUtility]::HtmlDecode($body) | Should -Match 'Team: Finance' + [Net.WebUtility]::HtmlDecode($body) | Should -Match 'Team: SCM' + @($db.issues.Values)[0].labels.name | Should -Contain 'feature-42' + @($db.issues.Values)[0].labels.name | Should -Contain 'feature-60' + } + + It 'repairs retries without duplicate rows, comments, PRs or lifecycle changes' -ForEach @( + @{ status = 'open'; merged = $null }, @{ status = 'closed'; merged = $null }, + @{ status = 'closed'; merged = '2026-10-21T12:00:00Z' } + ) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $pull = @($db.pulls.Values)[0]; $pull.state = $status; $pull.merged_at = $merged + $db.sources['42'].labels = @(@{ name = 'event-request' }) + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + [regex]::Matches(@($db.issues.Values)[0].body, 'bc-extrequest-backport:row:').Count | Should -Be 3 + $db.sources['42'].labels.name | Should -Contain 'ext-backport-processed' + @($db.writes | Where-Object { $_.body.ContainsKey('state') -and $_.body.state -eq 'closed' }).Count | Should -Be 0 + } + + It 'reopens a matching closed tracker only while its pinned milestone stays open' -ForEach @( + @{ closedMilestone = $false }, @{ closedMilestone = $true } + ) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + @($db.issues.Values)[0].state = 'closed' + if ($closedMilestone) { + $db.milestones[0].state = 'closed' + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*closed*' + @($db.issues.Values)[0].state | Should -Be 'closed' + } else { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + @($db.issues.Values)[0].state | Should -Be 'open' + } + $db.issues.Count | Should -Be 1 + } + + It 'stops prepared-version rollover and uses a new tracker for fresh version-29.2 work' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $oldBody = @($db.issues.Values)[0].body + $script:state = New-MilestoneFixture -Start 50 -Outcomes @('applied') + Register-MilestoneSources $state + Save-MilestoneFixture $state + $db.version = '29.2' + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*rollover*' + $state.release = Get-BackportRelease microsoft/BCApps releases/29.x + $state.cohort.request.release = $state.release + [void]$state.cohort.request.Remove('key') + $state.cohort.request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $state.cohort.request)).Substring(0, 32) + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues.Count | Should -Be 2 + $db.issues['100'].body | Should -BeExactly $oldBody + $db.issues['101'].title | Should -Match '\[Version 29.2\]$' + } + + It 'does not automatically replay a registered all-conflict/no-op cohort or mark its sources processed' { + $script:state = New-MilestoneFixture -Outcomes @('conflict', 'already-present') + Register-MilestoneSources $state + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues.Count | Should -Be 1 + $db.pulls.Count | Should -Be 0 + $db.comments.Count | Should -Be 1 + Should -Invoke Assert-BackportPreparedBatch -Times 1 + Should -Invoke Invoke-BackportGit -Times 0 + @($db.writes | Where-Object { $_.path -match '/issues/(42|43)/labels$' }).Count | Should -Be 0 + } + + It 'preserves explicit Unassigned/Ambiguous teams and sources without original issues' -ForEach @( + @{ team = 'Unassigned' }, @{ team = 'Ambiguous' } + ) { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + $state.cohort.members[0].main_team = $team + $state.cohort.members[0].issues = @() + Register-MilestoneSources $state + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + @($db.issues.Values)[0].body | Should -Match "\| $team \|" + @($db.issues.Values)[0].body | Should -Match 'No linked issue' + } + + It 'fails closed on unsafe tracker, journal or PR identities' -ForEach @( + @{ kind = 'tracker-author' }, @{ kind = 'comment-author' }, @{ kind = 'duplicate' }, + @{ kind = 'pull-author' }, @{ kind = 'pull-sha' }, @{ kind = 'milestone' } + ) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + switch ($kind) { + 'tracker-author' { $db.issues['100'].user.login = 'human' } + 'comment-author' { $db.comments['1000'].user.login = 'human' } + 'duplicate' { + $copy = @{} + $db.comments['1000']; $copy.id = 1001; $db.comments['1001'] = $copy + } + 'pull-author' { $db.pulls['200'].user.login = 'human' } + 'pull-sha' { $db.pulls['200'].head.sha = '0' * 40 } + 'milestone' { $db.pulls['200'].milestone = $db.milestones[1] } + } + $count = $db.writes.Count + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw + $db.writes.Count | Should -Be $count + } + + It 'never retries uncertain comment creation within a run' { + Mock Invoke-BackportApi { throw 'Lost comment response' } -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/comments' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*journal creation is unconfirmed*' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/comments' } + $db.pulls.Count | Should -Be 0 + } + + It 'does not repeat an uncertain Task POST, and reuses an observed Task after a lost response' { + Mock Invoke-BackportApi { + $script:db.issues['100'] = @{ + number = 100; title = $Body.title; body = $Body.body; state = 'open' + milestone = $script:db.milestones[0]; labels = @(@{ name = 'ext-backporting' }) + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' + } + throw 'Lost Task response' + } -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/issues' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Task creation is unconfirmed*' + $db.pulls.Count | Should -Be 0 + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/issues' } + } + + It 'uses a journal observed at the final membership check instead of repeating comment POST' { + $script:membershipChecks = 0 + Mock Assert-BackportNewCohort { + $script:membershipChecks++ + if ($script:membershipChecks -eq 2) { + $comment = @{ + id = 1000; issue_number = 100; body = Get-BackportJournalMarker $script:state + user = @{ login = 'test-app[bot]' } + } + $script:db.comments['1000'] = $comment + return @{ issue = $script:db.issues['100']; journal = $comment } + } + } + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/comments' } + } + + It 'never overwrites an existing human-modified branch or marks its source processed' { + Mock Invoke-BackportGit { @{ text = "$('0' * 40)`trefs/heads/backport" } } -ParameterFilter { $Arguments[0] -eq 'ls-remote' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*never overwrite*' + $db.pulls.Count | Should -Be 0 + $db.sources['42'].labels.name | Should -Not -Contain 'ext-backport-processed' + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'rejects an orphaned closed standalone backport PR rather than creating a second PR or Task' { + Mock Get-BackportPull { @{ number = 91; state = 'closed' } } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*standalone backport PR*' + $db.issues.Count | Should -Be 0 + $db.writes.Count | Should -Be 0 + } + + It 'pushes only the exact prepared branch tip, without force, before recording PR intent' { + $script:pushed = $false + Mock Invoke-BackportGit { + if ($Arguments[0] -eq 'push') { + $Arguments -join ' ' | Should -BeExactly "push origin $($script:state.cohort.head):refs/heads/bc-extrequest-backport/$($script:state.cohort.request.key)" + $script:db.comments['1000'].body | Should -Not -Match 'publication-intent:' + $script:pushed = $true + return @{ text = '' } + } + if ($Arguments[0] -eq 'ls-remote') { + if ($script:pushed) { return @{ text = "$($script:state.cohort.head)`trefs/heads/backport" } } + return @{ text = '' } + } + throw 'Unexpected Git command' + } + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Should -Invoke Invoke-BackportGit -Times 1 -ParameterFilter { $Arguments[0] -eq 'push' } + Should -Invoke Invoke-BackportGit -Times 2 -ParameterFilter { $Arguments[0] -eq 'ls-remote' } + } + + It 'rejects duplicate trackers or an exact-title tracker without an App identity' -ForEach @( + @{ duplicate = $true }, @{ duplicate = $false } + ) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + if ($duplicate) { + $copy = @{} + $db.issues['100']; $copy.number = 101; $db.issues['101'] = $copy + } else { + $db.issues['100'].body = 'Human-created tracker with the exact requested title.' + $script:state = New-MilestoneFixture -Start 50 -Outcomes @('applied') + Register-MilestoneSources $state + Save-MilestoneFixture $state + } + $writes = $db.writes.Count + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw + $db.writes.Count | Should -Be $writes + } + + It 'filters shared tracker labels without changing original-issue PR labels' { + $labels = @($state.cohort.members | ForEach-Object { $_.labels }) + $filtered = @(Get-BackportTrackerLabels $state.cohort.members @( + @{ name = 'retained'; color = 'ffffff'; description = '' }, + @{ name = 'ext-ready-to-implement' }, @{ name = 'ext-backport-processed' } + )) + $filtered.name | Should -Contain 'retained' + $filtered.name | Should -Contain 'ext-backporting' + $filtered.name | Should -Not -Contain 'ext-ready-to-implement' + $filtered.name | Should -Not -Contain 'ext-backport-processed' + @($filtered | Where-Object { $_.name -match '^(Team:|Ownership:)' }).Count | Should -Be 0 + $state.cohort.members[0].labels.name | Should -Contain 'Ownership: External' + (Get-BackportBatchLabels $state.cohort).name | Should -Contain 'Team: Finance' + @($state.cohort.members | ForEach-Object { $_.labels }).Count | Should -Be $labels.Count + } + + It 'persists PR intent in the comment and never blindly retries an uncertain PR POST' { + Mock Invoke-BackportApi { throw 'Lost PR response' } -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/pulls' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost PR response*' + (Read-BackportPublicationIntent $db.comments['1000'].body) | Should -Be $state.cohort.head + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/pulls' } + $db.sources['42'].labels.name | Should -Not -Contain 'ext-backport-processed' + } + + It 'paginates exact milestones and more than one hundred distinct cohort journals' { + $db.milestones = @(1..100 | ForEach-Object { @{ number = $_ + 10; title = "Future $_"; state = 'open' } }) + + @(@{ number = 1; title = 'Version 29.1'; state = 'open' }) + (Get-BackportRelease microsoft/BCApps releases/29.x).milestone_number | Should -Be 1 + $tracker = @{ + number = 100; title = Get-BackportSharedTitle $state.release; body = Get-BackportSharedBody $state.release + user = @{ login = 'test-app[bot]' }; milestone = $db.milestones[-1]; state = 'open'; labels = @() + } + $db.issues['100'] = $tracker + foreach ($i in 1..101) { + $cohort = New-MilestoneFixture -Start (1000 + $i) -Outcomes @('conflict') + $db.comments[[string]$i] = @{ + id = $i; issue_number = 100; user = @{ login = 'test-app[bot]' } + body = Get-BackportJournalMarker $cohort + } + } + @(Get-BackportJournalRecords microsoft/BCApps).Count | Should -Be 101 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -like '*/comments?per_page=100&page=2' } + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -like '*/milestones?state=all&per_page=100&page=2' } + } + + It 'keeps multiple original-issue rows per source and escapes title delimiters' { + $state.cohort.members[0].issues += @{ + number = 11; url = 'https://github.com/microsoft/BCApps/issues/11'; title = 'AL | symbol ' + } + $state.cohort.members[0].issues += $state.cohort.members[0].issues[0] + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $body = $db.issues['100'].body + [regex]::Matches($body, 'bc-extrequest-backport:row:').Count | Should -Be 4 + [regex]::Matches($body, '\[#200\]').Count | Should -Be 2 + $body | Should -Match 'AL | symbol <tag>.' + } + + It 'renders source text literally rather than interpreting Markdown, HTML or protocol syntax' -ForEach @( + @{ text = 'left | right' }, + @{ text = "first`r`nsecond`tthird" }, + @{ text = '[click](https://example.invalid/path "title")' }, + @{ text = '![image](https://example.invalid/image.png)' }, + @{ text = 'link' }, + @{ text = '' }, + @{ text = '@someone #123 <tag> |' }, + @{ text = '**bold** _emphasis_ ~~strike~~ `code` \ [ref]: https://example.invalid' }, + @{ text = ([char]::ConvertFromUtf32(0x1f680) + ' AL.OnAfter_Event()') } + ) { + $escaped = ConvertTo-BackportTableText $text + $expected = [regex]::Replace($text, '\s+', ' ').Trim() + [Net.WebUtility]::HtmlDecode($escaped) | Should -BeExactly $expected + $withoutEntities = [regex]::Replace($escaped, '&#[0-9]+;', '') + $withoutEntities | Should -Not -Match '[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e\r\n]' + $html = (ConvertFrom-Markdown -InputObject $escaped).Html + $html | Should -Not -Match '<(?:a|img|script|strong|em|del|code)(?:\s|>)| @someone #999 |' + if ($field -eq 'team') { + $script:state = New-MilestoneFixture -Team ("Team: $payload") + } else { + $state.cohort.members[0].issues[0].title = "$payload`r`n" + } + Register-MilestoneSources $state + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $body = $db.issues['100'].body + $rows = @($body -split '\r?\n' | Where-Object { $_ -match 'bc-extrequest-backport:row:' }) + $rows.Count | Should -Be 3 + foreach ($row in $rows) { [regex]::Matches($row, '\|').Count | Should -Be 5 } + [regex]::Matches($body, '').Count | Should -Be 1 + Read-BackportJournalMarker $body | Should -BeNullOrEmpty + (Read-BackportSharedMarker $body).milestone_number | Should -Be 1 + $html = (ConvertFrom-Markdown -InputObject $body).Html + [regex]::Matches($html, '').Count | Should -Be 12 + [regex]::Matches($html, ')|href="https://example\.invalid' + $db.issues.Count | Should -Be 1 + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + } + + It 'retains historical rows while moving old trailing markers inside the fourth table cell' { + $history = "| Historical issue | [#98](https://github.com/microsoft/BCApps/pull/98) | Team: History | Already present | " + $body = (Get-BackportSharedBody $state.release).Replace('', + "$history`n") + Add-MilestoneTracker $body + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $body = $db.issues['100'].body + $body | Should -Match "Already present \|" + $body | Should -Match 'Historical issue' + $html = (ConvertFrom-Markdown -InputObject $body).Html + [regex]::Matches($html, '').Count | Should -Be 4 + [regex]::Matches($html, '').Count | Should -Be 16 + } + + It 'rejects changed pinned request release and unsafe intent or confirmed-PR envelopes' -ForEach @( + @{ kind = 'release' }, @{ kind = 'intent-sha' }, @{ kind = 'pr-sha' }, + @{ kind = 'pr-duplicate' }, @{ kind = 'complete-duplicate' }, @{ kind = 'pr-without-intent' } + ) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $comment = @($db.comments.Values)[0] + switch ($kind) { + 'release' { + $bad = ConvertTo-BackportCanonicalJson $state | ConvertFrom-Json -AsHashtable -Depth 80 + $bad.release.version = '29.2'; $bad.release.milestone_title = 'Version 29.2' + Save-MilestoneFixture $bad + } + 'intent-sha' { $comment.body = $comment.body.Replace("publication-intent:v1 sha=$($state.cohort.head)", "publication-intent:v1 sha=$('0' * 40)") } + 'pr-sha' { $comment.body = $comment.body.Replace("number=200 sha=$($state.cohort.head)", "number=200 sha=$('0' * 40)") } + 'pr-duplicate' { $comment.body += "`n" } + 'complete-duplicate' { $comment.body += "`n" } + 'pr-without-intent' { $comment.body = $comment.body -replace '(?m)^') + } + } + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*interruption*' + $record = @(Get-BackportJournalRecords microsoft/BCApps)[0] + $record.journal.body | Should -Not -Match 'journal-complete:v1' + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 43 -BatchId 999)[0] + $row.source_prs -join ',' | Should -Be '42,43,44' + $row.target | Should -Be 'releases/29.x' + [DateTimeOffset]$row.reference_time | Should -Be ([DateTimeOffset]$state.cohort.request.reference_time) + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues.Count | Should -Be 1 + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + $db.sources['42'].labels.name | Should -Contain 'ext-backport-processed' + } + + It 'does not mark a source or retry POST after a PR was created but its response was lost' { + Mock Invoke-BackportApi { + $created = @{ + number = 200; title = $Body.title; body = $Body.body; state = 'open'; merged_at = $null; draft = $true + user = @{ login = 'test-app[bot]' }; milestone = $null; html_url = 'https://github.com/microsoft/BCApps/pull/200' + head = @{ ref = $Body.head; sha = $script:state.cohort.head; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } }; labels = @() + } + $script:db.pulls['200'] = $created + throw 'Lost response' + } -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/pulls' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' + $db.sources['42'].labels.name | Should -Not -Contain 'ext-backport-processed' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.sources['42'].labels.name | Should -Contain 'ext-backport-processed' + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/pulls' } + } + + It 'reuses an observed journal after a lost comment POST response without replaying its POST' { + Mock Invoke-BackportApi { + $id = $script:db.nextComment++ + $script:db.comments[[string]$id] = @{ + id = $id; issue_number = 100; body = $Body.body; user = @{ login = 'test-app[bot]' } + } + throw 'Lost comment response' + } -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/issues/100/comments' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/comments' } + } + + It 'refuses a processed source without a journal before creating any Task' { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Save-MilestoneFixture $state + $db.sources['42'].labels += @{ name = 'ext-backport-processed' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Processed source*' + $db.issues.Count | Should -Be 0 + $db.writes.Count | Should -Be 0 + } + + It 'preserves human-managed table edits by failing rather than replacing unknown rows' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues['100'].body = $db.issues['100'].body.Replace('', + "| Human edit | Preserve | Me | Please |`n") + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Unrecognized*' + $db.issues['100'].body | Should -Match 'Human edit' + } + + It 'retries only an explicitly selected standalone conflict through a linear journal revision' { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('conflict') + Register-MilestoneSources $state + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $old = @(Get-BackportJournalRecords microsoft/BCApps)[0] + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 999)[0] + $row.retry_conflict | Should -BeTrue + $revision = New-MilestoneFixture -Mode single -Outcomes @('applied') + $revision.cohort.request = $state.cohort.request + $revision.supersedes = $old.journal.id + $script:state = $revision + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.comments.Count | Should -Be 2 + $db.pulls.Count | Should -Be 1 + @(Get-BackportJournalRecords microsoft/BCApps).Count | Should -Be 1 + [regex]::Matches($db.issues['100'].body, 'bc-extrequest-backport:row:').Count | Should -Be 1 + $db.issues['100'].body | Should -Not -Match 'Conflict - manual resolution needed' + $fork = @{} + $db.comments['1001'] + $fork.id = 1002 + $db.comments['1002'] = $fork + { Get-BackportJournalRecords microsoft/BCApps } | Should -Throw '*Duplicate/conflicting*' + } + + It 'keeps a manual team-conflict selection in its original frozen cumulative cohort without Git replay' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 43 -BatchId 999)[0] + $row.ContainsKey('retry_conflict') | Should -BeFalse + $row.source_prs.Count | Should -Be 3 + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 80) $statePath + Should -Invoke Invoke-BackportGit -Times 1 + # The sole Git call was the first publication's exact remote-branch check. + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'cherry-pick' } + $db.sources['43'].labels.name | Should -Not -Contain 'ext-backport-processed' + } + + It 'promotes legacy v2 partial publication to the shared Task and resumes old prepared state safely' { + $legacy = ConvertTo-BackportCanonicalJson $state.cohort | ConvertFrom-Json -AsHashtable -Depth 80 + foreach ($member in $legacy.members) { [void]$member.Remove('main_team') } + [void]$legacy.request.Remove('release') + [void]$legacy.request.Remove('key') + $legacy.request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $legacy.request)).Substring(0, 32) + $db.issues['90'] = @{ + number = 90; body = Get-BackportBatchBody $legacy; title = 'Legacy batch'; state = 'open' + user = @{ login = 'test-app[bot]' }; milestone = $null; labels = @(@{ name = 'ext-backporting' }) + html_url = 'https://github.com/microsoft/BCApps/issues/90' + } + $oldBody = $db.issues['90'].body + Save-MilestoneFixture $legacy + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $exception = @{} + $legacy.members[1] + $exception.batch_key = $legacy.request.key + Publish-ExtRequestBackport $statePath 'test-app[bot]' -PreparedState $exception + $db.issues.Count | Should -Be 2 + $db.issues['90'].body | Should -BeExactly $oldBody + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + @(Get-BackportBatchRecords microsoft/BCApps).Count | Should -Be 1 + $db.issues['100'].body | Should -Match 'Conflict - manual resolution needed' + } + + It 'pins legacy cohort promotion during read-only preparation without replaying recorded conflicts' { + $script:state = New-MilestoneFixture -Outcomes @('conflict') + Register-MilestoneSources $state + $legacy = ConvertTo-BackportCanonicalJson $state.cohort | ConvertFrom-Json -AsHashtable -Depth 80 + [void]$legacy.members[0].Remove('main_team') + [void]$legacy.request.Remove('release') + [void]$legacy.request.Remove('key') + $legacy.request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $legacy.request)).Substring(0, 32) + $db.issues['90'] = @{ + number = 90; title = 'Legacy cohort'; body = Get-BackportBatchBody $legacy; state = 'open'; milestone = $null + user = @{ login = 'test-app[bot]' }; labels = @(@{ name = 'ext-backporting' }) + } + $row = @{} + $legacy.request + $row.manifest = $legacy + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 80) $statePath + Should -Invoke Invoke-BackportGit -Times 0 + $db.writes.Count | Should -Be 0 + $prepared = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + $prepared.schema | Should -Be 3 + $prepared.legacy_issue | Should -Be 90 + $prepared.release.version | Should -Be '29.1' + $db.version = '29.2' + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*rollover*' + $db.writes.Count | Should -Be 0 + } + + It 'does not shift a pinned standalone legacy preparation to a new milestone during publication' { + $member = @{} + $state.cohort.members[0] + $member.release = $state.release + $db.issues['90'] = @{ + number = 90; title = 'Legacy Task'; body = Get-BackportBody $member; state = 'open'; milestone = $null + user = @{ login = 'test-app[bot]' }; labels = @(@{ name = 'ext-backporting' }) + } + Save-MilestoneFixture $member + $db.version = '29.2' + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*rollover*' + $db.writes.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + } + + It 'emits compact immutable references instead of tracker bodies, manifests or journal collections' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues['100'].body += "`nManual notes: $('n' * 12000)" + foreach ($id in 1001..1005) { + $db.comments[[string]$id] = @{ + id = $id; issue_number = 100; user = @{ login = 'human' }; body = 'unrelated discussion ' + ('d' * 12000) + } + } + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 43 -BatchId 999)[0] + $row.tracking_issue | Should -Be 100 + $row.journal_id | Should -Be 1000 + $row.state_digest | Should -Be (Get-BackportDigest (ConvertTo-BackportCanonicalJson $state)) + foreach ($field in 'manifest', 'journal_state', 'body', 'comments', 'members') { + $row.ContainsKey($field) | Should -BeFalse + } + $json = ConvertTo-Json $row -Depth 15 -Compress + $json.Length | Should -BeLessThan 4000 + $json | Should -Not -Match 'Manual notes|unrelated discussion|bc-extrequest-backport:journal' + New-ExtRequestBackportBatch microsoft/BCApps $json $statePath + $prepared = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + (Get-BackportJournalMarker $prepared) | Should -Be (Get-BackportJournalMarker $state) + } + + It 'rejects stale or missing compact registry references before Git or publication' -ForEach @( + @{ field = 'tracking_issue' }, @{ field = 'journal_id' }, @{ field = 'state_digest' }, @{ field = 'missing' } + ) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 43 -BatchId 999)[0] + if ($field -eq 'missing') { $db.comments.Clear() } + elseif ($field -eq 'state_digest') { $row.state_digest = '0' * 64 } + else { $row[$field] = 999 } + $writes = $db.writes.Count + { New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $statePath } | Should -Throw + $db.writes.Count | Should -Be $writes + Should -Invoke Invoke-BackportGit -Times 1 + } + + It 'stops an over-capacity complete table before PR, journal, label or reopen writes' -ForEach @( + @{ trackerState = 'open' }, @{ trackerState = 'closed' } + ) { + $history = "| $('h' * 64000) | Original history | Team: History | Historical outcome | " + $body = (Get-BackportSharedBody $state.release).Replace('', + "$history`n") + $body.Length | Should -BeLessThan 65000 + Add-MilestoneTracker $body $trackerState + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*table exceeds*' + $db.issues['100'].body | Should -BeExactly $body + $db.issues['100'].state | Should -Be $trackerState + $db.writes.Count | Should -Be 0 + $db.pulls.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + Should -Invoke Invoke-BackportGit -Times 0 + } + + It 'reserves complete PR links even when the shorter pending rows would fit' { + $body = Get-BackportSharedBody $state.release + $pending = Merge-BackportTrackerRows $body $state $null + $body += "`n" + ('n' * (65000 - $pending.Length - 1)) + (Merge-BackportTrackerRows $body $state $null).Length | Should -Be 65000 + Add-MilestoneTracker $body + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*table exceeds*' + $db.pulls.Count | Should -Be 0 + $db.writes.Count | Should -Be 0 + } + + It 'rechecks table capacity after branch preparation and retries safely without a premature intent' { + $script:grew = $false + $script:originalBody = '' + Mock Invoke-BackportGit { + if ($Arguments[0] -ne 'ls-remote') { throw 'Unexpected Git operation' } + if (-not $script:grew) { + $script:originalBody = $script:db.issues['100'].body + $script:db.issues['100'].body += 'n' * (64800 - $script:originalBody.Length) + $script:grew = $true + } + @{ text = "$($script:state.cohort.head)`trefs/heads/backport" } + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*table exceeds*' + $db.comments['1000'].body | Should -Not -Match 'publication-intent:' + $db.pulls.Count | Should -Be 0 + $db.sources['42'].labels.name | Should -Not -Contain 'ext-backport-processed' + $db.issues['100'].body = $script:originalBody + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues.Count | Should -Be 1 + $db.comments.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/pulls' } + } + + It 'preserves manual notes outside the managed table during publication and retry' { + $prefix = "Manual introduction - retain exactly.`n`n" + $suffix = "`n`nManual footer - retain exactly." + Add-MilestoneTracker ($prefix + (Get-BackportSharedBody $state.release) + $suffix) + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.issues['100'].body.StartsWith($prefix, [StringComparison]::Ordinal) | Should -BeTrue + $db.issues['100'].body.EndsWith($suffix, [StringComparison]::Ordinal) | Should -BeTrue + [regex]::Matches($db.issues['100'].body, 'bc-extrequest-backport:row:').Count | Should -Be 3 + } + + It 'fails stale discovery before initializing a Git preparation' { + $db.version = '29.2' + $path = Join-Path $TestDrive 'stale-discovery.json' + { New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $state.cohort.request -Depth 60) $path } | + Should -Throw '*rollover*' + Should -Invoke Invoke-BackportGit -Times 0 + $db.writes.Count | Should -Be 0 + Test-Path $path | Should -BeFalse + } + + It 'rejects a standalone legacy helper that silently changes the discovered release' { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + $db.issues['90'] = @{ + number = 90; title = 'Legacy Task'; body = Get-BackportBody $state.cohort.members[0] + state = 'open'; milestone = $null; user = @{ login = 'test-app[bot]' } + labels = @(@{ name = 'ext-backporting' }) + } + Mock New-ExtRequestBackport { + @{ + schema = 1; source_sha = $script:state.cohort.members[0].source_sha + release = @{ target = 'releases/29.x'; version = '29.2'; milestone_number = 2; milestone_title = 'Version 29.2' } + } | ConvertTo-Json -Depth 10 | Set-Content $StatePath + } + $path = Join-Path $TestDrive 'legacy-release-mismatch.json' + { New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $state.cohort.request -Depth 60) $path } | + Should -Throw '*changed the discovered release*' + $db.writes.Count | Should -Be 0 + } + + It 'fails a rollover during Git preparation rather than saving a silently retargeted state' { + Mock Initialize-BackportBatchGit { $script:db.version = '29.2' } + Mock Invoke-BackportApi { @{ object = @{ sha = $script:state.cohort.target_sha } } } -ParameterFilter { + $Method -ne 'POST' -and $Path -like '*/git/ref/*' + } + Mock Invoke-BackportApi { @{ status = 'ahead' } } -ParameterFilter { + $Method -ne 'POST' -and $Path -like '*/compare/*' + } + Mock Invoke-BackportBatchApply { + @($script:state.cohort.members | Where-Object source_sha -eq $SourceSha)[0].result + } + Mock Invoke-BackportGit { + if ($Arguments[0] -in @('fetch', 'ls-remote')) { return @{ text = ''; exit_code = 0 } } + if ($Arguments[0] -eq 'rev-parse') { return @{ text = $script:state.cohort.tree } } + throw 'Unexpected Git operation' + } + $path = Join-Path $TestDrive 'rollover-during-prepare.json' + { New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $state.cohort.request -Depth 60) $path } | + Should -Throw '*rollover*' + Should -Invoke Initialize-BackportBatchGit -Times 1 + $db.writes.Count | Should -Be 0 + Test-Path $path | Should -BeFalse + } + + It 'revalidates version and milestone after expensive provenance work, before any writes' -ForEach @( + @{ change = 'version' }, @{ change = 'closed' }, @{ change = 'missing' } + ) { + $script:releaseChange = $change + Mock Assert-BackportPreparedBatch { + switch ($script:releaseChange) { + 'version' { $script:db.version = '29.2' } + 'closed' { $script:db.milestones[0].state = 'closed' } + 'missing' { $script:db.milestones = @($script:db.milestones | Where-Object number -ne 1) } + } + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw + $db.writes.Count | Should -Be 0 + $db.issues.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + $db.pulls.Count | Should -Be 0 + } + + It 'stops a rollover after Task creation before writing a cohort journal or PR' { + Mock Invoke-BackportApi { + $script:db.version = '29.2' + $script:db.issues['100'] + } -ParameterFilter { $Method -ne 'POST' -and $Method -ne 'PATCH' -and $Path -eq 'repos/microsoft/BCApps/issues/100' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*rollover*' + $db.issues.Count | Should -Be 1 + $db.issues['100'].milestone.number | Should -Be 1 + $db.comments.Count | Should -Be 0 + $db.pulls.Count | Should -Be 0 + $db.writes.Count | Should -Be 1 + } + + It 'keeps existing journal and PR pins through a later branch version rollover' { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.sources['42'].labels = @(@{ name = 'event-request' }) + $db.version = '29.2' + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 999)[0] + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $statePath + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $prepared = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + $prepared.release.version | Should -Be '29.1' + $db.issues['100'].milestone.number | Should -Be 1 + $db.pulls['200'].milestone.number | Should -Be 1 + $db.issues.Count | Should -Be 1 + $db.pulls.Count | Should -Be 1 + $db.comments.Count | Should -Be 1 + $db.sources['42'].labels.name | Should -Contain 'ext-backport-processed' + } + + It 'retains a legacy standalone PR without changing its Task or PR and repairs only source processed' { + $member = $state.cohort.members[0] + $db.issues['90'] = @{ + number = 90; title = 'Legacy Task'; body = Get-BackportBody $member; state = 'open'; milestone = $null + user = @{ login = 'test-app[bot]' }; labels = @(@{ name = 'ext-backporting' }) + html_url = 'https://github.com/microsoft/BCApps/issues/90' + } + $db.pulls['91'] = @{ + number = 91; body = Get-BackportBody $member 90; state = 'closed'; merged_at = '2026-10-21T12:00:00Z' + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/91' + head = @{ ref = Get-BackportBranch 42 $member.target; sha = $member.result.commit; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = $member.target; repo = @{ full_name = 'microsoft/BCApps' } } + } + Save-MilestoneFixture $member + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $db.writes.Count | Should -Be 1 + $db.writes[0].path | Should -Be 'repos/microsoft/BCApps/issues/42/labels' + $db.issues['90'].state | Should -Be 'open' + $db.comments.Count | Should -Be 0 + $db.sources['42'].labels.name | Should -Contain 'ext-backport-processed' + $db.sources['42'].labels = @(@{ name = 'event-request' }) + $db.pulls['91'].head.sha = '0' * 40 + $writes = $db.writes.Count + { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*expected prepared commit*' + $db.writes.Count | Should -Be $writes + } + } +} + BeforeAll { & (Get-Module Backport) { function script:New-BatchFixture { @@ -70,6 +1134,10 @@ Describe 'Team batch discovery and immutable identity' { $script:issueData[[string]$member.source_pr] = $member.issues } Mock Get-BackportBatchRecords { @() } + Mock Get-BackportRelease { + $major = ($Target -split '/')[1] -replace '\.x$', '' + @{ target = $Target; version = "$major.1"; milestone_number = 1; milestone_title = "Version $major.1" } + } Mock Get-BackportTracking { $null } Mock Get-BackportSource { $script:sourceData[[string]$Number] } Mock Get-BackportSourceIssues { $script:issueData[[string]$Number] } @@ -102,550 +1170,156 @@ Describe 'Team batch discovery and immutable identity' { @{ kind = 'multiple-issue' }, @{ kind = 'different-issue' }, @{ kind = 'external-issue' } ) { switch ($kind) { - 'missing-pr' { $script:sourceData['42'].labels = @(@{ name = 'event-request' }) } - 'multiple-pr' { $script:sourceData['42'].labels += @{ name = 'Team: Other' } } - 'missing-issue' { $script:issueData['42'][0].labels.nodes = @(@{ name = 'event-request' }) } - 'multiple-issue' { $script:issueData['42'][0].labels.nodes += @{ name = 'Team: Other' } } - 'different-issue' { - $script:issueData['42'][0].labels.nodes = @(@{ name = 'event-request' }, @{ name = 'Team: Other' }) - } - 'external-issue' { $script:issueData['42'][0].repository.nameWithOwner = 'other/repo' } - } - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) - $rows.Count | Should -Be 2 - @($rows | Where-Object mode -eq 'single')[0].source_prs -join ',' | Should -Be '42' - @($rows | Where-Object mode -eq 'team')[0].source_prs -join ',' | Should -Be '43,44' - } - - It 'uses the sole main PR team when no original issue exists' { - $script:issueData['42'] = @() - @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123).Count | Should -Be 1 - } - - It 'retains category checks on each linked original issue' { - $script:issueData['42'][0].labels.nodes += @{ name = 'enum-request' } - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) - $rows[0].source_prs -join ',' | Should -Be '43,44' - } - - It 'keeps legacy requests and interrupted source intents independent and pinned' { - $script:sourceData['42'].labels += @{ name = 'ext-backport-pending:28.x' } - Mock Get-BackportTracking { - if ($SourcePr -eq 43) { - @{ body = Get-BackportMarker 43 'releases/27.x' ('b' * 40) } - } - } - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) - $rows.Count | Should -Be 3 - ($rows | Where-Object key -eq 'single-42').target | Should -Be 'releases/28.x' - ($rows | Where-Object key -eq 'single-43').target | Should -Be 'releases/27.x' - } - - It 'manual single bypasses dates, categories and processed filters and yields exactly one source' { - $script:sourceData['42'].merged_at = '2020-01-01T00:00:00Z' - $script:sourceData['42'].labels = @(@{ name = 'enum-request' }, @{ name = 'ext-backport-processed' }) - $script:issueData['42'][0].labels.nodes = @(@{ name = 'enum-request' }) - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 123) - $rows.Count | Should -Be 1 - $rows[0].mode | Should -Be 'single' - $rows[0].manual | Should -BeTrue - $rows[0].source_prs -join ',' | Should -Be '42' - } - - It 'manual full uses the same automatic filtering and fixed discovery clock' { - $script:sourceData['42'].labels += @{ name = 'ext-backport-processed' } - $script:sourceData['43'].merged_at = [DateTimeOffset]::UtcNow.AddDays(-16).ToString('o') - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr '' -BatchId 123) - $rows[0].source_prs -join ',' | Should -Be '44' - $rows[0].manual | Should -BeFalse - [DateTimeOffset]$rows[0].reference_time | Should -BeLessOrEqual ([DateTimeOffset]::UtcNow) - } - - It 'validates queued workers against the fixed discovery clock rather than their current date' { - $clock = [DateTimeOffset]::UtcNow.AddDays(-30) - $fixture.request.reference_time = $clock.ToString('o') - foreach ($proof in $fixture.request.sources) { - $proof.merged_at = $clock.AddDays(-14).AddMinutes($proof.number).ToString('o') - $script:sourceData[[string]$proof.number].merged_at = $proof.merged_at - } - Test-BackportAutomaticSource $script:sourceData['42'] | Should -BeFalse - @(Assert-BackportBatchSources $fixture.request microsoft/BCApps).Count | Should -Be 3 - } - - It 'delegates manual independent preparation and preserves the schema-one publication API' { - $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 123)[0] - $path = Join-Path $TestDrive 'single.json' - Mock New-ExtRequestBackport { - @{ schema = 1; source_sha = 'a' * 40 } | ConvertTo-Json | Set-Content $StatePath - } - New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $path - Should -Invoke New-ExtRequestBackport -Times 1 -ParameterFilter { - $Manual -and $SourcePr -eq 42 -and $Target -eq 'releases/29.x' -and - $ReferenceTime -eq [DateTimeOffset]$row.reference_time - } - Mock Publish-ExtRequestBackport {} - Publish-ExtRequestBackportBatch $path 'test-app[bot]' - Should -Invoke Publish-ExtRequestBackport -Times 1 - } - - It 'routes the old per-PR prepare API to its immutable existing batch' { - Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } - Mock New-ExtRequestBackportBatch {} - New-ExtRequestBackport microsoft/BCApps 42 releases/99.x $true (Join-Path $TestDrive 'legacy.json') - Should -Invoke New-ExtRequestBackportBatch -Times 1 -ParameterFilter { - ($RequestJson | ConvertFrom-Json).source_prs.Count -eq 3 -and - ($RequestJson | ConvertFrom-Json).target -eq 'releases/29.x' - } - } - - It 'manual batch member returns exactly its frozen batch including already processed members' { - Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } - $script:sourceData['42'].labels += @{ name = 'ext-backport-processed' } - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 43 -TargetOverride releases/99.x -BatchId 999) - $rows.Count | Should -Be 1 - $rows[0].key | Should -Be $fixture.request.key - $rows[0].source_prs -join ',' | Should -Be '42,43,44' - $rows[0].target | Should -Be 'releases/29.x' - $rows[0].batch_id | Should -Be '123' - Should -Invoke Invoke-BackportApi -Times 0 - } - - It 'resumes an open immutable batch even when all members are outside discovery' { - Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } - Mock Invoke-BackportApi { @() } - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 999) - $rows.Count | Should -Be 1 - $rows[0].key | Should -Be $fixture.request.key - } - - It 'does not poll a completed batch or replay its recorded conflict while its Task stays open' { - Mock Get-BackportBatchRecords { @{ issue = @{ state = 'open' }; manifest = $script:fixture } } - foreach ($number in 42, 43) { $script:sourceData[[string]$number].labels += @{ name = 'ext-backport-processed' } } - Mock Get-BackportTracking { - if ($SourcePr -eq 44) { @{ state = 'open'; body = 'Preparation result: **conflict**.' } } - } - @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 999).Count | Should -Be 0 - $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 44 -BatchId 999) - $rows.Count | Should -Be 1 - $rows[0].key | Should -Be $fixture.request.key - } - - It 'rejects changed frozen membership, targets, source proofs and malformed manifests' -ForEach @( - @{ kind = 'target' }, @{ kind = 'membership' }, @{ kind = 'sha' }, @{ kind = 'parent' } - ) { - Assert-BackportBatchManifest $fixture - $marker = Get-BackportBatchMarker $fixture - (Read-BackportBatchMarker $marker).request.key | Should -Be $fixture.request.key - { Read-BackportBatchMarker "$marker`n$marker" } | Should -Throw - { Read-BackportBatchMarker '' } | Should -Throw - switch ($kind) { - 'target' { $fixture.request.target = 'releases/30.x' } - 'membership' { $fixture.request.source_prs[0] = 999 } - 'sha' { $fixture.request.sources[0].sha = 'e' * 40 } - 'parent' { $fixture.members[1].result.target_sha = 'e' * 40 } - } - { Assert-BackportBatchManifest $fixture } | Should -Throw - } - } -} - -Describe 'Batch publication and duplicate prevention' { - InModuleScope Backport { - BeforeEach { - $script:fixture = New-BatchFixture - $script:statePath = Join-Path $TestDrive 'batch-state.json' - $script:batchTracking = $null - $script:batchPull = $null - $script:singleTracking = @{} - $script:sourceData = @{} - foreach ($member in $fixture.members) { - $script:sourceData[[string]$member.source_pr] = @{ - number = $member.source_pr; title = $member.source_title; merge_commit_sha = $member.source_sha - merged_at = $fixture.request.sources[$member.source_pr - 42].merged_at - base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } - labels = @($member.labels | Where-Object { $_.name -ne 'ext-backporting' }) - } - } - $fixture | ConvertTo-Json -Depth 60 | Set-Content $statePath - Mock Get-BackportBatchRecords { - if ($null -ne $script:batchTracking) { @{ issue = $script:batchTracking; manifest = $script:fixture } } - } - Mock Get-BackportBatchPull { $script:batchPull } - Mock Get-BackportTracking { $script:singleTracking[[string]$SourcePr] } - Mock Get-BackportPull { $null } - Mock Get-BackportSource { $script:sourceData[[string]$Number] } - Mock Get-BackportSourceIssues { @($script:fixture.members | Where-Object source_pr -eq $Number)[0].issues } - Mock Ensure-BackportLabels {} - $script:realProcessed = (Get-Command Set-BackportProcessed).ScriptBlock - Mock Set-BackportProcessed { - & $script:realProcessed -Repository $Repository -SourcePr $SourcePr - $script:sourceData[[string]$SourcePr].labels += @{ name = 'ext-backport-processed' } - } - Mock Assert-BackportPreparedBatch {} - Mock Invoke-BackportGit { - if ($Arguments[0] -eq 'ls-remote') { return @{ text = "$($script:fixture.head)`trefs/heads/bc-extrequest-backport/$($script:fixture.request.key)" } } - throw "Unexpected Git write: $($Arguments -join ' ')" - } - Mock Get-BackportApiPages { - if ($null -ne $script:batchTracking) { $script:batchTracking } - foreach ($item in $script:singleTracking.Values) { $item } - } - Mock Invoke-BackportApi { - if ([string]::IsNullOrEmpty($Method)) { $Method = 'GET' } - if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues') { - $object = @{ - number = 100; title = $Body.title; body = $Body.body; state = 'open' - user = @{ login = 'test-app[bot]' }; labels = @() - html_url = 'https://github.com/microsoft/BCApps/issues/100' - } - if ($Body.body.Contains('bc-extrequest-backport:v2')) { $script:batchTracking = $object } - else { - $marker = Read-BackportMarker $Body.body - $object.number = 200 + $marker.source_pr - $object.html_url = "https://github.com/microsoft/BCApps/issues/$($object.number)" - $script:singleTracking[[string]$marker.source_pr] = $object - } - return $object - } - if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls') { - $script:batchPull = @{ - number = 101; body = $Body.body; draft = $Body.draft; state = 'open'; merged_at = $null - user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/101' - head = @{ sha = $script:fixture.head; ref = $Body.head; repo = @{ full_name = 'microsoft/BCApps' } } - base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } } - } - return $script:batchPull - } - if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/pulls/101') { return $script:batchPull } - if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { return $script:batchTracking } - foreach ($object in $script:singleTracking.Values) { - if ($Method -eq 'GET' -and $Path -eq "repos/microsoft/BCApps/issues/$($object.number)") { return $object } - } - if ($Method -eq 'POST' -and $Path -match '/issues/\d+/labels$') { return } - if ($Method -eq 'PATCH' -and $Path -match '/issues/\d+$') { - if ($Path -eq 'repos/microsoft/BCApps/issues/100') { - if ($Body.ContainsKey('body')) { $script:batchTracking.body = $Body.body } - if ($Body.ContainsKey('state')) { $script:batchTracking.state = $Body.state } - } - return - } - throw "Unexpected remote operation: $Method $Path" - } - } - - It 'publishes one team Task and draft PR plus an independent conflict Task with deduplicated links and label union' { - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Path -eq 'repos/microsoft/BCApps/issues' -and $Method -eq 'POST' -and - $Body.body.Contains('bc-extrequest-backport:v2') -and $Body.type -eq 'Task' -and - $Body.labels -contains 'issue-0' -and $Body.labels -contains 'issue-1' -and - $Body.labels -notcontains 'issue-2' - } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Path -eq 'repos/microsoft/BCApps/pulls' -and $Method -eq 'POST' -and $Body.draft -and - $Body.body.Contains('Fixes #100') -and $Body.base -eq 'releases/29.x' -and - $Body.title -ceq $script:batchTracking.title - } - $body = $script:batchPull.body - $body | Should -Match 'pull/42' - $body | Should -Match 'pull/43' - $identity = Read-BackportBatchMarker $body - $identity.members[0].source_sha | Should -Be ('a' * 40) - $identity.members[1].source_sha | Should -Be ('b' * 40) - [regex]::Matches($body, 'https://github.com/microsoft/BCApps/issues/10(?=\s|$)').Count | Should -Be 1 - $body | Should -Match '(?m)^Fixes #100$' - $body | Should -Not -Match '(?m)^Fixes #10$' - $script:batchTracking.body.TrimEnd() | Should -Match '\*Auto-generated content\*$' - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 43 } - Should -Invoke Set-BackportProcessed -Times 0 -ParameterFilter { $SourcePr -eq 44 } - Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { - $Method -eq 'POST' -and $Path -match '^repos/microsoft/BCApps/issues/(42|43)/labels$' -and - $Body.labels.Count -eq 1 -and $Body.labels[0] -ceq 'ext-backport-processed' - } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -ne 'GET' -and $Path -match '^repos/microsoft/BCApps/issues/(10|44)(?:/|$)' - } - Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { - $Arguments[0] -in @('cherry-pick', 'checkout', 'fetch') - } - } - - It 'reports actual team, source proofs, original issue links and each prepared outcome' { - Mock Write-BackportSummary {} - Write-BackportBatchSummary $fixture prepared - Should -Invoke Write-BackportSummary -Times 1 -ParameterFilter { - $Text.Contains('pull/42') -and $Text.Contains('Team: Finance') -and - $Text.Contains('issues/10') -and $Text.Contains('**applied**') -and $Text.Contains('a' * 40) - } - Should -Invoke Write-BackportSummary -Times 1 -ParameterFilter { - $Text.Contains('pull/44') -and $Text.Contains('issues/10') -and $Text.Contains('**conflict**') - } - } - - It 'repairs processed flags for any existing batch PR without Task lifecycle changes' -ForEach @( - @{ pullState = 'open'; merged = $null }, @{ pullState = 'closed'; merged = $null }, - @{ pullState = 'closed'; merged = '2026-10-20T09:00:00Z' } - ) { - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - $script:batchPull.state = $pullState - $script:batchPull.merged_at = $merged - $script:batchTracking.state = 'closed' - $script:sourceData['42'].labels = @(@{ name = 'event-request' }) - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Set-BackportProcessed -Times 2 -ParameterFilter { $SourcePr -eq 42 } - Should -Invoke Set-BackportProcessed -Times 2 -ParameterFilter { $SourcePr -eq 43 } - Should -Invoke Set-BackportProcessed -Times 0 -ParameterFilter { $SourcePr -eq 44 } - Should -Invoke Assert-BackportPreparedBatch -Times 1 - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('state') - } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - } - - It 'reconciles an existing batch without duplicating issue, PR or conflict outcomes' { - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - } - - It 'prepares an existing batch PR resume without local Git or lifecycle polling' { - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - $script:batchPull.state = 'closed' - $script:batchPull.merged_at = '2026-10-20T09:00:00Z' - $row = @{} + $fixture.request - $row.manifest = $fixture - $resumedPath = Join-Path $TestDrive 'resumed-batch.json' - Mock Initialize-BackportBatchGit { throw 'Existing PR must not prepare Git again.' } - New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $resumedPath - $resumed = Get-Content $resumedPath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 - (Get-BackportBatchMarker $resumed) | Should -Be (Get-BackportBatchMarker $fixture) - Publish-ExtRequestBackportBatch $resumedPath 'test-app[bot]' - Should -Invoke Initialize-BackportBatchGit -Times 0 - Should -Invoke Assert-BackportPreparedBatch -Times 1 - Should -Invoke Invoke-BackportGit -Times 1 - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('state') + 'missing-pr' { $script:sourceData['42'].labels = @(@{ name = 'event-request' }) } + 'multiple-pr' { $script:sourceData['42'].labels += @{ name = 'Team: Other' } } + 'missing-issue' { $script:issueData['42'][0].labels.nodes = @(@{ name = 'event-request' }) } + 'multiple-issue' { $script:issueData['42'][0].labels.nodes += @{ name = 'Team: Other' } } + 'different-issue' { + $script:issueData['42'][0].labels.nodes = @(@{ name = 'event-request' }, @{ name = 'Team: Other' }) + } + 'external-issue' { $script:issueData['42'][0].repository.nameWithOwner = 'other/repo' } } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) + $rows.Count | Should -Be 2 + @($rows | Where-Object mode -eq 'single')[0].source_prs -join ',' | Should -Be '42' + @($rows | Where-Object mode -eq 'team')[0].source_prs -join ',' | Should -Be '43,44' } - It 'attempts an uncertain team Task POST only once within an invocation and never labels sources' { - Mock Invoke-BackportApi { throw 'Uncertain POST' } -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' - } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Uncertain POST*' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } - Should -Invoke Set-BackportProcessed -Times 0 + It 'uses the sole main PR team when no original issue exists' { + $script:issueData['42'] = @() + @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123).Count | Should -Be 1 } - It 'recovers a tracking Task observed by the final REST membership recheck without another Task POST' { - $script:lookupCount = 0 - $script:observed = @{ - number = 100; body = Get-BackportBatchMarker $fixture; state = 'open'; labels = @() - title = 'Recovered backport Task title' - user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' - } - Mock Get-BackportBatchRecords { - $script:lookupCount++ - if ($script:lookupCount -ge 2) { - $script:batchTracking = $script:observed - @{ issue = $script:observed; manifest = $script:fixture } - } - } - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and - $Body.body.Contains('bc-extrequest-backport:v2') - } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' -and - $Body.title -ceq $script:observed.title - } + It 'retains category checks on each linked original issue' { + $script:issueData['42'][0].labels.nodes += @{ name = 'enum-request' } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) + $rows[0].source_prs -join ',' | Should -Be '43,44' } - It 'recovers a confirmed team issue after an interrupted response without repeating its POST' { - Mock Invoke-BackportApi { - $script:batchTracking = @{ - number = 100; body = $Body.body; state = 'open'; labels = @() - title = $Body.title - user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' + It 'keeps legacy requests and interrupted source intents independent and pinned' { + $script:sourceData['42'].labels += @{ name = 'ext-backport-pending:28.x' } + Mock Get-BackportTracking { + if ($SourcePr -eq 43) { + @{ body = Get-BackportMarker 43 'releases/27.x' ('b' * 40) } } - throw 'Lost response' - } -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v2') } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost response*' - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v2') } - $script:batchPull | Should -Not -BeNullOrEmpty + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 123) + $rows.Count | Should -Be 3 + ($rows | Where-Object key -eq 'single-42').target | Should -Be 'releases/28.x' + ($rows | Where-Object key -eq 'single-43').target | Should -Be 'releases/27.x' } - It 'does not blindly repeat an unconfirmed batch PR POST' { - Mock Invoke-BackportApi { throw 'Uncertain PR POST' } -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Uncertain PR POST*' - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - (Read-BackportPublicationIntent $script:batchTracking.body) | Should -Be $fixture.head - Should -Invoke Set-BackportProcessed -Times 0 + It 'manual single bypasses dates, categories and processed filters and yields exactly one source' { + $script:sourceData['42'].merged_at = '2020-01-01T00:00:00Z' + $script:sourceData['42'].labels = @(@{ name = 'enum-request' }, @{ name = 'ext-backport-processed' }) + $script:issueData['42'][0].labels.nodes = @(@{ name = 'enum-request' }) + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 123) + $rows.Count | Should -Be 1 + $rows[0].mode | Should -Be 'single' + $rows[0].manual | Should -BeTrue + $rows[0].source_prs -join ',' | Should -Be '42' } - It 'requires durable intent readback before attempting a batch PR POST' { - Mock Invoke-BackportApi {} -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('body') -and - $Body.body.Contains('bc-extrequest-backport:publication-intent:') - } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*intent was not confirmed*' - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - Should -Invoke Set-BackportProcessed -Times 0 + It 'manual full uses the same automatic filtering and fixed discovery clock' { + $script:sourceData['42'].labels += @{ name = 'ext-backport-processed' } + $script:sourceData['43'].merged_at = [DateTimeOffset]::UtcNow.AddDays(-16).ToString('o') + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr '' -BatchId 123) + $rows[0].source_prs -join ',' | Should -Be '44' + $rows[0].manual | Should -BeFalse + [DateTimeOffset]$rows[0].reference_time | Should -BeLessOrEqual ([DateTimeOffset]::UtcNow) } - It 'retains an accepted intent PATCH with a lost response and never guesses whether to POST a PR' { - Mock Invoke-BackportApi { - $script:batchTracking.body = $Body.body - throw 'Lost intent response' - } -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('body') -and - $Body.body.Contains('bc-extrequest-backport:publication-intent:') - } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost intent response*' - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' + It 'validates queued workers against the fixed discovery clock rather than their current date' { + $clock = [DateTimeOffset]::UtcNow.AddDays(-30) + $fixture.request.reference_time = $clock.ToString('o') + foreach ($proof in $fixture.request.sources) { + $proof.merged_at = $clock.AddDays(-14).AddMinutes($proof.number).ToString('o') + $script:sourceData[[string]$proof.number].merged_at = $proof.merged_at } - Should -Invoke Set-BackportProcessed -Times 0 - } - - It 'reuses an accepted batch PR when its response was lost' { - Mock Invoke-BackportApi { - $script:batchPull = @{ - number = 101; body = $Body.body; draft = $true; state = 'open'; merged_at = $null - user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/101' - head = @{ sha = $script:fixture.head; ref = $Body.head; repo = @{ full_name = 'microsoft/BCApps' } } - base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } } - } - throw 'Lost PR response' - } -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost PR response*' - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } + Test-BackportAutomaticSource $script:sourceData['42'] | Should -BeFalse + @(Assert-BackportBatchSources $fixture.request microsoft/BCApps).Count | Should -Be 3 } - It 'publishes the clean batch even when independent conflict issue publication fails' { - Mock Invoke-BackportApi { throw 'Independent POST failed' } -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and - $Body.body.Contains('bc-extrequest-backport:v1') + It 'delegates manual independent preparation and preserves the schema-one publication API' { + Mock Get-BackportTracking { + @{ number = 90; body = Get-BackportMarker 42 releases/29.x ('a' * 40) } } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Independent POST failed*' - $script:batchPull | Should -Not -BeNullOrEmpty - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } - Should -Invoke Set-BackportProcessed -Times 0 -ParameterFilter { $SourcePr -eq 44 } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and - $Body.body.Contains('bc-extrequest-backport:v1') + $row = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 123)[0] + $path = Join-Path $TestDrive 'single.json' + Mock Assert-BackportRelease {} + Mock New-ExtRequestBackport { + @{ schema = 1; source_sha = 'a' * 40 } | ConvertTo-Json | Set-Content $StatePath } - } - - It 'never publishes a branch or PR when every source is conflict or already present' { - $script:fixture = New-BatchFixture -Outcomes @('conflict', 'already-present') - foreach ($proof in $fixture.request.sources) { $script:sourceData[[string]$proof.number].merged_at = $proof.merged_at } - $fixture | ConvertTo-Json -Depth 60 | Set-Content $statePath - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'POST' -and ($Path -eq 'repos/microsoft/BCApps/pulls' -or - ($Path -eq 'repos/microsoft/BCApps/issues' -and $Body.body.Contains('bc-extrequest-backport:v2'))) + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $path + Should -Invoke New-ExtRequestBackport -Times 1 -ParameterFilter { + $Manual -and $SourcePr -eq 42 -and $Target -eq 'releases/29.x' -and + $ReferenceTime -eq [DateTimeOffset]$row.reference_time } - Should -Invoke Invoke-BackportGit -Times 0 - Should -Invoke Set-BackportProcessed -Times 0 - } - - It 'fails closed on modified prepared provenance before any remote write' { - Mock Assert-BackportPreparedBatch { throw 'Provenance changed' } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Provenance changed*' - Should -Invoke Invoke-BackportApi -Times 0 + Mock Publish-BackportLegacyOrPromote {} + Publish-ExtRequestBackportBatch $path 'test-app[bot]' + Should -Invoke Publish-BackportLegacyOrPromote -Times 1 } - It 'refuses changed source proofs before writes' { - $script:sourceData['43'].merge_commit_sha = '0' * 40 - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*SHA changed*' - Should -Invoke Invoke-BackportApi -Times 0 + It 'routes the old per-PR prepare API to its immutable existing batch' { + Mock Get-BackportBatchRecords { @{ issue = @{ number = 90; state = 'open' }; manifest = $script:fixture } } + Mock New-ExtRequestBackportBatch {} + New-ExtRequestBackport microsoft/BCApps 42 releases/99.x $true (Join-Path $TestDrive 'legacy.json') + Should -Invoke New-ExtRequestBackportBatch -Times 1 -ParameterFilter { + ($RequestJson | ConvertFrom-Json).source_prs.Count -eq 3 -and + ($RequestJson | ConvertFrom-Json).target -eq 'releases/29.x' + } } - It 'refuses a different per-source pinned release instead of silently moving a resumed request' { - $script:sourceData['43'].labels += @{ name = 'ext-backport-pending:28.x' } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*different pinned target*' + It 'manual batch member returns exactly its frozen batch including already processed members' { + Mock Get-BackportBatchRecords { @{ issue = @{ number = 90; state = 'open' }; manifest = $script:fixture } } + $script:sourceData['42'].labels += @{ name = 'ext-backport-processed' } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 43 -TargetOverride releases/99.x -BatchId 999) + $rows.Count | Should -Be 1 + $rows[0].key | Should -Be $fixture.request.key + $rows[0].source_prs -join ',' | Should -Be '42,43,44' + $rows[0].target | Should -Be 'releases/29.x' + $rows[0].batch_id | Should -Be '123' Should -Invoke Invoke-BackportApi -Times 0 } - It 'reads legacy source publication intents fail-closed without writing or clearing legacy labels' { - $script:sourceData['42'].labels += @{ name = 'ext-backport-publishing:29.x' } - $script:batchTracking = @{ - number = 100; body = Get-BackportBatchMarker $fixture; state = 'open' - user = @{ login = 'test-app[bot]' } - } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Legacy batch PR publication is unconfirmed*' - Should -Invoke Invoke-BackportApi -Times 0 - Should -Invoke Set-BackportProcessed -Times 0 + It 'resumes an open immutable batch even when all members are outside discovery' { + Mock Get-BackportBatchRecords { @{ issue = @{ number = 90; state = 'open' }; manifest = $script:fixture } } + Mock Invoke-BackportApi { @() } + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 999) + $rows.Count | Should -Be 1 + $rows[0].key | Should -Be $fixture.request.key } - It 'refuses a batch object by another actor even if its manifest is intact' { - $script:batchTracking = @{ - number = 100; body = Get-BackportBatchMarker $fixture; state = 'open'; user = @{ login = 'human' } + It 'does not poll a completed batch or replay its recorded conflict while its Task stays open' { + Mock Get-BackportBatchRecords { @{ issue = @{ number = 90; state = 'open' }; manifest = $script:fixture } } + foreach ($number in 42, 43) { $script:sourceData[[string]$number].labels += @{ name = 'ext-backport-processed' } } + Mock Get-BackportTracking { + if ($SourcePr -eq 44) { @{ state = 'open'; body = 'Preparation result: **conflict**.' } } } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*author*' - Should -Invoke Invoke-BackportApi -Times 0 + @(Find-ExtRequestBackportBatches microsoft/BCApps -BatchId 999).Count | Should -Be 0 + $rows = @(Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 44 -BatchId 999) + $rows.Count | Should -Be 1 + $rows[0].key | Should -Be $fixture.request.key } - It 'does not mark sources for an existing PR with changed author or SHA' -ForEach @( - @{ kind = 'author' }, @{ kind = 'sha' } + It 'rejects changed frozen membership, targets, source proofs and malformed manifests' -ForEach @( + @{ kind = 'target' }, @{ kind = 'membership' }, @{ kind = 'sha' }, @{ kind = 'parent' } ) { - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - if ($kind -eq 'author') { $script:batchPull.user.login = 'human' } - else { $script:batchPull.head.sha = '0' * 40 } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw - Should -Invoke Set-BackportProcessed -Times 2 - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - } - - It 'does not overwrite a batch branch with human changes' { - Mock Invoke-BackportGit { @{ text = "$('e' * 40)`trefs/heads/changed" } } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*never overwrite*' - Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - } - - It 'reuses a no-op Task after initial closure failure without marking its source or duplicating the Task' { - $script:fixture = New-BatchFixture -Outcomes @('already-present') - $script:sourceData['42'].merged_at = $fixture.request.sources[0].merged_at - $fixture | ConvertTo-Json -Depth 60 | Set-Content $statePath - Mock Invoke-BackportApi { throw 'Close failed' } -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' - } - { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Close failed*' - Should -Invoke Set-BackportProcessed -Times 0 - Mock Invoke-BackportApi { $script:singleTracking['42'].state = 'closed' } -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' - } - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' - Should -Invoke Set-BackportProcessed -Times 0 - Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('state') -and $Body.state -eq 'closed' - } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' + Assert-BackportBatchManifest $fixture + $marker = Get-BackportBatchMarker $fixture + (Read-BackportBatchMarker $marker).request.key | Should -Be $fixture.request.key + { Read-BackportBatchMarker "$marker`n$marker" } | Should -Throw + { Read-BackportBatchMarker '' } | Should -Throw + switch ($kind) { + 'target' { $fixture.request.target = 'releases/30.x' } + 'membership' { $fixture.request.source_prs[0] = 999 } + 'sha' { $fixture.request.sources[0].sha = 'e' * 40 } + 'parent' { $fixture.members[1].result.target_sha = 'e' * 40 } } + { Assert-BackportBatchManifest $fixture } | Should -Throw } } } - Describe 'Backport descriptions and titles' { InModuleScope Backport { BeforeEach { @@ -1011,51 +1685,6 @@ Describe 'Backport discovery and recovery' { $object.base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } { Assert-BackportObject $object $state 'test-app[bot]' } | Should -Throw } - - It 'repairs labels and links for an existing PR instead of creating another issue or PR' { - $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) - $state = @{ - repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 - labels = @(@{ name = 'event-request' }, @{ name = 'ext-backporting' }) - } - $tracking = @{ number = 100; body = $marker; user = @{ login = 'test-app[bot]' }; state = 'open'; html_url = 'issue-url' } - $pull = @{ - number = 101; body = $marker; user = @{ login = 'test-app[bot]' }; merged_at = $null - head = @{ sha = 'b' * 40; ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } - base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } - state = 'open'; html_url = 'pr-url' - } - Mock Ensure-BackportLabels {} - Mock Set-BackportProcessed {} - Mock Invoke-BackportApi {} - Complete-BackportPull $state $tracking $pull 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 2 -ParameterFilter { $Path -like '*/labels' -and $Method -eq 'POST' } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Path -match '/(issues|pulls)$' -and $Method -eq 'POST' } - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } - } - - It 'marks the original PR for a validated merged PR without closing its tracking Task' { - $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) - $state = @{ repository = 'microsoft/BCApps'; source_pr = 42; target = 'releases/29.x'; source_sha = 'a' * 40 } - $tracking = @{ - number = 100; body = "$marker`nBackport PR: pr-url"; user = @{ login = 'test-app[bot]' } - state = 'open'; html_url = 'issue-url'; labels = @(@{ name = 'ext-backporting' }) - } - $pull = @{ - number = 101; body = $marker; user = @{ login = 'test-app[bot]' }; merged_at = '2026-10-19T00:00:00Z' - head = @{ sha = 'b' * 40; ref = 'bc-extrequest-backport/pr-42/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } - base = @{ ref = 'releases/29.x'; repo = @{ full_name = 'microsoft/BCApps' } } - state = 'closed'; html_url = 'pr-url' - } - Mock Ensure-BackportLabels {} - Mock Set-BackportProcessed {} - Mock Invoke-BackportApi {} - Complete-BackportPull $state $tracking $pull 'test-app[bot]' - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('state') - } - } } } @@ -1141,14 +1770,18 @@ Describe 'Native Git backport integration' { $cumulative.outcome | Should -Be 'conflict' } - It 'prepares and validates a real cumulative batch and resumes its exact immutable release and membership' { + It 'prepares real Git through the team or standalone API and resumes immutable state' -ForEach @( + @{ mode = 'team' }, @{ mode = 'single' } + ) { [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "source`nsecond`n") $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'second') $second = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() - $script:fixture = New-BatchFixture -Outcomes @('applied', 'applied') + [string[]]$outcomes = if ($mode -eq 'single') { @('applied') } else { @('applied', 'applied') } + $script:fixture = New-BatchFixture -Outcomes $outcomes $request = $fixture.request $request.sources[0].sha = $source - $request.sources[1].sha = $second + if ($mode -eq 'team') { $request.sources[1].sha = $second } + else { $request.sources[0].merged_at = '2020-01-01T00:00:00Z' } [void]$request.Remove('key') $request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $request)).Substring(0, 32) $script:request = $request @@ -1158,9 +1791,14 @@ Describe 'Native Git backport integration' { $script:actualGit = (Get-Command Invoke-BackportGit).ScriptBlock Mock Get-BackportBatchRecords { if ($null -ne $script:durable) { - @{ issue = @{ state = 'open'; body = Get-BackportBatchMarker $script:durable }; manifest = $script:durable } + @{ shared = $true; issue = @{ state = 'open' }; manifest = $script:durable + journal_state = $script:preparedState; journal = @{ body = Get-BackportJournalMarker $script:preparedState } } } } + Mock Get-BackportRelease { + @{ target = 'releases/29.x'; version = '29.1'; milestone_number = 1; milestone_title = 'Version 29.1' } + } + Mock Assert-BackportRelease {} Mock Get-BackportBatchPull { $null } Mock Get-BackportTracking { $null } Mock Get-BackportSource { @@ -1168,7 +1806,8 @@ Describe 'Native Git backport integration' { @{ number = $Number; title = "Source $Number"; merge_commit_sha = $proof.sha; merged_at = $proof.merged_at base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } - labels = @(@{ name = 'event-request' }, @{ name = 'Team: Finance' }) + labels = if ($script:request.sources.Count -eq 1) { @(@{ name = 'enum-request' }) } + else { @(@{ name = 'event-request' }, @{ name = 'Team: Finance' }) } } } Mock Get-BackportSourceIssues { @($script:fixture.members | Where-Object source_pr -eq $Number)[0].issues } @@ -1191,30 +1830,102 @@ Describe 'Native Git backport integration' { $preparedDirectory = Join-Path $root 'git' Move-Item -LiteralPath $directory -Destination $preparedDirectory $statePath = Join-Path $root 'state.json' - New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $request -Depth 60) $statePath - $script:durable = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 - $durable.members.Count | Should -Be 2 - $durable.members[1].result.target_sha | Should -Be $durable.members[0].result.commit - (Invoke-BackportGit $preparedDirectory @('show', "$($durable.head):src/Foo.al")).text | Should -Be "source`nsecond`n" + if ($mode -eq 'single') { + New-ExtRequestBackport microsoft/BCApps 42 releases/29.x $true $statePath + } else { + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $request -Depth 60) $statePath + } + $script:preparedState = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + $preparedState.schema | Should -Be 3 + $script:durable = $preparedState.cohort + $durable.members.Count | Should -Be $outcomes.Count + if ($mode -eq 'team') { + $durable.members[1].result.target_sha | Should -Be $durable.members[0].result.commit + (Invoke-BackportGit $preparedDirectory @('show', "$($durable.head):src/Foo.al")).text | Should -Be "source`nsecond`n" + } else { + $durable.members[0].main_team | Should -Be 'Unassigned' + $durable.request.manual | Should -BeTrue + $durable.request.key | Should -Be 'single-42' + } Assert-BackportPreparedBatch $durable $preparedDirectory $tampered = $durable | ConvertTo-Json -Depth 60 | ConvertFrom-Json -AsHashtable -Depth 60 - $tampered.members[1].result.paths = @('src/Unexpected.al') + $tampered.members[-1].result.paths = @('src/Unexpected.al') { Assert-BackportPreparedBatch $tampered $preparedDirectory } | Should -Throw '*provenance differs*' $resumeRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory $resumeRoot Copy-Item -LiteralPath $preparedDirectory -Destination (Join-Path $resumeRoot 'git') -Recurse $script:release = '0' * 40 $script:remoteHead = "$($durable.head)`trefs/heads/bc-extrequest-backport/$($request.key)" - $row = @{} + $request + $row = @{} + $durable.request $row.manifest = $durable $resumedPath = Join-Path $resumeRoot 'state.json' New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $resumedPath $resumed = Get-Content $resumedPath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 - (Get-BackportBatchMarker $resumed) | Should -Be (Get-BackportBatchMarker $durable) + (Get-BackportJournalMarker $resumed) | Should -Be (Get-BackportJournalMarker $preparedState) Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Path -like '*/git/ref/*' } Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } } + It 'does not replay a frozen singleton conflict until explicit manual retry on the current release' { + $null = Invoke-BackportGit $directory @('checkout', '--detach', $base) + [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "incompatible release`n") + $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'old release') + $oldRelease = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $cohort = New-BatchFixture -Outcomes @('conflict') + $cohort.request.mode = 'single'; $cohort.request.key = 'single-42'; $cohort.request.manual = $true + $cohort.request.sources[0].sha = $source + $cohort.target_sha = $oldRelease; $cohort.head = $oldRelease + $cohort.tree = (Invoke-BackportGit $directory @('rev-parse', 'HEAD^{tree}')).text.Trim() + $cohort.members[0].source_sha = $source; $cohort.members[0].manual = $true + $cohort.members[0].main_team = 'Team: Finance' + $cohort.members[0].result = Invoke-BackportApply $directory $source $oldRelease + $cohort.members[0].result.outcome | Should -Be 'conflict' + $script:frozen = @{ + schema = 3; repository = 'microsoft/BCApps'; cohort = $cohort + release = @{ target = 'releases/29.x'; version = '29.1'; milestone_number = 1; milestone_title = 'Version 29.1' } + } + $script:currentRelease = $base + $script:retryDirectory = $directory + $script:actualGit = (Get-Command Invoke-BackportGit).ScriptBlock + Mock Get-BackportBatchRecords { + @{ shared = $true; manifest = $script:frozen.cohort; journal_state = $script:frozen + issue = @{ state = 'open' }; journal = @{ id = 1000; body = Get-BackportJournalMarker $script:frozen } } + } + Mock Get-BackportBatchPull { $null } + Mock Assert-BackportRelease {} + Mock Get-BackportSource { + @{ number = 42; title = 'Source'; merge_commit_sha = $script:frozen.cohort.members[0].source_sha + merged_at = $script:frozen.cohort.request.sources[0].merged_at + labels = @(@{ name = 'Team: Finance' }) } + } + Mock Get-BackportSourceIssues { $script:frozen.cohort.members[0].issues } + Mock Initialize-BackportBatchGit { Move-Item -LiteralPath $script:retryDirectory -Destination $Directory } + Mock Invoke-BackportApi { + if ($Method -and $Method -ne 'GET') { throw 'Preparation attempted a write.' } + if ($Path -like '*/git/ref/*') { return @{ object = @{ sha = $script:currentRelease } } } + if ($Path -like '*/compare/*') { return @{ status = 'ahead' } } + throw "Unexpected API read: $Path" + } + Mock Invoke-BackportGit { + if ($Arguments[0] -eq 'fetch' -or $Arguments[0] -eq 'ls-remote') { return @{ text = ''; exit_code = 0 } } + & $script:actualGit -Directory $Directory -Arguments $Arguments -InputText $InputText ` + -AllowFailure:$AllowFailure -CommitDate $CommitDate + } + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $path = Join-Path $root 'state.json' + $row = @{} + $frozen.cohort.request + $row.manifest = $frozen.cohort + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 80) $path + Should -Invoke Invoke-BackportGit -Times 0 + $row.retry_conflict = $true + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 80) $path + $prepared = Get-Content $path -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + $prepared.supersedes | Should -Be 1000 + $prepared.cohort.target_sha | Should -Be $base + $prepared.cohort.members[0].result.outcome | Should -Be 'applied' + Assert-BackportPreparedBatch $prepared.cohort (Join-Path $root 'git') + } + It 'isolates conflicts, excludes dependent paths and retains a disjoint clean source' { [IO.File]::WriteAllText((Join-Path $directory 'src/Other.al'), "clean`n") $null = Invoke-BackportGit $directory @('add', 'src/Other.al') @@ -1250,285 +1961,4 @@ Describe 'Native Git backport integration' { Test-Path (Join-Path $directory 'ran-hook') | Should -BeFalse } } - - Describe 'Backport publication contract' { - InModuleScope Backport { - BeforeEach { - $script:statePath = Join-Path $TestDrive 'state.json' - $script:source = @{ - number = 42; title = 'Expose an event'; merged_at = '2020-01-01T00:00:00Z' - base = @{ ref = 'main'; repo = @{ full_name = 'microsoft/BCApps' } } - merge_commit_sha = 'a' * 40; labels = @(@{ name = 'enum-request' }) - } - $script:state = @{ - schema = 1; repository = 'microsoft/BCApps'; source_pr = 42; source_sha = 'a' * 40 - target = 'releases/29.x'; manual = $true; source_title = 'Expose an event' - result = @{ outcome = 'applied'; commit = 'b' * 40; tree = 'c' * 40; target_sha = 'd' * 40 } - } - $script:tracking = $null - $script:pull = $null - $script:issues = @(@{ - number = 10; url = 'https://github.com/microsoft/BCApps/issues/10' - title = 'Allow extensions to use the requested helper' - labels = @{ nodes = @( - @{ name = 'enum-request'; color = 'ffffff'; description = '' } - @{ name = 'Team: SCM'; color = 'ffffff'; description = '' } - @{ name = 'ext-ready-to-implement'; color = 'ffffff'; description = '' } - ) } - }) - Mock Get-BackportSource { $script:source } - Mock Get-BackportSourceIssues { $script:issues } - Mock Get-BackportTracking { $script:tracking } - Mock Get-BackportPull { $script:pull } - Mock Get-BackportBatchRecords { @() } - Mock Ensure-BackportLabels {} - $script:realProcessed = (Get-Command Set-BackportProcessed).ScriptBlock - Mock Set-BackportProcessed { & $script:realProcessed -Repository $Repository -SourcePr $SourcePr } - Mock Invoke-BackportGit { - switch ($Arguments -join ' ') { - 'rev-parse HEAD' { return @{ text = 'b' * 40 } } - 'rev-parse HEAD^{tree}' { return @{ text = 'c' * 40 } } - 'rev-parse HEAD^' { return @{ text = 'd' * 40 } } - default { - if ($Arguments[0] -eq 'ls-remote') { return @{ text = "$('b' * 40)`trefs/heads/bc-extrequest-backport/pr-42/29.x" } } - throw "Unexpected Git invocation: $($Arguments -join ' ')" - } - } - } - Mock Invoke-BackportApi { - if ([string]::IsNullOrEmpty($Method)) { $Method = 'GET' } - if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues') { - $script:tracking = @{ - number = 100; title = $Body.title; body = $Body.body; state = 'open'; labels = @() - user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/issues/100' - } - return $script:tracking - } - if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { return $script:tracking } - if ($Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls') { - $script:pull = @{ - number = 101; body = $Body.body; draft = $Body.draft; merged_at = $null; state = 'open' - user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/101' - head = @{ sha = 'b' * 40; ref = $Body.head; repo = @{ full_name = 'microsoft/BCApps' } } - base = @{ ref = $Body.base; repo = @{ full_name = 'microsoft/BCApps' } } - } - return $script:pull - } - if ($Method -eq 'GET' -and $Path -eq 'repos/microsoft/BCApps/pulls/101') { return $script:pull } - if ($Path -like '*/compare/*' -and $Method -eq 'GET') { return @{ status = 'ahead' } } - if ($Method -eq 'POST' -and $Path -match '/issues/(42|100|101)/labels$') { return } - if ($Method -eq 'PATCH' -and $Path -eq 'repos/microsoft/BCApps/issues/100') { - if ($Body.ContainsKey('body')) { $script:tracking.body = $Body.body } - if ($Body.ContainsKey('state')) { $script:tracking.state = $Body.state } - return - } - throw "Unexpected API invocation: $Method $Path" - } - } - - It 'publishes a Task and draft PR for a manual old PR with a normally excluded category' { - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - Publish-ExtRequestBackport $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' -and - $Body.type -eq 'Task' -and $Body.labels -contains 'ext-backporting' -and - $Body.labels -contains 'Team: SCM' -and $Body.labels -contains 'enum-request' -and - $Body.labels -notcontains 'ext-ready-to-implement' - } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' -and - $Body.draft -and $Body.base -eq 'releases/29.x' -and - $Body.head -eq 'bc-extrequest-backport/pr-42/29.x' -and - $Body.body.Contains('https://github.com/microsoft/BCApps/pull/42') -and - $Body.body.Contains('https://github.com/microsoft/BCApps/issues/10') -and - $Body.body.Contains('Fixes #100') -and - $Body.title -ceq $script:tracking.title - } - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } - (Read-BackportPublicationIntent $script:tracking.body) | Should -Be ('b' * 40) - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Path -like 'repos/microsoft/BCApps/issues/10/*' - } - } - - It 'creates the tracking issue on a conflict but publishes no branch or PR' { - $state.result.outcome = 'conflict' - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - Publish-ExtRequestBackport $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - Should -Invoke Invoke-BackportGit -Times 0 - Should -Invoke Set-BackportProcessed -Times 0 - } - - It 'closes an already-present tracking issue without creating an empty PR' { - $state.result.outcome = 'already-present' - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - Publish-ExtRequestBackport $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { $Method -eq 'PATCH' -and $Body.state -eq 'closed' } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - Should -Invoke Set-BackportProcessed -Times 0 - } - - It 'reuses recorded conflict and no-op results without Git re-preparation, source labels or duplicate Tasks' -ForEach @( - @{ outcome = 'conflict'; manual = $false }, - @{ outcome = 'already-present'; manual = $false }, @{ outcome = 'already-present'; manual = $true } - ) { - $state.result.outcome = $outcome - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - Publish-ExtRequestBackport $statePath 'test-app[bot]' - $reusedPath = Join-Path $TestDrive 'reused.json' - New-ExtRequestBackport microsoft/BCApps 42 releases/99.x $manual $reusedPath - $reused = Get-Content $reusedPath -Raw | ConvertFrom-Json -AsHashtable - $reused.target | Should -Be 'releases/29.x' - $reused.result.outcome | Should -Be $outcome - Publish-ExtRequestBackport $reusedPath 'test-app[bot]' - Should -Invoke Invoke-BackportGit -Times 0 - Should -Invoke Set-BackportProcessed -Times 0 - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' - } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - } - - It 'allows explicit manual preparation to retry an open standalone conflict' { - $state.result.outcome = 'conflict' - Publish-ExtRequestBackport -BotLogin 'test-app[bot]' -PreparedState $state - Mock Invoke-BackportGit { throw 'Manual preparation attempted' } -ParameterFilter { - $Arguments[0] -eq 'init' - } - { New-ExtRequestBackport microsoft/BCApps 42 releases/99.x $true (Join-Path $TestDrive 'retry.json') } | - Should -Throw '*Manual preparation attempted*' - Should -Invoke Set-BackportProcessed -Times 0 - } - - It 'publishes a clean manual conflict retry using the same Task and refreshes its stale outcome' { - $state.result.outcome = 'conflict' - Publish-ExtRequestBackport -BotLogin 'test-app[bot]' -PreparedState $state - $state.result.outcome = 'applied' - Publish-ExtRequestBackport -StatePath $statePath -BotLogin 'test-app[bot]' -PreparedState $state - $script:tracking.body | Should -Not -Match 'Preparation result: \*\*conflict\*\*' - $script:tracking.body | Should -Match 'Preparation result: \*\*applied\*\*' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' - } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - Should -Invoke Set-BackportProcessed -Times 1 -ParameterFilter { $SourcePr -eq 42 } - } - - It 'uses only the processed payload after a confirmed standalone PR and never labels original issues' { - Mock Set-BackportProcessed { - if ($null -eq $script:pull -or $script:pull.head.sha -cne $script:state.result.commit) { - throw 'Attempted to mark a source without a confirmed PR.' - } - & $script:realProcessed -Repository $Repository -SourcePr $SourcePr - } - Mock Invoke-BackportApi {} -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues/42/labels' - } - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - Publish-ExtRequestBackport $statePath 'test-app[bot]' - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues/42/labels' -and - $Body.labels.Count -eq 1 -and $Body.labels[0] -ceq 'ext-backport-processed' - } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -ne 'GET' -and $Path -match '^repos/microsoft/BCApps/issues/10(?:/|$)' - } - } - - It 'requires confirmed intent before PR creation and leaves source labels untouched on PR POST failure' { - Mock Invoke-BackportApi { - (Read-BackportPublicationIntent $script:tracking.body) | Should -Be ('b' * 40) - throw 'Uncertain PR response' - } -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*Uncertain PR response*' - { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*unconfirmed*' - Should -Invoke Set-BackportProcessed -Times 0 - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - } - - It 'fails closed on legacy source state without a tracking record' { - $source.labels += @{ name = 'ext-backport-pending:29.x' } - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' } - } - - It 'does not recreate processed work whose tracking issue is missing, even manually' { - $source.labels += @{ name = 'ext-backport-processed' } - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*prior backport state*' - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' - } - } - - It 'does not repeat an uncertain release-PR POST' { - $marker = Get-BackportMarker 42 'releases/29.x' ('a' * 40) - $script:tracking = @{ - number = 100; body = "$marker`n"; state = 'open' - user = @{ login = 'test-app[bot]' } - } - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*previous PR creation is unconfirmed*' - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' } - Should -Invoke Set-BackportProcessed -Times 0 - } - - It 'reuses open, closed and merged standalone PRs without changing Task lifecycle' -ForEach @( - @{ pullState = 'open'; merged = $null }, @{ pullState = 'closed'; merged = $null }, - @{ pullState = 'closed'; merged = '2026-10-20T09:00:00Z' } - ) { - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - Publish-ExtRequestBackport $statePath 'test-app[bot]' - $script:pull.state = $pullState - $script:pull.merged_at = $merged - $script:tracking.state = 'closed' - Publish-ExtRequestBackport $statePath 'test-app[bot]' - Should -Invoke Set-BackportProcessed -Times 2 - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/issues' - } - Should -Invoke Invoke-BackportApi -Times 1 -ParameterFilter { - $Method -eq 'POST' -and $Path -eq 'repos/microsoft/BCApps/pulls' - } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { - $Method -eq 'PATCH' -and $Body.ContainsKey('state') - } - } - - It 'refuses changed automatic source categories before any writes' { - $state.manual = $false - $source.merged_at = [DateTimeOffset]::UtcNow.AddHours(-1).ToString('o') - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*eligibility changed*' - Should -Invoke Invoke-BackportApi -Times 0 - } - - It 'does not overwrite an existing branch with different content' { - Mock Invoke-BackportGit { - switch ($Arguments -join ' ') { - 'rev-parse HEAD' { return @{ text = 'b' * 40 } } - 'rev-parse HEAD^{tree}' { return @{ text = 'c' * 40 } } - 'rev-parse HEAD^' { return @{ text = 'd' * 40 } } - default { return @{ text = "$('e' * 40)`trefs/heads/bc-extrequest-backport/pr-42/29.x" } } - } - } - $state | ConvertTo-Json -Depth 20 | Set-Content $statePath - { Publish-ExtRequestBackport $statePath 'test-app[bot]' } | Should -Throw '*never overwrite*' - Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } - Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Path -eq 'repos/microsoft/BCApps/pulls' -and $Method -eq 'POST' } - } - } - } } diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index c0bc7c35650..f73a2f036bb 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -393,8 +393,7 @@ function New-ExtRequestBackport { Assert-BackportRepository $Repository $batch = @(Get-BackportBatchRecords $Repository | Where-Object { $SourcePr -in $_.manifest.request.source_prs }) if ($batch.Count -gt 0) { - $row = @{} + $batch[0].manifest.request - $row.manifest = $batch[0].manifest + $row = Get-BackportResumeRequest $batch[0] -Manual:$Manual New-ExtRequestBackportBatch $Repository (ConvertTo-Json $row -Depth 60) $StatePath return } @@ -402,9 +401,16 @@ function New-ExtRequestBackport { $issues = @(Get-BackportSourceIssues $Repository $SourcePr) $labels = @(Get-BackportLabels -Issues $issues -PrLabels $source.labels) $tracking = Get-BackportTracking $Repository $SourcePr - if (-not $Manual -and $null -eq $tracking -and (-not (Test-BackportAutomaticSource $source $ReferenceTime) -or - @($issues | Where-Object { -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) }).Count -gt 0)) { - throw "Source PR #$SourcePr or one of its issues is no longer automatically eligible." + if ($null -eq $tracking) { + $request = @{ + mode = 'single'; key = "single-$SourcePr"; source_prs = @($SourcePr) + sources = @(@{ number = $SourcePr; sha = $source.merge_commit_sha; merged_at = $source.merged_at }) + target = $Target; team = Get-BackportSourceTeam $source $issues $Repository + batch_id = '0'; reference_time = $ReferenceTime.ToUniversalTime().ToString('o'); manual = $Manual + release = Get-BackportRelease $Repository $Target + } + New-ExtRequestBackportBatch $Repository (ConvertTo-Json $request -Depth 60) $StatePath + return } $target = Get-BackportPinnedTarget -Source $source -Tracking $tracking -Default $Target Assert-BackportTarget $target @@ -420,6 +426,9 @@ function New-ExtRequestBackport { } $directory = Join-Path (Split-Path -Parent $StatePath) 'git' $publicationBlocked = $null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body) + $pinnedRelease = if ($null -eq $pull -and $result.outcome -eq 'existing' -and -not $publicationBlocked) { + Get-BackportRelease $Repository $target + } else { $null } if ($null -eq $pull -and $result.outcome -eq 'existing' -and -not $publicationBlocked) { if (Test-Path -LiteralPath $directory) { throw 'Backport work directory already exists.' } $null = New-Item -ItemType Directory -Path $directory -Force @@ -454,6 +463,11 @@ function New-ExtRequestBackport { reference_time = $ReferenceTime.ToUniversalTime().ToString('o') result = $result } + if ($null -ne $pinnedRelease) { + Assert-BackportRelease $pinnedRelease $Repository -Current + $state.release = $pinnedRelease + $state.main_team = Get-BackportMainTeam $source + } $null = New-Item -ItemType Directory -Path (Split-Path -Parent $StatePath) -Force $state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM $issueLinks = if ($issues.Count -gt 0) { ($issues | ForEach-Object { $_.url }) -join ', ' } else { '(none)' } @@ -508,25 +522,7 @@ function Read-BackportPublicationIntent { return $null } -function Set-BackportPublicationIntent { - param([hashtable] $State, [hashtable] $Tracking, [string] $Sha, [string] $BotLogin) - $repository = $State.repository - $fresh = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" - if ($State.schema -eq 2) { Assert-BackportBatchObject $fresh $State $BotLogin } - else { Assert-BackportObject $fresh $State $BotLogin } - if ($null -ne (Read-BackportPublicationIntent $fresh.body)) { - throw 'Previous PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' - } - $marker = "" - $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ - body = "$marker`n`n$($fresh.body)" - } - $confirmed = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" - if ($State.schema -eq 2) { Assert-BackportBatchObject $confirmed $State $BotLogin } - else { Assert-BackportObject $confirmed $State $BotLogin } - if ((Read-BackportPublicationIntent $confirmed.body) -cne $Sha) { throw 'Tracking issue publication intent was not confirmed.' } - return $confirmed -} + function Assert-BackportObject { param([hashtable] $Object, [hashtable] $State, [string] $BotLogin) @@ -543,6 +539,12 @@ function Assert-BackportObject { $Object.head.repo.full_name -cne $State.repository)) { throw 'Existing backport PR has an unexpected source or target.' } + if ($Object.ContainsKey('head')) { + $expected = if ($State.ContainsKey('result')) { $State.result.commit } else { $null } + if ($Object.head.sha -cnotmatch '^[0-9a-f]{40}$' -or ($expected -and $Object.head.sha -cne $expected)) { + throw 'Existing backport PR does not contain the expected prepared commit.' + } + } } function Get-BackportIssueSummary { @@ -593,6 +595,10 @@ function Get-BackportDescription { function Get-BackportTitle { param([hashtable] $State) + if ($State.schema -eq 3) { return Get-BackportTitle $State.cohort } + if ($State.schema -eq 2 -and $State.request.ContainsKey('mode') -and $State.request.mode -eq 'single') { + return Get-BackportTitle $State.members[0] + } if ($State.schema -eq 2) { $version = ($State.request.target -split '/')[1] $team = $State.request.team -replace '[\r\n]', ' ' @@ -620,32 +626,7 @@ function Get-BackportBody { ) -join "`n" } -function Complete-BackportPull { - param([hashtable] $State, [hashtable] $Tracking, [hashtable] $Pull, [string] $BotLogin) - Assert-BackportObject $Tracking $State $BotLogin - Assert-BackportObject $Pull $State $BotLogin - $expectedCommit = if ($State.ContainsKey('result')) { $State.result.commit } else { $null } - if (-not $Pull.ContainsKey('head') -or $Pull.head.sha -cnotmatch '^[0-9a-f]{40}$' -or - ($expectedCommit -and $Pull.head.sha -cne $expectedCommit)) { - throw 'Existing backport PR does not contain the expected prepared commit.' - } - $repository = $State.repository - $labels = if ($State.ContainsKey('labels')) { $State.labels } else { $Tracking.labels } - Ensure-BackportLabels $repository $labels - foreach ($number in @($Tracking.number, $Pull.number)) { - $null = Invoke-BackportApi "repos/$repository/issues/$number/labels" -Method POST -Body @{ - labels = @($labels | ForEach-Object { $_.name }) - } - } - $body = $Tracking.body - $link = "Backport PR: $($Pull.html_url)" - if (-not $body.Contains($link)) { - $null = Invoke-BackportApi "repos/$repository/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "`n`n$body" } - } - Set-BackportProcessed $repository $State.source_pr - Write-BackportSummary "Main PR #$($State.source_pr): validated backport $($Pull.html_url); issue $($Tracking.html_url)." -} function Publish-ExtRequestBackport { [CmdletBinding()] @@ -653,133 +634,8 @@ function Publish-ExtRequestBackport { $state = if ($null -ne $PreparedState) { $PreparedState } else { Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 } - if ($state.schema -eq 2) { Publish-ExtRequestBackportBatch $StatePath $BotLogin; return } - if ($state.schema -ne 1 -or $BotLogin -cnotmatch '^[a-z0-9-]+\[bot\]$') { throw 'Invalid publication state or App identity.' } - Assert-BackportRepository $state.repository - Assert-BackportTarget $state.target - $repository = $state.repository - $source = Get-BackportSource $repository $state.source_pr - if ($source.merge_commit_sha -cne $state.source_sha) { throw 'Source merge identity changed.' } - $tracking = Get-BackportTracking $repository $state.source_pr - $batchException = $false - if ($state.ContainsKey('batch_key')) { - $records = @(Get-BackportBatchRecords $repository | Where-Object { $_.manifest.request.key -ceq $state.batch_key }) - if ($records.Count -ne 1) { throw 'Independent exception has no durable cohort.' } - Assert-BackportBatchObject $records[0].issue $records[0].manifest $BotLogin - $member = @($records[0].manifest.members | Where-Object { $_.source_pr -eq $state.source_pr }) - if ($member.Count -ne 1 -or $member[0].result.outcome -eq 'applied' -or - (ConvertTo-BackportCanonicalJson $member[0].result) -cne (ConvertTo-BackportCanonicalJson $state.result)) { - throw 'Independent exception differs from durable cohort.' - } - $batchException = $true - } - $clock = if ($state.ContainsKey('reference_time')) { [DateTimeOffset]$state.reference_time } else { [DateTimeOffset]::UtcNow } - if (-not $state.manual -and $null -eq $tracking -and -not $batchException -and - -not (Test-BackportAutomaticSource $source $clock)) { throw 'Automatic source eligibility changed before publication.' } - $state.issues = @(Get-BackportSourceIssues $repository $state.source_pr) - if (-not $state.manual -and $null -eq $tracking -and -not $batchException -and @($state.issues | Where-Object { - -not (Test-BackportCategory @($_.labels.nodes | ForEach-Object { $_.name })) - }).Count -gt 0) { throw 'A source issue is no longer automatically eligible.' } - $state.labels = @(Get-BackportLabels -Issues $state.issues -PrLabels $source.labels) - if ($null -ne $tracking) { Assert-BackportObject $tracking $state $BotLogin } - $pull = Get-BackportPull $repository $state.source_pr $state.target - if ($null -ne $pull) { - if ($null -eq $tracking) { throw 'Existing backport PR has no tracking issue; reconcile manually.' } - Complete-BackportPull $state $tracking $pull $BotLogin - return - } - $labelsOnSource = @($source.labels | ForEach-Object { $_.name }) - if ($null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body)) { - throw 'Previous PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' - } - if (@($labelsOnSource | Where-Object { $_ -match '^ext-backport-publishing:' }).Count -gt 0) { - throw 'Legacy PR publication is unconfirmed; operator reconciliation is required before retrying.' - } - if ($null -eq $tracking -and @($labelsOnSource | Where-Object { $_ -like 'ext-backport-*' }).Count -gt 0) { - throw 'Source has prior backport state but no tracking issue. Reconcile manually; do not create a duplicate.' - } - if ($state.result.outcome -notin @('applied', 'conflict', 'already-present')) { throw 'No publishable preparation result.' } - Ensure-BackportLabels $repository $state.labels - if ($null -eq $tracking) { - $cohorts = @(Get-BackportBatchRecords $repository | Where-Object { $state.source_pr -in $_.manifest.request.source_prs }) - if ($cohorts.Count -gt 0 -and (-not $batchException -or $cohorts[0].manifest.request.key -cne $state.batch_key)) { - throw 'Source acquired immutable batch membership; rediscover instead of creating a Task.' - } - # No pre-Task journal exists. Recheck observed objects, issue exactly one POST, - # and leave an absent/unconfirmed Task after a failed POST to the operator. - $tracking = Get-BackportTracking $repository $state.source_pr - $pull = Get-BackportPull $repository $state.source_pr $state.target - if ($null -ne $tracking) { - Assert-BackportObject $tracking $state $BotLogin - if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin; return } - } - elseif ($null -ne $pull) { throw 'Existing backport PR has no tracking issue; reconcile manually.' } - else { - $tracking = New-BackportTrackingTask $repository @{ - title = Get-BackportTitle -State $state; body = Get-BackportBody $state; type = 'Task' - labels = @($state.labels | ForEach-Object { $_.name }) - } - Assert-BackportObject $tracking $state $BotLogin - } - } - if ($null -ne (Read-BackportPublicationIntent $tracking.body)) { - throw 'Previous PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' - } - $retryConflict = $state.manual -and -not $batchException -and $state.result.outcome -eq 'applied' -and - $tracking.body.Contains('Preparation result: **conflict**.') - if (($tracking.body.Contains('Preparation result: **conflict**.') -and -not $retryConflict) -or - $state.result.outcome -eq 'conflict') { - Write-BackportSummary "Conflict for main PR #$($state.source_pr); manual backport tracked at $($tracking.html_url)." - return - } - if ($tracking.body.Contains('Preparation result: **already-present**.') -or $state.result.outcome -eq 'already-present') { - if ($tracking.state -ne 'closed') { - $null = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" -Method PATCH -Body @{ state = 'closed'; state_reason = 'completed' } - } - Write-BackportSummary "Main PR #$($state.source_pr) is already present; closed $($tracking.html_url) without an empty PR." - return - } - if ($tracking.state -eq 'closed') { - Write-BackportSummary "Main PR #$($state.source_pr): retained closed tracking issue $($tracking.html_url); manual attention is required." - return - } - $directory = Join-Path (Split-Path -Parent $StatePath) 'git' - $head = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() - $tree = (Invoke-BackportGit $directory @('rev-parse', 'HEAD^{tree}')).text.Trim() - $parent = (Invoke-BackportGit $directory @('rev-parse', 'HEAD^')).text.Trim() - if ($head -cne $state.result.commit -or $tree -cne $state.result.tree -or $parent -cne $state.result.target_sha) { - throw 'Prepared Git result does not match the publication state.' - } - $comparison = Invoke-BackportApi "repos/$repository/compare/$parent...$($state.target)" - if ($comparison.status -notin @('ahead', 'identical')) { throw 'Pinned release parent is no longer on the release branch.' } - $branch = Get-BackportBranch $state.source_pr $state.target - $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() - if ($remote) { - if (($remote -split '\s+')[0] -cne $head) { throw 'Existing backport branch differs; never overwrite it.' } - } - else { - $null = Invoke-BackportGit $directory @('push', 'origin', "$head`:refs/heads/$branch") - $readback = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() - if (($readback -split '\s+')[0] -cne $head) { throw 'Backport branch publication was not independently confirmed.' } - } - $pull = Get-BackportPull $repository $state.source_pr $state.target - if ($null -ne $pull) { Complete-BackportPull $state $tracking $pull $BotLogin; return } - if ($retryConflict) { - $null = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" -Method PATCH -Body @{ - body = Get-BackportBody $state - } - $tracking = Invoke-BackportApi "repos/$repository/issues/$($tracking.number)" - Assert-BackportObject $tracking $state $BotLogin - } - $tracking = Set-BackportPublicationIntent $state $tracking $head $BotLogin - $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ - title = $tracking.title; body = Get-BackportBody $state $tracking.number - head = $branch; base = $state.target; draft = $true - } - $pull = Invoke-BackportApi "repos/$repository/pulls/$($pull.number)" - Assert-BackportObject $pull $state $BotLogin - if ($pull.head.sha -cne $head) { throw 'Created backport PR does not contain the prepared commit.' } - Complete-BackportPull $state $tracking $pull $BotLogin + if ($state.schema -eq 3) { Publish-BackportMilestone $state $StatePath $BotLogin; return } + Publish-BackportLegacyOrPromote $state $StatePath $BotLogin } function ConvertTo-BackportCanonicalJson { @@ -806,12 +662,369 @@ function Get-BackportDigest { [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Text))).ToLowerInvariant() } +function Get-BackportRelease { + param([string] $Repository, [string] $Target) + Assert-BackportTarget $Target + $content = Invoke-BackportApi "repos/$Repository/contents/.github/AL-Go-Settings.json?ref=$([uri]::EscapeDataString($Target))" + if ($content.encoding -cne 'base64') { throw 'Target settings must be base64-encoded JSON content.' } + $settings = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($content.content)) | + ConvertFrom-Json -AsHashtable + $value = $settings.repoVersion + if ($null -eq $value -or $value -is [bool] -or $value -is [Collections.IDictionary] -or $value -is [array]) { + throw 'Target settings have no valid repoVersion.' + } + $version = [Convert]::ToString($value, [Globalization.CultureInfo]::InvariantCulture) + if ($version -cnotmatch '^([1-9][0-9]*)\.[0-9]+(?:\.[0-9]+)?$' -or + [long]($version.Split('.')[0]) -ne [long](($Target.Split('/')[1]).Split('.')[0])) { + throw 'Target repoVersion is invalid or its major version does not match the release branch.' + } + $title = "Version $version" + $milestones = @(Get-BackportApiPages "repos/$Repository/milestones?state=all" | Where-Object { $_.title -ceq $title }) + if ($milestones.Count -ne 1) { throw "Expected exactly one milestone titled '$title'; found $($milestones.Count)." } + if ($milestones[0].state -cne 'open') { throw "Milestone '$title' is closed; no publication is allowed." } + if ([long]$milestones[0].number -le 0) { throw 'Resolved milestone has an invalid identity.' } + return @{ target = $Target; version = $version; milestone_number = [long]$milestones[0].number; milestone_title = $title } +} + +function Assert-BackportRelease { + param([hashtable] $Release, [string] $Repository, [switch] $Current) + Assert-BackportTarget $Release.target + if ($Release.version -cnotmatch '^[1-9][0-9]*\.[0-9]+(?:\.[0-9]+)?$' -or + $Release.milestone_title -cne "Version $($Release.version)" -or + $Release.milestone_number -le 0 -or + [long]$Release.version.Split('.')[0] -ne [long]$Release.target.Split('/')[1].Split('.')[0]) { + throw 'Invalid pinned release/milestone identity.' + } + $milestone = Invoke-BackportApi "repos/$Repository/milestones/$($Release.milestone_number)" + if ($milestone.title -cne $Release.milestone_title -or $milestone.state -cne 'open') { + throw 'Pinned milestone is missing, changed or closed; it must never be reopened automatically.' + } + if ($Current -and (ConvertTo-BackportCanonicalJson (Get-BackportRelease $Repository $Release.target)) -cne + (ConvertTo-BackportCanonicalJson $Release)) { + throw 'Target version rollover detected; the prepared cohort remains pinned and must not silently move milestones.' + } +} + +function Get-BackportSharedTitle { + param([hashtable] $Release) + "[Backporting][$($Release.target)][Extensibility Requests][$($Release.milestone_title)]" +} + +function Get-BackportSharedMarker { + param([hashtable] $Release) + "" +} + +function Read-BackportSharedMarker { + param([string] $Body) + $found = [regex]::Matches($Body, '(?m)^\r?$') + if ($found.Count -gt 1 -or ($found.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:tracker:'))) { + throw 'Malformed or ambiguous shared tracker identity.' + } + if ($found.Count -eq 1) { + return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($found[0].Groups[1].Value)) | ConvertFrom-Json -AsHashtable + } + return $null +} + +function Assert-BackportSharedTracker { + param([hashtable] $Issue, [hashtable] $Release, [string] $BotLogin = '') + $identity = Read-BackportSharedMarker $Issue.body + if ($null -eq $identity -or (Get-BackportSharedMarker $identity) -cne (Get-BackportSharedMarker $Release) -or + $Issue.title -cne (Get-BackportSharedTitle $Release) -or $Issue.user.login -cnotmatch '^[a-z0-9-]+\[bot\]$' -or + ($BotLogin -and $Issue.user.login -cne $BotLogin) -or $null -eq $Issue.milestone -or + $Issue.milestone.number -ne $Release.milestone_number -or $Issue.milestone.title -cne $Release.milestone_title -or + ($Issue.ContainsKey('type') -and ($null -eq $Issue.type -or $Issue.type.name -cne 'Task')) -or + $Issue.ContainsKey('pull_request')) { throw 'Shared tracker identity, milestone or App author is unexpected.' } +} + +function Get-BackportSharedTracker { + param([string] $Repository, [hashtable] $Release, [string] $BotLogin) + $trackerCandidates = @(Get-BackportApiPages "repos/$Repository/issues?state=all&milestone=$($Release.milestone_number)" | + Where-Object { + if ($_.ContainsKey('pull_request')) { return $false } + $identity = Read-BackportSharedMarker $_.body + $_.title -ceq (Get-BackportSharedTitle $Release) -or + ($null -ne $identity -and $identity.target -ceq $Release.target) + }) + if ($trackerCandidates.Count -gt 1) { throw 'Duplicate shared milestone trackers; operator reconciliation is required.' } + if ($trackerCandidates.Count -eq 1) { + Assert-BackportSharedTracker $trackerCandidates[0] $Release $BotLogin + return $trackerCandidates[0] + } + return $null +} + +function Get-BackportMainTeam { + param([hashtable] $Source) + $teams = @($Source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team:*' } | Sort-Object -Unique) + if ($teams.Count -eq 0) { return 'Unassigned' } + if ($teams.Count -ne 1 -or $teams[0] -cnotmatch '^Team: \S[^\r\n]*$') { return 'Ambiguous' } + return $teams[0] +} + +function Assert-BackportJournalState { + param([hashtable] $State) + if ($State.schema -ne 3) { throw 'Unsupported journal schema.' } + Assert-BackportRepository $State.repository + Assert-BackportBatchManifest $State.cohort + if ($State.repository -cne $State.cohort.repository -or $State.release.target -cne $State.cohort.request.target -or + $State.release.milestone_title -cne "Version $($State.release.version)" -or $State.release.milestone_number -le 0) { + throw 'Journal release and cohort identities do not agree.' + } + if ($State.cohort.request.ContainsKey('release') -and + (Get-BackportSharedMarker $State.release) -cne (Get-BackportSharedMarker $State.cohort.request.release)) { + throw 'Pinned request release differs from the journal release.' + } + foreach ($member in $State.cohort.members) { + if (-not $member.ContainsKey('main_team') -or + ($member.main_team -notin @('Unassigned', 'Ambiguous') -and $member.main_team -cnotmatch '^Team: \S[^\r\n]*$') -or + ($State.cohort.request.mode -eq 'team' -and $member.main_team -cne $State.cohort.request.team)) { + throw 'Journal original main-PR team metadata is missing or inconsistent.' + } + } +} + +function Get-BackportJournalMarker { + param([hashtable] $State) + "" +} + +function Read-BackportJournalMarker { + param([string] $Body) + $found = [regex]::Matches($Body, '(?m)^\r?$') + if ($found.Count -gt 1 -or ($found.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:journal:'))) { + throw 'Malformed or ambiguous cohort journal.' + } + if ($found.Count -eq 0) { return $null } + $state = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($found[0].Groups[1].Value)) | + ConvertFrom-Json -AsHashtable -Depth 80 + Assert-BackportJournalState $state + return $state +} + +function Assert-BackportJournal { + param([hashtable] $Comment, [hashtable] $State, [string] $BotLogin) + $found = Read-BackportJournalMarker $Comment.body + if ($null -eq $found -or $Comment.user.login -cne $BotLogin -or + (Get-BackportJournalMarker $found) -cne (Get-BackportJournalMarker $State)) { + throw 'Cohort journal author or immutable manifest changed.' + } + $null = Read-BackportJournalPublication $Comment.body $State.cohort.head +} + +function Read-BackportJournalPublication { + param([string] $Body, [string] $Head) + $intent = Read-BackportPublicationIntent $Body + if ($null -ne $intent -and $intent -cne $Head) { throw 'Journal publication intent SHA differs from the cohort.' } + $markers = [regex]::Matches($Body, '(?m)^\r?$') + if ($markers.Count -gt 1 -or ($markers.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:journal-pr:'))) { + throw 'Malformed or conflicting journal PR identities.' + } + $complete = [regex]::Matches($Body, '(?m)^\r?$') + if ($complete.Count -gt 1 -or ($complete.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:journal-complete:'))) { + throw 'Malformed or duplicate journal completion marker.' + } + if ($markers.Count -eq 1) { + if ($markers[0].Groups[2].Value -cne $Head -or $null -eq $intent) { throw 'Journal PR proof or intent is invalid.' } + return [long]$markers[0].Groups[1].Value + } + return $null +} + +function Assert-BackportLegacyReplacement { + param([hashtable] $State, [hashtable] $Legacy, [string] $BotLogin) + if (-not $State.ContainsKey('legacy_issue') -or $State.legacy_issue -ne $Legacy.number) { + throw 'Legacy replacement tracking identity differs.' + } + $batch = Read-BackportBatchMarker $Legacy.body + if ($null -ne $batch) { + Assert-BackportBatchObject $Legacy $batch $BotLogin + $candidate = ConvertTo-BackportCanonicalJson $State.cohort | ConvertFrom-Json -AsHashtable -Depth 80 + for ($i = 0; $i -lt $candidate.members.Count; $i++) { + if ($i -lt $batch.members.Count -and -not $batch.members[$i].ContainsKey('main_team')) { + [void]$candidate.members[$i].Remove('main_team') + } + } + if ((Get-BackportBatchMarker $candidate) -cne (Get-BackportBatchMarker $batch)) { + throw 'Legacy replacement changed immutable membership, proofs or outcomes.' + } + } + else { + if ($State.cohort.request.mode -ne 'single' -or $State.cohort.members.Count -ne 1) { + throw 'Standalone legacy tracking cannot replace an entire team cohort.' + } + Assert-BackportObject $Legacy $State.cohort.members[0] $BotLogin + } + if ($null -ne (Read-BackportPublicationIntent $Legacy.body)) { throw 'Legacy publication is unconfirmed.' } +} + +function New-BackportLegacyJournal { + param([hashtable] $Cohort, [hashtable] $Release, [object[]] $Metadata, [long] $IssueNumber) + if ($IssueNumber -le 0) { throw 'Legacy cohort has no durable tracking issue identity.' } + $copy = ConvertTo-BackportCanonicalJson $Cohort | ConvertFrom-Json -AsHashtable -Depth 80 + for ($i = 0; $i -lt $copy.members.Count; $i++) { + if (-not $copy.members[$i].ContainsKey('main_team')) { + $copy.members[$i].main_team = Get-BackportMainTeam $Metadata[$i].source + } + } + $state = @{ + schema = 3; repository = $copy.repository; release = $Release; cohort = $copy; legacy_issue = $IssueNumber + } + Assert-BackportJournalState $state + return $state +} + +function Get-BackportJournalRecords { + param([string] $Repository, [hashtable] $Tracker) + $issues = if ($null -ne $Tracker) { @($Tracker) } + else { @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting") } + $records = @() + foreach ($issue in $issues) { + if ($issue.ContainsKey('pull_request')) { continue } + $release = Read-BackportSharedMarker $issue.body + if ($null -eq $release) { continue } + Assert-BackportSharedTracker $issue $release + foreach ($comment in @(Get-BackportApiPages "repos/$Repository/issues/$($issue.number)/comments")) { + $state = Read-BackportJournalMarker $comment.body + if ($null -eq $state) { continue } + Assert-BackportJournal $comment $state $issue.user.login + if ((Get-BackportSharedMarker $state.release) -cne (Get-BackportSharedMarker $release)) { + throw 'Cohort journal is registered on the wrong milestone tracker.' + } + $records += @{ issue = $issue; journal = $comment; journal_state = $state; manifest = $state.cohort; shared = $true } + } + } + $active = @{} + foreach ($record in @($records | Sort-Object { [long]$_.journal.id })) { + $state = $record.journal_state + $numbers = @($state.cohort.request.source_prs) + $prior = @($numbers | ForEach-Object { if ($active.ContainsKey([string]$_)) { $active[[string]$_] } } | + Sort-Object { $_.journal.id } -Unique) + if ($prior.Count -gt 0) { + if ($prior.Count -ne 1 -or $numbers.Count -ne 1 -or $state.cohort.request.mode -ne 'single' -or + -not $state.ContainsKey('supersedes') -or $state.supersedes -ne $prior[0].journal.id -or + $prior[0].manifest.request.mode -ne 'single' -or + $prior[0].manifest.members[0].result.outcome -ne 'conflict' -or + (Get-BackportSharedMarker $state.release) -cne (Get-BackportSharedMarker $prior[0].journal_state.release) -or + $state.cohort.members[0].source_sha -cne $prior[0].manifest.members[0].source_sha) { + throw 'Duplicate/conflicting journal source membership or unsafe conflict retry.' + } + } + elseif ($state.ContainsKey('supersedes')) { throw 'Conflict retry has no preceding cohort journal.' } + foreach ($number in $numbers) { $active[[string]$number] = $record } + } + @($active.Values | Sort-Object { $_.journal.id } -Unique) +} + +function Get-BackportTrackerLabels { + param([object[]] $Members, [object[]] $Existing = @()) + $labels = @{} + foreach ($label in @($Existing) + @($Members | ForEach-Object { $_.labels })) { + if ($label.name -notmatch '^(Team:|Ownership:)' -and + $label.name -notin @('ext-ready-to-implement', 'ext-backport-processed')) { $labels[$label.name] = $label } + } + $labels['ext-backporting'] = @{ name = 'ext-backporting'; color = '5319E7'; description = 'Tracks an extensibility backport.' } + @($labels.Values | Sort-Object name) +} + +function Get-BackportSharedBody { + param([hashtable] $Release) + @( + (Get-BackportSharedMarker $Release) + '' + "Backporting of extensibility requests to ``$($Release.target)`` for **$($Release.milestone_title)**." + '' + '' + '| Original issue | Original PR | Team | Backport PR |' + '| --- | --- | --- | --- |' + '' + '' + '*Auto-generated content*' + ) -join "`n" +} + +function ConvertTo-BackportTableText { + param([string] $Text) + $plain = [regex]::Replace($Text, '\s+', ' ').Trim() + # Entities stay literal text to Markdown, including URL, mention and protocol punctuation. + [regex]::Replace($plain, '[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]', { + param($match) + "&#$([int][char]$match.Value);" + }) +} + +function Merge-BackportTrackerRows { + param([string] $Body, [hashtable] $State, [hashtable] $Pull) + $pattern = '(?s)(\r?\n\| Original issue \| Original PR \| Team \| Backport PR \|\r?\n\| --- \| --- \| --- \| --- \|)(.*?)(\r?\n)' + $sections = [regex]::Matches($Body, $pattern) + if ($sections.Count -ne 1) { throw 'Shared tracker managed table is missing or ambiguous; do not overwrite it.' } + $rows = [ordered]@{} + foreach ($line in $sections[0].Groups[2].Value.Trim().Split("`n", [StringSplitOptions]::RemoveEmptyEntries)) { + $line = $line.TrimEnd("`r") + if ($line -cmatch '^(?\| .* )\| (?)$') { + $line = "$($Matches.cells)$($Matches.marker) |" + } + if ($line -cnotmatch '^\| .* \|$' -or + [regex]::Matches($line, '\|').Count -ne 5) { + throw 'Unrecognized managed tracker row; preserve it for operator reconciliation.' + } + $key = $Matches[1] + if ($rows.Contains($key)) { throw 'Duplicate managed tracker rows.' } + $rows[$key] = $line.TrimEnd("`r") + } + foreach ($member in $State.cohort.members) { + $outcome = switch ($member.result.outcome) { + 'conflict' { 'Conflict - manual resolution needed' } + 'already-present' { 'Already present' } + 'applied' { if ($null -ne $Pull) { "[#$($Pull.number)]($($Pull.html_url))" } else { 'Pending publication' } } + } + $originals = @($member.issues | Sort-Object url -Unique) + if ($originals.Count -eq 0) { $originals = ,$null } + foreach ($issue in $originals) { + $url = if ($null -eq $issue) { '' } else { $issue.url } + $cell = if ($null -eq $issue) { 'No linked issue' } + else { "[$($issue.url)]($($issue.url)) - $(ConvertTo-BackportTableText (Get-BackportIssueSummary $issue))" } + $key = Get-BackportDigest "$($member.source_pr)|$url" + $pr = "https://github.com/$($State.repository)/pull/$($member.source_pr)" + $rows[$key] = "| $cell | [#$($member.source_pr)]($pr) | $(ConvertTo-BackportTableText $member.main_team) | $outcome |" + } + } + $replacement = $sections[0].Groups[1].Value + "`n" + ($rows.Values -join "`n") + $sections[0].Groups[3].Value + $merged = $Body.Substring(0, $sections[0].Index) + $replacement + $Body.Substring($sections[0].Index + $sections[0].Length) + if ($merged.Length -gt 65000) { throw 'Shared tracker table exceeds GitHub body limits; never truncate earlier cohorts.' } + return $merged +} + +function Assert-BackportTrackerCapacity { + param([string] $Body, [hashtable] $State, [hashtable] $Pull) + if ($null -eq $Pull) { + # Reserve the complete eventual PR link, not the shorter pending text. + $Pull = @{ + number = [long]::MaxValue + html_url = "https://github.com/$($State.repository)/pull/$([long]::MaxValue)" + } + } + $null = Merge-BackportTrackerRows $Body $State $Pull +} + function Assert-BackportBatchRequest { param([hashtable] $Request) foreach ($name in @('key', 'mode', 'source_prs', 'sources', 'target', 'team', 'batch_id', 'reference_time', 'manual')) { if (-not $Request.ContainsKey($name)) { throw "Missing batch request field $name." } } Assert-BackportTarget $Request.target + if ($Request.ContainsKey('retry_conflict') -and $Request.retry_conflict -isnot [bool]) { + throw 'Explicit conflict retry intent must be boolean.' + } + if ($Request.ContainsKey('tracking_issue') -or $Request.ContainsKey('state_digest') -or $Request.ContainsKey('journal_id')) { + if (-not $Request.ContainsKey('tracking_issue') -or -not $Request.ContainsKey('state_digest') -or + [string]$Request.tracking_issue -cnotmatch '^[1-9][0-9]*$' -or + $Request.state_digest -cnotmatch '^[0-9a-f]{64}$' -or + ($Request.ContainsKey('journal_id') -and [string]$Request.journal_id -cnotmatch '^[1-9][0-9]*$')) { + throw 'Invalid immutable cohort registry reference.' + } + } if ($Request.key -cnotmatch '^(team-[0-9a-f]{32}|single-[1-9][0-9]*)$' -or $Request.mode -notin @('team', 'single') -or $Request.batch_id -cnotmatch '^[0-9]+$' -or $Request.manual -isnot [bool] -or @@ -836,14 +1049,22 @@ function Assert-BackportBatchRequest { } else { if ($Request.team -cnotmatch '^Team: \S[^\r\n]*$' -or $Request.manual) { throw 'Invalid team request.' } - $identity = @{} + $Request + $identity = Get-BackportRequestCore $Request [void]$identity.Remove('key') - [void]$identity.Remove('manifest') $expected = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $identity)).Substring(0, 32) if ($Request.key -cne $expected) { throw 'Batch membership or identity has changed.' } } } +function Get-BackportRequestCore { + param([hashtable] $Request) + $core = @{} + $Request + foreach ($name in @('manifest', 'journal_state', 'retry_conflict', 'tracking_issue', 'journal_id', 'state_digest')) { + [void]$core.Remove($name) + } + return $core +} + function Get-BackportBatchMarker { param([hashtable] $Manifest) $json = ConvertTo-BackportCanonicalJson $Manifest @@ -869,7 +1090,9 @@ function Assert-BackportBatchManifest { if ($Manifest.schema -ne 2) { throw 'Unsupported batch manifest schema.' } Assert-BackportRepository $Manifest.repository Assert-BackportBatchRequest $Manifest.request - if ($Manifest.request.mode -ne 'team' -or $Manifest.request.ContainsKey('manifest') -or + if ($Manifest.request.mode -notin @('team', 'single') -or + (ConvertTo-BackportCanonicalJson $Manifest.request) -cne + (ConvertTo-BackportCanonicalJson (Get-BackportRequestCore $Manifest.request)) -or @($Manifest.members).Count -ne @($Manifest.request.source_prs).Count -or $Manifest.target_sha -cnotmatch '^[0-9a-f]{40}$') { throw 'Invalid complete batch manifest.' } $tip = $Manifest.target_sha @@ -895,16 +1118,32 @@ function Assert-BackportBatchManifest { function Get-BackportBatchRecords { param([string] $Repository) $mapping = @{} + $journals = @(Get-BackportJournalRecords $Repository) foreach ($issue in @(Get-BackportApiPages "repos/$Repository/issues?state=all&labels=ext-backporting")) { if ($issue.ContainsKey('pull_request')) { continue } $manifest = Read-BackportBatchMarker $issue.body if ($null -eq $manifest) { continue } + $replacement = @($journals | Where-Object { + $_.journal_state.ContainsKey('legacy_issue') -and $_.journal_state.legacy_issue -eq $issue.number + }) + if ($replacement.Count -gt 1) { throw 'Multiple journals replace one legacy cohort.' } + if ($replacement.Count -eq 1) { + Assert-BackportLegacyReplacement $replacement[0].journal_state $issue $replacement[0].issue.user.login + continue + } foreach ($number in $manifest.request.source_prs) { if ($mapping.ContainsKey([string]$number)) { throw "Multiple batch mappings for source #$number." } $mapping[[string]$number] = $manifest.request.key } @{ issue = $issue; manifest = $manifest } } + foreach ($record in $journals) { + foreach ($number in $record.manifest.request.source_prs) { + if ($mapping.ContainsKey([string]$number)) { throw "Multiple batch mappings for source #$number." } + $mapping[[string]$number] = $record.manifest.request.key + } + $record + } } function Get-BackportBatchPull { @@ -929,6 +1168,23 @@ function Get-BackportSourceTeam { return $teams[0] } +function Get-BackportResumeRequest { + param([hashtable] $Record, [switch] $Manual) + $row = @{} + $Record.manifest.request + $row.tracking_issue = $Record.issue.number + $state = $Record.manifest + if ($Record.ContainsKey('shared')) { + $row.journal_id = $Record.journal.id + $state = $Record.journal_state + if ($Manual -and $row.mode -eq 'single' -and $state.cohort.members[0].result.outcome -eq 'conflict') { + $row.retry_conflict = $true + } + } + $row.state_digest = Get-BackportDigest (ConvertTo-BackportCanonicalJson $state) + Assert-BackportBatchRequest $row + return $row +} + function Find-ExtRequestBackportBatches { [CmdletBinding()] param([string] $Repository, [string] $SourcePr = '', [string] $TargetOverride = '', [string] $BatchId) @@ -944,9 +1200,7 @@ function Find-ExtRequestBackportBatches { if ($SourcePr) { if ($SourcePr -cnotmatch '^[1-9][0-9]{0,9}$') { throw 'source_pr must be a positive PR number.' } if ($mapped.ContainsKey($SourcePr)) { - $row = @{} + $mapped[$SourcePr].manifest.request - $row.manifest = $mapped[$SourcePr].manifest - return $row + return Get-BackportResumeRequest $mapped[$SourcePr] -Manual } } $candidates = @(Find-ExtRequestBackport $Repository -SourcePr $SourcePr -TargetOverride $TargetOverride -ReferenceTime ([DateTimeOffset]$clock)) @@ -955,6 +1209,14 @@ function Find-ExtRequestBackportBatches { if (-not $SourcePr) { foreach ($record in $records) { $unfinished = $false + if ($record.ContainsKey('shared')) { + $unfinished = -not $record.journal.body.Contains('') + if ($unfinished) { + $row = Get-BackportResumeRequest $record + $requests[$row.key] = $row + } + continue + } foreach ($member in $record.manifest.members) { if ($member.result.outcome -eq 'applied') { $original = Get-BackportSource $Repository $member.source_pr @@ -968,8 +1230,7 @@ function Find-ExtRequestBackportBatches { } } if ($unfinished) { - $row = @{} + $record.manifest.request - $row.manifest = $record.manifest + $row = Get-BackportResumeRequest $record $requests[$row.key] = $row } } @@ -1000,6 +1261,7 @@ function Find-ExtRequestBackportBatches { foreach ($row in $groups.Values) { $row.sources = @($row.sources | Sort-Object { [DateTimeOffset]$_.merged_at }, number) $row.source_prs = @($row.sources | ForEach-Object { $_.number }) + $row.release = Get-BackportRelease $Repository $row.target $row.concurrency_key = if ($row.team) { 'team-' + (Get-BackportDigest "$($row.team)|$($row.target)").Substring(0, 24) } else { "single-$($row.source_prs[0])" } @@ -1075,30 +1337,66 @@ function New-ExtRequestBackportBatch { $resume = $overlap.Count -eq 1 if ($resume) { $stored = $overlap[0].manifest - $identity = @{} + $request - [void]$identity.Remove('manifest') + $identity = Get-BackportRequestCore $request if ($stored.request.key -cne $request.key -or (ConvertTo-BackportCanonicalJson $stored.request) -cne (ConvertTo-BackportCanonicalJson $identity)) { throw 'Request differs from the existing immutable batch; rediscover it.' } + if ($request.ContainsKey('tracking_issue')) { + $current = Get-BackportResumeRequest $overlap[0] + if ($current.tracking_issue -ne $request.tracking_issue -or $current.state_digest -cne $request.state_digest -or + $current.ContainsKey('journal_id') -ne $request.ContainsKey('journal_id') -or + ($request.ContainsKey('journal_id') -and $current.journal_id -ne $request.journal_id)) { + throw 'Immutable cohort registry reference changed; rediscover it.' + } + } $request = $stored.request } - elseif ($request.ContainsKey('manifest')) { throw 'Previously discovered batch tracking issue is missing.' } + elseif ($request.ContainsKey('manifest') -or $request.ContainsKey('journal_state') -or $request.ContainsKey('tracking_issue')) { + throw 'Previously discovered batch tracking issue or journal is missing.' + } $metadata = @(Assert-BackportBatchSources $request $Repository -Resume:$resume) + $shared = $resume -and $overlap[0].ContainsKey('shared') + $retryConflict = $false + # The stored request is immutable; retry intent belongs to the invocation, not its identity. + if ($shared) { + $inputRow = ConvertFrom-Json $RequestJson -AsHashtable -Depth 80 + $retryConflict = $inputRow.ContainsKey('retry_conflict') -and $inputRow.retry_conflict -and + $stored.request.mode -eq 'single' -and $stored.members[0].result.outcome -eq 'conflict' + } $root = Split-Path -Parent $StatePath $null = New-Item -ItemType Directory -Path $root -Force - if ($request.mode -eq 'single') { + if ($request.mode -eq 'single' -and -not $shared -and $null -ne (Get-BackportTracking $Repository $request.source_prs[0])) { + if ($request.ContainsKey('release')) { Assert-BackportRelease $request.release $Repository -Current } New-ExtRequestBackport $Repository $request.source_prs[0] $request.target $request.manual $StatePath ` -ReferenceTime ([DateTimeOffset]$request.reference_time) + if ($request.ContainsKey('release')) { Assert-BackportRelease $request.release $Repository -Current } $single = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 if ($single.schema -eq 1 -and $single.source_sha -cne $request.sources[0].sha) { throw 'Independent source proof changed during preparation.' } + if ($request.ContainsKey('release') -and $single.ContainsKey('release') -and + (Get-BackportSharedMarker $request.release) -cne (Get-BackportSharedMarker $single.release)) { + throw 'Independent preparation changed the discovered release; rediscover it.' + } return } - if ($resume -and ($null -ne (Get-BackportBatchPull $Repository $request.key) -or - $null -ne (Read-BackportPublicationIntent $overlap[0].issue.body))) { - $stored | ConvertTo-Json -Depth 60 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM + $recordBody = if ($shared) { $overlap[0].journal.body } elseif ($resume) { $overlap[0].issue.body } else { '' } + $existingPull = if ($resume) { Get-BackportBatchPull $Repository $request.key } else { $null } + $intent = Read-BackportPublicationIntent $recordBody + $release = if ($shared) { $overlap[0].journal_state.release } + elseif ($request.ContainsKey('release')) { $request.release } + elseif (-not $resume -or ($null -eq $existingPull -and $null -eq $intent)) { Get-BackportRelease $Repository $request.target } + else { $null } + if ($null -ne $release) { Assert-BackportRelease $release $Repository -Current:(-not $resume) } + if ($resume -and ($null -ne $existingPull -or $null -ne $intent -or + (-not $retryConflict -and @($stored.members | Where-Object { $_.result.outcome -eq 'applied' }).Count -eq 0))) { + $output = if ($shared) { $overlap[0].journal_state } else { $stored } + if (-not $shared -and $null -eq $existingPull -and $null -eq $intent) { + Assert-BackportRelease $release $Repository -Current + $output = New-BackportLegacyJournal $stored $release $metadata $overlap[0].issue.number + } + $output | ConvertTo-Json -Depth 80 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM Write-BackportBatchSummary $stored 'prepared' return } @@ -1111,7 +1409,7 @@ function New-ExtRequestBackportBatch { } $directory = Join-Path $root 'git' Initialize-BackportBatchGit $directory $Repository - $targetSha = if ($resume) { $stored.target_sha } + $targetSha = if ($resume -and -not $retryConflict) { $stored.target_sha } else { (Invoke-BackportApi "repos/$Repository/git/ref/heads/$($request.target)").object.sha } $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/bc-extrequest-backport/$($request.key)")).text.Trim() if ($remote -and -not $resume) { throw 'Untracked batch branch exists; reconcile manually.' } @@ -1132,6 +1430,7 @@ function New-ExtRequestBackportBatch { schema = 1; repository = $Repository; source_pr = $proof.number; source_sha = $proof.sha target = $request.target; manual = $request.manual; reference_time = $request.reference_time source_title = $metadata[$i].source.title; issues = $metadata[$i].issues + main_team = Get-BackportMainTeam $metadata[$i].source labels = @(Get-BackportLabels $metadata[$i].issues $metadata[$i].source.labels); result = $result } if ($result.outcome -eq 'applied') { $tip = $result.commit } @@ -1140,7 +1439,7 @@ function New-ExtRequestBackportBatch { schema = 2; repository = $Repository; request = $request; members = $members; target_sha = $targetSha head = $tip; tree = (Invoke-BackportGit $directory @('rev-parse', "$tip`^{tree}")).text.Trim() } - if ($resume) { + if ($resume -and -not $retryConflict) { # Membership, outcomes, source proofs and commit chain cannot change during recovery. foreach ($index in 0..($members.Count - 1)) { if ((ConvertTo-BackportCanonicalJson $members[$index].result) -cne @@ -1153,7 +1452,16 @@ function New-ExtRequestBackportBatch { if (@($members | Where-Object { $_.result.outcome -eq 'applied' }).Count -gt 0) { $null = Get-BackportBatchBody $manifest 9999999999 } - $manifest | ConvertTo-Json -Depth 60 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM + $output = $manifest + if (-not $resume -or $shared) { + $output = @{ schema = 3; repository = $Repository; release = $release; cohort = $manifest } + if ($retryConflict) { $output.supersedes = $overlap[0].journal.id } + elseif ($shared) { $output = $overlap[0].journal_state } + Assert-BackportJournalState $output + } + else { $output = New-BackportLegacyJournal $manifest $release $metadata $overlap[0].issue.number } + if (-not $shared) { Assert-BackportRelease $release $Repository -Current } + $output | ConvertTo-Json -Depth 80 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM Write-BackportBatchSummary $manifest 'prepared' } @@ -1191,7 +1499,9 @@ function Write-BackportBatchSummary { $issues = if ($member.issues.Count -gt 0) { (@($member.issues | ForEach-Object { $_.url } | Sort-Object -Unique)) -join ', ' } else { '(none)' } - Write-BackportSummary "- Main PR https://github.com/$($Manifest.repository)/pull/$($member.source_pr); $($Manifest.request.team); target ``$($Manifest.request.target)``; source ``$($member.source_sha)``; outcome **$($member.result.outcome)**; original issues: $issues" + $team = if ($member.ContainsKey('main_team')) { $member.main_team } + elseif ($Manifest.request.team) { $Manifest.request.team } else { 'Unassigned' } + Write-BackportSummary "- Main PR https://github.com/$($Manifest.repository)/pull/$($member.source_pr); $team; target ``$($Manifest.request.target)``; source ``$($member.source_sha)``; outcome **$($member.result.outcome)**; original issues: $issues" } } } @@ -1211,48 +1521,9 @@ function Assert-BackportBatchObject { } } -function Complete-BackportBatchPull { - param([hashtable] $Manifest, [hashtable] $Tracking, [hashtable] $Pull, [string] $BotLogin) - Assert-BackportBatchObject $Tracking $Manifest $BotLogin - Assert-BackportBatchObject $Pull $Manifest $BotLogin - if (-not $Pull.ContainsKey('head')) { throw 'The batch result is not a validated backport PR.' } - # Reconciliation verifies every source identity, including already processed members. - $null = @(Assert-BackportBatchSources $Manifest.request $Manifest.repository -Resume) - $labels = @(Get-BackportBatchLabels $Manifest) - Ensure-BackportLabels $Manifest.repository $labels - foreach ($number in @($Tracking.number, $Pull.number)) { - $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$number/labels" -Method POST -Body @{ - labels = @($labels | ForEach-Object { $_.name }) - } - } - $link = "Backport PR: $($Pull.html_url)" - if (-not $Tracking.body.Contains($link)) { - $null = Invoke-BackportApi "repos/$($Manifest.repository)/issues/$($Tracking.number)" -Method PATCH -Body @{ body = "`n`n$($Tracking.body)" } - } - foreach ($member in $Manifest.members) { - if ($member.result.outcome -ne 'applied') { continue } - Set-BackportProcessed $Manifest.repository $member.source_pr - } - Write-BackportBatchSummary $Manifest 'validated PR' "$($Tracking.html_url) $($Pull.html_url)" -} -function Publish-BackportBatchExceptions { - param([hashtable] $Manifest, [string] $BotLogin, [switch] $Durable, [switch] $ContinueOnFailure) - foreach ($member in $Manifest.members) { - if ($member.result.outcome -eq 'applied') { continue } - # Preserve the proven cumulative-tip outcome. A release-only reapplication can - # succeed while conflicting with the clean team changes, so never re-prepare here. - $single = @{} + $member - if ($Durable) { $single.batch_key = $Manifest.request.key } - try { Publish-ExtRequestBackport -BotLogin $BotLogin -PreparedState $single } - catch { - if (-not $ContinueOnFailure) { throw } - $failure = "Independent source #$($member.source_pr): $($_.Exception.Message)" - Write-Warning $failure - $failure - } - } -} + + function Assert-BackportPreparedBatch { param([hashtable] $Manifest, [string] $Directory) @@ -1277,102 +1548,394 @@ function Publish-ExtRequestBackportBatch { [CmdletBinding()] param([string] $StatePath, [string] $BotLogin) $manifest = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 - if ($manifest.schema -eq 1) { Publish-ExtRequestBackport $StatePath $BotLogin; return } + if ($manifest.schema -eq 3) { Publish-BackportMilestone $manifest $StatePath $BotLogin; return } + Publish-BackportLegacyOrPromote $manifest $StatePath $BotLogin +} + +function Get-BackportMilestonePullBody { + param([hashtable] $State, [long] $TrackerNumber) + $members = @($State.cohort.members | Where-Object { $_.result.outcome -eq 'applied' }) + $team = if ($State.cohort.request.mode -eq 'team') { $State.cohort.request.team } else { '' } + $body = @((Get-BackportJournalMarker $State), '', + (Get-BackportDescription $State.repository $State.release.target @($members | ForEach-Object { $_.issues }) ` + @($members | ForEach-Object { $_.source_pr }) -Team $team -TrackingNumber $TrackerNumber)) -join "`n" + if ($body.Length -gt 65000) { throw 'Cohort proof exceeds GitHub body limits; never truncate membership.' } + return $body +} + +function Assert-BackportMilestonePull { + param([hashtable] $Pull, [hashtable] $State, [string] $BotLogin) + $found = Read-BackportJournalMarker $Pull.body + if ($null -eq $found -or (Get-BackportJournalMarker $found) -cne (Get-BackportJournalMarker $State) -or + $Pull.user.login -cne $BotLogin -or $Pull.head.sha -cne $State.cohort.head -or + $Pull.head.ref -cne "bc-extrequest-backport/$($State.cohort.request.key)" -or + $Pull.head.repo.full_name -cne $State.repository -or $Pull.base.repo.full_name -cne $State.repository -or + $Pull.base.ref -cne $State.release.target) { throw 'Backport PR author, branch, milestone cohort or SHA proof changed.' } + if ($null -ne $Pull.milestone -and $Pull.milestone.number -ne $State.release.milestone_number) { + throw 'Backport PR has a different milestone; do not overwrite human changes.' + } +} + +function Update-BackportJournal { + param([hashtable] $State, [hashtable] $Comment, [string] $Addition, [string] $BotLogin) + $path = "repos/$($State.repository)/issues/comments/$($Comment.id)" + $fresh = Invoke-BackportApi $path + Assert-BackportJournal $fresh $State $BotLogin + if (-not $fresh.body.Contains($Addition)) { + $body = "$($fresh.body)`n$Addition" + if ($body.Length -gt 65000) { throw 'Cohort journal exceeds GitHub body limits.' } + $candidate = @{} + $fresh + $candidate.body = $body + Assert-BackportJournal $candidate $State $BotLogin + $null = Invoke-BackportApi $path -Method PATCH -Body @{ body = $body } + } + $confirmed = Invoke-BackportApi $path + Assert-BackportJournal $confirmed $State $BotLogin + if (-not $confirmed.body.Contains($Addition)) { throw 'Journal update was not independently confirmed.' } + return $confirmed +} + +function Assert-BackportNewCohort { + param([hashtable] $State, [string] $BotLogin, [bool] $LegacyBatch) + $repository = $State.repository + $cohort = $State.cohort + $observed = @(Get-BackportJournalRecords $repository) + $exact = @($observed | Where-Object { + (Get-BackportJournalMarker $_.journal_state) -ceq (Get-BackportJournalMarker $State) + }) + if ($exact.Count -eq 1) { + Assert-BackportSharedTracker $exact[0].issue $State.release $BotLogin + Assert-BackportJournal $exact[0].journal $State $BotLogin + return $exact[0] + } + foreach ($other in @($observed | Where-Object { + @($_.manifest.request.source_prs | Where-Object { $_ -in $cohort.request.source_prs }).Count -gt 0 + })) { + if (-not $State.ContainsKey('supersedes') -or $State.supersedes -ne $other.journal.id -or + $cohort.request.mode -ne 'single' -or $other.manifest.request.mode -ne 'single' -or + $other.manifest.members[0].result.outcome -ne 'conflict' -or + $other.manifest.members[0].source_sha -cne $cohort.members[0].source_sha -or + (Get-BackportSharedMarker $other.journal_state.release) -cne (Get-BackportSharedMarker $State.release)) { + throw 'Cohort membership changed before publication.' + } + } + foreach ($member in $cohort.members) { + $source = Get-BackportSource $repository $member.source_pr + if ($source.merge_commit_sha -cne $member.source_sha) { throw 'Source SHA changed before publication.' } + if ('ext-backport-processed' -in @($source.labels | ForEach-Object { $_.name })) { + throw 'Processed source has no matching journal; fail closed.' + } + $tracking = if (-not $LegacyBatch) { Get-BackportTracking $repository $member.source_pr } else { $null } + if ($null -ne $tracking -and (-not $State.ContainsKey('legacy_issue') -or $tracking.number -ne $State.legacy_issue)) { + throw 'Source acquired a legacy tracking identity; do not duplicate it.' + } + if ($null -ne (Get-BackportPull $repository $member.source_pr $member.target)) { + throw 'An existing standalone backport PR must be reconciled through its legacy identity, not duplicated.' + } + } + $oldBatches = @(Get-BackportBatchRecords $repository | Where-Object { + -not $_.ContainsKey('shared') -and + @($_.manifest.request.source_prs | Where-Object { $_ -in $cohort.request.source_prs }).Count -gt 0 + }) + if (@($oldBatches | Where-Object { + -not $State.ContainsKey('legacy_issue') -or $_.issue.number -ne $State.legacy_issue + }).Count -gt 0) { throw 'Source acquired a legacy batch identity; do not duplicate it.' } + if ($null -ne (Get-BackportBatchPull $repository $cohort.request.key)) { + throw 'Unregistered PR appeared before journal creation.' + } +} + +function Publish-BackportMilestone { + param([hashtable] $State, [string] $StatePath, [string] $BotLogin) if ($BotLogin -cnotmatch '^[a-z0-9-]+\[bot\]$') { throw 'Invalid App identity.' } - Assert-BackportBatchManifest $manifest - $null = Get-BackportBatchBody $manifest 9999999999 - $request = $manifest.request - $repository = $manifest.repository - $records = @(Get-BackportBatchRecords $repository) + Assert-BackportJournalState $State + $repository = $State.repository + $cohort = $State.cohort + $release = $State.release + $records = @(Get-BackportJournalRecords $repository) $overlap = @($records | Where-Object { - @($_.manifest.request.source_prs | Where-Object { $_ -in $request.source_prs }).Count -gt 0 + @($_.manifest.request.source_prs | Where-Object { $_ -in $cohort.request.source_prs }).Count -gt 0 }) - if ($overlap.Count -gt 1) { throw 'Multiple durable batch mappings.' } - $tracking = if ($overlap.Count -eq 1) { $overlap[0].issue } else { $null } - if ($null -ne $tracking) { Assert-BackportBatchObject $tracking $manifest $BotLogin } - $metadata = @(Assert-BackportBatchSources $request $repository -Resume:($null -ne $tracking)) - $directory = Join-Path (Split-Path -Parent $StatePath) 'git' - $applied = @($manifest.members | Where-Object { $_.result.outcome -eq 'applied' }) - $exceptionFailures = @() - $pull = Get-BackportBatchPull $repository $request.key - if ($null -ne $pull -and $null -eq $tracking) { throw 'Batch PR has no tracking issue.' } + if ($overlap.Count -gt 1) { throw 'Prepared cohort overlaps multiple immutable journal records.' } + $record = if ($overlap.Count -eq 1) { $overlap[0] } else { $null } + $same = $null -ne $record -and (Get-BackportJournalMarker $record.journal_state) -ceq (Get-BackportJournalMarker $State) + $revision = $null -ne $record -and $State.ContainsKey('supersedes') -and $State.supersedes -eq $record.journal.id -and + $cohort.request.mode -eq 'single' -and $record.manifest.request.mode -eq 'single' -and + $record.manifest.members[0].result.outcome -eq 'conflict' -and + $cohort.members[0].source_sha -ceq $record.manifest.members[0].source_sha -and + (Get-BackportSharedMarker $release) -ceq (Get-BackportSharedMarker $record.journal_state.release) + if ($null -ne $record -and -not $same -and -not $revision) { throw 'Source membership already belongs to another immutable cohort.' } + $legacyBatch = $false + if ($State.ContainsKey('legacy_issue')) { + $legacy = Invoke-BackportApi "repos/$repository/issues/$($State.legacy_issue)" + Assert-BackportLegacyReplacement $State $legacy $BotLogin + $legacyBatch = $null -ne (Read-BackportBatchMarker $legacy.body) + } + $null = @(Assert-BackportBatchSources $cohort.request $repository -Resume:($null -ne $record -or $State.ContainsKey('legacy_issue'))) + Assert-BackportRelease $release $repository + $pull = Get-BackportBatchPull $repository $cohort.request.key + $comment = if ($same) { $record.journal } else { $null } + if ($null -ne $comment) { + Assert-BackportJournal $comment $State $BotLogin + $number = Read-BackportJournalPublication $comment.body $cohort.head + if ($null -ne $number) { + if ($null -ne $pull -and $pull.number -ne $number) { throw 'Branch lookup conflicts with the confirmed journal PR.' } + $pull = Invoke-BackportApi "repos/$repository/pulls/$number" + if ($null -eq $pull -or $pull.number -ne $number) { throw 'Confirmed journal PR is missing; do not repeat POST.' } + } + } if ($null -ne $pull) { - Assert-BackportBatchObject $pull $manifest $BotLogin - $exceptionFailures = @(Publish-BackportBatchExceptions $manifest $BotLogin -Durable -ContinueOnFailure) - Complete-BackportBatchPull $manifest $tracking $pull $BotLogin - if ($exceptionFailures.Count -gt 0) { throw ($exceptionFailures -join "`n") } - return + if (-not $same) { throw 'Backport PR has no matching immutable journal.' } + Assert-BackportMilestonePull $pull $State $BotLogin + if ($null -eq (Read-BackportPublicationIntent $comment.body)) { + throw 'Existing PR has no confirmed journal publication intent; reconcile manually.' + } } - if ($null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body)) { - throw 'Previous batch PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' + if ($null -eq $pull) { + if ($null -ne $comment -and $null -ne (Read-BackportPublicationIntent $comment.body)) { + throw 'Previous PR creation is unconfirmed in the cohort journal; do not repeat POST.' + } + $applied = @($cohort.members | Where-Object { $_.result.outcome -eq 'applied' }) + if (-not $same -or $applied.Count -gt 0) { Assert-BackportRelease $release $repository -Current } + if ($applied.Count -gt 0 -or (-not $same -and -not $legacyBatch)) { + Assert-BackportPreparedBatch $cohort (Join-Path (Split-Path -Parent $StatePath) 'git') + } } - if (@($metadata | ForEach-Object { $_.source.labels } | Where-Object { - $_.name -match '^ext-backport-publishing:' - }).Count -gt 0) { throw 'Legacy batch PR publication is unconfirmed; operator reconciliation is required before retrying.' } - Assert-BackportPreparedBatch $manifest $directory - if ($applied.Count -gt 0) { - $labels = @(Get-BackportBatchLabels $manifest) + $null = Get-BackportMilestonePullBody $State 9999999999 + if (-not $same) { $null = Assert-BackportNewCohort $State $BotLogin $legacyBatch } + $tracker = Get-BackportSharedTracker $repository $release $BotLogin + if ($null -eq $tracker) { + $tracker = Get-BackportSharedTracker $repository $release $BotLogin + } + $capacityBody = if ($null -eq $tracker) { Get-BackportSharedBody $release } else { $tracker.body } + Assert-BackportTrackerCapacity $capacityBody $State $pull + if (-not $same) { Assert-BackportRelease $release $repository -Current } + if ($null -eq $tracker) { + $labels = @(Get-BackportTrackerLabels $cohort.members) Ensure-BackportLabels $repository $labels - if ($null -eq $tracking) { - $observed = @(Get-BackportBatchRecords $repository | Where-Object { - @($_.manifest.request.source_prs | Where-Object { $_ -in $request.source_prs }).Count -gt 0 + $tracker = New-BackportTrackingTask $repository @{ + title = Get-BackportSharedTitle $release; body = Get-BackportSharedBody $release + type = 'Task'; milestone = $release.milestone_number; labels = @($labels | ForEach-Object { $_.name }) + } + Assert-BackportSharedTracker $tracker $release $BotLogin + } + Assert-BackportSharedTracker $tracker $release $BotLogin + if ($tracker.state -eq 'closed') { + Assert-BackportRelease $release $repository -Current:(-not $same) + $null = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)" -Method PATCH -Body @{ state = 'open' } + $tracker = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)" + Assert-BackportSharedTracker $tracker $release $BotLogin + if ($tracker.state -cne 'open') { throw 'Shared tracker reopen was not confirmed.' } + } + if ($null -eq $comment) { + $observed = @(Get-BackportJournalRecords $repository -Tracker $tracker) + $matchingJournals = @($observed | Where-Object { (Get-BackportJournalMarker $_.journal_state) -ceq (Get-BackportJournalMarker $State) }) + if ($matchingJournals.Count -gt 1) { throw 'Duplicate cohort journals.' } + if ($matchingJournals.Count -eq 1) { $comment = $matchingJournals[0].journal } + else { + $other = @($observed | Where-Object { + @($_.manifest.request.source_prs | Where-Object { $_ -in $cohort.request.source_prs }).Count -gt 0 }) - if ($observed.Count -gt 1) { throw 'Multiple durable batch mappings.' } - if ($observed.Count -eq 1) { - $tracking = $observed[0].issue - Assert-BackportBatchObject $tracking $manifest $BotLogin + if ($other.Count -gt 0 -and (-not $revision -or $other.Count -ne 1 -or $other[0].journal.id -ne $State.supersedes)) { + throw 'Cohort membership changed before journal creation.' + } + $appeared = Assert-BackportNewCohort $State $BotLogin $legacyBatch + if ($null -ne $appeared) { + if ($appeared.issue.number -ne $tracker.number) { throw 'Journal appeared on another shared tracker; reconcile duplicate trackers.' } + $comment = $appeared.journal } else { - foreach ($item in @(Assert-BackportBatchSources $request $repository)) { - if ($null -ne (Get-BackportTracking $repository $item.source.number) -or - @($item.source.labels | Where-Object { $_.name -like 'ext-backport-*' }).Count -gt 0) { - throw 'Source has prior backport state; do not create a duplicate batch.' - } - } - $pull = Get-BackportBatchPull $repository $request.key - if ($null -ne $pull) { throw 'Batch PR has no tracking issue.' } - # One creation attempt only. Without a pre-Task journal, an absent Task - # after a failed POST is uncertain and requires operator investigation. - $tracking = New-BackportTrackingTask $repository @{ - title = Get-BackportTitle -State $manifest - body = Get-BackportBatchBody $manifest; type = 'Task'; labels = @($labels | ForEach-Object { $_.name }) + Assert-BackportRelease $release $repository -Current + $body = (Get-BackportJournalMarker $State) + "`n`nImmutable extensibility backport cohort." + if ($body.Length -gt 64000) { throw 'Cohort journal exceeds GitHub body limits.' } + try { + $created = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)/comments" -Method POST -Body @{ body = $body } + $comment = Invoke-BackportApi "repos/$repository/issues/comments/$($created.id)" } - Assert-BackportBatchObject $tracking $manifest $BotLogin + catch { throw "Cohort journal creation is unconfirmed; no POST retry was attempted. Operator investigation is required. $($_.Exception.Message)" } } + Assert-BackportJournal $comment $State $BotLogin } - $exceptionFailures = @(Publish-BackportBatchExceptions $manifest $BotLogin -Durable -ContinueOnFailure) - $pull = Get-BackportBatchPull $repository $request.key - if ($null -ne $pull) { Complete-BackportBatchPull $manifest $tracking $pull $BotLogin } - elseif ($tracking.state -eq 'closed') { - throw 'Closed batch issue has no PR; manual reconciliation required.' + } + Assert-BackportJournal $comment $State $BotLogin + if ($null -eq $pull -and @($cohort.members | Where-Object { $_.result.outcome -eq 'applied' }).Count -gt 0) { + Assert-BackportRelease $release $repository -Current + $directory = Join-Path (Split-Path -Parent $StatePath) 'git' + $branch = "bc-extrequest-backport/$($cohort.request.key)" + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if ($remote) { + if (($remote -split '\s+')[0] -cne $cohort.head) { throw 'Existing branch differs; never overwrite human changes.' } } else { - if ($null -ne (Read-BackportPublicationIntent $tracking.body)) { - throw 'Previous batch PR creation is unconfirmed; reconcile the tracking issue publication intent before retrying.' - } - $branch = "bc-extrequest-backport/$($request.key)" + $null = Invoke-BackportGit $directory @('push', 'origin', "$($cohort.head):refs/heads/$branch") $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() - if ($remote) { - if (($remote -split '\s+')[0] -cne $manifest.head) { throw 'Existing batch branch differs; never overwrite it.' } - } - else { - $null = Invoke-BackportGit $directory @('push', 'origin', "$($manifest.head):refs/heads/$branch") - $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() - if (($remote -split '\s+')[0] -cne $manifest.head) { throw 'Batch branch publication not confirmed.' } - } - $tracking = Set-BackportPublicationIntent $manifest $tracking $manifest.head $BotLogin - $pull = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ - title = $tracking.title - body = Get-BackportBatchBody $manifest $tracking.number; head = $branch; base = $request.target; draft = $true + if (($remote -split '\s+')[0] -cne $cohort.head) { throw 'Branch publication was not confirmed.' } + } + $pull = Get-BackportBatchPull $repository $cohort.request.key + if ($null -eq $pull) { + if ($null -ne (Read-BackportPublicationIntent $comment.body)) { throw 'Previous PR POST is unconfirmed; do not repeat it.' } + Assert-BackportRelease $release $repository -Current + $tracker = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)" + Assert-BackportSharedTracker $tracker $release $BotLogin + Assert-BackportTrackerCapacity $tracker.body $State + $intent = "" + $comment = Update-BackportJournal $State $comment $intent $BotLogin + try { + $created = Invoke-BackportApi "repos/$repository/pulls" -Method POST -Body @{ + title = Get-BackportTitle $State; body = Get-BackportMilestonePullBody $State $tracker.number + head = $branch; base = $release.target; draft = $true + } + $pull = Invoke-BackportApi "repos/$repository/pulls/$($created.number)" } + catch { throw "PR creation is unconfirmed; no POST retry was attempted. Reconcile the journal before retrying. $($_.Exception.Message)" } + } + Assert-BackportMilestonePull $pull $State $BotLogin + } + if ($null -ne $pull) { + Assert-BackportMilestonePull $pull $State $BotLogin + if ($null -eq $pull.milestone) { + $null = Invoke-BackportApi "repos/$repository/issues/$($pull.number)" -Method PATCH -Body @{ milestone = $release.milestone_number } $pull = Invoke-BackportApi "repos/$repository/pulls/$($pull.number)" - Complete-BackportBatchPull $manifest $tracking $pull $BotLogin + Assert-BackportMilestonePull $pull $State $BotLogin + if ($null -eq $pull.milestone) { throw 'PR milestone assignment was not confirmed.' } } + $comment = Update-BackportJournal $State $comment "" $BotLogin + $labels = @(Get-BackportBatchLabels $cohort) + Ensure-BackportLabels $repository $labels + $null = Invoke-BackportApi "repos/$repository/issues/$($pull.number)/labels" -Method POST -Body @{ + labels = @($labels | ForEach-Object { $_.name }) + } + } + $tracker = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)" + Assert-BackportSharedTracker $tracker $release $BotLogin + Assert-BackportRelease $release $repository + $body = Merge-BackportTrackerRows $tracker.body $State $pull + $labels = @(Get-BackportTrackerLabels $cohort.members $tracker.labels) + Ensure-BackportLabels $repository $labels + $null = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)" -Method PATCH -Body @{ + body = $body; labels = @($labels | ForEach-Object { $_.name }); milestone = $release.milestone_number } - if ($applied.Count -eq 0) { $exceptionFailures = @(Publish-BackportBatchExceptions $manifest $BotLogin -ContinueOnFailure) } - $summaryStatus = if ($exceptionFailures.Count -gt 0) { 'partial publication - independent outcomes need attention' } else { 'published' } - Write-BackportBatchSummary $manifest $summaryStatus - if ($exceptionFailures.Count -gt 0) { throw ($exceptionFailures -join "`n") } + $confirmed = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)" + Assert-BackportSharedTracker $confirmed $release $BotLogin + if ($confirmed.body -cne $body) { throw 'Shared tracker table update was not confirmed.' } + if ($null -ne $pull) { + foreach ($member in $cohort.members) { + if ($member.result.outcome -eq 'applied') { Set-BackportProcessed $repository $member.source_pr } + } + } + $null = Update-BackportJournal $State $comment '' $BotLogin + Write-BackportBatchSummary $cohort 'published' "$($tracker.html_url) $(if ($null -ne $pull) { $pull.html_url })" +} + +function Publish-BackportLegacyOrPromote { + param([hashtable] $State, [string] $StatePath, [string] $BotLogin) + if ($BotLogin -cnotmatch '^[a-z0-9-]+\[bot\]$') { throw 'Invalid App identity.' } + $repository = $State.repository + Assert-BackportRepository $repository + if ($State.schema -eq 1 -and $State.ContainsKey('batch_key')) { + $batches = @(Get-BackportBatchRecords $repository | Where-Object { $_.manifest.request.key -ceq $State.batch_key }) + if ($batches.Count -ne 1) { throw 'Legacy exception has no unique immutable cohort.' } + $member = @($batches[0].manifest.members | Where-Object source_pr -eq $State.source_pr) + if ($member.Count -ne 1 -or $member[0].result.outcome -eq 'applied' -or + $member[0].source_sha -cne $State.source_sha -or + (ConvertTo-BackportCanonicalJson $member[0].result) -cne (ConvertTo-BackportCanonicalJson $State.result)) { + throw 'Legacy exception differs from its frozen cohort.' + } + if ($batches[0].ContainsKey('shared')) { + Publish-BackportMilestone $batches[0].journal_state $StatePath $BotLogin + } else { Publish-BackportLegacyOrPromote $batches[0].manifest $StatePath $BotLogin } + return + } + $numbers = if ($State.schema -eq 1) { @($State.source_pr) } elseif ($State.schema -eq 2) { @($State.request.source_prs) } + else { throw 'Unsupported publication state.' } + $existing = @(Get-BackportJournalRecords $repository | Where-Object { + @($_.manifest.request.source_prs | Where-Object { $_ -in $numbers }).Count -gt 0 + }) + if ($existing.Count -gt 0) { + if ($existing.Count -ne 1 -or -not $existing[0].journal_state.ContainsKey('legacy_issue') -or + ($existing[0].manifest.request.source_prs -join ',') -cne ($numbers -join ',')) { + throw 'Legacy state overlaps a different registered cohort.' + } + $prior = $existing[0].journal_state + if ($State.schema -eq 1 -and ($prior.cohort.members[0].source_sha -cne $State.source_sha -or + $prior.release.target -cne $State.target)) { throw 'Legacy source proof changed.' } + if ($State.schema -eq 2 -and (Get-BackportBatchMarker $State) -cne + (Get-BackportBatchMarker (Read-BackportBatchMarker (Invoke-BackportApi "repos/$repository/issues/$($prior.legacy_issue)").body))) { + throw 'Legacy prepared cohort differs from its original tracking record.' + } + Publish-BackportMilestone $prior $StatePath $BotLogin + return + } + if ($State.schema -eq 1) { + $source = Get-BackportSource $repository $State.source_pr + if ($source.merge_commit_sha -cne $State.source_sha) { throw 'Source SHA changed.' } + $tracking = Get-BackportTracking $repository $State.source_pr + $pull = Get-BackportPull $repository $State.source_pr $State.target + if ($null -ne $tracking) { Assert-BackportObject $tracking $State $BotLogin } + if ($null -ne $pull) { + if ($null -eq $tracking) { throw 'Legacy PR has no tracking identity.' } + Assert-BackportObject $pull $State $BotLogin + if ('ext-backport-processed' -notin @($source.labels | ForEach-Object { $_.name })) { + Set-BackportProcessed $repository $State.source_pr + } + Write-BackportSummary "Retained legacy backport $($pull.html_url); legacy objects were not modified." + return + } + if ($null -ne $tracking -and $State.result.outcome -ne 'applied') { + Write-BackportSummary "Retained legacy tracking issue $($tracking.html_url); no replay." + return + } + if ($null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body)) { throw 'Legacy publication is unconfirmed.' } + $request = @{ + key = "single-$($State.source_pr)"; mode = 'single'; target = $State.target; team = '' + batch_id = '0'; reference_time = $State.reference_time; manual = $State.manual + source_prs = @($State.source_pr); sources = @(@{ + number = $State.source_pr; sha = $State.source_sha; merged_at = $source.merged_at + }) + } + if (-not $State.ContainsKey('main_team')) { $State.main_team = Get-BackportMainTeam $source } + if ($State.ContainsKey('release')) { $request.release = $State.release } + $cohort = @{ + schema = 2; repository = $repository; request = $request; members = @($State) + target_sha = $State.result.target_sha; head = $State.result.commit; tree = $State.result.tree + } + if ($State.result.outcome -ne 'applied') { + $cohort.head = $State.result.target_sha + $cohort.tree = (Invoke-BackportGit (Join-Path (Split-Path -Parent $StatePath) 'git') @('rev-parse', "$($cohort.head)^{tree}")).text.Trim() + } + } + elseif ($State.schema -eq 2) { + $cohort = $State + $matchingBatchRecords = @(Get-BackportBatchRecords $repository | Where-Object { $_.manifest.request.key -ceq $cohort.request.key }) + $tracking = if ($matchingBatchRecords.Count -eq 1) { $matchingBatchRecords[0].issue } else { $null } + $pull = Get-BackportBatchPull $repository $cohort.request.key + if ($null -ne $tracking) { Assert-BackportBatchObject $tracking $cohort $BotLogin } + if ($null -ne $pull) { + if ($null -eq $tracking) { throw 'Legacy PR has no tracking identity.' } + Assert-BackportBatchObject $pull $cohort $BotLogin + foreach ($member in @($cohort.members | Where-Object { $_.result.outcome -eq 'applied' })) { + $source = Get-BackportSource $repository $member.source_pr + if ($source.merge_commit_sha -cne $member.source_sha) { throw 'Legacy source SHA changed.' } + } + foreach ($member in @($cohort.members | Where-Object { $_.result.outcome -eq 'applied' })) { + $source = Get-BackportSource $repository $member.source_pr + if ('ext-backport-processed' -notin @($source.labels | ForEach-Object { $_.name })) { + Set-BackportProcessed $repository $member.source_pr + } + } + Write-BackportSummary "Retained legacy backport $($pull.html_url); legacy objects were not modified." + return + } + if ($null -ne $tracking -and $null -ne (Read-BackportPublicationIntent $tracking.body)) { throw 'Legacy publication is unconfirmed.' } + foreach ($member in $cohort.members) { $member.main_team = Get-BackportMainTeam (Get-BackportSource $repository $member.source_pr) } + } + else { throw 'Unsupported publication state.' } + $release = if ($State.schema -eq 1 -and $State.ContainsKey('release')) { $State.release } + elseif ($cohort.request.ContainsKey('release')) { $cohort.request.release } + else { Get-BackportRelease $repository $cohort.request.target } + $wrapped = @{ schema = 3; repository = $repository; cohort = $cohort; release = $release } + if ($null -ne $tracking) { $wrapped.legacy_issue = $tracking.number } + Publish-BackportMilestone $wrapped $StatePath $BotLogin } Export-ModuleMember -Function Find-ExtRequestBackport, New-ExtRequestBackport, Publish-ExtRequestBackport, diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index 135c20d8eda..7ae59821d4c 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -19,47 +19,61 @@ checkout, keeping one provenance commit per source. The entire source PR is considered, including every original issue already combined in a main-branch team PR. -A nonempty successful team batch creates **one Task tracking issue and one -draft release PR**, not a pair per source. The Task lists original issues with -short summaries; the PR links every included main PR and its original issues -from `closingIssuesReferences`, without duplicates. Both new objects inherit the deduplicated union of the original -issues' labels, excluding `ext-ready-to-implement`, and add `ext-backporting`. -When there are no original issue references, labels come from the source PR, -excluding its source-only `ext-backport-processed` marker. - -The issue and PR share a title. Team batches use +A nonempty successful team batch creates **one draft release PR**. All batches +and individual requests for the same target branch and milestone share +**one Task tracking issue**, across teams, weekly iterations and manual runs. + +The shared issue uses this title: + +```text +[Backporting][releases/29.x][Extensibility Requests][Version 29.1] +``` + +Its description contains a managed table with original issue links and short +title-derived summaries, original main PR links, source teams and backport PR +links. Existing rows are retained and new rows are deduplicated. A backport PR +covering several original issues has several rows pointing to that same PR. +Sources without a linked original issue are shown explicitly rather than +inventing an issue. +The issue ends with italic "Auto-generated content". + +Backport PR titles remain independent of the shared issue title. Team PRs use `[Backport 29.x] [Extensibility Requests] [Team: SCM] till 2026-10-21`, with the actual release, team and discovery date in Europe/Copenhagen. The date -is fixed for retries of the same batch. - -Descriptions begin with "Backporting of extensibility requests", including -the team for a batch. Tracking Tasks list only the original issues under -`Issues:`, each with a concise summary derived from its original title. They -do not display a PR list and end with italic "Auto-generated content". -Backport PRs retain the `Issues:` and `PRs:` link lists, followed by -`Fixes #` and the Important AI-generated disclaimer. +is fixed for retries of the same batch. Individual PRs retain their source-based +title. PR descriptions retain the original `Issues:` and `PRs:` lists, +`Fixes #` and the Important AI-generated disclaimer. They never use `Fixes` for the original issues. Source hashes and recovery -metadata remain hidden. Summaries do not require AI or model credentials. +metadata remain hidden; summaries do not require AI or model credentials. + +Backport PR labels inherit the original issue label union, excluding +`ext-ready-to-implement`, plus `ext-backporting`. When there are no original +issues, labels come from the main PR, excluding `ext-backport-processed`. +The shared Task also excludes `Team:*` and `Ownership:*`: its source teams are +recorded in table rows instead. Generated `ext-backporting` issues are excluded +from ownership classification; backport PR ownership remains unchanged. A source is processed independently when its team is missing, ambiguous, or inconsistent with a linked original issue's team. For example, a main PR labeled `Team: SCM` whose original issue has `Team: Finance` does not enter either team's -batch. It retains the individual backport issue/PR behavior rather than being -silently skipped or assigned to a guessed team. +batch. It retains individual PR preparation while using the same milestone +tracker, rather than being silently skipped or assigned to a guessed team. A conflicting source is also handled independently: its entire cherry-pick is -aborted, a separate needs-attention Task is created, and no partial source branch -or PR is published. Other clean sources can still form the team batch. An -already-present source gets independent no-op tracking rather than an empty -commit or PR. A team with no applied sources gets no empty batch PR. +aborted, its row records the need for manual resolution, and no partial source +branch or PR is published. Other clean sources can still form the team batch. +Already-present sources are recorded in the same shared tracker without empty +commits or PRs. Neither outcome creates another Task or marks its source PR +processed without a real backport PR. A team with no applied sources gets no +empty batch PR. Later sources that touch paths belonging to a failed source are also held for attention to avoid publishing an incomplete dependency chain. This is a conservative path check, not an inference of semantic dependencies. -For example, three clean Finance PRs create one Finance issue/PR pair; two clean -SCM PRs create one SCM pair. A third SCM PR with a conflict gets its own -needs-attention issue, not another draft PR and not a blocker for the clean SCM -batch. +For example, three clean Finance PRs create one Finance PR; two clean +SCM PRs create one SCM PR. Both PRs reference the same milestone Task. A third +SCM PR with a conflict gets a manual-attention row in that Task, not another +issue or draft PR and not a blocker for the clean SCM batch. Batch membership is fixed for that invocation and retained with its durable identity. Retries must resume the same sources and target, including members @@ -68,6 +82,25 @@ runs create new batches only for work that has not already been handled. The 15-day selection clock is fixed at discovery, not recalculated as each queued worker starts. +## Milestone selection and tracker lifetime + +The milestone is selected using the same setting as BCApps' Work Item +Validation workflow: read `repoVersion` from +`.github/AL-Go-Settings.json` on the selected target branch, then find the exact +existing milestone named `Version `. For example, +`releases/29.x` configured as `29.1` uses `Version 29.1`, even if future milestones +such as `Version 29.2` are already open. The workflow does not select the highest +open milestone or create milestones automatically. + +New work for another version gets another tracking Task. Existing durable +requests retain their original target and milestone. Missing, ambiguous or +closed milestones stop publication rather than selecting a different version. + +If the matching tracking issue is closed while its milestone is still open, +adding more work reopens that same issue. It never creates a replacement Task +for the same branch/milestone. A closed milestone is never reopened. +The shared tracker is not closed when an individual backport PR merges. + ## Configuration Use the existing `extensibility-agent` environment and GitHub App: @@ -111,6 +144,9 @@ original target for an existing request, and duplicate prevention. A selected source already included in a team batch refers back to that batch; it must not create a second individual backport. +Both manual forms reuse the same milestone Task as scheduled runs. Manual +selection does not create a separate tracking issue. + **Every manual run publishes.** There is no preview input or dry-run workflow mode. Publication requires the existing protected environment and App permissions even when scheduled creation is disabled. @@ -126,8 +162,10 @@ outcomes. A PR is never split simply to reduce batch size. Independent requests retain the deterministic branch `bc-extrequest-backport/pr-/.x`. Team requests have their own stable batch identity and branch, with immutable membership linking each source -PR and merge SHA. Tracking issues and PRs carry versioned identity metadata, -the pinned target and original source identities. Existing objects must have +PR and merge SHA. The shared Task is a mutable milestone index, not a single +immutable cohort. Per-request journal records preserve membership, outcomes, +source identities and publication intent separately from the accumulating +table. Existing objects must have the expected identity, repository, branches and GitHub App author. Open and closed PRs are both checked; legacy independent requests are not replayed into new team batches. @@ -140,36 +178,39 @@ It prevents future automatic selection; it does not mean the backport merged. No temporary, versioned, or merge-status labels are added to original PRs, and original issues are never relabeled. -There is no daily status job or merge-lifecycle automation. Merging or closing -a backport PR does not trigger further source-label updates or closure of its -tracking Task by this workflow. Tracking issues and backport PRs still inherit -the original labels and receive `ext-backporting`. +There is no daily status job or source merge-lifecycle automation. Merging or +closing a backport PR does not trigger further source-label updates or closure +of the shared tracking Task by this workflow. Existing tracking identities and open/closed PR lookup prevent normal duplicate creation. A repeated publishing run repairs missing links or the single processed label without adding lifecycle statuses. Conflict and already-present sources -receive no processed label when no backport PR exists; their tracking records -prevent duplicate Tasks and automatic conflict retries. +receive no processed label when no backport PR exists; their journal records +prevent duplicate rows and automatic conflict retries. API writes are not automatically retried within a run. Before creating a PR, -the workflow records a hidden publication-intent marker on its **backport -tracking issue**, not on the original PR or issues. If that marker exists but -no matching open/closed PR can be found, publication stops for operator review. +the workflow records publication intent in its per-request journal, not on the +original PR or issues. If intent exists but no matching open/closed PR can be +found, publication stops for operator review. Do not clear it until the actual outcome has been established. -If Task creation fails with an uncertain response, inspect the run and existing -tracking issues before rerunning. Without temporary source labels there is no -separate durable issue-creation reservation: object discovery recovers observed -Tasks, but cannot prove the outcome of an unobserved create. Do not blindly rerun -an ambiguous create. - -Conflicts create a linked independent tracking issue but no partial branch or -PR, and are not retried automatically. An open standalone conflict can be -retried by manually selecting its main PR if the target has changed; the -existing Task is reused. Outcomes recorded in an immutable team cohort remain -frozen and require manual resolution. A separate human-created resolution PR -is not adopted automatically; complete its tracking manually. Closed-unmerged -PRs and closed tracking issues are not recreated automatically. +If Task or journal creation fails with an uncertain response, inspect the run +and existing tracking objects before rerunning. Without temporary source labels +there is no separate durable creation reservation: object discovery recovers +observed records, but cannot prove the outcome of an unobserved create. +Do not blindly rerun an ambiguous create. Oversized complete tables or journal +records fail explicitly rather than truncating history or splitting the tracker +into extra issues. + +Conflicts create a row in the shared tracker, but no partial branch or PR, and +are not retried automatically. A manual selected-PR run can retry a standalone +conflict against a changed target, reusing the tracker through a validated +journal revision. Outcomes recorded in an immutable team cohort remain frozen +and require manual resolution. A separate human-created +resolution PR is not adopted automatically; complete its tracking manually. +Closed-unmerged PRs are not recreated automatically. Legacy App-authored +independent tracking records remain recognizable; human-authored demonstration +issues and PRs are not silently adopted into production tracking. ## Content boundaries @@ -193,8 +234,14 @@ before creating the PR. Normal release CI and developer review remain mandatory. The workflow never merges a backport PR. Its offline Pester tests run for relevant workflow PRs and before discovery; they also exercise real local Git cherry-picks without remote -writes. Run them with the repository's Pester tooling: +writes and execute the ownership workflow's preflight against mocked GitHub +APIs. Changes to either workflow or its regression tests trigger this targeted +suite. Run it with the repository's Pester tooling: ```powershell -Invoke-Pester .github/scripts/bc-extrequest-backport/Backport.Test.ps1 +Import-Module Pester -RequiredVersion 5.7.1 +Invoke-Pester -Path @( + '.github/scripts/bc-extrequest-backport/Backport.Test.ps1' + 'build/scripts/tests/OwnershipClassification.Test.ps1' +) ``` diff --git a/.github/workflows/bc-extrequest-backport.yml b/.github/workflows/bc-extrequest-backport.yml index 10b2d53f9ad..71833e3da79 100644 --- a/.github/workflows/bc-extrequest-backport.yml +++ b/.github/workflows/bc-extrequest-backport.yml @@ -20,7 +20,9 @@ on: pull_request: paths: - '.github/workflows/bc-extrequest-backport.yml' + - '.github/workflows/ownership-classification.yml' - '.github/scripts/bc-extrequest-backport/**' + - 'build/scripts/tests/OwnershipClassification.Test.ps1' permissions: contents: read @@ -41,14 +43,20 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - sparse-checkout: .github/scripts/bc-extrequest-backport + sparse-checkout: | + .github/scripts/bc-extrequest-backport + .github/workflows + build/scripts/tests - name: Run offline PowerShell tests run: | if (-not (Get-Module -ListAvailable Pester | Where-Object Version -eq '5.7.1')) { Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force } Import-Module Pester -RequiredVersion 5.7.1 - $result = Invoke-Pester ./.github/scripts/bc-extrequest-backport/Backport.Test.ps1 -PassThru + $result = Invoke-Pester -Path @( + './.github/scripts/bc-extrequest-backport/Backport.Test.ps1' + './build/scripts/tests/OwnershipClassification.Test.ps1' + ) -PassThru if ($result.Result -ne 'Passed' -or $result.PassedCount -eq 0) { throw 'Backport tests failed or no tests ran.' } @@ -90,7 +98,7 @@ jobs: -SourcePr $env:SOURCE_PR -TargetOverride $env:TARGET_OVERRIDE -BatchId $env:BATCH_ID) "has-work=$(($requests.Count -gt 0).ToString().ToLowerInvariant())" | Add-Content $env:GITHUB_OUTPUT - "matrix=$(@{ include = $requests } | ConvertTo-Json -Depth 15 -Compress)" | + "matrix=$(@{ include = $requests } | ConvertTo-Json -Depth 60 -Compress)" | Add-Content $env:GITHUB_OUTPUT backport: diff --git a/.github/workflows/ownership-classification.yml b/.github/workflows/ownership-classification.yml index 31de882b772..54ed5c91e4b 100644 --- a/.github/workflows/ownership-classification.yml +++ b/.github/workflows/ownership-classification.yml @@ -43,7 +43,8 @@ jobs: classify: name: Classify team ownership if: >- - github.repository == 'microsoft/BCApps' && ( + github.repository == 'microsoft/BCApps' && + !(github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'ext-backporting')) && ( ( github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) @@ -127,6 +128,14 @@ jobs: if (actualKind !== subjectKind) { throw new Error(`#${issue_number} is ${actualKind}, not ${subjectKind}`); } + const freshLabels = (response.data.labels || []).map(label => + typeof label === 'string' ? label : label.name + ); + if (subjectKind === 'issue' && freshLabels.some(name => name.toLowerCase() === 'ext-backporting')) { + core.setOutput('dispatch', 'false'); + core.info('Backport tracking issues record source teams in their managed table; ownership classification is skipped.'); + return; + } if (response.data.state !== 'open') { throw new Error(`${subjectKind} #${issue_number} is not open`); } diff --git a/build/scripts/tests/OwnershipClassification.Test.ps1 b/build/scripts/tests/OwnershipClassification.Test.ps1 new file mode 100644 index 00000000000..4545155f938 --- /dev/null +++ b/build/scripts/tests/OwnershipClassification.Test.ps1 @@ -0,0 +1,104 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +Describe 'Ownership classification backport tracker exclusion' { + BeforeAll { + $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent + $workflow = Get-Content (Join-Path $repoRoot '.github/workflows/ownership-classification.yml') -Raw + $stepIndex = $workflow.IndexOf('- name: Check labels and manual override', [StringComparison]::Ordinal) + if ($stepIndex -lt 0) { throw 'Ownership preflight step was not found.' } + $scriptMatch = [regex]::Match($workflow.Substring($stepIndex), + '(?m)^ script: \|\r?\n(?(?: {12}[^\r\n]*(?:\r?\n|$)|\r?\n)+)') + if (-not $scriptMatch.Success) { throw 'Ownership preflight JavaScript could not be extracted.' } + $scriptCode = (($scriptMatch.Groups['code'].Value -split '\r?\n') | ForEach-Object { + if ($_.StartsWith(' ')) { $_.Substring(12) } else { $_ } + }) -join "`n" + $preflightPath = Join-Path $TestDrive 'preflight.js' + Set-Content -LiteralPath $preflightPath -Value $scriptCode -Encoding utf8NoBOM + $harnessPath = Join-Path $TestDrive 'ownership.cjs' + @' +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const script = fs.readFileSync(process.argv[2], 'utf8'); +const scenario = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); +const labels = scenario.currentLabels.map(name => ({ name })); +const actual = { state: scenario.state, labels }; +if (scenario.kind === 'pull_request') actual.pull_request = {}; +process.env.SUBJECT_KIND = scenario.kind; +process.env.SUBJECT_NUMBER = '1'; +let repositoryLabelReads = 0; +const listLabelsForRepo = () => {}; +const listLabelsOnIssue = () => {}; +const github = { + rest: { + issues: { + get: async () => ({ data: actual }), + listLabelsForRepo, + listLabelsOnIssue, + addLabels: async () => { throw new Error('Unexpected label mutation'); }, + removeLabel: async () => { throw new Error('Unexpected label mutation'); }, + }, + }, + paginate: async operation => { + if (operation === listLabelsForRepo) { + repositoryLabelReads++; + return ['Team: Finance', 'Team: SCM', 'Team: Integrations', 'Team: Other', + 'Ownership: Manual', 'Ownership: Needs Review'].map(name => ({ name })); + } + if (operation === listLabelsOnIssue) return labels; + throw new Error('Unexpected paginated operation'); + }, +}; +const context = { + eventName: scenario.event, + repo: { owner: 'example', repo: 'repository' }, + payload: { action: 'opened', [scenario.kind]: { labels: scenario.payloadLabels } }, +}; +const outputs = {}; +const core = { + setOutput: (name, value) => { outputs[name] = value; }, + info: () => {}, +}; +async function main() { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + await new AsyncFunction('github', 'context', 'core', script)(github, context, core); + assert.equal(outputs.dispatch, scenario.expectedDispatch); + assert.equal(repositoryLabelReads, scenario.expectedRepositoryLabelReads); +} +main().catch(error => { console.error(error); process.exitCode = 1; }); +'@ | Set-Content -LiteralPath $harnessPath -Encoding utf8NoBOM + } + + It 'handles without unintended label writes' -ForEach @( + @{ name = 'automatic backport tracker'; kind = 'issue'; event = 'issues'; state = 'open' + currentLabels = @('ext-backporting'); payloadLabels = @('ext-backporting') + expectedDispatch = 'false'; expectedRepositoryLabelReads = 0 } + @{ name = 'manually dispatched backport tracker'; kind = 'issue'; event = 'workflow_dispatch'; state = 'open' + currentLabels = @('ext-backporting'); payloadLabels = @() + expectedDispatch = 'false'; expectedRepositoryLabelReads = 0 } + @{ name = 'stale issue event missing the current backport label'; kind = 'issue'; event = 'issues'; state = 'open' + currentLabels = @('ext-backporting'); payloadLabels = @() + expectedDispatch = 'false'; expectedRepositoryLabelReads = 0 } + @{ name = 'case-insensitive backport label on manual dispatch'; kind = 'issue'; event = 'workflow_dispatch'; state = 'open' + currentLabels = @('Ext-Backporting'); payloadLabels = @() + expectedDispatch = 'false'; expectedRepositoryLabelReads = 0 } + @{ name = 'closed backport tracker'; kind = 'issue'; event = 'workflow_dispatch'; state = 'closed' + currentLabels = @('ext-backporting'); payloadLabels = @() + expectedDispatch = 'false'; expectedRepositoryLabelReads = 0 } + @{ name = 'ordinary extensibility issue'; kind = 'issue'; event = 'issues'; state = 'open' + currentLabels = @('event-request', 'Team: SCM'); payloadLabels = @('event-request', 'Team: SCM') + expectedDispatch = 'true'; expectedRepositoryLabelReads = 1 } + @{ name = 'backport PR retaining normal ownership classification'; kind = 'pull_request'; event = 'pull_request_target'; state = 'open' + currentLabels = @('ext-backporting', 'Team: SCM'); payloadLabels = @('ext-backporting', 'Team: SCM') + expectedDispatch = 'true'; expectedRepositoryLabelReads = 1 } + ) { + $scenarioPath = Join-Path $TestDrive 'scenario.json' + @{ + kind = $kind; event = $event; state = $state + currentLabels = $currentLabels; payloadLabels = $payloadLabels + expectedDispatch = $expectedDispatch; expectedRepositoryLabelReads = $expectedRepositoryLabelReads + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $scenarioPath -Encoding utf8NoBOM + $output = & node $harnessPath $preflightPath $scenarioPath 2>&1 + $LASTEXITCODE | Should -Be 0 -Because ($output -join "`n") + } +} From fc5d3a4c6e3481f381a733c4b8dd4bf0b2f6619f Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 18:24:35 +0200 Subject: [PATCH 10/13] Add a user-friendly extensibility backport guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/bc-extrequest-backport/README.md | 3 + .../bc-extrequest-backport/USER-GUIDE.md | 77 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 .github/scripts/bc-extrequest-backport/USER-GUIDE.md diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index 7ae59821d4c..e3d0d548688 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -1,5 +1,8 @@ # Extensibility request backports +For a short explanation of everyday use and common scenarios, see the +[user guide](USER-GUIDE.md). This document covers configuration and technical recovery. + `bc-extrequest-backport.yml` creates backports on **Sunday at 21:10 Europe/Copenhagen**. It scans the rolling last **15 days** for PRs merged into `main`, with no fixed start date. The overlapping window allows a later weekly diff --git a/.github/scripts/bc-extrequest-backport/USER-GUIDE.md b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md new file mode 100644 index 00000000000..6b53ed7020e --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md @@ -0,0 +1,77 @@ +# BCApps extensibility backports - user guide + +## 1. Overview + +A **backport** copies a change from `main` to a release branch, so customers need not wait for the next major version. The workflow creates draft pull requests (PRs) containing the code and a shared issue tracking the work. + +Introduced in [microsoft/BCApps#12987](https://github.com/microsoft/BCApps/pull/12987). Scheduled processing requires the workflow to be merged and a maintainer to enable it. + +## 2. Why It Exists + +Reduce manual copying and tracking, while leaving review and merging to people. + +## 3. How It Works + +**When:** Sundays at **21:10, Europe/Copenhagen time**, checking PRs merged into `main` in the previous **15 days**. This overlap helps catch a missed week. It does not run immediately after merges or scan all history. + +**Which requests qualify automatically:** + +| Label | Request | +| --- | --- | +| `event-request` | Add events that extensions can subscribe to. | +| `request-for-external` | Make existing functionality accessible to extensions. | + +Either or both types are allowed. Other categories, including enum requests, extensibility bugs and enhancements, are excluded, even in mixed PRs. Linked original issues must also qualify. No extra trigger label is needed. + +**Processing:** + +1. Select eligible, unprocessed main PRs and the latest numbered release branch, such as `releases/29.x`. Maintainers can override the branch. +2. Select the exact milestone from that branch's configured version (`repoVersion`). For example, `29.1` selects **Version 29.1**, even if **Version 29.2** already exists. +3. Copy whole PRs, oldest first, normally combining each team's clean changes into one draft backport PR per run. +4. Create or update **one tracking Task per branch and milestone**, shared across teams and runs. + +Example tracker title: + +```text +[Backporting][releases/29.x][Extensibility Requests][Version 29.1] +``` + +Its table contains **Original issue + short summary | Original PR | Team | Backport PR**. New runs add entries without duplicating earlier ones. + +Team backport PRs use team/date titles, link the original work and reference the shared Task using `Fixes`. Both new objects inherit original labels, excluding `ext-ready-to-implement`, and add `ext-backporting`. The shared Task omits team/ownership labels; teams appear in its table. + +Original issues remain unchanged. The only label added to a main PR is `ext-backport-processed`, once its backport PR exists: **created**, not **merged**. + +## 4. Usage / Scenarios + +**Weekly use:** merge eligible work into `main`, then review generated drafts through normal release procedures. + +**Manual use:** after deployment, open **BCApps > Actions > Extensibility Request Backport > Run workflow**, selecting `main`. + +- Leave `source_pr` blank for normal 15-day selection and team batching. +- Enter a merged main **PR number**, not an issue number, for a specific request. This bypasses date, category and processed-label selection, but not safety or duplicate checks. + +Both modes reuse the same milestone tracker. **Manual runs make real changes; there is no preview mode.** + +## 5. Edge Cases + +| What if... | Result | +| --- | --- | +| Nothing qualifies? | No new backport PR or tracker. | +| The PR is older than 15 days? | Select its number manually. | +| It was already backported? | Reuse its existing record/PR, not a duplicate. | +| Team information is missing or inconsistent? | Process separately, without guessing a team. | +| Changes conflict with the release? | Record manual attention; no partial source change or automatic retry. Unrelated clean work continues; related changes may be held. | +| Changes are already present? | Record that outcome; create no empty PR. | +| The tracker is closed? | Reopen it when adding work, only while its milestone is open. | +| The configured version changes? | New work uses a new tracker; existing requests keep their original version. | +| The milestone is missing or closed? | Stop for maintainer attention; do not guess another milestone. | +| A run fails partway through? | Existing records support recovery. An uncertain creation outcome needs maintainer inspection before retrying. | + +## 6. Limitations + +The workflow does not resolve conflicts with AI, merge PRs, or automatically close the shared tracker when release PRs merge. People review, merge and close tracking work. + +It targets one release branch, not every supported release. Only normal code files under `src/` are supported, up to 250 changed file paths per source PR; workflow/build changes and unsafe content stop processing. + +Maintainers must enable scheduled runs with `EXT_REQ_BACKPORT_ENABLED=true` and provide the required GitHub App permissions. Manual publication still requires those permissions, even when scheduling is disabled. From 129e780da0b4de07dba3426172ae6384977b42c1 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 18:30:41 +0200 Subject: [PATCH 11/13] Clarify automatic weekly backport discovery in user guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/bc-extrequest-backport/USER-GUIDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/USER-GUIDE.md b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md index 6b53ed7020e..dc5bab8cb94 100644 --- a/.github/scripts/bc-extrequest-backport/USER-GUIDE.md +++ b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md @@ -44,9 +44,9 @@ Original issues remain unchanged. The only label added to a main PR is `ext-back ## 4. Usage / Scenarios -**Weekly use:** merge eligible work into `main`, then review generated drafts through normal release procedures. +**Automatic weekly processing:** every Sunday, the workflow finds eligible extensibility-request PRs already merged into `main` during the last 15 days and excludes those labeled `ext-backport-processed`. It prepares their backports and updates the shared tracker. No manual start or extra label is needed; people only review and merge the resulting backport PRs through normal release procedures. -**Manual use:** after deployment, open **BCApps > Actions > Extensibility Request Backport > Run workflow**, selecting `main`. +**Manual use:** after deployment, open **BCApps > Actions > Extensibility Request Backport > Run workflow**. Select `main` in **Use workflow from** to run the deployed workflow definition. This does not select the backport destination; the target remains the release branch. - Leave `source_pr` blank for normal 15-day selection and team batching. - Enter a merged main **PR number**, not an issue number, for a specific request. This bypasses date, category and processed-label selection, but not safety or duplicate checks. From 17c46c376058bae59251b487280db246f58cc594 Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 19:01:20 +0200 Subject: [PATCH 12/13] Clarify that already backported PRs are skipped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/bc-extrequest-backport/USER-GUIDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/USER-GUIDE.md b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md index dc5bab8cb94..97cb8fe1645 100644 --- a/.github/scripts/bc-extrequest-backport/USER-GUIDE.md +++ b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md @@ -49,7 +49,7 @@ Original issues remain unchanged. The only label added to a main PR is `ext-back **Manual use:** after deployment, open **BCApps > Actions > Extensibility Request Backport > Run workflow**. Select `main` in **Use workflow from** to run the deployed workflow definition. This does not select the backport destination; the target remains the release branch. - Leave `source_pr` blank for normal 15-day selection and team batching. -- Enter a merged main **PR number**, not an issue number, for a specific request. This bypasses date, category and processed-label selection, but not safety or duplicate checks. +- Enter a merged main **PR number**, not an issue number, for a specific request. This can override the normal selection filters, but never creates another copy of an existing backport. An interrupted run can resume to finish missing tracking updates. Both modes reuse the same milestone tracker. **Manual runs make real changes; there is no preview mode.** @@ -59,7 +59,7 @@ Both modes reuse the same milestone tracker. **Manual runs make real changes; th | --- | --- | | Nothing qualifies? | No new backport PR or tracker. | | The PR is older than 15 days? | Select its number manually. | -| It was already backported? | Reuse its existing record/PR, not a duplicate. | +| It was already backported? | Skip it; do not backport it again. PRs labeled `ext-backport-processed` are excluded from automatic selection. | | Team information is missing or inconsistent? | Process separately, without guessing a team. | | Changes conflict with the release? | Record manual attention; no partial source change or automatic retry. Unrelated clean work continues; related changes may be held. | | Changes are already present? | Record that outcome; create no empty PR. | From 9b19711b2d88d185e680e0ee5bf6e5859d6c635b Mon Sep 17 00:00:00 2001 From: MarkoAleksandric Date: Wed, 21 Oct 2026 22:25:48 +0200 Subject: [PATCH 13/13] Use readable backport branches with collision-safe creation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bc-extrequest-backport/Backport.Test.ps1 | 679 +++++++++++++++++- .../bc-extrequest-backport/Backport.psm1 | 204 +++++- .../scripts/bc-extrequest-backport/README.md | 29 +- .../bc-extrequest-backport/USER-GUIDE.md | 5 + 4 files changed, 881 insertions(+), 36 deletions(-) diff --git a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 index ae76cd4d607..a9d07061d83 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -3,6 +3,7 @@ using module .\Backport.psm1 Describe 'Shared milestone journal engine' { InModuleScope Backport { BeforeAll { + $script:originalBackportGit = (Get-Command Invoke-BackportGit).ScriptBlock function New-MilestoneFixture { param([int] $Start = 42, [string] $Team = 'Team: Finance', [string[]] $Outcomes = @('applied', 'conflict', 'already-present'), [string] $Mode = 'team') @@ -77,6 +78,30 @@ Describe 'Shared milestone journal engine' { } $script:db.nextIssue = 101 } + function Use-ReadableBranchFixture { + $script:db.remoteHeads = @{} + Mock Invoke-BackportGit { + $prefix = 'refs/heads/' + if ($Arguments[0] -eq 'ls-remote') { + $branch = $Arguments[-1].Substring($prefix.Length) + $sha = $script:db.remoteHeads[$branch] + return @{ text = if ($sha) { "$sha`t$prefix$branch" } else { '' }; exit_code = 0 } + } + if ($Arguments[0] -eq 'push') { + $Arguments.Count | Should -Be 4 + $Arguments[2] | Should -Be 'origin' + $sha, $ref = $Arguments[3].Split(':', 2) + $Arguments[1] | Should -BeExactly "--force-with-lease=$($ref):" + $branch = $ref.Substring($prefix.Length) + $script:db.remoteHeads.ContainsKey($branch) | Should -BeFalse + $script:db.remoteHeads[$branch] = $sha + return @{ text = ''; exit_code = 0 } + } + throw "Unexpected Git operation $($Arguments -join ' ')" + } + $script:state.cohort.branch = New-BackportCohortBranch microsoft/BCApps $TestDrive $script:state.cohort.request @() + Save-MilestoneFixture $script:state + } } BeforeEach { $script:statePath = Join-Path $TestDrive 'milestone.json' @@ -196,6 +221,578 @@ Describe 'Shared milestone journal engine' { } } + It 'names single requests from the original main PR, never its original issue' { + $script:state = New-MilestoneFixture -Start 11166 -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Use-ReadableBranchFixture + $state.cohort.branch | Should -BeExactly 'bc-extrequest-backport/backport_11166' + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $pull = $db.pulls['200'] + $pull.head.ref | Should -BeExactly 'bc-extrequest-backport/backport_11166' + $pull.head.ref | Should -Not -Match '10$' + Assert-BackportMilestonePull $pull $state 'test-app[bot]' + Should -Invoke Invoke-BackportApi -ParameterFilter { + $Path -like '*/pulls?state=all&head=microsoft:bc-extrequest-backport/backport_11166&*' + } + Should -Invoke Invoke-BackportGit -Times 1 -ParameterFilter { + $Arguments[0] -eq 'push' -and + $Arguments[3] -ceq "$($script:state.cohort.head):refs/heads/bc-extrequest-backport/backport_11166" + } + $db.sources['11166'].labels.name | Should -Contain 'ext-backport-processed' + @($db.writes | Where-Object path -eq 'repos/microsoft/BCApps/issues/10/labels').Count | Should -Be 0 + } + + It 'blocks the personal demo singleton alias after manual discovery bypasses the processed label' -ForEach @( + @{ status = 'open' }, @{ status = 'closed' } + ) { + $script:state = New-MilestoneFixture -Start 11166 -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + $db.sources['11166'].labels += @{ name = 'ext-backport-processed' } + $db.issues['13146'] = @{ + number = 13146; title = '[Simulation] Personal backport demonstration'; state = $status + body = '' + user = @{ login = 'personal-demo-author' }; milestone = $null; labels = @(@{ name = 'ext-backporting' }) + } + $db.pulls['13147'] = @{ + number = 13147; title = '[Simulation] Backport #11166'; state = $status + body = '' + user = @{ login = 'personal-demo-author' } + head = @{ ref = 'bc-extrequest-backport/single-11166'; sha = $state.cohort.head } + } + $personalBefore = ConvertTo-BackportCanonicalJson @($db.issues['13146'], $db.pulls['13147']) + Mock Invoke-BackportApi { @(@{ name = 'releases/29.x' }) } -ParameterFilter { + $Method -ne 'POST' -and $Path -like '*/branches?*' + } + $row = Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 11166 -BatchId 999 + $row.manual | Should -BeTrue + $row.source_prs -join ',' | Should -BeExactly '11166' + $row.ContainsKey('journal_id') | Should -BeFalse + Use-ReadableBranchFixture + $state.cohort.branch | Should -BeExactly 'bc-extrequest-backport/backport_11166' + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*legacy alias*foreign or unregistered*' + (ConvertTo-BackportCanonicalJson @($db.issues['13146'], $db.pulls['13147'])) | Should -BeExactly $personalBefore + $db.writes.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportApi -ParameterFilter { + $Path -like '*/pulls?state=all&head=microsoft:bc-extrequest-backport/single-11166&*' + } + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'blocks open or closed native-v1 aliases regardless of foreign or unregistered App authorship' -ForEach @( + @{ status = 'open'; author = 'human' }, @{ status = 'closed'; author = 'human' }, + @{ status = 'open'; author = 'test-app[bot]' }, @{ status = 'closed'; author = 'test-app[bot]' } + ) { + $script:state = New-MilestoneFixture -Start 11166 -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Use-ReadableBranchFixture + $db.pulls['91'] = @{ + number = 91; title = 'Unregistered native backport'; state = $status + body = Get-BackportBody $state.cohort.members[0] + user = @{ login = $author } + head = @{ ref = 'bc-extrequest-backport/pr-11166/29.x'; sha = $state.cohort.head } + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*legacy alias*foreign or unregistered*' + $db.writes.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportApi -ParameterFilter { + $Path -like '*/pulls?state=all&head=microsoft:bc-extrequest-backport/pr-11166/29.x&*' + } + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'fails safely on multiple old aliases or multiple PRs on either alias' -ForEach @( + @{ kind = 'both' }, @{ kind = 'single-duplicates' }, @{ kind = 'native-duplicates' } + ) { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Use-ReadableBranchFixture + $first = if ($kind -eq 'native-duplicates') { 'bc-extrequest-backport/pr-42/29.x' } + else { 'bc-extrequest-backport/single-42' } + $second = if ($kind -eq 'both') { 'bc-extrequest-backport/pr-42/29.x' } else { $first } + $db.pulls['90'] = @{ number = 90; state = 'open'; head = @{ ref = $first } } + $db.pulls['91'] = @{ number = 91; state = 'closed'; head = @{ ref = $second } } + $expectedError = switch ($kind) { + 'both' { '*Ambiguous legacy standalone backport PR aliases*' } + 'single-duplicates' { '*Multiple PRs for one batch branch*' } + 'native-duplicates' { '*Multiple backport PRs for the same branch*' } + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw $expectedError + $db.writes.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + $db.pulls.Count | Should -Be 2 + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'blocks an old singleton alias for any member of a new readable team cohort' { + Use-ReadableBranchFixture + $db.pulls['91'] = @{ + number = 91; title = '[Simulation] Excluded source backport'; state = 'closed' + head = @{ ref = 'bc-extrequest-backport/single-43' }; user = @{ login = 'human' }; body = '' + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*legacy alias*' + $db.writes.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'rechecks legacy aliases when resuming a registered but unpublished readable cohort' { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Use-ReadableBranchFixture + Mock Invoke-BackportGit { throw 'Interrupted push' } -ParameterFilter { $Arguments[0] -eq 'push' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Interrupted push*' + $db.pulls['91'] = @{ + number = 91; title = '[Simulation] Personal backport'; state = 'closed' + head = @{ ref = 'bc-extrequest-backport/single-42' }; user = @{ login = 'human' }; body = '' + } + $writes = $db.writes.Count + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*legacy alias*' + $db.writes.Count | Should -Be $writes + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportGit -Times 1 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'does not adopt an App-authored singleton alias without its registered immutable cohort' { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Use-ReadableBranchFixture + $db.pulls['91'] = @{ + number = 91; title = 'Unregistered App backport'; state = 'closed' + head = @{ ref = 'bc-extrequest-backport/single-42' }; user = @{ login = 'test-app[bot]' } + body = Get-BackportJournalMarker $state + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*foreign or unregistered*' + $db.writes.Count | Should -Be 0 + $db.comments.Count | Should -Be 0 + $db.pulls.Count | Should -Be 1 + } + + It 'rechecks old aliases after push and stops before recording PR creation intent' { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Use-ReadableBranchFixture + Mock Invoke-BackportGit { + $script:db.remoteHeads[$script:state.cohort.branch] = $script:state.cohort.head + $script:db.pulls['91'] = @{ + number = 91; title = '[Simulation] Concurrent personal backport'; state = 'closed' + head = @{ ref = 'bc-extrequest-backport/single-42' }; user = @{ login = 'human' }; body = '' + } + return @{ text = ''; exit_code = 0 } + } -ParameterFilter { $Arguments[0] -eq 'push' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*legacy alias*' + $db.comments['1000'].body | Should -Match ':branch-intent:' + $db.comments['1000'].body | Should -Not -Match ':publication-intent:' + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportApi -Times 0 -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/pulls' } + } + + It 'normalizes actual team names to safe lowercase ASCII branch slugs' -ForEach @( + @{ team = 'Team:SCM'; expected = 'scm' }, + @{ team = 'Team: Finance'; expected = 'finance' }, + @{ team = 'Team: MiXeD CASE / & Fulfillment...'; expected = 'mixed-case-fulfillment' }, + @{ team = 'Team: --Client_UI::v2--'; expected = 'client-ui-v2' } + ) { + $script:state = New-MilestoneFixture -Team $team -Outcomes @('applied') + $request = $state.cohort.request + $request.reference_time = '2026-10-21T21:42:00Z' + [void]$request.Remove('key') + $request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $request)).Substring(0, 32) + Register-MilestoneSources $state + Use-ReadableBranchFixture + $state.cohort.branch | Should -BeExactly "bc-extrequest-backport/$expected-2026-10-21" + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls['200'].head.ref | Should -BeExactly $state.cohort.branch + Assert-BackportMilestonePull $db.pulls['200'] $state 'test-app[bot]' + (Get-BackportSourceTeam $db.sources['42'] $state.cohort.members[0].issues microsoft/BCApps) | Should -BeExactly $team + } + + It 'shares the Copenhagen title date across midnight and daylight-saving boundaries' -ForEach @( + @{ clock = '2026-10-21T22:30:00Z'; expected = '2026-10-22' }, + @{ clock = '2026-01-31T23:30:00Z'; expected = '2026-02-01' }, + @{ clock = '2026-10-25T22:30:00Z'; expected = '2026-10-25' } + ) { + $request = $state.cohort.request + $request.reference_time = $clock + (Get-BackportBranchBase $request) | Should -BeExactly "bc-extrequest-backport/finance-$expected" + Get-BackportTitle $state | Should -Match "till $expected$" + } + + It 'rejects teams without a nonempty safe ASCII slug' -ForEach @( + @{ team = 'Team: !!!' }, @{ team = 'Team: 中文' }, @{ team = 'Team: ---' } + ) { + $state.cohort.request.team = $team + { Get-BackportBranchBase $state.cohort.request } | Should -Throw '*safe ASCII*' + } + + It 'allocates the lowest suffix across remote branches, closed PRs and journal-only reservations' { + Use-ReadableBranchFixture + $base = $state.cohort.branch + $db.remoteHeads[$base] = '0' * 40 + $db.pulls['90'] = @{ number = 90; state = 'closed'; head = @{ ref = "$base-2" } } + $reserved = @{} + $reserved += $state.cohort + $reserved.branch = "$base-3" + $records = @(@{ manifest = $reserved }) + $chosen = New-BackportCohortBranch microsoft/BCApps $TestDrive $state.cohort.request $records + $chosen | Should -BeExactly "$base-4" + [void]$db.remoteHeads.Remove($base) + (New-BackportCohortBranch microsoft/BCApps $TestDrive $state.cohort.request $records) | Should -BeExactly $base + $db.writes.Count | Should -Be 0 + } + + It 'reserves all-conflict and no-op cohorts without a remote branch and separates another same-day run' -ForEach @( + @{ outcome = 'conflict' }, @{ outcome = 'already-present' } + ) { + $script:state = New-MilestoneFixture -Outcomes @($outcome) + Register-MilestoneSources $state + Use-ReadableBranchFixture + $first = $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.remoteHeads.Count | Should -Be 0 + $script:state = New-MilestoneFixture -Start 50 -Outcomes @('applied') + $state.cohort.request.reference_time = $first.cohort.request.reference_time + [void]$state.cohort.request.Remove('key') + $state.cohort.request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $state.cohort.request)).Substring(0, 32) + Register-MilestoneSources $state + $state.cohort.branch = New-BackportCohortBranch microsoft/BCApps $TestDrive $state.cohort.request @(Get-BackportBatchRecords microsoft/BCApps) + $state.cohort.branch | Should -BeExactly "$($first.cohort.branch)-2" + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.comments.Count | Should -Be 2 + $db.pulls['200'].head.ref | Should -BeExactly $state.cohort.branch + $db.sources['42'].labels.name | Should -Not -Contain 'ext-backport-processed' + } + + It 'keeps readable pins and compact rows through later discovery, target changes and closed PRs' { + Use-ReadableBranchFixture + $branch = $state.cohort.branch + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls['200'].state = 'closed' + $db.version = '29.2' + $row = Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -TargetOverride releases/30.x -BatchId 999 + $row.key | Should -BeExactly $state.cohort.request.key + $row.target | Should -BeExactly 'releases/29.x' + $row.ContainsKey('branch') | Should -BeFalse + (ConvertTo-Json $row -Compress -Depth 30).Length | Should -BeLessThan 4000 + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $statePath + $prepared = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + $prepared.cohort.branch | Should -BeExactly $branch + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportGit -Times 1 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'rejects malformed or identity-changing pinned branches' -ForEach @( + @{ branch = 'human/finance-2026-10-21' }, @{ branch = 'bc-extrequest-backport/../main' }, + @{ branch = 'bc-extrequest-backport/backport_10' }, @{ branch = 'bc-extrequest-backport/finance-2026-10-20' }, + @{ branch = 'bc-extrequest-backport/finance-2026-10-21-1' }, @{ branch = 'bc-extrequest-backport/finance-2026-10-21-02' }, + @{ branch = 'bc-extrequest-backport/Finance-2026-10-21' }, @{ branch = 'bc-extrequest-backport/finance-2026-10-21.lock' }, + @{ branch = '' }, @{ branch = $null }, @{ branch = @('bc-extrequest-backport/finance-2026-10-21') } + ) { + $state.cohort.request.reference_time = '2026-10-21T12:00:00Z' + [void]$state.cohort.request.Remove('key') + $state.cohort.request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $state.cohort.request)).Substring(0, 32) + $state.cohort.branch = $branch + Save-MilestoneFixture $state + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*branch*' + $db.writes.Count | Should -Be 0 + } + + It 'rejects readable branch tampering in prepared state, journal or PR without adopting branch identity alone' -ForEach @( + @{ kind = 'prepared' }, @{ kind = 'journal' }, @{ kind = 'pr-head' }, @{ kind = 'pr-source' } + ) { + Use-ReadableBranchFixture + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + switch ($kind) { + 'prepared' { $state.cohort.branch += '-2'; Save-MilestoneFixture $state } + 'journal' { + $other = ConvertTo-BackportCanonicalJson $state | ConvertFrom-Json -AsHashtable -Depth 80 + $other.cohort.branch += '-2' + $db.comments['1000'].body = Get-BackportJournalMarker $other + } + 'pr-head' { $db.pulls['200'].head.ref += '-2' } + 'pr-source' { + $other = New-MilestoneFixture -Start 50 -Outcomes @('applied') + $db.pulls['200'].body = Get-BackportMilestonePullBody $other 100 + } + } + $count = $db.writes.Count + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw + $db.writes.Count | Should -Be $count + } + + It 'fails rather than repinning a branch that became reserved during preparation' { + Use-ReadableBranchFixture + $first = $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $script:state = New-MilestoneFixture -Start 50 -Outcomes @('applied') + Register-MilestoneSources $state + $state.cohort.branch = $first.cohort.branch + Save-MilestoneFixture $state + $count = $db.writes.Count + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*reserved*' + $db.writes.Count | Should -Be $count + } + + It 'rejects an unreserved remote-only branch even when its SHA equals the prepared head' { + Use-ReadableBranchFixture + $db.remoteHeads[$state.cohort.branch] = $state.cohort.head + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*appeared*' + $db.writes.Count | Should -Be 0 + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'resumes a lost push response from its confirmed branch intent without repinning or repeating a PR POST' { + Use-ReadableBranchFixture + Mock Invoke-BackportGit { + $script:db.remoteHeads[$script:state.cohort.branch] = $script:state.cohort.head + throw 'Lost push response' + } -ParameterFilter { $Arguments[0] -eq 'push' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Lost push*' + $db.comments['1000'].body | Should -Match 'branch-intent:v1' + $db.comments['1000'].body | Should -Not -Match ':publication-intent:' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls.Count | Should -Be 1 + $db.comments.Count | Should -Be 1 + Should -Invoke Invoke-BackportGit -Times 1 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'reads an exact existing local-bare head without pushing only under confirmed branch intent' -ForEach @( + @{ confirmedIntent = $true }, @{ confirmedIntent = $false } + ) { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + $state.cohort.branch = Get-BackportBranchBase $state.cohort.request + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $local = Join-Path $root 'git' + $bare = Join-Path $root 'remote.git' + $null = New-Item -ItemType Directory -Path $local -Force + $null = & $script:originalBackportGit $local @('init', '--quiet') + [IO.File]::WriteAllText((Join-Path $local '.git/empty-config'), '') + $null = & $script:originalBackportGit $local @('commit', '--quiet', '--allow-empty', '-m', 'prepared head') + $head = (& $script:originalBackportGit $local @('rev-parse', 'HEAD')).text.Trim() + $null = & $script:originalBackportGit $local @('init', '--bare', '--quiet', $bare) + $null = & $script:originalBackportGit $local @('remote', 'add', 'origin', $bare) + $null = & $script:originalBackportGit $local @('-c', 'protocol.file.allow=always', 'push', 'origin', + "$($head):refs/heads/$($state.cohort.branch)") + $state.cohort.head = $head + $state.cohort.members[0].result.commit = $head + $script:statePath = Join-Path $root 'state.json' + Save-MilestoneFixture $state + Add-MilestoneTracker (Get-BackportSharedBody $state.release) + $body = Get-BackportJournalMarker $state + if ($confirmedIntent) { $body += "`n" } + $db.comments['1000'] = @{ id = 1000; issue_number = 100; body = $body; user = @{ login = 'test-app[bot]' } } + Mock Invoke-BackportGit { + & $script:originalBackportGit -Directory $Directory ` + -Arguments (@('-c', 'protocol.file.allow=always') + $Arguments) ` + -InputText $InputText -AllowFailure:$AllowFailure -CommitDate $CommitDate + } + if ($confirmedIntent) { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls['200'].head.sha | Should -BeExactly $head + } + else { + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*no confirmed branch publication intent*' + $db.writes.Count | Should -Be 0 + $db.pulls.Count | Should -Be 0 + } + (& $script:originalBackportGit $local @("--git-dir=$bare", 'rev-parse', + "refs/heads/$($state.cohort.branch)")).text.Trim() | Should -BeExactly $head + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'reserves unfinished same-team cohorts and normalizes distinct labels without cross-associating PRs' { + Use-ReadableBranchFixture + Mock Invoke-BackportApi { throw 'Interrupted after journal reservation' } -ParameterFilter { + $Method -eq 'PATCH' -and $Path -like '*/issues/comments/*' -and $Body.body.Contains(':branch-intent:') + } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Interrupted*' + $first = $state + $db.remoteHeads.Count | Should -Be 0 + $script:state = New-MilestoneFixture -Start 50 -Team 'Team: FINANCE!!' -Outcomes @('already-present') + $state.cohort.request.reference_time = $first.cohort.request.reference_time + [void]$state.cohort.request.Remove('key') + $state.cohort.request.key = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $state.cohort.request)).Substring(0, 32) + Register-MilestoneSources $state + $state.cohort.branch = New-BackportCohortBranch microsoft/BCApps $TestDrive $state.cohort.request @(Get-BackportBatchRecords microsoft/BCApps) + $state.cohort.branch | Should -BeExactly "$($first.cohort.branch)-2" + Save-MilestoneFixture $state + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls.Count | Should -Be 0 + $db.comments.Count | Should -Be 2 + $records = @(Get-BackportBatchRecords microsoft/BCApps) + @($records | Where-Object { $_.journal.body.Contains(':journal-complete:') }).Count | Should -Be 1 + $row = Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 999 + $row.key | Should -BeExactly $first.cohort.request.key + $row.source_prs.Count | Should -Be 3 + } + + It 'rejects a branch appearing during journal persistence without branch intent or a push' { + Use-ReadableBranchFixture + Mock Invoke-BackportApi { + $id = $script:db.nextComment++ + $comment = @{ id = $id; issue_number = 100; body = $Body.body; user = @{ login = 'test-app[bot]' } } + $script:db.comments[[string]$id] = $comment + $script:db.remoteHeads[$script:state.cohort.branch] = $script:state.cohort.head + return $comment + } -ParameterFilter { $Method -eq 'POST' -and $Path -like '*/issues/100/comments' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*no confirmed branch*' + $db.comments['1000'].body | Should -Not -Match ':branch-intent:|:publication-intent:' + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + $db.pulls.Count | Should -Be 0 + } + + It 'rejects changed branch reservations and malformed branch intents before any retry writes' -ForEach @( + @{ kind = 'wrong-sha' }, @{ kind = 'duplicate' }, @{ kind = 'malformed' }, @{ kind = 'reservation' } + ) { + Use-ReadableBranchFixture + Mock Invoke-BackportGit { throw 'Interrupted push' } -ParameterFilter { $Arguments[0] -eq 'push' } + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*Interrupted push*' + switch ($kind) { + 'wrong-sha' { + $db.comments['1000'].body = $db.comments['1000'].body.Replace( + "branch-intent:v1 sha=$($state.cohort.head)", "branch-intent:v1 sha=$('0' * 40)") + } + 'duplicate' { $db.comments['1000'].body += "`n" } + 'malformed' { $db.comments['1000'].body = $db.comments['1000'].body.Replace('branch-intent:v1 sha=', 'branch-intent:invalid sha=') } + 'reservation' { + $other = New-MilestoneFixture -Start 50 -Outcomes @('conflict') + $other.cohort.branch = $state.cohort.branch + $db.comments['1001'] = @{ + id = 1001; issue_number = 100; body = Get-BackportJournalMarker $other; user = @{ login = 'test-app[bot]' } + } + } + } + $writes = $db.writes.Count + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw + $db.writes.Count | Should -Be $writes + $db.pulls.Count | Should -Be 0 + } + + It 'preserves readable pins on explicit singleton conflict retries and rejects a new suffix' -ForEach @( + @{ changed = $false }, @{ changed = $true } + ) { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('conflict') + Register-MilestoneSources $state + Use-ReadableBranchFixture + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $old = @(Get-BackportJournalRecords microsoft/BCApps)[0] + $revision = New-MilestoneFixture -Mode single -Outcomes @('applied') + $revision.cohort.request = $state.cohort.request + $revision.cohort.branch = $state.cohort.branch + $revision.supersedes = $old.journal.id + if ($changed) { $revision.cohort.branch += '-2' } + $script:state = $revision + Save-MilestoneFixture $state + if ($changed) { + $count = $db.writes.Count + { Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' } | Should -Throw '*pinned cohort branch*' + $db.writes.Count | Should -Be $count + } + else { + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.comments.Count | Should -Be 2 + $db.pulls['200'].head.ref | Should -BeExactly 'bc-extrequest-backport/backport_42' + @(Get-BackportJournalRecords microsoft/BCApps).Count | Should -Be 1 + } + } + + It 'retains old schema-three branch derivation and canonical proof hashes when branch is absent' { + $marker = Get-BackportJournalMarker $state + $state.cohort.ContainsKey('branch') | Should -BeFalse + $expected = "bc-extrequest-backport/$($state.cohort.request.key)" + (Get-BackportCohortBranch $state.cohort) | Should -BeExactly $expected + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls['200'].head.ref | Should -BeExactly $expected + $row = Find-ExtRequestBackportBatches microsoft/BCApps -SourcePr 42 -BatchId 999 + New-ExtRequestBackportBatch microsoft/BCApps (ConvertTo-Json $row -Depth 60) $statePath + $loaded = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + (Get-BackportJournalMarker $loaded) | Should -BeExactly $marker + $loaded.cohort.ContainsKey('branch') | Should -BeFalse + $db.comments['1000'].body | Should -Not -Match ':branch-intent:' + } + + It 'pins readable names when publishing genuinely unpublished schema-one or schema-two preparations' -ForEach @( + @{ schema = 1 }, @{ schema = 2 } + ) { + if ($schema -eq 1) { $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') } + Register-MilestoneSources $state + Use-ReadableBranchFixture + $expected = $state.cohort.branch + if ($schema -eq 1) { + $old = $state.cohort.members[0] + $old.release = $state.release + } + else { + $old = @{} + $state.cohort + [void]$old.Remove('branch') + } + Save-MilestoneFixture $old + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $wrapped = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + $wrapped.schema | Should -Be 3 + $wrapped.cohort.branch | Should -BeExactly $expected + $db.pulls['200'].head.ref | Should -BeExactly $expected + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $db.pulls.Count | Should -Be 1 + $db.comments.Count | Should -Be 1 + } + + It 'retains an unpublished durable native-v1 branch when promoting its App-authored tracker' { + $script:state = New-MilestoneFixture -Mode single -Outcomes @('applied') + Register-MilestoneSources $state + Use-ReadableBranchFixture + $member = $state.cohort.members[0] + $member.release = $state.release + $branch = Get-BackportBranch $member.source_pr $member.target + $db.remoteHeads[$branch] = $member.result.commit + $db.issues['90'] = @{ + number = 90; title = 'Legacy Task'; body = Get-BackportBody $member; state = 'open'; milestone = $null + user = @{ login = 'test-app[bot]' }; labels = @(@{ name = 'ext-backporting' }) + html_url = 'https://github.com/microsoft/BCApps/issues/90' + } + $body = $db.issues['90'].body + Save-MilestoneFixture $member + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $wrapped = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 + $wrapped.legacy_issue | Should -Be 90 + $wrapped.cohort.branch | Should -BeExactly 'bc-extrequest-backport/pr-42/29.x' + $db.pulls['200'].head.ref | Should -BeExactly $branch + $db.issues['90'].body | Should -BeExactly $body + Publish-ExtRequestBackport $statePath 'test-app[bot]' + $db.pulls.Count | Should -Be 1 + Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } + } + + It 'retains a published closed v2 PR and its hashed branch through a version rollover' { + $legacy = $state.cohort + $body = Get-BackportBatchBody $legacy + $branch = "bc-extrequest-backport/$($legacy.request.key)" + $db.issues['90'] = @{ + number = 90; title = 'Legacy cohort'; body = $body; state = 'closed'; milestone = $null + user = @{ login = 'test-app[bot]' }; labels = @(@{ name = 'ext-backporting' }) + } + $db.pulls['91'] = @{ + number = 91; body = Get-BackportBatchBody $legacy 90; state = 'closed'; merged_at = $null + user = @{ login = 'test-app[bot]' }; html_url = 'https://github.com/microsoft/BCApps/pull/91' + head = @{ ref = $branch; sha = $legacy.head; repo = @{ full_name = 'microsoft/BCApps' } } + base = @{ ref = $legacy.request.target; repo = @{ full_name = 'microsoft/BCApps' } } + } + Save-MilestoneFixture $legacy + $db.version = '29.2' + Publish-ExtRequestBackportBatch $statePath 'test-app[bot]' + $db.pulls['91'].head.ref | Should -BeExactly $branch + $db.issues['90'].body | Should -BeExactly $body + $db.comments.Count | Should -Be 0 + $db.writes.Count | Should -Be 1 + $db.writes[0].path | Should -BeExactly 'repos/microsoft/BCApps/issues/42/labels' + Should -Invoke Invoke-BackportGit -Times 0 + } + It 'resolves the target branch version exactly instead of the highest open milestone' { $release = Get-BackportRelease microsoft/BCApps releases/29.x $release.version | Should -Be '29.1' @@ -436,11 +1033,12 @@ Describe 'Shared milestone journal engine' { $db.writes.Count | Should -Be 0 } - It 'pushes only the exact prepared branch tip, without force, before recording PR intent' { + It 'pushes only the exact prepared branch tip with an empty create-only lease before recording PR intent' { $script:pushed = $false Mock Invoke-BackportGit { if ($Arguments[0] -eq 'push') { - $Arguments -join ' ' | Should -BeExactly "push origin $($script:state.cohort.head):refs/heads/bc-extrequest-backport/$($script:state.cohort.request.key)" + $branch = Get-BackportCohortBranch $script:state.cohort + $Arguments -join ' ' | Should -BeExactly "push --force-with-lease=refs/heads/$($branch): origin $($script:state.cohort.head):refs/heads/$branch" $script:db.comments['1000'].body | Should -Not -Match 'publication-intent:' $script:pushed = $true return @{ text = '' } @@ -1713,6 +2311,57 @@ Describe 'Native Git backport integration' { (Invoke-BackportGit $directory @('log', '-1', '--format=%B')).text | Should -Match "cherry picked from commit $source" } + It 'never changes an existing local-bare ref even when an identical head succeeds as a no-op' -ForEach @( + @{ existing = 'absent' }, @{ existing = 'ancestor' }, @{ existing = 'divergent' }, @{ existing = 'identical' } + ) { + $bare = Join-Path $TestDrive ([guid]::NewGuid().ToString('N') + '.git') + $null = Invoke-BackportGit $directory @('init', '--bare', '--quiet', $bare) + $null = Invoke-BackportGit $directory @('remote', 'add', 'origin', $bare) + $script:createOnlyActualGit = (Get-Command Invoke-BackportGit).ScriptBlock + Mock Invoke-BackportGit { + & $script:createOnlyActualGit -Directory $Directory ` + -Arguments (@('-c', 'protocol.file.allow=always') + $Arguments) ` + -InputText $InputText -AllowFailure:$AllowFailure -CommitDate $CommitDate + } + $prepared = Invoke-BackportApply $directory $source $base + $prepared.outcome | Should -Be 'applied' + $null = Invoke-BackportGit $directory @('push', 'origin', "$($prepared.commit):refs/heads/fixture") + $branch = 'bc-extrequest-backport/backport_11166' + $ref = "refs/heads/$branch" + (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', $ref)).text.Trim() | Should -BeNullOrEmpty + if ($existing -ne 'absent') { + $foreign = if ($existing -eq 'identical') { $prepared.commit } else { $base } + if ($existing -eq 'divergent') { + $null = Invoke-BackportGit $directory @('checkout', '--detach', $base) + $null = Invoke-BackportGit $directory @('commit', '--quiet', '--allow-empty', '-m', 'foreign divergent head') + $foreign = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + $null = Invoke-BackportGit $directory @('push', 'origin', "$($foreign):refs/heads/foreign-fixture") + } + $null = Invoke-BackportGit $directory @("--git-dir=$bare", 'update-ref', $ref, $foreign) + if ($existing -eq 'ancestor') { + (Invoke-BackportGit $directory @('push', '--dry-run', 'origin', "$($prepared.commit):$ref")).exit_code | + Should -Be 0 + } + if ($existing -eq 'identical') { + { Publish-BackportBranchCreateOnly $directory $branch $prepared.commit } | Should -Not -Throw + } + else { + { Publish-BackportBranchCreateOnly $directory $branch $prepared.commit } | Should -Throw '*failed*' + } + (Invoke-BackportGit $directory @("--git-dir=$bare", 'rev-parse', $ref)).text.Trim() | Should -BeExactly $foreign + } + else { + Publish-BackportBranchCreateOnly $directory $branch $prepared.commit + (Invoke-BackportGit $directory @("--git-dir=$bare", 'rev-parse', $ref)).text.Trim() | Should -BeExactly $prepared.commit + } + Should -Invoke Invoke-BackportGit -Times 1 -ParameterFilter { + $Arguments[0] -eq 'push' -and + $Arguments[1] -ceq '--force-with-lease=refs/heads/bc-extrequest-backport/backport_11166:' -and + $Arguments[2] -ceq 'origin' -and + $Arguments[3] -ceq "$($prepared.commit):refs/heads/bc-extrequest-backport/backport_11166" + } + } + It 'proves an already-applied patch instead of creating an empty PR' { $result = Invoke-BackportApply $directory $source $source $result.outcome | Should -Be 'already-present' @@ -1771,7 +2420,8 @@ Describe 'Native Git backport integration' { } It 'prepares real Git through the team or standalone API and resumes immutable state' -ForEach @( - @{ mode = 'team' }, @{ mode = 'single' } + @{ mode = 'team'; collision = $false }, @{ mode = 'single'; collision = $false }, + @{ mode = 'team'; collision = $true }, @{ mode = 'single'; collision = $true } ) { [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "source`nsecond`n") $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'second') @@ -1788,6 +2438,11 @@ Describe 'Native Git backport integration' { $script:release = $base $script:durable = $null $script:remoteHead = '' + $script:occupiedBranch = '' + if ($collision) { + $script:occupiedBranch = if ($mode -eq 'single') { 'bc-extrequest-backport/backport_42' } + else { Get-BackportBranchBase $request } + } $script:actualGit = (Get-Command Invoke-BackportGit).ScriptBlock Mock Get-BackportBatchRecords { if ($null -ne $script:durable) { @@ -1821,7 +2476,12 @@ Describe 'Native Git backport integration' { } Mock Invoke-BackportGit { if ($Arguments[0] -eq 'fetch') { return @{ text = ''; exit_code = 0 } } - if ($Arguments[0] -eq 'ls-remote') { return @{ text = $script:remoteHead; exit_code = 0 } } + if ($Arguments[0] -eq 'ls-remote') { + if ($script:occupiedBranch -and $Arguments[-1] -ceq "refs/heads/$script:occupiedBranch") { + return @{ text = "$('0' * 40)`trefs/heads/$script:occupiedBranch"; exit_code = 0 } + } + return @{ text = $script:remoteHead; exit_code = 0 } + } & $script:actualGit -Directory $Directory -Arguments $Arguments -InputText $InputText ` -AllowFailure:$AllowFailure -CommitDate $CommitDate } @@ -1838,6 +2498,9 @@ Describe 'Native Git backport integration' { $script:preparedState = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable -Depth 80 $preparedState.schema | Should -Be 3 $script:durable = $preparedState.cohort + $expectedBranch = Get-BackportBranchBase $durable.request + if ($collision) { $expectedBranch += '-2' } + $durable.branch | Should -BeExactly $expectedBranch $durable.members.Count | Should -Be $outcomes.Count if ($mode -eq 'team') { $durable.members[1].result.target_sha | Should -Be $durable.members[0].result.commit @@ -1855,7 +2518,7 @@ Describe 'Native Git backport integration' { $null = New-Item -ItemType Directory $resumeRoot Copy-Item -LiteralPath $preparedDirectory -Destination (Join-Path $resumeRoot 'git') -Recurse $script:release = '0' * 40 - $script:remoteHead = "$($durable.head)`trefs/heads/bc-extrequest-backport/$($request.key)" + $script:remoteHead = "$($durable.head)`trefs/heads/$($durable.branch)" $row = @{} + $durable.request $row.manifest = $durable $resumedPath = Join-Path $resumeRoot 'state.json' @@ -1866,13 +2529,16 @@ Describe 'Native Git backport integration' { Should -Invoke Invoke-BackportGit -Times 0 -ParameterFilter { $Arguments[0] -eq 'push' } } - It 'does not replay a frozen singleton conflict until explicit manual retry on the current release' { + It 'does not replay a frozen singleton conflict until explicit manual retry on the current release' -ForEach @( + @{ readable = $false }, @{ readable = $true } + ) { $null = Invoke-BackportGit $directory @('checkout', '--detach', $base) [IO.File]::WriteAllText((Join-Path $directory 'src/Foo.al'), "incompatible release`n") $null = Invoke-BackportGit $directory @('commit', '--quiet', '-am', 'old release') $oldRelease = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() $cohort = New-BatchFixture -Outcomes @('conflict') $cohort.request.mode = 'single'; $cohort.request.key = 'single-42'; $cohort.request.manual = $true + if ($readable) { $cohort.branch = 'bc-extrequest-backport/backport_42-2' } $cohort.request.sources[0].sha = $source $cohort.target_sha = $oldRelease; $cohort.head = $oldRelease $cohort.tree = (Invoke-BackportGit $directory @('rev-parse', 'HEAD^{tree}')).text.Trim() @@ -1923,6 +2589,7 @@ Describe 'Native Git backport integration' { $prepared.supersedes | Should -Be 1000 $prepared.cohort.target_sha | Should -Be $base $prepared.cohort.members[0].result.outcome | Should -Be 'applied' + (Get-BackportCohortBranch $prepared.cohort) | Should -BeExactly (Get-BackportCohortBranch $cohort) Assert-BackportPreparedBatch $prepared.cohort (Join-Path $root 'git') } diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 index f73a2f036bb..d8e5ab19c2c 100644 --- a/.github/scripts/bc-extrequest-backport/Backport.psm1 +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -306,6 +306,13 @@ function Invoke-BackportGit { -InputText $InputText -Environment $environment -AllowFailure:$AllowFailure } +function Publish-BackportBranchCreateOnly { + param([string] $Directory, [string] $Branch, [string] $Head) + # An empty lease cannot change an existing ref; identical heads may succeed as a no-op, not ownership proof. + $null = Invoke-BackportGit $Directory @('push', "--force-with-lease=refs/heads/$($Branch):", + 'origin', "$($Head):refs/heads/$Branch") +} + function Assert-BackportPaths { param([string] $RawDiff) @@ -471,7 +478,7 @@ function New-ExtRequestBackport { $null = New-Item -ItemType Directory -Path (Split-Path -Parent $StatePath) -Force $state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM $issueLinks = if ($issues.Count -gt 0) { ($issues | ForEach-Object { $_.url }) -join ', ' } else { '(none)' } - $teams = @($source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team: *' }) + $teams = @($source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team:*' }) $teamText = if ($teams.Count -gt 0) { ($teams -join ', ') -replace '[\r\n]', ' ' } else { '(none)' } Write-BackportSummary "Main PR #$SourcePr (https://github.com/$Repository/pull/$SourcePr) -> $target`: **$($result.outcome)**. Main PR teams: $teamText. Source issues: $issueLinks" } @@ -602,9 +609,7 @@ function Get-BackportTitle { if ($State.schema -eq 2) { $version = ($State.request.target -split '/')[1] $team = $State.request.team -replace '[\r\n]', ' ' - $zone = [TimeZoneInfo]::FindSystemTimeZoneById('Europe/Copenhagen') - $localTime = [TimeZoneInfo]::ConvertTime([DateTimeOffset]$State.request.reference_time, $zone) - $date = $localTime.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) + $date = Get-BackportDiscoveryDate $State.request $title = "[Backport $version] [Extensibility Requests] [$team] till $date" } else { @@ -759,7 +764,7 @@ function Get-BackportMainTeam { param([hashtable] $Source) $teams = @($Source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team:*' } | Sort-Object -Unique) if ($teams.Count -eq 0) { return 'Unassigned' } - if ($teams.Count -ne 1 -or $teams[0] -cnotmatch '^Team: \S[^\r\n]*$') { return 'Ambiguous' } + if ($teams.Count -ne 1 -or $teams[0] -cnotmatch '^Team: *\S[^\r\n]*$') { return 'Ambiguous' } return $teams[0] } @@ -778,7 +783,7 @@ function Assert-BackportJournalState { } foreach ($member in $State.cohort.members) { if (-not $member.ContainsKey('main_team') -or - ($member.main_team -notin @('Unassigned', 'Ambiguous') -and $member.main_team -cnotmatch '^Team: \S[^\r\n]*$') -or + ($member.main_team -notin @('Unassigned', 'Ambiguous') -and $member.main_team -cnotmatch '^Team: *\S[^\r\n]*$') -or ($State.cohort.request.mode -eq 'team' -and $member.main_team -cne $State.cohort.request.team)) { throw 'Journal original main-PR team metadata is missing or inconsistent.' } @@ -811,6 +816,18 @@ function Assert-BackportJournal { throw 'Cohort journal author or immutable manifest changed.' } $null = Read-BackportJournalPublication $Comment.body $State.cohort.head + $null = Read-BackportBranchIntent $Comment.body $State.cohort.head +} + +function Read-BackportBranchIntent { + param([string] $Body, [string] $Head) + $found = [regex]::Matches($Body, '(?m)^\r?$') + if ($found.Count -gt 1 -or + ($found.Count -eq 0 -and $Body.Contains('bc-extrequest-backport:branch-intent:')) -or + ($found.Count -eq 1 -and $found[0].Groups[1].Value -cne $Head)) { + throw 'Malformed, conflicting or changed branch publication intent.' + } + return $found.Count -eq 1 } function Read-BackportJournalPublication { @@ -910,6 +927,9 @@ function Get-BackportJournalRecords { $state.cohort.members[0].source_sha -cne $prior[0].manifest.members[0].source_sha) { throw 'Duplicate/conflicting journal source membership or unsafe conflict retry.' } + if ((Get-BackportCohortBranch $state.cohort) -cne (Get-BackportCohortBranch $prior[0].manifest)) { + throw 'Conflict retry changed the pinned cohort branch.' + } } elseif ($state.ContainsKey('supersedes')) { throw 'Conflict retry has no preceding cohort journal.' } foreach ($number in $numbers) { $active[[string]$number] = $record } @@ -1048,7 +1068,7 @@ function Assert-BackportBatchRequest { if ($numbers.Count -ne 1 -or $Request.key -cne "single-$($numbers[0])") { throw 'Invalid independent request.' } } else { - if ($Request.team -cnotmatch '^Team: \S[^\r\n]*$' -or $Request.manual) { throw 'Invalid team request.' } + if ($Request.team -cnotmatch '^Team: *\S[^\r\n]*$' -or $Request.manual) { throw 'Invalid team request.' } $identity = Get-BackportRequestCore $Request [void]$identity.Remove('key') $expected = 'team-' + (Get-BackportDigest (ConvertTo-BackportCanonicalJson $identity)).Substring(0, 32) @@ -1090,6 +1110,7 @@ function Assert-BackportBatchManifest { if ($Manifest.schema -ne 2) { throw 'Unsupported batch manifest schema.' } Assert-BackportRepository $Manifest.repository Assert-BackportBatchRequest $Manifest.request + $null = Get-BackportCohortBranch $Manifest if ($Manifest.request.mode -notin @('team', 'single') -or (ConvertTo-BackportCanonicalJson $Manifest.request) -cne (ConvertTo-BackportCanonicalJson (Get-BackportRequestCore $Manifest.request)) -or @@ -1146,21 +1167,104 @@ function Get-BackportBatchRecords { } } +function Get-BackportDiscoveryDate { + param([hashtable] $Request) + $zone = [TimeZoneInfo]::FindSystemTimeZoneById('Europe/Copenhagen') + $localTime = [TimeZoneInfo]::ConvertTime([DateTimeOffset]$Request.reference_time, $zone) + $localTime.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) +} + +function Get-BackportBranchBase { + param([hashtable] $Request) + if ($Request.mode -eq 'single') { + return "bc-extrequest-backport/backport_$($Request.source_prs[0])" + } + $team = $Request.team -replace '^Team:\s*', '' + $slug = [regex]::Replace($team.ToLowerInvariant(), '[^a-z0-9]+', '-').Trim('-') + if (-not $slug) { throw 'Team has no safe ASCII branch slug.' } + "bc-extrequest-backport/$slug-$(Get-BackportDiscoveryDate $Request)" +} + +function Get-BackportCohortBranch { + param([hashtable] $Manifest) + # Absence is meaningful: old manifests and their hashes must remain unchanged. + if (-not $Manifest.ContainsKey('branch')) { return "bc-extrequest-backport/$($Manifest.request.key)" } + $branch = $Manifest.branch + if ($Manifest.request.mode -eq 'single' -and $branch -is [string] -and + $branch -ceq (Get-BackportBranch $Manifest.request.source_prs[0] $Manifest.request.target)) { return $branch } + $base = Get-BackportBranchBase $Manifest.request + $pattern = '^' + [regex]::Escape($base) + '(?:-(?:[2-9]|[1-9][0-9]+))?$' + if ($branch -isnot [string] -or $branch -cnotmatch $pattern) { + throw 'Pinned cohort branch is malformed or differs from its source/team discovery identity.' + } + return $branch +} + +function New-BackportCohortBranch { + param([string] $Repository, [string] $Directory, [hashtable] $Request, [object[]] $Records) + $base = Get-BackportBranchBase $Request + $reserved = @($Records | ForEach-Object { Get-BackportCohortBranch $_.manifest }) + for ($suffix = 1; $suffix -le 10000; $suffix++) { + $branch = if ($suffix -eq 1) { $base } else { "$base-$suffix" } + if ($branch -cin $reserved) { continue } + $remote = (Invoke-BackportGit $Directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if ($remote) { continue } + if ($null -ne (Get-BackportBatchPull $Repository -Branch $branch)) { continue } + return $branch + } + throw 'No free backport branch within the allocation limit; reconcile reservations manually.' +} + +function Assert-BackportBranchReservation { + param([hashtable] $State) + $branch = Get-BackportCohortBranch $State.cohort + foreach ($record in @(Get-BackportBatchRecords $State.repository)) { + if ((Get-BackportCohortBranch $record.manifest) -cne $branch) { continue } + if ($record.ContainsKey('shared') -and + (Get-BackportJournalMarker $record.journal_state) -ceq (Get-BackportJournalMarker $State)) { continue } + if ($State.ContainsKey('supersedes') -and $record.ContainsKey('shared') -and + $State.supersedes -eq $record.journal.id -and + $State.cohort.request.mode -eq 'single' -and $record.manifest.request.mode -eq 'single' -and + $State.cohort.members[0].source_pr -eq $record.manifest.members[0].source_pr -and + $State.cohort.members[0].source_sha -ceq $record.manifest.members[0].source_sha -and + $record.manifest.members[0].result.outcome -eq 'conflict') { continue } + if ($State.ContainsKey('legacy_issue') -and -not $record.ContainsKey('shared') -and + $State.legacy_issue -eq $record.issue.number) { continue } + throw 'Pinned backport branch is reserved by another immutable cohort; rediscover before publication.' + } +} + function Get-BackportBatchPull { - param([string] $Repository, [string] $Key) + param([string] $Repository, [string] $Key, [string] $Branch = '') + if (-not $Branch) { $Branch = "bc-extrequest-backport/$Key" } $owner = $Repository.Split('/')[0] - $pulls = @(Get-BackportApiPages "repos/$Repository/pulls?state=all&head=$owner`:bc-extrequest-backport/$Key") + $pulls = @(Get-BackportApiPages "repos/$Repository/pulls?state=all&head=$owner`:$Branch") if ($pulls.Count -gt 1) { throw 'Multiple PRs for one batch branch.' } if ($pulls.Count -eq 1) { return $pulls[0] } return $null } +function Assert-BackportSourceAliases { + param([string] $Repository, [long] $SourcePr, [string] $Target) + $aliases = @() + $native = Get-BackportPull $Repository $SourcePr $Target + if ($null -ne $native) { $aliases += $native } + $single = Get-BackportBatchPull $Repository -Branch "bc-extrequest-backport/single-$SourcePr" + if ($null -ne $single) { $aliases += $single } + if ($aliases.Count -gt 1) { + throw 'Ambiguous legacy standalone backport PR aliases; operator reconciliation is required.' + } + if ($aliases.Count -eq 1) { + throw 'An existing standalone backport PR on a legacy alias is foreign or unregistered; reconcile its source identity, never duplicate or adopt it.' + } +} + function Get-BackportSourceTeam { param([hashtable] $Source, [object[]] $Issues, [string] $Repository) - $teams = @($Source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team: *' }) - if ($teams.Count -ne 1 -or $teams[0] -cnotmatch '^Team: \S[^\r\n]*$') { return '' } + $teams = @($Source.labels | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team:*' }) + if ($teams.Count -ne 1 -or $teams[0] -cnotmatch '^Team: *\S[^\r\n]*$') { return '' } foreach ($issue in $Issues) { - $issueTeams = @($issue.labels.nodes | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team: *' }) + $issueTeams = @($issue.labels.nodes | ForEach-Object { $_.name } | Where-Object { $_ -clike 'Team:*' }) if ($issueTeams.Count -ne 1 -or $issueTeams[0] -cne $teams[0] -or -not $issue.ContainsKey('repository') -or $null -eq $issue.repository -or -not $issue.repository.ContainsKey('nameWithOwner') -or $issue.repository.nameWithOwner -cne $Repository) { return '' } @@ -1382,7 +1486,7 @@ function New-ExtRequestBackportBatch { return } $recordBody = if ($shared) { $overlap[0].journal.body } elseif ($resume) { $overlap[0].issue.body } else { '' } - $existingPull = if ($resume) { Get-BackportBatchPull $Repository $request.key } else { $null } + $existingPull = if ($resume) { Get-BackportBatchPull $Repository -Branch (Get-BackportCohortBranch $stored) } else { $null } $intent = Read-BackportPublicationIntent $recordBody $release = if ($shared) { $overlap[0].journal_state.release } elseif ($request.ContainsKey('release')) { $request.release } @@ -1409,9 +1513,11 @@ function New-ExtRequestBackportBatch { } $directory = Join-Path $root 'git' Initialize-BackportBatchGit $directory $Repository + $branch = if ($resume) { Get-BackportCohortBranch $stored } + else { New-BackportCohortBranch $Repository $directory $request $records } $targetSha = if ($resume -and -not $retryConflict) { $stored.target_sha } else { (Invoke-BackportApi "repos/$Repository/git/ref/heads/$($request.target)").object.sha } - $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/bc-extrequest-backport/$($request.key)")).text.Trim() + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() if ($remote -and -not $resume) { throw 'Untracked batch branch exists; reconcile manually.' } $null = Invoke-BackportGit $directory @('fetch', '--quiet', '--no-tags', '--filter=blob:none', '--depth=2', 'origin', "$targetSha`:refs/backport/target") @@ -1439,6 +1545,8 @@ function New-ExtRequestBackportBatch { schema = 2; repository = $Repository; request = $request; members = $members; target_sha = $targetSha head = $tip; tree = (Invoke-BackportGit $directory @('rev-parse', "$tip`^{tree}")).text.Trim() } + if (-not $resume) { $manifest.branch = $branch } + elseif ($stored.ContainsKey('branch')) { $manifest.branch = $stored.branch } if ($resume -and -not $retryConflict) { # Membership, outcomes, source proofs and commit chain cannot change during recovery. foreach ($index in 0..($members.Count - 1)) { @@ -1514,7 +1622,7 @@ function Assert-BackportBatchObject { throw 'Batch object has an unexpected author or immutable manifest.' } if ($Object.ContainsKey('head') -and - ($Object.head.ref -cne "bc-extrequest-backport/$($Manifest.request.key)" -or + ($Object.head.ref -cne (Get-BackportCohortBranch $Manifest) -or $Object.head.sha -cne $Manifest.head -or $Object.head.repo.full_name -cne $Manifest.repository -or $Object.base.ref -cne $Manifest.request.target -or $Object.base.repo.full_name -cne $Manifest.repository)) { throw 'Batch PR branch, target or provenance changed; never adopt human changes.' @@ -1568,7 +1676,7 @@ function Assert-BackportMilestonePull { $found = Read-BackportJournalMarker $Pull.body if ($null -eq $found -or (Get-BackportJournalMarker $found) -cne (Get-BackportJournalMarker $State) -or $Pull.user.login -cne $BotLogin -or $Pull.head.sha -cne $State.cohort.head -or - $Pull.head.ref -cne "bc-extrequest-backport/$($State.cohort.request.key)" -or + $Pull.head.ref -cne (Get-BackportCohortBranch $State.cohort) -or $Pull.head.repo.full_name -cne $State.repository -or $Pull.base.repo.full_name -cne $State.repository -or $Pull.base.ref -cne $State.release.target) { throw 'Backport PR author, branch, milestone cohort or SHA proof changed.' } if ($null -ne $Pull.milestone -and $Pull.milestone.number -ne $State.release.milestone_number) { @@ -1620,6 +1728,7 @@ function Assert-BackportNewCohort { } } foreach ($member in $cohort.members) { + Assert-BackportSourceAliases $repository $member.source_pr $member.target $source = Get-BackportSource $repository $member.source_pr if ($source.merge_commit_sha -cne $member.source_sha) { throw 'Source SHA changed before publication.' } if ('ext-backport-processed' -in @($source.labels | ForEach-Object { $_.name })) { @@ -1629,9 +1738,6 @@ function Assert-BackportNewCohort { if ($null -ne $tracking -and (-not $State.ContainsKey('legacy_issue') -or $tracking.number -ne $State.legacy_issue)) { throw 'Source acquired a legacy tracking identity; do not duplicate it.' } - if ($null -ne (Get-BackportPull $repository $member.source_pr $member.target)) { - throw 'An existing standalone backport PR must be reconciled through its legacy identity, not duplicated.' - } } $oldBatches = @(Get-BackportBatchRecords $repository | Where-Object { -not $_.ContainsKey('shared') -and @@ -1640,7 +1746,8 @@ function Assert-BackportNewCohort { if (@($oldBatches | Where-Object { -not $State.ContainsKey('legacy_issue') -or $_.issue.number -ne $State.legacy_issue }).Count -gt 0) { throw 'Source acquired a legacy batch identity; do not duplicate it.' } - if ($null -ne (Get-BackportBatchPull $repository $cohort.request.key)) { + Assert-BackportBranchReservation $State + if ($null -ne (Get-BackportBatchPull $repository -Branch (Get-BackportCohortBranch $cohort))) { throw 'Unregistered PR appeared before journal creation.' } } @@ -1652,6 +1759,7 @@ function Publish-BackportMilestone { $repository = $State.repository $cohort = $State.cohort $release = $State.release + Assert-BackportBranchReservation $State $records = @(Get-BackportJournalRecords $repository) $overlap = @($records | Where-Object { @($_.manifest.request.source_prs | Where-Object { $_ -in $cohort.request.source_prs }).Count -gt 0 @@ -1665,6 +1773,9 @@ function Publish-BackportMilestone { $cohort.members[0].source_sha -ceq $record.manifest.members[0].source_sha -and (Get-BackportSharedMarker $release) -ceq (Get-BackportSharedMarker $record.journal_state.release) if ($null -ne $record -and -not $same -and -not $revision) { throw 'Source membership already belongs to another immutable cohort.' } + if ($revision -and (Get-BackportCohortBranch $cohort) -cne (Get-BackportCohortBranch $record.manifest)) { + throw 'Conflict retry changed the pinned cohort branch.' + } $legacyBatch = $false if ($State.ContainsKey('legacy_issue')) { $legacy = Invoke-BackportApi "repos/$repository/issues/$($State.legacy_issue)" @@ -1673,7 +1784,7 @@ function Publish-BackportMilestone { } $null = @(Assert-BackportBatchSources $cohort.request $repository -Resume:($null -ne $record -or $State.ContainsKey('legacy_issue'))) Assert-BackportRelease $release $repository - $pull = Get-BackportBatchPull $repository $cohort.request.key + $pull = Get-BackportBatchPull $repository -Branch (Get-BackportCohortBranch $cohort) $comment = if ($same) { $record.journal } else { $null } if ($null -ne $comment) { Assert-BackportJournal $comment $State $BotLogin @@ -1700,6 +1811,12 @@ function Publish-BackportMilestone { if ($applied.Count -gt 0 -or (-not $same -and -not $legacyBatch)) { Assert-BackportPreparedBatch $cohort (Join-Path (Split-Path -Parent $StatePath) 'git') } + if ($cohort.ContainsKey('branch') -and -not $same -and -not $State.ContainsKey('legacy_issue')) { + $directory = Join-Path (Split-Path -Parent $StatePath) 'git' + $branch = Get-BackportCohortBranch $cohort + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if ($remote) { throw 'Pinned branch appeared before its durable reservation; never overwrite or adopt it.' } + } } $null = Get-BackportMilestonePullBody $State 9999999999 if (-not $same) { $null = Assert-BackportNewCohort $State $BotLogin $legacyBatch } @@ -1758,26 +1875,51 @@ function Publish-BackportMilestone { } } Assert-BackportJournal $comment $State $BotLogin + Assert-BackportBranchReservation $State if ($null -eq $pull -and @($cohort.members | Where-Object { $_.result.outcome -eq 'applied' }).Count -gt 0) { + $guardedBranch = $cohort.ContainsKey('branch') -and -not $State.ContainsKey('legacy_issue') + if ($guardedBranch) { + foreach ($member in $cohort.members) { + Assert-BackportSourceAliases $repository $member.source_pr $member.target + } + } Assert-BackportRelease $release $repository -Current $directory = Join-Path (Split-Path -Parent $StatePath) 'git' - $branch = "bc-extrequest-backport/$($cohort.request.key)" + Assert-BackportBranchReservation $State + $branch = Get-BackportCohortBranch $cohort $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + $branchIntent = Read-BackportBranchIntent $comment.body $cohort.head if ($remote) { if (($remote -split '\s+')[0] -cne $cohort.head) { throw 'Existing branch differs; never overwrite human changes.' } + if ($guardedBranch -and -not $branchIntent) { + throw 'Existing branch has no confirmed branch publication intent; never adopt it.' + } } else { - $null = Invoke-BackportGit $directory @('push', 'origin', "$($cohort.head):refs/heads/$branch") + if ($guardedBranch -and -not $branchIntent) { + $comment = Update-BackportJournal $State $comment "" $BotLogin + Assert-BackportBranchReservation $State + $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() + if ($remote) { throw 'Pinned branch appeared before push; never overwrite or adopt it.' } + } + Publish-BackportBranchCreateOnly $directory $branch $cohort.head $remote = (Invoke-BackportGit $directory @('ls-remote', '--heads', 'origin', "refs/heads/$branch")).text.Trim() if (($remote -split '\s+')[0] -cne $cohort.head) { throw 'Branch publication was not confirmed.' } } - $pull = Get-BackportBatchPull $repository $cohort.request.key + Assert-BackportBranchReservation $State + $pull = Get-BackportBatchPull $repository -Branch $branch if ($null -eq $pull) { if ($null -ne (Read-BackportPublicationIntent $comment.body)) { throw 'Previous PR POST is unconfirmed; do not repeat it.' } Assert-BackportRelease $release $repository -Current $tracker = Invoke-BackportApi "repos/$repository/issues/$($tracker.number)" Assert-BackportSharedTracker $tracker $release $BotLogin Assert-BackportTrackerCapacity $tracker.body $State + Assert-BackportBranchReservation $State + if ($guardedBranch) { + foreach ($member in $cohort.members) { + Assert-BackportSourceAliases $repository $member.source_pr $member.target + } + } $intent = "" $comment = Update-BackportJournal $State $comment $intent $BotLogin try { @@ -1908,7 +2050,7 @@ function Publish-BackportLegacyOrPromote { $cohort = $State $matchingBatchRecords = @(Get-BackportBatchRecords $repository | Where-Object { $_.manifest.request.key -ceq $cohort.request.key }) $tracking = if ($matchingBatchRecords.Count -eq 1) { $matchingBatchRecords[0].issue } else { $null } - $pull = Get-BackportBatchPull $repository $cohort.request.key + $pull = Get-BackportBatchPull $repository -Branch (Get-BackportCohortBranch $cohort) if ($null -ne $tracking) { Assert-BackportBatchObject $tracking $cohort $BotLogin } if ($null -ne $pull) { if ($null -eq $tracking) { throw 'Legacy PR has no tracking identity.' } @@ -1934,7 +2076,17 @@ function Publish-BackportLegacyOrPromote { elseif ($cohort.request.ContainsKey('release')) { $cohort.request.release } else { Get-BackportRelease $repository $cohort.request.target } $wrapped = @{ schema = 3; repository = $repository; cohort = $cohort; release = $release } - if ($null -ne $tracking) { $wrapped.legacy_issue = $tracking.number } + if ($null -ne $tracking) { + $wrapped.legacy_issue = $tracking.number + if ($State.schema -eq 1) { $cohort.branch = Get-BackportBranch $State.source_pr $State.target } + } + elseif (-not $cohort.ContainsKey('branch')) { + Assert-BackportRelease $release $repository -Current + $directory = Join-Path (Split-Path -Parent $StatePath) 'git' + $cohort.branch = New-BackportCohortBranch $repository $directory $cohort.request @(Get-BackportBatchRecords $repository) + } + Assert-BackportJournalState $wrapped + $wrapped | ConvertTo-Json -Depth 80 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM Publish-BackportMilestone $wrapped $StatePath $BotLogin } diff --git a/.github/scripts/bc-extrequest-backport/README.md b/.github/scripts/bc-extrequest-backport/README.md index e3d0d548688..a0b4f280ffb 100644 --- a/.github/scripts/bc-extrequest-backport/README.md +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -162,10 +162,29 @@ outcomes. A PR is never split simply to reduce batch size. ## Publication and recovery -Independent requests retain the deterministic branch -`bc-extrequest-backport/pr-/.x`. Team requests have their own -stable batch identity and branch, with immutable membership linking each source -PR and merge SHA. The shared Task is a mutable milestone index, not a single +New backport branches use these names: + +| Request | Branch example | +| --- | --- | +| Single original main PR 11166 | `bc-extrequest-backport/backport_11166` | +| Weekly SCM batch | `bc-extrequest-backport/scm-2026-10-21` | +| Weekly Finance batch | `bc-extrequest-backport/finance-2026-10-21` | + +The single-request number is the **original PR number**, not an issue number. +Team names use a lowercase branch-safe slug; the date is the discovery date in +Europe/Copenhagen. If another backport already uses or reserves the name, add +`-2`, `-3`, and so on. The chosen name is retained with the immutable cohort, +so a retry uses the same branch even on a different day. A branch that changes +unexpectedly is never overwritten. + +Existing requests keep their original branches, including older +`pr-/.x`, `single-` and `team-` names under +`bc-extrequest-backport/`. They are not renamed or recreated under the new naming +scheme. + +Team requests retain a stable batch identity, separate from the readable branch +name, with immutable membership linking each source PR and merge SHA. +The shared Task is a mutable milestone index, not a single immutable cohort. Per-request journal records preserve membership, outcomes, source identities and publication intent separately from the accumulating table. Existing objects must have @@ -173,6 +192,8 @@ the expected identity, repository, branches and GitHub App author. Open and closed PRs are both checked; legacy independent requests are not replayed into new team batches. Branches are never force-pushed and human edits are never overwritten. +New references use create-only publication: a late collision stops rather than +updating an existing branch or silently changing the cohort's pinned name. The only label this workflow adds to an original main PR is **`ext-backport-processed`**, after it confirms that source's backport PR exists. diff --git a/.github/scripts/bc-extrequest-backport/USER-GUIDE.md b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md index 97cb8fe1645..3896fdf441d 100644 --- a/.github/scripts/bc-extrequest-backport/USER-GUIDE.md +++ b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md @@ -30,6 +30,11 @@ Either or both types are allowed. Other categories, including enum requests, ext 3. Copy whole PRs, oldest first, normally combining each team's clean changes into one draft backport PR per run. 4. Create or update **one tracking Task per branch and milestone**, shared across teams and runs. +New code branches are named `bc-extrequest-backport/backport_11166` for original +PR 11166, or `bc-extrequest-backport/scm-2026-10-21` for a weekly SCM batch. +The team date uses Copenhagen time. If another backport already uses the name, +add `-2`, `-3`, etc. Retries keep the same branch; existing branches are not renamed. + Example tracker title: ```text