Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions .github/scripts/Fix-MilestoneDrift.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,165 @@ BeforeAll {
. "$PSScriptRoot/Fix-MilestoneDrift.ps1"
}

Describe 'Resolve-MergedAfterCutoff' {
It 'defaults to 2026-01-01 UTC when value is "<Value>"' -ForEach @(
@{ Value = $null }
@{ Value = '' }
@{ Value = ' ' }
) {
$result = Resolve-MergedAfterCutoff $Value
$result | Should -Be ([datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc))
$result.Kind | Should -Be ([System.DateTimeKind]::Utc)
}

It 'parses a date-only value "<Value>" as UTC midnight' -ForEach @(
@{ Value = '2025-01-01'; Year = 2025; Month = 1; Day = 1 }
@{ Value = '2024-06-15'; Year = 2024; Month = 6; Day = 15 }
@{ Value = '2020-12-31'; Year = 2020; Month = 12; Day = 31 }
) {
$result = Resolve-MergedAfterCutoff $Value
$result.Year | Should -Be $Year
$result.Month | Should -Be $Month
$result.Day | Should -Be $Day
$result.Hour | Should -Be 0
$result.Kind | Should -Be ([System.DateTimeKind]::Utc)
}

It 'parses an ISO-8601 value with explicit UTC offset' {
$result = Resolve-MergedAfterCutoff '2025-06-01T12:30:00Z'
$result.Kind | Should -Be ([System.DateTimeKind]::Utc)
$result | Should -Be ([datetime]::new(2025, 6, 1, 12, 30, 0, [System.DateTimeKind]::Utc))
}

It 'normalizes a non-UTC offset to UTC' {
# 2025-06-01T00:00:00+05:00 == 2025-05-31T19:00:00Z
$result = Resolve-MergedAfterCutoff '2025-06-01T00:00:00+05:00'
$result.Kind | Should -Be ([System.DateTimeKind]::Utc)
$result | Should -Be ([datetime]::new(2025, 5, 31, 19, 0, 0, [System.DateTimeKind]::Utc))
}

It 'throws a clear error for unparseable value "<Value>"' -ForEach @(
@{ Value = 'garbage' }
@{ Value = 'not-a-date' }
@{ Value = '2025-13-99' }
@{ Value = '13/13/2025' }
) {
{ Resolve-MergedAfterCutoff $Value } | Should -Throw "*Invalid -MergedAfter value*"
}
}

Describe 'Get-PrInfo — merged-after cutoff enforcement' {
BeforeAll {
# Build a GitHub-pulls-API-shaped object (ConvertFrom-Json style) for the mock.
function New-FakePr {
param([string]$MergedAt, [int]$Number = 42)
[pscustomobject]@{
title = "PR $Number"
html_url = "https://github.com/dotnet/maui/pull/$Number"
body = ''
merged_at = $MergedAt
milestone = $null
base = [pscustomobject]@{ ref = 'net11.0' }
merge_commit_sha = 'deadbeef'
}
}
}

AfterAll {
# Restore the default cutoff so later Describes are unaffected.
$script:MergedAfterCutoff = Resolve-MergedAfterCutoff ''
}

It 'skips a PR merged before the cutoff (returns a pre-cutoff sentinel, not $null)' {
$script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01
Mock Invoke-GhApi { New-FakePr -MergedAt '2025-05-01T00:00:00Z' -Number 100 }
$result = Get-PrInfo 100
$result | Should -BeOfType [hashtable]
$result.SkippedPreCutoff | Should -BeTrue
$result.Number | Should -Be 100
}

It 'includes a PR merged on/after the cutoff (returns the object, no skip sentinel)' {
$script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01
Mock Invoke-GhApi { New-FakePr -MergedAt '2026-03-01T00:00:00Z' -Number 101 }
$pr = Get-PrInfo 101
$pr | Should -Not -BeNullOrEmpty
$pr.Number | Should -Be 101
$pr.ContainsKey('SkippedPreCutoff') | Should -BeFalse
}

It 'includes a PR merged exactly at the cutoff boundary (strict less-than)' {
$script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01T00:00:00Z
Mock Invoke-GhApi { New-FakePr -MergedAt '2026-01-01T00:00:00Z' -Number 102 }
Get-PrInfo 102 | Should -Not -BeNullOrEmpty
}

It 'a lowered cutoff lets an older PR through (the configurable use case)' {
# Same 2025 PR that the default cutoff skips is now processed.
$script:MergedAfterCutoff = Resolve-MergedAfterCutoff '2024-01-01'
Mock Invoke-GhApi { New-FakePr -MergedAt '2025-05-01T00:00:00Z' -Number 103 }
$pr = Get-PrInfo 103
$pr | Should -Not -BeNullOrEmpty
$pr.Number | Should -Be 103
}

It 'a raised cutoff skips a PR that the default would include' {
$script:MergedAfterCutoff = Resolve-MergedAfterCutoff '2026-06-01'
Mock Invoke-GhApi { New-FakePr -MergedAt '2026-03-01T00:00:00Z' -Number 104 }
$result = Get-PrInfo 104
$result.SkippedPreCutoff | Should -BeTrue
$result.Number | Should -Be 104
}

It 'never skips an unmerged PR (no merged_at) regardless of cutoff' {
$script:MergedAfterCutoff = Resolve-MergedAfterCutoff ''
Mock Invoke-GhApi { New-FakePr -MergedAt $null -Number 105 }
Get-PrInfo 105 | Should -Not -BeNullOrEmpty
}
}

