Skip to content

Commit 580ef8a

Browse files
PureWeenCopilot
andauthored
Never classify a CLOSED issue as open-on-main in release-readiness (dotnet#36410)
<!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ## Why The release-readiness regression classifier could report a **CLOSED** issue as an active `open-on-main` regression (Tier 2 — "wait for main merge, then backport"). That's contradictory: an unmerged OPEN PR cannot have closed a completed issue. It happens when a giant still-open "Candidate" changelog PR `Fixes`-lists dozens of issues — its OPEN state gets attributed to an already-completed issue. Real-world: issue dotnet#35615 (CLOSED/COMPLETED) surfaced under `open-on-main` in the SR9 tracker while candidate PR dotnet#35716 was still open. ## What `Classify-RegressionCandidate` now enforces: **never emit `open-on-main` for a CLOSED issue.** When the aggregated best verdict is exactly `open-on-main` and the issue is CLOSED: 1. First try the same comment-prose recovery the empty-candidate path uses — a merged fix verifiably present on the SR wins → `closed-fix-unlinked` (Tier 3, "no ship risk; add a closing reference for traceability"). 2. Otherwise fall to the honest `no-fix-yet` (Tier 3 for a CLOSED issue via the existing `Get-OverallVerdict` downgrade) — the automation can't pin a verified fix on this SR and the open candidate hasn't merged. The shared recovery logic is extracted into a new `Resolve-ClosedFixUnlinked` helper, called from both the empty-`strongPrs` CLOSED path and the new guard, preserving the fix-phrase gate, merged-on-SR gate, revert guards, and tooling-only skips exactly. **Strictly scoped:** only `open-on-main` + CLOSED is contradictory. Every other verdict (`merged-on-main-no-backport`, `backport-in-progress`, `rejected-from-sr`, `in-sr-*`, `needs-human-review`) is unchanged even for CLOSED issues — those remain legitimately actionable (the SR may still need the backport). Genuinely-OPEN issues still get `open-on-main`. ## Tests Three new unit tests in `Test-ReleaseReadiness.ps1`: 1. CLOSED issue + OPEN candidate on main + no comment fix → `no-fix-yet`, proven non-blocking (🟢) via `Get-OverallVerdict`. 2. CLOSED issue + OPEN candidate + comment-cited merged fix on SR → `closed-fix-unlinked` (recovery wins). 3. Regression guard: OPEN issue + OPEN candidate → stays `open-on-main`. Full suite green (829/0). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: PureWeen <223556219+Copilot@users.noreply.github.com>
1 parent 462e72a commit 580ef8a

2 files changed

Lines changed: 217 additions & 68 deletions

File tree

.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1

Lines changed: 124 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,93 @@ function Get-BackportPrsForSr {
20332033
return @($list)
20342034
}
20352035

2036+
function Resolve-ClosedFixUnlinked {
2037+
<#
2038+
.SYNOPSIS
2039+
Recover 'closed-fix-unlinked' for a CLOSED regression issue whose fix
2040+
lives ONLY in comment prose (no closing keyword, no timeline link).
2041+
2042+
.DESCRIPTION
2043+
Maintainers routinely close a regression with a plain-text comment
2044+
("fixed by PR #35028") that GitHub never turns into a structured link.
2045+
Recover the cited PR and — ONLY when it actually MERGED and its commit
2046+
is verifiably on THIS SR branch — classify 'closed-fix-unlinked': the
2047+
fix is present (no ship risk), but the issue<->PR link is missing and
2048+
should be added for traceability.
2049+
2050+
Two gates make prose evidence safe, and BOTH are required:
2051+
1. fix-phrase ONLY — the comment must pair the PR with fix/resolve/
2052+
close language ("was fixed by PR #X"). A BARE mention is rejected
2053+
because regression issues routinely name the CAUSE PR for context
2054+
("Before PR #32080 ... After PR #32080 the behavior changed"), and
2055+
the cause naturally lives on the branch — it is not a fix.
2056+
2. merged AND on the SR branch — a cited PR that never merged, or
2057+
merged elsewhere, is not proof the fix shipped here.
2058+
2059+
Reverted fixes are dropped (a rolled-back fix is not a fix), mirroring
2060+
the $revertedPrSet / Revert-title handling on the SR-contents and
2061+
candidate paths.
2062+
2063+
Returns the 'closed-fix-unlinked' result hashtable, or $null when the
2064+
issue is not CLOSED or no cited PR survives both gates (caller falls
2065+
back to its own no-fix-yet handling).
2066+
#>
2067+
param($Ctx, $Issue, $RevertedPrSet)
2068+
2069+
$issueState = Get-AzdoProp $Issue 'state'
2070+
if ($issueState -ne 'CLOSED') { return $null }
2071+
2072+
$commentPrs = Get-IssueCommentPrs -Repo $Ctx.repo -IssueNumber $Issue.number
2073+
$verifiedFixes = @()
2074+
foreach ($cp in $commentPrs) {
2075+
# Gate 1: require explicit fix language. Bare mentions (the cause-PR
2076+
# blame pattern) are NOT fixes and must not reclassify the issue.
2077+
if ($cp.evidence -ne 'fix-phrase') { continue }
2078+
if ($cp.number -eq $Issue.number) { continue } # self-reference
2079+
$info = Get-PrInfo -Repo $Ctx.repo -PrNumber $cp.number
2080+
if (-not $info) { continue }
2081+
if ($info.state -ne 'MERGED') { continue }
2082+
# A reverted fix is NOT a fix. Mirror the main SR-contents/candidate
2083+
# paths: drop PRs the SR later reverted, and drop PRs that are themselves
2084+
# rollbacks ("Revert ..." titles). Without this, the `(#<num>)` on-branch
2085+
# token checked below matches the reverted fix's number inside the revert
2086+
# commit's own subject `Revert "... (#num)" (#N)`, so a rolled-back fix
2087+
# would pass the on-branch gate and be reported as "No ship risk".
2088+
if ($RevertedPrSet.ContainsKey([int]$info.number)) { continue }
2089+
if (($info.title -match '(?i)^(?:\[[^\]]+\]\s+)?Revert\b') -or ($info.title -match '\[Revert\]')) { continue }
2090+
# Skip agent/skill/workflow PRs that only mention the issue for context.
2091+
if (Test-PrIsToolingOnly -Files $info.files) { continue }
2092+
$mergeSha = if ($info.mergeCommit) { $info.mergeCommit.oid } else { $null }
2093+
# Gate 2: presence on the SR branch via EITHER signal — direct SHA
2094+
# ancestry (fix merged straight to SR) OR the `(#<num>)` subject token
2095+
# (fix flowed in from inflight/main under a different SHA — the common
2096+
# case).
2097+
$onSr = (Test-CommitOnBranch -Sha $mergeSha -BranchRef "origin/$($Ctx.srBranch)") `
2098+
-or (Test-PrNumberOnBranch -PrNumber ([int]$info.number) -BranchRef "origin/$($Ctx.srBranch)")
2099+
if (-not $onSr) { continue }
2100+
$verifiedFixes += @{
2101+
number = [int]$info.number
2102+
title = $info.title
2103+
state = $info.state
2104+
mergeSha = $mergeSha
2105+
evidenceType = "comment-$($cp.evidence)"
2106+
}
2107+
}
2108+
if ($verifiedFixes.Count -gt 0) {
2109+
$prList = (@($verifiedFixes | ForEach-Object { "#$($_.number)" }) | Sort-Object -Unique) -join ', '
2110+
return @{
2111+
classification = 'closed-fix-unlinked'
2112+
confidence = 'high'
2113+
evidence = @("Issue is CLOSED and fix PR $prList is MERGED and present on $($Ctx.srBranch), but was never linked to the issue (no closing keyword, no timeline cross-reference). Linkage recovered from a closing comment that explicitly names the fix.")
2114+
candidateFixPrs = @($verifiedFixes | ForEach-Object {
2115+
@{ number = $_.number; title = $_.title; state = $_.state; evidenceType = $_.evidenceType }
2116+
})
2117+
recommendedAction = "No ship risk — fix is already in the SR. Add a closing reference for traceability (e.g. ``Fixes #$($Issue.number)`` in $prList, or link via the issue's Development panel) so future runs classify it automatically."
2118+
}
2119+
}
2120+
return $null
2121+
}
2122+
20362123
function Classify-RegressionCandidate {
20372124
param($Issue, $CandidatePrs, $Ctx, $SrContents)
20382125

@@ -2171,74 +2258,10 @@ function Classify-RegressionCandidate {
21712258

21722259
# ── FALLBACK: closed issue whose fix lives ONLY in comment prose ──
21732260
# No timeline-cross-referenced candidate survived the evidence filter,
2174-
# but the issue is CLOSED. Maintainers routinely close a regression with
2175-
# a plain-text comment ("fixed by PR #35028") that GitHub never turns
2176-
# into a structured link. Recover the cited PR and — ONLY when it
2177-
# actually MERGED and its commit is verifiably on THIS SR branch —
2178-
# classify 'closed-fix-unlinked': the fix is present (no ship risk), but
2179-
# the issue<->PR link is missing and should be added for traceability.
2180-
#
2181-
# Two gates make prose evidence safe, and BOTH are required:
2182-
# 1. fix-phrase ONLY — the comment must pair the PR with fix/resolve/
2183-
# close language ("was fixed by PR #X"). A BARE mention is rejected
2184-
# because regression issues routinely name the CAUSE PR for context
2185-
# ("Before PR #32080 ... After PR #32080 the behavior changed"), and
2186-
# the cause naturally lives on the branch — it is not a fix.
2187-
# 2. merged AND on the SR branch — a cited PR that never merged, or
2188-
# merged elsewhere, is not proof the fix shipped here.
2189-
$issueState = Get-AzdoProp $Issue 'state'
2190-
if ($issueState -eq 'CLOSED') {
2191-
$commentPrs = Get-IssueCommentPrs -Repo $Ctx.repo -IssueNumber $Issue.number
2192-
$verifiedFixes = @()
2193-
foreach ($cp in $commentPrs) {
2194-
# Gate 1: require explicit fix language. Bare mentions (the cause-PR
2195-
# blame pattern) are NOT fixes and must not reclassify the issue.
2196-
if ($cp.evidence -ne 'fix-phrase') { continue }
2197-
if ($cp.number -eq $Issue.number) { continue } # self-reference
2198-
$info = Get-PrInfo -Repo $Ctx.repo -PrNumber $cp.number
2199-
if (-not $info) { continue }
2200-
if ($info.state -ne 'MERGED') { continue }
2201-
# A reverted fix is NOT a fix. Mirror the main SR-contents/candidate
2202-
# paths (see $revertedPrSet at the top of this function and the
2203-
# Revert-title skip in the candidate walk): drop PRs the SR later
2204-
# reverted, and drop PRs that are themselves rollbacks ("Revert ..."
2205-
# titles). Without this, the `(#<num>)` on-branch token checked below
2206-
# matches the reverted fix's number inside the revert commit's own
2207-
# subject `Revert "... (#num)" (#N)`, so a rolled-back fix would pass
2208-
# the on-branch gate and be reported as "No ship risk".
2209-
if ($revertedPrSet.ContainsKey([int]$info.number)) { continue }
2210-
if (($info.title -match '(?i)^(?:\[[^\]]+\]\s+)?Revert\b') -or ($info.title -match '\[Revert\]')) { continue }
2211-
# Skip agent/skill/workflow PRs that only mention the issue for context.
2212-
if (Test-PrIsToolingOnly -Files $info.files) { continue }
2213-
$mergeSha = if ($info.mergeCommit) { $info.mergeCommit.oid } else { $null }
2214-
# Gate 2: presence on the SR branch via EITHER signal — direct SHA
2215-
# ancestry (fix merged straight to SR) OR the `(#<num>)` subject
2216-
# token (fix flowed in from inflight/main under a different SHA —
2217-
# the common case).
2218-
$onSr = (Test-CommitOnBranch -Sha $mergeSha -BranchRef "origin/$($Ctx.srBranch)") `
2219-
-or (Test-PrNumberOnBranch -PrNumber ([int]$info.number) -BranchRef "origin/$($Ctx.srBranch)")
2220-
if (-not $onSr) { continue }
2221-
$verifiedFixes += @{
2222-
number = [int]$info.number
2223-
title = $info.title
2224-
state = $info.state
2225-
mergeSha = $mergeSha
2226-
evidenceType = "comment-$($cp.evidence)"
2227-
}
2228-
}
2229-
if ($verifiedFixes.Count -gt 0) {
2230-
$prList = (@($verifiedFixes | ForEach-Object { "#$($_.number)" }) | Sort-Object -Unique) -join ', '
2231-
return @{
2232-
classification = 'closed-fix-unlinked'
2233-
confidence = 'high'
2234-
evidence = @("Issue is CLOSED and fix PR $prList is MERGED and present on $($Ctx.srBranch), but was never linked to the issue (no closing keyword, no timeline cross-reference). Linkage recovered from a closing comment that explicitly names the fix.")
2235-
candidateFixPrs = @($verifiedFixes | ForEach-Object {
2236-
@{ number = $_.number; title = $_.title; state = $_.state; evidenceType = $_.evidenceType }
2237-
})
2238-
recommendedAction = "No ship risk — fix is already in the SR. Add a closing reference for traceability (e.g. ``Fixes #$($Issue.number)`` in $prList, or link via the issue's Development panel) so future runs classify it automatically."
2239-
}
2240-
}
2241-
}
2261+
# but the issue is CLOSED. Recover a fix cited only in a closing comment
2262+
# (see Resolve-ClosedFixUnlinked for the fix-phrase + merged-on-SR gates).
2263+
$rec = Resolve-ClosedFixUnlinked -Ctx $Ctx -Issue $Issue -RevertedPrSet $revertedPrSet
2264+
if ($rec) { return $rec }
22422265

22432266
return @{
22442267
classification = 'no-fix-yet'
@@ -2335,6 +2358,39 @@ function Classify-RegressionCandidate {
23352358
}
23362359
$best = $perPrVerdicts | Sort-Object { $priority[$_.verdict] } | Select-Object -First 1
23372360

2361+
# ── CLOSED-issue guard against a contradictory 'open-on-main' ──
2362+
# 'open-on-main' means "the fix PR is still OPEN on main; wait for it to
2363+
# merge, then backport" — an ACTIVE (Tier-2) regression. That is impossible
2364+
# for a CLOSED issue: an unmerged PR cannot have closed it. This happens
2365+
# when a giant still-open 'Candidate' changelog PR `Fixes`-lists dozens of
2366+
# issues, so its OPEN state gets attributed to an already-completed issue
2367+
# (real-world: #35615 shown as open-on-main under SR9 while candidate #35716
2368+
# was still open). Never emit open-on-main for a CLOSED issue:
2369+
# a. First try the same comment-prose recovery path 1 uses — a merged fix
2370+
# verifiably on the SR wins → 'closed-fix-unlinked' (Tier 3).
2371+
# b. Otherwise fall to the honest 'no-fix-yet' (Tier 3 for a CLOSED issue):
2372+
# the automation can't pin a verified fix on this SR and the open
2373+
# candidate hasn't merged. NOT an active SR regression.
2374+
# Scope is strict: ONLY open-on-main + CLOSED is contradictory. Every other
2375+
# verdict (merged-*, backport-in-progress, rejected-from-sr, in-sr-*,
2376+
# needs-human-review) stays as-is even for CLOSED issues — those are still
2377+
# actionable (the SR may still need the backport).
2378+
if ($best.verdict -eq 'open-on-main' -and (Get-AzdoProp $Issue 'state') -eq 'CLOSED') {
2379+
$rec = Resolve-ClosedFixUnlinked -Ctx $Ctx -Issue $Issue -RevertedPrSet $revertedPrSet
2380+
if ($rec) { return $rec }
2381+
return @{
2382+
classification = 'no-fix-yet'
2383+
confidence = 'medium'
2384+
evidence = @("Issue is CLOSED but the candidate fix PR (#$($best.pr.number)) is OPEN/unmerged on $($best.pr.baseRef) — an unmerged PR cannot have closed this issue; the real fix likely shipped elsewhere or the candidate is stale. Not an active SR regression.")
2385+
candidateFixPrs = @($strongPrs | ForEach-Object { @{
2386+
number = $_.number; title = $_.title; state = $_.state
2387+
baseRef = $_.baseRef; evidenceType = $_.evidenceType
2388+
onMain = $_.onMain; backports = $_.backports
2389+
} })
2390+
recommendedAction = 'Verify the fix is present on this SR (or add a closing reference); the open candidate PR has not merged.'
2391+
}
2392+
}
2393+
23382394
$recAction = switch ($best.verdict) {
23392395
'in-sr-active' { 'No action — fix is shipping' }
23402396
'in-sr-reverted' { 'Investigate: backport landed and was reverted on SR' }

.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1771,6 +1771,99 @@ $clsRevertTitle = Classify-RegressionCandidate `
17711771
Assert-Eq -Label "Comment-cited 'Revert ...' PR is a rollback, not a fix → stays no-fix-yet" `
17721772
-Expected 'no-fix-yet' -Actual $clsRevertTitle.classification
17731773

1774+
# ───── Classify-RegressionCandidate (CLOSED issue never open-on-main) ─────
1775+
# Real-world case: SR9 tracker #35876 flagged CLOSED issue #35615 as `open-on-main`
1776+
# (an ACTIVE Tier-2 regression) because a giant still-OPEN 'Candidate' changelog PR
1777+
# (#35716) `Fixes`-listed dozens of issues. That is contradictory — an unmerged PR
1778+
# cannot have closed a completed issue. The guard reroutes open-on-main + CLOSED to
1779+
# a Tier-3 class: `closed-fix-unlinked` when a merged fix is verifiably on the SR, or
1780+
# the honest `no-fix-yet` fallback otherwise. It must NOT change behavior for
1781+
# genuinely-OPEN issues.
1782+
Write-Host "`n[Unit] Classify-RegressionCandidate (CLOSED issue never open-on-main)" -ForegroundColor Cyan
1783+
1784+
# A single OPEN 'Candidate' changelog PR on main that survives the evidence filter
1785+
# (body `Fixes #35615` → closing-keyword; base=main; a real product file so it is
1786+
# NOT tooling-only; not a Revert title) → the strong-PR walk verdict is open-on-main.
1787+
function Get-BackportPrsForSr { param($Repo, $SrBranch, $SourcePrNumber) return @() }
1788+
function Test-CommitOnBranch { param([string]$Sha, [string]$BranchRef) return $false }
1789+
function Test-PrNumberOnBranch { param([int]$PrNumber, [string]$BranchRef) return $false }
1790+
function Get-PrInfo {
1791+
param($Repo, $PrNumber)
1792+
if ([int]$PrNumber -eq 35716) {
1793+
return [pscustomobject]@{
1794+
number = 35716
1795+
title = '[Candidate] SR9 changelog'
1796+
state = 'OPEN'
1797+
baseRefName = 'main'
1798+
mergedAt = $null
1799+
closedAt = $null
1800+
body = 'Fixes #35615'
1801+
mergeCommit = $null
1802+
files = @([pscustomobject]@{ path = 'src/Controls/src/Core/Something.cs'; additions = 1; deletions = 0 })
1803+
}
1804+
}
1805+
# The comment-cited fix PR (used only in the recovery test below): MERGED into
1806+
# inflight/candidate, present on SR9 via the (#num) subject token.
1807+
return [pscustomobject]@{
1808+
number = $PrNumber
1809+
title = 'Fix the actual regression'
1810+
state = 'MERGED'
1811+
baseRefName = 'inflight/candidate'
1812+
mergedAt = '2026-06-01T00:00:00Z'
1813+
closedAt = '2026-06-01T00:00:00Z'
1814+
body = 'Fixes #35104' # deliberately a DIFFERENT issue: recovery fires on the COMMENT citation, not this PR body
1815+
mergeCommit = [pscustomobject]@{ oid = 'c1d6d72768c0ffee' }
1816+
files = @([pscustomobject]@{ path = 'src/Controls/src/Core/CollectionView.cs'; additions = 4; deletions = 0 })
1817+
}
1818+
}
1819+
1820+
# Test 1 — CLOSED issue + OPEN candidate on main + NO comment-cited fix →
1821+
# reroute to `no-fix-yet` (NOT `open-on-main`).
1822+
function Get-IssueCommentPrs { param($Repo, $IssueNumber) return @() }
1823+
$clsClosedNoFix = Classify-RegressionCandidate `
1824+
-Issue ([pscustomobject]@{ number = 35615; state = 'CLOSED' }) `
1825+
-CandidatePrs @(35716) `
1826+
-Ctx @{ repo = 'dotnet/maui'; srBranch = 'release/10.0.1xx-sr9'; mainBranch = 'main' } `
1827+
-SrContents @{ sourcePrs = @(); reverts = @() }
1828+
Assert-Eq -Label "CLOSED issue + OPEN candidate PR → no-fix-yet, never open-on-main" `
1829+
-Expected 'no-fix-yet' -Actual $clsClosedNoFix.classification
1830+
# `no-fix-yet` is raw Tier 1 via Get-VerdictTier, but Get-OverallVerdict downgrades a
1831+
# CLOSED no-fix-yet to non-blocking (🟢). That downgrade is the whole point: it turns
1832+
# the false blocking Tier-2 `open-on-main` into a Tier-3 (non-blocking) outcome.
1833+
$vClosedNoFix = Get-OverallVerdict -Data @{
1834+
metadata = @{ mode = 'shipped' }
1835+
regressions = @(@{ classification = $clsClosedNoFix.classification; state = 'CLOSED' })
1836+
ci = @{ overall = 'green' }
1837+
}
1838+
Assert-Eq -Label "CLOSED no-fix-yet is non-blocking (Tier 3 effective → 🟢)" `
1839+
-Expected '🟢' -Actual $vClosedNoFix.symbol
1840+
1841+
# Test 2 — CLOSED issue + same OPEN candidate on main, BUT a comment cites a MERGED
1842+
# fix that is on the SR branch → the recovery wins → `closed-fix-unlinked`.
1843+
function Get-IssueCommentPrs { param($Repo, $IssueNumber) return @(@{ number = 35028; evidence = 'fix-phrase' }) }
1844+
function Test-PrNumberOnBranch { param([int]$PrNumber, [string]$BranchRef) return ($PrNumber -eq 35028 -and $BranchRef -eq 'origin/release/10.0.1xx-sr9') }
1845+
$clsClosedRecovered = Classify-RegressionCandidate `
1846+
-Issue ([pscustomobject]@{ number = 35615; state = 'CLOSED' }) `
1847+
-CandidatePrs @(35716) `
1848+
-Ctx @{ repo = 'dotnet/maui'; srBranch = 'release/10.0.1xx-sr9'; mainBranch = 'main' } `
1849+
-SrContents @{ sourcePrs = @(); reverts = @() }
1850+
Assert-Eq -Label "CLOSED issue + OPEN candidate + comment-cited merged fix on SR → closed-fix-unlinked (recovery wins)" `
1851+
-Expected 'closed-fix-unlinked' -Actual $clsClosedRecovered.classification
1852+
Assert-Eq -Label "closed-fix-unlinked recovery is Tier 3 (non-blocking)" `
1853+
-Expected 3 -Actual (Get-VerdictTier -Classification $clsClosedRecovered.classification)
1854+
1855+
# Test 3 — REGRESSION GUARD: OPEN issue + OPEN candidate on main → the guard is
1856+
# gated on CLOSED, so a genuinely-open regression STAYS `open-on-main`.
1857+
function Get-IssueCommentPrs { param($Repo, $IssueNumber) return @() }
1858+
function Test-PrNumberOnBranch { param([int]$PrNumber, [string]$BranchRef) return $false }
1859+
$clsOpenIssue = Classify-RegressionCandidate `
1860+
-Issue ([pscustomobject]@{ number = 35615; state = 'OPEN' }) `
1861+
-CandidatePrs @(35716) `
1862+
-Ctx @{ repo = 'dotnet/maui'; srBranch = 'release/10.0.1xx-sr9'; mainBranch = 'main' } `
1863+
-SrContents @{ sourcePrs = @(); reverts = @() }
1864+
Assert-Eq -Label "OPEN issue + OPEN candidate PR → stays open-on-main (guard is CLOSED-only)" `
1865+
-Expected 'open-on-main' -Actual $clsOpenIssue.classification
1866+
17741867
# ───── Get-VerdictTier (deterministic tier table) ─────
17751868
Write-Host "`n[Unit] Get-VerdictTier (deterministic tier table)" -ForegroundColor Cyan
17761869

0 commit comments

Comments
 (0)