diff --git a/.github/scripts/Invoke-RerunReviewTrigger.Tests.ps1 b/.github/scripts/Invoke-RerunReviewTrigger.Tests.ps1 index 7d4554c45ddf..abede87b89a8 100644 --- a/.github/scripts/Invoke-RerunReviewTrigger.Tests.ps1 +++ b/.github/scripts/Invoke-RerunReviewTrigger.Tests.ps1 @@ -14,7 +14,7 @@ BeforeAll { $script:ReviewTriggerWindowHours = 24 $script:MaxReviewTriggersPerWindow = 3 - foreach ($functionName in @('Get-ReviewTriggerRateLimitStatus', 'ConvertTo-SafeLogValue', 'ConvertTo-TrimmedString', 'Test-GhApiPrNotFound', 'Get-MatchingCandidate', 'Normalize-PipelineRef', 'Get-PlatformFromLabels')) { + foreach ($functionName in @('Get-ReviewTriggerRateLimitStatus', 'ConvertTo-SafeLogValue', 'ConvertTo-TrimmedString', 'Test-GhApiPrNotFound', 'Get-MatchingCandidate', 'Normalize-PipelineRef', 'Get-PlatformFromLabels', 'Expand-RerunDecisionItems')) { $function = $ast.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq $functionName @@ -85,6 +85,61 @@ Describe 'Test-GhApiPrNotFound' { Test-GhApiPrNotFound 'gh: Internal Server Error (HTTP 500)' | Should -BeFalse Test-GhApiPrNotFound '' | Should -BeFalse } + + It 'does not misclassify bare "Not Found"/"Gone" text without an HTTP 404/410 status' { + # Proxy/firewall/auth error bodies can contain these words without the + # resource actually being deleted. Treating them as 404 previously caused + # open PRs to be falsely skipped, silently cancelling every rerun. + Test-GhApiPrNotFound 'proxy error: Not Found' | Should -BeFalse + Test-GhApiPrNotFound 'The page you requested is Gone' | Should -BeFalse + } +} + +Describe 'Expand-RerunDecisionItems' { + It 'expands a single item carrying a JSON-string decisions array' { + $json = '[{"pr_number":"1","decision":"trigger"},{"pr_number":"2","decision":"skip"}]' + $item = [pscustomobject]@{ type = 'trigger_rerun_review'; decisions = $json } + $result = Expand-RerunDecisionItems -Items @($item) + $result.Count | Should -Be 2 + $result[0].pr_number | Should -Be '1' + $result[0].decision | Should -Be 'trigger' + $result[1].pr_number | Should -Be '2' + $result[1].decision | Should -Be 'skip' + } + + It 'expands a decisions array that is already an object array' { + $item = [pscustomobject]@{ + type = 'trigger_rerun_review' + decisions = @( + [pscustomobject]@{ pr_number = '7'; decision = 'trigger' } + ) + } + $result = Expand-RerunDecisionItems -Items @($item) + $result.Count | Should -Be 1 + $result[0].pr_number | Should -Be '7' + } + + It 'aggregates decisions across multiple items' { + $a = [pscustomobject]@{ type = 'trigger_rerun_review'; decisions = '[{"pr_number":"1","decision":"trigger"}]' } + $b = [pscustomobject]@{ type = 'trigger_rerun_review'; decisions = '[{"pr_number":"2","decision":"skip"}]' } + $result = Expand-RerunDecisionItems -Items @($a, $b) + $result.Count | Should -Be 2 + ($result | ForEach-Object { $_.pr_number }) | Should -Be @('1', '2') + } + + It 'passes through a legacy scalar item without a decisions field' { + $item = [pscustomobject]@{ type = 'trigger_rerun_review'; pr_number = '9'; decision = 'trigger' } + $result = Expand-RerunDecisionItems -Items @($item) + $result.Count | Should -Be 1 + $result[0].pr_number | Should -Be '9' + } + + It 'ignores empty or null decisions payloads' { + $empty = [pscustomobject]@{ type = 'trigger_rerun_review'; decisions = '' } + $nullItem = [pscustomobject]@{ type = 'trigger_rerun_review'; decisions = $null } + $result = Expand-RerunDecisionItems -Items @($empty, $nullItem) + $result.Count | Should -Be 0 + } } Describe 'ConvertTo-TrimmedString' { diff --git a/.github/scripts/Invoke-RerunReviewTrigger.ps1 b/.github/scripts/Invoke-RerunReviewTrigger.ps1 index 202bb125fcd0..080d60ef4fde 100644 --- a/.github/scripts/Invoke-RerunReviewTrigger.ps1 +++ b/.github/scripts/Invoke-RerunReviewTrigger.ps1 @@ -20,13 +20,56 @@ $MaxReviewTriggersPerWindow = 3 . "$PSScriptRoot/shared/Update-AgentLabels.ps1" +function Expand-RerunDecisionItems { + param([object[]]$Items) + + # A custom safe-output job is capped at one invocation per run, so the agent + # now batches every candidate's decision into a single item's `decisions` + # field (a JSON array, or array of objects). Expand that into one object per + # decision. Items that already carry scalar decision fields (legacy shape or + # a single decision) are passed through unchanged for back-compatibility. + $expanded = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Items) { + $rawDecisions = $item.PSObject.Properties['decisions'] + if (-not $rawDecisions -or $null -eq $rawDecisions.Value) { + if ($item.PSObject.Properties['pr_number']) { + $expanded.Add($item) + } + continue + } + + $value = $rawDecisions.Value + $parsed = $null + if ($value -is [string]) { + if ([string]::IsNullOrWhiteSpace($value)) { continue } + try { + $parsed = $value | ConvertFrom-Json + } catch { + Write-Host "::warning::Skipping unparseable decisions payload: $($_.Exception.Message)" + continue + } + } else { + $parsed = $value + } + + foreach ($decision in @($parsed)) { + if ($null -ne $decision) { + $expanded.Add($decision) + } + } + } + + return $expanded.ToArray() +} + function Get-AgentItems { if (-not $env:GH_AW_AGENT_OUTPUT -or -not (Test-Path $env:GH_AW_AGENT_OUTPUT)) { throw "GH_AW_AGENT_OUTPUT is missing or does not exist." } $payload = Get-Content -Raw -LiteralPath $env:GH_AW_AGENT_OUTPUT | ConvertFrom-Json - return @($payload.items | Where-Object { $_.type -eq 'trigger_rerun_review' }) + $triggerItems = @($payload.items | Where-Object { $_.type -eq 'trigger_rerun_review' }) + return Expand-RerunDecisionItems -Items $triggerItems } function Get-CandidateItems { @@ -75,7 +118,13 @@ function Test-GhApiPrNotFound { return $false } - return $Output -match '(?i)\bHTTP\s+(404|410)\b' -or $Output -match '(?i)\b(Not Found|Gone)\b' + # Only treat an explicit HTTP 404/410 status as "deleted". gh emits a + # structured error such as "gh: Not Found (HTTP 404)" for a genuinely + # missing resource. The bare words "Not Found"/"Gone" are intentionally NOT + # matched on their own: auth failures, proxy/firewall errors, and rate-limit + # bodies can contain that text and previously caused open PRs to be falsely + # classified as deleted, silently skipping every rerun dispatch. + return $Output -match '(?i)\bHTTP\s+(404|410)\b' } function ConvertTo-TrimmedString { @@ -88,6 +137,30 @@ function ConvertTo-TrimmedString { return ([string]$Value).Trim() } +function Get-PullRequestApiResult { + param( + [Parameter(Mandatory = $true)][string]$Owner, + [Parameter(Mandatory = $true)][string]$Repo, + [Parameter(Mandatory = $true)][int]$PRNumber + ) + + $stdErrFile = New-TemporaryFile + try { + $output = @(& gh api "repos/$Owner/$Repo/pulls/$PRNumber" 2> $stdErrFile) + $exitCode = $LASTEXITCODE + $json = ConvertTo-TrimmedString ($output | Out-String) + $stdErr = ConvertTo-TrimmedString (Get-Content -Raw -LiteralPath $stdErrFile -ErrorAction SilentlyContinue) + } finally { + Remove-Item -LiteralPath $stdErrFile -Force -ErrorAction SilentlyContinue + } + + return [pscustomobject]@{ + ExitCode = $exitCode + Json = $json + StdErr = $stdErr + } +} + function Add-CommentReaction { param( [Parameter(Mandatory = $true)][Int64]$CommentId, @@ -332,25 +405,36 @@ foreach ($item in $items) { } Write-Host "Processing PR #$prNumber decision=$decision reason=$(ConvertTo-SafeLogValue $reason)" - $prStdErrFile = New-TemporaryFile - try { - $prOutput = @(& gh api "repos/$Owner/$Repo/pulls/$prNumber" 2> $prStdErrFile) - $prExitCode = $LASTEXITCODE - $prJson = ConvertTo-TrimmedString ($prOutput | Out-String) - $prStdErr = ConvertTo-TrimmedString (Get-Content -Raw -LiteralPath $prStdErrFile -ErrorAction SilentlyContinue) - } finally { - Remove-Item -LiteralPath $prStdErrFile -Force -ErrorAction SilentlyContinue - } - if ($prExitCode -ne 0) { - $prError = if ([string]::IsNullOrWhiteSpace($prStdErr)) { $prJson } else { $prStdErr } + $prFetch = Get-PullRequestApiResult -Owner $Owner -Repo $Repo -PRNumber $prNumber + if ($prFetch.ExitCode -ne 0) { + $prError = if ([string]::IsNullOrWhiteSpace($prFetch.StdErr)) { $prFetch.Json } else { $prFetch.StdErr } + # Always surface the raw gh error so misclassified failures are + # debuggable instead of being swallowed by the not-found guard. + Write-Host " ⚠️ gh api for PR #$prNumber failed (exit $($prFetch.ExitCode)): $(ConvertTo-SafeLogValue $prError)" if (Test-GhApiPrNotFound -Output $prError) { - $global:LASTEXITCODE = 0 - Write-Host " ⏭️ PR #$prNumber no longer exists; skipping stale decision" - continue + # Confirm with a second authenticated probe before treating the + # PR as deleted. A single transient/auth/proxy 404 must not + # silently cancel a real rerun dispatch. + $confirm = Get-PullRequestApiResult -Owner $Owner -Repo $Repo -PRNumber $prNumber + $confirmError = if ([string]::IsNullOrWhiteSpace($confirm.StdErr)) { $confirm.Json } else { $confirm.StdErr } + if ($confirm.ExitCode -ne 0 -and (Test-GhApiPrNotFound -Output $confirmError)) { + $global:LASTEXITCODE = 0 + Write-Host " ⏭️ PR #$prNumber no longer exists (confirmed HTTP 404/410); skipping stale decision" + continue + } + if ($confirm.ExitCode -eq 0) { + # First 404 was transient; recheck recovered. Reuse the successful result. + $global:LASTEXITCODE = 0 + $prFetch = $confirm + Write-Host " ✓ PR #$prNumber recheck succeeded (transient 404 recovered)" + } else { + throw "PR #$prNumber returned a not-found error that did not reproduce on re-check; refusing to silently skip. First: $(ConvertTo-SafeLogValue $prError); Recheck: $(ConvertTo-SafeLogValue $confirmError)" + } + } else { + throw "Failed to load PR #$prNumber via gh api: $(ConvertTo-SafeLogValue $prError)" } - - throw "Failed to load PR #$prNumber via gh api: $(ConvertTo-SafeLogValue $prError)" } + $prJson = $prFetch.Json if ([string]::IsNullOrWhiteSpace($prJson)) { throw "Failed to load PR #$prNumber via gh api: empty response." } diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 index 086788b95207..dfa522ee0edc 100644 --- a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -851,7 +851,19 @@ function Format-MarkdownCell { # `List` that GitHub markdown would otherwise swallow as an HTML tag. The # engine's own markers are emitted via AppendLine, not through this formatter, # so escaping cells never disturbs them. - return (($Value -replace "\|", "\|") -replace "<", "<" -replace ">", ">").Trim() + # Collapse embedded newlines first: a malformed upstream title can contain a + # literal CR/LF (observed: ci-scan issue #35957), which would otherwise split + # the markdown table row across physical lines and break the rendered table. + # Escape each pipe AND double only the backslash run immediately preceding it: + # a title may legally contain a literal `\|`, and escaping only the pipe would + # yield `\\|` — which GFM renders as a literal `\` plus an ACTIVE column delimiter + # (table breakout). Doubling the pipe-adjacent run makes `\|` -> `\\\|`, a literal + # `\|`. Scoping the doubling to `(\\*)\|` (rather than every backslash) preserves a + # title's other backslash escapes (e.g. `\[link\](url)` is not de-escaped into an + # active link). No-pipe-adjacent-backslash titles are unaffected (`a | b` -> `a \| b`). + $v = $Value -replace "[\r\n]+", " " + $v = [regex]::Replace($v, '(\\*)\|', { param($m) ($m.Groups[1].Value * 2) + '\|' }) + return ($v -replace "<", "<" -replace ">", ">").Trim() } function Format-GitHubHandle { diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index abe8610bb359..a920642fa9dd 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -2814,6 +2814,55 @@ function ConvertTo-LinkedPr { return "[#$PrNumber]($RepoUrl/pull/$PrNumber)" } +function Format-MarkdownTableCell { + <# + .SYNOPSIS + Sanitize an arbitrary (often upstream-controlled) string for safe use inside a + single Markdown table cell. Also used for the candidate-PR bulleted list, where + the newline collapse matters and `\|` renders as `|`. + .DESCRIPTION + Three hazards are neutralized so a hostile/malformed issue or PR title cannot + corrupt the rendered body: + 1. Embedded CR/LF runs are collapsed to a single space, so the value cannot + split the row across physical lines (observed live: ci-scan issue #35957, + whose title contained a literal newline). + 2. Each pipe is escaped to `\|`, AND any run of backslashes immediately + preceding that pipe is doubled FIRST. This ordering is load-bearing: + GitHub-issue/PR titles may legally contain a literal `\|` (backslash + immediately followed by a pipe). Escaping only the pipe would turn that + into `\\|`, which GFM renders as a literal `\` followed by an ACTIVE + column delimiter `|` (the classic "escape-the-escaper" table breakout). + Doubling the preceding backslash run first makes `\|` -> `\\\|`, which + renders as a literal `\|` and cannot open a new column. The doubling is + SCOPED to pipe-adjacent backslash runs (via the `(\\*)\|` match) rather + than every backslash in the string, so a title's OTHER backslash escapes + are preserved verbatim — e.g. an author-escaped `\[link\](url)` or `\*not + emphasis\*` is NOT de-escaped into active Markdown. Titles with no pipe- + adjacent backslash (the common case) are unaffected: `a | b` -> `a \| b`. + 3. `<` / `>` are escaped to `<` / `>`, so a title cannot inject raw HTML + (e.g. an `` comment) into the body. This matches the Preview + engine's Format-MarkdownCell, has zero visual cost (`<T>` renders as + ``, preserving titles like `List`), and is defense-in-depth: even + though SR is structurally hash-freeze-immune (it emits its own semantic hash + at the TOP of the body, so an injected lower `` can never + win the workflow's `head -n1` extraction) and its human-notes markers are + matched FULL-LINE-ANCHORED (a forged marker only fires if it lands alone on a + physical line, which hazard 1 already prevents), escaping `<>` keeps SR and + Preview consistent and removes any reliance on those backend invariants. + Every SR markdown cell that embeds upstream-controlled text routes through this + single helper: the ci-scan rows, the Open-PRs / regression / Blocking / Cleanup / + ship-readiness-checks / Open-Fix-PRs tables, and the candidate-PR list. + #> + param([string]$Value) + if ([string]::IsNullOrEmpty($Value)) { return '' } + $v = $Value -replace '[\r\n]+', ' ' + # Escape each pipe AND double only the backslash run immediately before it, so a + # pre-existing `\|` cannot survive as `\\|` (literal `\` + active delimiter). Non- + # pipe backslash escapes elsewhere in the title are left intact. + $v = [regex]::Replace($v, '(\\*)\|', { param($m) ($m.Groups[1].Value * 2) + '\|' }) + return ($v -replace '<', '<' -replace '>', '>').Trim() +} + function Format-CiScanIssueRows { <# .SYNOPSIS @@ -2842,7 +2891,10 @@ function Format-CiScanIssueRows { } } $issLink = "[#$($iss.number)]($RepoUrl/issues/$($iss.number))" - $title = ($iss.title -replace '\|', '\|').Trim() + # Sanitize the upstream ci-scan title for a single Markdown table cell: + # collapse embedded CR/LF (observed: #35957) and escape pipes. See + # Format-MarkdownTableCell for the full rationale (and why SR omits `<>`). + $title = Format-MarkdownTableCell $iss.title [void]$sb.AppendLine("| $marker$issLink | $title | $ageDisplay |") } if ($Issues.Count -gt $MaxRows) { @@ -3105,9 +3157,9 @@ function Format-MarkdownReport { [void]$sb.AppendLine('| Area | Details | Next action |') [void]$sb.AppendLine('|---|---|---|') foreach ($b in $blockingItems) { - $area = ($b.area -replace '\|', '\|').Trim() - $details = ($b.details -replace '\|', '\|').Trim() - $action = ($b.action -replace '\|', '\|').Trim() + $area = Format-MarkdownTableCell $b.area + $details = Format-MarkdownTableCell $b.details + $action = Format-MarkdownTableCell $b.action [void]$sb.AppendLine("| $area | $details | $action |") } [void]$sb.AppendLine() @@ -3142,9 +3194,9 @@ function Format-MarkdownReport { [void]$sb.AppendLine('| Area | Details | Next action |') [void]$sb.AppendLine('|---|---|---|') foreach ($c in $cleanupItems) { - $area = ($c.area -replace '\|', '\|').Trim() - $details = ($c.details -replace '\|', '\|').Trim() - $action = ($c.action -replace '\|', '\|').Trim() + $area = Format-MarkdownTableCell $c.area + $details = Format-MarkdownTableCell $c.details + $action = Format-MarkdownTableCell $c.action [void]$sb.AppendLine("| $area | $details | $action |") } [void]$sb.AppendLine() @@ -3245,11 +3297,11 @@ function Format-MarkdownReport { [void]$sb.AppendLine('| Fix PR | Base | Regression issue | Status | Next action |') [void]$sb.AppendLine('|---|---|---|---|---|') foreach ($row in $openFixRows) { - $prCell = ($row.prCell -replace '\|', '\|').Trim() - $baseCell = ($row.baseCell -replace '\|', '\|').Trim() - $issCell = ($row.issCell -replace '\|', '\|').Trim() - $statCell = ($row.statusCell -replace '\|', '\|').Trim() - $actCell = ($row.actionCell -replace '\|', '\|').Trim() + $prCell = Format-MarkdownTableCell $row.prCell + $baseCell = Format-MarkdownTableCell $row.baseCell + $issCell = Format-MarkdownTableCell $row.issCell + $statCell = Format-MarkdownTableCell $row.statusCell + $actCell = Format-MarkdownTableCell $row.actionCell [void]$sb.AppendLine("| $prCell | $baseCell | $issCell | $statCell | $actCell |") } [void]$sb.AppendLine() @@ -3270,9 +3322,9 @@ function Format-MarkdownReport { 'CLEANUP' { '🧹 CLEANUP' } default { "⚪ $($sc.Status)" } } - $area = ($sc.Area -replace '\|', '\|').Trim() - $details = ($sc.Details -replace '\|', '\|').Trim() - $action = ($sc.NextAction -replace '\|', '\|').Trim() + $area = Format-MarkdownTableCell $sc.Area + $details = Format-MarkdownTableCell $sc.Details + $action = Format-MarkdownTableCell $sc.NextAction [void]$sb.AppendLine("| $area | $statusEmoji | $details | $action |") } [void]$sb.AppendLine() @@ -3368,6 +3420,12 @@ function Format-MarkdownReport { foreach ($cp in $candidatePrs) { $cpLink = ConvertTo-LinkedPr -PrNumber $cp.number -RepoUrl $RepoUrl $cpTitle = if ($cp.title.Length -gt 80) { $cp.title.Substring(0, 80) + '...' } else { $cp.title } + # Collapse newlines (and escape pipes) even though this is a list, not a + # table: an upstream title with an embedded newline could otherwise push + # injected content (e.g. a forged `` + # marker) onto its own physical line. Markdown renders `\|` as `|` in a + # list, so escaping is harmless here. + $cpTitle = Format-MarkdownTableCell $cpTitle [void]$sb.AppendLine("- $cpLink — $cpTitle (by $(Format-GitHubHandle $cp.author.login), updated $($cp.updatedAt))") } [void]$sb.AppendLine() @@ -3381,6 +3439,7 @@ function Format-MarkdownReport { [void]$sb.AppendLine('|---|---|---|---|---|---|') foreach ($pr in $Data['openSrPrs']) { $title = if ($pr.title.Length -gt 60) { $pr.title.Substring(0, 60) + '...' } else { $pr.title } + $title = Format-MarkdownTableCell $title $draft = if ($pr.isDraft) { '✏️' } else { '' } $rev = if ($pr.reviewDecision) { $pr.reviewDecision } else { '—' } $prLink = ConvertTo-LinkedPr -PrNumber $pr.number -RepoUrl $RepoUrl @@ -3392,7 +3451,10 @@ function Format-MarkdownReport { # === Regressions section — organized into tiers === if ($Data.ContainsKey('regressions') -and $Data['regressions']) { - $regs = $Data['regressions'] + # Force array context: regression results are hashtables, and when exactly one + # candidate exists PowerShell unwraps the single-element array to that lone hashtable, + # so $regs.Count would otherwise return the hashtable's key count instead of 1. + $regs = @($Data['regressions']) $summary = if ($Data.ContainsKey('summary')) { $Data['summary'] } else { @{} } [void]$sb.AppendLine("## Regression Candidates — $($regs.Count) issues scanned") @@ -3434,6 +3496,7 @@ function Format-MarkdownReport { # Stable sort: by issue number ascending foreach ($it in ($items | Sort-Object issue)) { $title = if ($it.title.Length -gt 50) { $it.title.Substring(0, 50) + '...' } else { $it.title } + $title = Format-MarkdownTableCell $title $prList = @($it.candidateFixPrs | ForEach-Object { ConvertTo-LinkedPr -PrNumber $_.number -RepoUrl $RepoUrl }) -join ', ' if (-not $prList) { $prList = '—' } $issueLink = if ($RepoUrl) { "[#$($it.issue)]($RepoUrl/issues/$($it.issue))" } else { "#$($it.issue)" } diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 75774a18d0a9..417f9a203fcf 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -1647,6 +1647,46 @@ Assert-Eq -Label "Truncated body retains exactly one notes:end marker" -Expect Assert-Eq -Label "Truncated body retains the semantic-hash marker" -Expected $true ` -Actual ($mdCapped -match '') +# ───── Regression header count = candidate count, not hashtable key count ───── +# Get-RegressionCandidates returns its $results accumulator; when exactly ONE candidate +# matches, PowerShell unwraps the single-element array on return, so $Data['regressions'] +# arrives as a LONE hashtable (not a 1-element array). The header rendered +# $regs = $Data['regressions']; "... $($regs.Count) issues scanned" +# and .Count on a scalar hashtable returns its KEY count — the exact live symptom on +# tracker #35867: "Regression Candidates — 13 issues scanned" with a single candidate. +# This test is DISCRIMINATING: it assigns the regression result as a SCALAR hashtable to +# reproduce that unwrap (NOT '@(...)', which would mask the bug); pre-fix the header prints +# the key count, post-fix it prints 1. +Write-Host "`n[Unit] Regression header count = candidate count, not hashtable keys" -ForegroundColor Cyan + +# Production-shaped regression hashtable (13 keys, mirroring Get-RegressionCandidates output). +$singleReg = @{ + issue = 96100; title = 'Lone regression'; state = 'OPEN'; classification = 'no-fix-yet' + candidateFixPrs = @(); recommendedAction = 'Investigate'; createdAt = '2026-06-01T00:00:00Z' + confidence = 'high'; milestone = '10.0-sr9'; closedAt = $null; evidence = @() + labels = @(); stateReason = $null +} +$mdDataOneReg = @{} + $mdData +$mdDataOneReg['regressions'] = $singleReg # scalar hashtable → mimics the N=1 return-unwrap +$mdDataOneReg['summary'] = @{ 'no-fix-yet' = 1 } +# Lock the reproduction precondition: the value must be a scalar hashtable, NOT a list — +# otherwise the bug can't manifest and a future edit could silently neuter this test. +Assert-Eq -Label "Repro precondition: regressions arrives as a scalar hashtable with >1 key" -Expected $true ` + -Actual ($mdDataOneReg['regressions'] -is [hashtable] ` + -and -not ($mdDataOneReg['regressions'] -is [System.Collections.IList]) ` + -and $mdDataOneReg['regressions'].Keys.Count -gt 1) +$mdOneReg = Format-MarkdownReport -Data $mdDataOneReg -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr9' -MaxBodyBytes 60000 +$oneRegHeader = @($mdOneReg -split "`r?`n" | Where-Object { $_ -match 'Regression Candidates —' }) +Assert-Eq -Label "Single-candidate header reports '1 issues scanned' (not the hashtable key count)" -Expected $true ` + -Actual ($oneRegHeader.Count -eq 1 -and $oneRegHeader[0] -match 'Regression Candidates — 1 issues scanned') + +# Guard the N≥2 path stays correct (array preserved → .Count = element count). +$mdTwoReg = Format-MarkdownReport -Data (@{} + $mdData) -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr9' -MaxBodyBytes 60000 +Assert-Eq -Label "Two-candidate header reports '2 issues scanned'" -Expected $true ` + -Actual ($mdTwoReg -match 'Regression Candidates — 2 issues scanned') + # ───── UTF-8 boundary repair: truncation must never split a multibyte char ───── # Regression for the boundary-repair fix. A naive "trim trailing continuation # bytes" cut leaves an orphan multibyte LEAD byte (and even strips a COMPLETE @@ -1740,6 +1780,130 @@ Assert-Eq -Label "Hostile PR title: @another/user defanged to `another/user`" -E Assert-Eq -Label "Author column also defanged (no bare @jfversluis)" -Expected $true ` -Actual ($mdWithAt -match '`jfversluis`') +# ───── Sibling SR title cells must be sanitized too (Format-MarkdownTableCell) ───── +# Beyond the ci-scan rows, two other SR tables embed upstream titles into pipe-delimited +# rows: the "Open PRs Targeting " table and the regression classification +# table. A literal '|' (common in titles) or an embedded newline in those titles must NOT +# corrupt the row. Each integration test below is DISCRIMINATING on the pre-fix code: +# the trailing-column match fails when an embedded newline splits the row, and the +# escaped-pipe match fails when the pipe is left raw. +Write-Host "`n[Unit] Sibling SR table cells sanitized (Open-PRs + regression tables)" -ForegroundColor Cyan + +# (1) Open PRs Targeting table (shipped mode renders the full table). +$mdDataPipePr = @{} + $mdData +$mdDataPipePr['metadata'] = @{} + $mdData.metadata +$mdDataPipePr['metadata']['mode'] = 'shipped' +$mdDataPipePr['openSrPrs'] = @( + @{ number = 96001; title = "Fix A | B`nand C"; author = @{ login = 'alice' }; + isDraft = $false; reviewDecision = 'APPROVED'; updatedAt = '2026-06-01T00:00:00Z' } +) +$mdPipePr = Format-MarkdownReport -Data $mdDataPipePr -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +$prRowLines = @($mdPipePr -split "`r?`n" | Where-Object { $_ -match '#96001' }) +Assert-Eq -Label "Open-PRs table: piped/newline PR title stays on one physical row" -Expected 1 ` + -Actual $prRowLines.Count +Assert-Eq -Label "Open-PRs table: pipe escaped AND trailing columns intact on the row" -Expected $true ` + -Actual ($prRowLines.Count -eq 1 -and $prRowLines[0] -match 'Fix A \\\| B' -and $prRowLines[0] -match 'APPROVED') + +# (2) Regression classification table (needs-human-review is a Tier-2 class that renders). +$mdDataPipeIss = @{} + $mdData +$mdDataPipeIss['regressions'] = @( + @{ issue = 96002; title = "Crash | NRE`nin layout"; state = 'OPEN'; classification = 'needs-human-review'; + candidateFixPrs = @(); recommendedAction = 'Investigate' } +) +$mdDataPipeIss['summary'] = @{ 'needs-human-review' = 1 } +$mdPipeIss = Format-MarkdownReport -Data $mdDataPipeIss -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +$issRowLines = @($mdPipeIss -split "`r?`n" | Where-Object { $_ -match '#96002' }) +Assert-Eq -Label "Regression table: piped/newline issue title stays on one physical row" -Expected 1 ` + -Actual $issRowLines.Count +Assert-Eq -Label "Regression table: pipe escaped AND trailing action column intact on the row" -Expected $true ` + -Actual ($issRowLines.Count -eq 1 -and $issRowLines[0] -match 'Crash \\\| NRE' -and $issRowLines[0] -match 'Investigate') + +# ───── Blocking summary + Open-Fix-PRs cells sanitized; human-notes marker-forgery defense ───── +# The remaining SR tables that embed upstream titles — the 🔴 Blocking summary +# (Tier-1 regressions + BLOCKED ship-checks) and the 📥 Open Fix PRs Inbound table — +# plus the candidate-PR bulleted list now route every upstream cell through +# Format-MarkdownTableCell. Each assertion below is DISCRIMINATING on pre-fix code: +# an embedded newline splits the row (orphaning the title tail onto its own line), +# and the escaped-pipe match fails when the pipe is left raw. +# +# The marker-forgery assertions are the security centerpiece: the production workflow +# preserves Release-Captain notes by splicing on FULL-LINE-ANCHORED markers +# (^\s*\s*$). A hostile title containing +# `...\n\n...` would, pre-fix, isolate that marker on its own physical +# line and forge a second notes region — letting an attacker's PR/issue title corrupt the +# notes-preservation step. Collapsing newlines defeats this (the marker can no longer land +# alone on a line), and escaping `<>` to entities is belt-and-suspenders (the injected +# ``ntail"; state = 'OPEN'; + classification = 'no-fix-yet'; candidateFixPrs = @(); recommendedAction = 'Investigate' } +) +$mdDataForgeTbl['summary'] = @{ 'no-fix-yet' = 1 } +$mdForgeTbl = Format-MarkdownReport -Data $mdDataForgeTbl -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +$forgeTblMarkers = @($mdForgeTbl -split "`r?`n" | Where-Object { $_ -match '^\s*\s*$' }) +Assert-Eq -Label "Marker-forgery (table cell): exactly ONE anchored begin-marker survives (the legit one)" -Expected 1 ` + -Actual $forgeTblMarkers.Count + +# (6) Marker-forgery via the candidate-PR LIST (the bulleted site, not a table). The title +# must match \bcandidate\b to be selected, and embeds the marker between newlines. +$mdDataForgeList = @{} + $mdData +$mdDataForgeList['metadata'] = @{} + $mdData.metadata +$mdDataForgeList['metadata']['mode'] = 'candidate' +$mdDataForgeList['metadata']['priorSrBranch'] = 'release/10.0.1xx-sr7' +$mdDataForgeList['metadata']['srBranch'] = 'main' +$mdDataForgeList['openSrPrs'] = @( + @{ number = 96005; title = "Candidate`n`ntail"; + author = @{ login = 'mallory' }; isDraft = $false; reviewDecision = 'APPROVED'; updatedAt = '2026-06-01T00:00:00Z' } +) +$mdForgeList = Format-MarkdownReport -Data $mdDataForgeList -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr8' -MaxBodyBytes 60000 +$forgeListMarkers = @($mdForgeList -split "`r?`n" | Where-Object { $_ -match '^\s*\s*$' }) +Assert-Eq -Label "Marker-forgery (candidate list): exactly ONE anchored begin-marker survives (the legit one)" -Expected 1 ` + -Actual $forgeListMarkers.Count +Assert-Eq -Label "Candidate list: hostile title collapsed onto the bullet line (no isolated tail)" -Expected 0 ` + -Actual (@($mdForgeList -split "`r?`n" | Where-Object { $_ -match '^\s*tail\b' }).Count) + # ───── Candidate-mode open-PR collapse: avoid noisy main-PR dump ───── Write-Host "`n[Unit] Candidate-mode open-PR collapse (link to candidate PR only)" -ForegroundColor Cyan @@ -1838,8 +2002,8 @@ Assert-Eq -Label "BLOCKED ship check: blocking summary header reflects count" -E -Actual ($mdBlocked -match '🔴 Blocking — \d+ item') Assert-Eq -Label "BLOCKED ship check: blocking summary mentions versions.props area" -Expected $true ` -Actual ($mdBlocked -match '🛠️ versions.props PatchVersion') -Assert-Eq -Label "BLOCKED ship check: blocking summary contains the next-action text" -Expected $true ` - -Actual ($mdBlocked -match 'Bump ') +Assert-Eq -Label "BLOCKED ship check: blocking summary contains the next-action text (angle brackets entity-escaped so GitHub actually displays them)" -Expected $true ` + -Actual ($mdBlocked -match 'Bump <PatchVersion>') Assert-Eq -Label "BLOCKED ship check: full Ship-readiness checks table emitted" -Expected $true ` -Actual ($mdBlocked -match 'Ship-readiness checks') Assert-Eq -Label "BLOCKED ship check: table shows READY entry for bug template (transparency)" -Expected $true ` @@ -1864,6 +2028,27 @@ $mdReady = Format-MarkdownReport -Data $mdDataReady -RepoUrl 'https://github.com Assert-Eq -Label "All ship checks READY (no Tier 1 regressions): '🟢 No blocking items'" -Expected $true ` -Actual ($mdReady -match '🟢 No blocking items') +# Ship-readiness checks TABLE: a WATCH check (renders only in the table, not the blocking +# summary) whose Details carry a literal pipe + embedded newline must stay on one row, +# pipe-escaped — proving the table's Details/NextAction cells route through the sanitizer. +$mdDataWatchCell = @{} + $mdData +$mdDataWatchCell['shipChecks'] = @( + [PSCustomObject]@{ + Area = 'Maestro channel' + Status = 'WATCH' + Details = "Default channel A | B`nnot yet mapped" + NextAction = 'Verify mapping' + } +) +$mdWatchCell = Format-MarkdownReport -Data $mdDataWatchCell -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +$watchOrphans = @($mdWatchCell -split "`r?`n" | Where-Object { $_ -match '^\s*not yet mapped' }) +Assert-Eq -Label "Ship-checks table: embedded newline in Details does NOT orphan a line" -Expected 0 ` + -Actual $watchOrphans.Count +$watchRow = @($mdWatchCell -split "`r?`n" | Where-Object { $_ -match '🟡 WATCH' }) +Assert-Eq -Label "Ship-checks table: Details glued + pipe-escaped, NextAction column intact" -Expected $true ` + -Actual ($watchRow.Count -eq 1 -and $watchRow[0] -match 'Default channel A \\\| B not yet mapped' -and $watchRow[0] -match 'Verify mapping') + # Hash includes shipChecks state (changing a ship check status flips the hash) $h1 = if ($mdReady -match '') { $Matches[1] } else { $null } $h2 = if ($mdBlocked -match '') { $Matches[1] } else { $null } @@ -2263,6 +2448,54 @@ Assert-Eq -Label "Age column shows 'Nd ago' for older issues" -Expected $true ` Assert-Eq -Label "Format-CiScanIssueRows returns null for empty input" -Expected $true ` -Actual ($null -eq (Format-CiScanIssueRows -Issues @() -RepoUrl 'https://github.com/dotnet/maui')) +# Regression: a malformed upstream ci-scan title containing a literal newline +# (observed live: #35957) must NOT split the markdown table row across physical +# lines. The title cell is collapsed to a single line so the rendered table stays +# intact. On the pre-fix code the embedded LF pushed the title tail + age cell onto +# a second line that no longer contained the issue link. +# +# Discrimination note: the FIRST assertion below ("issue row is a single physical +# line") is a coarse sanity check and is NON-discriminating — it also passes on the +# pre-fix code, because the split row still leaves '#35957' on exactly one physical +# line (the title tail + age spill onto a SEPARATE line with no issue link). The +# SECOND assertion ("title tail + age stay on that row") is the real regression +# guard: it fails pre-fix and passes post-fix. Do not weaken or remove it assuming +# the first assertion already covers row integrity. +$nlIssue = @([PSCustomObject]@{ number = 35957; url = 'https://github.com/dotnet/maui/issues/35957'; + title = "Recurring long title (maui-pr-uitest`n[Content truncated due to length]"; + createdAt = $nowUtc.AddDays(-3).ToString('o') }) +$nlRows = Format-CiScanIssueRows -Issues $nlIssue -RepoUrl 'https://github.com/dotnet/maui' +$nlRowLines = @($nlRows -split "`r?`n" | Where-Object { $_ -match '#35957' }) +Assert-Eq -Label "Newline ci-scan title: issue row is a single physical line (coarse sanity; non-discriminating)" -Expected 1 ` + -Actual $nlRowLines.Count +Assert-Eq -Label "Newline ci-scan title: title tail + age stay on that row (discriminating regression guard)" -Expected $true ` + -Actual ($nlRowLines.Count -eq 1 -and $nlRowLines[0] -match 'truncated due to length.*ago \|') + +# ───── Format-MarkdownTableCell: shared SR table-cell sanitizer ───── +# This helper backs every SR title cell (ci-scan rows, Open-PRs table, regression +# classification table). It must collapse CR/LF (row-split safety) AND escape pipes +# (column-injection safety), null-safely, while deliberately NOT escaping `<`/`>` +# (the SR engine emits its own hash at the top of the body, so it has no Preview-style +# HTML-comment hash-freeze vector — escaping `<>` here would only reduce fidelity). +Write-Host "`n[Unit] Format-MarkdownTableCell (shared SR table-cell sanitizer)" -ForegroundColor Cyan +Assert-Eq -Label "Format-MarkdownTableCell: pipe escaped" -Expected 'a \| b' -Actual (Format-MarkdownTableCell 'a | b') +Assert-Eq -Label "Format-MarkdownTableCell: LF collapsed to space" -Expected 'a b' -Actual (Format-MarkdownTableCell "a`nb") +Assert-Eq -Label "Format-MarkdownTableCell: CRLF run collapsed" -Expected 'a b' -Actual (Format-MarkdownTableCell "a`r`n`r`nb") +Assert-Eq -Label "Format-MarkdownTableCell: newline + pipe together" -Expected 'a \| b' -Actual (Format-MarkdownTableCell "a`n| b") +Assert-Eq -Label "Format-MarkdownTableCell: no CR/LF survives" -Expected $false -Actual ((Format-MarkdownTableCell "x`ny") -match "`r|`n") +Assert-Eq -Label "Format-MarkdownTableCell: null → empty string" -Expected '' -Actual (Format-MarkdownTableCell $null) +Assert-Eq -Label "Format-MarkdownTableCell: empty → empty string" -Expected '' -Actual (Format-MarkdownTableCell '') +Assert-Eq -Label "Format-MarkdownTableCell: surrounding whitespace trimmed" -Expected 'a b' -Actual (Format-MarkdownTableCell " a`nb ") +Assert-Eq -Label "Format-MarkdownTableCell: angle brackets escaped to entities (SR↔Preview parity)" -Expected 'List<T>' -Actual (Format-MarkdownTableCell 'List') +# Backslash-first ordering closes the "escape-the-escaper" table breakout: a title that +# already contains a literal `\|` must NOT collapse to `\\|` (literal `\` + ACTIVE pipe). +# Pre-fix (pipe-only escape) returns 'A \\| B' and these go red. +Assert-Eq -Label "Format-MarkdownTableCell: literal backslash-pipe does NOT break out (doubled backslash)" -Expected 'A \\\| B' -Actual (Format-MarkdownTableCell 'A \| B') +Assert-Eq -Label "Format-MarkdownTableCell: pre-existing NON-pipe backslash preserved (doubling is scoped to pipe-adjacent runs)" -Expected 'C:\dir' -Actual (Format-MarkdownTableCell 'C:\dir') +Assert-Eq -Label "Format-MarkdownTableCell: author-escaped non-pipe Markdown NOT de-escaped" -Expected '\[link\](url)' -Actual (Format-MarkdownTableCell '\[link\](url)') +# Injected HTML comment opener is rendered inert (cannot start an `` region). +Assert-Eq -Label "Format-MarkdownTableCell: HTML-comment opener neutralized" -Expected 'Crash <!--' -Actual (Format-MarkdownTableCell 'Crash