Describe 'Invoke-AnalyzeRelease — pre-cutoff skips are accounted separately from errors' {
BeforeEach {
# Minimal mocks so Invoke-AnalyzeRelease reaches the PR loop without touching git/gh.
Mock ConvertTo-Milestone { '.NET 10 SR8' }
Mock Get-AllTags { @('10.0.70', '10.0.80') }
Mock Initialize-MilestoneValidationContext { }
Mock Get-MainBranchForVersion { 'net10.0' }
Mock Get-AllMilestones { @{ '.NET 10 SR8' = 999 } }
Mock Find-MatchingMilestone { @{ Number = 999; Title = '.NET 10 SR8' } }
Mock Get-PrNumbersBetweenTags { @(100, 101) }
# Defensive: only reached for real (non-skipped, non-null) PRs — never hit in these tests.
Mock Test-PrBelongsToVersion { $true }
Mock Test-AndRecordCorrection { }
Mock Get-LinkedIssues { @() }
}

It 'counts an all-pre-cutoff cohort as skipped, not as errors (no spurious failure)' {
# Regression: a cohort whose PRs all predate the cutoff must NOT be reported as
# "0 PRs checked, N errors" (which makes the top-level script throw a red run).
Mock Get-PrInfo {
param([int]$PrNum)
return @{ SkippedPreCutoff = $true; Number = $PrNum }
}
$report = Invoke-AnalyzeRelease '10.0.80' '10.0.70' '.'
$report.PrsSkippedPreCutoff | Should -Be 2
$report.PrsChecked | Should -Be 0
$report.Errors.Count | Should -Be 0 # the top-level throw guard keys off Errors.Count
}

It 'still records a genuine fetch failure as an error, distinct from a pre-cutoff skip' {
Mock Get-PrInfo {
param([int]$PrNum)
if ($PrNum -eq 101) { return $null } # real fetch failure
return @{ SkippedPreCutoff = $true; Number = $PrNum } # pre-cutoff skip
}
$report = Invoke-AnalyzeRelease '10.0.80' '10.0.70' '.'
$report.PrsSkippedPreCutoff | Should -Be 1
$report.Errors.Count | Should -Be 1
$report.Errors[0] | Should -BeLike '*Failed to fetch PR #101*'
}
}

