#Requires -Version 7.0 param( [ValidateRange(1, [int]::MaxValue)] [int]$Age = 120, [string]$Repo = 'microsoft/vcpkg', [ValidateRange(1, [int]::MaxValue)] [int]$Limit = 1000, [string]$OutputFile, [ValidateScript({ if ($_ -and -not (Test-Path -LiteralPath $_ -PathType Leaf)) { throw '-CloseWithComment requires a valid markdown file path.' } $true })] [string]$CloseWithComment ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $NoStaleLabel = 'no-stale' function Invoke-GhCommand { param([Parameter(Mandatory)][string[]]$Arguments) $previousGhToken = $env:GH_TOKEN $previousGithubToken = $env:GITHUB_TOKEN Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue try { $output = & gh @Arguments 2>&1 return @{ Output = $output; ExitCode = $LASTEXITCODE } } finally { if ($null -eq $previousGhToken) { Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue } else { $env:GH_TOKEN = $previousGhToken } if ($null -eq $previousGithubToken) { Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_TOKEN = $previousGithubToken } } } function Get-GhPrList { param( [Parameter(Mandatory)] [string]$Repo, [Parameter(Mandatory)] [int]$Limit, [Parameter(Mandatory)] [string]$CutoffDate ) $searchQuery = "is:pr updated:<$CutoffDate -label:$NoStaleLabel sort:updated-asc" $command = @('pr', 'list', '--repo', $Repo, '--state', 'open', '--limit', $Limit.ToString(), '--search', $searchQuery, '--json', 'number,title,url,updatedAt,labels,author') $result = Invoke-GhCommand -Arguments $command if ($result.ExitCode -ne 0) { $message = ($result.Output | Out-String).Trim() throw "Unable to list PRs with gh: $message" } return ($result.Output | Out-String) | ConvertFrom-Json -AsHashtable } function Get-AgeDays { param([Parameter(Mandatory)][string]$UpdatedAt) $updatedUtc = [datetime]::Parse( $UpdatedAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal ) return [int][Math]::Floor(((Get-Date).ToUniversalTime() - $updatedUtc).TotalDays) } function Write-CandidateReport { param( [Parameter(Mandatory)][hashtable]$Pr, [Parameter(Mandatory)][int]$AgeDays ) $labels = @($Pr.labels | ForEach-Object { $_.name }) $labelText = if ($labels.Count -gt 0) { ($labels | Sort-Object -Unique) -join ', ' } else { '(none)' } $age = Get-AgeDays -UpdatedAt $Pr.updatedAt $authorObject = $Pr['author'] $author = if ($authorObject -and $authorObject.Contains('login')) { [string]$authorObject['login'] } else { '(unknown)' } return @( "PR #$($Pr.number): $($Pr.title)", " author: $author", " last updated $age days ago (threshold: $AgeDays days)", " labels: $labelText", " url: $($Pr.url)" ) -join [Environment]::NewLine } function Write-ReportLine { param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [System.Collections.Generic.List[string]]$ReportLines ) if ($null -ne $ReportLines) { $ReportLines.Add($Text) } [Console]::WriteLine($Text) } function Test-GhAuthentication { $result = Invoke-GhCommand -Arguments @('auth', 'status') if ($result.ExitCode -ne 0) { $message = ($result.Output | Out-String).Trim() throw "GitHub CLI is not authenticated. Run 'gh auth login' first. $message" } } function Close-PullRequest { param( [Parameter(Mandatory)][hashtable]$Pr, [Parameter(Mandatory)][string]$Repo, [ValidateScript({ -not $_ -or (Test-Path -LiteralPath $_ -PathType Leaf) })] [string]$CommentFile ) $command = @('pr', 'close', [string]$Pr.number, '--repo', $Repo) if ($CommentFile) { $comment = Get-Content -LiteralPath $CommentFile -Raw $command += @('--comment', $comment) } $result = Invoke-GhCommand -Arguments $command if ($result.ExitCode -ne 0) { $message = ($result.Output | Out-String).Trim() throw "Unable to close PR #$($Pr.number): $message" } return ($result.Output | Out-String) } function Main { $reportLines = [System.Collections.Generic.List[string]]::new() $resolvedRepo = if ($env:GITHUB_REPOSITORY) { $env:GITHUB_REPOSITORY } else { $Repo } if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Error "'gh' is not available on PATH." return 1 } Test-GhAuthentication $cutoffDate = (Get-Date).ToUniversalTime().AddDays(-$Age).ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) $prs = Get-GhPrList -Repo $resolvedRepo -Limit $Limit -CutoffDate $cutoffDate $stalePrs = @($prs | Sort-Object { [datetime]::Parse($_.updatedAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal) }) if ($stalePrs.Count -eq 0) { Write-ReportLine -Text "No open PRs with no activity for $Age days or more found in $resolvedRepo." -ReportLines $reportLines if ($OutputFile) { $directory = Split-Path -Path $OutputFile -Parent if ($directory -and -not (Test-Path -LiteralPath $directory -PathType Container)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } Set-Content -LiteralPath $OutputFile -Value $reportLines -Encoding utf8NoBOM } return } Write-ReportLine -Text "Found $($stalePrs.Count) stale PR(s) in $resolvedRepo (threshold: $Age days since last update):" -ReportLines $reportLines Write-ReportLine -Text '' -ReportLines $reportLines foreach ($pr in $stalePrs) { $reportText = Write-CandidateReport -Pr $pr -AgeDays $Age Write-ReportLine -Text $reportText -ReportLines $reportLines Write-ReportLine -Text '' -ReportLines $reportLines if ($CloseWithComment) { Close-PullRequest -Pr $pr -Repo $resolvedRepo -CommentFile $CloseWithComment | Out-Null Write-ReportLine -Text " closed PR #$($pr.number) via gh pr close" -ReportLines $reportLines } else { Write-ReportLine -Text " would close PR #$($pr.number); pass -CloseWithComment to close it" -ReportLines $reportLines } } if ($OutputFile) { $directory = Split-Path -Path $OutputFile -Parent if ($directory -and -not (Test-Path -LiteralPath $directory -PathType Container)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } Set-Content -LiteralPath $OutputFile -Value $reportLines -Encoding utf8NoBOM } } if ($MyInvocation.InvocationName -ne '.') { exit (Main) }