mirror of
https://github.com/kellymichels/zscripts-token-savers
synced 2026-10-06 07:08:17 +00:00
* feat: add zmerge and zpull
Two commands that were private-only until now. They turned out to be useful
beyond the fleet they were written for, so they are manifested for publication
and removed from the sanitization denylist's private-only list.
zmerge merge every pull request across the org that is genuinely ready -
MERGEABLE/CLEAN and not a draft - re-checking each one immediately
before and after every merge, because merging into a default branch
can conflict a sibling PR in the same repository. Dry run by
default; -Execute or -e merges.
zpull zmerge, then git pull --ff-only in every checkout the merges
affected. Skips a checkout that is dirty or is not on its default
branch rather than guessing at it.
WHY THEY COULD BE PUBLISHED NOW. zmerge carried a hardcoded list of sixteen
repository names, which was both the reason it could not be published and a
bug: the org has thirty active repositories, so it scanned about half and
reported "Nothing open to merge" while a ready pull request sat in one it had
never heard of. It asks GitHub now, and the names went with the list.
Get-FleetRepos throws rather than returning an empty list when gh fails,
because a tool that quietly scans nothing prints the same reassuring line as
one that scanned everything and found nothing.
-e is an alias for -Execute on both, the way -s already works for -Scan.
Also: __pycache__/ is gitignored. scripts/plaintext_twins.py creates it on
every run and it was showing up as untracked work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: CRLF the new scripts, as .gitattributes pins them
zmerge.ps1, zpull.ps1 and zpull.cmd went in with LF endings. .gitattributes
pins *.ps1 and *.cmd to eol=crlf precisely so CHECKSUMS.txt can hold one hash
per file rather than one per platform - so git handed CI a CRLF checkout while
the manifest carried hashes taken from my LF copies, and the three new files
were the only ones that failed.
Local verification passed and CI did not, which is the tell: the manifest was
generated against bytes that only existed on this machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
252 lines
11 KiB
PowerShell
252 lines
11 KiB
PowerShell
# Evomedia.net Token Savers — https://github.com/evomedia-net/evo.zscripts
|
|
# Created by Kelly Michels · dev@evomedia.net
|
|
# Licensed under the MIT License. See LICENSE.
|
|
# Version: v1.0.0.0.24
|
|
|
|
# zmerge.ps1 - merge the fleet's ready pull requests in one pass.
|
|
#
|
|
# Usage:
|
|
# zmerge dry run: list every open PR and its verdict
|
|
# zmerge -Execute merge everything that is genuinely ready
|
|
# zmerge -e the same; -e is an alias, as -s is for -Scan
|
|
# zmerge -Exclude 431 skip PRs by number (repeatable)
|
|
# zmerge -Repo <name> limit to one repo
|
|
# zmerge -Only 68,67 merge just these
|
|
# zmerge -Execute -Yes skip the confirmation prompt
|
|
#
|
|
# WHY THIS EXISTS
|
|
# ---------------
|
|
# Eleven ready PRs across three repos is eleven trips through the GitHub UI, and
|
|
# the failure mode is not the clicking - it is that `gh pr create` and the merge
|
|
# button will both happily accept a PR that cannot actually merge. Mergeability
|
|
# is computed asynchronously, so a PR reports UNKNOWN for a few seconds after any
|
|
# push and CONFLICTING only later. Merging by hand, the tenth PR is the one that
|
|
# gets rubber-stamped.
|
|
#
|
|
# So this refuses to merge anything it has not just re-checked, and it re-checks
|
|
# after every merge, because merging one PR can conflict another in the same
|
|
# repo.
|
|
#
|
|
# WHAT IT WILL NOT DO
|
|
# -------------------
|
|
# * merge a PR that is not MERGEABLE/CLEAN at the moment it is reached
|
|
# * merge a draft, or one with a failing required check
|
|
# * bump versions - each repo stamps differently (zbump for zscripts,
|
|
# bump_build_version.mjs for www), and a wrong stamp is worse than none.
|
|
# The follow-up commands are printed instead.
|
|
# * deploy anything. Deploys are run by hand, deliberately.
|
|
#
|
|
# ON UNKNOWN
|
|
# ----------
|
|
# GitHub returns mergeable=UNKNOWN while it computes, which is indistinguishable
|
|
# from trouble if you only look once. Each PR is polled up to $PollTries times
|
|
# before being treated as not ready, so a slow answer does not read as a failure
|
|
# and a real CONFLICTING never reads as "probably fine".
|
|
|
|
param(
|
|
[Alias('e')][switch]$Execute,
|
|
[switch]$Yes,
|
|
[int[]]$Exclude = @(),
|
|
[int[]]$Only = @(),
|
|
[string]$Repo,
|
|
[int]$PollTries = 6,
|
|
[int]$PollDelaySeconds = 4
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# Two blank lines at the end of a run, matching every other z-script, so output
|
|
# is separated from the next prompt. Local copy rather than ZHelpers: this
|
|
# script does not dot-source it.
|
|
function Write-ZTrailer { Write-Host ""; Write-Host "" }
|
|
|
|
# Every repository in the org, asked of GitHub rather than remembered here.
|
|
#
|
|
# Local to this script for the same reason Write-ZTrailer is: zmerge needs gh
|
|
# and nothing else, and dot-sourcing 1,200 lines of deploy helpers for one
|
|
# function would trade that away.
|
|
#
|
|
# THROWS rather than returning an empty list when gh fails. A merge tool that
|
|
# quietly scans nothing prints exactly the same reassuring line as one that
|
|
# scanned everything and found nothing, and those two must never be
|
|
# confusable - which is precisely how the list this replaced hid its own rot.
|
|
function Get-FleetRepos {
|
|
param([Parameter(Mandatory)][string]$Org)
|
|
$raw = & gh repo list $Org --limit 200 --json name,isArchived 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "gh repo list $Org failed ($LASTEXITCODE): $($raw -join ' ')"
|
|
}
|
|
try { $all = $raw | ConvertFrom-Json } catch {
|
|
throw "gh repo list $Org did not return JSON: $($raw -join ' ')"
|
|
}
|
|
if (-not $all) { throw "gh repo list $Org returned no repositories" }
|
|
$names = @($all | Where-Object { -not $_.isArchived } |
|
|
ForEach-Object { $_.name } | Sort-Object)
|
|
if ($names.Count -eq 0) { throw "every repository in $Org is archived?" }
|
|
return $names
|
|
}
|
|
|
|
|
|
|
|
$ORG = "evomedia-net"
|
|
# Discovered, never listed. The list this replaced had fallen fourteen
|
|
# repositories behind: a scan covered sixteen of thirty and said "Nothing open
|
|
# to merge" while a ready PR sat in one of the fourteen it could not see.
|
|
$REPOS = Get-FleetRepos -Org $ORG
|
|
|
|
# How each repo advances its build stamp after a merge. Printed as follow-up,
|
|
# never run: see the header.
|
|
$BUMP = @{
|
|
"evo.zscripts" = "zbump"
|
|
"evo.www" = "node scripts/bump_build_version.mjs bump (on main, then push)"
|
|
}
|
|
|
|
function Invoke-Gh {
|
|
param([string[]]$GhArgs, [switch]$AllowFail)
|
|
$prev = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
$out = & gh @GhArgs 2>&1 | ForEach-Object { "$_" }
|
|
$code = $LASTEXITCODE
|
|
} finally { $ErrorActionPreference = $prev }
|
|
if ($code -ne 0 -and -not $AllowFail) {
|
|
throw "gh $($GhArgs -join ' ') failed ($code): $($out -join "`n")"
|
|
}
|
|
return [pscustomobject]@{ Output = ($out -join "`n"); Code = $code }
|
|
}
|
|
|
|
function Get-OpenPrs {
|
|
param([string]$RepoName)
|
|
$r = Invoke-Gh @("pr", "list", "-R", "$ORG/$RepoName", "--state", "open",
|
|
"--limit", "100", "--json", "number,title,isDraft,headRefName") -AllowFail
|
|
if ($r.Code -ne 0 -or -not $r.Output) { return @() }
|
|
return @($r.Output | ConvertFrom-Json)
|
|
}
|
|
|
|
# Re-checked immediately before every merge, and again after each one, because
|
|
# merging into the default branch can conflict a sibling PR in the same repo.
|
|
function Get-Readiness {
|
|
param([string]$RepoName, [int]$Number)
|
|
for ($i = 1; $i -le $PollTries; $i++) {
|
|
$r = Invoke-Gh @("pr", "view", "$Number", "-R", "$ORG/$RepoName",
|
|
"--json", "mergeable,mergeStateStatus,state,isDraft") -AllowFail
|
|
if ($r.Code -ne 0) { return [pscustomobject]@{ Ready = $false; Why = "cannot read PR" } }
|
|
$j = $r.Output | ConvertFrom-Json
|
|
if ($j.state -ne "OPEN") { return [pscustomobject]@{ Ready = $false; Why = "state is $($j.state)" } }
|
|
if ($j.isDraft) { return [pscustomobject]@{ Ready = $false; Why = "draft" } }
|
|
if ($j.mergeable -eq "MERGEABLE" -and $j.mergeStateStatus -eq "CLEAN") {
|
|
return [pscustomobject]@{ Ready = $true; Why = "MERGEABLE/CLEAN" }
|
|
}
|
|
if ($j.mergeable -eq "CONFLICTING") {
|
|
return [pscustomobject]@{ Ready = $false; Why = "CONFLICTING - rebase it" }
|
|
}
|
|
# UNKNOWN, or a non-CLEAN state such as BLOCKED/BEHIND: give GitHub a
|
|
# moment, since it computes mergeability asynchronously.
|
|
if ($j.mergeable -ne "UNKNOWN" -and $j.mergeStateStatus -ne "UNKNOWN") {
|
|
return [pscustomobject]@{ Ready = $false; Why = "$($j.mergeable)/$($j.mergeStateStatus)" }
|
|
}
|
|
Start-Sleep -Seconds $PollDelaySeconds
|
|
}
|
|
return [pscustomobject]@{ Ready = $false; Why = "still UNKNOWN after $PollTries tries" }
|
|
}
|
|
|
|
$targets = if ($Repo) { @($Repo) } else { $REPOS }
|
|
|
|
Write-Host ""
|
|
Write-Host "Scanning $($targets.Count) repo(s) for open pull requests..." -ForegroundColor Cyan
|
|
|
|
$queue = @()
|
|
foreach ($r in $targets) {
|
|
foreach ($pr in (Get-OpenPrs -RepoName $r)) {
|
|
if ($Exclude -contains $pr.number) { continue }
|
|
if ($Only.Count -gt 0 -and $Only -notcontains $pr.number) { continue }
|
|
$queue += [pscustomobject]@{ Repo = $r; Number = $pr.number; Title = $pr.title; Draft = $pr.isDraft }
|
|
}
|
|
}
|
|
|
|
if ($queue.Count -eq 0) { Write-Host "Nothing open to merge." -ForegroundColor Yellow; Write-ZTrailer; exit 0 }
|
|
|
|
Write-Host ""
|
|
foreach ($p in $queue) {
|
|
$v = Get-Readiness -RepoName $p.Repo -Number $p.Number
|
|
$p | Add-Member -NotePropertyName Ready -NotePropertyValue $v.Ready -Force
|
|
$p | Add-Member -NotePropertyName Why -NotePropertyValue $v.Why -Force
|
|
$mark = if ($v.Ready) { "OK " } else { "SKIP" }
|
|
$col = if ($v.Ready) { "Green" } else { "Yellow" }
|
|
Write-Host (" {0} {1,-14} #{2,-4} {3}" -f $mark, $p.Repo, $p.Number, $p.Title) -ForegroundColor $col
|
|
if (-not $v.Ready) { Write-Host (" -> {0}" -f $v.Why) -ForegroundColor DarkYellow }
|
|
}
|
|
|
|
$ready = @($queue | Where-Object { $_.Ready })
|
|
Write-Host ""
|
|
Write-Host "$($ready.Count) of $($queue.Count) ready to merge." -ForegroundColor Cyan
|
|
|
|
if (-not $Execute) {
|
|
Write-Host ""
|
|
Write-Host "Dry run. Re-run with -Execute (or -e) to merge." -ForegroundColor Yellow
|
|
Write-ZTrailer
|
|
exit 0
|
|
}
|
|
if ($ready.Count -eq 0) { Write-ZTrailer; exit 1 }
|
|
|
|
if (-not $Yes) {
|
|
Write-Host ""
|
|
$answer = Read-Host "Squash-merge these $($ready.Count) PRs and delete their branches? (y/N)"
|
|
if ($answer -notmatch '^(y|yes)$') { Write-Host "Aborted." -ForegroundColor Yellow; Write-ZTrailer; exit 1 }
|
|
}
|
|
|
|
$merged = @(); $failed = @()
|
|
foreach ($p in $ready) {
|
|
# Re-check: an earlier merge in this same repo may have conflicted this one.
|
|
$v = Get-Readiness -RepoName $p.Repo -Number $p.Number
|
|
if (-not $v.Ready) {
|
|
Write-Host (" SKIP {0} #{1} - {2}" -f $p.Repo, $p.Number, $v.Why) -ForegroundColor Yellow
|
|
$failed += [pscustomobject]@{ Repo = $p.Repo; Number = $p.Number; Why = $v.Why }
|
|
continue
|
|
}
|
|
$r = Invoke-Gh @("pr", "merge", "$($p.Number)", "-R", "$ORG/$($p.Repo)",
|
|
"--squash", "--delete-branch") -AllowFail
|
|
if ($r.Code -eq 0) {
|
|
Write-Host (" MERGED {0} #{1}" -f $p.Repo, $p.Number) -ForegroundColor Green
|
|
$merged += $p
|
|
} else {
|
|
Write-Host (" FAILED {0} #{1}" -f $p.Repo, $p.Number) -ForegroundColor Red
|
|
Write-Host (" {0}" -f $r.Output) -ForegroundColor DarkRed
|
|
$failed += [pscustomobject]@{ Repo = $p.Repo; Number = $p.Number; Why = $r.Output }
|
|
}
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host "merged $($merged.Count), failed/skipped $($failed.Count)" -ForegroundColor Cyan
|
|
|
|
# Verify rather than trust the exit codes - a merge can report success and leave
|
|
# the PR in an unexpected state.
|
|
if ($merged.Count -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "Verifying:" -ForegroundColor Cyan
|
|
foreach ($p in $merged) {
|
|
$r = Invoke-Gh @("pr", "view", "$($p.Number)", "-R", "$ORG/$($p.Repo)",
|
|
"--json", "state,mergedAt") -AllowFail
|
|
$j = if ($r.Code -eq 0) { $r.Output | ConvertFrom-Json } else { $null }
|
|
$state = if ($j) { $j.state } else { "unreadable" }
|
|
$col = if ($state -eq "MERGED") { "Green" } else { "Red" }
|
|
Write-Host (" {0,-14} #{1,-4} {2}" -f $p.Repo, $p.Number, $state) -ForegroundColor $col
|
|
}
|
|
|
|
# Follow-up, printed not run: ONE build bump per release - not one per
|
|
# merged PR - on the default branch, and then a deploy. Both deliberately
|
|
# by hand. This used to print one bump per PR, which is how a single
|
|
# release came to be stamped as two builds.
|
|
Write-Host ""
|
|
Write-Host "Follow-up (not run):" -ForegroundColor Cyan
|
|
foreach ($grp in ($merged | Group-Object Repo)) {
|
|
$how = if ($BUMP.ContainsKey($grp.Name)) { $BUMP[$grp.Name] } else { "bump this repo's build stamp" }
|
|
Write-Host (" {0,-14} {1} merged -> 1 build bump for the release: {2}" -f $grp.Name, $grp.Count, $how)
|
|
}
|
|
Write-Host " then deploy each project you want live (zdeploy, by hand)"
|
|
}
|
|
|
|
if ($failed.Count -gt 0) { Write-ZTrailer; exit 1 }
|
|
|
|
Write-ZTrailer
|