Describe 'ConvertTo-Milestone' {
It 'maps GA tag "<Tag>" to "<Expected>"' -ForEach @(
@{ Tag = '10.0.0'; Expected = '.NET 10.0 GA' }
Expand Down
58 changes: 54 additions & 4 deletions .github/scripts/Fix-MilestoneDrift.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
1. Single PR: -PrNumber 33818 [-Tag 10.0.50]
2. Single tag: -Tag 10.0.50 [-PreviousTag 10.0.41]

Safety: PRs merged before 2026-01-01 are always skipped.
Safety: PRs merged before a cutoff date are always skipped. The cutoff
defaults to 2026-01-01 (when this automation went live) and is configurable
via -MergedAfter, so an older release can be processed deliberately.
Comment on lines +16 to +18

.PARAMETER PrNumber
Analyze and fix a single PR (and its linked issues).
Expand All @@ -30,6 +32,15 @@
.PARAMETER Output
Output JSON file path.

.PARAMETER MergedAfter
Cutoff date: PRs merged strictly before this date are skipped (never
milestoned or closed). Defaults to 2026-01-01 (when this automation went
live) so the bulk -Apply / -CloseFixedIssues path can't reach back and
rewrite milestones for PRs that predate it. Override to deliberately process
an older release — e.g. -MergedAfter '2025-01-01' to close linked issues for
a historical SR. Accepts any parseable date (e.g. '2025-01-01' or
'2025-06-01T00:00:00Z'); no-timezone values are treated as UTC.

.PARAMETER Apply
Actually apply milestone fixes. Without this flag, only a dry-run report is produced.

Expand All @@ -46,6 +57,8 @@
./Fix-MilestoneDrift.ps1 -PrNumber 33818 -RepoPath ~/Projects/maui -Verbose
./Fix-MilestoneDrift.ps1 -PrNumber 33818 -Apply
./Fix-MilestoneDrift.ps1 -Tag 10.0.50 -RepoPath ~/Projects/maui
# Process a historical SR (closing linked issues) by lowering the cutoff:
./Fix-MilestoneDrift.ps1 -Tag 9.0.90 -MergedAfter '2024-01-01' -Apply -CloseFixedIssues
#>

[CmdletBinding()]
Expand All @@ -55,13 +68,33 @@ param(
[string]$PreviousTag,
[string]$RepoPath = ".",
[string]$Output,
[string]$MergedAfter,
[switch]$Apply,
[switch]$CreateIssue,
[switch]$CloseFixedIssues
)

# Safety: never process PRs merged before 2026
$script:MergedAfterCutoff = [datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc)
# Resolve the "merged after" safety cutoff. PRs merged strictly before this
# date are always skipped, so the bulk -Apply / -CloseFixedIssues path can never
# reach back and rewrite milestones for PRs that predate this automation.
# Defaults to 2026-01-01 (go-live); override via -MergedAfter to deliberately
# process an older release. Pure + side-effect-free so it can be unit tested.
function Resolve-MergedAfterCutoff {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return [datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc)
}
try {
return [datetime]::Parse(
$Value,
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)
} catch {
throw "Invalid -MergedAfter value '$Value'. Expected a date such as '2025-01-01' or '2025-06-01T00:00:00Z'."
}
}

$script:MergedAfterCutoff = Resolve-MergedAfterCutoff $MergedAfter

# Only enable StrictMode during normal execution — not when dot-sourced for testing,
# since StrictMode leaks into the caller scope and can break Pester or other scripts.
Expand Down Expand Up @@ -427,7 +460,10 @@ function Get-PrInfo([int]$PrNum) {
}
if ($mergedAt -lt $script:MergedAfterCutoff) {
Write-Warning "PR #$PrNum merged $($pr.merged_at) — before cutoff ($($script:MergedAfterCutoff.ToString('yyyy-MM-dd'))). Skipping."
return $null
# Return a distinct sentinel (not $null) so callers can tell an
# intentional pre-cutoff skip apart from a real fetch failure and
# avoid mis-reporting the skip as an error / failing the whole run.
return @{ SkippedPreCutoff = $true; Number = $PrNum }
}
}
return @{
Expand Down Expand Up @@ -987,6 +1023,9 @@ function Invoke-AnalyzeSinglePr([int]$PrNum, [string]$ReleaseTag, [string]$Repo)

# Fetch PR info first — we need merge_commit_sha for version detection
$pr = Get-PrInfo $PrNum
if ($pr -is [hashtable] -and $pr.ContainsKey('SkippedPreCutoff')) {
throw "PR #$PrNum was merged before the -MergedAfter cutoff ($($script:MergedAfterCutoff.ToString('yyyy-MM-dd'))). Lower -MergedAfter to process it."
}
if (-not $pr) { throw "Could not fetch PR #$PrNum" }

if ($ReleaseTag) {
Expand Down Expand Up @@ -1225,6 +1264,7 @@ function Invoke-AnalyzeRelease([string]$ReleaseTag, [string]$PrevTag, [string]$R
TotalPrs = $prNumbers.Count
PrsChecked = 0
PrsSkippedWrongBranch = 0
PrsSkippedPreCutoff = 0
IssuesChecked = 0
AlreadyCorrect = 0
Corrections = [System.Collections.ArrayList]::new()
Expand All @@ -1236,6 +1276,13 @@ function Invoke-AnalyzeRelease([string]$ReleaseTag, [string]$PrevTag, [string]$R
Write-Verbose " [$($i+1)/$($prNumbers.Count)] PR #$prNum..."

$pr = Get-PrInfo $prNum
if ($pr -is [hashtable] -and $pr.ContainsKey('SkippedPreCutoff')) {
# Intentional pre-cutoff skip (see Get-PrInfo) — not a fetch failure.
# Count it separately so an all-pre-cutoff cohort exits cleanly instead
# of being reported as "0 PRs checked, N errors".
$report.PrsSkippedPreCutoff++
continue
}
if (-not $pr) {
[void]$report.Errors.Add("Failed to fetch PR #$prNum")
continue
Expand Down Expand Up @@ -1282,6 +1329,9 @@ function Write-Report([hashtable]$Report) {
if ($Report.ContainsKey('PrsSkippedWrongBranch') -and $Report.PrsSkippedWrongBranch -gt 0) {
Write-Host " PRs skipped (wrong branch): $($Report.PrsSkippedWrongBranch)"
}
if ($Report.ContainsKey('PrsSkippedPreCutoff') -and $Report.PrsSkippedPreCutoff -gt 0) {
Write-Host " PRs skipped (merged before cutoff): $($Report.PrsSkippedPreCutoff)"
}
Write-Host " Issues checked: $($Report.IssuesChecked)"
Write-Host " Already correct: $($Report.AlreadyCorrect)"
$keptCount = if ($Report.ContainsKey('Kept')) { $Report.Kept.Count } else { 0 }
Expand Down
Loading
Loading