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..a9d07061d83 --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/Backport.Test.ps1 @@ -0,0 +1,2631 @@ +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') + $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 + } + 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' + $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 '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' + $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 with an empty create-only lease before recording PR intent' { + $script:pushed = $false + Mock Invoke-BackportGit { + if ($Arguments[0] -eq 'push') { + $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 = '' } + } + 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 { + 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' + title = 'Expose the requested event' + 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-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] } + 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' { + Mock Get-BackportTracking { + @{ number = 90; body = Get-BackportMarker 42 releases/29.x ('a' * 40) } + } + $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 + } + 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-BackportLegacyOrPromote {} + Publish-ExtRequestBackportBatch $path 'test-app[bot]' + Should -Invoke Publish-BackportLegacyOrPromote -Times 1 + } + + 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 '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 '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 '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**.' } } + } + @(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 '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'; 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`.' + '' + '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 '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() + $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 + $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 + $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 + } + + 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 { + 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 fifteen-day inclusive cutoff and has no November cutoff' { + $now = [DateTimeOffset]'2026-10-19T12:00:00Z' + 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 + } + + 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 '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 + } + + 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' }, + @{ 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' { + $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 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' }, + @{ 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 '(?m)^Fixes #99$' + $body | Should -Not -Match '(?m)^Fixes #(10|11)$' + } + + 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 'does not rediscover a processed source PR in the fifteen-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-backport-processed' }) + } + @{ + 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 'writes only the single processed label to the original main PR' { + Mock Ensure-BackportLabels {} + Mock Invoke-BackportApi {} + 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.Count -eq 1 + } + Should -Invoke Ensure-BackportLabels -Times 1 -ParameterFilter { + @($Labels | Where-Object name -eq 'ext-backport-processed').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' { + 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 + } + } +} + +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 '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' + } + + 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 '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 real Git through the team or standalone API and resumes immutable state' -ForEach @( + @{ 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') + $second = (Invoke-BackportGit $directory @('rev-parse', 'HEAD')).text.Trim() + [string[]]$outcomes = if ($mode -eq 'single') { @('applied') } else { @('applied', 'applied') } + $script:fixture = New-BatchFixture -Outcomes $outcomes + $request = $fixture.request + $request.sources[0].sha = $source + 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 + $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) { + @{ 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 { + $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 = 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 } + 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') { + 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 + } + $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' + 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 + $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 + (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') + { 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/$($durable.branch)" + $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-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' -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() + $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' + (Get-BackportCohortBranch $prepared.cohort) | Should -BeExactly (Get-BackportCohortBranch $cohort) + 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') + $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") + 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 + } + } +} diff --git a/.github/scripts/bc-extrequest-backport/Backport.psm1 b/.github/scripts/bc-extrequest-backport/Backport.psm1 new file mode 100644 index 00000000000..d8e5ab19c2c --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/Backport.psm1 @@ -0,0 +1,2094 @@ +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' -and + $Labels -notcontains 'ext-backport-processed' +} + +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(-15) -and $merged -le $Now -and + (Test-BackportCategory @($Source.labels | ForEach-Object { $_.name })) +} + +function Find-ExtRequestBackport { + [CmdletBinding()] + param([string] $Repository, [string] $SourcePr = '', [string] $TargetOverride = '', + [DateTimeOffset] $ReferenceTime = [DateTimeOffset]::UtcNow) + + 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 = $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(-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)) { + @{ 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 | Where-Object { $_.name -ne 'ext-backport-processed' }) } + 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) + $markerMatches = [regex]::Matches($Body, + '(?m)^\r?$') + 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) + + $trackingIssues = @(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 + }) + 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 +} + +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 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) + + $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, [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) + $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, + [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 = Get-BackportResumeRequest $batch[0] -Manual:$Manual + 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) + $tracking = Get-BackportTracking $Repository $SourcePr + 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 + $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' + $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 + $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 + 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)' } + $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 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 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') + } +} + +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)" + } +} + +function Read-BackportPublicationIntent { + param([string] $Body) + $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 ($intentMatches.Count -eq 1) { return $intentMatches[0].Groups[1].Value } + return $null +} + + + +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.' + } + 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 { + 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, + [string] $Team = '', [long] $TrackingNumber = 0, [string] $Outcome = '' + ) + + $teamText = if ($Team) { " for $Team" } else { '' } + $lines = @("Backporting of extensibility requests$teamText to ``$Target``.", '', 'Issues:') + $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.') + } + 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 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]', ' ' + $date = Get-BackportDiscoveryDate $State.request + $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 Publish-ExtRequestBackport { + [CmdletBinding()] + param([string] $StatePath, [string] $BotLogin, [hashtable] $PreparedState) + + $state = if ($null -ne $PreparedState) { $PreparedState } + else { Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable -Depth 60 } + if ($state.schema -eq 3) { Publish-BackportMilestone $state $StatePath $BotLogin; return } + Publish-BackportLegacyOrPromote $state $StatePath $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 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 + $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 { + 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.' + } + 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 } + } + @($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 + ($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 = Get-BackportRequestCore $Request + [void]$identity.Remove('key') + $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 + "" +} + +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 + $null = Get-BackportCohortBranch $Manifest + 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 + 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 = @{} + $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-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, [string] $Branch = '') + if (-not $Branch) { $Branch = "bc-extrequest-backport/$Key" } + $owner = $Repository.Split('/')[0] + $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 '' } + 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 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) + 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)) { + return Get-BackportResumeRequest $mapped[$SourcePr] -Manual + } + } + $candidates = @(Find-ExtRequestBackport $Repository -SourcePr $SourcePr -TargetOverride $TargetOverride -ReferenceTime ([DateTimeOffset]$clock)) + # 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) { + $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 + 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 = Get-BackportResumeRequest $record + $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.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])" } + $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 = 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') -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' -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 + } + $recordBody = if ($shared) { $overlap[0].journal.body } elseif ($resume) { $overlap[0].issue.body } else { '' } + $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 } + 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 + } + 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 + $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/$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") + $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 + 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 } + } + $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 (-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)) { + 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 + } + $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' +} + +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) + $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 +} + +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)' } + $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" + } + } +} + +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 (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.' + } +} + + + + + +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 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 (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) { + 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) { + 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 })) { + 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.' + } + } + $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.' } + Assert-BackportBranchReservation $State + if ($null -ne (Get-BackportBatchPull $repository -Branch (Get-BackportCohortBranch $cohort))) { + 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-BackportJournalState $State + $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 + }) + 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.' } + 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)" + 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 -Branch (Get-BackportCohortBranch $cohort) + $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) { + 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 -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 ($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 } + $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 + $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 ($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 { + 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)" + } + catch { throw "Cohort journal creation is unconfirmed; no POST retry was attempted. Operator investigation is required. $($_.Exception.Message)" } + } + Assert-BackportJournal $comment $State $BotLogin + } + } + 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' + 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 { + 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.' } + } + 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 { + $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)" + 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 + } + $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 -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.' } + 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 + 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 +} + +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 new file mode 100644 index 00000000000..a0b4f280ffb --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/README.md @@ -0,0 +1,271 @@ +# 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 +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 +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. + +## 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 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. 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. + +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 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, 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 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 +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. + +## 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: + +| Setting | Purpose | +| --- | --- | +| 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. | + +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 a tracking issue 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 +# Publish all eligible team batches and independent exceptions. +gh workflow run bc-extrequest-backport.yml --repo microsoft/BCApps --ref main + +# 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 +``` + +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 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. + +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 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. + +## Publication and recovery + +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 +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. +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 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 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 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 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 + +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 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 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 +before discovery; they also exercise real local Git cherry-picks without remote +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 +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/scripts/bc-extrequest-backport/USER-GUIDE.md b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md new file mode 100644 index 00000000000..3896fdf441d --- /dev/null +++ b/.github/scripts/bc-extrequest-backport/USER-GUIDE.md @@ -0,0 +1,82 @@ +# 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. + +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 +[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 + +**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**. 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 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.** + +## 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? | 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. | +| 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. diff --git a/.github/workflows/bc-extrequest-backport.yml b/.github/workflows/bc-extrequest-backport.yml new file mode 100644 index 00000000000..71833e3da79 --- /dev/null +++ b/.github/workflows/bc-extrequest-backport.yml @@ -0,0 +1,155 @@ +name: 'Extensibility Request Backport' + +run-name: >- + ${{ github.event_name == 'workflow_dispatch' && + (inputs.source_pr != '' && + 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' + workflow_dispatch: + inputs: + source_pr: + description: Optional merged main PR number; leave empty for all eligible team batches + required: false + default: '' + type: string + 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 + +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 + .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 -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.' + } + + discover: + needs: tests + if: >- + github.repository == 'microsoft/BCApps' && + github.ref == 'refs/heads/main' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'schedule' && + github.event.schedule == '10 21 * * 0' && + vars.EXT_REQ_BACKPORT_ENABLED == 'true')) + runs-on: ubuntu-latest + timeout-minutes: 30 + 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 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-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 60 -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: 120 + 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.concurrency_key || matrix.key }} + 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 }} + 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-ExtRequestBackportBatch -Repository $env:GITHUB_REPOSITORY ` + -RequestJson $env:REQUEST_JSON -StatePath $statePath + - name: Mint fresh GitHub App publication token + 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 backport + 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-ExtRequestBackportBatch -StatePath (Join-Path $env:RUNNER_TEMP 'extrequest-backport/state.json') ` + -BotLogin $env:BACKPORT_BOT_LOGIN 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") + } +}