From 73ad9659322846988eb726b9b29273bc1863f325 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 12:09:23 -0700 Subject: [PATCH 01/28] Initial check-in of breaking-change doc script --- eng/breakingChanges/README.md | 155 +++ eng/breakingChanges/breaking-change-doc.ps1 | 1000 +++++++++++++++++++ eng/breakingChanges/config.ps1 | 32 + 3 files changed, 1187 insertions(+) create mode 100644 eng/breakingChanges/README.md create mode 100644 eng/breakingChanges/breaking-change-doc.ps1 create mode 100644 eng/breakingChanges/config.ps1 diff --git a/eng/breakingChanges/README.md b/eng/breakingChanges/README.md new file mode 100644 index 00000000000000..dbc6a09d34bdda --- /dev/null +++ b/eng/breakingChanges/README.md @@ -0,0 +1,155 @@ +# Breaking Change Documentation Automation + +This script automates the creation of high-quality breaking change documentation for .NET runtime PRs using AI-powered analysis. + +## Key Features + +- **GitHub Models Integration**: Uses GitHub's AI models (no API keys required) with fallback to other providers +- **Dynamic Template Fetching**: Automatically fetches the latest breaking change issue template from dotnet/docs +- **Example-Based Learning**: Analyzes recent breaking change issues to improve content quality +- **Version Detection**: Analyzes GitHub tags to determine accurate .NET version information for proper milestone assignment +- **Flexible Workflow**: Multiple execution modes (CollectOnly, Comment, CreateIssues) with DryRun overlay +- **Comprehensive Data Collection**: Gathers PR details, related issues, merge commits, review comments, and closing issues +- **Area Label Detection**: Automatically detects feature areas from GitHub labels (area-*) with file path fallback +- **Individual File Output**: Creates separate JSON files per PR for easy examination + +## Quick Setup + +1. **Install Prerequisites:** + - GitHub CLI: `gh auth login` + - **Local Repository**: Clone dotnet/runtime locally for accurate version detection + - Choose LLM provider: + - **GitHub Models** (recommended): `gh extension install github/gh-models` + - **OpenAI**: Set `$env:OPENAI_API_KEY = "your-key"` + - **Others**: See configuration section below + +2. **Configure:** + ```powershell + # Edit config.ps1 to set: + # - LlmProvider = "github-models" (or other provider) + # - LocalRepoPath = "path\to\your\dotnet\runtime\clone" + ``` + +3. **Run the workflow:** + ```powershell + .\breaking-change-doc.ps1 -DryRun + ``` + +4. **Choose your workflow:** + ```powershell + # Default: Add comments with create issue links (recommended) + .\breaking-change-doc.ps1 + + # Create issues directly + .\breaking-change-doc.ps1 -CreateIssues + + # Just collect data + .\breaking-change-doc.ps1 -CollectOnly + ``` + +## Commands + +```powershell +# Dry run (inspect commands) +.\breaking-change-doc.ps1 -DryRun + +# Default workflow (add comments with links) +.\breaking-change-doc.ps1 + +# Create issues directly +.\breaking-change-doc.ps1 -CreateIssues + +# Data collection only +.\breaking-change-doc.ps1 -CollectOnly + +# Single PR mode +.\breaking-change-doc.ps1 -PRNumber 123456 + +# Clean start +.\breaking-change-doc.ps1 -CleanStart + +# Help +.\breaking-change-doc.ps1 -Help +``` + +## Configuration + +Edit `config.ps1` to customize: +- **LocalRepoPath**: Path to your local clone of dotnet/runtime (required for accurate version detection) +- **LLM provider**: GitHub Models, OpenAI, Anthropic, Azure OpenAI +- **Search parameters**: Date ranges, labels, excluded milestones +- **Output settings**: Labels, assignees, notification emails + +## LLM Providers + +**GitHub Models** (recommended - no API key needed): +```powershell +gh extension install github/gh-models +# Set provider in config.ps1: LlmProvider = "github-models" +``` + +**OpenAI**: +```powershell +$env:OPENAI_API_KEY = "your-key" +# Set provider in config.ps1: LlmProvider = "openai" +``` + +**Anthropic Claude**: +```powershell +$env:ANTHROPIC_API_KEY = "your-key" +# Set provider in config.ps1: LlmProvider = "anthropic" +``` + +**Azure OpenAI**: +```powershell +$env:AZURE_OPENAI_API_KEY = "your-key" +# Configure endpoint in config.ps1: LlmProvider = "azure-openai" +``` + +## Output + +- **Data Collection**: `.\data\summary_report.md`, `.\data\pr_*.json` +- **Issue Drafts**: `.\issue-drafts\*.md` +- **Comment Drafts**: `.\comment-drafts\*.md` +- **GitHub Issues**: Created automatically (unless -DryRun) + +## Workflow Steps + +1. **Fetch PRs** - Downloads PR data from dotnet/runtime with comprehensive details +2. **Version Detection** - Analyzes GitHub tags to determine accurate .NET version information +3. **Template & Examples** - Fetches latest issue template and analyzes recent breaking change issues +4. **AI Analysis** - Generates high-quality breaking change documentation using AI +5. **Create Issues/Comments** - Adds comments with issue creation links or creates issues directly + +## Version Detection + +The script automatically determines accurate .NET version information using your local git repository: +- **Fast and reliable**: Uses `git describe` commands on local repository clone +- **No API rate limits**: Avoids GitHub API calls for version detection +- **Accurate timing**: Analyzes actual commit ancestry and tag relationships +- **Merge commit analysis**: For merged PRs, finds the exact merge commit and determines version context +- **Branch-aware**: For unmerged PRs, uses target branch information + +**Requirements**: Local clone of dotnet/runtime repository configured in `LocalRepoPath` + +## Manual Review + +AI generates 90%+ ready documentation, but review for: +- Technical accuracy +- API completeness +- Edge cases + +## Cleanup + +Between runs: +```powershell +.\breaking-change-doc.ps1 -CleanStart +``` + +## Troubleshooting + +**GitHub CLI**: `gh auth status` and `gh auth login` +**Local Repository**: Ensure `LocalRepoPath` in config.ps1 points to a valid dotnet/runtime clone +**API Keys**: Verify environment variables are set for non-GitHub Models providers +**Rate Limits**: Use -DryRun for testing, script includes delays +**Git Operations**: Ensure git is in PATH and repository is up to date (`git fetch --tags`) \ No newline at end of file diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 new file mode 100644 index 00000000000000..a0eac3725d5e24 --- /dev/null +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -0,0 +1,1000 @@ +# Breaking Change Documentation Tool - All-in-One Script +# This script automates the creation of breaking change documentation for .NET runtime PRs +# Combines all functionality into a single, easy-to-use script + +param( + [switch]$CollectOnly = $false, # Only collect PR data, don't create issues + [switch]$CreateIssues = $false, # Create GitHub issues directly + [switch]$Comment = $false, # Add comments with links to create issues + [switch]$CleanStart = $false, # Clean previous data before starting + [string]$PrNumber = $null, # Process only specific PR number + [string]$Query = $null, # GitHub search query for PRs + [switch]$Help = $false # Show help +) + +# Show help +if ($Help) { + Write-Host @" +Breaking Change Documentation Workflow + +DESCRIPTION: + Automates the creation of high-quality breaking change documentation + for .NET runtime PRs using an LLM to analyze and author docs. + + DEFAULT BEHAVIOR: Analyzes PRs and generates documentation drafts without + making any changes to GitHub. Use -CreateIssues or -Comment to execute actions. + +USAGE: + .\breaking-change-doc.ps1 [parameters] + +PARAMETERS: + -CollectOnly Only collect PR data, don't create documentation + -CreateIssues Create GitHub issues directly + -Comment Add comments with links to create issues + -CleanStart Clean previous data before starting + -PrNumber Process only specific PR number + -Query GitHub search query for PRs (required if no -PrNumber) + -Help Show this help + +EXAMPLES: + .\breaking-change-doc.ps1 -PrNumber 114929 # Process specific PR + .\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" + .\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged" -Comment + .\breaking-change-doc.ps1 -PrNumber 114929 -CreateIssues # Create issues directly + .\breaking-change-doc.ps1 -Query "your-search-query" -CollectOnly # Only collect data + +QUERY EXAMPLES: + # PRs merged after specific date, excluding milestone: + "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" + + # All PRs with the target label: + "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged" + + # PRs from specific author: + "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged author:username" + +SETUP: + 1. Install GitHub CLI and authenticate: gh auth login + 2. Choose LLM provider: + - For GitHub Models: gh extension install github/gh-models + - For OpenAI: `$env:OPENAI_API_KEY = "your-key" + - For others: Set appropriate API key + 3. Edit config.ps1 to customize settings +"@ + exit 0 +} + +Write-Host "๐Ÿค– Breaking Change Documentation Tool" -ForegroundColor Cyan +Write-Host "====================================" -ForegroundColor Cyan + +# Load configuration +if (Test-Path ".\config.ps1") { + . ".\config.ps1" +} else { + Write-Error "config.ps1 not found. Please create configuration file." + exit 1 +} + +# Validate prerequisites +Write-Host "`n๐Ÿ” Validating prerequisites..." -ForegroundColor Yellow + +# Check GitHub CLI +if (-not (Get-Command "gh" -ErrorAction SilentlyContinue)) { + Write-Error "โŒ GitHub CLI not found. Install from https://cli.github.com/" + exit 1 +} + +try { + gh auth status | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "โŒ GitHub CLI not authenticated. Run 'gh auth login'" + exit 1 + } + Write-Host "โœ… GitHub CLI authenticated" -ForegroundColor Green +} catch { + Write-Error "โŒ GitHub CLI error: $($_.Exception.Message)" + exit 1 +} + +# Check LLM API key or GitHub CLI for GitHub Models +$llmProvider = $Config.LlmProvider +$apiKey = switch ($llmProvider) { + "openai" { $env:OPENAI_API_KEY } + "anthropic" { $env:ANTHROPIC_API_KEY } + "azure-openai" { $env:AZURE_OPENAI_API_KEY } + "github-models" { $null } # No API key needed for GitHub Models + default { $env:OPENAI_API_KEY } +} + +if ($llmProvider -eq "github-models") { + # Check if gh-models extension is installed + try { + $extensions = gh extension list 2>$null + if ($extensions -notmatch "github/gh-models") { + Write-Error "โŒ GitHub Models extension not found. Install with: gh extension install github/gh-models" + exit 1 + } + Write-Host "โœ… GitHub Models extension found" -ForegroundColor Green + } catch { + Write-Error "โŒ Could not check GitHub Models extension: $($_.Exception.Message)" + exit 1 + } +} elseif (-not $apiKey) { + Write-Error "โŒ No LLM API key found. Set environment variable:" + Write-Host " For OpenAI: `$env:OPENAI_API_KEY = 'your-key'" + Write-Host " For Anthropic: `$env:ANTHROPIC_API_KEY = 'your-key'" + Write-Host " For Azure OpenAI: `$env:AZURE_OPENAI_API_KEY = 'your-key'" + Write-Host " For GitHub Models: Use 'github-models' provider (no key needed)" + exit 1 +} else { + Write-Host "โœ… LLM API key found ($llmProvider)" -ForegroundColor Green +} + +# Check local repository path +if (-not (Test-Path $Config.LocalRepoPath)) { + Write-Warning "โš ๏ธ Local repository path not found: $($Config.LocalRepoPath)" + Write-Host " Version detection will be limited. Consider cloning the repository or updating the path in config.ps1" +} else { + Write-Host "โœ… Local repository found: $($Config.LocalRepoPath)" -ForegroundColor Green +} + +# Clean start if requested +if ($CleanStart) { + Write-Host "`n๐Ÿงน Cleaning previous data..." -ForegroundColor Yellow + if (Test-Path ".\data") { Remove-Item ".\data" -Recurse -Force } + if (Test-Path ".\issue-drafts") { Remove-Item ".\issue-drafts" -Recurse -Force } + if (Test-Path ".\comment-drafts") { Remove-Item ".\comment-drafts" -Recurse -Force } + Write-Host "โœ… Cleanup completed" -ForegroundColor Green +} + +# Create output directories +New-Item -ItemType Directory -Path ".\data" -Force | Out-Null +New-Item -ItemType Directory -Path ".\issue-drafts" -Force | Out-Null +New-Item -ItemType Directory -Path ".\comment-drafts" -Force | Out-Null + +# Validate parameters +if (-not $PrNumber -and -not $Query) { + Write-Error @" +โŒ Either -PrNumber or -Query must be specified. + +EXAMPLES: + Process specific PR: + .\breaking-change-doc.ps1 -PrNumber 114929 + + Query for PRs (example - customize as needed): + .\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" + +Use -Help for more examples and detailed usage information. +"@ + exit 1 +} + +if ($PrNumber -and $Query) { + Write-Error "โŒ Cannot specify both -PrNumber and -Query. Choose one." + exit 1 +} + +# Determine action mode - default to analysis only if no action specified +$executeActions = $CreateIssues -or $Comment + +# Validate parameter combinations +if (($CreateIssues -and $Comment) -or ($CollectOnly -and ($CreateIssues -or $Comment))) { + Write-Error "โŒ Cannot combine -CollectOnly, -CreateIssues, and -Comment. Choose one action mode." + exit 1 +} + +$actionMode = if ($CollectOnly) { "Collect Only" } + elseif ($CreateIssues) { "Create Issues" } + elseif ($Comment) { "Add Comments" } + else { "Analysis Only" } + +Write-Host " Action Mode: $actionMode" -ForegroundColor Cyan +if (-not $executeActions -and -not $CollectOnly) { + Write-Host " ๏ฟฝ Will generate drafts without making changes to GitHub" -ForegroundColor Yellow +} + +# Function to safely truncate text +function Limit-Text { + param([string]$text, [int]$maxLength = 2000) + + if (-not $text -or $text.Length -le $maxLength) { + return $text + } + + $truncated = $text.Substring(0, $maxLength) + $lastPeriod = $truncated.LastIndexOf('.') + $lastNewline = $truncated.LastIndexOf("`n") + + $cutPoint = [Math]::Max($lastPeriod, $lastNewline) + if ($cutPoint -gt ($maxLength * 0.8)) { + $truncated = $truncated.Substring(0, $cutPoint + 1) + } + + return $truncated + "`n`n[Content truncated for length]" +} + +# Function to URL encode text for GitHub issue URLs +function Get-UrlEncodedText { + param([string]$text) + + # Basic URL encoding for GitHub issue URLs + $encoded = $text -replace '\r\n', '%0A' -replace '\n', '%0A' -replace '\r', '%0A' + $encoded = $encoded -replace ' ', '%20' -replace '#', '%23' -replace '&', '%26' + $encoded = $encoded -replace '\[', '%5B' -replace '\]', '%5D' -replace '\(', '%28' -replace '\)', '%29' + $encoded = $encoded -replace ':', '%3A' -replace ';', '%3B' -replace '\?', '%3F' -replace '=', '%3D' + $encoded = $encoded -replace '@', '%40' -replace '\+', '%2B' -replace '\$', '%24' + $encoded = $encoded -replace '"', '%22' -replace "'", '%27' -replace '<', '%3C' -replace '>', '%3E' + + return $encoded +} + +# Function to fetch issue template from GitHub +function Get-IssueTemplate { + try { + Write-Host " ๐Ÿ“‹ Fetching issue template..." -ForegroundColor DarkGray + $templateContent = gh api "repos/$($Config.DocsRepo)/contents/$($Config.IssueTemplatePath)" --jq '.content' | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($_)) } + return $templateContent + } + catch { + Write-Error "โŒ Failed to fetch issue template from $($Config.DocsRepo)/$($Config.IssueTemplatePath): $($_.Exception.Message)" + Write-Error " Template is required for high-quality documentation generation. Please check repository access and template path." + exit 1 + } +} + +# Function to fetch example breaking change issues +function Get-ExampleBreakingChangeIssues { + try { + Write-Host " ๐Ÿ“š Fetching example breaking change issues..." -ForegroundColor DarkGray + $exampleIssuesJson = gh issue list --repo $Config.DocsRepo --label "breaking-change" --limit 3 --json number,title,body,url + $exampleIssues = $exampleIssuesJson | ConvertFrom-Json + + if ($exampleIssues.Count -eq 0) { + Write-Error "โŒ No example breaking change issues found in $($Config.DocsRepo) with label 'breaking-change'" + Write-Error " Examples are required for high-quality documentation generation. Please check repository and label." + exit 1 + } + + $examples = @() + foreach ($issue in $exampleIssues) { + $examples += @" +**Example #$($issue.number)**: $($issue.title) +URL: $($issue.url) +Body: $(Limit-Text -text $issue.body -maxLength 800) +"@ + } + return $examples -join "`n`n---`n`n" + } + catch { + Write-Error "โŒ Failed to fetch example breaking change issues from $($Config.DocsRepo): $($_.Exception.Message)" + Write-Error " Examples are required for high-quality documentation generation. Please check repository access." + exit 1 + } +} + +# Function to find the closest tag by commit distance +function Find-ClosestTagByDistance { + param([string]$targetCommit, [int]$maxTags = 10) + + $recentTags = git tag --sort=-version:refname 2>$null | Select-Object -First $maxTags + $closestTag = $null + $minDistance = [int]::MaxValue + + foreach ($tag in $recentTags) { + # Check if this tag contains the target commit (skip if it does for merged PRs) + if ($targetCommit -match '^[a-f0-9]{40}$') { + # This is a commit hash, check if tag contains it + git merge-base --is-ancestor $targetCommit $tag 2>$null + if ($LASTEXITCODE -eq 0) { + # This tag contains our commit, skip it + continue + } + } + + # Calculate commit distance between tag and target + $distance = git rev-list --count "$tag..$targetCommit" 2>$null + if ($LASTEXITCODE -eq 0 -and $distance -match '^\d+$') { + $distanceNum = [int]$distance + if ($distanceNum -lt $minDistance) { + $minDistance = $distanceNum + $closestTag = $tag + } + } + } + + return $closestTag +} + +# Function to get version information using local git repository +function Get-VersionInfo { + param([string]$prNumber, [string]$mergedAt, [string]$baseRef = "main") + + try { + if (-not (Test-Path $Config.LocalRepoPath)) { + Write-Warning "Local repository path not found: $($Config.LocalRepoPath)" + return @{ + LastTagBeforeMerge = "Unknown - no local repo" + FirstTagWithChange = "Not yet released" + EstimatedVersion = "Next release" + } + } + + # Change to the repository directory + Push-Location $Config.LocalRepoPath + + try { + # Ensure we have latest info + git fetch --tags 2>$null | Out-Null + + # For merged PRs, try to find the merge commit and get version info + if ($prNumber -and $mergedAt) { + # Get the merge commit for this PR + $mergeCommit = gh pr view $prNumber --repo $Config.SourceRepo --json mergeCommit --jq '.mergeCommit.oid' 2>$null + + if ($mergeCommit) { + # Find the closest tag by commit distance (not time) + $closestTag = Find-ClosestTagByDistance -targetCommit $mergeCommit + $lastTagBefore = if ($closestTag) { $closestTag } else { "Unknown" } + + # Get the first tag that includes this commit + $firstTagWith = git describe --tags --contains $mergeCommit 2>$null + if ($firstTagWith -and $firstTagWith -match '^([^~^]+)') { + $firstTagWith = $matches[1] + } + } else { + # Fallback: use the target branch approach + if ($baseRef -eq "main") { + $lastTagBefore = git describe --tags --abbrev=0 "origin/$baseRef" 2>$null + if (-not $lastTagBefore) { + $allMainTags = git tag --merged "origin/$baseRef" --sort=-version:refname 2>$null + if ($allMainTags) { + $lastTagBefore = ($allMainTags | Select-Object -First 1) + } + } + } else { + $lastTagBefore = git describe --tags --abbrev=0 "origin/$baseRef" 2>$null + } + $firstTagWith = "Not yet released" + } + } else { + # For unmerged PRs, get the most recent tag available + # Use the same commit-distance approach as for merged PRs + if ($baseRef -eq "main") { + # Get the HEAD of main branch and find closest tag + $mainHead = git rev-parse "origin/$baseRef" 2>$null + + if ($mainHead) { + $closestTag = Find-ClosestTagByDistance -targetCommit $mainHead + $lastTagBefore = if ($closestTag) { $closestTag } else { + # Fallback to the most recent tag overall + git tag --sort=-version:refname | Select-Object -First 1 2>$null + } + } else { + # Final fallback + $lastTagBefore = git tag --sort=-version:refname | Select-Object -First 1 2>$null + } + } else { + $lastTagBefore = git describe --tags --abbrev=0 "origin/$baseRef" 2>$null + } + $firstTagWith = "Not yet released" + } + + # Clean up tag names and estimate version + $lastTagBefore = if ($lastTagBefore) { $lastTagBefore.Trim() } else { "Unknown" } + $firstTagWith = if ($firstTagWith -and $firstTagWith -ne "Not yet released") { $firstTagWith.Trim() } else { "Not yet released" } + + # Estimate version from the most recent tag + $estimatedVersion = "Next release" + if ($firstTagWith -ne "Not yet released") { + if ($firstTagWith -match "v?(\d+)\.(\d+)") { + $major = [int]$matches[1] + $minor = [int]$matches[2] + $estimatedVersion = ".NET $major.$minor" + } + } elseif ($lastTagBefore -ne "Unknown") { + # Estimate version from last tag + if ($lastTagBefore -match 'v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)\.') { + # RC tag found - breaking change will be in next major release + $nextMajor = [int]$matches[1] + 1 + if ($baseRef -eq "main") { + $estimatedVersion = ".NET $nextMajor Preview 1" + } else { + $estimatedVersion = ".NET $nextMajor" + } + } elseif ($lastTagBefore -match 'v(\d+)\.(\d+)\.(\d+)-preview\.(\d+)\.') { + $major = $matches[1] + $estimatedVersion = ".NET $major" + } elseif ($lastTagBefore -match 'v(\d+)\.(\d+)\.(\d+)$') { + # Release tag - next will be next major + $nextMajor = [int]$matches[1] + 1 + $estimatedVersion = ".NET $nextMajor" + } elseif ($lastTagBefore -match "v?(\d+)\.(\d+)") { + $major = [int]$matches[1] + $minor = [int]$matches[2] + # For main branch, it's usually the next major version + if ($baseRef -eq "main") { + $estimatedVersion = ".NET $($major + 1)" + } else { + $estimatedVersion = ".NET $major.$minor" + } + } + } + + return @{ + LastTagBeforeMerge = $lastTagBefore + FirstTagWithChange = $firstTagWith + EstimatedVersion = $estimatedVersion + } + } + finally { + Pop-Location + } + } + catch { + Write-Warning "Could not get version information using git: $($_.Exception.Message)" + return @{ + LastTagBeforeMerge = "Unknown" + FirstTagWithChange = "Not yet released" + EstimatedVersion = "Next release" + } + } +} + +# Function to call LLM API +function Invoke-LlmApi { + param([string]$Prompt, [string]$SystemPrompt = "", [int]$MaxTokens = 3000) + + switch ($Config.LlmProvider) { + "github-models" { + # Use GitHub CLI with models extension + try { + $ghArgs = @( + "models", "run", + $Config.LlmModel, + $Prompt, + "--max-tokens", $MaxTokens.ToString(), + "--temperature", "0.1" + ) + + if ($SystemPrompt) { + $ghArgs += @("--system-prompt", $SystemPrompt) + } + + $response = & gh @ghArgs + return $response -join "`n" + } + catch { + Write-Error "GitHub Models API call failed: $($_.Exception.Message)" + return $null + } + } + default { + # Existing API-based providers + $headers = @{ 'Content-Type' = 'application/json' } + $body = @{} + $endpoint = "" + + switch ($Config.LlmProvider) { + "openai" { + $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/chat/completions" } else { "https://api.openai.com/v1/chat/completions" } + $headers['Authorization'] = "Bearer $apiKey" + + $messages = @() + if ($SystemPrompt) { $messages += @{ role = "system"; content = $SystemPrompt } } + $messages += @{ role = "user"; content = $Prompt } + + $body = @{ + model = $Config.LlmModel + messages = $messages + max_tokens = $MaxTokens + temperature = 0.1 + } + } + "anthropic" { + $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/messages" } else { "https://api.anthropic.com/v1/messages" } + $headers['x-api-key'] = $apiKey + $headers['anthropic-version'] = "2023-06-01" + + $fullPrompt = if ($SystemPrompt) { "$SystemPrompt`n`nHuman: $Prompt`n`nAssistant:" } else { "Human: $Prompt`n`nAssistant:" } + + $body = @{ + model = $Config.LlmModel + max_tokens = $MaxTokens + messages = @(@{ role = "user"; content = $fullPrompt }) + temperature = 0.1 + } + } + } + + try { + $requestJson = $body | ConvertTo-Json -Depth 10 + $response = Invoke-RestMethod -Uri $endpoint -Method POST -Headers $headers -Body $requestJson + + switch ($Config.LlmProvider) { + "openai" { return $response.choices[0].message.content } + "anthropic" { return $response.content[0].text } + } + } + catch { + Write-Error "LLM API call failed: $($_.Exception.Message)" + return $null + } + } + } +} + +# STEP 1: Collect PR data +Write-Host "`n๐Ÿ“ฅ Step 1: Collecting comprehensive PR data..." -ForegroundColor Green + +if ($PrNumber) { + # Single PR mode - fetch only the specified PR + Write-Host " Mode: Single PR #$PrNumber" + try { + $prJson = gh pr view $PrNumber --repo $Config.SourceRepo --json number,title,url,baseRefName,closedAt,mergeCommit,labels,files,state + $prData = $prJson | ConvertFrom-Json + + # Verify PR has the target label + $hasTargetLabel = $prData.labels | Where-Object { $_.name -eq $Config.TargetLabel } + if (-not $hasTargetLabel) { + Write-Error "PR #$PrNumber does not have the '$($Config.TargetLabel)' label" + exit 1 + } + + $prs = @($prData) + Write-Host " Found PR #$($prs[0].number): $($prs[0].title)" + } catch { + Write-Error "Failed to fetch PR #${PrNumber}: $($_.Exception.Message)" + exit 1 + } +} else { + # Query mode - fetch all PRs matching criteria + Write-Host " Mode: Query - $Query" + + try { + $prsJson = gh pr list --repo $Config.SourceRepo --search $Query --limit $Config.MaxPRs --json number,title,url,baseRefName,closedAt,mergeCommit,labels,files + $prs = $prsJson | ConvertFrom-Json + } catch { + Write-Error "Failed to fetch PRs: $($_.Exception.Message)" + exit 1 + } + + Write-Host " Found $($prs.Count) PRs to collect data for" +} + +# Collect detailed data for each PR +$analysisData = @() +foreach ($pr in $prs) { + Write-Host " Collecting data for PR #$($pr.number): $($pr.title)" -ForegroundColor Gray + + # Get comprehensive PR details including comments and reviews + try { + $prDetails = gh pr view $pr.number --repo $Config.SourceRepo --json body,title,comments,reviews,closingIssuesReferences + $prDetailData = $prDetails | ConvertFrom-Json + } catch { + Write-Warning "Could not fetch detailed PR data for #$($pr.number)" + continue + } + + # Get closing issues with full details and comments + $closingIssues = @() + foreach ($issueRef in $prDetailData.closingIssuesReferences) { + if ($issueRef.number) { + try { + Write-Host " Fetching issue #$($issueRef.number)..." -ForegroundColor DarkGray + $issueDetails = gh issue view $issueRef.number --repo $Config.SourceRepo --json number,title,body,comments,labels,state,createdAt,closedAt,url + $issueData = $issueDetails | ConvertFrom-Json + $closingIssues += @{ + Number = $issueData.number + Title = $issueData.title + Body = $issueData.body + Comments = $issueData.comments + Labels = $issueData.labels | ForEach-Object { $_.name } + State = $issueData.state + CreatedAt = $issueData.createdAt + ClosedAt = $issueData.closedAt + Url = $issueData.url + } + } + catch { + Write-Warning "Could not fetch issue #$($issueRef.number)" + } + } + } + + # Create merge commit URL + $mergeCommitUrl = if ($pr.mergeCommit.oid) { + "https://github.com/$($Config.SourceRepo)/commit/$($pr.mergeCommit.oid)" + } else { + $null + } + + # Get version information using local git repository + Write-Host " ๐Ÿท๏ธ Getting version info..." -ForegroundColor DarkGray + $versionInfo = Get-VersionInfo -prNumber $pr.number -mergedAt $pr.closedAt -baseRef $pr.baseRefName + + # Check for existing docs issues + $hasDocsIssue = $false + try { + $searchResult = gh issue list --repo $Config.DocsRepo --search "Breaking change $($pr.number)" --json number,title + $existingIssues = $searchResult | ConvertFrom-Json + $hasDocsIssue = $existingIssues.Count -gt 0 + } catch { + Write-Warning "Could not check for existing docs issues for PR #$($pr.number)" + } + + # Get feature areas from area- labels first, then fall back to file paths + $featureAreas = @() + + # First try to get feature areas from area- labels + foreach ($label in $pr.labels) { + if ($label.name -match "^area-(.+)$") { + $featureAreas += $matches[1] + } + } + + # If no area labels found, fall back to file path analysis + if ($featureAreas.Count -eq 0) { + foreach ($file in $pr.files) { + if ($file.path -match "src/libraries/([^/]+)" -and $file.path -match "\.cs$") { + $namespace = $matches[1] -replace "System\.", "" + if ($namespace -eq "Private.CoreLib") { $namespace = "System.Runtime" } + $featureAreas += $namespace + } + } + } + + $featureAreas = $featureAreas | Select-Object -Unique + if ($featureAreas.Count -eq 0) { $featureAreas = @("Runtime") } + + $analysisData += @{ + Number = $pr.number + Title = $pr.title + Url = $pr.url + Author = $pr.author.login + BaseRef = $pr.baseRefName + ClosedAt = $pr.closedAt + MergedAt = $pr.mergedAt + MergeCommit = @{ + Sha = $pr.mergeCommit.oid + Url = $mergeCommitUrl + } + Body = if ($prDetailData.body) { $prDetailData.body } else { $pr.body } + Comments = $prDetailData.comments + Reviews = $prDetailData.reviews + ClosingIssues = $closingIssues + HasDocsIssue = $hasDocsIssue + ExistingDocsIssues = if ($hasDocsIssue) { $existingIssues } else { @() } + FeatureAreas = $featureAreas -join ", " + ChangedFiles = $pr.files | ForEach-Object { $_.path } + Labels = $pr.labels | ForEach-Object { $_.name } + VersionInfo = $versionInfo + } + + # Save individual PR data file + $prFileName = ".\data\pr_$($pr.number).json" + $analysisData[-1] | ConvertTo-Json -Depth 10 | Out-File $prFileName -Encoding UTF8 + Write-Host " ๐Ÿ’พ Saved: $prFileName" -ForegroundColor DarkGray + + Start-Sleep -Seconds $Config.RateLimiting.DelayBetweenCalls +} + +# Save combined data with comprehensive details (for overview) +$analysisData | ConvertTo-Json -Depth 10 | Out-File ".\data\combined.json" -Encoding UTF8 + +# Create summary report +$queryInfo = if ($PrNumber) { + "Single PR #$PrNumber" +} else { + "Query: $Query" +} + +$summaryReport = @" +# Breaking Change Documentation Collection Report + +**Generated**: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') +**Mode**: $queryInfo + +## Summary + +- **Total PRs collected**: $($analysisData.Count) +- **PRs with existing docs issues**: $($analysisData | Where-Object HasDocsIssue | Measure-Object).Count +- **PRs needing docs issues**: $($analysisData | Where-Object { -not $_.HasDocsIssue } | Measure-Object).Count + +## PRs Needing Documentation + +$($analysisData | Where-Object { -not $_.HasDocsIssue } | ForEach-Object { + "- **PR #$($_.Number)**: $($_.Title)`n - URL: $($_.Url)`n - Feature Areas: $($_.FeatureAreas)`n" +} | Out-String) + +## PRs With Existing Documentation + +$($analysisData | Where-Object HasDocsIssue | ForEach-Object { + "- **PR #$($_.Number)**: $($_.Title)`n - URL: $($_.Url)`n" +} | Out-String) +"@ + +$summaryReport | Out-File ".\data\summary_report.md" -Encoding UTF8 + +Write-Host "โœ… Data collection completed completed" -ForegroundColor Green +Write-Host " ๐Ÿ“Š Summary: .\data\summary_report.md" +Write-Host " ๐Ÿ“‹ Combined: .\data\combined.json" +Write-Host " ๐Ÿ“„ Individual: .\data\pr_*.json ($($analysisData.Count) files)" + +if ($CollectOnly) { + exit 0 +} + +# STEP 2: Generate breaking change issues +Write-Host "`n๐Ÿ“ Step 2: Generating breaking change documentation..." -ForegroundColor Green + +$prsNeedingDocs = $analysisData | Where-Object { -not $_.HasDocsIssue } + +if ($prsNeedingDocs.Count -eq 0) { + if ($PrNumber) { + Write-Host " PR #$PrNumber already has documentation or doesn't need it." -ForegroundColor Yellow + } else { + Write-Host " No PRs found that need documentation issues." -ForegroundColor Yellow + } + exit 0 +} + +if ($PrNumber) { + Write-Host " Processing PR #$PrNumber for issue generation" +} else { + Write-Host " Processing $($prsNeedingDocs.Count) PRs for issue generation" +} + +foreach ($pr in $prsNeedingDocs) { + Write-Host " ๐Ÿ” Processing PR #$($pr.Number): $($pr.Title)" -ForegroundColor Cyan + + # Get detailed PR data + try { + $prDetails = gh pr view $pr.Number --repo $Config.SourceRepo --json body,title,comments,reviews,closingIssuesReferences,files + $prData = $prDetails | ConvertFrom-Json + } catch { + Write-Error "Failed to get PR details for #$($pr.Number)" + continue + } + + # Prepare data for LLM + $comments = if ($pr.Comments -and $pr.Comments.Count -gt 0) { + ($pr.Comments | ForEach-Object { "**@$($_.author.login)**: $(Limit-Text -text $_.body -maxLength 300)" }) -join "`n`n" + } else { "No comments" } + + $reviews = if ($pr.Reviews -and $pr.Reviews.Count -gt 0) { + ($pr.Reviews | ForEach-Object { "**@$($_.author.login)** ($($_.state)): $(Limit-Text -text $_.body -maxLength 200)" }) -join "`n`n" + } else { "No reviews" } + + $closingIssuesInfo = if ($pr.ClosingIssues -and $pr.ClosingIssues.Count -gt 0) { + $issuesList = $pr.ClosingIssues | ForEach-Object { + $issueComments = if ($_.Comments -and $_.Comments.Count -gt 0) { + "Comments: $($_.Comments.Count) comments available" + } else { + "No comments" + } + @" +**Issue #$($_.Number)**: $($_.Title) +$($_.Url) +$(if ($_.Body) { "$(Limit-Text -text $_.Body -maxLength 500)" }) +$issueComments +"@ + } + "`n## Related Issues`n" + ($issuesList -join "`n`n") + } else { "" } + + # Fetch issue template and examples (required for quality) + $issueTemplate = Get-IssueTemplate + $exampleIssues = Get-ExampleBreakingChangeIssues + + # Use version information collected in Step 1 + $versionInfo = $pr.VersionInfo + + # Create LLM prompt + $systemPrompt = @" +You are an expert .NET developer and technical writer. Create high-quality breaking change documentation for Microsoft .NET. + +**CRITICAL: Generate clean markdown content following the structure shown in the examples. Do NOT output YAML or fill in template forms. The template is provided only as a reference for sections and values.** + +Focus on: +1. Clear, specific descriptions of what changed +2. Concrete before/after behavior with examples +3. Actionable migration guidance for developers +4. Appropriate breaking change categorization +5. Professional tone for official Microsoft documentation + +Use the provided template as a reference for structure only, and follow the examples for the actual output format. +Pay special attention to the version information provided to ensure accuracy. +"@ + + $templateSection = @" +## Issue Template Structure Reference +The following GitHub issue template shows the required sections and possible values for breaking change documentation. +**IMPORTANT: This is NOT the expected output format. Use this only as a reference for what sections to include and what values are available. Generate clean markdown content, not YAML.** + +```yaml +$issueTemplate +``` +"@ + + $exampleSection = @" + +## Examples of Good Breaking Change Documentation +Here are recent examples of well-written breaking change documentation: + +$exampleIssues +"@ + + $versionSection = @" + +## Version Information +**Last GitHub tag before this PR was merged**: $($versionInfo.LastTagBeforeMerge) +**First GitHub tag that includes this change**: $($versionInfo.FirstTagWithChange) +**Estimated .NET version for this change**: $($versionInfo.EstimatedVersion) + +Use this version information to accurately determine when this breaking change was introduced. +"@ + + $userPrompt = @" +Analyze this .NET runtime pull request and create breaking change documentation. + +## PR Information +**Number**: #$($pr.Number) +**Title**: $($pr.Title) +**URL**: $($pr.Url) +**Author**: $($pr.Author) +**Base Branch**: $($pr.BaseRef) +**Merged At**: $($pr.MergedAt) +**Feature Areas**: $($pr.FeatureAreas) + +$(if ($pr.MergeCommit.Url) { "**Merge Commit**: $($pr.MergeCommit.Url)" }) + +**PR Body**: +$(Limit-Text -text $pr.Body -maxLength 1500) + +## Changed Files +$($pr.ChangedFiles -join "`n") + +## Comments +$comments + +## Reviews +$reviews +$closingIssuesInfo + +$templateSection +$exampleSection +$versionSection + +**OUTPUT FORMAT: Generate a complete breaking change issue in clean markdown format following the structure and style of the examples above. Do NOT output YAML template syntax.** + +Generate the complete issue following the template structure and using the examples as guidance for quality and style. +"@ + + # Call LLM API + Write-Host " ๐Ÿค– Generating content..." -ForegroundColor Gray + $llmResponse = Invoke-LlmApi -SystemPrompt $systemPrompt -Prompt $userPrompt + + if (-not $llmResponse) { + Write-Error "Failed to get LLM response for PR #$($pr.Number)" + continue + } + + # Parse response + if ($llmResponse -match '(?s)\*\*Issue Title\*\*:\s*(.+?)\s*\*\*Issue Body\*\*:\s*(.+)$') { + $issueTitle = $matches[1].Trim() + $issueBody = $matches[2].Trim() + } else { + $issueTitle = "[Breaking change]: $($prData.title -replace '^\[.*?\]\s*', '')" + $issueBody = $llmResponse + } + + # Save issue draft + $issueFile = ".\issue-drafts\issue_pr_$($pr.Number).md" + @" +# $issueTitle + +$issueBody + +--- +*Generated by Breaking Change Documentation Tool* +*PR: $($pr.Url)* +*Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')* +"@ | Out-File $issueFile -Encoding UTF8 + + Write-Host " ๐Ÿ“„ Draft saved: $issueFile" -ForegroundColor Gray + + # Add comment with link to create issue using GitHub's issue creation URL + $commentFile = ".\comment-drafts\comment_pr_$($pr.Number).md" + + # URL encode the title and full issue body + $encodedTitle = Get-UrlEncodedText -text $issueTitle + $encodedBody = Get-UrlEncodedText -text $issueBody + $encodedLabels = Get-UrlEncodedText -text ($Config.IssueTemplate.Labels -join ",") + + # Create GitHub issue creation URL with full content and labels + $createIssueUrl = "https://github.com/$($Config.DocsRepo)/issues/new?title=$encodedTitle&body=$encodedBody&labels=$encodedLabels" + + $commentBody = @" +## ๐Ÿ“‹ Breaking Change Documentation Required + +[Create a breaking change issue with AI-generated content]($createIssueUrl) + +*Generated by Breaking Change Documentation Tool - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')* +"@ + + # Save comment draft + $commentBody | Out-File $commentFile -Encoding UTF8 + Write-Host " ๐Ÿ’ฌ Comment draft saved: $commentFile" -ForegroundColor Gray + + # Handle different action modes + if (-not $executeActions) { + # Draft only mode, just log the commands that could be run. + Write-Host " ๏ฟฝ To create an issue use command:" -ForegroundColor Yellow + Write-Host " gh issue create --repo $($Config.DocsRepo) --title `"$issueTitle`" --body `"...[content truncated]...`" --label `"$($Config.IssueTemplate.Labels -join ',')`" --assignee `"$($Config.IssueTemplate.Assignee)`"" -ForegroundColor Gray + + Write-Host " ๏ฟฝ To add a comment use command:" -ForegroundColor Yellow + Write-Host " gh pr comment $($pr.Number) --repo $($Config.SourceRepo) --body-file `"$commentFile`"" -ForegroundColor Gray + + } elseif ($CreateIssues) { + # Create GitHub issue directly + try { + Write-Host " ๐Ÿš€ Creating GitHub issue..." -ForegroundColor Gray + + $result = gh issue create --repo $Config.DocsRepo --title $issueTitle --body $issueBody --label ($Config.IssueTemplate.Labels -join ",") --assignee $Config.IssueTemplate.Assignee + + if ($LASTEXITCODE -eq 0) { + Write-Host " โœ… Issue created: $result" -ForegroundColor Green + + # Add comment to original PR + $prComment = "Breaking change documentation issue created: $result" + gh pr comment $pr.Number --repo $Config.SourceRepo --body $prComment | Out-Null + } else { + Write-Error "Failed to create issue for PR #$($pr.Number)" + } + } + catch { + Write-Error "Error creating issue for PR #$($pr.Number): $($_.Exception.Message)" + } + } elseif ($Comment) { + # Add a comment to the PR to allow the author to create the issue + try { + Write-Host " ๐Ÿ’ฌ Adding comment to PR..." -ForegroundColor Gray + + $result = gh pr comment $pr.Number --repo $Config.SourceRepo --body-file $commentFile + + if ($LASTEXITCODE -eq 0) { + Write-Host " โœ… Comment added to PR #$($pr.Number)" -ForegroundColor Green + } else { + Write-Error "Failed to add comment to PR #$($pr.Number)" + } + } + catch { + Write-Error "Error adding comment to PR #$($pr.Number): $($_.Exception.Message)" + } + } + + Start-Sleep -Seconds $Config.RateLimiting.DelayBetweenIssues +} + +# Final summary +Write-Host "`n๐ŸŽฏ Workflow completed!" -ForegroundColor Green + +if (-not $executeActions -and -not $CollectOnly) { + Write-Host " ๐Ÿ“ Analysis completed - drafts generated without making changes" -ForegroundColor Yellow + Write-Host " ๐Ÿ’ก Use -CreateIssues or -Comment to execute actions on GitHub" +} elseif ($CreateIssues) { + Write-Host " โœ… Issues created in: $($Config.DocsRepo)" -ForegroundColor Green + Write-Host " ๐Ÿ“ง Email issue links to: $($Config.IssueTemplate.NotificationEmail)" -ForegroundColor Yellow +} elseif ($Comment) { + Write-Host " ๐Ÿ’ฌ Comments added to PRs with create issue links" -ForegroundColor Green + Write-Host " ๐Ÿ“ Issue drafts saved in: .\issue-drafts\" + Write-Host " ๐Ÿ”— Click the links in PR comments to create issues when ready" +} else { + Write-Host " ๐Ÿ“ Issue drafts saved in: .\issue-drafts\" +} + +Write-Host "`n๐Ÿ“ Output files:" +Write-Host " ๐Ÿ“Š Summary: .\data\summary_report.md" +Write-Host " ๐Ÿ“‹ Combined: .\data\combined_analysis.json" +Write-Host " ๐Ÿ“„ Individual: .\data\pr_*.json" +Write-Host " ๐Ÿ“ Drafts: .\issue-drafts\*.md" diff --git a/eng/breakingChanges/config.ps1 b/eng/breakingChanges/config.ps1 new file mode 100644 index 00000000000000..376aee9effd76e --- /dev/null +++ b/eng/breakingChanges/config.ps1 @@ -0,0 +1,32 @@ +# Configuration for Breaking Change Documentation Workflow + +$Config = @{ + # LLM Settings + LlmProvider = "github-models" # openai, anthropic, azure-openai, github-models + LlmModel = "openai/gpt-4o" # For GitHub Models: openai/gpt-4o, openai/gpt-4o-mini, microsoft/phi-4, etc. + LlmApiKey = $null # Uses environment variables by default (not needed for github-models) + LlmBaseUrl = $null # For Azure OpenAI: https://your-resource.openai.azure.com + + # GitHub Settings + SourceRepo = "dotnet/runtime" + DocsRepo = "dotnet/docs" + LocalRepoPath = "c:\src\dotnet\runtime" # Path to local clone of the runtime repository + TargetLabel = "needs-breaking-change-doc-created" + IssueTemplatePath = ".github/ISSUE_TEMPLATE/02-breaking-change.yml" # Path to issue template in DocsRepo + + # Analysis Settings + MaxPRs = 100 + + # Output Settings + IssueTemplate = @{ + Labels = @("breaking-change", "Pri1", "doc-idea") + Assignee = "gewarren" + NotificationEmail = "dotnetbcn@microsoft.com" + } + + # Rate Limiting + RateLimiting = @{ + DelayBetweenCalls = 2 # seconds + DelayBetweenIssues = 3 # seconds + } +} From b72e3127a9a9337f4f073d7d46ce2dfc9013e15c Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 12:18:49 -0700 Subject: [PATCH 02/28] Adjust breaking change script for runtime repo --- eng/breakingChanges/README.md | 24 +-- eng/breakingChanges/breaking-change-doc.ps1 | 227 ++++++++++---------- eng/breakingChanges/config.ps1 | 1 - 3 files changed, 120 insertions(+), 132 deletions(-) diff --git a/eng/breakingChanges/README.md b/eng/breakingChanges/README.md index dbc6a09d34bdda..b189135ef897c5 100644 --- a/eng/breakingChanges/README.md +++ b/eng/breakingChanges/README.md @@ -17,9 +17,8 @@ This script automates the creation of high-quality breaking change documentation 1. **Install Prerequisites:** - GitHub CLI: `gh auth login` - - **Local Repository**: Clone dotnet/runtime locally for accurate version detection - Choose LLM provider: - - **GitHub Models** (recommended): `gh extension install github/gh-models` + - **GitHub Models** (recommended): `gh extension install github/gh-models` - **OpenAI**: Set `$env:OPENAI_API_KEY = "your-key"` - **Others**: See configuration section below @@ -27,7 +26,6 @@ This script automates the creation of high-quality breaking change documentation ```powershell # Edit config.ps1 to set: # - LlmProvider = "github-models" (or other provider) - # - LocalRepoPath = "path\to\your\dotnet\runtime\clone" ``` 3. **Run the workflow:** @@ -39,10 +37,10 @@ This script automates the creation of high-quality breaking change documentation ```powershell # Default: Add comments with create issue links (recommended) .\breaking-change-doc.ps1 - + # Create issues directly .\breaking-change-doc.ps1 -CreateIssues - + # Just collect data .\breaking-change-doc.ps1 -CollectOnly ``` @@ -75,7 +73,6 @@ This script automates the creation of high-quality breaking change documentation ## Configuration Edit `config.ps1` to customize: -- **LocalRepoPath**: Path to your local clone of dotnet/runtime (required for accurate version detection) - **LLM provider**: GitHub Models, OpenAI, Anthropic, Azure OpenAI - **Search parameters**: Date ranges, labels, excluded milestones - **Output settings**: Labels, assignees, notification emails @@ -108,9 +105,9 @@ $env:AZURE_OPENAI_API_KEY = "your-key" ## Output -- **Data Collection**: `.\data\summary_report.md`, `.\data\pr_*.json` -- **Issue Drafts**: `.\issue-drafts\*.md` -- **Comment Drafts**: `.\comment-drafts\*.md` +- **Data Collection**: `(repoRoot)\artifacts\docs\breakingChanges\data\summary_report.md`, `(repoRoot)\artifacts\docs\breakingChanges\data\pr_*.json` +- **Issue Drafts**: `(repoRoot)\artifacts\docs\breakingChanges\issue-drafts\*.md` +- **Comment Drafts**: `(repoRoot)\artifacts\docs\breakingChanges\comment-drafts\*.md` - **GitHub Issues**: Created automatically (unless -DryRun) ## Workflow Steps @@ -123,15 +120,13 @@ $env:AZURE_OPENAI_API_KEY = "your-key" ## Version Detection -The script automatically determines accurate .NET version information using your local git repository: -- **Fast and reliable**: Uses `git describe` commands on local repository clone +The script automatically determines accurate .NET version information using the local git repository: +- **Fast and reliable**: Uses `git describe` commands on the repository - **No API rate limits**: Avoids GitHub API calls for version detection - **Accurate timing**: Analyzes actual commit ancestry and tag relationships - **Merge commit analysis**: For merged PRs, finds the exact merge commit and determines version context - **Branch-aware**: For unmerged PRs, uses target branch information -**Requirements**: Local clone of dotnet/runtime repository configured in `LocalRepoPath` - ## Manual Review AI generates 90%+ ready documentation, but review for: @@ -149,7 +144,6 @@ Between runs: ## Troubleshooting **GitHub CLI**: `gh auth status` and `gh auth login` -**Local Repository**: Ensure `LocalRepoPath` in config.ps1 points to a valid dotnet/runtime clone **API Keys**: Verify environment variables are set for non-GitHub Models providers **Rate Limits**: Use -DryRun for testing, script includes delays -**Git Operations**: Ensure git is in PATH and repository is up to date (`git fetch --tags`) \ No newline at end of file +**Git Operations**: Ensure git is in PATH and repository is up to date (`git fetch --tags`) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index a0eac3725d5e24..7b6ba5a0c67612 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -20,7 +20,7 @@ Breaking Change Documentation Workflow DESCRIPTION: Automates the creation of high-quality breaking change documentation for .NET runtime PRs using an LLM to analyze and author docs. - + DEFAULT BEHAVIOR: Analyzes PRs and generates documentation drafts without making any changes to GitHub. Use -CreateIssues or -Comment to execute actions. @@ -28,7 +28,7 @@ USAGE: .\breaking-change-doc.ps1 [parameters] PARAMETERS: - -CollectOnly Only collect PR data, don't create documentation + -CollectOnly Only collect PR data, don't create documentation -CreateIssues Create GitHub issues directly -Comment Add comments with links to create issues -CleanStart Clean previous data before starting @@ -46,10 +46,10 @@ EXAMPLES: QUERY EXAMPLES: # PRs merged after specific date, excluding milestone: "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" - + # All PRs with the target label: "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged" - + # PRs from specific author: "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged author:username" @@ -130,27 +130,31 @@ if ($llmProvider -eq "github-models") { Write-Host "โœ… LLM API key found ($llmProvider)" -ForegroundColor Green } -# Check local repository path -if (-not (Test-Path $Config.LocalRepoPath)) { - Write-Warning "โš ๏ธ Local repository path not found: $($Config.LocalRepoPath)" - Write-Host " Version detection will be limited. Consider cloning the repository or updating the path in config.ps1" -} else { - Write-Host "โœ… Local repository found: $($Config.LocalRepoPath)" -ForegroundColor Green -} +# Determine repository root and set up output paths +$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent (Split-Path -Parent $scriptPath) +$outputRoot = Join-Path $repoRoot "artifacts\docs\breakingChanges" + +# Create output directories +$dataDir = Join-Path $outputRoot "data" +$issueDraftsDir = Join-Path $outputRoot "issue-drafts" +$commentDraftsDir = Join-Path $outputRoot "comment-drafts" + +New-Item -ItemType Directory -Path $dataDir -Force | Out-Null +New-Item -ItemType Directory -Path $issueDraftsDir -Force | Out-Null +New-Item -ItemType Directory -Path $commentDraftsDir -Force | Out-Null # Clean start if requested if ($CleanStart) { Write-Host "`n๐Ÿงน Cleaning previous data..." -ForegroundColor Yellow - if (Test-Path ".\data") { Remove-Item ".\data" -Recurse -Force } - if (Test-Path ".\issue-drafts") { Remove-Item ".\issue-drafts" -Recurse -Force } - if (Test-Path ".\comment-drafts") { Remove-Item ".\comment-drafts" -Recurse -Force } + if (Test-Path $outputRoot) { Remove-Item $outputRoot -Recurse -Force } Write-Host "โœ… Cleanup completed" -ForegroundColor Green } # Create output directories -New-Item -ItemType Directory -Path ".\data" -Force | Out-Null -New-Item -ItemType Directory -Path ".\issue-drafts" -Force | Out-Null -New-Item -ItemType Directory -Path ".\comment-drafts" -Force | Out-Null +New-Item -ItemType Directory -Path $dataDir -Force | Out-Null +New-Item -ItemType Directory -Path $issueDraftsDir -Force | Out-Null +New-Item -ItemType Directory -Path $commentDraftsDir -Force | Out-Null # Validate parameters if (-not $PrNumber -and -not $Query) { @@ -183,8 +187,8 @@ if (($CreateIssues -and $Comment) -or ($CollectOnly -and ($CreateIssues -or $Com exit 1 } -$actionMode = if ($CollectOnly) { "Collect Only" } - elseif ($CreateIssues) { "Create Issues" } +$actionMode = if ($CollectOnly) { "Collect Only" } + elseif ($CreateIssues) { "Create Issues" } elseif ($Comment) { "Add Comments" } else { "Analysis Only" } @@ -196,27 +200,27 @@ if (-not $executeActions -and -not $CollectOnly) { # Function to safely truncate text function Limit-Text { param([string]$text, [int]$maxLength = 2000) - + if (-not $text -or $text.Length -le $maxLength) { return $text } - + $truncated = $text.Substring(0, $maxLength) $lastPeriod = $truncated.LastIndexOf('.') $lastNewline = $truncated.LastIndexOf("`n") - + $cutPoint = [Math]::Max($lastPeriod, $lastNewline) if ($cutPoint -gt ($maxLength * 0.8)) { $truncated = $truncated.Substring(0, $cutPoint + 1) } - + return $truncated + "`n`n[Content truncated for length]" } # Function to URL encode text for GitHub issue URLs function Get-UrlEncodedText { param([string]$text) - + # Basic URL encoding for GitHub issue URLs $encoded = $text -replace '\r\n', '%0A' -replace '\n', '%0A' -replace '\r', '%0A' $encoded = $encoded -replace ' ', '%20' -replace '#', '%23' -replace '&', '%26' @@ -224,7 +228,7 @@ function Get-UrlEncodedText { $encoded = $encoded -replace ':', '%3A' -replace ';', '%3B' -replace '\?', '%3F' -replace '=', '%3D' $encoded = $encoded -replace '@', '%40' -replace '\+', '%2B' -replace '\$', '%24' $encoded = $encoded -replace '"', '%22' -replace "'", '%27' -replace '<', '%3C' -replace '>', '%3E' - + return $encoded } @@ -248,13 +252,13 @@ function Get-ExampleBreakingChangeIssues { Write-Host " ๐Ÿ“š Fetching example breaking change issues..." -ForegroundColor DarkGray $exampleIssuesJson = gh issue list --repo $Config.DocsRepo --label "breaking-change" --limit 3 --json number,title,body,url $exampleIssues = $exampleIssuesJson | ConvertFrom-Json - + if ($exampleIssues.Count -eq 0) { Write-Error "โŒ No example breaking change issues found in $($Config.DocsRepo) with label 'breaking-change'" Write-Error " Examples are required for high-quality documentation generation. Please check repository and label." exit 1 } - + $examples = @() foreach ($issue in $exampleIssues) { $examples += @" @@ -275,11 +279,11 @@ Body: $(Limit-Text -text $issue.body -maxLength 800) # Function to find the closest tag by commit distance function Find-ClosestTagByDistance { param([string]$targetCommit, [int]$maxTags = 10) - + $recentTags = git tag --sort=-version:refname 2>$null | Select-Object -First $maxTags $closestTag = $null $minDistance = [int]::MaxValue - + foreach ($tag in $recentTags) { # Check if this tag contains the target commit (skip if it does for merged PRs) if ($targetCommit -match '^[a-f0-9]{40}$') { @@ -290,7 +294,7 @@ function Find-ClosestTagByDistance { continue } } - + # Calculate commit distance between tag and target $distance = git rev-list --count "$tag..$targetCommit" 2>$null if ($LASTEXITCODE -eq 0 -and $distance -match '^\d+$') { @@ -301,41 +305,32 @@ function Find-ClosestTagByDistance { } } } - + return $closestTag } # Function to get version information using local git repository function Get-VersionInfo { param([string]$prNumber, [string]$mergedAt, [string]$baseRef = "main") - + try { - if (-not (Test-Path $Config.LocalRepoPath)) { - Write-Warning "Local repository path not found: $($Config.LocalRepoPath)" - return @{ - LastTagBeforeMerge = "Unknown - no local repo" - FirstTagWithChange = "Not yet released" - EstimatedVersion = "Next release" - } - } - - # Change to the repository directory - Push-Location $Config.LocalRepoPath - + # Change to the repository directory (we're already in the repo) + Push-Location $repoRoot + try { # Ensure we have latest info git fetch --tags 2>$null | Out-Null - + # For merged PRs, try to find the merge commit and get version info if ($prNumber -and $mergedAt) { # Get the merge commit for this PR $mergeCommit = gh pr view $prNumber --repo $Config.SourceRepo --json mergeCommit --jq '.mergeCommit.oid' 2>$null - + if ($mergeCommit) { # Find the closest tag by commit distance (not time) $closestTag = Find-ClosestTagByDistance -targetCommit $mergeCommit $lastTagBefore = if ($closestTag) { $closestTag } else { "Unknown" } - + # Get the first tag that includes this commit $firstTagWith = git describe --tags --contains $mergeCommit 2>$null if ($firstTagWith -and $firstTagWith -match '^([^~^]+)') { @@ -362,10 +357,10 @@ function Get-VersionInfo { if ($baseRef -eq "main") { # Get the HEAD of main branch and find closest tag $mainHead = git rev-parse "origin/$baseRef" 2>$null - + if ($mainHead) { $closestTag = Find-ClosestTagByDistance -targetCommit $mainHead - $lastTagBefore = if ($closestTag) { $closestTag } else { + $lastTagBefore = if ($closestTag) { $closestTag } else { # Fallback to the most recent tag overall git tag --sort=-version:refname | Select-Object -First 1 2>$null } @@ -378,11 +373,11 @@ function Get-VersionInfo { } $firstTagWith = "Not yet released" } - + # Clean up tag names and estimate version $lastTagBefore = if ($lastTagBefore) { $lastTagBefore.Trim() } else { "Unknown" } $firstTagWith = if ($firstTagWith -and $firstTagWith -ne "Not yet released") { $firstTagWith.Trim() } else { "Not yet released" } - + # Estimate version from the most recent tag $estimatedVersion = "Next release" if ($firstTagWith -ne "Not yet released") { @@ -419,7 +414,7 @@ function Get-VersionInfo { } } } - + return @{ LastTagBeforeMerge = $lastTagBefore FirstTagWithChange = $firstTagWith @@ -443,23 +438,23 @@ function Get-VersionInfo { # Function to call LLM API function Invoke-LlmApi { param([string]$Prompt, [string]$SystemPrompt = "", [int]$MaxTokens = 3000) - + switch ($Config.LlmProvider) { "github-models" { # Use GitHub CLI with models extension try { $ghArgs = @( - "models", "run", - $Config.LlmModel, + "models", "run", + $Config.LlmModel, $Prompt, "--max-tokens", $MaxTokens.ToString(), "--temperature", "0.1" ) - + if ($SystemPrompt) { $ghArgs += @("--system-prompt", $SystemPrompt) } - + $response = & gh @ghArgs return $response -join "`n" } @@ -473,16 +468,16 @@ function Invoke-LlmApi { $headers = @{ 'Content-Type' = 'application/json' } $body = @{} $endpoint = "" - + switch ($Config.LlmProvider) { "openai" { $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/chat/completions" } else { "https://api.openai.com/v1/chat/completions" } $headers['Authorization'] = "Bearer $apiKey" - + $messages = @() if ($SystemPrompt) { $messages += @{ role = "system"; content = $SystemPrompt } } $messages += @{ role = "user"; content = $Prompt } - + $body = @{ model = $Config.LlmModel messages = $messages @@ -494,9 +489,9 @@ function Invoke-LlmApi { $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/messages" } else { "https://api.anthropic.com/v1/messages" } $headers['x-api-key'] = $apiKey $headers['anthropic-version'] = "2023-06-01" - + $fullPrompt = if ($SystemPrompt) { "$SystemPrompt`n`nHuman: $Prompt`n`nAssistant:" } else { "Human: $Prompt`n`nAssistant:" } - + $body = @{ model = $Config.LlmModel max_tokens = $MaxTokens @@ -505,11 +500,11 @@ function Invoke-LlmApi { } } } - + try { $requestJson = $body | ConvertTo-Json -Depth 10 $response = Invoke-RestMethod -Uri $endpoint -Method POST -Headers $headers -Body $requestJson - + switch ($Config.LlmProvider) { "openai" { return $response.choices[0].message.content } "anthropic" { return $response.content[0].text } @@ -532,14 +527,14 @@ if ($PrNumber) { try { $prJson = gh pr view $PrNumber --repo $Config.SourceRepo --json number,title,url,baseRefName,closedAt,mergeCommit,labels,files,state $prData = $prJson | ConvertFrom-Json - + # Verify PR has the target label $hasTargetLabel = $prData.labels | Where-Object { $_.name -eq $Config.TargetLabel } if (-not $hasTargetLabel) { Write-Error "PR #$PrNumber does not have the '$($Config.TargetLabel)' label" exit 1 } - + $prs = @($prData) Write-Host " Found PR #$($prs[0].number): $($prs[0].title)" } catch { @@ -549,7 +544,7 @@ if ($PrNumber) { } else { # Query mode - fetch all PRs matching criteria Write-Host " Mode: Query - $Query" - + try { $prsJson = gh pr list --repo $Config.SourceRepo --search $Query --limit $Config.MaxPRs --json number,title,url,baseRefName,closedAt,mergeCommit,labels,files $prs = $prsJson | ConvertFrom-Json @@ -557,7 +552,7 @@ if ($PrNumber) { Write-Error "Failed to fetch PRs: $($_.Exception.Message)" exit 1 } - + Write-Host " Found $($prs.Count) PRs to collect data for" } @@ -565,7 +560,7 @@ if ($PrNumber) { $analysisData = @() foreach ($pr in $prs) { Write-Host " Collecting data for PR #$($pr.number): $($pr.title)" -ForegroundColor Gray - + # Get comprehensive PR details including comments and reviews try { $prDetails = gh pr view $pr.number --repo $Config.SourceRepo --json body,title,comments,reviews,closingIssuesReferences @@ -574,7 +569,7 @@ foreach ($pr in $prs) { Write-Warning "Could not fetch detailed PR data for #$($pr.number)" continue } - + # Get closing issues with full details and comments $closingIssues = @() foreach ($issueRef in $prDetailData.closingIssuesReferences) { @@ -600,18 +595,18 @@ foreach ($pr in $prs) { } } } - + # Create merge commit URL $mergeCommitUrl = if ($pr.mergeCommit.oid) { "https://github.com/$($Config.SourceRepo)/commit/$($pr.mergeCommit.oid)" } else { $null } - + # Get version information using local git repository Write-Host " ๐Ÿท๏ธ Getting version info..." -ForegroundColor DarkGray $versionInfo = Get-VersionInfo -prNumber $pr.number -mergedAt $pr.closedAt -baseRef $pr.baseRefName - + # Check for existing docs issues $hasDocsIssue = $false try { @@ -621,17 +616,17 @@ foreach ($pr in $prs) { } catch { Write-Warning "Could not check for existing docs issues for PR #$($pr.number)" } - + # Get feature areas from area- labels first, then fall back to file paths $featureAreas = @() - + # First try to get feature areas from area- labels foreach ($label in $pr.labels) { if ($label.name -match "^area-(.+)$") { $featureAreas += $matches[1] } } - + # If no area labels found, fall back to file path analysis if ($featureAreas.Count -eq 0) { foreach ($file in $pr.files) { @@ -642,10 +637,10 @@ foreach ($pr in $prs) { } } } - + $featureAreas = $featureAreas | Select-Object -Unique if ($featureAreas.Count -eq 0) { $featureAreas = @("Runtime") } - + $analysisData += @{ Number = $pr.number Title = $pr.title @@ -669,17 +664,17 @@ foreach ($pr in $prs) { Labels = $pr.labels | ForEach-Object { $_.name } VersionInfo = $versionInfo } - + # Save individual PR data file - $prFileName = ".\data\pr_$($pr.number).json" + $prFileName = Join-Path $dataDir "pr_$($pr.number).json" $analysisData[-1] | ConvertTo-Json -Depth 10 | Out-File $prFileName -Encoding UTF8 Write-Host " ๐Ÿ’พ Saved: $prFileName" -ForegroundColor DarkGray - + Start-Sleep -Seconds $Config.RateLimiting.DelayBetweenCalls } # Save combined data with comprehensive details (for overview) -$analysisData | ConvertTo-Json -Depth 10 | Out-File ".\data\combined.json" -Encoding UTF8 +$analysisData | ConvertTo-Json -Depth 10 | Out-File (Join-Path $dataDir "combined.json") -Encoding UTF8 # Create summary report $queryInfo = if ($PrNumber) { @@ -713,12 +708,12 @@ $($analysisData | Where-Object HasDocsIssue | ForEach-Object { } | Out-String) "@ -$summaryReport | Out-File ".\data\summary_report.md" -Encoding UTF8 +$summaryReport | Out-File (Join-Path $dataDir "summary_report.md") -Encoding UTF8 Write-Host "โœ… Data collection completed completed" -ForegroundColor Green -Write-Host " ๐Ÿ“Š Summary: .\data\summary_report.md" -Write-Host " ๐Ÿ“‹ Combined: .\data\combined.json" -Write-Host " ๐Ÿ“„ Individual: .\data\pr_*.json ($($analysisData.Count) files)" +Write-Host " ๐Ÿ“Š Summary: $(Join-Path $dataDir "summary_report.md")" +Write-Host " ๐Ÿ“‹ Combined: $(Join-Path $dataDir "combined.json")" +Write-Host " ๐Ÿ“„ Individual: $(Join-Path $dataDir "pr_*.json") ($($analysisData.Count) files)" if ($CollectOnly) { exit 0 @@ -746,7 +741,7 @@ if ($PrNumber) { foreach ($pr in $prsNeedingDocs) { Write-Host " ๐Ÿ” Processing PR #$($pr.Number): $($pr.Title)" -ForegroundColor Cyan - + # Get detailed PR data try { $prDetails = gh pr view $pr.Number --repo $Config.SourceRepo --json body,title,comments,reviews,closingIssuesReferences,files @@ -755,16 +750,16 @@ foreach ($pr in $prsNeedingDocs) { Write-Error "Failed to get PR details for #$($pr.Number)" continue } - + # Prepare data for LLM $comments = if ($pr.Comments -and $pr.Comments.Count -gt 0) { ($pr.Comments | ForEach-Object { "**@$($_.author.login)**: $(Limit-Text -text $_.body -maxLength 300)" }) -join "`n`n" } else { "No comments" } - + $reviews = if ($pr.Reviews -and $pr.Reviews.Count -gt 0) { ($pr.Reviews | ForEach-Object { "**@$($_.author.login)** ($($_.state)): $(Limit-Text -text $_.body -maxLength 200)" }) -join "`n`n" } else { "No reviews" } - + $closingIssuesInfo = if ($pr.ClosingIssues -and $pr.ClosingIssues.Count -gt 0) { $issuesList = $pr.ClosingIssues | ForEach-Object { $issueComments = if ($_.Comments -and $_.Comments.Count -gt 0) { @@ -781,17 +776,17 @@ $issueComments } "`n## Related Issues`n" + ($issuesList -join "`n`n") } else { "" } - + # Fetch issue template and examples (required for quality) $issueTemplate = Get-IssueTemplate $exampleIssues = Get-ExampleBreakingChangeIssues - + # Use version information collected in Step 1 $versionInfo = $pr.VersionInfo - + # Create LLM prompt $systemPrompt = @" -You are an expert .NET developer and technical writer. Create high-quality breaking change documentation for Microsoft .NET. +You are an expert .NET developer and technical writer. Create high-quality breaking change documentation for Microsoft .NET. **CRITICAL: Generate clean markdown content following the structure shown in the examples. Do NOT output YAML or fill in template forms. The template is provided only as a reference for sections and values.** @@ -808,7 +803,7 @@ Pay special attention to the version information provided to ensure accuracy. $templateSection = @" ## Issue Template Structure Reference -The following GitHub issue template shows the required sections and possible values for breaking change documentation. +The following GitHub issue template shows the required sections and possible values for breaking change documentation. **IMPORTANT: This is NOT the expected output format. Use this only as a reference for what sections to include and what values are available. Generate clean markdown content, not YAML.** ```yaml @@ -873,12 +868,12 @@ Generate the complete issue following the template structure and using the examp # Call LLM API Write-Host " ๐Ÿค– Generating content..." -ForegroundColor Gray $llmResponse = Invoke-LlmApi -SystemPrompt $systemPrompt -Prompt $userPrompt - + if (-not $llmResponse) { Write-Error "Failed to get LLM response for PR #$($pr.Number)" continue } - + # Parse response if ($llmResponse -match '(?s)\*\*Issue Title\*\*:\s*(.+?)\s*\*\*Issue Body\*\*:\s*(.+)$') { $issueTitle = $matches[1].Trim() @@ -887,9 +882,9 @@ Generate the complete issue following the template structure and using the examp $issueTitle = "[Breaking change]: $($prData.title -replace '^\[.*?\]\s*', '')" $issueBody = $llmResponse } - + # Save issue draft - $issueFile = ".\issue-drafts\issue_pr_$($pr.Number).md" + $issueFile = Join-Path $issueDraftsDir "issue_pr_$($pr.Number).md" @" # $issueTitle @@ -902,18 +897,18 @@ $issueBody "@ | Out-File $issueFile -Encoding UTF8 Write-Host " ๐Ÿ“„ Draft saved: $issueFile" -ForegroundColor Gray - + # Add comment with link to create issue using GitHub's issue creation URL - $commentFile = ".\comment-drafts\comment_pr_$($pr.Number).md" - + $commentFile = Join-Path $commentDraftsDir "comment_pr_$($pr.Number).md" + # URL encode the title and full issue body $encodedTitle = Get-UrlEncodedText -text $issueTitle $encodedBody = Get-UrlEncodedText -text $issueBody $encodedLabels = Get-UrlEncodedText -text ($Config.IssueTemplate.Labels -join ",") - + # Create GitHub issue creation URL with full content and labels $createIssueUrl = "https://github.com/$($Config.DocsRepo)/issues/new?title=$encodedTitle&body=$encodedBody&labels=$encodedLabels" - + $commentBody = @" ## ๐Ÿ“‹ Breaking Change Documentation Required @@ -939,12 +934,12 @@ $issueBody # Create GitHub issue directly try { Write-Host " ๐Ÿš€ Creating GitHub issue..." -ForegroundColor Gray - + $result = gh issue create --repo $Config.DocsRepo --title $issueTitle --body $issueBody --label ($Config.IssueTemplate.Labels -join ",") --assignee $Config.IssueTemplate.Assignee - + if ($LASTEXITCODE -eq 0) { Write-Host " โœ… Issue created: $result" -ForegroundColor Green - + # Add comment to original PR $prComment = "Breaking change documentation issue created: $result" gh pr comment $pr.Number --repo $Config.SourceRepo --body $prComment | Out-Null @@ -959,9 +954,9 @@ $issueBody # Add a comment to the PR to allow the author to create the issue try { Write-Host " ๐Ÿ’ฌ Adding comment to PR..." -ForegroundColor Gray - + $result = gh pr comment $pr.Number --repo $Config.SourceRepo --body-file $commentFile - + if ($LASTEXITCODE -eq 0) { Write-Host " โœ… Comment added to PR #$($pr.Number)" -ForegroundColor Green } else { @@ -972,7 +967,7 @@ $issueBody Write-Error "Error adding comment to PR #$($pr.Number): $($_.Exception.Message)" } } - + Start-Sleep -Seconds $Config.RateLimiting.DelayBetweenIssues } @@ -987,14 +982,14 @@ if (-not $executeActions -and -not $CollectOnly) { Write-Host " ๐Ÿ“ง Email issue links to: $($Config.IssueTemplate.NotificationEmail)" -ForegroundColor Yellow } elseif ($Comment) { Write-Host " ๐Ÿ’ฌ Comments added to PRs with create issue links" -ForegroundColor Green - Write-Host " ๐Ÿ“ Issue drafts saved in: .\issue-drafts\" + Write-Host " ๐Ÿ“ Issue drafts saved in: $issueDraftsDir" Write-Host " ๐Ÿ”— Click the links in PR comments to create issues when ready" } else { - Write-Host " ๐Ÿ“ Issue drafts saved in: .\issue-drafts\" + Write-Host " ๐Ÿ“ Issue drafts saved in: $issueDraftsDir" } Write-Host "`n๐Ÿ“ Output files:" -Write-Host " ๐Ÿ“Š Summary: .\data\summary_report.md" -Write-Host " ๐Ÿ“‹ Combined: .\data\combined_analysis.json" -Write-Host " ๐Ÿ“„ Individual: .\data\pr_*.json" -Write-Host " ๐Ÿ“ Drafts: .\issue-drafts\*.md" +Write-Host " ๐Ÿ“Š Summary: $(Join-Path $dataDir "summary_report.md")" +Write-Host " ๐Ÿ“‹ Combined: $(Join-Path $dataDir "combined.json")" +Write-Host " ๐Ÿ“„ Individual: $(Join-Path $dataDir "pr_*.json")" +Write-Host " ๐Ÿ“ Drafts: $(Join-Path $issueDraftsDir "*.md")" diff --git a/eng/breakingChanges/config.ps1 b/eng/breakingChanges/config.ps1 index 376aee9effd76e..94483126fa0b0c 100644 --- a/eng/breakingChanges/config.ps1 +++ b/eng/breakingChanges/config.ps1 @@ -10,7 +10,6 @@ $Config = @{ # GitHub Settings SourceRepo = "dotnet/runtime" DocsRepo = "dotnet/docs" - LocalRepoPath = "c:\src\dotnet\runtime" # Path to local clone of the runtime repository TargetLabel = "needs-breaking-change-doc-created" IssueTemplatePath = ".github/ISSUE_TEMPLATE/02-breaking-change.yml" # Path to issue template in DocsRepo From af539cf2d3179355c28e9b74e3d4c26ace5000e0 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 12:27:22 -0700 Subject: [PATCH 03/28] Remove label checking --- eng/breakingChanges/breaking-change-doc.ps1 | 7 ------- eng/breakingChanges/config.ps1 | 1 - 2 files changed, 8 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index 7b6ba5a0c67612..a66f8b46b20be5 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -528,13 +528,6 @@ if ($PrNumber) { $prJson = gh pr view $PrNumber --repo $Config.SourceRepo --json number,title,url,baseRefName,closedAt,mergeCommit,labels,files,state $prData = $prJson | ConvertFrom-Json - # Verify PR has the target label - $hasTargetLabel = $prData.labels | Where-Object { $_.name -eq $Config.TargetLabel } - if (-not $hasTargetLabel) { - Write-Error "PR #$PrNumber does not have the '$($Config.TargetLabel)' label" - exit 1 - } - $prs = @($prData) Write-Host " Found PR #$($prs[0].number): $($prs[0].title)" } catch { diff --git a/eng/breakingChanges/config.ps1 b/eng/breakingChanges/config.ps1 index 94483126fa0b0c..65b3748c697d5c 100644 --- a/eng/breakingChanges/config.ps1 +++ b/eng/breakingChanges/config.ps1 @@ -10,7 +10,6 @@ $Config = @{ # GitHub Settings SourceRepo = "dotnet/runtime" DocsRepo = "dotnet/docs" - TargetLabel = "needs-breaking-change-doc-created" IssueTemplatePath = ".github/ISSUE_TEMPLATE/02-breaking-change.yml" # Path to issue template in DocsRepo # Analysis Settings From cdde40fa55292b8a96093ee8dd34fb58772fbf33 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 12:31:18 -0700 Subject: [PATCH 04/28] Add a workflow triggered on breaking change docs needed. --- .github/workflows/breaking-change-doc.yml | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/breaking-change-doc.yml diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml new file mode 100644 index 00000000000000..303bb4de3ecc80 --- /dev/null +++ b/.github/workflows/breaking-change-doc.yml @@ -0,0 +1,74 @@ +name: Breaking Change Documentation + +on: + pull_request: + types: [labeled] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + generate-breaking-change-doc: + if: contains(github.event.label.name, 'needs-breaking-change-doc-created') + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need full history for version detection + + - name: Verify PowerShell + run: | + pwsh --version + + - name: Install GitHub CLI + run: | + # GitHub CLI is pre-installed on ubuntu-latest, but ensure it's latest + gh --version + + - name: Install GitHub Models extension + run: | + gh extension install github/gh-models --force + env: + GH_TOKEN: ${{ github.token }} + + - name: Fetch latest tags + run: | + git fetch --tags --force + + - name: Run breaking change documentation script + shell: pwsh + working-directory: eng/breakingChanges + run: | + try { + # Authenticate GitHub CLI with the workflow token + echo "${{ github.token }}" | gh auth login --with-token + + # Verify authentication + gh auth status + + Write-Host "Starting breaking change documentation for PR #${{ github.event.pull_request.number }}" + Write-Host "Triggered by label: ${{ github.event.label.name }}" + + # Run the script with the PR number, Comment flag, and skip label validation + ./breaking-change-doc.ps1 -PrNumber ${{ github.event.pull_request.number }} -Comment + + Write-Host "Breaking change documentation workflow completed successfully" + } + catch { + Write-Error "Breaking change documentation workflow failed: $($_.Exception.Message)" + Write-Host "Error details: $($_.Exception)" + exit 1 + } + env: + GH_TOKEN: ${{ github.token }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: breaking-change-doc-artifacts-${{ github.event.pull_request.number }} + path: artifacts/docs/breakingChanges/ + retention-days: 7 From 110097719f6cd85505de105c0f5a4423f32d9517 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 12:37:11 -0700 Subject: [PATCH 05/28] Remove issue write permission --- .github/workflows/breaking-change-doc.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index 303bb4de3ecc80..5e00e30954430e 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -7,7 +7,6 @@ on: permissions: contents: read pull-requests: write - issues: write jobs: generate-breaking-change-doc: From 8af1bca6d4f97a4d69082e03e216c6131c877674 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 12:46:05 -0700 Subject: [PATCH 06/28] Simplify breaking change doc step --- .github/workflows/breaking-change-doc.yml | 25 ++--------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index 5e00e30954430e..b16c530b77068b 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -23,9 +23,8 @@ jobs: run: | pwsh --version - - name: Install GitHub CLI + - name: Verify GitHub CLI run: | - # GitHub CLI is pre-installed on ubuntu-latest, but ensure it's latest gh --version - name: Install GitHub Models extension @@ -41,27 +40,7 @@ jobs: - name: Run breaking change documentation script shell: pwsh working-directory: eng/breakingChanges - run: | - try { - # Authenticate GitHub CLI with the workflow token - echo "${{ github.token }}" | gh auth login --with-token - - # Verify authentication - gh auth status - - Write-Host "Starting breaking change documentation for PR #${{ github.event.pull_request.number }}" - Write-Host "Triggered by label: ${{ github.event.label.name }}" - - # Run the script with the PR number, Comment flag, and skip label validation - ./breaking-change-doc.ps1 -PrNumber ${{ github.event.pull_request.number }} -Comment - - Write-Host "Breaking change documentation workflow completed successfully" - } - catch { - Write-Error "Breaking change documentation workflow failed: $($_.Exception.Message)" - Write-Host "Error details: $($_.Exception)" - exit 1 - } + run: ./breaking-change-doc.ps1 -PrNumber ${{ github.event.pull_request.number }} -Comment env: GH_TOKEN: ${{ github.token }} From 2afb344c4ae11822ba8d6e53e2af2dfb2b85121e Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 14:12:40 -0700 Subject: [PATCH 07/28] Use pull_request_target --- .github/workflows/breaking-change-doc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index b16c530b77068b..f968e510a98224 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -1,7 +1,7 @@ name: Breaking Change Documentation on: - pull_request: + pull_request_target: types: [labeled] permissions: From f20e2cc554aed5ff12aced3316bde80fd5699663 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 14:20:36 -0700 Subject: [PATCH 08/28] Only run workflow on dotnet repo. --- .github/workflows/breaking-change-doc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index f968e510a98224..c16516ac130e55 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -10,7 +10,7 @@ permissions: jobs: generate-breaking-change-doc: - if: contains(github.event.label.name, 'needs-breaking-change-doc-created') + if: contains(github.event.label.name, 'needs-breaking-change-doc-created') && github.repository_owner == 'dotnet' runs-on: ubuntu-latest steps: From 85affad1f7decefa68686108e149330615c391e1 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 15:05:04 -0700 Subject: [PATCH 09/28] Use GitHub REST API for fetching from dotnet/docs --- eng/breakingChanges/breaking-change-doc.ps1 | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index a66f8b46b20be5..b80047f6e5d76e 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -236,7 +236,9 @@ function Get-UrlEncodedText { function Get-IssueTemplate { try { Write-Host " ๐Ÿ“‹ Fetching issue template..." -ForegroundColor DarkGray - $templateContent = gh api "repos/$($Config.DocsRepo)/contents/$($Config.IssueTemplatePath)" --jq '.content' | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($_)) } + # Use public GitHub API (no auth required for public repos) + $response = Invoke-RestMethod -Uri "https://api.github.com/repos/$($Config.DocsRepo)/contents/$($Config.IssueTemplatePath)" -Headers @{ 'User-Agent' = 'dotnet-runtime-breaking-change-tool' } + $templateContent = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($response.content)) return $templateContent } catch { @@ -250,20 +252,20 @@ function Get-IssueTemplate { function Get-ExampleBreakingChangeIssues { try { Write-Host " ๐Ÿ“š Fetching example breaking change issues..." -ForegroundColor DarkGray - $exampleIssuesJson = gh issue list --repo $Config.DocsRepo --label "breaking-change" --limit 3 --json number,title,body,url - $exampleIssues = $exampleIssuesJson | ConvertFrom-Json + # Use public GitHub API for issues + $response = Invoke-RestMethod -Uri "https://api.github.com/repos/$($Config.DocsRepo)/issues?labels=breaking-change&state=all&per_page=3" -Headers @{ 'User-Agent' = 'dotnet-runtime-breaking-change-tool' } - if ($exampleIssues.Count -eq 0) { + if ($response.Count -eq 0) { Write-Error "โŒ No example breaking change issues found in $($Config.DocsRepo) with label 'breaking-change'" Write-Error " Examples are required for high-quality documentation generation. Please check repository and label." exit 1 } $examples = @() - foreach ($issue in $exampleIssues) { + foreach ($issue in $response) { $examples += @" **Example #$($issue.number)**: $($issue.title) -URL: $($issue.url) +URL: $($issue.html_url) Body: $(Limit-Text -text $issue.body -maxLength 800) "@ } From 465fa5b29974ebf2c9e8ce6890a6a2d7ac65db8b Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 15:07:58 -0700 Subject: [PATCH 10/28] Fix log messages --- eng/breakingChanges/breaking-change-doc.ps1 | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index b80047f6e5d76e..b978b7f4fb2927 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -151,11 +151,6 @@ if ($CleanStart) { Write-Host "โœ… Cleanup completed" -ForegroundColor Green } -# Create output directories -New-Item -ItemType Directory -Path $dataDir -Force | Out-Null -New-Item -ItemType Directory -Path $issueDraftsDir -Force | Out-Null -New-Item -ItemType Directory -Path $commentDraftsDir -Force | Out-Null - # Validate parameters if (-not $PrNumber -and -not $Query) { Write-Error @" @@ -194,7 +189,7 @@ $actionMode = if ($CollectOnly) { "Collect Only" } Write-Host " Action Mode: $actionMode" -ForegroundColor Cyan if (-not $executeActions -and -not $CollectOnly) { - Write-Host " ๏ฟฝ Will generate drafts without making changes to GitHub" -ForegroundColor Yellow + Write-Host " ๐Ÿ“ Will generate drafts without making changes to GitHub" -ForegroundColor Yellow } # Function to safely truncate text @@ -705,7 +700,7 @@ $($analysisData | Where-Object HasDocsIssue | ForEach-Object { $summaryReport | Out-File (Join-Path $dataDir "summary_report.md") -Encoding UTF8 -Write-Host "โœ… Data collection completed completed" -ForegroundColor Green +Write-Host "โœ… Data collection completed" -ForegroundColor Green Write-Host " ๐Ÿ“Š Summary: $(Join-Path $dataDir "summary_report.md")" Write-Host " ๐Ÿ“‹ Combined: $(Join-Path $dataDir "combined.json")" Write-Host " ๐Ÿ“„ Individual: $(Join-Path $dataDir "pr_*.json") ($($analysisData.Count) files)" @@ -919,10 +914,10 @@ $issueBody # Handle different action modes if (-not $executeActions) { # Draft only mode, just log the commands that could be run. - Write-Host " ๏ฟฝ To create an issue use command:" -ForegroundColor Yellow + Write-Host " ๐Ÿ“ To create an issue use command:" -ForegroundColor Yellow Write-Host " gh issue create --repo $($Config.DocsRepo) --title `"$issueTitle`" --body `"...[content truncated]...`" --label `"$($Config.IssueTemplate.Labels -join ',')`" --assignee `"$($Config.IssueTemplate.Assignee)`"" -ForegroundColor Gray - Write-Host " ๏ฟฝ To add a comment use command:" -ForegroundColor Yellow + Write-Host " ๐Ÿ’ฌ To add a comment use command:" -ForegroundColor Yellow Write-Host " gh pr comment $($pr.Number) --repo $($Config.SourceRepo) --body-file `"$commentFile`"" -ForegroundColor Gray } elseif ($CreateIssues) { From 6b37cfd78389af263665623801ebfe634202f81b Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 22 Oct 2025 15:20:39 -0700 Subject: [PATCH 11/28] Try models: read permission --- .github/workflows/breaking-change-doc.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index c16516ac130e55..a951e3309f88e6 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -7,6 +7,7 @@ on: permissions: contents: read pull-requests: write + models: read jobs: generate-breaking-change-doc: From 448102f2355385aa8ae526f846715600c92fe386 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 24 Oct 2025 12:06:02 -0700 Subject: [PATCH 12/28] Add support for Copilot CLI --- .github/workflows/breaking-change-doc.yml | 5 ++ eng/breakingChanges/breaking-change-doc.ps1 | 61 ++++++++++++++++++++- eng/breakingChanges/config.ps1 | 4 +- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index a951e3309f88e6..ee3345ed95f2ac 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -34,6 +34,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} + - name: Install GitHub Copilot CLI + run: | + npm install -g @github/copilot + copilot --version + - name: Fetch latest tags run: | git fetch --tags --force diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index b978b7f4fb2927..e1f909ed15ccd4 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -57,6 +57,7 @@ SETUP: 1. Install GitHub CLI and authenticate: gh auth login 2. Choose LLM provider: - For GitHub Models: gh extension install github/gh-models + - For GitHub Copilot: Install GitHub Copilot CLI from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli - For OpenAI: `$env:OPENAI_API_KEY = "your-key" - For others: Set appropriate API key 3. Edit config.ps1 to customize settings @@ -96,21 +97,22 @@ try { exit 1 } -# Check LLM API key or GitHub CLI for GitHub Models +# Check LLM API key or GitHub CLI for GitHub Models/Copilot $llmProvider = $Config.LlmProvider $apiKey = switch ($llmProvider) { "openai" { $env:OPENAI_API_KEY } "anthropic" { $env:ANTHROPIC_API_KEY } "azure-openai" { $env:AZURE_OPENAI_API_KEY } "github-models" { $null } # No API key needed for GitHub Models + "github-copilot" { $null } # No API key needed for GitHub Copilot CLI default { $env:OPENAI_API_KEY } } if ($llmProvider -eq "github-models") { # Check if gh-models extension is installed try { - $extensions = gh extension list 2>$null - if ($extensions -notmatch "github/gh-models") { + $modelsExtension = gh extension list 2>$null | Select-String "gh models" + if (-not $modelsExtension) { Write-Error "โŒ GitHub Models extension not found. Install with: gh extension install github/gh-models" exit 1 } @@ -119,12 +121,27 @@ if ($llmProvider -eq "github-models") { Write-Error "โŒ Could not check GitHub Models extension: $($_.Exception.Message)" exit 1 } +} elseif ($llmProvider -eq "github-copilot") { + # Check if standalone GitHub Copilot CLI is installed + try { + $copilotVersion = copilot --version 2>$null + if (-not $copilotVersion -or $LASTEXITCODE -ne 0) { + Write-Error "โŒ GitHub Copilot CLI not found. Install from: https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli" + exit 1 + } + Write-Host "โœ… GitHub Copilot CLI found (version: $($copilotVersion.Split("`n")[0]))" -ForegroundColor Green + } catch { + Write-Error "โŒ Could not check GitHub Copilot CLI: $($_.Exception.Message)" + Write-Error " Install from: https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli" + exit 1 + } } elseif (-not $apiKey) { Write-Error "โŒ No LLM API key found. Set environment variable:" Write-Host " For OpenAI: `$env:OPENAI_API_KEY = 'your-key'" Write-Host " For Anthropic: `$env:ANTHROPIC_API_KEY = 'your-key'" Write-Host " For Azure OpenAI: `$env:AZURE_OPENAI_API_KEY = 'your-key'" Write-Host " For GitHub Models: Use 'github-models' provider (no key needed)" + Write-Host " For GitHub Copilot: Install GitHub Copilot CLI from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli (no key needed)" exit 1 } else { Write-Host "โœ… LLM API key found ($llmProvider)" -ForegroundColor Green @@ -460,6 +477,44 @@ function Invoke-LlmApi { return $null } } + "github-copilot" { + # Use GitHub Copilot CLI in programmatic mode + try { + # Combine system prompt and user prompt, emphasizing text-only response + $fullPrompt = if ($SystemPrompt) { + "$SystemPrompt`n`nIMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" + } else { + "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" + } + + # Use copilot with -p flag for programmatic mode and suppress logs + $rawResponse = copilot -p $fullPrompt --log-level none --allow-all-tools + + # Parse the response to extract just the content, removing usage statistics + # The response format typically includes usage stats at the end starting with "Total usage est:" + $lines = $rawResponse -split "`n" + $contentLines = @() + $foundUsageStats = $false + + foreach ($line in $lines) { + if ($line -match "^Total usage est:" -or $line -match "^Total duration") { + $foundUsageStats = $true + break + } + if (-not $foundUsageStats) { + $contentLines += $line + } + } + + # Join the content lines and trim whitespace + $response = ($contentLines -join "`n").Trim() + return $response + } + catch { + Write-Error "GitHub Copilot CLI call failed: $($_.Exception.Message)" + return $null + } + } default { # Existing API-based providers $headers = @{ 'Content-Type' = 'application/json' } diff --git a/eng/breakingChanges/config.ps1 b/eng/breakingChanges/config.ps1 index 65b3748c697d5c..0150813dbd14dc 100644 --- a/eng/breakingChanges/config.ps1 +++ b/eng/breakingChanges/config.ps1 @@ -2,9 +2,9 @@ $Config = @{ # LLM Settings - LlmProvider = "github-models" # openai, anthropic, azure-openai, github-models + LlmProvider = "github-copilot" # openai, anthropic, azure-openai, github-models, github-copilot LlmModel = "openai/gpt-4o" # For GitHub Models: openai/gpt-4o, openai/gpt-4o-mini, microsoft/phi-4, etc. - LlmApiKey = $null # Uses environment variables by default (not needed for github-models) + LlmApiKey = $null # Uses environment variables by default (not needed for github-models or github-copilot) LlmBaseUrl = $null # For Azure OpenAI: https://your-resource.openai.azure.com # GitHub Settings From 14985232c106cb9f9940bfbc501257a0cb1db80c Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 24 Oct 2025 12:19:33 -0700 Subject: [PATCH 13/28] Address feedback --- eng/breakingChanges/breaking-change-doc.ps1 | 140 +++++++++++++------- eng/breakingChanges/config.ps1 | 2 + 2 files changed, 91 insertions(+), 51 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index e1f909ed15ccd4..abd80a14d39843 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -59,6 +59,7 @@ SETUP: - For GitHub Models: gh extension install github/gh-models - For GitHub Copilot: Install GitHub Copilot CLI from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli - For OpenAI: `$env:OPENAI_API_KEY = "your-key" + - For Azure OpenAI: `$env:AZURE_OPENAI_API_KEY = "your-key" and set LlmBaseUrl in config.ps1 - For others: Set appropriate API key 3. Edit config.ps1 to customize settings "@ @@ -233,15 +234,7 @@ function Limit-Text { function Get-UrlEncodedText { param([string]$text) - # Basic URL encoding for GitHub issue URLs - $encoded = $text -replace '\r\n', '%0A' -replace '\n', '%0A' -replace '\r', '%0A' - $encoded = $encoded -replace ' ', '%20' -replace '#', '%23' -replace '&', '%26' - $encoded = $encoded -replace '\[', '%5B' -replace '\]', '%5D' -replace '\(', '%28' -replace '\)', '%29' - $encoded = $encoded -replace ':', '%3A' -replace ';', '%3B' -replace '\?', '%3F' -replace '=', '%3D' - $encoded = $encoded -replace '@', '%40' -replace '\+', '%2B' -replace '\$', '%24' - $encoded = $encoded -replace '"', '%22' -replace "'", '%27' -replace '<', '%3C' -replace '>', '%3E' - - return $encoded + return [System.Web.HttpUtility]::UrlEncode($text) } # Function to fetch issue template from GitHub @@ -469,7 +462,7 @@ function Invoke-LlmApi { $ghArgs += @("--system-prompt", $SystemPrompt) } - $response = & gh @ghArgs + $response = gh @ghArgs return $response -join "`n" } catch { @@ -515,57 +508,102 @@ function Invoke-LlmApi { return $null } } - default { - # Existing API-based providers - $headers = @{ 'Content-Type' = 'application/json' } - $body = @{} - $endpoint = "" - - switch ($Config.LlmProvider) { - "openai" { - $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/chat/completions" } else { "https://api.openai.com/v1/chat/completions" } - $headers['Authorization'] = "Bearer $apiKey" - - $messages = @() - if ($SystemPrompt) { $messages += @{ role = "system"; content = $SystemPrompt } } - $messages += @{ role = "user"; content = $Prompt } - - $body = @{ - model = $Config.LlmModel - messages = $messages - max_tokens = $MaxTokens - temperature = 0.1 - } - } - "anthropic" { - $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/messages" } else { "https://api.anthropic.com/v1/messages" } - $headers['x-api-key'] = $apiKey - $headers['anthropic-version'] = "2023-06-01" - - $fullPrompt = if ($SystemPrompt) { "$SystemPrompt`n`nHuman: $Prompt`n`nAssistant:" } else { "Human: $Prompt`n`nAssistant:" } - - $body = @{ - model = $Config.LlmModel - max_tokens = $MaxTokens - messages = @(@{ role = "user"; content = $fullPrompt }) - temperature = 0.1 - } - } + "openai" { + # OpenAI API + $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/chat/completions" } else { "https://api.openai.com/v1/chat/completions" } + $headers = @{ + 'Content-Type' = 'application/json' + 'Authorization' = "Bearer $apiKey" + } + + $messages = @() + if ($SystemPrompt) { $messages += @{ role = "system"; content = $SystemPrompt } } + $messages += @{ role = "user"; content = $Prompt } + + $body = @{ + model = $Config.LlmModel + messages = $messages + max_tokens = $MaxTokens + temperature = 0.1 } try { $requestJson = $body | ConvertTo-Json -Depth 10 $response = Invoke-RestMethod -Uri $endpoint -Method POST -Headers $headers -Body $requestJson + return $response.choices[0].message.content + } + catch { + Write-Error "OpenAI API call failed: $($_.Exception.Message)" + return $null + } + } + "anthropic" { + # Anthropic API + $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/messages" } else { "https://api.anthropic.com/v1/messages" } + $headers = @{ + 'Content-Type' = 'application/json' + 'x-api-key' = $apiKey + 'anthropic-version' = "2023-06-01" + } - switch ($Config.LlmProvider) { - "openai" { return $response.choices[0].message.content } - "anthropic" { return $response.content[0].text } - } + $fullPrompt = if ($SystemPrompt) { "$SystemPrompt`n`nHuman: $Prompt`n`nAssistant:" } else { "Human: $Prompt`n`nAssistant:" } + + $body = @{ + model = $Config.LlmModel + max_tokens = $MaxTokens + messages = @(@{ role = "user"; content = $fullPrompt }) + temperature = 0.1 + } + + try { + $requestJson = $body | ConvertTo-Json -Depth 10 + $response = Invoke-RestMethod -Uri $endpoint -Method POST -Headers $headers -Body $requestJson + return $response.content[0].text } catch { - Write-Error "LLM API call failed: $($_.Exception.Message)" + Write-Error "Anthropic API call failed: $($_.Exception.Message)" + return $null + } + } + "azure-openai" { + # Azure OpenAI API + # Endpoint format: https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={api-version} + if (-not $Config.LlmBaseUrl) { + Write-Error "Azure OpenAI requires LlmBaseUrl to be set in config (e.g., 'https://your-resource.openai.azure.com')" return $null } + + $apiVersion = if ($Config.AzureApiVersion) { $Config.AzureApiVersion } else { "2024-02-15-preview" } + $endpoint = "$($Config.LlmBaseUrl)/openai/deployments/$($Config.LlmModel)/chat/completions?api-version=$apiVersion" + + $headers = @{ + 'Content-Type' = 'application/json' + 'api-key' = $apiKey + } + + $messages = @() + if ($SystemPrompt) { $messages += @{ role = "system"; content = $SystemPrompt } } + $messages += @{ role = "user"; content = $Prompt } + + $body = @{ + messages = $messages + max_tokens = $MaxTokens + temperature = 0.1 + } + + try { + $requestJson = $body | ConvertTo-Json -Depth 10 + $response = Invoke-RestMethod -Uri $endpoint -Method POST -Headers $headers -Body $requestJson + return $response.choices[0].message.content + } + catch { + Write-Error "Azure OpenAI API call failed: $($_.Exception.Message)" + return $null + } + } + default { + Write-Error "Unknown LLM provider: $($Config.LlmProvider)" + return $null } } } diff --git a/eng/breakingChanges/config.ps1 b/eng/breakingChanges/config.ps1 index 0150813dbd14dc..a0bad2285f85f6 100644 --- a/eng/breakingChanges/config.ps1 +++ b/eng/breakingChanges/config.ps1 @@ -4,8 +4,10 @@ $Config = @{ # LLM Settings LlmProvider = "github-copilot" # openai, anthropic, azure-openai, github-models, github-copilot LlmModel = "openai/gpt-4o" # For GitHub Models: openai/gpt-4o, openai/gpt-4o-mini, microsoft/phi-4, etc. + # For Azure OpenAI: deployment name (e.g., "gpt-4o", "gpt-35-turbo") LlmApiKey = $null # Uses environment variables by default (not needed for github-models or github-copilot) LlmBaseUrl = $null # For Azure OpenAI: https://your-resource.openai.azure.com + AzureApiVersion = "2024-02-15-preview" # Azure OpenAI API version (optional, defaults to 2024-02-15-preview) # GitHub Settings SourceRepo = "dotnet/runtime" From 3d50c46c04176868440923f5bcfd6c6eed087e6a Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 24 Oct 2025 13:04:33 -0700 Subject: [PATCH 14/28] Fix yml indenting --- .github/workflows/breaking-change-doc.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index ee3345ed95f2ac..aacc72c4f81912 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -34,10 +34,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} - - name: Install GitHub Copilot CLI - run: | - npm install -g @github/copilot - copilot --version + - name: Install GitHub Copilot CLI + run: | + npm install -g @github/copilot + copilot --version - name: Fetch latest tags run: | From d57ace3043e91cebcb943fb16f7064e04bee97fc Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 24 Oct 2025 13:22:29 -0700 Subject: [PATCH 15/28] Allow external API key for Github Models and Copilot CLI Use models. Make sure to disable tools when using copilot CLI --- .github/workflows/breaking-change-doc.yml | 6 +- eng/breakingChanges/breaking-change-doc.ps1 | 87 +++++++++++++-------- eng/breakingChanges/config.ps1 | 2 +- 3 files changed, 56 insertions(+), 39 deletions(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index aacc72c4f81912..d5952499ec9889 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -34,11 +34,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} - - name: Install GitHub Copilot CLI - run: | - npm install -g @github/copilot - copilot --version - - name: Fetch latest tags run: | git fetch --tags --force @@ -49,6 +44,7 @@ jobs: run: ./breaking-change-doc.ps1 -PrNumber ${{ github.event.pull_request.number }} -Comment env: GH_TOKEN: ${{ github.token }} + GITHUB_MODELS_API_KEY: ${{ secrets.MODELS_TOKEN }} - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index abd80a14d39843..6dc7f9dc79406d 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -56,8 +56,8 @@ QUERY EXAMPLES: SETUP: 1. Install GitHub CLI and authenticate: gh auth login 2. Choose LLM provider: - - For GitHub Models: gh extension install github/gh-models - - For GitHub Copilot: Install GitHub Copilot CLI from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli + - For GitHub Models: gh extension install github/gh-models (optional: set GITHUB_MODELS_API_KEY) + - For GitHub Copilot: Install GitHub Copilot CLI from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli (optional: set GITHUB_COPILOT_API_KEY) - For OpenAI: `$env:OPENAI_API_KEY = "your-key" - For Azure OpenAI: `$env:AZURE_OPENAI_API_KEY = "your-key" and set LlmBaseUrl in config.ps1 - For others: Set appropriate API key @@ -104,8 +104,8 @@ $apiKey = switch ($llmProvider) { "openai" { $env:OPENAI_API_KEY } "anthropic" { $env:ANTHROPIC_API_KEY } "azure-openai" { $env:AZURE_OPENAI_API_KEY } - "github-models" { $null } # No API key needed for GitHub Models - "github-copilot" { $null } # No API key needed for GitHub Copilot CLI + "github-models" { $env:GITHUB_MODELS_API_KEY } # Optional API key for GitHub Models + "github-copilot" { $env:GITHUB_COPILOT_API_KEY } # Optional API key for GitHub Copilot CLI default { $env:OPENAI_API_KEY } } @@ -141,8 +141,8 @@ if ($llmProvider -eq "github-models") { Write-Host " For OpenAI: `$env:OPENAI_API_KEY = 'your-key'" Write-Host " For Anthropic: `$env:ANTHROPIC_API_KEY = 'your-key'" Write-Host " For Azure OpenAI: `$env:AZURE_OPENAI_API_KEY = 'your-key'" - Write-Host " For GitHub Models: Use 'github-models' provider (no key needed)" - Write-Host " For GitHub Copilot: Install GitHub Copilot CLI from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli (no key needed)" + Write-Host " For GitHub Models: Use 'github-models' provider (no key needed, or set GITHUB_MODELS_API_KEY for different account)" + Write-Host " For GitHub Copilot: Install GitHub Copilot CLI from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli (no key needed, or set GITHUB_COPILOT_API_KEY for different account)" exit 1 } else { Write-Host "โœ… LLM API key found ($llmProvider)" -ForegroundColor Green @@ -462,6 +462,11 @@ function Invoke-LlmApi { $ghArgs += @("--system-prompt", $SystemPrompt) } + # Add API key if provided via environment variable + if ($apiKey) { + $ghArgs += @("--api-key", $apiKey) + } + $response = gh @ghArgs return $response -join "`n" } @@ -473,35 +478,51 @@ function Invoke-LlmApi { "github-copilot" { # Use GitHub Copilot CLI in programmatic mode try { - # Combine system prompt and user prompt, emphasizing text-only response - $fullPrompt = if ($SystemPrompt) { - "$SystemPrompt`n`nIMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" - } else { - "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" + # Set API key environment variable if provided + $originalGitHubToken = $env:GITHUB_TOKEN + if ($apiKey) { + $env:GITHUB_TOKEN = $apiKey } - # Use copilot with -p flag for programmatic mode and suppress logs - $rawResponse = copilot -p $fullPrompt --log-level none --allow-all-tools + try { + # Combine system prompt and user prompt, emphasizing text-only response + $fullPrompt = if ($SystemPrompt) { + "$SystemPrompt`n`nIMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" + } else { + "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" + } + + # Use copilot with -p flag for programmatic mode and suppress logs + $rawResponse = copilot -p $fullPrompt --log-level none - # Parse the response to extract just the content, removing usage statistics - # The response format typically includes usage stats at the end starting with "Total usage est:" - $lines = $rawResponse -split "`n" - $contentLines = @() - $foundUsageStats = $false + # Parse the response to extract just the content, removing usage statistics + # The response format typically includes usage stats at the end starting with "Total usage est:" + $lines = $rawResponse -split "`n" + $contentLines = @() + $foundUsageStats = $false - foreach ($line in $lines) { - if ($line -match "^Total usage est:" -or $line -match "^Total duration") { - $foundUsageStats = $true - break + foreach ($line in $lines) { + if ($line -match "^Total usage est:" -or $line -match "^Total duration") { + $foundUsageStats = $true + break + } + if (-not $foundUsageStats) { + $contentLines += $line + } } - if (-not $foundUsageStats) { - $contentLines += $line + + # Join the content lines and trim whitespace + $response = ($contentLines -join "`n").Trim() + return $response + } + finally { + # Restore original GITHUB_TOKEN + if ($originalGitHubToken) { + $env:GITHUB_TOKEN = $originalGitHubToken + } elseif ($apiKey) { + Remove-Item env:GITHUB_TOKEN -ErrorAction SilentlyContinue } } - - # Join the content lines and trim whitespace - $response = ($contentLines -join "`n").Trim() - return $response } catch { Write-Error "GitHub Copilot CLI call failed: $($_.Exception.Message)" @@ -511,7 +532,7 @@ function Invoke-LlmApi { "openai" { # OpenAI API $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/chat/completions" } else { "https://api.openai.com/v1/chat/completions" } - $headers = @{ + $headers = @{ 'Content-Type' = 'application/json' 'Authorization' = "Bearer $apiKey" } @@ -540,7 +561,7 @@ function Invoke-LlmApi { "anthropic" { # Anthropic API $endpoint = if ($Config.LlmBaseUrl) { "$($Config.LlmBaseUrl)/messages" } else { "https://api.anthropic.com/v1/messages" } - $headers = @{ + $headers = @{ 'Content-Type' = 'application/json' 'x-api-key' = $apiKey 'anthropic-version' = "2023-06-01" @@ -572,11 +593,11 @@ function Invoke-LlmApi { Write-Error "Azure OpenAI requires LlmBaseUrl to be set in config (e.g., 'https://your-resource.openai.azure.com')" return $null } - + $apiVersion = if ($Config.AzureApiVersion) { $Config.AzureApiVersion } else { "2024-02-15-preview" } $endpoint = "$($Config.LlmBaseUrl)/openai/deployments/$($Config.LlmModel)/chat/completions?api-version=$apiVersion" - - $headers = @{ + + $headers = @{ 'Content-Type' = 'application/json' 'api-key' = $apiKey } diff --git a/eng/breakingChanges/config.ps1 b/eng/breakingChanges/config.ps1 index a0bad2285f85f6..66689847ea1593 100644 --- a/eng/breakingChanges/config.ps1 +++ b/eng/breakingChanges/config.ps1 @@ -2,7 +2,7 @@ $Config = @{ # LLM Settings - LlmProvider = "github-copilot" # openai, anthropic, azure-openai, github-models, github-copilot + LlmProvider = "github-models" # openai, anthropic, azure-openai, github-models, github-copilot LlmModel = "openai/gpt-4o" # For GitHub Models: openai/gpt-4o, openai/gpt-4o-mini, microsoft/phi-4, etc. # For Azure OpenAI: deployment name (e.g., "gpt-4o", "gpt-35-turbo") LlmApiKey = $null # Uses environment variables by default (not needed for github-models or github-copilot) From 61c3e30cfcdeb1d0ceb429134e52f1053ac33f82 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 24 Oct 2025 13:39:40 -0700 Subject: [PATCH 16/28] Use optional token when invoking gh models / copilot --- eng/breakingChanges/breaking-change-doc.ps1 | 99 ++++++++++++--------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index 6dc7f9dc79406d..f803c0ba197248 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -230,6 +230,38 @@ function Limit-Text { return $truncated + "`n`n[Content truncated for length]" } +# Function to execute a script block with a temporary GITHUB_TOKEN +function Invoke-WithGitHubToken { + param( + [string]$ApiKey, + [scriptblock]$ScriptBlock + ) + + if (-not $ApiKey) { + # No API key provided, execute without modification + return & $ScriptBlock + } + + # Store original token + $originalGitHubToken = $env:GITHUB_TOKEN + + try { + # Set temporary token + $env:GITHUB_TOKEN = $ApiKey + + # Execute the script block + return & $ScriptBlock + } + finally { + # Restore original token + if ($originalGitHubToken) { + $env:GITHUB_TOKEN = $originalGitHubToken + } else { + Remove-Item env:GITHUB_TOKEN -ErrorAction SilentlyContinue + } + } +} + # Function to URL encode text for GitHub issue URLs function Get-UrlEncodedText { param([string]$text) @@ -462,12 +494,10 @@ function Invoke-LlmApi { $ghArgs += @("--system-prompt", $SystemPrompt) } - # Add API key if provided via environment variable - if ($apiKey) { - $ghArgs += @("--api-key", $apiKey) + $response = Invoke-WithGitHubToken -ApiKey $apiKey -ScriptBlock { + gh @ghArgs } - $response = gh @ghArgs return $response -join "`n" } catch { @@ -478,51 +508,36 @@ function Invoke-LlmApi { "github-copilot" { # Use GitHub Copilot CLI in programmatic mode try { - # Set API key environment variable if provided - $originalGitHubToken = $env:GITHUB_TOKEN - if ($apiKey) { - $env:GITHUB_TOKEN = $apiKey + # Combine system prompt and user prompt, emphasizing text-only response + $fullPrompt = if ($SystemPrompt) { + "$SystemPrompt`n`nIMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" + } else { + "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" } - try { - # Combine system prompt and user prompt, emphasizing text-only response - $fullPrompt = if ($SystemPrompt) { - "$SystemPrompt`n`nIMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" - } else { - "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" - } - - # Use copilot with -p flag for programmatic mode and suppress logs - $rawResponse = copilot -p $fullPrompt --log-level none + $rawResponse = Invoke-WithGitHubToken -ApiKey $apiKey -ScriptBlock { + copilot -p $fullPrompt --log-level none + } - # Parse the response to extract just the content, removing usage statistics - # The response format typically includes usage stats at the end starting with "Total usage est:" - $lines = $rawResponse -split "`n" - $contentLines = @() - $foundUsageStats = $false + # Parse the response to extract just the content, removing usage statistics + # The response format typically includes usage stats at the end starting with "Total usage est:" + $lines = $rawResponse -split "`n" + $contentLines = @() + $foundUsageStats = $false - foreach ($line in $lines) { - if ($line -match "^Total usage est:" -or $line -match "^Total duration") { - $foundUsageStats = $true - break - } - if (-not $foundUsageStats) { - $contentLines += $line - } + foreach ($line in $lines) { + if ($line -match "^Total usage est:" -or $line -match "^Total duration") { + $foundUsageStats = $true + break } - - # Join the content lines and trim whitespace - $response = ($contentLines -join "`n").Trim() - return $response - } - finally { - # Restore original GITHUB_TOKEN - if ($originalGitHubToken) { - $env:GITHUB_TOKEN = $originalGitHubToken - } elseif ($apiKey) { - Remove-Item env:GITHUB_TOKEN -ErrorAction SilentlyContinue + if (-not $foundUsageStats) { + $contentLines += $line } } + + # Join the content lines and trim whitespace + $response = ($contentLines -join "`n").Trim() + return $response } catch { Write-Error "GitHub Copilot CLI call failed: $($_.Exception.Message)" From 730d99a3b0b47f59bc6dd4e9fbee5f55db3eb5bc Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 24 Oct 2025 13:54:11 -0700 Subject: [PATCH 17/28] Use GH_TOKEN instead of GITHUB_TOKEN --- eng/breakingChanges/breaking-change-doc.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index f803c0ba197248..68d9003c889251 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -243,11 +243,11 @@ function Invoke-WithGitHubToken { } # Store original token - $originalGitHubToken = $env:GITHUB_TOKEN + $originalGitHubToken = $env:GH_TOKEN try { # Set temporary token - $env:GITHUB_TOKEN = $ApiKey + $env:GH_TOKEN = $ApiKey # Execute the script block return & $ScriptBlock @@ -255,9 +255,9 @@ function Invoke-WithGitHubToken { finally { # Restore original token if ($originalGitHubToken) { - $env:GITHUB_TOKEN = $originalGitHubToken + $env:GH_TOKEN = $originalGitHubToken } else { - Remove-Item env:GITHUB_TOKEN -ErrorAction SilentlyContinue + Remove-Item env:GH_TOKEN -ErrorAction SilentlyContinue } } } From 37aea61da94b9ec2d1a77a622faf5186709b4c74 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Thu, 6 Nov 2025 20:07:59 -0800 Subject: [PATCH 18/28] Address feedback --- eng/breakingChanges/breaking-change-doc.ps1 | 277 ++++++++++++-------- 1 file changed, 166 insertions(+), 111 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index 68d9003c889251..f963e7bf6760e1 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -231,11 +231,8 @@ function Limit-Text { } # Function to execute a script block with a temporary GITHUB_TOKEN -function Invoke-WithGitHubToken { - param( - [string]$ApiKey, - [scriptblock]$ScriptBlock - ) +function Enter-GitHubSession { + param([string]$ApiKey) if (-not $ApiKey) { # No API key provided, execute without modification @@ -244,29 +241,22 @@ function Invoke-WithGitHubToken { # Store original token $originalGitHubToken = $env:GH_TOKEN + + # Set temporary token + $env:GH_TOKEN = $ApiKey - try { - # Set temporary token - $env:GH_TOKEN = $ApiKey - - # Execute the script block - return & $ScriptBlock - } - finally { - # Restore original token - if ($originalGitHubToken) { - $env:GH_TOKEN = $originalGitHubToken - } else { - Remove-Item env:GH_TOKEN -ErrorAction SilentlyContinue - } - } + return $originalGitHubToken } -# Function to URL encode text for GitHub issue URLs -function Get-UrlEncodedText { - param([string]$text) +function Exit-GitHubSession { + param([string]$OriginalGitHubToken) - return [System.Web.HttpUtility]::UrlEncode($text) + # Restore original token + if ($OriginalGitHubToken) { + $env:GH_TOKEN = $OriginalGitHubToken + } else { + Remove-Item env:GH_TOKEN -ErrorAction SilentlyContinue + } } # Function to fetch issue template from GitHub @@ -315,6 +305,108 @@ Body: $(Limit-Text -text $issue.body -maxLength 800) } } +# Function to parse a .NET runtime tag into its components +function ConvertFrom-DotNetTag { + param([string]$tagName) + + if (-not $tagName -or $tagName -eq "Unknown") { + return $null + } + + # Parse v(major).(minor).(build)(-prerelease) + 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 } + + # Parse prerelease into type and number using single regex + $prereleaseType = $null + $prereleaseNumber = $null + + if ($prerelease -and $prerelease -match '^([a-zA-Z]+)\.(\d+)') { + $rawType = $matches[1] + $prereleaseNumber = [int]$matches[2] + + # Normalize prerelease type casing + if ($rawType -ieq "rc") { + $prereleaseType = "RC" + } else { + # Capitalize first letter for other types + $prereleaseType = $rawType.Substring(0,1).ToUpper() + $rawType.Substring(1).ToLower() + } + } + + return @{ + Major = $major + Minor = $minor + Build = $build + Prerelease = $prerelease + PrereleaseType = $prereleaseType + PrereleaseNumber = $prereleaseNumber + IsRelease = $null -eq $prerelease + } + } + + return $null +} + +# Function to format a parsed tag as a readable .NET version +function Format-DotNetVersion { + param($parsedTag) + + if (-not $parsedTag) { + return "Next release" + } + + $baseVersion = ".NET $($parsedTag.Major).$($parsedTag.Minor)" + + if ($parsedTag.IsRelease) { + return $baseVersion + } + + if ($parsedTag.PrereleaseType -and $parsedTag.PrereleaseNumber) { + return "$baseVersion $($parsedTag.PrereleaseType) $($parsedTag.PrereleaseNumber)" + } + + # Fallback for unknown prerelease formats + return "$baseVersion ($($parsedTag.Prerelease))" +} + +# Function to estimate the next version based on current tag and branch +function Get-EstimatedNextVersion { + param($parsedTag, [string]$baseRef) + + if (-not $parsedTag) { + return "Next release" + } + + $isMainBranch = $baseRef -eq "main" + + # If this is a release version + if ($parsedTag.IsRelease) { + if ($isMainBranch) { + # Assume changes to main when last tag is release go to next release. + $nextMajor = $parsedTag.Major + 1 + return ".NET $nextMajor.0 Preview 1" + } else { + # Next patch/build + return ".NET $($parsedTag.Major).$($parsedTag.Minor)" + } + } + + # If this is a prerelease version + if ($isMainBranch -and $parsedTag.PrereleaseType -eq "RC") { + # Assume changes to main when last tag is RC go to next release. + $nextMajor = $parsedTag.Major + 1 + return ".NET $nextMajor.0 Preview 1" + } else { + # Next preview + $nextPreview = $parsedTag.PrereleaseNumber + 1 + return ".NET $($parsedTag.Major).$($parsedTag.Minor) $($parsedTag.PrereleaseType) $nextPreview" + } +} + # Function to find the closest tag by commit distance function Find-ClosestTagByDistance { param([string]$targetCommit, [int]$maxTags = 10) @@ -360,98 +452,64 @@ function Get-VersionInfo { # Ensure we have latest info git fetch --tags 2>$null | Out-Null - # For merged PRs, try to find the merge commit and get version info - if ($prNumber -and $mergedAt) { - # Get the merge commit for this PR - $mergeCommit = gh pr view $prNumber --repo $Config.SourceRepo --json mergeCommit --jq '.mergeCommit.oid' 2>$null - - if ($mergeCommit) { - # Find the closest tag by commit distance (not time) - $closestTag = Find-ClosestTagByDistance -targetCommit $mergeCommit - $lastTagBefore = if ($closestTag) { $closestTag } else { "Unknown" } + # Determine the target commit for version analysis + $targetCommit = $null + $firstTagWith = "Not yet released" + if ($prNumber -and $mergedAt) { + # For merged PRs, try to get the merge commit + $targetCommit = gh pr view $prNumber --repo $Config.SourceRepo --json mergeCommit --jq '.mergeCommit.oid' 2>$null + + if ($targetCommit) { # Get the first tag that includes this commit - $firstTagWith = git describe --tags --contains $mergeCommit 2>$null + $firstTagWith = git describe --tags --contains $targetCommit 2>$null if ($firstTagWith -and $firstTagWith -match '^([^~^]+)') { $firstTagWith = $matches[1] } + } + } + + # If no target commit yet (unmerged PR or failed to get merge commit), use branch head + if (-not $targetCommit) { + $targetCommit = git rev-parse "origin/$baseRef" 2>$null + } + + # Find the last tag before this commit + $lastTagBefore = "Unknown" + if ($targetCommit) { + $closestTag = Find-ClosestTagByDistance -targetCommit $targetCommit + if ($closestTag) { + $lastTagBefore = $closestTag } else { - # Fallback: use the target branch approach + # Fallback strategies if ($baseRef -eq "main") { + # Try git describe on the target branch $lastTagBefore = git describe --tags --abbrev=0 "origin/$baseRef" 2>$null if (-not $lastTagBefore) { - $allMainTags = git tag --merged "origin/$baseRef" --sort=-version:refname 2>$null - if ($allMainTags) { - $lastTagBefore = ($allMainTags | Select-Object -First 1) - } + # Final fallback: most recent tag overall + $lastTagBefore = git tag --sort=-version:refname | Select-Object -First 1 2>$null } } else { $lastTagBefore = git describe --tags --abbrev=0 "origin/$baseRef" 2>$null } - $firstTagWith = "Not yet released" } - } else { - # For unmerged PRs, get the most recent tag available - # Use the same commit-distance approach as for merged PRs - if ($baseRef -eq "main") { - # Get the HEAD of main branch and find closest tag - $mainHead = git rev-parse "origin/$baseRef" 2>$null - - if ($mainHead) { - $closestTag = Find-ClosestTagByDistance -targetCommit $mainHead - $lastTagBefore = if ($closestTag) { $closestTag } else { - # Fallback to the most recent tag overall - git tag --sort=-version:refname | Select-Object -First 1 2>$null - } - } else { - # Final fallback - $lastTagBefore = git tag --sort=-version:refname | Select-Object -First 1 2>$null - } - } else { - $lastTagBefore = git describe --tags --abbrev=0 "origin/$baseRef" 2>$null - } - $firstTagWith = "Not yet released" } # Clean up tag names and estimate version $lastTagBefore = if ($lastTagBefore) { $lastTagBefore.Trim() } else { "Unknown" } $firstTagWith = if ($firstTagWith -and $firstTagWith -ne "Not yet released") { $firstTagWith.Trim() } else { "Not yet released" } - # Estimate version from the most recent tag + # Determine the estimated version using new tag parsing logic $estimatedVersion = "Next release" + if ($firstTagWith -ne "Not yet released") { - if ($firstTagWith -match "v?(\d+)\.(\d+)") { - $major = [int]$matches[1] - $minor = [int]$matches[2] - $estimatedVersion = ".NET $major.$minor" - } - } elseif ($lastTagBefore -ne "Unknown") { - # Estimate version from last tag - if ($lastTagBefore -match 'v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)\.') { - # RC tag found - breaking change will be in next major release - $nextMajor = [int]$matches[1] + 1 - if ($baseRef -eq "main") { - $estimatedVersion = ".NET $nextMajor Preview 1" - } else { - $estimatedVersion = ".NET $nextMajor" - } - } elseif ($lastTagBefore -match 'v(\d+)\.(\d+)\.(\d+)-preview\.(\d+)\.') { - $major = $matches[1] - $estimatedVersion = ".NET $major" - } elseif ($lastTagBefore -match 'v(\d+)\.(\d+)\.(\d+)$') { - # Release tag - next will be next major - $nextMajor = [int]$matches[1] + 1 - $estimatedVersion = ".NET $nextMajor" - } elseif ($lastTagBefore -match "v?(\d+)\.(\d+)") { - $major = [int]$matches[1] - $minor = [int]$matches[2] - # For main branch, it's usually the next major version - if ($baseRef -eq "main") { - $estimatedVersion = ".NET $($major + 1)" - } else { - $estimatedVersion = ".NET $major.$minor" - } - } + # If we know the first tag that contains this change, use it directly + $parsedFirstTag = ConvertFrom-DotNetTag $firstTagWith + $estimatedVersion = Format-DotNetVersion $parsedFirstTag + } else { + # Estimate based on the last tag before this change + $parsedLastTag = ConvertFrom-DotNetTag $lastTagBefore + $estimatedVersion = Get-EstimatedNextVersion $parsedLastTag $baseRef } return @{ @@ -493,9 +551,12 @@ function Invoke-LlmApi { if ($SystemPrompt) { $ghArgs += @("--system-prompt", $SystemPrompt) } - - $response = Invoke-WithGitHubToken -ApiKey $apiKey -ScriptBlock { + + try { + $gitHubSession = Enter-GitHubSession $apiKey gh @ghArgs + } finally { + Exit-GitHubSession $gitHubSession } return $response -join "`n" @@ -515,8 +576,11 @@ function Invoke-LlmApi { "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" } - $rawResponse = Invoke-WithGitHubToken -ApiKey $apiKey -ScriptBlock { + try { + $gitHubSession = Enter-GitHubSession $apiKey copilot -p $fullPrompt --log-level none + } finally { + Exit-GitHubSession $gitHubSession } # Parse the response to extract just the content, removing usage statistics @@ -746,20 +810,11 @@ foreach ($pr in $prs) { } } - # If no area labels found, fall back to file path analysis + $featureAreas = $featureAreas | Select-Object -Unique if ($featureAreas.Count -eq 0) { - foreach ($file in $pr.files) { - if ($file.path -match "src/libraries/([^/]+)" -and $file.path -match "\.cs$") { - $namespace = $matches[1] -replace "System\.", "" - if ($namespace -eq "Private.CoreLib") { $namespace = "System.Runtime" } - $featureAreas += $namespace - } - } + Write-Error "Unable to determine feature area for PR #$($pr.Number). Please set an 'area-' label." } - $featureAreas = $featureAreas | Select-Object -Unique - if ($featureAreas.Count -eq 0) { $featureAreas = @("Runtime") } - $analysisData += @{ Number = $pr.number Title = $pr.title @@ -1021,9 +1076,9 @@ $issueBody $commentFile = Join-Path $commentDraftsDir "comment_pr_$($pr.Number).md" # URL encode the title and full issue body - $encodedTitle = Get-UrlEncodedText -text $issueTitle - $encodedBody = Get-UrlEncodedText -text $issueBody - $encodedLabels = Get-UrlEncodedText -text ($Config.IssueTemplate.Labels -join ",") + $encodedTitle = [Uri]::EscapeDataString($issueTitle) + $encodedBody = [Uri]::EscapeDataString($issueBody) + $encodedLabels = [Uri]::EscapeDataString($Config.IssueTemplate.Labels -join ",") # Create GitHub issue creation URL with full content and labels $createIssueUrl = "https://github.com/$($Config.DocsRepo)/issues/new?title=$encodedTitle&body=$encodedBody&labels=$encodedLabels" From d946c03045e9fcb5a502c4019b45a6457c036ad8 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 7 Nov 2025 14:01:43 -0800 Subject: [PATCH 19/28] Only run the breaking change doc workflow on merged PRs The workflow will now run when a PR is closed, or a label is appled. In both cases it only proceeds if the PR has both been merged and has the breaking change doc label. --- .github/workflows/breaking-change-doc.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index d5952499ec9889..84b5e5004c4ae2 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -2,7 +2,7 @@ name: Breaking Change Documentation on: pull_request_target: - types: [labeled] + types: [closed, labeled] permissions: contents: read @@ -11,7 +11,7 @@ permissions: jobs: generate-breaking-change-doc: - if: contains(github.event.label.name, 'needs-breaking-change-doc-created') && github.repository_owner == 'dotnet' + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-breaking-change-doc-created') && github.repository_owner == 'dotnet' runs-on: ubuntu-latest steps: From 076970f71ddbfd6943fd5b12389e32c037f11080 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Fri, 7 Nov 2025 17:58:14 -0800 Subject: [PATCH 20/28] Add workflow dispatch to breaking change workflow --- .github/workflows/breaking-change-doc.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index 84b5e5004c4ae2..64c6bdcb020854 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -3,6 +3,12 @@ name: Breaking Change Documentation on: pull_request_target: types: [closed, labeled] + workflow_dispatch: + inputs: + pr_number: + description: "Pull Request Number" + required: true + type: number permissions: contents: read @@ -11,7 +17,11 @@ permissions: jobs: generate-breaking-change-doc: - if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-breaking-change-doc-created') && github.repository_owner == 'dotnet' + if: | + github.repository_owner == 'dotnet' && ( + (github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-breaking-change-doc-created')) || + github.event_name == 'workflow_dispatch' + ) runs-on: ubuntu-latest steps: @@ -41,7 +51,7 @@ jobs: - name: Run breaking change documentation script shell: pwsh working-directory: eng/breakingChanges - run: ./breaking-change-doc.ps1 -PrNumber ${{ github.event.pull_request.number }} -Comment + run: ./breaking-change-doc.ps1 -PrNumber ${{ inputs.pr_number || github.event.pull_request.number }} -Comment env: GH_TOKEN: ${{ github.token }} GITHUB_MODELS_API_KEY: ${{ secrets.MODELS_TOKEN }} @@ -49,6 +59,6 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: breaking-change-doc-artifacts-${{ github.event.pull_request.number }} + name: breaking-change-doc-artifacts-${{ inputs.pr_number || github.event.pull_request.number }} path: artifacts/docs/breakingChanges/ retention-days: 7 From 34ac6207510a1e4493dd98217bb91625deee9963 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Sat, 8 Nov 2025 08:16:23 -0800 Subject: [PATCH 21/28] Add docs to breaking change workflow. --- .github/workflows/breaking-change-doc.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index 64c6bdcb020854..92cb7262c1ac0d 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -1,3 +1,11 @@ +# This workflow generates breaking change documentation for merged pull requests. +# It runs automatically when a PR with the 'needs-breaking-change-doc-created' label is merged, +# or when that label is added to an already merged PR. +# It can be manually triggered to generate documentation for any specific PR. +# +# The workflow uses GitHub Models AI to analyze the PR changes and create appropriate +# breaking change documentation that gets posted as a PR comment as a clickable link +# to open an issue in the dotnet/docs repository. name: Breaking Change Documentation on: From 62ebd7fb5064202ddbe28cbc7e1b3f303310286a Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Sat, 8 Nov 2025 08:27:49 -0800 Subject: [PATCH 22/28] Fix workflow condition --- .github/workflows/breaking-change-doc.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/breaking-change-doc.yml b/.github/workflows/breaking-change-doc.yml index 92cb7262c1ac0d..1378670b5a1352 100644 --- a/.github/workflows/breaking-change-doc.yml +++ b/.github/workflows/breaking-change-doc.yml @@ -27,7 +27,8 @@ jobs: generate-breaking-change-doc: if: | github.repository_owner == 'dotnet' && ( - (github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-breaking-change-doc-created')) || + (github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-breaking-change-doc-created')) || + (github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.pull_request.merged == true && github.event.label.name == 'needs-breaking-change-doc-created') || github.event_name == 'workflow_dispatch' ) runs-on: ubuntu-latest From c797328fac96e76ed552ae2240c7932b39effe39 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Sat, 8 Nov 2025 12:59:29 -0800 Subject: [PATCH 23/28] Fix calling gh CLI and use prompt file --- eng/breakingChanges/breaking-change-doc.ps1 | 143 +++++++++++++++++--- 1 file changed, 121 insertions(+), 22 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index f963e7bf6760e1..2dfb2210234bb1 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -77,6 +77,73 @@ if (Test-Path ".\config.ps1") { exit 1 } +# Simple YAML conversion function for prompt files +function ConvertTo-Yaml { + param([Parameter(ValueFromPipeline)]$Object, [int]$Depth = 0) + + $indent = " " * $Depth + + if ($Object -is [hashtable] -or $Object -is [PSCustomObject]) { + $lines = @() + $properties = if ($Object -is [hashtable]) { $Object.Keys } else { $Object.PSObject.Properties.Name } + + foreach ($key in $properties) { + $value = if ($Object -is [hashtable]) { $Object[$key] } else { $Object.$key } + + if ($value -is [array]) { + $lines += "${indent}${key}:" + foreach ($item in $value) { + if ($item -is [hashtable] -or $item -is [PSCustomObject]) { + $lines += "${indent}- " + $itemProperties = if ($item -is [hashtable]) { $item.Keys } else { $item.PSObject.Properties.Name } + $isFirstProperty = $true + foreach ($itemKey in $itemProperties) { + $itemValue = if ($item -is [hashtable]) { $item[$itemKey] } else { $item.$itemKey } + if ($isFirstProperty) { + if ($itemValue -match '[\r\n]') { + $lines[-1] += "${itemKey}: |" + $itemValue -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } + } else { + $lines[-1] += "${itemKey}: $itemValue" + } + $isFirstProperty = $false + } else { + if ($itemValue -match '[\r\n]') { + $lines += "${indent} ${itemKey}: |" + $itemValue -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } + } else { + $lines += "${indent} ${itemKey}: $itemValue" + } + } + } + } else { + if ($item -match '[\r\n]') { + $lines += "${indent}- |" + $item -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } + } else { + $lines += "${indent}- $item" + } + } + } + } elseif ($value -is [hashtable] -or $value -is [PSCustomObject]) { + $lines += "${indent}${key}:" + $subYaml = ConvertTo-Yaml -Object $value -Depth ($Depth + 1) + $lines += $subYaml + } else { + if ($value -match '[\r\n]') { + $lines += "${indent}${key}: |" + $value -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } + } else { + $lines += "${indent}${key}: $value" + } + } + } + return $lines -join "`n" + } else { + return $Object.ToString() + } +} + # Validate prerequisites Write-Host "`n๐Ÿ” Validating prerequisites..." -ForegroundColor Yellow @@ -157,10 +224,12 @@ $outputRoot = Join-Path $repoRoot "artifacts\docs\breakingChanges" $dataDir = Join-Path $outputRoot "data" $issueDraftsDir = Join-Path $outputRoot "issue-drafts" $commentDraftsDir = Join-Path $outputRoot "comment-drafts" +$promptsDir = Join-Path $outputRoot "prompts" New-Item -ItemType Directory -Path $dataDir -Force | Out-Null New-Item -ItemType Directory -Path $issueDraftsDir -Force | Out-Null New-Item -ItemType Directory -Path $commentDraftsDir -Force | Out-Null +New-Item -ItemType Directory -Path $promptsDir -Force | Out-Null # Clean start if requested if ($CleanStart) { @@ -231,19 +300,16 @@ function Limit-Text { } # Function to execute a script block with a temporary GITHUB_TOKEN -function Enter-GitHubSession { +function Enter-GitHubSession { param([string]$ApiKey) - if (-not $ApiKey) { - # No API key provided, execute without modification - return & $ScriptBlock - } - # Store original token $originalGitHubToken = $env:GH_TOKEN - # Set temporary token - $env:GH_TOKEN = $ApiKey + if ($ApiKey) { + # Set temporary token + $env:GH_TOKEN = $ApiKey + } return $originalGitHubToken } @@ -534,32 +600,58 @@ function Get-VersionInfo { # Function to call LLM API function Invoke-LlmApi { - param([string]$Prompt, [string]$SystemPrompt = "", [int]$MaxTokens = 3000) + param([string]$Prompt, [string]$SystemPrompt = "", [int]$MaxTokens = 3000, [string]$PrNumber = "unknown") switch ($Config.LlmProvider) { "github-models" { # Use GitHub CLI with models extension try { - $ghArgs = @( - "models", "run", - $Config.LlmModel, - $Prompt, - "--max-tokens", $MaxTokens.ToString(), - "--temperature", "0.1" - ) - + # Create prompt file in YAML format for GitHub Models + $promptFile = Join-Path $promptsDir "pr_${PrNumber}_prompt.yml" + + # Create YAML structure for GitHub Models + $messages = @() + if ($SystemPrompt) { - $ghArgs += @("--system-prompt", $SystemPrompt) + $messages += @{ + role = "system" + content = $SystemPrompt + } + } + + $messages += @{ + role = "user" + content = $Prompt } + $promptYaml = @{ + name = "Breaking Change Documentation" + description = "Generate breaking change documentation for .NET runtime PR" + model = $Config.LlmModel + modelParameters = @{ + temperature = 0.1 + max_tokens = $MaxTokens + } + messages = $messages + } + + # Convert to YAML and save to file + $promptYaml | ConvertTo-Yaml | Out-File -FilePath $promptFile -Encoding UTF8 + try { $gitHubSession = Enter-GitHubSession $apiKey - gh @ghArgs + $output = gh models run --file $promptFile + $exitCode = $LASTEXITCODE } finally { Exit-GitHubSession $gitHubSession } - return $response -join "`n" + if ($exitCode -ne 0) { + throw "gh models run failed with exit code $exitCode" + } + + # Join the output lines with newlines to preserve formatting + return $output -join "`n" } catch { Write-Error "GitHub Models API call failed: $($_.Exception.Message)" @@ -569,16 +661,23 @@ function Invoke-LlmApi { "github-copilot" { # Use GitHub Copilot CLI in programmatic mode try { + # Create prompt file for GitHub Copilot CLI + $promptFile = Join-Path $promptsDir "pr_${PrNumber}_copilot_prompt.txt" + # Combine system prompt and user prompt, emphasizing text-only response $fullPrompt = if ($SystemPrompt) { "$SystemPrompt`n`nIMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" } else { "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" } + + # Write prompt to file + $fullPrompt | Out-File -FilePath $promptFile -Encoding UTF8 try { $gitHubSession = Enter-GitHubSession $apiKey - copilot -p $fullPrompt --log-level none + # Add --allow-all-tools for non-interactive mode and --allow-all-paths to avoid file access prompts + $rawResponse = copilot -p "@$promptFile" --log-level none --allow-all-tools --allow-all-paths } finally { Exit-GitHubSession $gitHubSession } @@ -1041,7 +1140,7 @@ Generate the complete issue following the template structure and using the examp # Call LLM API Write-Host " ๐Ÿค– Generating content..." -ForegroundColor Gray - $llmResponse = Invoke-LlmApi -SystemPrompt $systemPrompt -Prompt $userPrompt + $llmResponse = Invoke-LlmApi -SystemPrompt $systemPrompt -Prompt $userPrompt -PrNumber $pr.Number if (-not $llmResponse) { Write-Error "Failed to get LLM response for PR #$($pr.Number)" From 5ae118ed13c465e0b21419f6f8c7ac7011de9950 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Sat, 8 Nov 2025 13:14:33 -0800 Subject: [PATCH 24/28] Use powershell-yaml --- eng/breakingChanges/breaking-change-doc.ps1 | 78 ++++----------------- 1 file changed, 15 insertions(+), 63 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index 2dfb2210234bb1..394685ea1e5e4e 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -77,71 +77,23 @@ if (Test-Path ".\config.ps1") { exit 1 } -# Simple YAML conversion function for prompt files -function ConvertTo-Yaml { - param([Parameter(ValueFromPipeline)]$Object, [int]$Depth = 0) - - $indent = " " * $Depth - - if ($Object -is [hashtable] -or $Object -is [PSCustomObject]) { - $lines = @() - $properties = if ($Object -is [hashtable]) { $Object.Keys } else { $Object.PSObject.Properties.Name } - - foreach ($key in $properties) { - $value = if ($Object -is [hashtable]) { $Object[$key] } else { $Object.$key } - - if ($value -is [array]) { - $lines += "${indent}${key}:" - foreach ($item in $value) { - if ($item -is [hashtable] -or $item -is [PSCustomObject]) { - $lines += "${indent}- " - $itemProperties = if ($item -is [hashtable]) { $item.Keys } else { $item.PSObject.Properties.Name } - $isFirstProperty = $true - foreach ($itemKey in $itemProperties) { - $itemValue = if ($item -is [hashtable]) { $item[$itemKey] } else { $item.$itemKey } - if ($isFirstProperty) { - if ($itemValue -match '[\r\n]') { - $lines[-1] += "${itemKey}: |" - $itemValue -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } - } else { - $lines[-1] += "${itemKey}: $itemValue" - } - $isFirstProperty = $false - } else { - if ($itemValue -match '[\r\n]') { - $lines += "${indent} ${itemKey}: |" - $itemValue -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } - } else { - $lines += "${indent} ${itemKey}: $itemValue" - } - } - } - } else { - if ($item -match '[\r\n]') { - $lines += "${indent}- |" - $item -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } - } else { - $lines += "${indent}- $item" - } - } - } - } elseif ($value -is [hashtable] -or $value -is [PSCustomObject]) { - $lines += "${indent}${key}:" - $subYaml = ConvertTo-Yaml -Object $value -Depth ($Depth + 1) - $lines += $subYaml - } else { - if ($value -match '[\r\n]') { - $lines += "${indent}${key}: |" - $value -split '[\r\n]' | ForEach-Object { $lines += "${indent} $_" } - } else { - $lines += "${indent}${key}: $value" - } - } +# Ensure powershell-yaml module is available for GitHub Models +if ($Config.LlmProvider -eq "github-models") { + if (-not (Get-Module -ListAvailable -Name "powershell-yaml")) { + Write-Host "๐Ÿ“ฆ Installing powershell-yaml module for GitHub Models support..." -ForegroundColor Yellow + try { + Install-Module -Name "powershell-yaml" -Scope CurrentUser -Force -AllowClobber + Write-Host "โœ… powershell-yaml module installed successfully" -ForegroundColor Green + } + catch { + Write-Error "โŒ Failed to install powershell-yaml module: $($_.Exception.Message)" + Write-Error " Please install manually: Install-Module -Name powershell-yaml -Scope CurrentUser" + exit 1 } - return $lines -join "`n" - } else { - return $Object.ToString() } + + # Import the module + Import-Module powershell-yaml -ErrorAction Stop } # Validate prerequisites From 10d1e6f55e64e2be556ec16a3d3f45925100f5c9 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Sat, 8 Nov 2025 13:21:08 -0800 Subject: [PATCH 25/28] Fix Clean to work correctly --- eng/breakingChanges/breaking-change-doc.ps1 | 23 ++++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index 394685ea1e5e4e..5287f8739ab7b6 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -6,7 +6,7 @@ param( [switch]$CollectOnly = $false, # Only collect PR data, don't create issues [switch]$CreateIssues = $false, # Create GitHub issues directly [switch]$Comment = $false, # Add comments with links to create issues - [switch]$CleanStart = $false, # Clean previous data before starting + [switch]$Clean = $false, # Clean previous data before starting [string]$PrNumber = $null, # Process only specific PR number [string]$Query = $null, # GitHub search query for PRs [switch]$Help = $false # Show help @@ -31,7 +31,7 @@ PARAMETERS: -CollectOnly Only collect PR data, don't create documentation -CreateIssues Create GitHub issues directly -Comment Add comments with links to create issues - -CleanStart Clean previous data before starting + -Clean Clean previous data before starting -PrNumber Process only specific PR number -Query GitHub search query for PRs (required if no -PrNumber) -Help Show this help @@ -172,24 +172,27 @@ $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent (Split-Path -Parent $scriptPath) $outputRoot = Join-Path $repoRoot "artifacts\docs\breakingChanges" -# Create output directories +# Define output directories $dataDir = Join-Path $outputRoot "data" $issueDraftsDir = Join-Path $outputRoot "issue-drafts" $commentDraftsDir = Join-Path $outputRoot "comment-drafts" $promptsDir = Join-Path $outputRoot "prompts" -New-Item -ItemType Directory -Path $dataDir -Force | Out-Null -New-Item -ItemType Directory -Path $issueDraftsDir -Force | Out-Null -New-Item -ItemType Directory -Path $commentDraftsDir -Force | Out-Null -New-Item -ItemType Directory -Path $promptsDir -Force | Out-Null - -# Clean start if requested -if ($CleanStart) { +if ($Clean) { Write-Host "`n๐Ÿงน Cleaning previous data..." -ForegroundColor Yellow if (Test-Path $outputRoot) { Remove-Item $outputRoot -Recurse -Force } Write-Host "โœ… Cleanup completed" -ForegroundColor Green + + if (-not $PrNumber -and -not $Query) { + exit 0 + } } +New-Item -ItemType Directory -Path $dataDir -Force | Out-Null +New-Item -ItemType Directory -Path $issueDraftsDir -Force | Out-Null +New-Item -ItemType Directory -Path $commentDraftsDir -Force | Out-Null +New-Item -ItemType Directory -Path $promptsDir -Force | Out-Null + # Validate parameters if (-not $PrNumber -and -not $Query) { Write-Error @" From 917988386ab629a7e8d4551e3abc182918587568 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Sat, 8 Nov 2025 13:25:11 -0800 Subject: [PATCH 26/28] Update readme --- eng/breakingChanges/README.md | 69 +++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/eng/breakingChanges/README.md b/eng/breakingChanges/README.md index b189135ef897c5..aa691584608d29 100644 --- a/eng/breakingChanges/README.md +++ b/eng/breakingChanges/README.md @@ -8,7 +8,7 @@ This script automates the creation of high-quality breaking change documentation - **Dynamic Template Fetching**: Automatically fetches the latest breaking change issue template from dotnet/docs - **Example-Based Learning**: Analyzes recent breaking change issues to improve content quality - **Version Detection**: Analyzes GitHub tags to determine accurate .NET version information for proper milestone assignment -- **Flexible Workflow**: Multiple execution modes (CollectOnly, Comment, CreateIssues) with DryRun overlay +- **Flexible Workflow**: Multiple execution modes (CollectOnly, Comment, CreateIssues) with analysis-only default - **Comprehensive Data Collection**: Gathers PR details, related issues, merge commits, review comments, and closing issues - **Area Label Detection**: Automatically detects feature areas from GitHub labels (area-*) with file path fallback - **Individual File Output**: Creates separate JSON files per PR for easy examination @@ -30,44 +30,50 @@ This script automates the creation of high-quality breaking change documentation 3. **Run the workflow:** ```powershell - .\breaking-change-doc.ps1 -DryRun + .\breaking-change-doc.ps1 -Help ``` 4. **Choose your workflow:** ```powershell - # Default: Add comments with create issue links (recommended) - .\breaking-change-doc.ps1 + # Default: Analysis only (generates drafts without making GitHub changes) + .\breaking-change-doc.ps1 -PrNumber 123456 + + # Add comments with create issue links + .\breaking-change-doc.ps1 -PrNumber 123456 -Comment # Create issues directly - .\breaking-change-doc.ps1 -CreateIssues + .\breaking-change-doc.ps1 -PrNumber 123456 -CreateIssues # Just collect data - .\breaking-change-doc.ps1 -CollectOnly + .\breaking-change-doc.ps1 -PrNumber 123456 -CollectOnly ``` ## Commands ```powershell -# Dry run (inspect commands) -.\breaking-change-doc.ps1 -DryRun +# Help (shows all parameters and examples) +.\breaking-change-doc.ps1 -Help -# Default workflow (add comments with links) -.\breaking-change-doc.ps1 +# Default workflow (analysis only - generates drafts) +.\breaking-change-doc.ps1 -PrNumber 123456 + +# Add comments with issue creation links +.\breaking-change-doc.ps1 -PrNumber 123456 -Comment # Create issues directly -.\breaking-change-doc.ps1 -CreateIssues +.\breaking-change-doc.ps1 -PrNumber 123456 -CreateIssues # Data collection only -.\breaking-change-doc.ps1 -CollectOnly +.\breaking-change-doc.ps1 -PrNumber 123456 -CollectOnly -# Single PR mode -.\breaking-change-doc.ps1 -PRNumber 123456 +# Query multiple PRs +.\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged" -# Clean start -.\breaking-change-doc.ps1 -CleanStart +# Clean previous data +.\breaking-change-doc.ps1 -Clean -# Help -.\breaking-change-doc.ps1 -Help +# Clean and process +.\breaking-change-doc.ps1 -Clean -PrNumber 123456 ``` ## Configuration @@ -108,15 +114,17 @@ $env:AZURE_OPENAI_API_KEY = "your-key" - **Data Collection**: `(repoRoot)\artifacts\docs\breakingChanges\data\summary_report.md`, `(repoRoot)\artifacts\docs\breakingChanges\data\pr_*.json` - **Issue Drafts**: `(repoRoot)\artifacts\docs\breakingChanges\issue-drafts\*.md` - **Comment Drafts**: `(repoRoot)\artifacts\docs\breakingChanges\comment-drafts\*.md` -- **GitHub Issues**: Created automatically (unless -DryRun) +- **GitHub Issues**: Created automatically when using -CreateIssues +- **GitHub Comments**: Added to PRs when using -Comment ## Workflow Steps 1. **Fetch PRs** - Downloads PR data from dotnet/runtime with comprehensive details 2. **Version Detection** - Analyzes GitHub tags to determine accurate .NET version information 3. **Template & Examples** - Fetches latest issue template and analyzes recent breaking change issues -4. **AI Analysis** - Generates high-quality breaking change documentation using AI -5. **Create Issues/Comments** - Adds comments with issue creation links or creates issues directly +3. **AI Analysis** - Generates high-quality breaking change documentation using AI +4. **Output Generation** - Creates issue drafts and comment drafts for review +5. **Optional Actions** - Adds comments with issue creation links (-Comment) or creates issues directly (-CreateIssues) ## Version Detection @@ -138,12 +146,27 @@ AI generates 90%+ ready documentation, but review for: Between runs: ```powershell -.\breaking-change-doc.ps1 -CleanStart +.\breaking-change-doc.ps1 -Clean ``` +## Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `-Help` | Show help and parameter information | `.\breaking-change-doc.ps1 -Help` | +| `-PrNumber` | Process a specific PR number | `.\breaking-change-doc.ps1 -PrNumber 123456` | +| `-Query` | GitHub search query for multiple PRs | `.\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged"` | +| `-CollectOnly` | Only collect PR data, don't generate documentation | `.\breaking-change-doc.ps1 -PrNumber 123456 -CollectOnly` | +| `-Comment` | Add comments to PRs with issue creation links | `.\breaking-change-doc.ps1 -PrNumber 123456 -Comment` | +| `-CreateIssues` | Create GitHub issues directly | `.\breaking-change-doc.ps1 -PrNumber 123456 -CreateIssues` | +| `-Clean` | Clean previous data before starting | `.\breaking-change-doc.ps1 -Clean` | + +**Note**: Either `-PrNumber` or `-Query` must be specified (unless using `-Clean` or `-Help` alone). + ## Troubleshooting **GitHub CLI**: `gh auth status` and `gh auth login` **API Keys**: Verify environment variables are set for non-GitHub Models providers -**Rate Limits**: Use -DryRun for testing, script includes delays +**Rate Limits**: Script includes delays between API calls **Git Operations**: Ensure git is in PATH and repository is up to date (`git fetch --tags`) +**Parameter Issues**: Use `-Help` to see current parameter list and examples From d27e210a908455c9ebf1b930e777a124f1f38789 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Mon, 24 Nov 2025 14:13:25 -0800 Subject: [PATCH 27/28] Fetch PR commits, and remove some redundant CLI calls. --- eng/breakingChanges/breaking-change-doc.ps1 | 50 ++++++++++++--------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index 5287f8739ab7b6..c960d618620a6a 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -768,16 +768,7 @@ Write-Host "`n๐Ÿ“ฅ Step 1: Collecting comprehensive PR data..." -ForegroundColor if ($PrNumber) { # Single PR mode - fetch only the specified PR Write-Host " Mode: Single PR #$PrNumber" - try { - $prJson = gh pr view $PrNumber --repo $Config.SourceRepo --json number,title,url,baseRefName,closedAt,mergeCommit,labels,files,state - $prData = $prJson | ConvertFrom-Json - - $prs = @($prData) - Write-Host " Found PR #$($prs[0].number): $($prs[0].title)" - } catch { - Write-Error "Failed to fetch PR #${PrNumber}: $($_.Exception.Message)" - exit 1 - } + $prs = @(@{ number = $PrNumber }) } else { # Query mode - fetch all PRs matching criteria Write-Host " Mode: Query - $Query" @@ -798,15 +789,27 @@ $analysisData = @() foreach ($pr in $prs) { Write-Host " Collecting data for PR #$($pr.number): $($pr.title)" -ForegroundColor Gray - # Get comprehensive PR details including comments and reviews + # Get comprehensive PR details including comments, reviews, and commits try { - $prDetails = gh pr view $pr.number --repo $Config.SourceRepo --json body,title,comments,reviews,closingIssuesReferences + $prDetails = gh pr view $pr.number --repo $Config.SourceRepo --json body,title,comments,reviews,closingIssuesReferences,commits $prDetailData = $prDetails | ConvertFrom-Json } catch { Write-Warning "Could not fetch detailed PR data for #$($pr.number)" continue } + # Extract commits from the PR details + $commits = @() + if ($prDetailData.commits) { + foreach ($commit in $prDetailData.commits) { + $commitMessage = $commit.messageHeadline + if ($commit.messageBody -and $commit.messageBody.Trim() -ne "") { + $commitMessage += "`n`n" + $commit.messageBody + } + $commits += $commitMessage + } + } + # Get closing issues with full details and comments $closingIssues = @() foreach ($issueRef in $prDetailData.closingIssuesReferences) { @@ -881,6 +884,7 @@ foreach ($pr in $prs) { Sha = $pr.mergeCommit.oid Url = $mergeCommitUrl } + Commits = $commits Body = if ($prDetailData.body) { $prDetailData.body } else { $pr.body } Comments = $prDetailData.comments Reviews = $prDetailData.reviews @@ -970,14 +974,8 @@ if ($PrNumber) { foreach ($pr in $prsNeedingDocs) { Write-Host " ๐Ÿ” Processing PR #$($pr.Number): $($pr.Title)" -ForegroundColor Cyan - # Get detailed PR data - try { - $prDetails = gh pr view $pr.Number --repo $Config.SourceRepo --json body,title,comments,reviews,closingIssuesReferences,files - $prData = $prDetails | ConvertFrom-Json - } catch { - Write-Error "Failed to get PR details for #$($pr.Number)" - continue - } + # Use commits data already collected in Step 1 + $commits = $pr.Commits # Prepare data for LLM $comments = if ($pr.Comments -and $pr.Comments.Count -gt 0) { @@ -1074,6 +1072,16 @@ $(if ($pr.MergeCommit.Url) { "**Merge Commit**: $($pr.MergeCommit.Url)" }) **PR Body**: $(Limit-Text -text $pr.Body -maxLength 1500) +## Commits +$(if ($commits -and $commits.Count -gt 0) { + $commitInfo = $commits | ForEach-Object { + "$(Limit-Text -text $_ -maxLength 300)" + } + $commitInfo -join "`n`n" +} else { + "No commit information available" +}))) + ## Changed Files $($pr.ChangedFiles -join "`n") @@ -1107,7 +1115,7 @@ Generate the complete issue following the template structure and using the examp $issueTitle = $matches[1].Trim() $issueBody = $matches[2].Trim() } else { - $issueTitle = "[Breaking change]: $($prData.title -replace '^\[.*?\]\s*', '')" + $issueTitle = "[Breaking change]: $($pr.Title -replace '^\[.*?\]\s*', '')" $issueBody = $llmResponse } From 0f88e948eedc6bae3eeed6c1b44b4c978ca85f5c Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 26 Nov 2025 09:38:25 -0800 Subject: [PATCH 28/28] Remove repo from queries --- eng/breakingChanges/breaking-change-doc.ps1 | 50 ++++++++++----------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/eng/breakingChanges/breaking-change-doc.ps1 b/eng/breakingChanges/breaking-change-doc.ps1 index c960d618620a6a..83bf12c242c4ac 100644 --- a/eng/breakingChanges/breaking-change-doc.ps1 +++ b/eng/breakingChanges/breaking-change-doc.ps1 @@ -38,20 +38,20 @@ PARAMETERS: EXAMPLES: .\breaking-change-doc.ps1 -PrNumber 114929 # Process specific PR - .\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" - .\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged" -Comment + .\breaking-change-doc.ps1 -Query "state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" + .\breaking-change-doc.ps1 -Query "state:closed label:needs-breaking-change-doc-created is:merged" -Comment .\breaking-change-doc.ps1 -PrNumber 114929 -CreateIssues # Create issues directly .\breaking-change-doc.ps1 -Query "your-search-query" -CollectOnly # Only collect data QUERY EXAMPLES: # PRs merged after specific date, excluding milestone: - "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" + "state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" # All PRs with the target label: - "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged" + "state:closed label:needs-breaking-change-doc-created is:merged" # PRs from specific author: - "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged author:username" + "state:closed label:needs-breaking-change-doc-created is:merged author:username" SETUP: 1. Install GitHub CLI and authenticate: gh auth login @@ -91,7 +91,7 @@ if ($Config.LlmProvider -eq "github-models") { exit 1 } } - + # Import the module Import-Module powershell-yaml -ErrorAction Stop } @@ -182,7 +182,7 @@ if ($Clean) { Write-Host "`n๐Ÿงน Cleaning previous data..." -ForegroundColor Yellow if (Test-Path $outputRoot) { Remove-Item $outputRoot -Recurse -Force } Write-Host "โœ… Cleanup completed" -ForegroundColor Green - + if (-not $PrNumber -and -not $Query) { exit 0 } @@ -203,7 +203,7 @@ EXAMPLES: .\breaking-change-doc.ps1 -PrNumber 114929 Query for PRs (example - customize as needed): - .\breaking-change-doc.ps1 -Query "repo:dotnet/runtime state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" + .\breaking-change-doc.ps1 -Query "state:closed label:needs-breaking-change-doc-created is:merged merged:>2024-09-16 -milestone:11.0.0" Use -Help for more examples and detailed usage information. "@ @@ -260,7 +260,7 @@ function Enter-GitHubSession { # Store original token $originalGitHubToken = $env:GH_TOKEN - + if ($ApiKey) { # Set temporary token $env:GH_TOKEN = $ApiKey @@ -337,18 +337,18 @@ function ConvertFrom-DotNetTag { # Parse v(major).(minor).(build)(-prerelease) if ($tagName -match '^v(\d+)\.(\d+)\.(\d+)(?:-(.+))?$') { $major = [int]$matches[1] - $minor = [int]$matches[2] + $minor = [int]$matches[2] $build = [int]$matches[3] $prerelease = if ($matches[4]) { $matches[4] } else { $null } # Parse prerelease into type and number using single regex $prereleaseType = $null $prereleaseNumber = $null - + if ($prerelease -and $prerelease -match '^([a-zA-Z]+)\.(\d+)') { $rawType = $matches[1] $prereleaseNumber = [int]$matches[2] - + # Normalize prerelease type casing if ($rawType -ieq "rc") { $prereleaseType = "RC" @@ -381,7 +381,7 @@ function Format-DotNetVersion { } $baseVersion = ".NET $($parsedTag.Major).$($parsedTag.Minor)" - + if ($parsedTag.IsRelease) { return $baseVersion } @@ -480,7 +480,7 @@ function Get-VersionInfo { if ($prNumber -and $mergedAt) { # For merged PRs, try to get the merge commit $targetCommit = gh pr view $prNumber --repo $Config.SourceRepo --json mergeCommit --jq '.mergeCommit.oid' 2>$null - + if ($targetCommit) { # Get the first tag that includes this commit $firstTagWith = git describe --tags --contains $targetCommit 2>$null @@ -489,7 +489,7 @@ function Get-VersionInfo { } } } - + # If no target commit yet (unmerged PR or failed to get merge commit), use branch head if (-not $targetCommit) { $targetCommit = git rev-parse "origin/$baseRef" 2>$null @@ -522,7 +522,7 @@ function Get-VersionInfo { # Determine the estimated version using new tag parsing logic $estimatedVersion = "Next release" - + if ($firstTagWith -ne "Not yet released") { # If we know the first tag that contains this change, use it directly $parsedFirstTag = ConvertFrom-DotNetTag $firstTagWith @@ -563,22 +563,22 @@ function Invoke-LlmApi { try { # Create prompt file in YAML format for GitHub Models $promptFile = Join-Path $promptsDir "pr_${PrNumber}_prompt.yml" - + # Create YAML structure for GitHub Models $messages = @() - + if ($SystemPrompt) { $messages += @{ role = "system" content = $SystemPrompt } } - + $messages += @{ - role = "user" + role = "user" content = $Prompt } - + $promptYaml = @{ name = "Breaking Change Documentation" description = "Generate breaking change documentation for .NET runtime PR" @@ -589,10 +589,10 @@ function Invoke-LlmApi { } messages = $messages } - + # Convert to YAML and save to file $promptYaml | ConvertTo-Yaml | Out-File -FilePath $promptFile -Encoding UTF8 - + try { $gitHubSession = Enter-GitHubSession $apiKey $output = gh models run --file $promptFile @@ -618,14 +618,14 @@ function Invoke-LlmApi { try { # Create prompt file for GitHub Copilot CLI $promptFile = Join-Path $promptsDir "pr_${PrNumber}_copilot_prompt.txt" - + # Combine system prompt and user prompt, emphasizing text-only response $fullPrompt = if ($SystemPrompt) { "$SystemPrompt`n`nIMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" } else { "IMPORTANT: Please respond with only the requested text content. Do not create, modify, or execute any files. Just return the text response.`n`n$Prompt" } - + # Write prompt to file $fullPrompt | Out-File -FilePath $promptFile -Encoding UTF8