diff --git a/.claude/skills/build-duty/SKILL.md b/.claude/skills/build-duty/SKILL.md new file mode 100644 index 000000000000..cf3863dbc2c0 --- /dev/null +++ b/.claude/skills/build-duty/SKILL.md @@ -0,0 +1,279 @@ +--- +name: build-duty +description: Generate a build duty PR triage report across dotnet/sdk, dotnet/installer, dotnet/templating, and dotnet/dotnet repositories. Use when asked about "build duty", "triage PRs", "build duty report", "merge queue", "dependency PRs", "what PRs need merging", "build duty status", or "check open PRs for build duty". +--- + +# Build Duty PR Triage + +Monitor and classify pull requests across .NET SDK repositories for build duty engineers. Produces a structured triage report with PR status, age, classification, and failure details. + +> 🚨 **NEVER** use `gh pr merge` or approve PRs automatically. Merging and approval are human-only actions. This skill only generates reports. + +**Workflow**: Run the script (Step 1) → Read the output + JSON summary (Step 2) → Investigate failing PRs with ci-analysis (Step 3) → Synthesize the final report (Step 4). + +## When to Use This Skill + +Use this skill when: +- Checking build duty status ("what PRs need merging?", "build duty report") +- Triaging automated PRs across dotnet repos +- Generating a daily build duty triage report +- Checking if dependency update or codeflow PRs are ready to merge +- Asked "what's the merge queue look like?" or "any stuck PRs?" + +## Quick Start + +```powershell +# Full report across all 4 repos +./.claude/skills/build-duty/scripts/Get-BuildDutyReport.ps1 + +# Report for specific repos only +./.claude/skills/build-duty/scripts/Get-BuildDutyReport.ps1 -IncludeRepo sdk,installer + +# JSON-only output +./.claude/skills/build-duty/scripts/Get-BuildDutyReport.ps1 -OutputJson +``` + +## Key Parameters + +| Parameter | Description | +|-----------|-------------| +| `-IncludeRepo` | Filter to specific repos: `sdk`, `installer`, `templating`, `dotnet` (default: all 4) | +| `-DaysStale` | Days after which a PR is flagged stale (default: 7) | +| `-OutputJson` | Emit only JSON (no human-readable tables) | +| `-Verbose` | Show individual `gh` API calls for debugging | + +## What the Script Does + +1. **Queries 4 repositories** via `gh` CLI for open PRs from monitored authors: + - `dotnet-maestro[bot]` — Dependency updates and codeflow PRs + - `github-actions[bot]` — **Only** inter-branch merge PRs (titles containing "Merge branch"); excludes backport PRs + - `vseanreesermsft` — Release management PRs + - `dotnet-bot` — Automated bot PRs + +2. **Applies special VMR filtering** for `dotnet/dotnet`: Only includes PRs from `dotnet-maestro[bot]` whose titles reference SDK-owned repos (`dotnet/sdk`, `dotnet/templating`, `dotnet/deployment-tools`, `dotnet/source-build-reference-packages`). + +3. **Fetches detailed status** for each PR via GitHub GraphQL: + - `mergeStateStatus` (CLEAN, BLOCKED, UNSTABLE) + - `statusCheckRollup` (SUCCESS, FAILURE, PENDING) + - `reviewDecision` (APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED) + - `mergeable` (MERGEABLE, CONFLICTING, UNKNOWN) + - `changedFiles` count (0 = empty PR with no actual changes) + - Individual check run results (name, conclusion, status) + - Labels, age, draft status + +4. **Classifies each PR** into categories (see below). + +5. **Generates a recommendation** for each PR (see Recommendation Codes below). + +6. **Outputs** both human-readable tables and a `[BUILD_DUTY_SUMMARY]` JSON block. + +## PR Classification Categories + +### ✅ Ready to Merge +PRs where: +- `mergeStateStatus` is `CLEAN` or `UNSTABLE` (non-required checks failing is still mergeable) +- No blocking labels (`DO NOT MERGE`, `Branch Lockdown`) +- Not a draft PR + +**Action:** These can be merged immediately by the build duty engineer. + +### 🔒 Branch Lockdown +PRs where: +- Has `Branch Lockdown` label +- Expected during servicing windows + +**Action:** Queue for merge when lockdown lifts. No investigation needed. + +### ⚠️ Changes Requested +PRs where: +- `reviewDecision` is `CHANGES_REQUESTED` +- Reviewer name is included in the report + +**Action:** Requires action from PR author or upstream team. Note which reviewer requested changes. + +### ❌ Failing / Blocked +PRs where: +- `mergeStateStatus` is `BLOCKED` +- Or has `DO NOT MERGE` label + +> Note: Classification relies on GitHub's `mergeStateStatus` and labels. Individual failing or pending status checks may not cause a PR to be categorized as Failing / Blocked if GitHub still reports `mergeStateStatus` as `CLEAN` (i.e., only non-required checks are failing). + +**Action:** Investigate failures. Use the ci-analysis skill for detailed failure information. + +### ⏳ Stale (cross-cutting flag) +Any PR older than 7 days (configurable) that does NOT have `Branch Lockdown` label. These may be stuck or forgotten and need attention. + +## Recommendation Codes + +Each PR gets an automatic recommendation based on its status. The agent should act on these: + +| Code | Meaning | Agent Action | +|------|---------|-------------| +| `MERGE_EMPTY_CODEFLOW` | PR from dotnet-bot with 0 changed files — inter-branch codeflow with merge commits | Recommend merging. Completing these PRs reduces churn in the next codeflow PR. | +| `CLOSE_EMPTY_PR` | PR has 0 changed files — no actual code changes after merge/sync | Recommend closing or merging trivially. Provide `gh pr close` command. | +| `FIX_DARC_CONFLICT` | PR from maestro with 0 changed files and a darc merge conflict comment | Flag for manual resolution using `darc vmr resolve-conflict`. Direct the engineer to check the PR comments for step-by-step instructions. | +| `FIX_MERGE_CONFLICTS` | Merge PR has unresolved conflicts | Flag for manual conflict resolution. Cannot be auto-fixed. | +| `RETRY_SINGLE_LEG` | Only 1 CI leg failed out of many (likely flaky, common in templating) | Comment `/azp run` on the PR to trigger a retry. | +| `MERGE` | PR is ready to merge | List as quick win. Do NOT auto-merge — human action only. | +| `WAIT_FOR_LOCKDOWN` | Branch is locked for servicing | No action needed; queue for when lockdown lifts. | +| `ADDRESS_REVIEW` | Changes were requested by a reviewer | Note the reviewer; requires upstream action. | +| `NEEDS_REVIEW` | CI is passing but PR needs review approval | List as needing review. Common for VMR PRs. | +| `INVESTIGATE_FAILURE` | Multiple legs failing or complex failure | Run ci-analysis skill to diagnose. | + +## Analysis Workflow + +### Step 1: Run the Script + +```powershell +./.claude/skills/build-duty/scripts/Get-BuildDutyReport.ps1 -Verbose +``` + +This produces: +- Human-readable tables grouped by category +- `[BUILD_DUTY_SUMMARY]` JSON block with all PR data + +### Step 2: Read the Results + +Parse the `[BUILD_DUTY_SUMMARY]` JSON. Key fields: +- `counts` — How many PRs in each category +- `prs.ready` — PRs ready to merge (list these first) +- `prs.blocked` — PRs that need investigation +- `prs.changesRequested` — PRs waiting on reviewers +- `prs.lockdown` — PRs in branch lockdown +- `stalePrs` — PRs flagged as stale + +### Step 3: Investigate Failing PRs + +For each PR in the `blocked` category, run the ci-analysis skill to get detailed failure information: + +```powershell +# For dotnet/sdk PRs +./.claude/skills/ci-analysis/scripts/Get-CIStatus.ps1 -PRNumber -ShowLogs + +# For other repos +./.claude/skills/ci-analysis/scripts/Get-CIStatus.ps1 -PRNumber -Repository "dotnet/installer" -ShowLogs +``` + +From the CI analysis, extract: +- Whether failures are known issues (safe to retry) +- Whether failures correlate with PR changes (need fixing) +- Whether failures are infrastructure-related (transient) + +### Step 4: Generate the Final Report + +Synthesize the script output and CI analysis into a markdown report. Use this structure: + +```markdown +# 🔧 Build Duty Triage Report +**Date:** {today's date} +**Repositories:** dotnet/sdk, dotnet/installer, dotnet/templating, dotnet/dotnet + +--- + +## ✅ Ready to Merge ({count}) + +| # | Title | Repo | Target | Age | +|---|-------|------|--------|-----| +| [#1234](url) | Update dependencies | dotnet/sdk | main | 2d | + +--- + +## 🔒 Branch Lockdown ({count}) + +| # | Title | Repo | Target | Age | +|---|-------|------|--------|-----| + +--- + +## ⚠️ Changes Requested ({count}) + +| # | Title | Repo | Target | Age | Reviewer | +|---|-------|------|--------|-----|----------| + +--- + +## ❌ Failing / Blocked ({count}) + +| # | Title | Repo | Target | Age | Recommendation | Issue | +|---|-------|------|--------|-----|----------------|-------| +| [#5678](url) | Source code updates | dotnet/sdk | main | 3d | 🔍 Investigate | NU1603: package version mismatch | + +--- + +## 🗑️ Empty PRs — Close or Merge ({count}) + +PRs with 0 file changes. These typically result from merge conflicts that resolved to no-ops. +- **dotnet-bot PRs (codeflow):** Merge these — they contain merge commits and completing them reduces churn in the next codeflow PR. +- **maestro PRs with darc conflict comment:** Run `darc vmr resolve-conflict` per the instructions in the PR comments. +- **Other empty PRs:** Close with `gh pr close`. + +| # | Title | Repo | Recommendation | Command | +|---|-------|------|----------------|---------| +| [#1234](url) | [branch] Source code updates | dotnet/dotnet | Close | `gh pr close 1234 --repo dotnet/dotnet --comment 'Closing: no file changes.'` | +| [#2345](url) | Merge branch X => Y | dotnet/sdk | Merge (codeflow) | Ready to merge — reduces churn in next PR | +| [#3456](url) | [branch] Source code updates | dotnet/sdk | Fix darc conflict | See PR comments for `darc vmr resolve-conflict` instructions | + +--- + +## 🔀 Merge Conflict PRs ({count}) + +PRs with unresolved merge conflicts that need manual resolution. + +| # | Title | Repo | Target | +|---|-------|------|--------| + +--- + +## 📊 Summary + +| Category | Count | +|----------|-------| +| Ready to Merge | X | +| Branch Lockdown | X | +| Changes Requested | X | +| Failing/Blocked | X | +| Stale (>7d) | X | +| **Total** | **X** | + +--- + +## 📋 Recommended Actions + +1. **Merge:** {count} PRs are ready — review and merge +2. **Retry:** {count} PRs have known-issue failures — retry with `/azp run` +3. **Investigate:** {count} PRs have unclassified failures — run CI analysis +4. **Stale:** {count} PRs are >7 days old — escalate if stuck +``` + +## Interpreting Common Patterns + +### Cascading Merge Failures +When an inter-branch merge PR (e.g., `release/10.0.1xx => release/10.0.2xx`) is blocked, downstream merges in the chain will also be blocked. Look for the **root cause** at the start of the merge chain. + +### Codeflow PRs +PRs titled `[branch] Source code updates from dotnet/dotnet` are automated codeflow from the VMR. These often fail due to: +- Package version mismatches (NU1603) +- Breaking changes flowing from other repos +- Merge conflicts with concurrent changes + +### Merge Chains +The dotnet/sdk repo has a merge flow: `release/9.0.3xx → 10.0.1xx → 10.0.2xx → 10.0.3xx → main`. A failure early in the chain blocks everything downstream. + +### Branch Lockdown +During servicing windows (typically part of each month), release branches are locked. PRs targeting locked branches get the `Branch Lockdown` label automatically. + +## Labels Reference + +| Label | Meaning | +|-------|---------| +| `DO NOT MERGE` | Explicit block — never merge | +| `Branch Lockdown` | Branch closed for servicing | +| `Area-CodeFlow` | Codeflow/sync PR | + +## Tips + +1. Start with the "Ready to Merge" category — those are quick wins. +2. For failing PRs, check if multiple PRs share the same failure — fixing one may unblock others. +3. Merge chain PRs (`Merge branch X => Y`) should be merged in order from oldest branch to newest. +4. Stale PRs (>7 days) often indicate a systemic issue — check if the same failure pattern repeats. +5. Use `-IncludeRepo sdk` for a quick check of just the primary repo. diff --git a/.claude/skills/build-duty/scripts/Get-BuildDutyReport.ps1 b/.claude/skills/build-duty/scripts/Get-BuildDutyReport.ps1 new file mode 100644 index 000000000000..1fbd56272f72 --- /dev/null +++ b/.claude/skills/build-duty/scripts/Get-BuildDutyReport.ps1 @@ -0,0 +1,782 @@ +<# +.SYNOPSIS + Queries and classifies pull requests across .NET SDK repositories for build duty triage. + +.DESCRIPTION + This script queries GitHub for open pull requests from monitored authors across + dotnet/sdk, dotnet/installer, dotnet/templating, and dotnet/dotnet repositories. + It classifies each PR into categories (Ready to Merge, Branch Lockdown, Changes Requested, + Failing/Blocked) and outputs both a human-readable summary and structured JSON. + +.PARAMETER IncludeRepo + Filter to specific repositories. Valid values: sdk, installer, templating, dotnet. + Default: all four repositories. + +.PARAMETER DaysStale + Number of days after which a PR is flagged as stale. Default: 7. + +.PARAMETER OutputJson + Output only the JSON summary (no human-readable tables). + +.EXAMPLE + .\Get-BuildDutyReport.ps1 + # Full report across all repos + +.EXAMPLE + .\Get-BuildDutyReport.ps1 -IncludeRepo sdk,installer + # Report for sdk and installer only + +.EXAMPLE + .\Get-BuildDutyReport.ps1 -OutputJson + # JSON-only output for programmatic consumption +#> +[CmdletBinding()] +param( + [ValidateSet('sdk', 'installer', 'templating', 'dotnet')] + [string[]]$IncludeRepo = @('sdk', 'installer', 'templating', 'dotnet'), + + [int]$DaysStale = 7, + + [switch]$OutputJson +) + +$ErrorActionPreference = 'Stop' + +# Repo full names +$RepoMap = @{ + 'sdk' = 'dotnet/sdk' + 'installer' = 'dotnet/installer' + 'templating' = 'dotnet/templating' + 'dotnet' = 'dotnet/dotnet' +} + +# Authors to monitor (for non-VMR repos) +$MonitoredAuthors = @( + 'dotnet-maestro[bot]', + 'github-actions[bot]', + 'vseanreesermsft', + 'dotnet-bot' +) + +# For dotnet/dotnet VMR, only include PRs whose titles match these repos +$VmrTitleFilters = @( + 'dotnet/sdk', + 'dotnet/templating', + 'dotnet/deployment-tools', + 'dotnet/source-build-reference-packages' +) + +# GraphQL query for fetching PR details efficiently +# This fetches all fields we need in a single call per PR +$PrDetailQuery = @' +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + number + title + url + createdAt + isDraft + author { login } + baseRefName + mergeable + changedFiles + mergeStateStatus + reviewDecision + labels(first: 100) { nodes { name } } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 50) { + nodes { + ... on CheckRun { + name + conclusion + status + } + } + } + } + } + } + } + reviews(last: 10) { + nodes { + state + author { login } + } + } + } + } +} +'@ + +function Get-PrDetails { + <# + .SYNOPSIS + Fetches detailed PR information via GraphQL. + #> + param( + [string]$Owner, + [string]$Repo, + [int]$Number + ) + + try { + $result = gh api graphql -f "query=$PrDetailQuery" --field "owner=$Owner" --field "repo=$Repo" --field "number=$Number" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "GraphQL query failed for $Owner/$Repo#$Number : $result" + return $null + } + return ($result | ConvertFrom-Json).data.repository.pullRequest + } + catch { + Write-Warning "Failed to fetch details for $Owner/$Repo#$Number : $_" + return $null + } +} + +function Get-OpenPrsByAuthor { + <# + .SYNOPSIS + Lists open PRs by a specific author in a repo using gh CLI. + #> + param( + [string]$Repo, + [string]$Author + ) + + try { + $json = gh pr list --repo $Repo --author $Author --state open --json number,title,author --limit 200 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "gh pr list failed for $Repo author:$Author : $json" + return @() + } + $prs = $json | ConvertFrom-Json + if ($null -eq $prs) { return @() } + return @($prs) + } + catch { + Write-Warning "Failed to list PRs for $Repo author:$Author : $_" + return @() + } +} + +function Test-IsMergePr { + <# + .SYNOPSIS + Checks if a PR from github-actions[bot] is an inter-branch merge PR (not a backport). + #> + param([string]$Title) + + # Merge PRs have titles like "[automated] Merge branch 'release/9.0.2xx' => 'release/9.0.3xx'" + return $Title -match '(?i)merge\s+branch|^\[automated\]\s+Merge' +} + +function Test-IsVmrSdkPr { + <# + .SYNOPSIS + Checks if a dotnet/dotnet VMR PR is owned by the SDK team based on title. + #> + param([string]$Title) + + foreach ($filter in $VmrTitleFilters) { + if ($Title -match [regex]::Escape($filter)) { + return $true + } + } + return $false +} + +function Get-PrCategory { + <# + .SYNOPSIS + Classifies a PR into a triage category based on its status. + #> + param([PSCustomObject]$Pr) + + $labels = @() + if ($Pr.labels -and $Pr.labels.nodes) { + $labels = @($Pr.labels.nodes | ForEach-Object { $_.name }) + } + + # Branch Lockdown takes priority + if ($labels -contains 'Branch Lockdown') { + return 'lockdown' + } + + # DO NOT MERGE + if ($labels -contains 'DO NOT MERGE') { + return 'blocked' + } + + # Draft PRs + if ($Pr.isDraft) { + return 'draft' + } + + # Changes Requested + if ($Pr.reviewDecision -eq 'CHANGES_REQUESTED') { + return 'changes_requested' + } + + # Check merge state + $mergeState = $Pr.mergeStateStatus + $checkState = $null + if ($Pr.commits -and $Pr.commits.nodes -and $Pr.commits.nodes.Count -gt 0) { + $rollup = $Pr.commits.nodes[0].commit.statusCheckRollup + if ($rollup) { + $checkState = $rollup.state + } + } + + # Ready to merge: CLEAN merge state and SUCCESS checks + if ($mergeState -eq 'CLEAN' -and $checkState -eq 'SUCCESS') { + return 'ready' + } + + # Also ready if merge state is CLEAN (checks may not be required) + if ($mergeState -eq 'CLEAN') { + return 'ready' + } + + # Unstable: non-required checks failing + if ($mergeState -eq 'UNSTABLE') { + return 'ready' # Non-required checks failing is still mergeable + } + + # Everything else is blocked/failing + return 'blocked' +} + +function Get-AgeDays { + <# + .SYNOPSIS + Computes the age of a PR in days from its creation date. + #> + param([string]$CreatedAt) + + $created = [DateTimeOffset]::Parse($CreatedAt) + $age = [DateTimeOffset]::UtcNow - $created + return [math]::Max(0, [math]::Floor($age.TotalDays)) +} + +function Format-AgeString { + param([int]$Days) + if ($Days -eq 0) { return "<1d" } + return "${Days}d" +} + +function Get-ChangesRequestedReviewer { + <# + .SYNOPSIS + Gets the reviewer who requested changes. + #> + param($Reviews) + + if (-not $Reviews -or -not $Reviews.nodes) { return $null } + + $changesRequested = $Reviews.nodes | Where-Object { $_.state -eq 'CHANGES_REQUESTED' } | Select-Object -Last 1 + if ($changesRequested -and $changesRequested.author) { + return $changesRequested.author.login + } + return $null +} + +function Get-CheckRunDetails { + <# + .SYNOPSIS + Extracts per-check-run details (name, conclusion, status) from a PR's commit status. + Returns a summary of total, passed, failed, and pending checks plus the failed check names. + #> + param([PSCustomObject]$Pr) + + $result = [PSCustomObject]@{ + total = 0 + passed = 0 + failed = 0 + pending = 0 + failedNames = @() + } + + if (-not $Pr.commits -or -not $Pr.commits.nodes -or $Pr.commits.nodes.Count -eq 0) { + return $result + } + + $rollup = $Pr.commits.nodes[0].commit.statusCheckRollup + if (-not $rollup -or -not $rollup.contexts -or -not $rollup.contexts.nodes) { + return $result + } + + # Filter to actual CI checks (exclude Maestro policy checks and license/cla) + $ciChecks = @($rollup.contexts.nodes | Where-Object { + $_.name -and + $_.name -notmatch '^Maestro' -and + $_.name -notmatch '^license/' -and + $_.name -notmatch '^WIP$' + }) + + $result.total = $ciChecks.Count + $result.passed = @($ciChecks | Where-Object { $_.conclusion -eq 'SUCCESS' }).Count + $result.failed = @($ciChecks | Where-Object { $_.conclusion -eq 'FAILURE' }).Count + $result.pending = @($ciChecks | Where-Object { $_.status -ne 'COMPLETED' }).Count + $result.failedNames = @($ciChecks | Where-Object { $_.conclusion -eq 'FAILURE' } | ForEach-Object { $_.name }) + + return $result +} + +function Test-HasDarcConflictComment { + <# + .SYNOPSIS + Checks if a PR has a comment from maestro indicating a darc merge conflict + that requires running 'darc vmr resolve-conflict' to fix. + #> + param( + [string]$Repo, + [int]$Number + ) + + try { + $comments = gh pr view $Number --repo $Repo --json comments --jq '.comments[-5:][].body' 2>&1 + if ($LASTEXITCODE -ne 0) { return $false } + foreach ($body in $comments) { + if ($body -match 'Action Required.*Conflict detected' -and $body -match 'darc vmr resolve-conflict') { + return $true + } + } + return $false + } + catch { + return $false + } +} + +function Get-PrRecommendation { + <# + .SYNOPSIS + Generates an actionable recommendation for a PR based on its status. + #> + param( + [PSCustomObject]$PrInfo, + [PSCustomObject]$CheckDetails + ) + + $isMergePr = Test-IsMergePr -Title $PrInfo.title + $isTemplating = $PrInfo.repo -eq 'dotnet/templating' + + # Merge PR with merge conflicts + if ($isMergePr -and $PrInfo.mergeable -eq 'CONFLICTING') { + return 'FIX_MERGE_CONFLICTS' + } + + # Empty PR (0 changed files, no conflicts) — author-specific guidance + if ($PrInfo.changedFiles -eq 0 -and $PrInfo.mergeable -ne 'CONFLICTING') { + # dotnet-bot: inter-branch codeflow with merge commits — merge to reduce churn in the next PR + if ($PrInfo.author -eq 'dotnet-bot') { + return 'MERGE_EMPTY_CODEFLOW' + } + + # dotnet-maestro[bot]: check for darc conflict comment indicating a merge error + if ($PrInfo.author -eq 'dotnet-maestro[bot]') { + if (Test-HasDarcConflictComment -Repo $PrInfo.repo -Number $PrInfo.number) { + return 'FIX_DARC_CONFLICT' + } + } + + return 'CLOSE_EMPTY_PR' + } + + # Templating single-leg failure — likely flaky, rerun + if ($isTemplating -and $CheckDetails.failed -eq 1 -and $CheckDetails.passed -gt 0) { + return 'RETRY_SINGLE_LEG' + } + + # Ready PRs + if ($PrInfo.category -eq 'ready') { + return 'MERGE' + } + + # Lockdown + if ($PrInfo.category -eq 'lockdown') { + return 'WAIT_FOR_LOCKDOWN' + } + + # Changes requested + if ($PrInfo.category -eq 'changes_requested') { + return 'ADDRESS_REVIEW' + } + + # Blocked with all checks passing — likely just needs review + if ($PrInfo.category -eq 'blocked' -and $PrInfo.checkState -eq 'SUCCESS') { + return 'NEEDS_REVIEW' + } + + # Default blocked + return 'INVESTIGATE_FAILURE' +} + +function ConvertTo-PrSummaryObject { + <# + .SYNOPSIS + Converts a PR info object into a summary hashtable for JSON output. + #> + param([PSCustomObject]$Pr) + return [ordered]@{ + repo = $Pr.repo + number = $Pr.number + title = $Pr.title + url = $Pr.url + author = $Pr.author + targetBranch = $Pr.targetBranch + ageDays = $Pr.ageDays + mergeable = $Pr.mergeable + changedFiles = $Pr.changedFiles + mergeStateStatus = $Pr.mergeStateStatus + checkState = $Pr.checkState + checkDetails = [ordered]@{ + total = $Pr.checkDetails.total + passed = $Pr.checkDetails.passed + failed = $Pr.checkDetails.failed + pending = $Pr.checkDetails.pending + failedNames = $Pr.checkDetails.failedNames + } + reviewDecision = $Pr.reviewDecision + isStale = $Pr.isStale + changesRequestedBy = $Pr.changesRequestedBy + labels = $Pr.labels + recommendation = $Pr.recommendation + } +} + +# ---- Main ---- + +Write-Verbose "Build Duty PR Triage Report" +Write-Verbose "Repos: $($IncludeRepo -join ', ')" +Write-Verbose "Stale threshold: $DaysStale days" +Write-Verbose "" + +$allPrs = @() + +foreach ($repoKey in $IncludeRepo) { + $repoFull = $RepoMap[$repoKey] + $owner, $repo = $repoFull -split '/' + + Write-Verbose "Querying $repoFull..." + + if ($repoKey -eq 'dotnet') { + # VMR: only maestro PRs with SDK-owned titles + $candidates = Get-OpenPrsByAuthor -Repo $repoFull -Author 'dotnet-maestro[bot]' + $filtered = @($candidates | Where-Object { Test-IsVmrSdkPr -Title $_.title }) + Write-Verbose " dotnet-maestro[bot]: $($candidates.Count) total, $($filtered.Count) SDK-owned" + } + else { + $filtered = @() + foreach ($author in $MonitoredAuthors) { + $prs = Get-OpenPrsByAuthor -Repo $repoFull -Author $author + Write-Verbose " ${author}: $($prs.Count) PRs" + + if ($author -eq 'github-actions[bot]') { + # Only keep merge PRs, not backports + $mergePrs = @($prs | Where-Object { Test-IsMergePr -Title $_.title }) + Write-Verbose " Filtered to $($mergePrs.Count) merge PRs" + $filtered += $mergePrs + } + else { + $filtered += $prs + } + } + } + + # Fetch details for each PR via GraphQL + foreach ($pr in $filtered) { + Write-Verbose " Fetching details for #$($pr.number)..." + $details = Get-PrDetails -Owner $owner -Repo $repo -Number $pr.number + if ($null -eq $details) { + Write-Warning " Skipping #$($pr.number) - could not fetch details" + continue + } + + $ageDays = Get-AgeDays -CreatedAt $details.createdAt + $category = Get-PrCategory -Pr $details + + $labels = @() + if ($details.labels -and $details.labels.nodes) { + $labels = @($details.labels.nodes | ForEach-Object { $_.name }) + } + + $checkState = $null + if ($details.commits -and $details.commits.nodes -and $details.commits.nodes.Count -gt 0) { + $rollup = $details.commits.nodes[0].commit.statusCheckRollup + if ($rollup) { $checkState = $rollup.state } + } + + $changesRequestedBy = Get-ChangesRequestedReviewer -Reviews $details.reviews + $checkDetails = Get-CheckRunDetails -Pr $details + + $prInfo = [PSCustomObject]@{ + repo = $repoFull + number = $details.number + title = $details.title + url = $details.url + author = $details.author.login + targetBranch = $details.baseRefName + createdAt = $details.createdAt + ageDays = $ageDays + isDraft = $details.isDraft + mergeable = $details.mergeable + changedFiles = $details.changedFiles + mergeStateStatus = $details.mergeStateStatus + reviewDecision = $details.reviewDecision + checkState = $checkState + checkDetails = $checkDetails + labels = $labels + category = $category + isStale = ($ageDays -ge $DaysStale -and $category -ne 'lockdown') + changesRequestedBy = $changesRequestedBy + recommendation = $null # filled below + } + + $prInfo.recommendation = Get-PrRecommendation -PrInfo $prInfo -CheckDetails $checkDetails + + $allPrs += $prInfo + } +} + +# Group by category +$ready = @($allPrs | Where-Object { $_.category -eq 'ready' }) +$lockdown = @($allPrs | Where-Object { $_.category -eq 'lockdown' }) +$changesRequested = @($allPrs | Where-Object { $_.category -eq 'changes_requested' }) +$blocked = @($allPrs | Where-Object { $_.category -eq 'blocked' }) +$draft = @($allPrs | Where-Object { $_.category -eq 'draft' }) +$stale = @($allPrs | Where-Object { $_.isStale }) + +# ---- Output ---- + +if (-not $OutputJson) { + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " Build Duty PR Triage Report" -ForegroundColor Cyan + Write-Host " Generated: $([DateTimeOffset]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')) UTC" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + + # Ready to Merge + Write-Host "✅ Ready to Merge ($($ready.Count))" -ForegroundColor Green + if ($ready.Count -gt 0) { + Write-Host ("-" * 120) + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5} {5,-10}" -f "Repo", "#", "Title", "Target", "Age", "Checks") + Write-Host ("-" * 120) + foreach ($pr in ($ready | Sort-Object @{Expression='repo'; Ascending=$true}, @{Expression='ageDays'; Descending=$true})) { + $title = if ($pr.title.Length -gt 57) { $pr.title.Substring(0, 57) + "..." } else { $pr.title } + $ageStr = Format-AgeString $pr.ageDays + $staleFlag = if ($pr.isStale) { " ⚠️" } else { "" } + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5} {5,-10}" -f $pr.repo, "#$($pr.number)", $title, $pr.targetBranch, "$ageStr$staleFlag", $pr.checkState) + } + } + else { + Write-Host " (none)" -ForegroundColor DarkGray + } + Write-Host "" + + # Branch Lockdown + Write-Host "🔒 Branch Lockdown ($($lockdown.Count))" -ForegroundColor Yellow + if ($lockdown.Count -gt 0) { + Write-Host ("-" * 120) + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5}" -f "Repo", "#", "Title", "Target", "Age") + Write-Host ("-" * 120) + foreach ($pr in ($lockdown | Sort-Object @{Expression='repo'; Ascending=$true}, @{Expression='ageDays'; Descending=$true})) { + $title = if ($pr.title.Length -gt 57) { $pr.title.Substring(0, 57) + "..." } else { $pr.title } + $ageStr = Format-AgeString $pr.ageDays + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5}" -f $pr.repo, "#$($pr.number)", $title, $pr.targetBranch, $ageStr) + } + } + else { + Write-Host " (none)" -ForegroundColor DarkGray + } + Write-Host "" + + # Changes Requested + Write-Host "⚠️ Changes Requested ($($changesRequested.Count))" -ForegroundColor DarkYellow + if ($changesRequested.Count -gt 0) { + Write-Host ("-" * 120) + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5} {5,-15}" -f "Repo", "#", "Title", "Target", "Age", "Reviewer") + Write-Host ("-" * 120) + foreach ($pr in ($changesRequested | Sort-Object @{Expression='repo'; Ascending=$true}, @{Expression='ageDays'; Descending=$true})) { + $title = if ($pr.title.Length -gt 57) { $pr.title.Substring(0, 57) + "..." } else { $pr.title } + $ageStr = Format-AgeString $pr.ageDays + $reviewer = if ($pr.changesRequestedBy) { "@$($pr.changesRequestedBy)" } else { "unknown" } + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5} {5,-15}" -f $pr.repo, "#$($pr.number)", $title, $pr.targetBranch, $ageStr, $reviewer) + } + } + else { + Write-Host " (none)" -ForegroundColor DarkGray + } + Write-Host "" + + # Failing / Blocked + Write-Host "❌ Failing / Blocked ($($blocked.Count))" -ForegroundColor Red + if ($blocked.Count -gt 0) { + Write-Host ("-" * 140) + Write-Host ("{0,-18} {1,-6} {2,-50} {3,-25} {4,-5} {5,-10} {6,-25}" -f "Repo", "#", "Title", "Target", "Age", "Checks", "Recommendation") + Write-Host ("-" * 140) + foreach ($pr in ($blocked | Sort-Object @{Expression='repo'; Ascending=$true}, @{Expression='ageDays'; Descending=$true})) { + $title = if ($pr.title.Length -gt 47) { $pr.title.Substring(0, 47) + "..." } else { $pr.title } + $ageStr = Format-AgeString $pr.ageDays + $staleFlag = if ($pr.isStale) { " ⚠️" } else { "" } + $recStr = switch ($pr.recommendation) { + 'CLOSE_EMPTY_PR' { '🗑️ Close (empty PR)' } + 'MERGE_EMPTY_CODEFLOW' { '✅ Merge (empty codeflow)' } + 'FIX_DARC_CONFLICT' { '🔧 Fix darc conflict' } + 'FIX_MERGE_CONFLICTS' { '🔀 Fix merge conflicts' } + 'RETRY_SINGLE_LEG' { "🔄 Retry ($($pr.checkDetails.failedNames -join ', '))" } + 'NEEDS_REVIEW' { '👀 Needs review approval' } + 'INVESTIGATE_FAILURE' { '🔍 Investigate' } + default { $pr.recommendation } + } + Write-Host ("{0,-18} {1,-6} {2,-50} {3,-25} {4,-5} {5,-10} {6,-25}" -f $pr.repo, "#$($pr.number)", $title, $pr.targetBranch, "$ageStr$staleFlag", $pr.checkState, $recStr) + } + } + else { + Write-Host " (none)" -ForegroundColor DarkGray + } + Write-Host "" + + # Draft + if ($draft.Count -gt 0) { + Write-Host "📝 Draft ($($draft.Count))" -ForegroundColor DarkGray + Write-Host ("-" * 120) + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5}" -f "Repo", "#", "Title", "Target", "Age") + Write-Host ("-" * 120) + foreach ($pr in ($draft | Sort-Object @{Expression='repo'; Ascending=$true}, @{Expression='ageDays'; Descending=$true})) { + $title = if ($pr.title.Length -gt 57) { $pr.title.Substring(0, 57) + "..." } else { $pr.title } + $ageStr = Format-AgeString $pr.ageDays + Write-Host ("{0,-18} {1,-6} {2,-60} {3,-25} {4,-5}" -f $pr.repo, "#$($pr.number)", $title, $pr.targetBranch, $ageStr) -ForegroundColor DarkGray + } + Write-Host "" + } + + # Stale flag + if ($stale.Count -gt 0) { + Write-Host "⏳ Stale PRs (>$DaysStale days, excluding Branch Lockdown): $($stale.Count)" -ForegroundColor DarkYellow + foreach ($pr in $stale) { + Write-Host " ⚠️ $($pr.repo)#$($pr.number) - $(Format-AgeString $pr.ageDays) old - $($pr.title)" -ForegroundColor DarkYellow + } + Write-Host "" + } + + # Actionable items + $emptyPrs = @($allPrs | Where-Object { $_.recommendation -eq 'CLOSE_EMPTY_PR' }) + $emptyCodeflowPrs = @($allPrs | Where-Object { $_.recommendation -eq 'MERGE_EMPTY_CODEFLOW' }) + $darcConflictPrs = @($allPrs | Where-Object { $_.recommendation -eq 'FIX_DARC_CONFLICT' }) + $conflictPrs = @($allPrs | Where-Object { $_.recommendation -eq 'FIX_MERGE_CONFLICTS' }) + $retryPrs = @($allPrs | Where-Object { $_.recommendation -eq 'RETRY_SINGLE_LEG' }) + $reviewPrs = @($allPrs | Where-Object { $_.recommendation -eq 'NEEDS_REVIEW' }) + + if ($emptyPrs.Count -gt 0 -or $emptyCodeflowPrs.Count -gt 0 -or $darcConflictPrs.Count -gt 0 -or $conflictPrs.Count -gt 0 -or $retryPrs.Count -gt 0 -or $reviewPrs.Count -gt 0) { + Write-Host "========================================" -ForegroundColor Magenta + Write-Host " Quick Actions" -ForegroundColor Magenta + Write-Host "========================================" -ForegroundColor Magenta + + if ($emptyCodeflowPrs.Count -gt 0) { + Write-Host "" + Write-Host " ✅ Merge empty codeflow PRs (0 file changes, merge commits reduce churn in next PR):" -ForegroundColor Green + foreach ($pr in $emptyCodeflowPrs) { + Write-Host " $($pr.repo)#$($pr.number) - $($pr.title)" -ForegroundColor Green + } + } + + if ($emptyPrs.Count -gt 0) { + Write-Host "" + Write-Host " 🗑️ Close empty PRs (0 file changes):" -ForegroundColor DarkGray + foreach ($pr in $emptyPrs) { + Write-Host " gh pr close $($pr.number) --repo $($pr.repo) --comment 'Closing: no file changes after merge.'" -ForegroundColor DarkGray + } + } + + if ($darcConflictPrs.Count -gt 0) { + Write-Host "" + Write-Host " 🔧 Fix darc merge conflicts (run 'darc vmr resolve-conflict'):" -ForegroundColor Yellow + foreach ($pr in $darcConflictPrs) { + Write-Host " $($pr.repo)#$($pr.number) - $($pr.title)" -ForegroundColor Yellow + Write-Host " See PR comments for darc resolve-conflict instructions" -ForegroundColor DarkGray + } + } + + if ($conflictPrs.Count -gt 0) { + Write-Host "" + Write-Host " 🔀 Fix merge conflicts:" -ForegroundColor Yellow + foreach ($pr in $conflictPrs) { + Write-Host " $($pr.repo)#$($pr.number) - $($pr.title)" -ForegroundColor Yellow + } + } + + if ($retryPrs.Count -gt 0) { + Write-Host "" + Write-Host " 🔄 Retry single-leg failures (likely flaky):" -ForegroundColor Cyan + foreach ($pr in $retryPrs) { + $failedLeg = $pr.checkDetails.failedNames -join ', ' + Write-Host " $($pr.repo)#$($pr.number) - failed: $failedLeg" -ForegroundColor Cyan + Write-Host " gh pr comment $($pr.number) --repo $($pr.repo) --body '/azp run'" -ForegroundColor DarkGray + } + } + + if ($reviewPrs.Count -gt 0) { + Write-Host "" + Write-Host " 👀 Needs review approval (CI passing):" -ForegroundColor Green + foreach ($pr in $reviewPrs) { + Write-Host " $($pr.repo)#$($pr.number) - $($pr.title)" -ForegroundColor Green + } + } + Write-Host "" + } + + # Summary + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " Summary" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " Ready to Merge: $($ready.Count)" -ForegroundColor Green + Write-Host " Branch Lockdown: $($lockdown.Count)" -ForegroundColor Yellow + Write-Host " Changes Requested: $($changesRequested.Count)" -ForegroundColor DarkYellow + Write-Host " Failing/Blocked: $($blocked.Count)" -ForegroundColor Red + Write-Host " Draft: $($draft.Count)" -ForegroundColor DarkGray + Write-Host " Stale (>$($DaysStale)d): $($stale.Count)" -ForegroundColor DarkYellow + Write-Host " Total: $($allPrs.Count)" -ForegroundColor Cyan + Write-Host "" +} + +# Build and emit JSON summary +$summaryJson = [ordered]@{ + generatedAt = [DateTimeOffset]::UtcNow.ToString('o') + repos = @($IncludeRepo | ForEach-Object { $RepoMap[$_] }) + staleThresholdDays = $DaysStale + totalPrs = $allPrs.Count + counts = [ordered]@{ + ready = $ready.Count + lockdown = $lockdown.Count + changesRequested = $changesRequested.Count + blocked = $blocked.Count + draft = $draft.Count + stale = $stale.Count + } + prs = [ordered]@{ + ready = @($ready | ForEach-Object { ConvertTo-PrSummaryObject $_ }) + lockdown = @($lockdown | ForEach-Object { ConvertTo-PrSummaryObject $_ }) + changesRequested = @($changesRequested | ForEach-Object { ConvertTo-PrSummaryObject $_ }) + blocked = @($blocked | ForEach-Object { ConvertTo-PrSummaryObject $_ }) + draft = @($draft | ForEach-Object { ConvertTo-PrSummaryObject $_ }) + } + stalePrs = @($stale | ForEach-Object { ConvertTo-PrSummaryObject $_ }) +} + +$jsonOutput = $summaryJson | ConvertTo-Json -Depth 10 + +if ($OutputJson) { + Write-Output $jsonOutput +} +else { + Write-Host "[BUILD_DUTY_SUMMARY]" + Write-Host $jsonOutput + Write-Host "[/BUILD_DUTY_SUMMARY]" +}