A pull request
craftz pr "<title>" takes what's in the working tree to a pull request that merges itself
once CI passes, which is how every change reaches main. A repository runs it as pnpm pr, so
a person and an agent take the same steps everywhere, from the version the repository installs.
On main, it starts a branch named after the title. It commits what's changed, with the title
as the message, runs verify, pushes, opens the pull request and turns on auto-merge. A branch
whose pull request is open already gets the push, and the pull request keeps its title.
A package's pull request takes one label, --label <label>, from the ones its
.github/release.yml groups the notes by. Without one it stops before anything is committed,
as CI would stop after. A site or a tool has no release notes, so it takes none.
--body <text> is the description. --wait waits for the merge, then switches to main,
pulls, and deletes the branch.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { parseArgs } from "node:util";
function fail(message: string): never {
console.error(message);
process.exit(1);
}
const run = (command: string, ...args: string[]): void => {
execFileSync(command, args, { stdio: "inherit" });
};
const output = (command: string, ...args: string[]): string =>
execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
/** The labels CI accepts, read from the notes config the way its Label step reads them. */
export const labelsIn = (notes: string): string[] =>
[...notes.matchAll(/labels: \[(\w+)\]|^ +- (\w+)$/gm)].map((m) => m[1] ?? m[2] ?? "");
/** The branch a title starts on `main`. */
export const branchOf = (title: string): string =>
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 48)
.replace(/-$/, "");
/** Opens the pull request for what's in the working tree, and waits for it with `--wait`. */
export async function pr(args: string[]): Promise<void> {
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
options: {
label: { type: "string" },
body: { type: "string", default: "" },
wait: { type: "boolean", default: false },
},
});
const title = positionals[0];
if (!title || positionals.length > 1) {
fail('Give a title: craftz pr "<title>" [--label <label>] [--body <text>] [--wait]');
}
const NOTES = ".github/release.yml";
const known = existsSync(NOTES) ? labelsIn(readFileSync(NOTES, "utf8")) : [];
const label = values.label;
if (known.length && !label) fail(`Label this pull request with one of ${known.join(", ")}.`);
if (label && !known.includes(label)) {
fail(
known.length ? `${label} isn't one of ${known.join(", ")}.` : "Only a package takes a label.",
);
}
let branch = output("git", "branch", "--show-current");
if (branch === "main") {
branch = branchOf(title);
run("git", "switch", "--create", branch);
}
if (output("git", "status", "--porcelain")) {
run("git", "add", "--all");
run("git", "commit", "--quiet", "--message", title);
}
run("git", "fetch", "--quiet", "origin", "main");
if (output("git", "rev-list", "--count", "origin/main..HEAD") === "0") {
fail(`${branch} has nothing that isn't on main.`);
}
run("pnpm", "run", "verify");
run("git", "push", "--quiet", "--set-upstream", "origin", branch);
const open = (): string => {
try {
return output(
"gh",
"pr",
"view",
branch,
"--json",
"state,url",
"--jq",
'select(.state == "OPEN") | .url',
);
} catch {
return "";
}
};
let url = open();
if (!url) {
const labels = label ? ["--label", label] : [];
url = output("gh", "pr", "create", "--title", title, "--body", values.body, ...labels);
}
run("gh", "pr", "merge", branch, "--auto", "--squash");
console.log(url);
if (values.wait) await merged(branch, url);
}#Waiting for the merge
Asks GitHub every ten seconds, and stops at the first check that fails, with the pull request left open to fix and run again. A check counts by its latest run, since adding a label starts a new run that cancels the old one.
A pull request that falls behind main, because another merged first, has its branch updated,
so CI runs again on what it would merge. main requires a branch that's up to date, and
auto-merge doesn't update one.
type Check = { name?: string; conclusion?: string; startedAt?: string };
type State = { state: string; mergeStateStatus: string; statusCheckRollup: Check[] };
const FAILED = ["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"];
async function merged(branch: string, url: string): Promise<void> {
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 10_000));
const pr = JSON.parse(
output("gh", "pr", "view", branch, "--json", "state,mergeStateStatus,statusCheckRollup"),
) as State;
if (pr.state === "MERGED") break;
if (pr.state === "CLOSED") fail(`${url} was closed without merging.`);
if (pr.mergeStateStatus === "BEHIND") {
// A pull request that's merging reads as behind its own merge for a moment, and then there's
// nothing to update, so a failed update only means asking again.
try {
run("gh", "pr", "update-branch", branch);
} catch {}
continue;
}
const latest = new Map<string, Check>();
for (const check of pr.statusCheckRollup) {
const seen = latest.get(check.name ?? "");
if ((check.startedAt ?? "") >= (seen?.startedAt ?? "")) latest.set(check.name ?? "", check);
}
const failed = [...latest.values()].find((check) => FAILED.includes(check.conclusion ?? ""));
if (failed) fail(`${failed.name ?? "A check"} failed on ${url}.`);
}
run("git", "switch", "--quiet", "main");
run("git", "pull", "--quiet", "--prune");
run("git", "branch", "--quiet", "--delete", "--force", branch);
console.log("Merged, and on main.");
}