diff --git a/.github/scripts/close-inactive-comment.md b/.github/scripts/close-inactive-comment.md new file mode 100644 index 0000000000..7abbdec417 --- /dev/null +++ b/.github/scripts/close-inactive-comment.md @@ -0,0 +1,3 @@ +We have [closed this PR due to inactivity](https://learn.microsoft.com/vcpkg/contributing/maintainer-guide#close-inactive-prs). Please create a new PR if you're still interested in getting these changes merged. + +This action was performed automatically. diff --git a/.github/scripts/close-inactive-prs.ps1 b/.github/scripts/close-inactive-prs.ps1 new file mode 100644 index 0000000000..b129e96fee --- /dev/null +++ b/.github/scripts/close-inactive-prs.ps1 @@ -0,0 +1,220 @@ +#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) +}