diff --git a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 index 6fb00936c92ab8..caa7eab2317769 100644 --- a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 +++ b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 @@ -1,16 +1,29 @@ # Build-IssueComment.ps1 -# Reads a breaking change issue draft markdown file, URL-encodes the title, -# body, and labels, and produces a brief PR comment markdown file containing: -# - A short header with instructions -# - @mentions of the PR assignees so they get notified -# - A clickable link that pre-fills a new issue in dotnet/docs -# - An email reminder +# Reads a breaking change issue draft markdown file and produces a PR comment +# markdown file that links to a pre-filled "new issue" page in dotnet/docs. +# +# Hybrid link strategy: +# * The link always targets `issues/new` directly (NOT `?template=`), which opens +# a blank markdown editor rather than the structured issue form. Short fields +# (title, labels, assignees) are always pre-filled from the query string. +# * If the full URL -- including the URL-encoded body -- stays under the GitHub +# request-line limit (~8192 bytes; we use a conservative 8000-byte budget on the +# full URL string), the body is pre-filled too, so the issue is one click away. +# * Otherwise the body is dropped from the URL and included in the comment inside a +#
block for the user to copy-paste into the opened (empty) editor. +# +# Labels and assignees are supplied by the caller and should come from the +# dotnet/docs breaking-change issue template (.github/ISSUE_TEMPLATE/02-breaking-change.yml). +# The runtime PR's assignees are passed separately as -CcMentions +# and only used for /cc notifications. # # Usage: # pwsh .github/skills/breaking-change-doc/Build-IssueComment.ps1 ` # -IssueDraftPath issue-draft.md ` # -Title "[Breaking change]: Something changed" ` -# -Assignees "@user1 @user2" ` +# -Labels "breaking-change" ` +# -IssueAssignees "gewarren" ` +# -CcMentions "@user1 @user2" ` # -OutputPath pr-comment.md # # The issue draft file should contain only the issue body markdown (no title). @@ -22,13 +35,26 @@ param( [Parameter(Mandatory = $true)] [string]$Title, - [string]$Assignees = "", + # Comma-separated labels, from the docs issue template's `labels:` field. + [string]$Labels = "", + + # Comma-separated GitHub usernames for the issue `assignees=` prefill, + # from the docs issue template's `assignees:` field. + [string]$IssueAssignees = "", + + # Space-separated @mentions (the runtime PR's assignees) for the /cc line. + [string]$CcMentions = "", [string]$OutputPath = "pr-comment.md", - [string]$Labels = "breaking-change,Pri1,doc-idea", + [string]$DocsRepo = "dotnet/docs", + + # Conservative budget on the full URL string length in bytes. GitHub's front end + # rejects a request line over ~8192 bytes with HTTP 414; 8000 leaves headroom. + [int]$MaxUrlBytes = 8000, - [string]$DocsRepo = "dotnet/docs" + # GitHub rejects comment bodies over 65536 characters; 65000 leaves headroom. + [int]$MaxCommentLength = 65000 ) $ErrorActionPreference = "Stop" @@ -40,49 +66,108 @@ if (-not (Test-Path $IssueDraftPath)) { $issueBody = Get-Content -Path $IssueDraftPath -Raw -Encoding UTF8 -# URL-encode using .NET Uri class (same technique the old script used) -$encodedTitle = [Uri]::EscapeDataString($Title) +# Build the query string for the short (always pre-filled) fields. +# [Uri]::EscapeDataString produces ASCII-only output, so the resulting URL string's +# character count equals its UTF-8 byte count. +$queryParts = @("title=$([Uri]::EscapeDataString($Title))") + +if (-not [string]::IsNullOrWhiteSpace($Labels)) { + $queryParts += "labels=$([Uri]::EscapeDataString($Labels.Trim()))" +} + +if (-not [string]::IsNullOrWhiteSpace($IssueAssignees)) { + # GitHub expects a comma-separated list for the assignees prefill. + $assigneesValue = ($IssueAssignees.Trim() -replace '\s*,\s*', ',') + $queryParts += "assignees=$([Uri]::EscapeDataString($assigneesValue))" +} + +$baseUrl = "https://github.com/$DocsRepo/issues/new?" + ($queryParts -join '&') + +# Try to also pre-fill the body if the full URL fits within the budget. $encodedBody = [Uri]::EscapeDataString($issueBody) -$encodedLabels = [Uri]::EscapeDataString($Labels) -$encodedEmailSubject = [Uri]::EscapeDataString("[Breaking Change] $Title") +$fullUrl = "$baseUrl&body=$encodedBody" +$bodyInUrl = $fullUrl.Length -le $MaxUrlBytes -$issueUrl = "https://github.com/$DocsRepo/issues/new?title=$encodedTitle&body=$encodedBody&labels=$encodedLabels" -$notificationEmailUrl = "mailto:dotnetbcn@microsoft.com?subject=$encodedEmailSubject" +$issueUrl = if ($bodyInUrl) { $fullUrl } else { $baseUrl } -# Build a brief assignee mention line (if any) +# Build the /cc mention line (from the runtime PR's assignees). $mentionLine = "" -$trimmedAssignees = "" -if (-not [string]::IsNullOrWhiteSpace($Assignees)) { - $trimmedAssignees = $Assignees.Trim() - $mentionLine = "`n`n/cc $trimmedAssignees" +if (-not [string]::IsNullOrWhiteSpace($CcMentions)) { + $mentionLine = "`n`n/cc $($CcMentions.Trim())" +} + +# When the body isn't pre-filled in the URL it is embedded in the comment for +# copy-paste, wrapped in a fenced code block so the raw markdown copies verbatim. Use a +# fence one backtick longer than the longest backtick run in the body (minimum 3), so an +# embedded code fence of any length can't prematurely close the block. +$longestBacktickRun = 0 +foreach ($m in [regex]::Matches($issueBody, '`+')) { + if ($m.Length -gt $longestBacktickRun) { $longestBacktickRun = $m.Length } } +$fence = '`' * [Math]::Max(3, $longestBacktickRun + 1) + +# Assemble the comment. $bodyText is what goes inside the copy-paste fence; when +# $truncated is set, a note points the reader at the complete issue-draft.md artifact +# (used when the full body had to be shortened to fit GitHub's comment-length limit). +$assemble = { + param([string]$bodyText, [bool]$truncated) + + $copyPasteSection = "" + if (-not $bodyInUrl) { + $truncNote = if ($truncated) { + "`n`n> [!IMPORTANT]`n> The body below was truncated to fit GitHub's comment-length limit. Copy the complete body from the issue-draft.md workflow artifact instead." + } else { "" } + + $copyPasteSection = @" + + +The issue body is too long to pre-fill in the link above, so the link opens a +**blank issue** with only the title, labels, and assignees set. Copy the body +below into the description field before submitting.$truncNote -$comment = @" +
+Issue body (click to expand, then copy) + +$($fence)md +$bodyText +$fence + +
+"@ + } + + return @" ## Breaking Change Documentation A breaking change draft has been prepared for this PR. -:point_right: **[Click here to create the issue in $DocsRepo]($issueUrl)** +:point_right: **[Click here to create the issue in $DocsRepo]($issueUrl)**$copyPasteSection -After creating the issue, please email a link to it to -[.NET Breaking Change Notifications]($notificationEmailUrl).$mentionLine +After creating the issue, please email a link to it to the +.NET Breaking Change Notifications alias (dotnetbcn@microsoft.com).$mentionLine > [!NOTE] > This documentation was generated with AI assistance from Copilot. "@ +} -# GitHub comment body limit is 65536 characters. The comment is now brief, -# but the URL itself can be very long. Warn if the comment exceeds the -# configured comment-length threshold; the URL length is checked separately below. -$maxCommentLength = 65000 -if ($comment.Length -gt $maxCommentLength) { - Write-Warning "Comment body ($($comment.Length) chars) exceeds GitHub limit." +$comment = & $assemble $issueBody $false + +# If embedding the full body pushes the comment past GitHub's limit, truncate the +# embedded copy so the later post still succeeds. The complete body is always available +# in issue-draft.md, which the truncation note points to. +if (-not $bodyInUrl -and $comment.Length -gt $MaxCommentLength) { + $marker = "`n`n[... body truncated -- see the issue-draft.md artifact for the full text ...]" + $overhead = (& $assemble '' $true).Length + $available = $MaxCommentLength - $overhead - $marker.Length - 50 # 50-char safety margin + if ($available -lt 0) { $available = 0 } + $truncatedBody = $issueBody.Substring(0, [Math]::Min($issueBody.Length, $available)).TrimEnd() + $marker + $comment = & $assemble $truncatedBody $true + Write-Warning "Issue body too large to embed; truncated the copy-paste body to fit GitHub's comment-length limit. The full body is in the issue-draft.md artifact." } $comment | Out-File -FilePath $OutputPath -Encoding UTF8 -NoNewline Write-Host "Wrote PR comment to $OutputPath ($($comment.Length) characters)" -Write-Host "Issue URL length: $($issueUrl.Length) characters" -if ($issueUrl.Length -gt 8192) { - Write-Warning "URL exceeds 8192 characters. Some browsers may truncate it. Consider shortening the issue body." -} +Write-Host "Body pre-filled in URL: $bodyInUrl" +Write-Host "Issue URL length: $($issueUrl.Length) bytes (budget: $MaxUrlBytes)" diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index 76332081aa3d38..9116c56dbe5cbc 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -1,10 +1,24 @@ # Get-VersionInfo.ps1 # Determines the .NET version context for a merged PR using the GitHub CLI (gh). # +# The reliable signal is the existence of release branches named +# release/.-preview (and later -rc / GA release/.) +# Once release/.-preview exists, that preview has been branched off, so a +# change merged to main lands in the *next* milestone -- following the standard .NET +# cadence of $PreviewCount previews, then $RcCount RCs, then GA (so Preview 7 rolls +# over to RC 1, and RC 2 to GA). Exceptions: +# * If the change was backported into an already-branched milestone, it ships there. +# A merged backport is found via commit containment (compare API); an unmerged one +# is found via the backport PR and reported as tentative. +# * For changes that already shipped in a past major (whose preview/RC branches were +# pruned), containment resolves only to the GA line, so release *tags* (which are +# never pruned) are scanned to recover the exact first preview/RC. +# # Usage: # pwsh .github/skills/breaking-change-doc/Get-VersionInfo.ps1 -PrNumber 114929 # -# Output: JSON object with LastTagBeforeMerge, FirstTagWithChange, EstimatedVersion +# Output: JSON object with EstimatedVersion, Tentative, DetectionMethod, HighestBranch, +# ContainedInBranch, FirstShippedTag, Backports, MergeCommit, MergedAt, BaseRef. param( [Parameter(Mandatory = $true)] @@ -12,185 +26,569 @@ param( [string]$SourceRepo = "dotnet/runtime", - [string]$BaseRef = "" + [string]$BaseRef = "", + + # .NET release cadence: this many previews, then this many RCs, then GA. + # Used to predict the next milestone across Preview->RC and RC->GA transitions. + [int]$PreviewCount = 7, + + [int]$RcCount = 2 ) $ErrorActionPreference = "Stop" -function ConvertFrom-DotNetTag { - param([string]$tagName) - - if (-not $tagName -or $tagName -eq "Unknown") { +# Parse a release branch name into a structured milestone. +# Recognizes: +# release/11.0 -> GA +# release/11.0-preview6 -> Preview 6 +# release/11.0-rc1 -> RC 1 +# release/11.0-staging -> GA (staging variant, treated as GA milestone) +# Returns $null for non-release or unrecognized branch names. +function ConvertFrom-ReleaseBranch { + param([string]$branchName) + + if ($branchName -notmatch '^release/(\d+)\.(\d+)(?:-([a-zA-Z]+)(\d+)?)?$') { return $null } - if ($tagName -match '^v(\d+)\.(\d+)\.(\d+)(?:-(.+))?$') { - $major = [int]$matches[1] - $minor = [int]$matches[2] - $build = [int]$matches[3] - $prerelease = if ($matches[4]) { $matches[4] } else { $null } + $major = [int]$matches[1] + $minor = [int]$matches[2] + $label = if ($matches[3]) { $matches[3].ToLower() } else { $null } + $number = if ($matches[4]) { [int]$matches[4] } else { $null } + + # Milestone ordering rank within a major.minor: previews < rc < GA. + switch ($label) { + "preview" { $kind = "preview"; $rank = 1000 + ($number ?? 0) } + "rc" { $kind = "rc"; $rank = 2000 + ($number ?? 0) } + "staging" { $kind = "ga"; $rank = 3000 } + $null { $kind = "ga"; $rank = 3000 } + default { return $null } # unrecognized label (e.g. release/11.0-rtm) + } - $prereleaseType = $null - $prereleaseNumber = $null + return [pscustomobject]@{ + Major = $major + Minor = $minor + Kind = $kind + Number = $number + Rank = $rank + Branch = $branchName + } +} - if ($prerelease -and $prerelease -match '^([a-zA-Z]+)\.(\d+)') { - $rawType = $matches[1] - $prereleaseNumber = [int]$matches[2] +# Format a milestone (kind + number) for a given major.minor as a docs-template +# version string, e.g. ".NET 11 Preview 7", ".NET 11 RC 1", ".NET 11". +function Format-Milestone { + param([int]$major, [int]$minor, [string]$kind, [Nullable[int]]$number) - if ($rawType -ieq "rc") { - $prereleaseType = "RC" + $baseVersion = ".NET $major" + if ($minor -ne 0) { + $baseVersion = ".NET $major.$minor" + } + + switch ($kind) { + "preview" { return "$baseVersion Preview $number" } + "rc" { return "$baseVersion RC $number" } + default { return $baseVersion } + } +} + +# Compute the version a change merged to main would ship in, given the release branches +# already cut for its major.minor. Follows the .NET cadence: $previewCount previews, +# then $rcCount RCs, then GA -- so the milestone *after* the highest branched one, with +# Preview N -> RC 1 and RC N -> GA rollovers. Returns the version string, the highest +# branch name, and a human-readable detection note. +function Get-NextMilestoneVersion { + param( + [pscustomobject[]]$milestones, + [pscustomobject]$devVersion, + [string]$baseRef, + [int]$previewCount, + [int]$rcCount + ) + + if (-not $milestones -or $milestones.Count -eq 0) { + return [pscustomobject]@{ + Version = Format-Milestone $devVersion.Major $devVersion.Minor "preview" 1 + HighestBranch = "(none)" + Detection = "Merged to '$baseRef'; no milestone branched yet for this version" + } + } + + $highest = $milestones | Sort-Object Rank -Descending | Select-Object -First 1 + + switch ($highest.Kind) { + "preview" { + $version = if ($highest.Number -lt $previewCount) { + Format-Milestone $devVersion.Major $devVersion.Minor "preview" ($highest.Number + 1) } else { - $prereleaseType = $rawType.Substring(0, 1).ToUpper() + $rawType.Substring(1).ToLower() + # Final preview branched -> next milestone is RC 1. + Format-Milestone $devVersion.Major $devVersion.Minor "rc" 1 } } - - return @{ - Major = $major - Minor = $minor - Build = $build - Prerelease = $prerelease - PrereleaseType = $prereleaseType - PrereleaseNumber = $prereleaseNumber - IsRelease = $null -eq $prerelease + "rc" { + $version = if ($highest.Number -lt $rcCount) { + Format-Milestone $devVersion.Major $devVersion.Minor "rc" ($highest.Number + 1) + } else { + # Final RC branched -> next milestone is GA. + Format-Milestone $devVersion.Major $devVersion.Minor "ga" $null + } + } + default { + # Highest branched milestone is GA: + # a change on main that isn't in it ships in the *next* major's first preview. + $version = Format-Milestone ($devVersion.Major + 1) 0 "preview" 1 } } - return $null + return [pscustomobject]@{ + Version = $version + HighestBranch = $highest.Branch + Detection = "Merged to '$baseRef'; ships after highest branched milestone '$($highest.Branch)'" + } } -function Format-DotNetVersion { - param($parsedTag) +# Parse a .NET release tag (e.g. v11.0.0-preview.7.25380.108, v10.0.0-rc.1.xxx, +# v10.0.0, v10.0.3) into a structured milestone. Unlike release branches, tags are +# never pruned, so they preserve preview/RC granularity for already-shipped versions. +function ConvertFrom-DotNetTag { + param([string]$tagName) - if (-not $parsedTag) { - return "Next release" + if ($tagName -notmatch '^v(\d+)\.(\d+)\.(\d+)(?:-(.+))?$') { + return $null } - $baseVersion = ".NET $($parsedTag.Major).$($parsedTag.Minor)" + $major = [int]$matches[1] + $minor = [int]$matches[2] + $build = [int]$matches[3] + $prerelease = if ($matches[4]) { $matches[4] } else { $null } + + $kind = "ga" + $number = $null + if ($prerelease) { + if ($prerelease -match '^([a-zA-Z]+)\.(\d+)') { + $raw = $matches[1] + $number = [int]$matches[2] + $kind = if ($raw -ieq "rc") { "rc" } elseif ($raw -ieq "preview") { "preview" } else { $raw.ToLower() } + } else { + $kind = "prerelease" + } + } - if ($parsedTag.IsRelease) { - return $baseVersion + return [pscustomobject]@{ + Major = $major + Minor = $minor + Build = $build + Kind = $kind + Number = $number + IsRelease = ($null -eq $prerelease) + Tag = $tagName } +} - if ($parsedTag.PrereleaseType -and $parsedTag.PrereleaseNumber) { - return "$baseVersion $($parsedTag.PrereleaseType) $($parsedTag.PrereleaseNumber)" +# Ordering key so preview < rc < GA within a patch, and lower patch first. +function Get-TagSortKey { + param($t) + + $sub = switch ($t.Kind) { + "preview" { 1000 + ($t.Number ?? 0) } + "rc" { 2000 + ($t.Number ?? 0) } + "ga" { 3000 } + default { 500 } } + return ($t.Build * 100000) + $sub +} - return "$baseVersion ($($parsedTag.Prerelease))" +# Find the earliest published tag for a given major.minor that already contains the +# merge commit. Used to recover exact preview/RC granularity when branch containment +# only resolved to a (pruned) GA line. Only the tags for that major.minor are fetched +# (server-side prefix filter). Returns the parsed tag, or $null. +# +# Containment is monotonic along release order: each tag on a major.minor line is a +# superset of the previous one (preview N+1 includes preview N, GA includes all +# previews/RCs, each servicing release includes GA). So the candidates -- sorted into +# chronological release order by Get-TagSortKey -- form a false*..true* sequence, and +# the earliest containing tag can be found with a binary search (~log2(n) compare-API +# calls) instead of probing every candidate linearly. +function Get-EarliestContainingTag { + param([string]$repo, [string]$commit, [int]$major, [int]$minor) + + if (-not $commit) { return $null } + + # Fetch only the tags for this major.minor via a server-side prefix filter + # (git/matching-refs) instead of paginating every tag in the repo. The trailing + # dot scopes the match to v..* (e.g. v9.0.0, v9.0.0-preview.4, + # v9.0.10). This endpoint returns HTTP 200 with an empty array (not 404) when + # nothing matches, so the exit-code check below stays valid. + $refs = @(gh api "repos/$repo/git/matching-refs/tags/v$major.$minor." --paginate --jq '.[].ref' 2>$null) + if ($LASTEXITCODE -ne 0) { return $null } + + $candidates = @( + $refs | + ForEach-Object { ConvertFrom-DotNetTag ($_ -replace '^refs/tags/', '') } | + Where-Object { $_ -and $_.Major -eq $major -and $_.Minor -eq $minor } | + Sort-Object { Get-TagSortKey $_ } + ) + if ($candidates.Count -eq 0) { return $null } + + $lo = 0 + $hi = $candidates.Count - 1 + $earliest = $null + while ($lo -le $hi) { + $mid = [int](($lo + $hi) / 2) + if (Test-CommitInBranch -repo $repo -commit $commit -branch $candidates[$mid].Tag) { + $earliest = $candidates[$mid] + $hi = $mid - 1 # a containing tag exists; look for an earlier one + } else { + $lo = $mid + 1 + } + } + return $earliest } -function Get-EstimatedNextVersion { - param($parsedTag, [string]$baseRef) +# Has major.minor reached GA? True when the exact GA tag vN.M.0 exists (ignoring +# prerelease tags like vN.M.0-preview/-rc). Used to tell a *pre-GA* release/N.M branch +# -- which is cut at the RC1 fork, months before GA, and parsed as a GA milestone by +# name -- apart from a shipped GA/servicing branch. +function Test-GATagExists { + param([string]$repo, [int]$major, [int]$minor) + + $refs = @(gh api "repos/$repo/git/matching-refs/tags/v$major.$minor.0" --jq '.[].ref' 2>$null) + if ($LASTEXITCODE -ne 0) { return $false } + foreach ($r in $refs) { + if (($r -replace '^refs/tags/', '') -eq "v$major.$minor.0") { return $true } + } + return $false +} - if (-not $parsedTag) { - return "Next release" +# Refine a milestone parsed from a bare `release/N.M` branch to the stage it is heading +# toward. A bare release/N.M branch is cut around the RC1 fork -- months before GA -- +# yet is parsed as a GA milestone by name. A separate `release/N.M-rc` branch exists +# for each RC that has been taken; the bare branch keeps stabilizing toward the *next* stage. +# So the highest rc branch number present indicates how far along the bare branch is: +# if the product has not GA'd (no vN.M.0 tag), map the highest existing rc branch number K to the bare +# branch's stage: +# no rc branch yet -> RC 1 (just forked); rc exists -> RC (K+1); the final RC +# (rcCount) branch exists -> heading to GA. Returns the milestone unchanged when it +# already GA'd, isn't a GA-kind milestone, or the rc lookup fails. +function Resolve-ReleaseBranchStage { + param([string]$repo, [pscustomobject]$milestone, [int]$rcCount = 2) + + if (-not $milestone -or $milestone.Kind -ne 'ga') { return $milestone } + if (Test-GATagExists -repo $repo -major $milestone.Major -minor $milestone.Minor) { return $milestone } + + $rcRefs = @(gh api "repos/$repo/git/matching-refs/heads/release/$($milestone.Major).$($milestone.Minor)-rc" --jq '.[].ref' 2>$null) + if ($LASTEXITCODE -ne 0) { return $milestone } + + $highestRc = @( + $rcRefs | + ForEach-Object { ConvertFrom-ReleaseBranch ($_ -replace '^refs/heads/', '') } | + Where-Object { $_ -and $_.Kind -eq 'rc' -and $_.Major -eq $milestone.Major -and $_.Minor -eq $milestone.Minor } | + Sort-Object Rank -Descending | + Select-Object -First 1 + ) + + $k = if ($highestRc) { $highestRc.Number } else { 0 } + if ($k -ge $rcCount) { + # The final RC branch already exists -> the bare branch heads to GA (unchanged). + return $milestone } - $isMainBranch = $baseRef -eq "main" + $next = $k + 1 + return [pscustomobject]@{ + Major = $milestone.Major + Minor = $milestone.Minor + Kind = 'rc' + Number = $next + Rank = 2000 + $next + Branch = $milestone.Branch + } +} - if ($parsedTag.IsRelease) { - if ($isMainBranch) { - $nextMajor = $parsedTag.Major + 1 - return ".NET $nextMajor.0 Preview 1" - } else { - return ".NET $($parsedTag.Major).$($parsedTag.Minor)" +# Read MajorVersion/MinorVersion from eng/Versions.props at a specific ref. +# This is the authoritative source for the in-development major.minor on main. +function Get-DevMajorMinor { + param([string]$repo, [string]$ref) + + try { + $content = gh api "repos/$repo/contents/eng/Versions.props?ref=$ref" -H "Accept: application/vnd.github.raw" 2>$null + if ($LASTEXITCODE -ne 0 -or -not $content) { + return $null + } + $text = ($content | Out-String) + if ($text -match '(\d+)' ) { + $maj = [int]$matches[1] + $min = 0 + if ($text -match '(\d+)') { + $min = [int]$matches[1] + } + return [pscustomobject]@{ Major = $maj; Minor = $min } } + } catch { } + + return $null +} + +# Is the merge commit already contained in the given branch? +# True when the milestone branched after the PR merged (normal case), or when a +# backport was already merged into the branch. +function Test-CommitInBranch { + param([string]$repo, [string]$commit, [string]$branch) + + if (-not $commit) { return $false } + $behind = gh api "repos/$repo/compare/$commit...$branch" --jq '.behind_by' 2>$null + return ($LASTEXITCODE -eq 0 -and $behind -match '^\d+$' -and [int]$behind -eq 0) +} + +# Find *potential* backport PRs for a source PR via GitHub's cross-reference graph. +# A backport PR is almost always cross-referenced from the primary PR (through its +# body, a comment, or the backport tooling), regardless of its head-branch name -- +# which makes this far more robust than matching a branch naming convention (real +# backports use ad-hoc names, and branches are deleted after merge). The primary PR's +# timeline yields candidate PR numbers; a single GraphQL batch resolves each one's +# base branch and state; and we keep only those targeting a release branch. +# +# NOTE: these are *candidates only*. A cross-reference does not prove a PR is a +# backport of this change, so the caller (skill) should confirm via the PR's +# title/description/diff before trusting it. +function Get-BackportPRs { + param([string]$repo, [string]$prNumber) + + # Cross-references can point at PRs in *other* repos (e.g. VMR codeflow PRs from + # dotnet/dotnet). Filter to same-repo PRs up front (via source.issue.repository) + # so the GraphQL batch only asks for numbers that exist in this repo. + # Capture the call separately and check the exit code: on failure, warn and return + # no candidates rather than failing silently. Backport detection only enriches the + # result (it can make a version tentative), so a timeline hiccup shouldn't abort the + # whole version detection -- but the warning ensures the gap is visible in the log. + $timeline = gh api "repos/$repo/issues/$prNumber/timeline" --paginate ` + --jq ".[] | select(.event==`"cross-referenced`") | select(.source.issue.pull_request != null) | select(.source.issue.repository.full_name == `"$repo`") | .source.issue.number" 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to read cross-references from the issue timeline for $repo#$prNumber; proceeding without backport candidates." + return @() + } + $candidateNumbers = @($timeline | Sort-Object -Unique) + if ($candidateNumbers.Count -eq 0) { return @() } + + $parts = $repo -split '/' + $owner = $parts[0] + $name = $parts[1] + + # Build a single batched GraphQL query with one alias per candidate. + $aliases = for ($i = 0; $i -lt $candidateNumbers.Count; $i++) { + "p${i}: pullRequest(number: $($candidateNumbers[$i])) { number state merged baseRefName title }" } + $query = 'query($owner:String!,$name:String!){ repository(owner:$owner,name:$name){ ' + ($aliases -join ' ') + ' } }' - if ($isMainBranch -and $parsedTag.PrereleaseType -eq "RC") { - $nextMajor = $parsedTag.Major + 1 - return ".NET $nextMajor.0 Preview 1" - } else { - $nextPreview = $parsedTag.PrereleaseNumber + 1 - return ".NET $($parsedTag.Major).$($parsedTag.Minor) $($parsedTag.PrereleaseType) $nextPreview" + $resp = gh api graphql -f owner=$owner -f name=$name -f query=$query 2>$null + if ($LASTEXITCODE -ne 0 -or -not $resp) { + Write-Warning "Failed to resolve backport PR metadata via GraphQL for $repo#$prNumber; proceeding without backport candidates." + return @() } + $repository = ($resp | ConvertFrom-Json).data.repository + if (-not $repository) { return @() } + + $results = @() + foreach ($prop in $repository.PSObject.Properties) { + $pr = $prop.Value + if (-not $pr) { continue } + # Only PRs targeting a release branch are plausible backports. + $milestone = ConvertFrom-ReleaseBranch $pr.baseRefName + if (-not $milestone) { continue } + $results += [pscustomobject]@{ + Number = $pr.number + State = $pr.state # OPEN | CLOSED | MERGED + Merged = [bool]$pr.merged + TargetBranch = $pr.baseRefName + Title = $pr.title + Milestone = $milestone + } + } + + return $results } try { - # Step 1: Get PR merge info via GitHub CLI + # Step 1: PR merge info $prJson = gh pr view $PrNumber --repo $SourceRepo --json mergeCommit,mergedAt,baseRefName 2>$null if ($LASTEXITCODE -ne 0) { throw "Failed to fetch PR #$PrNumber from $SourceRepo" } $prData = $prJson | ConvertFrom-Json - $targetCommit = $prData.mergeCommit.oid + $mergeCommit = $prData.mergeCommit.oid $mergedAt = $prData.mergedAt - if (-not $BaseRef) { $BaseRef = $prData.baseRefName } - # Step 2: Get recent releases (tags with published dates) in a single API call - $releasesJson = gh release list --repo $SourceRepo --limit 100 --json tagName,publishedAt 2>$null - $releases = @() - if ($LASTEXITCODE -eq 0 -and $releasesJson) { - $releases = @($releasesJson | ConvertFrom-Json) + # Step 2: If the PR merged directly into a release branch, the milestone is that + # branch's milestone -- no further estimation needed. Refine a bare pre-GA + # release/N.M to its current RC stage (see Resolve-ReleaseBranchStage). + $baseMilestone = if ($BaseRef -match '^release/') { ConvertFrom-ReleaseBranch $BaseRef } else { $null } + + if ($baseMilestone) { + $baseMilestone = Resolve-ReleaseBranchStage -repo $SourceRepo -milestone $baseMilestone -rcCount $RcCount + $estimated = Format-Milestone $baseMilestone.Major $baseMilestone.Minor $baseMilestone.Kind $baseMilestone.Number + @{ + EstimatedVersion = $estimated + Tentative = $false + DetectionMethod = "PR merged directly into release branch '$BaseRef'" + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json + return + } + + # Step 3: Determine the in-development major.minor for main. + $devVersion = Get-DevMajorMinor -repo $SourceRepo -ref $mergeCommit + if (-not $devVersion) { + $devVersion = Get-DevMajorMinor -repo $SourceRepo -ref $BaseRef } - # Filter to .NET version tags (v{major}.{minor}.{patch}[-prerelease]) with a valid publishedAt - $versionReleases = @($releases | Where-Object { $_.tagName -match '^v\d+\.\d+\.\d+' -and $_.publishedAt }) + # Step 4: Enumerate release branches for that major.minor. + $branchNames = @(gh api "repos/$SourceRepo/branches" --paginate --jq '.[].name' 2>$null) + if ($LASTEXITCODE -ne 0) { + throw "Failed to list branches for $SourceRepo" + } - $lastTagBefore = "Unknown" - $firstTagWith = "Not yet released" + $allMilestones = @($branchNames | ForEach-Object { ConvertFrom-ReleaseBranch $_ } | Where-Object { $_ -ne $null }) - if ($mergedAt -and $versionReleases.Count -gt 0) { - $mergedAtDate = [DateTimeOffset]::Parse($mergedAt) + # If we could not read the dev version, fall back to the highest major.minor + # that has any release branch. + if (-not $devVersion -and $allMilestones.Count -gt 0) { + $top = $allMilestones | Sort-Object Major, Minor -Descending | Select-Object -First 1 + $devVersion = [pscustomobject]@{ Major = $top.Major; Minor = $top.Minor } + } - # Find the most recent release published before the merge - $beforeMerge = @($versionReleases | - Where-Object { [DateTimeOffset]::Parse($_.publishedAt) -lt $mergedAtDate } | - Sort-Object { [DateTimeOffset]::Parse($_.publishedAt) } -Descending) + if (-not $devVersion) { + throw "Could not determine the in-development major.minor version." + } - if ($beforeMerge.Count -gt 0) { - $lastTagBefore = $beforeMerge[0].tagName + $milestones = @($allMilestones | Where-Object { $_.Major -eq $devVersion.Major -and $_.Minor -eq $devVersion.Minor }) + + # Collect *potential* backport PRs (cross-referenced from this PR and targeting a + # release branch). Surface all of them -- including cross-major servicing backports + # -- as informational output; they are candidates the skill should confirm. + $backports = @(Get-BackportPRs -repo $SourceRepo -prNumber $PrNumber) + $backportInfo = @($backports | ForEach-Object { + [pscustomobject]@{ Number = $_.Number; State = $_.State; Merged = $_.Merged; Target = $_.TargetBranch; Title = $_.Title } + }) + + # Step 5a: If *this PR's merge commit itself* is contained in an existing milestone + # branch, the change definitively ships in that milestone (lowest such milestone). + # This is the normal "milestone branched after merge" case. Note it does NOT catch + # cherry-pick backports, whose commit differs from this PR's -- those are handled + # (tentatively) in Step 5b. + $containing = $null + foreach ($m in ($milestones | Sort-Object Rank)) { + if (Test-CommitInBranch -repo $SourceRepo -commit $mergeCommit -branch $m.Branch) { + $containing = $m + break } + } - # Find candidate releases published at or after the merge, oldest first - $afterMerge = @($versionReleases | - Where-Object { [DateTimeOffset]::Parse($_.publishedAt) -ge $mergedAtDate } | - Sort-Object { [DateTimeOffset]::Parse($_.publishedAt) }) - - # Verify containment via the compare API: behind_by == 0 means the tag - # includes every commit reachable from the merge commit. - if ($targetCommit -and $afterMerge.Count -gt 0) { - foreach ($release in $afterMerge) { - $tag = $release.tagName - $behindBy = gh api "repos/$SourceRepo/compare/${targetCommit}...${tag}" --jq '.behind_by' 2>$null - if ($LASTEXITCODE -eq 0 -and $behindBy -match '^\d+$' -and [int]$behindBy -eq 0) { - $firstTagWith = $tag - break - } + if ($containing) { + $detection = "Merge commit is already contained in branched milestone '$($containing.Branch)'" + $firstShippedTag = $null + $estimated = Format-Milestone $containing.Major $containing.Minor $containing.Kind $containing.Number + + # Refinement: preview/RC branches are pruned after a major ships, so containment + # on a GA branch loses granularity. Recover the exact first preview/RC from the + # persistent release tags. + if ($containing.Kind -eq "ga") { + $tag = Get-EarliestContainingTag -repo $SourceRepo -commit $mergeCommit -major $containing.Major -minor $containing.Minor + if ($tag) { + $estimated = Format-Milestone $tag.Major $tag.Minor $tag.Kind $tag.Number + $firstShippedTag = $tag.Tag + $detection = "First shipped in tag '$($tag.Tag)' (refined from GA branch '$($containing.Branch)')" } } + + @{ + EstimatedVersion = $estimated + Tentative = $false + DetectionMethod = $detection + HighestBranch = ($milestones | Sort-Object Rank -Descending | Select-Object -First 1).Branch + ContainedInBranch = $containing.Branch + FirstShippedTag = $firstShippedTag + Backports = $backportInfo + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json -Depth 5 + return } - # Step 3: Estimate version - $estimatedVersion = "Next release" + # Step 5b: Not contained by commit. A *linked* PR targeting an earlier branched + # milestone of the same major.minor suggests the change may also ship there (e.g. + # via a cherry-pick backport, whose commit differs from this PR's so containment + # can't see it). But a "backport" here is only "a linked PR to a release branch" and + # is NOT verified, so it is never definitive: report that milestone but mark the + # version **tentative** for the skill to confirm. Consider OPEN and MERGED linked + # PRs (a merged one may already be shipped there); ignore CLOSED (abandoned) ones. + $tentativeBackport = $backports | + Where-Object { ($_.State -eq 'OPEN' -or $_.State -eq 'MERGED') -and $_.Milestone -and $_.Milestone.Major -eq $devVersion.Major -and $_.Milestone.Minor -eq $devVersion.Minor } | + Sort-Object { $_.Milestone.Rank } | + Select-Object -First 1 + + if ($tentativeBackport) { + # RC-precision refinement: the target may be a bare pre-GA release/N.M branch + # (GA milestone by name but really heading to an RC stage). Resolve it to the + # stage it is heading toward. This only refines the reported backport version; + # the main-PR next-milestone prediction below still treats the bare branch as GA + # so it can roll over to the next major. + $origMilestone = $tentativeBackport.Milestone + $bm = Resolve-ReleaseBranchStage -repo $SourceRepo -milestone $origMilestone -rcCount $RcCount + $refinedNote = if ($bm.Kind -ne $origMilestone.Kind -or $bm.Number -ne $origMilestone.Number) { + " '$($tentativeBackport.TargetBranch)' is a pre-GA branch heading to the $(Format-Milestone $bm.Major $bm.Minor $bm.Kind $bm.Number) stage." + } else { "" } + + $estimated = Format-Milestone $bm.Major $bm.Minor $bm.Kind $bm.Number + $stateNote = if ($tentativeBackport.State -eq 'MERGED') { + "merged into '$($tentativeBackport.TargetBranch)' (may already ship there)" + } else { + "open against '$($tentativeBackport.TargetBranch)' (would ship there if it merges)" + } - if ($firstTagWith -ne "Not yet released") { - $parsedFirstTag = ConvertFrom-DotNetTag $firstTagWith - $estimatedVersion = Format-DotNetVersion $parsedFirstTag - } else { - $parsedLastTag = ConvertFrom-DotNetTag $lastTagBefore - $estimatedVersion = Get-EstimatedNextVersion $parsedLastTag $BaseRef + # The version if the linked PR turns out NOT to be a genuine backport: the + # normal "ships after highest branched milestone" result. Provided so the skill + # doesn't have to re-derive the cadence rollover itself. + $fallback = Get-NextMilestoneVersion -milestones $milestones -devVersion $devVersion -baseRef $BaseRef -previewCount $PreviewCount -rcCount $RcCount + + @{ + EstimatedVersion = $estimated + Tentative = $true + DetectionMethod = "Potential backport PR #$($tentativeBackport.Number) $stateNote;$refinedNote version is tentative -- confirm the PR is a genuine backport of this change (title/description/diff). If it is NOT, use FallbackVersion instead." + FallbackVersion = $fallback.Version + HighestBranch = $fallback.HighestBranch + Backports = $backportInfo + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json -Depth 5 + return } - # Output as JSON + # Step 6: Not contained and no relevant linked PR -> ships in the milestone *after* + # the highest branched one (cadence-aware; see Get-NextMilestoneVersion). + $next = Get-NextMilestoneVersion -milestones $milestones -devVersion $devVersion -baseRef $BaseRef -previewCount $PreviewCount -rcCount $RcCount + @{ - LastTagBeforeMerge = $lastTagBefore - FirstTagWithChange = $firstTagWith - EstimatedVersion = $estimatedVersion - MergeCommit = $targetCommit - MergedAt = $mergedAt - BaseRef = $BaseRef - } | ConvertTo-Json + EstimatedVersion = $next.Version + Tentative = $false + DetectionMethod = $next.Detection + HighestBranch = $next.HighestBranch + Backports = $backportInfo + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json -Depth 5 } catch { - # Return error info as JSON so the agent can handle it @{ - LastTagBeforeMerge = "Unknown" - FirstTagWithChange = "Not yet released" - EstimatedVersion = "Next release" - Error = $_.Exception.Message + EstimatedVersion = "Next release" + DetectionMethod = "error" + Error = $_.Exception.Message } | ConvertTo-Json } diff --git a/.github/skills/breaking-change-doc/SKILL.md b/.github/skills/breaking-change-doc/SKILL.md index ced83b253d7e50..bb4889d5d61e4c 100644 --- a/.github/skills/breaking-change-doc/SKILL.md +++ b/.github/skills/breaking-change-doc/SKILL.md @@ -34,8 +34,7 @@ migration guidance. This skill automates that process: at `.github/workflows/breaking-change-doc.md` triggers when a PR labeled `needs-breaking-change-doc-created` is merged (or the label is added to an already-merged PR). It can also be run manually via `workflow_dispatch` with - an optional `suppress_output` flag for dry-run inspection. The compiled - workflow (`.lock.yml`) must be regenerated with `gh aw compile` after changes. + an optional `suppress_output` flag for dry-run inspection. ### Files @@ -44,8 +43,8 @@ migration guidance. This skill automates that process: | `.github/workflows/breaking-change-doc.md` | gh-aw workflow — triggers on PR merge/label | | `.github/workflows/breaking-change-doc.lock.yml` | Compiled workflow (generated by `gh aw compile`) | | `.github/skills/breaking-change-doc/SKILL.md` | This skill | -| `.github/skills/breaking-change-doc/Get-VersionInfo.ps1` | `gh` CLI-based .NET version detection | -| `.github/skills/breaking-change-doc/Build-IssueComment.ps1` | Builds PR comment with URL-encoded issue creation link | +| `.github/skills/breaking-change-doc/Get-VersionInfo.ps1` | Release-branch/tag-based .NET version detection (via `gh` CLI) | +| `.github/skills/breaking-change-doc/Build-IssueComment.ps1` | Builds PR comment with a hybrid pre-filled issue creation link | --- @@ -56,7 +55,7 @@ The user provides one of: - A PR URL (e.g. `https://github.com/dotnet/runtime/pull/114929`) - A request like "document the breaking change in PR 114929" -Extract the PR number. The source repository is always `dotnet/runtime`. +The source repository is always `dotnet/runtime`. --- @@ -105,19 +104,62 @@ Run the helper script to determine the .NET version context: pwsh .github/skills/breaking-change-doc/Get-VersionInfo.ps1 -PrNumber ``` -**You MUST display the complete script output.** The script outputs JSON that includes: -- `LastTagBeforeMerge` — the closest release tag before the merge commit -- `FirstTagWithChange` — the first tag that contains this commit (or "Not yet released") -- `EstimatedVersion` — human-readable version string like ".NET 11 Preview 3" +**You MUST display the complete script output.** The script determines the version +from the existence of `release/.-preview` (and later `-rc` / GA) +branches: once a milestone is branched it is locked, so a change merged to `main` +ships in the *next* milestone. Two things can move it into an earlier, already-branched +milestone: the merge commit is already contained in that branch (compare API), or a +**linked PR** (open or merged) targets that branch — in the latter case the version is +reported as **tentative**, because such a linked PR is only "a PR that references this +one and targets a release branch" and is not verified to be a genuine backport (and a +cherry-pick backport's commit differs from this PR's, so containment can't confirm it). Backports are discovered via the primary PR's cross-reference graph +(GitHub timeline), so they are found regardless of the backport's head-branch name. +For predicting the *next, unbranched* milestone the script assumes the standard .NET +cadence of **7 previews, then 2 RCs, then GA** (tunable via `-PreviewCount` / +`-RcCount`), so it correctly rolls over Preview 7 → RC 1 and RC 2 → GA. For +changes that already shipped in a *past* major (whose preview/RC branches have been +pruned), containment only resolves to the GA line, so the script then scans the +persistent release **tags** to recover the exact first preview/RC. The JSON output +includes: +- `EstimatedVersion` — human-readable version string like ".NET 11 Preview 7" +- `Tentative` — `true` when the version depends on an unverified linked release-branch + PR (open or merged) +- `DetectionMethod` — how the version was determined (direct release-branch merge, + own-commit containment in a branched milestone, tag-refined first-shipped version, + potential linked backport, or "ships after highest branched milestone") +- `FallbackVersion` — present only when `Tentative` is `true`: the version to use if + the linked PR turns out **not** to be a genuine backport (the normal "ships after + highest branched milestone" result), so you don't have to re-derive it +- `HighestBranch` — the highest release branch found for the target major.minor +- `ContainedInBranch` — present only when the merge commit is already in a branch +- `FirstShippedTag` — set when the version was refined from release tags (the exact + tag where the change first shipped, e.g. `v9.0.0-preview.4.24266.19`) +- `Backports` — array of **potential** backport PRs cross-referenced from this PR and + targeting a release branch (`Number`, `State`, `Merged`, `Target`, `Title`), + including cross-major servicing backports (informational; they do not change the + primary version) - `MergeCommit` — the merge commit SHA - `MergedAt` — when the PR was merged - `BaseRef` — the PR base branch used to determine version context +**The `Backports` entries are candidates, not confirmed backports.** A cross-reference +only means the PR mentions this one; it does not prove it is a backport of this change. +When a backport is relevant to the outcome (especially a tentative version, or when +listing backports in the draft), **confirm it is a genuine backport** by inspecting the +candidate PR's title, description, and — if still unclear — its code diff, before +relying on it. + After running the script, **print the full JSON output** so it is visible in the workflow log. Then check the JSON: - If the JSON contains an `Error` field, report the error and fall back to - reading the PR's base branch and recent tags manually to estimate the version. + inspecting the release branches manually (`gh api repos/dotnet/runtime/branches`) + to estimate the version. +- If `Tentative` is `true`, confirm the referenced linked PR is a genuine backport of + this change (title/description/diff). If it is, use `EstimatedVersion` and note in the + draft that it depends on that backport; if it is not, use `FallbackVersion` instead. +- If the candidate backport is confirmed and merged, treat the `EstimatedVersion` as definitive. + If it's confirmed and still open, treat the `EstimatedVersion` as provisional and note this in the draft. - If the JSON does **not** contain an `Error` field, use `EstimatedVersion` as the version for the breaking change issue. **Do not fall back to manual detection when the script succeeds.** @@ -147,6 +189,10 @@ gh api repos/dotnet/docs/contents/.github/ISSUE_TEMPLATE/02-breaking-change.yml This template defines the required sections and dropdown values. Use it as a structural reference only — do **not** output YAML. +**Also capture the template's `labels:` and `assignees:` values** — these are the +authoritative source for the labels and assignee applied to the new issue and must +be passed to `Build-IssueComment.ps1` in Step 6. + ### Example issues Search `dotnet/docs` for 2-3 recent issues with the `breaking-change` label. @@ -228,37 +274,38 @@ Create the output directory and write the full markdown content from Step 5 `artifacts/docs/breakingChanges/issue-draft.md`. This file is the primary output and can be reviewed before taking any further action. -```bash -mkdir -p artifacts/docs/breakingChanges -``` - ### Build the PR comment file -Run the helper script to produce a PR comment with a URL-encoded issue link. -Pass the PR assignees so they are `@`-mentioned in the comment and receive a -GitHub notification: +Run the helper script to produce a PR comment with a pre-filled issue link. +Pass the labels and assignee **from the docs issue template** (Step 4) for the new +issue, and pass the runtime PR's assignees separately so they are `@`-mentioned: ```bash pwsh .github/skills/breaking-change-doc/Build-IssueComment.ps1 \ -IssueDraftPath artifacts/docs/breakingChanges/issue-draft.md \ -Title "" \ - -Assignees "@user1 @user2" \ + -Labels "" \ + -IssueAssignees "" \ + -CcMentions "@user1 @user2" \ -OutputPath artifacts/docs/breakingChanges/pr-comment.md ``` -Build the `-Assignees` value from the PR's assignee list (from Step 1 metadata), -prefixing each GitHub username with `@` and separating with spaces. +- `-Labels` and `-IssueAssignees` come from the dotnet/docs template captured in + Step 4 (e.g. `-Labels "breaking-change"`, `-IssueAssignees "gewarren"`). +- `-CcMentions` is built from the **runtime PR's** assignee list (Step 1 metadata), + prefixing each GitHub username with `@` and separating with spaces. These are + used for the `/cc` notification line. The script: -- URL-encodes the title, body, and labels using `[Uri]::EscapeDataString` -- Builds a clickable `https://github.com/dotnet/docs/issues/new?...` link -- Writes a **brief** `pr-comment.md` with instructions, the link, an email - reminder, and `/cc` mentions of the assignees -- Warns if the URL exceeds browser length limits - -The comment intentionally does **not** duplicate the full issue draft. The draft -is available in the `issue-draft.md` workflow artifact and is pre-filled in the -issue creation link. +- Builds a clickable `https://github.com/dotnet/docs/issues/new?...` link that opens + a **blank markdown editor** (not the structured form) with the title, labels, and + assignee pre-filled. +- Uses a **hybrid** body strategy: if the full URL (including the URL-encoded body) + stays within a conservative ~8000-byte budget, the body is pre-filled too so the + issue is one click away. Otherwise the body is dropped from the URL and embedded in + the comment inside a `
` block for the user to copy-paste into the opened + editor. +- Warns if the comment exceeds GitHub's 65536-character comment limit. ### Post the comment @@ -275,14 +322,8 @@ outputs for review. ### AI-generated content disclosure The `Build-IssueComment.ps1` script includes the standard AI disclosure note -in the generated comment. No additional action is needed. - -### Email reminder - -The generated comment includes an email reminder. When running interactively, -also remind the user: -> Please email a link to this breaking change issue to -> [.NET Breaking Change Notifications](mailto:dotnetbcn@microsoft.com). +and the email reminder to the .NET Breaking Change Notifications alias in the +generated comment. No additional action is needed. --- @@ -315,7 +356,7 @@ If the user provides a GitHub search query or asks to process multiple PRs: | Problem | Solution | |---|---| | No `area-*` label on PR | Ask the user to add one, or ask which area applies | -| Version detection script fails | Read the PR's base branch and use `git describe --tags --abbrev=0` manually | +| Version detection script fails | Inspect release branches manually: `gh api repos/dotnet/runtime/branches --paginate --jq '.[].name'` and find the highest milestone branch (`release/.-preview`, `release/.-rc`, or `release/.`); a change on `main` ships in the **next** milestone (Preview N+1, or RC 1 after the final Preview, or GA after the final RC) | | PR not yet merged | Breaking change docs are for merged PRs only — inform the user | | Existing docs issue found | Report the existing issue URL and stop | | Cannot determine affected APIs | Review the diff carefully; list the public types/methods in changed files |