diff --git a/.github/scripts/Fix-MilestoneDrift.Tests.ps1 b/.github/scripts/Fix-MilestoneDrift.Tests.ps1 new file mode 100644 index 000000000000..b1750e2359b2 --- /dev/null +++ b/.github/scripts/Fix-MilestoneDrift.Tests.ps1 @@ -0,0 +1,336 @@ +#!/usr/bin/env pwsh +#Requires -Modules Pester +<# +.SYNOPSIS + Pester tests for Fix-MilestoneDrift.ps1. + Tests the pure functions (milestone mapping, matching, linked-issue extraction) + without hitting GitHub or Git. + +.EXAMPLE + Invoke-Pester ./Fix-MilestoneDrift.Tests.ps1 + Invoke-Pester ./Fix-MilestoneDrift.Tests.ps1 -Output Detailed +#> + +BeforeAll { + . "$PSScriptRoot/Fix-MilestoneDrift.ps1" +} + +Describe 'ConvertTo-Milestone' { + It 'maps GA tag "" to ""' -ForEach @( + @{ Tag = '10.0.0'; Expected = '.NET 10.0 GA' } + @{ Tag = '9.0.0'; Expected = '.NET 9.0 GA' } + ) { + ConvertTo-Milestone $Tag | Should -Be $Expected + } + + It 'maps SR tag "" to ""' -ForEach @( + @{ Tag = '10.0.10'; Expected = '.NET 10 SR1' } + @{ Tag = '10.0.11'; Expected = '.NET 10 SR1.1' } + @{ Tag = '10.0.20'; Expected = '.NET 10 SR2' } + @{ Tag = '10.0.31'; Expected = '.NET 10 SR3.1' } + @{ Tag = '10.0.40'; Expected = '.NET 10 SR4' } + @{ Tag = '10.0.41'; Expected = '.NET 10 SR4.1' } + @{ Tag = '10.0.50'; Expected = '.NET 10 SR5' } + @{ Tag = '9.0.82'; Expected = '.NET 9 SR8.2' } + @{ Tag = '9.0.90'; Expected = '.NET 9 SR9' } + @{ Tag = '10.0.100'; Expected = '.NET 10 SR10' } + @{ Tag = '10.0.101'; Expected = '.NET 10 SR10.1' } + ) { + ConvertTo-Milestone $Tag | Should -Be $Expected + } + + It 'maps early patch "" to SR1' -ForEach @( + @{ Tag = '10.0.1' } + @{ Tag = '10.0.5' } + @{ Tag = '10.0.9' } + ) { + ConvertTo-Milestone $Tag | Should -Be '.NET 10.0 SR1' + } + + It 'returns $null for non-SR tags' -ForEach @( + @{ Tag = '10.0.0-preview.7.25406.3' } + @{ Tag = 'not-a-tag' } + @{ Tag = '' } + ) { + ConvertTo-Milestone $Tag | Should -BeNullOrEmpty + } +} + +Describe 'Test-MilestoneMatch' { + It 'exact match' { + Test-MilestoneMatch '.NET 10 SR5' '.NET 10 SR5' | Should -BeTrue + } + + It 'normalized match: ".NET 10.0 SR4" matches ".NET 10 SR4"' { + Test-MilestoneMatch '.NET 10.0 SR4' '.NET 10 SR4' | Should -BeTrue + } + + It 'normalized match: ".NET 10 SR4" matches ".NET 10.0 SR4"' { + Test-MilestoneMatch '.NET 10 SR4' '.NET 10.0 SR4' | Should -BeTrue + } + + It 'GA normalized match: ".NET 10.0 GA" matches ".NET 10 GA"' { + Test-MilestoneMatch '.NET 10.0 GA' '.NET 10 GA' | Should -BeTrue + } + + It 'GA normalized match: ".NET 10 GA" matches ".NET 10.0 GA"' { + Test-MilestoneMatch '.NET 10 GA' '.NET 10.0 GA' | Should -BeTrue + } + + It 'sub-patch ".NET 10 SR5.1" does NOT match ".NET 10 SR5" (distinct milestones)' { + Test-MilestoneMatch '.NET 10 SR5.1' '.NET 10 SR5' | Should -BeFalse + } + + It 'sub-patch ".NET 10 SR4.1" exact match' { + Test-MilestoneMatch '.NET 10 SR4.1' '.NET 10 SR4.1' | Should -BeTrue + } + + It 'sub-patch with normalization: ".NET 10.0 SR4.1" matches ".NET 10 SR4.1"' { + Test-MilestoneMatch '.NET 10.0 SR4.1' '.NET 10 SR4.1' | Should -BeTrue + } + + It 'does not match different SR numbers' { + Test-MilestoneMatch '.NET 10 SR4' '.NET 10 SR5' | Should -BeFalse + } + + It 'does not match null actual' { + Test-MilestoneMatch $null '.NET 10 SR5' | Should -BeFalse + } + + It 'does not match empty actual' { + Test-MilestoneMatch '' '.NET 10 SR5' | Should -BeFalse + } + + It 'does not match completely different milestones' { + Test-MilestoneMatch 'Backlog' '.NET 10 SR5' | Should -BeFalse + Test-MilestoneMatch '.NET 9 Servicing' '.NET 10 SR5' | Should -BeFalse + Test-MilestoneMatch '.NET 11 Planning' '.NET 10 SR5' | Should -BeFalse + } +} + +Describe 'Find-MatchingMilestone' { + BeforeAll { + $script:milestones = @{ + '.NET 10.0 GA' = 102 + '.NET 10.0 SR1' = 99 + '.NET 10.0 SR2' = 107 + '.NET 10.0 SR3' = 109 + '.NET 10.0 SR4' = 110 + '.NET 10 SR5' = 113 + '.NET 10 SR6' = 115 + '.NET 10.0 SR8' = 117 + } + } + + It 'direct match for ".NET 10 SR5"' { + $result = Find-MatchingMilestone '.NET 10 SR5' $milestones + $result.Title | Should -Be '.NET 10 SR5' + $result.Number | Should -Be 113 + } + + It 'normalized match: ".NET 10 SR4" resolves to ".NET 10.0 SR4"' { + $result = Find-MatchingMilestone '.NET 10 SR4' $milestones + $result.Title | Should -Be '.NET 10.0 SR4' + $result.Number | Should -Be 110 + } + + It 'normalized match: ".NET 10 SR1" resolves to ".NET 10.0 SR1"' { + $result = Find-MatchingMilestone '.NET 10 SR1' $milestones + $result.Title | Should -Be '.NET 10.0 SR1' + $result.Number | Should -Be 99 + } + + It 'alt format: ".NET 10.0 SR8" resolves from ".NET 10 SR8"' { + $result = Find-MatchingMilestone '.NET 10 SR8' $milestones + $result.Title | Should -Be '.NET 10.0 SR8' + $result.Number | Should -Be 117 + } + + It 'returns $null for non-existent milestone' { + Find-MatchingMilestone '.NET 10 SR99' $milestones | Should -BeNullOrEmpty + } +} + +Describe 'Find-PreviousTag' { + BeforeAll { + $script:tags = @( + '10.0.0', '10.0.1', '10.0.10', '10.0.11', + '10.0.20', '10.0.30', '10.0.31', + '10.0.40', '10.0.41', '10.0.50', + '9.0.82', '9.0.90' + ) + } + + It '"" → ""' -ForEach @( + @{ Tag = '10.0.50'; Expected = '10.0.41' } + @{ Tag = '10.0.41'; Expected = '10.0.40' } + @{ Tag = '10.0.40'; Expected = '10.0.31' } + @{ Tag = '10.0.20'; Expected = '10.0.11' } + @{ Tag = '10.0.10'; Expected = '10.0.1' } + @{ Tag = '10.0.1'; Expected = '10.0.0' } + @{ Tag = '9.0.90'; Expected = '9.0.82' } + ) { + Find-PreviousTag $Tag $tags | Should -Be $Expected + } + + It 'returns $null when no previous exists' { + Find-PreviousTag '10.0.0' $tags | Should -BeNullOrEmpty + } + + It 'only considers same major version' { + Find-PreviousTag '9.0.82' $tags | Should -Not -Match '^10\.' + } +} + +Describe 'Get-LinkedIssues' { + It 'extracts "Fixes #NNNNN"' { + $result = Get-LinkedIssues "Fixes #12345" "Some title" + $result | Should -Contain 12345 + } + + It 'extracts "Closes #NNNNN" and "Resolves #NNNNN"' { + $result = Get-LinkedIssues "Closes #111`nResolves #222" "Title" + $result | Should -Contain 111 + $result | Should -Contain 222 + } + + It 'extracts past-tense variants' { + $result = Get-LinkedIssues "Fixed #333`nClosed #444`nResolved #555" "Title" + $result | Should -Contain 333 + $result | Should -Contain 444 + $result | Should -Contain 555 + } + + It 'extracts bare "close" and "resolve" forms' { + $result = Get-LinkedIssues "Close #777`nResolve #888" "Title" + $result | Should -Contain 777 + $result | Should -Contain 888 + } + + It 'extracts full GitHub issue URLs with fixing keyword' { + $result = Get-LinkedIssues "Fixes https://github.com/dotnet/maui/issues/33800" "Title" + $result | Should -Contain 33800 + } + + It 'ignores bare GitHub issue URLs without fixing keyword' { + $result = @(Get-LinkedIssues "See https://github.com/dotnet/maui/issues/33800" "Title") + $result | Should -HaveCount 0 + } + + It 'ignores informational URL references' { + $result = @(Get-LinkedIssues "I believe a previously closed issue maybe the same thing happening:`nhttps://github.com/dotnet/maui/issues/17549" "Title") + $result | Should -HaveCount 0 + } + + It 'extracts from title' { + $result = Get-LinkedIssues "" "Fix issue fixes #99999" + $result | Should -Contain 99999 + } + + It 'deduplicates' { + $result = @(Get-LinkedIssues "Fixes #100`nAlso fixes #100`nResolves https://github.com/dotnet/maui/issues/100" "Title") + $result | Should -HaveCount 1 + $result[0] | Should -Be 100 + } + + It 'returns empty for no references' { + $result = Get-LinkedIssues "No issues here" "Just a title" + $result | Should -HaveCount 0 + } + + It 'handles case insensitivity' { + $result = Get-LinkedIssues "FIXES #555 and RESOLVES #666" "Title" + $result | Should -Contain 555 + $result | Should -Contain 666 + } +} + +Describe 'Get-PatchVersion' { + It '"" → ' -ForEach @( + @{ Tag = '10.0.50'; Expected = 50 } + @{ Tag = '10.0.0'; Expected = 0 } + @{ Tag = '10.0.100'; Expected = 100 } + @{ Tag = 'invalid'; Expected = 0 } + ) { + Get-PatchVersion $Tag | Should -Be $Expected + } +} + +Describe 'Test-IsReleaseTag' { + It 'accepts valid .NET 10 SR tags' { + Test-IsReleaseTag '10.0.50' 10 | Should -BeTrue + Test-IsReleaseTag '10.0.0' 10 | Should -BeTrue + } + + It 'rejects wrong major version' { + Test-IsReleaseTag '9.0.82' 10 | Should -BeFalse + } + + It 'rejects non-SR tags' { + Test-IsReleaseTag '10.0.0-preview.7.25406.3' 10 | Should -BeFalse + Test-IsReleaseTag 'not-a-tag' 10 | Should -BeFalse + } +} + +Describe 'Test-PrBelongsToVersion' { + Context 'MainBranch = main (e.g. .NET 10)' { + It 'allows PRs targeting main' { + Test-PrBelongsToVersion 'main' 'main' 10 | Should -BeTrue + } + + It 'allows PRs targeting inflight branches' { + Test-PrBelongsToVersion 'inflight/current' 'main' 10 | Should -BeTrue + } + + It 'allows PRs targeting release branches for same version' { + Test-PrBelongsToVersion 'release/10.0.50' 'main' 10 | Should -BeTrue + } + + It 'allows PRs targeting darc branches' { + Test-PrBelongsToVersion 'darc/main-abc123' 'main' 10 | Should -BeTrue + } + + It 'rejects PRs targeting net11.0' { + Test-PrBelongsToVersion 'net11.0' 'main' 10 | Should -BeFalse + } + + It 'rejects PRs targeting net12.0' { + Test-PrBelongsToVersion 'net12.0' 'main' 10 | Should -BeFalse + } + + It 'allows PRs targeting feature branches' { + Test-PrBelongsToVersion 'feature/my-feature' 'main' 10 | Should -BeTrue + } + + It 'allows null/empty base ref' { + Test-PrBelongsToVersion $null 'main' 10 | Should -BeTrue + Test-PrBelongsToVersion '' 'main' 10 | Should -BeTrue + } + } + + Context 'MainBranch = net11.0 (e.g. .NET 11)' { + It 'allows PRs targeting net11.0' { + Test-PrBelongsToVersion 'net11.0' 'net11.0' 11 | Should -BeTrue + } + + It 'rejects PRs targeting main (those are .NET 10)' { + Test-PrBelongsToVersion 'main' 'net11.0' 11 | Should -BeFalse + } + + It 'rejects PRs targeting inflight (feeds into main, not net11.0)' { + Test-PrBelongsToVersion 'inflight/current' 'net11.0' 11 | Should -BeFalse + } + + It 'rejects PRs targeting net10.0' { + Test-PrBelongsToVersion 'net10.0' 'net11.0' 11 | Should -BeFalse + } + + It 'allows PRs targeting release/11.x branches' { + Test-PrBelongsToVersion 'release/11.0.10' 'net11.0' 11 | Should -BeTrue + } + + It 'rejects PRs targeting release/10.x branches' { + Test-PrBelongsToVersion 'release/10.0.50' 'net11.0' 11 | Should -BeFalse + } + } +} diff --git a/.github/scripts/Fix-MilestoneDrift.ps1 b/.github/scripts/Fix-MilestoneDrift.ps1 new file mode 100644 index 000000000000..37e223efa05e --- /dev/null +++ b/.github/scripts/Fix-MilestoneDrift.ps1 @@ -0,0 +1,816 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Detects and fixes milestone drift for dotnet/maui releases. + +.DESCRIPTION + When PRs merge to inflight/current, they may get milestoned prematurely. + The actual release they ship in depends on which Candidate PR carries them + to main and which SR branch cut includes that commit. This script detects + and reports (or optionally fixes) milestone mismatches. + + Modes: + 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. + +.PARAMETER PrNumber + Analyze and fix a single PR (and its linked issues). + +.PARAMETER Tag + Release tag to analyze (e.g., "10.0.50"). For single-PR mode, auto-detected if omitted. + +.PARAMETER PreviousTag + Previous release tag. Auto-detected if omitted. + +.PARAMETER RepoPath + Path to a dotnet/maui git checkout with full tag history. + +.PARAMETER Output + Output JSON file path. + +.PARAMETER Apply + Actually apply milestone fixes. Without this flag, only a dry-run report is produced. + +.PARAMETER CreateIssue + Create a GitHub issue in dotnet/maui with the milestone drift report. + +.EXAMPLE + ./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 +#> + +[CmdletBinding()] +param( + [int]$PrNumber, + [string]$Tag, + [string]$PreviousTag, + [string]$RepoPath = ".", + [string]$Output, + [switch]$Apply, + [switch]$CreateIssue +) + +# Safety: never process PRs merged before 2026 +$script:MergedAfterCutoff = [datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc) + +# 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. +if ($MyInvocation.InvocationName -ne '.') { + Set-StrictMode -Version Latest + $ErrorActionPreference = "Stop" +} + +#region ── Milestone mapping helpers ────────────────────────────────────── + +function Get-CurrentMajorVersion([string]$Repo) { + <# Reads MajorVersion from eng/Versions.props on origin/main. #> + $versionXml = git -C $Repo --no-pager show origin/main:eng/Versions.props 2>&1 + if ($LASTEXITCODE -eq 0) { + $joined = ($versionXml -join "`n") + if ($joined -match '(\d+)') { + return [int]$Matches[1] + } + } + throw "Could not read MajorVersion from origin/main:eng/Versions.props" +} + +function Get-MainBranchForVersion([int]$Major, [string]$Repo) { + <# Determines which development branch owns a .NET version by reading + MajorVersion from eng/Versions.props on main. If main's MajorVersion + matches, the version lives on main. Otherwise it's on net{Major}.0. + This works correctly across version transitions — when main moves + from .NET 10 to .NET 11, MajorVersion in Versions.props changes too. #> + $versionXml = git -C $Repo --no-pager show origin/main:eng/Versions.props 2>&1 + if ($LASTEXITCODE -eq 0) { + $joined = ($versionXml -join "`n") + if ($joined -match '(\d+)') { + $mainMajor = [int]$Matches[1] + if ($mainMajor -eq $Major) { return "main" } + Write-Verbose "origin/main has MajorVersion=$mainMajor, not $Major — version lives on net$Major.0" + return "net$Major.0" + } + } + Write-Warning "Could not read MajorVersion from origin/main:eng/Versions.props — falling back to net$Major.0" + return "net$Major.0" +} + +function Get-VersionFromGitRef([string]$GitRef, [string]$Repo) { + <# Reads MajorVersion and PatchVersion from eng/Versions.props at a specific + git ref (commit SHA, branch, tag). Returns a synthetic release tag string + like "10.0.60" that can be passed to ConvertTo-Milestone. + Fetches the commit if not available locally (e.g. PRs merged to inflight). #> + $versionXml = git -C $Repo --no-pager show "${GitRef}:eng/Versions.props" 2>&1 + if ($LASTEXITCODE -ne 0) { + # Commit not in local history — fetch it + Write-Verbose " Fetching commit $GitRef..." + $null = git -C $Repo fetch origin $GitRef --quiet 2>&1 + $versionXml = git -C $Repo --no-pager show "${GitRef}:eng/Versions.props" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "Could not read Versions.props at commit $GitRef (even after fetch)" + return $null + } + } + $joined = ($versionXml -join "`n") + if ($joined -match '(\d+)') { + $major = $Matches[1] + } else { + Write-Warning "Could not parse MajorVersion from Versions.props at $GitRef" + return $null + } + if ($joined -match '(\d+)') { + $patch = $Matches[1] + } else { + Write-Warning "Could not parse PatchVersion from Versions.props at $GitRef" + return $null + } + return "$major.0.$patch" +} + +function ConvertTo-Milestone([string]$ReleaseTag) { + <# Converts "10.0.50" → ".NET 10 SR5", "10.0.41" → ".NET 10 SR4.1", "10.0.0" → ".NET 10.0 GA" #> + if ($ReleaseTag -notmatch '^(\d+)\.0\.(\d+)$') { return $null } + $major = [int]$Matches[1]; $patch = [int]$Matches[2] + if ($patch -eq 0) { return ".NET $major.0 GA" } + if ($patch -lt 10) { return ".NET $major.0 SR1" } + $sr = [math]::Floor($patch / 10) + $sub = $patch % 10 + if ($sub -eq 0) { return ".NET $major SR$sr" } + return ".NET $major SR$sr.$sub" +} + +function Get-PatchVersion([string]$ReleaseTag) { + if ($ReleaseTag -match '^(\d+)\.0\.(\d+)$') { return [int]$Matches[2] } + return 0 +} + +function Test-IsReleaseTag([string]$ReleaseTag, [int]$Major) { + return ($ReleaseTag -match "^$Major\.0\.\d+$") +} + +function Test-MilestoneMatch([string]$Actual, [string]$Expected) { + <# Handles ".NET 10.0 SR4" vs ".NET 10 SR4" and ".NET 10.0 GA" vs ".NET 10 GA" normalization. + Sub-patches like ".NET 10 SR4.1" are distinct milestones and do NOT match ".NET 10 SR4". #> + if ([string]::IsNullOrEmpty($Actual)) { return $false } + if ($Actual -eq $Expected) { return $true } + + # Normalize: ".NET 10.0 SRx" → ".NET 10 SRx" and ".NET 10.0 GA" → ".NET 10 GA" + $normActual = $Actual -replace '\.NET (\d+)\.0 (SR|GA)', '.NET $1 $2' + $normExpected = $Expected -replace '\.NET (\d+)\.0 (SR|GA)', '.NET $1 $2' + if ($normActual -eq $normExpected) { return $true } + + return $false +} + +function Find-MatchingMilestone([string]$Expected, [hashtable]$AllMilestones) { + <# Returns @{Title; Number} or $null #> + if ($AllMilestones.ContainsKey($Expected)) { + return @{ Title = $Expected; Number = $AllMilestones[$Expected] } + } + # Normalized search (handles ".NET 10.0 SRx" ↔ ".NET 10 SRx" and ".NET 10.0 GA" ↔ ".NET 10 GA") + $normExpected = $Expected -replace '\.NET (\d+)\.0 (SR|GA)', '.NET $1 $2' + foreach ($key in $AllMilestones.Keys) { + $normKey = $key -replace '\.NET (\d+)\.0 (SR|GA)', '.NET $1 $2' + if ($normKey -eq $normExpected) { + return @{ Title = $key; Number = $AllMilestones[$key] } + } + } + return $null +} + +function Find-PreviousTag([string]$ReleaseTag, [string[]]$AllTags) { + if ($ReleaseTag -notmatch '^(\d+)\.0\.(\d+)$') { return $null } + $major = [int]$Matches[1]; $patch = [int]$Matches[2] + $candidates = $AllTags | Where-Object { + $_ -match "^$major\.0\.(\d+)$" -and [int]$Matches[1] -lt $patch + } | Sort-Object { Get-PatchVersion $_ } + return ($candidates | Select-Object -Last 1) +} + +function Test-PrBelongsToVersion([string]$BaseRef, [string]$MainBranch, [int]$Major) { + <# Checks if a PR's base branch is compatible with the version being analyzed. + Prevents merge-up commits from causing incorrect milestoning. + E.g. when main (.NET 10) merges into net11.0, the .NET 10 PRs should not + be milestoned as .NET 11. #> + if ([string]::IsNullOrEmpty($BaseRef)) { return $true } + + # PR targets the main branch for this version — always allowed + if ($BaseRef -eq $MainBranch) { return $true } + + # Allow inflight/darc branches only if they feed into this version's main branch + if ($BaseRef -match '^(inflight|darc)/') { + # inflight/* and darc/* branches feed into 'main', so only allow when MainBranch is 'main' + return ($MainBranch -eq 'main') + } + + # Release branches: only allow if they match this major version + if ($BaseRef -match '^release/(\d+)\.') { + return ([int]$Matches[1] -eq $Major) + } + + # Reject PRs explicitly targeting a different .NET version branch (net11.0, net12.0, etc.) + if ($BaseRef -match '^net(\d+)\.\d+$') { + return ([int]$Matches[1] -eq $Major) + } + + # Reject 'main' when we're analyzing a non-main version (e.g. .NET 11 on net11.0) + # PRs targeting 'main' belong to the .NET version that lives on main, not this one. + if ($BaseRef -eq 'main' -and $MainBranch -ne 'main') { + return $false + } + + # Unknown branches (feature/*, etc.) — allow + return $true +} + +#endregion + +#region ── Git helpers ──────────────────────────────────────────────────── + +function Get-AllTags([string]$Repo) { + $output = git -C $Repo --no-pager tag -l 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git tag failed: $output" } + return ($output -split "`n" | Where-Object { $_ }) +} + +function Get-PrNumbersBetweenTags([string]$TagFrom, [string]$TagTo, [string]$Repo) { + $output = git -C $Repo --no-pager log --oneline "$TagFrom..$TagTo" 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git log failed: $output" } + $prs = [System.Collections.Generic.HashSet[int]]::new() + foreach ($line in ($output -split "`n")) { + foreach ($m in [regex]::Matches($line, '\(#(\d+)\)')) { + [void]$prs.Add([int]$m.Groups[1].Value) + } + } + return ($prs | Sort-Object) +} + +function Get-PrNumbersReachableFromTag([string]$TagName, [string]$Repo) { + <# Returns PR numbers reachable from a tag (all commits up to and including the tag). #> + $output = git -C $Repo --no-pager log --oneline $TagName 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git log failed: $output" } + $prs = [System.Collections.Generic.HashSet[int]]::new() + foreach ($line in ($output -split "`n")) { + foreach ($m in [regex]::Matches($line, '\(#(\d+)\)')) { + [void]$prs.Add([int]$m.Groups[1].Value) + } + } + return ($prs | Sort-Object) +} + +function Find-TagContainingPr([int]$PrNum, [string]$Repo, [int]$Major) { + <# Searches tag ranges to find which release contains this PR. + Handles GA (first tag) by searching all reachable commits. #> + $allTags = Get-AllTags $Repo + $srTags = $allTags | Where-Object { Test-IsReleaseTag $_ $Major } | + Sort-Object { Get-PatchVersion $_ } + + for ($i = 0; $i -lt $srTags.Count; $i++) { + $current = $srTags[$i] + + if ($i -eq 0) { + # First tag (e.g. GA) — search all commits reachable from it + $prsInRange = Get-PrNumbersReachableFromTag $current $Repo + } else { + $prev = $srTags[$i - 1] + $prsInRange = Get-PrNumbersBetweenTags $prev $current $Repo + } + + if ($PrNum -in $prsInRange) { + $previousTag = if ($i -gt 0) { $srTags[$i - 1] } else { $null } + return @{ Tag = $current; PreviousTag = $previousTag } + } + } + return $null +} + +#endregion + +#region ── GitHub helpers ───────────────────────────────────────────────── + +function Invoke-GhApi([string]$Endpoint) { + $result = gh api $Endpoint 2>&1 + if ($LASTEXITCODE -ne 0) { throw "gh api $Endpoint failed: $result" } + return ($result | ConvertFrom-Json) +} + +function Get-AllMilestones { + $milestones = @{}; $page = 1 + while ($true) { + $data = Invoke-GhApi "repos/dotnet/maui/milestones?state=all&per_page=100&page=$page" + foreach ($ms in $data) { $milestones[$ms.title] = $ms.number } + if ($data.Count -lt 100) { break } + $page++ + } + return $milestones +} + +function Get-PrInfo([int]$PrNum) { + try { + $pr = Invoke-GhApi "repos/dotnet/maui/pulls/$PrNum" + # Safety: skip PRs merged before cutoff date + if ($pr.merged_at) { + # ConvertFrom-Json may return a DateTime or a string depending on PS version + $mergedAt = if ($pr.merged_at -is [datetime]) { + $pr.merged_at.ToUniversalTime() + } else { + [datetime]::Parse($pr.merged_at, [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AdjustToUniversal) + } + 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 @{ + Number = $PrNum + Title = $pr.title + Milestone = if ($pr.milestone) { $pr.milestone.title } else { $null } + MsNumber = if ($pr.milestone) { $pr.milestone.number } else { $null } + Url = $pr.html_url + Body = if ($pr.body) { $pr.body } else { "" } + BaseRef = if ($pr.base -and $pr.base.ref) { $pr.base.ref } else { $null } + MergeCommitSha = if ($pr.merge_commit_sha) { $pr.merge_commit_sha } else { $null } + } + } catch { + Write-Warning "Failed to fetch PR #$PrNum`: $_" + return $null + } +} + +function Get-IssueInfo([int]$IssueNumber) { + try { + $issue = Invoke-GhApi "repos/dotnet/maui/issues/$IssueNumber" + if ($issue.PSObject.Properties['pull_request'] -and $issue.pull_request) { return $null } + return @{ + Number = $IssueNumber + Title = $issue.title + State = $issue.state + Milestone = if ($issue.milestone) { $issue.milestone.title } else { $null } + MsNumber = if ($issue.milestone) { $issue.milestone.number } else { $null } + Url = $issue.html_url + } + } catch { + Write-Warning "Failed to fetch issue #$IssueNumber`: $_" + return $null + } +} + +function Get-LinkedIssues([string]$Body, [string]$Title) { + $text = "$Title`n$Body" + $issues = [System.Collections.Generic.HashSet[int]]::new() + foreach ($m in [regex]::Matches($text, '(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s+#(\d+)', 'IgnoreCase')) { + [void]$issues.Add([int]$m.Groups[1].Value) + } + # Match URLs only when preceded by a fixing keyword (mirrors GitHub auto-close behavior). + # Bare URLs like "See https://github.com/.../issues/123" are informational, not fixing references. + foreach ($m in [regex]::Matches($text, '(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s+https?://github\.com/dotnet/maui/issues/(\d+)', 'IgnoreCase')) { + [void]$issues.Add([int]$m.Groups[1].Value) + } + return ($issues | Sort-Object) +} + +function Set-ItemMilestone([int]$ItemNumber, [int]$MilestoneNumber) { + $body = @{ milestone = $MilestoneNumber } | ConvertTo-Json + $result = $body | gh api "repos/dotnet/maui/issues/$ItemNumber" -X PATCH --input - 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Failed to set milestone on #$ItemNumber`: $result" } +} + +#endregion + +#region ── Correction helpers ───────────────────────────────────────────── + +function Test-AndRecordCorrection( + [string]$ItemType, + [int]$ItemNumber, + [string]$ItemTitle, + [string]$ItemUrl, + [string]$CurrentMilestone, + [string]$ExpectedMs, + [hashtable]$ResolvedMs, + [int]$RelatedPr, + [hashtable]$Report +) { + if (Test-MilestoneMatch $CurrentMilestone $ExpectedMs) { + # Dedup: don't count the same item as correct twice (e.g. issue linked from multiple PRs) + $key = "$ItemType`:$ItemNumber" + if (-not $Report.ContainsKey('_checkedItems')) { $Report._checkedItems = [System.Collections.Generic.HashSet[string]]::new() } + if ($Report._checkedItems.Add($key)) { + $Report.AlreadyCorrect++ + } + Write-Verbose " ✅ $ItemType #$ItemNumber`: $CurrentMilestone (correct)" + return + } + + # Skip if a correction for this item was already recorded (e.g. same issue linked from multiple PRs) + $existing = $Report.Corrections | Where-Object { $_.ItemType -eq $ItemType -and $_.Number -eq $ItemNumber } + if ($existing) { + Write-Verbose " ⏭️ $ItemType #$ItemNumber`: already queued for correction (via PR #$($existing.RelatedPr))" + return + } + + $correction = @{ + ItemType = $ItemType + Number = $ItemNumber + Title = $ItemTitle + Url = $ItemUrl + Current = $CurrentMilestone + Expected = $ExpectedMs + Resolved = $ResolvedMs.Title + ResolvedNo = $ResolvedMs.Number + } + if ($RelatedPr -gt 0) { $correction.RelatedPr = $RelatedPr } + [void]$Report.Corrections.Add($correction) + + $current = if ($CurrentMilestone) { $CurrentMilestone } else { "(none)" } + Write-Verbose " ⚠️ $ItemType #$ItemNumber`: $current → $($ResolvedMs.Title)" +} + +#endregion + +#region ── Analysis ─────────────────────────────────────────────────────── + +function Invoke-AnalyzeSinglePr([int]$PrNum, [string]$ReleaseTag, [string]$Repo) { + Write-Host "`n$('═' * 70)" + Write-Host " Single-PR mode: #$PrNum" + Write-Host "$('═' * 70)`n" + + # Fetch PR info first — we need merge_commit_sha for version detection + $pr = Get-PrInfo $PrNum + if (-not $pr) { throw "Could not fetch PR #$PrNum" } + + if ($ReleaseTag) { + # Explicit tag: validate it exists and optionally check PR is in range + $allTags = Get-AllTags $Repo + if ($ReleaseTag -notin $allTags) { + throw "Tag '$ReleaseTag' not found in repo. Available SR tags: $(($allTags | Where-Object { $_ -match '^\d+\.0\.\d+$' }) -join ', ')" + } + $prev = Find-PreviousTag $ReleaseTag $allTags + if ($prev) { + $prsInRange = Get-PrNumbersBetweenTags $prev $ReleaseTag $Repo + } else { + $prsInRange = Get-PrNumbersReachableFromTag $ReleaseTag $Repo + } + if ($PrNum -notin $prsInRange) { + Write-Warning "PR #$PrNum is not in the commit range for tag $ReleaseTag. The milestone may be set incorrectly." + } + Write-Host " Using explicit tag: $ReleaseTag" + } elseif ($pr.MergeCommitSha) { + # No tag — read Versions.props at the merge commit to determine + # the version the branch was building when this PR merged. + $versionAtMerge = Get-VersionFromGitRef $pr.MergeCommitSha $Repo + if ($versionAtMerge) { + $ReleaseTag = $versionAtMerge + Write-Host " Version from Versions.props at merge commit: $ReleaseTag" + } + } + + # Fallback: try to find in existing tag ranges + if (-not $ReleaseTag) { + Write-Host "Auto-detecting release tag for PR #$PrNum..." + $fallbackMajor = Get-CurrentMajorVersion $Repo + $found = Find-TagContainingPr $PrNum $Repo $fallbackMajor + if (-not $found) { throw "PR #$PrNum not found in any release tag range for .NET $fallbackMajor" } + $ReleaseTag = $found.Tag + $prevDisplay = if ($found.PreviousTag) { $found.PreviousTag } else { "(root)" } + Write-Host " Found in: $prevDisplay..$ReleaseTag" + } + + $expectedMs = ConvertTo-Milestone $ReleaseTag + if (-not $expectedMs) { throw "Cannot determine milestone for tag $ReleaseTag" } + + # Derive MajorVersion from the tag + $Major = if ($ReleaseTag -match '^(\d+)\.') { [int]$Matches[1] } else { Get-CurrentMajorVersion $Repo } + + # Detect which branch owns this version's tags + $Branch = Get-MainBranchForVersion $Major $Repo + Write-Host " Main branch for .NET $Major`: $Branch" + Write-Host " Expected milestone: $expectedMs" + Write-Host "Fetching GitHub milestones..." + $allMilestones = Get-AllMilestones + $match = Find-MatchingMilestone $expectedMs $allMilestones + if (-not $match) { + # Milestone may not have been created yet (common during normal development). + # Warn and return empty report — prevents red CI on every auto-triggered merge. + Write-Warning "No GitHub milestone found matching `"$expectedMs`". The milestone may not have been created yet. Skipping." + return @{ + Tag = $ReleaseTag + ExpectedMilestone = $expectedMs + TotalPrs = 1 + PrsChecked = 0 + IssuesChecked = 0 + AlreadyCorrect = 0 + Corrections = [System.Collections.ArrayList]::new() + Errors = [System.Collections.ArrayList]::new() + } + } + Write-Host " Resolved to: `"$($match.Title)`" (#$($match.Number))`n" + + $report = @{ + Tag = $ReleaseTag + ExpectedMilestone = $expectedMs + ResolvedMilestone = $match.Title + ResolvedMsNumber = $match.Number + TotalPrs = 1 + PrsChecked = 0 + IssuesChecked = 0 + AlreadyCorrect = 0 + Corrections = [System.Collections.ArrayList]::new() + Errors = [System.Collections.ArrayList]::new() + } + + # Safety: skip PRs targeting a different .NET version branch + if (-not (Test-PrBelongsToVersion $pr.BaseRef $Branch $Major)) { + Write-Warning "PR #$PrNum targets '$($pr.BaseRef)' which is not for .NET $Major (MainBranch: $Branch). Skipping." + return $report + } + $report.PrsChecked++ + + Test-AndRecordCorrection "pr" $PrNum $pr.Title $pr.Url $pr.Milestone $expectedMs $match 0 $report + + # Check linked issues + $linked = Get-LinkedIssues $pr.Body $pr.Title + foreach ($issueNum in $linked) { + $issue = Get-IssueInfo $issueNum + if (-not $issue) { continue } + $report.IssuesChecked++ + Test-AndRecordCorrection "issue" $issueNum $issue.Title $issue.Url $issue.Milestone $expectedMs $match $PrNum $report + } + + return $report +} + +function Invoke-AnalyzeRelease([string]$ReleaseTag, [string]$PrevTag, [string]$Repo) { + $expectedMs = ConvertTo-Milestone $ReleaseTag + if (-not $expectedMs) { throw "Cannot determine milestone for tag $ReleaseTag" } + + # Derive MajorVersion from the tag + $Major = if ($ReleaseTag -match '^(\d+)\.') { [int]$Matches[1] } else { Get-CurrentMajorVersion $Repo } + + $allTags = Get-AllTags $Repo + if ($ReleaseTag -notin $allTags) { throw "Tag $ReleaseTag not found in repo" } + + if (-not $PrevTag) { + $PrevTag = Find-PreviousTag $ReleaseTag $allTags + # No previous tag means this is the first release (e.g. GA). + # We'll use Get-PrNumbersReachableFromTag instead of a range. + } + + # Detect which branch owns this version's tags + $Branch = Get-MainBranchForVersion $Major $Repo + + $prevDisplay = if ($PrevTag) { $PrevTag } else { "(root)" } + + Write-Host "`n$('═' * 70)" + Write-Host " Release: $ReleaseTag" + Write-Host " Previous: $prevDisplay" + Write-Host " Main branch: $Branch" + Write-Host " Expected milestone: $expectedMs" + Write-Host "$('═' * 70)`n" + + Write-Host "Fetching GitHub milestones..." + $allMilestones = Get-AllMilestones + $match = Find-MatchingMilestone $expectedMs $allMilestones + if (-not $match) { + throw "No GitHub milestone found matching `"$expectedMs`". Available: $($allMilestones.Keys -join ', ')" + } + Write-Host " Resolved to: `"$($match.Title)`" (#$($match.Number))`n" + + if ($PrevTag) { + Write-Host "Finding PRs between $PrevTag..$ReleaseTag..." + $prNumbers = Get-PrNumbersBetweenTags $PrevTag $ReleaseTag $Repo + } else { + Write-Host "Finding all PRs reachable from $ReleaseTag (first tag)..." + $prNumbers = Get-PrNumbersReachableFromTag $ReleaseTag $Repo + } + Write-Host " Found $($prNumbers.Count) PRs`n" + + $report = @{ + Tag = $ReleaseTag + PreviousTag = $PrevTag + ExpectedMilestone = $expectedMs + ResolvedMilestone = $match.Title + ResolvedMsNumber = $match.Number + TotalPrs = $prNumbers.Count + PrsChecked = 0 + PrsSkippedWrongBranch = 0 + IssuesChecked = 0 + AlreadyCorrect = 0 + Corrections = [System.Collections.ArrayList]::new() + Errors = [System.Collections.ArrayList]::new() + } + + for ($i = 0; $i -lt $prNumbers.Count; $i++) { + $prNum = $prNumbers[$i] + Write-Verbose " [$($i+1)/$($prNumbers.Count)] PR #$prNum..." + + $pr = Get-PrInfo $prNum + if (-not $pr) { + [void]$report.Errors.Add("Failed to fetch PR #$prNum") + continue + } + + # Safety: skip PRs targeting a different .NET version branch + if (-not (Test-PrBelongsToVersion $pr.BaseRef $Branch $Major)) { + Write-Verbose " ⏭️ PR #$prNum targets '$($pr.BaseRef)' — not for .NET $Major, skipping" + $report.PrsSkippedWrongBranch++ + continue + } + $report.PrsChecked++ + + Test-AndRecordCorrection "pr" $prNum $pr.Title $pr.Url $pr.Milestone $expectedMs $match 0 $report + + $linked = Get-LinkedIssues $pr.Body $pr.Title + foreach ($issueNum in $linked) { + $issue = Get-IssueInfo $issueNum + if (-not $issue) { continue } + $report.IssuesChecked++ + Test-AndRecordCorrection "issue" $issueNum $issue.Title $issue.Url $issue.Milestone $expectedMs $match $prNum $report + } + } + + return $report +} + +#endregion + +#region ── Output ───────────────────────────────────────────────────────── + +function Write-Report([hashtable]$Report) { + Write-Host "`n$('═' * 70)" + Write-Host " MILESTONE DRIFT REPORT: $($Report.Tag)" + Write-Host "$('═' * 70)" + if ($Report.ContainsKey('PreviousTag') -and $Report.PreviousTag) { Write-Host " Range: $($Report.PreviousTag)..$($Report.Tag)" } + Write-Host " Expected milestone: $($Report.ExpectedMilestone)" + Write-Host " Resolved milestone: $($Report.ResolvedMilestone)" + Write-Host " PRs in range: $($Report.TotalPrs)" + Write-Host " PRs checked: $($Report.PrsChecked)" + if ($Report.ContainsKey('PrsSkippedWrongBranch') -and $Report.PrsSkippedWrongBranch -gt 0) { + Write-Host " PRs skipped (wrong branch): $($Report.PrsSkippedWrongBranch)" + } + Write-Host " Issues checked: $($Report.IssuesChecked)" + Write-Host " Already correct: $($Report.AlreadyCorrect)" + Write-Host " Corrections needed: $($Report.Corrections.Count)" + if ($Report.Errors.Count -gt 0) { Write-Host " Errors: $($Report.Errors.Count)" } + Write-Host "" + + if ($Report.Corrections.Count -eq 0) { + if ($Report.Errors.Count -gt 0 -and $Report.PrsChecked -eq 0) { + Write-Host " ❌ No PRs were successfully checked — all $($Report.Errors.Count) failed.`n" + } else { + Write-Host " ✅ All milestones are correct!`n" + } + return + } + + foreach ($c in $Report.Corrections) { + $current = if ($c.Current) { $c.Current } else { "(none)" } + $action = if ($c.Current) { "CHANGE" } else { "SET" } + $via = if ($c.ContainsKey('RelatedPr') -and $c.RelatedPr) { " (via PR #$($c.RelatedPr))" } else { "" } + Write-Host " [$action] $($c.ItemType) #$($c.Number)$via`: $current → $($c.Resolved)" + } + Write-Host "" +} + +function Save-ReportJson([hashtable]$Report, [string]$Path) { + $data = @{ + tag = $Report.Tag + previous_tag = if ($Report.ContainsKey('PreviousTag')) { $Report.PreviousTag } else { $null } + expected_milestone = $Report.ExpectedMilestone + resolved_milestone = $Report.ResolvedMilestone + summary = @{ + total_prs_in_range = $Report.TotalPrs + prs_checked = $Report.PrsChecked + prs_skipped_wrong_branch = if ($Report.ContainsKey('PrsSkippedWrongBranch')) { $Report.PrsSkippedWrongBranch } else { 0 } + issues_checked = $Report.IssuesChecked + already_correct = $Report.AlreadyCorrect + corrections_needed = $Report.Corrections.Count + errors = $Report.Errors.Count + } + corrections = @($Report.Corrections) + errors = @($Report.Errors) + } + $data | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding utf8 + Write-Host " 📄 Report saved to: $Path" +} + +function Invoke-ApplyCorrections([hashtable]$Report, [bool]$DoApply) { + $failCount = 0 + foreach ($c in $Report.Corrections) { + $current = if ($c.Current) { $c.Current } else { "(none)" } + if ($DoApply) { + try { + Set-ItemMilestone $c.Number $c.ResolvedNo + Write-Host " ✅ Updated $($c.ItemType) #$($c.Number): $current → $($c.Resolved)" + } catch { + $failCount++ + Write-Host " ❌ Failed $($c.ItemType) #$($c.Number): $_" + } + } else { + Write-Host " [DRY-RUN] Would set $($c.ItemType) #$($c.Number) milestone: $current → $($c.Resolved)" + } + } + if ($failCount -gt 0) { + throw "$failCount milestone update(s) failed. Check output above for details." + } +} + +function New-GitHubIssue([hashtable]$Report, [bool]$WasApplied) { + $tag = $Report.Tag + $status = if ($WasApplied) { "Applied" } else { "Dry-Run" } + $title = "Milestone drift report: $tag ($status)" + + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine("## Milestone Drift Report: ``$tag``") + [void]$sb.AppendLine() + if ($Report.ContainsKey('PreviousTag') -and $Report.PreviousTag) { + [void]$sb.AppendLine("**Range:** ``$($Report.PreviousTag)..$tag``") + } + [void]$sb.AppendLine("**Expected milestone:** $($Report.ExpectedMilestone)") + [void]$sb.AppendLine("**Resolved milestone:** $($Report.ResolvedMilestone)") + [void]$sb.AppendLine("**Status:** $status") + [void]$sb.AppendLine() + + # Summary table + [void]$sb.AppendLine("### Summary") + [void]$sb.AppendLine() + [void]$sb.AppendLine("| Metric | Count |") + [void]$sb.AppendLine("|--------|-------|") + [void]$sb.AppendLine("| PRs in range | $($Report.TotalPrs) |") + [void]$sb.AppendLine("| PRs checked | $($Report.PrsChecked) |") + [void]$sb.AppendLine("| Issues checked | $($Report.IssuesChecked) |") + [void]$sb.AppendLine("| Already correct | $($Report.AlreadyCorrect) |") + [void]$sb.AppendLine("| Corrections needed | $($Report.Corrections.Count) |") + if ($Report.Errors.Count -gt 0) { + [void]$sb.AppendLine("| Errors | $($Report.Errors.Count) |") + } + [void]$sb.AppendLine() + + if ($Report.Corrections.Count -eq 0) { + [void]$sb.AppendLine("✅ All milestones are correct!") + } else { + # Corrections table + [void]$sb.AppendLine("### Corrections") + [void]$sb.AppendLine() + [void]$sb.AppendLine("| Action | Type | Item | Via PR | Current | Expected |") + [void]$sb.AppendLine("|--------|------|------|--------|---------|----------|") + foreach ($c in $Report.Corrections) { + $current = if ($c.Current) { $c.Current } else { "_(none)_" } + $action = if ($c.Current) { "CHANGE" } else { "SET" } + $via = if ($c.ContainsKey('RelatedPr') -and $c.RelatedPr) { "#$($c.RelatedPr)" } else { "—" } + [void]$sb.AppendLine("| $action | $($c.ItemType) | #$($c.Number) | $via | $current | $($c.Resolved) |") + } + } + + $body = $sb.ToString() + + # Write body to a temp file to avoid shell quoting issues + $tempFile = [System.IO.Path]::GetTempFileName() + try { + $body | Set-Content -Path $tempFile -Encoding utf8 -NoNewline + $result = gh issue create --repo dotnet/maui --title $title --body-file $tempFile --label "area/infrastructure 🏗️" 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Failed to create issue: $result" } + Write-Host " 📋 Issue created: $result" + } finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue + } +} + +#endregion + +#region ── Main ─────────────────────────────────────────────────────────── + +# Guard: skip main execution when dot-sourced for testing +if ($MyInvocation.InvocationName -eq '.' -or $MyInvocation.Line -match '^\.\s') { return } + +if ($Apply) { + Write-Host "⚠️ --Apply mode: Will modify GitHub milestones!" +} + +if ($PrNumber -gt 0) { + $report = Invoke-AnalyzeSinglePr $PrNumber $Tag $RepoPath + Write-Report $report + if ($Output) { Save-ReportJson $report $Output } + Invoke-ApplyCorrections $report $Apply.IsPresent + if ($CreateIssue -and $report.Corrections.Count -gt 0) { New-GitHubIssue $report $Apply.IsPresent } +} +elseif ($Tag) { + $report = Invoke-AnalyzeRelease $Tag $PreviousTag $RepoPath + Write-Report $report + $outPath = if ($Output) { $Output } else { "milestone-drift-$($Tag -replace '\.','_').json" } + Save-ReportJson $report $outPath + Invoke-ApplyCorrections $report $Apply.IsPresent + if ($CreateIssue -and $report.Corrections.Count -gt 0) { New-GitHubIssue $report $Apply.IsPresent } + if ($report.Errors.Count -gt 0 -and $report.PrsChecked -eq 0) { + throw "Release analysis failed: $($report.Errors.Count) errors, 0 PRs checked." + } +} +else { + Write-Host "Error: -PrNumber or -Tag is required." -ForegroundColor Red + Get-Help $MyInvocation.MyCommand.Path -Detailed + exit 1 +} + +#endregion diff --git a/.github/workflows/fix-milestone-drift.yml b/.github/workflows/fix-milestone-drift.yml new file mode 100644 index 000000000000..22c5f48c5310 --- /dev/null +++ b/.github/workflows/fix-milestone-drift.yml @@ -0,0 +1,84 @@ +name: Milestone Management + +on: + pull_request_target: + types: [closed] + branches: [main, net*.0, inflight/*, release/*] + + workflow_dispatch: + inputs: + pr_number: + description: 'Single PR number to check/fix (leave empty for tag-based mode)' + required: false + type: string + tag: + description: 'Release tag to audit (e.g., "10.0.50")' + required: false + type: string + apply: + description: 'Apply milestone changes (default: dry-run only)' + required: false + type: boolean + default: false + create_issue: + description: 'Create a GitHub issue with the drift report' + required: false + type: boolean + default: false + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + manage-milestones: + # For PR merges: only run when actually merged (not just closed) + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout with full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Run milestone management + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_APPLY: ${{ inputs.apply }} + INPUT_CREATE_ISSUE: ${{ inputs.create_issue }} + EVENT_NAME: ${{ github.event_name }} + run: | + ARGS=() + + if [ "$EVENT_NAME" = "pull_request_target" ]; then + # Auto-trigger: set milestone on the merged PR + ARGS+=('-PrNumber' "$INPUT_PR_NUMBER" '-Apply') + else + # Manual trigger: use provided inputs + if [ -n "$INPUT_PR_NUMBER" ]; then + ARGS+=('-PrNumber' "$INPUT_PR_NUMBER") + fi + + if [ -n "$INPUT_TAG" ]; then + ARGS+=('-Tag' "$INPUT_TAG") + fi + + if [ "$INPUT_APPLY" = "true" ]; then + ARGS+=('-Apply') + fi + + if [ "$INPUT_CREATE_ISSUE" = "true" ]; then + ARGS+=('-CreateIssue') + fi + fi + + ARGS+=('-RepoPath' '.' '-Verbose') + + printf 'Running: pwsh -File .github/scripts/Fix-MilestoneDrift.ps1' + printf ' %q' "${ARGS[@]}" + echo + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 "${ARGS[@]}"