Repository navigation
feat(pr-pipeline): delete finished branches daily, keeping each while its issue is open #8976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
5dcbb1f
feat(pr-pipeline): delete stale branches daily by the branch-cleanup …
kyle-sexton e8cc272
test(pr-pipeline): pin the min-age-days boundary in the branch-cleanu…
kyle-sexton eaad8a9
feat(pr-pipeline): keep a branch only while its issue is open
kyle-sexton 1457084
Merge origin/main into feat/branch-cleanup-issue-lifetime
kyle-sexton 7cbca13
fix(pr-pipeline): reserve the delete calls before branch evaluation
kyle-sexton 9c20bb6
Merge origin/main into feat/branch-cleanup-issue-lifetime
kyle-sexton 6c1af60
fix(pr-pipeline): rotate where branch evaluation starts each run
kyle-sexton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| bump: minor | ||
| --- | ||
|
|
||
| ### Added | ||
|
|
||
| - A `branch-cleanup` leaf (`max-deletes-per-run`, default 25) and the daily | ||
| maintenance-delete-stale-branches.yml that applies it: a branch lives as long as its issue is | ||
| open, and one whose pull request closed or merged and whose linked issue closed is deleted, at | ||
| most `max-deletes-per-run` a run with the calls those deletes need reserved first, after a re-read of its tip and open pull requests and a | ||
| ledger line in the step summary and an artifact. A lane run's stop marker does not keep a | ||
| branch once its issue closed. A branch with an open issue or pull request, and a protected or | ||
| ruleset branch, are never deleted; a branch that never had a pull request, or whose tip moved | ||
| after its pull request closed with commits ahead, is listed for a person. A manual run is a | ||
| dry-run by default. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| // maintenance-delete-stale-branches.yml's job: sorts every branch of this | ||
| // repository into delete, keep or report by the branch-cleanup rule | ||
| // (docs/conventions/pr-pipeline/README.md, "Branch cleanup"), prints how many | ||
| // it will delete, and deletes at most `max-deletes-per-run` of them. Before | ||
| // each delete it re-reads the branch tip and its open pull requests, skips the | ||
| // branch when either changed, and appends the ledger line. In dry-run the | ||
| // client refuses every request but GET, so nothing is written. REST only, | ||
| // paced, within a call budget, of which the deletes' share is reserved before | ||
| // any branch is evaluated. No model. | ||
| // | ||
| // Environment: GITHUB_TOKEN, REPOSITORY, DRY_RUN (`true` or `false`), | ||
| // BASE_PATH (main's checkout), LEDGER_PATH (JSON Lines file), CALL_BUDGET, | ||
| // GITHUB_API_URL, GITHUB_STEP_SUMMARY, GITHUB_RUN_NUMBER. | ||
| import { appendFileSync } from "node:fs"; | ||
| import process from "node:process"; | ||
| import { setTimeout as delay } from "node:timers/promises"; | ||
| import { pathToFileURL } from "node:url"; | ||
|
|
||
| import { excluded, laneIssue, lastPull, linkedIssues, resolveBranchCleanup } from "./rules.mjs"; | ||
|
|
||
| export class BudgetSpent extends Error {} | ||
|
|
||
| export function client(env, { pace = () => delay(200), dryRun = true } = {}) { | ||
| let used = 0; | ||
| const budget = Number(env.CALL_BUDGET ?? 600); | ||
| const call = async (method, path) => { | ||
| if (dryRun && method !== "GET") throw new Error(`dry-run refuses ${method} ${path}`); | ||
| if (used >= budget) throw new BudgetSpent(`call budget of ${budget} spent`); | ||
| used += 1; | ||
| await pace(); | ||
| const response = await fetch(`${env.GITHUB_API_URL ?? "https://api.github.com"}${path}`, { | ||
| method, | ||
| headers: { | ||
| accept: "application/vnd.github+json", | ||
| authorization: `Bearer ${env.GITHUB_TOKEN}`, | ||
| "x-github-api-version": "2022-11-28", | ||
| }, | ||
| }); | ||
| if (response.status === 404 && method === "GET") return null; | ||
| if (!response.ok) throw new Error(`${method} ${path.split("?")[0]}: HTTP ${response.status}`); | ||
| return response.status === 204 ? null : response.json(); | ||
| }; | ||
| call.remaining = () => budget - used; | ||
| return call; | ||
| } | ||
|
|
||
| async function pages(call, path, max = 20) { | ||
| const items = []; | ||
| for (let page = 1; page <= max; page += 1) { | ||
| const batch = (await call("GET", `${path}${path.includes("?") ? "&" : "?"}per_page=100&page=${page}`)) ?? []; | ||
| items.push(...batch); | ||
| if (batch.length < 100) return items; | ||
| } | ||
| throw new Error(`${path.split("?")[0]}: more than ${max} pages`); | ||
| } | ||
|
|
||
| const refPath = (name) => name.split("/").map(encodeURIComponent).join("/"); | ||
|
|
||
| // One branch's verdict: { verdict: delete | keep | report, rule or reason, | ||
| // issue, pr }. Reads run cheapest first and stop at the first | ||
| // verdict; a failed read reports the branch. | ||
| export async function evaluate(call, { repo, owner, defaultBranch }, branch) { | ||
| const { name, sha } = branch; | ||
| const keep = (reason, extra = {}) => ({ verdict: "keep", reason, ...extra }); | ||
| const report = (reason, extra = {}) => ({ verdict: "report", reason, ...extra }); | ||
| if (branch.protected) return keep("protected"); | ||
| const pulls = await pages(call, `/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${name}`)}`); | ||
| const openPull = pulls.find((p) => p.state === "open"); | ||
| if (openPull) return keep("open pull request", { pr: openPull.number }); | ||
| const pull = lastPull(pulls); | ||
| const pr = pull?.number ?? null; | ||
| let issue = laneIssue(name); | ||
| if (issue) { | ||
| const read = await call("GET", `/repos/${repo}/issues/${issue}`); | ||
| if (!read) return report("its issue could not be read", { issue, pr }); | ||
| if (read.state !== "closed") return keep("its issue is open", { issue, pr }); | ||
| } else { | ||
| if (!pull) return report("never had a pull request", {}); | ||
| for (const number of linkedIssues(pull.body, repo)) { | ||
| const read = await call("GET", `/repos/${repo}/issues/${number}`); | ||
| if (!read) return report(`linked issue #${number} could not be read`, { issue: number, pr }); | ||
| if (read.state !== "closed") return keep(`linked issue #${number} is open`, { issue: number, pr }); | ||
| issue ??= number; | ||
| } | ||
| } | ||
|
|
||
| let rule; | ||
| if (!pull) { | ||
| rule = "lane-issue-closed"; | ||
| } else { | ||
| const tipMoved = pull.head?.sha !== sha; | ||
| if (pull.merged_at && !tipMoved) { | ||
| rule = "pr-merged"; | ||
| } else if (pull.merged_at) { | ||
| const compare = await call( | ||
| "GET", | ||
| `/repos/${repo}/compare/${encodeURIComponent(defaultBranch)}...${encodeURIComponent(sha)}`, | ||
| ); | ||
| if (!Number.isSafeInteger(compare?.ahead_by)) return report("its commits ahead of the default branch could not be read", { issue, pr }); | ||
| if (compare.ahead_by > 0) return report(`merged, then ${compare.ahead_by} commit(s) ahead of ${defaultBranch}`, { issue, pr }); | ||
| rule = "pr-merged-no-commits-ahead"; | ||
| } else if (!tipMoved) { | ||
| rule = "pr-closed"; | ||
| } else { | ||
| return report("pull request closed unmerged, then the tip moved", { issue, pr }); | ||
| } | ||
| } | ||
|
|
||
| const rules = await call("GET", `/repos/${repo}/rules/branches/${refPath(name)}`); | ||
| if (!Array.isArray(rules)) return report("the rules for it could not be read", { issue, pr }); | ||
| if (rules.length > 0) return keep("a ruleset applies to it", { issue, pr }); | ||
| return { verdict: "delete", rule, issue, pr }; | ||
| } | ||
|
|
||
| // Right before the delete: the tip still at the listed SHA, and no open pull | ||
| // request from it or into it. | ||
| async function stillDeletable(call, { repo, owner }, { name, sha }) { | ||
| const ref = await call("GET", `/repos/${repo}/git/ref/heads/${refPath(name)}`); | ||
| if (ref?.object?.sha !== sha) return `the tip moved to ${ref?.object?.sha ?? "nothing"} since the listing`; | ||
| const from = await call("GET", `/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${name}`)}&per_page=1`); | ||
| if (!Array.isArray(from) || from.length > 0) return "a pull request from it is open"; | ||
| const into = await call("GET", `/repos/${repo}/pulls?state=open&base=${encodeURIComponent(name)}&per_page=1`); | ||
| if (!Array.isArray(into) || into.length > 0) return "a pull request into it is open"; | ||
| return null; | ||
| } | ||
|
|
||
| // Calls one delete takes: the tip and the open pull requests from and into | ||
| // the branch re-read (stillDeletable), then the delete. | ||
| const CALLS_PER_DELETE = 4; | ||
|
|
||
| const row = (cells) => `| ${cells.map((c) => String(c ?? "").replaceAll("|", "\\|")).join(" | ")} |`; | ||
|
|
||
| export async function cleanup(env, { call, clock, loadConfig, append, log = console.log }) { | ||
| const repo = env.REPOSITORY; | ||
| if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo ?? "")) throw new Error("REPOSITORY is not owner/repo"); | ||
| const dryRun = env.DRY_RUN !== "false"; | ||
| const config = resolveBranchCleanup(loadConfig()); | ||
| const owner = repo.split("/")[0]; | ||
| const cap = config["max-deletes-per-run"]; | ||
| // Evaluation gets what the planned deletes leave of the client's budget. | ||
| const evalLimit = (call.remaining?.() ?? Number.POSITIVE_INFINITY) - cap * CALLS_PER_DELETE; | ||
| if (evalLimit <= 0) throw new Error(`the call budget does not cover ${cap} deletes and any reads`); | ||
| let evalUsed = 0; | ||
| const read = async (method, path) => { | ||
| if (evalUsed >= evalLimit) throw new BudgetSpent(`the evaluation share of ${evalLimit} calls is spent`); | ||
| evalUsed += 1; | ||
| return call(method, path); | ||
| }; | ||
| const defaultBranch = (await read("GET", `/repos/${repo}`))?.default_branch; | ||
| if (typeof defaultBranch !== "string" || defaultBranch === "") throw new Error("the default branch could not be read"); | ||
| const context = { repo, owner, defaultBranch }; | ||
|
|
||
| const branches = await pages(read, `/repos/${repo}/branches`); | ||
| // Evaluation stops once the cap is filled or its share is spent, so each run | ||
| // starts at a point the run number moves by about a quarter of what one run | ||
| // can read (a branch takes up to about four reads), wrapping around: kept | ||
| // branches early in the listing cannot hide the later ones from every run. | ||
| const stride = Number.isFinite(evalLimit) ? Math.max(1, Math.floor(evalLimit / 4)) : 0; | ||
| const start = branches.length === 0 ? 0 : ((Number.parseInt(env.GITHUB_RUN_NUMBER ?? "", 10) || 0) * stride) % branches.length; | ||
| const ordered = [...branches.slice(start), ...branches.slice(0, start)]; | ||
| const results = []; | ||
| let unevaluated = 0; | ||
| let found = 0; | ||
| for (const [index, branch] of ordered.entries()) { | ||
| if (cap > 0 && found >= cap) { | ||
| unevaluated = ordered.length - index; | ||
| break; | ||
| } | ||
| const name = branch.name; | ||
| const sha = branch.commit?.sha; | ||
| const skip = excluded(name, defaultBranch); | ||
| if (skip) { | ||
| results.push({ name, sha, verdict: "excluded", reason: skip }); | ||
| continue; | ||
| } | ||
| try { | ||
| const verdict = await evaluate(read, context, { name, sha, protected: branch.protected }); | ||
| if (verdict.verdict === "delete") found += 1; | ||
| results.push({ name, sha, ...verdict }); | ||
| } catch (error) { | ||
| if (error instanceof BudgetSpent) { | ||
| unevaluated = ordered.length - index; | ||
| break; | ||
| } | ||
| results.push({ name, sha, verdict: "report", reason: `a read failed: ${error.message}` }); | ||
| } | ||
| } | ||
|
|
||
| const candidates = results.filter((r) => r.verdict === "delete"); | ||
| const planned = candidates.slice(0, cap); | ||
| log( | ||
| `${dryRun ? "dry-run: would delete" : "will delete"} ${planned.length} of ${candidates.length} deletable branch(es) (cap ${cap}); ${branches.length} branch(es) listed`, | ||
| ); | ||
|
|
||
| const deleted = []; | ||
| const skipped = []; | ||
| for (const candidate of planned) { | ||
| if (dryRun) continue; | ||
| let reason; | ||
| try { | ||
| reason = await stillDeletable(call, context, candidate); | ||
| } catch (error) { | ||
| reason = `a re-read failed: ${error.message}`; | ||
| } | ||
| if (reason) { | ||
| skipped.push({ ...candidate, reason }); | ||
| continue; | ||
| } | ||
| const entry = { | ||
| repo, | ||
| branch: candidate.name, | ||
| sha: candidate.sha, | ||
| rule: candidate.rule, | ||
| issue: candidate.issue ?? null, | ||
| pr: candidate.pr ?? null, | ||
| deletedAt: clock(), | ||
| }; | ||
| append(entry); | ||
| try { | ||
| await call("DELETE", `/repos/${repo}/git/refs/heads/${refPath(candidate.name)}`); | ||
| deleted.push(entry); | ||
| } catch (error) { | ||
| skipped.push({ ...candidate, reason: `the delete failed: ${error.message}` }); | ||
| } | ||
| } | ||
|
|
||
| const count = (verdict) => results.filter((r) => r.verdict === verdict).length; | ||
| const lines = [ | ||
| `### Branch cleanup${dryRun ? " (dry-run: nothing deleted)" : ""}`, | ||
| "", | ||
| `${branches.length} branches: ${candidates.length} deletable, ${count("keep")} kept, ${count("report")} for a person, ${count("excluded")} excluded. Cap ${cap}.`, | ||
| "", | ||
| dryRun ? `#### Would delete (${planned.length})` : `#### Deleted (${deleted.length})`, | ||
|
kyle-sexton marked this conversation as resolved.
|
||
| "", | ||
| row(["Branch", "SHA", "Rule", "Issue", "PR"]), | ||
| row(["---", "---", "---", "---", "---"]), | ||
| ...(dryRun ? planned : planned.filter((c) => deleted.some((d) => d.branch === c.name))).map((c) => | ||
| row([`\`${c.name}\``, c.sha.slice(0, 12), c.rule, c.issue ? `#${c.issue}` : "", c.pr ? `#${c.pr}` : ""]), | ||
| ), | ||
| ]; | ||
| if (unevaluated > 0) { | ||
| lines.push("", `${unevaluated} branch(es) not evaluated this run (cap ${cap} reached or call budget spent); the next run starts at a different point in the listing.`); | ||
| } | ||
| if (skipped.length > 0) { | ||
| lines.push("", "#### Skipped at delete time", "", ...skipped.map((s) => `- \`${s.name}\`: ${s.reason}`)); | ||
| } | ||
| lines.push( | ||
| "", | ||
| `#### For a person (${count("report")})`, | ||
| "", | ||
| ...results.filter((r) => r.verdict === "report").map((r) => `- \`${r.name}\`: ${r.reason}`), | ||
| "", | ||
| `#### Kept (${count("keep")})`, | ||
| "", | ||
| ...results.filter((r) => r.verdict === "keep").map((r) => `- \`${r.name}\`: ${r.reason}`), | ||
| ); | ||
| return { dryRun, results, candidates, planned, deleted, skipped, unevaluated, summary: lines.join("\n") }; | ||
| } | ||
|
|
||
| async function main(env = process.env) { | ||
| const { branchCleanupFromBase } = await import( | ||
| pathToFileURL(`${env.BASE_PATH}/.github/actions/resolve-config/resolve-config.mjs`).href | ||
| ); | ||
| const dryRun = env.DRY_RUN !== "false"; | ||
| const result = await cleanup(env, { | ||
| call: client(env, { dryRun }), | ||
| clock: () => new Date().toISOString(), | ||
| loadConfig: () => branchCleanupFromBase({ BASE_PATH: env.BASE_PATH }), | ||
| append: (entry) => { | ||
| const line = `${JSON.stringify(entry)}\n`; | ||
| appendFileSync(env.LEDGER_PATH, line); | ||
| if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `ledger: \`${line.trim()}\`\n\n`); | ||
| }, | ||
| }); | ||
| if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `${result.summary}\n`); | ||
| else console.log(result.summary); | ||
| if (result.skipped.some((s) => s.reason.startsWith("the delete failed"))) process.exitCode = 1; | ||
| } | ||
|
|
||
| if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { | ||
| main().catch((error) => { | ||
| console.log(`::error::${error.message}`); | ||
| process.exitCode = 1; | ||
| }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.