Checks / web (push) Canceled after 0s
The board gains an "A tester" column for the checks that need a person: the project-board hook now reads it, so a commit that mentions one of its cards moves the card instead of adding it to a second column. A test that runs only with MAKE_TEST_SAVES=1 writes the backup those checks import, built from the local ROM and kept out of git. [no-card] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
8.6 KiB
JavaScript
204 lines
8.6 KiB
JavaScript
// Keeps the forge's project board in step with commits. Run by .githooks.
|
|
//
|
|
// node scripts/project-board.mjs commit [<rev>] [--dry-run]
|
|
// node scripts/project-board.mjs rewrite [--dry-run] (old/new pairs on stdin)
|
|
//
|
|
// A commit message mentioning "Closes #N" (also close/closed, fix/fixes/fixed,
|
|
// resolve/resolves/resolved) moves card N to Fait and closes the issue. Any
|
|
// other "#N" moves card N to En cours, including from the optional "A tester"
|
|
// column of manual test cards. A commit on the default branch with no
|
|
// reference creates a closed card in Fait from the commit message. Merges,
|
|
// amends, rebases and messages containing [no-card] are skipped. On other
|
|
// branches only referenced cards move, and only to En cours.
|
|
//
|
|
// Each commit's cards are recorded in .git/project-board.json so an amend or
|
|
// rebase (post-rewrite) replaces the old hash on those cards.
|
|
//
|
|
// Authentication: FORGEJO_TOKEN if set, otherwise the git credential helper.
|
|
// Settings (git config): board.project (default 1), board.branch (default main),
|
|
// board.disabled (true skips everything).
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
|
const COLUMNS = { todo: "A faire", doing: "En cours", done: "Fait" };
|
|
// Columns whose cards commits may move, but which the board may lack.
|
|
const OPTIONAL_COLUMNS = { test: "A tester" };
|
|
const CLOSING = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/gi;
|
|
const REFERENCE = /(?<![\w/])#(\d+)\b/g;
|
|
|
|
const args = process.argv.slice(2);
|
|
const dryRun = args.includes("--dry-run");
|
|
const [mode = "commit", rev = "HEAD"] = args.filter((a) => !a.startsWith("--"));
|
|
|
|
const git = (...a) =>
|
|
execFileSync("git", a, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }).trim();
|
|
const gitConfig = (key, fallback) => {
|
|
try {
|
|
return git("config", "--get", key);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
const mapPath = git("rev-parse", "--git-path", "project-board.json");
|
|
const loadMap = () => (existsSync(mapPath) ? JSON.parse(readFileSync(mapPath, "utf8")) : {});
|
|
const saveMap = (map) => dryRun || writeFileSync(mapPath, JSON.stringify(map, null, 2) + "\n");
|
|
|
|
function forge() {
|
|
const remote = git("remote", "get-url", "origin");
|
|
const match = remote.match(/^https?:\/\/(?:[^@/]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
if (!match) throw new Error(`Unsupported origin URL: ${remote}`);
|
|
const [, host, owner, repo] = match;
|
|
let authorization;
|
|
if (process.env.FORGEJO_TOKEN) authorization = `token ${process.env.FORGEJO_TOKEN}`;
|
|
else {
|
|
const fill = execFileSync("git", ["credential", "fill"], {
|
|
input: `protocol=https\nhost=${host}\n\n`,
|
|
encoding: "utf8",
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
});
|
|
const field = (k) => fill.match(new RegExp(`^${k}=(.*)$`, "m"))?.[1];
|
|
authorization = `Basic ${Buffer.from(`${field("username")}:${field("password")}`).toString("base64")}`;
|
|
}
|
|
const base = `https://${host}/api/v1/repos/${owner}/${repo}`;
|
|
return async (method, path, body) => {
|
|
const writes = method !== "GET";
|
|
if (writes && dryRun) {
|
|
console.log(`[dry-run] ${method} ${path}${body ? " " + JSON.stringify(body) : ""}`);
|
|
return method === "POST" && path === "/issues" ? { id: 0, number: 0 } : null;
|
|
}
|
|
const response = await fetch(base + path, {
|
|
method,
|
|
headers: { authorization, "content-type": "application/json" },
|
|
body: body && JSON.stringify(body),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`);
|
|
const text = await response.text();
|
|
return text ? JSON.parse(text) : null;
|
|
};
|
|
}
|
|
|
|
async function board(api) {
|
|
const project = gitConfig("board.project", "1");
|
|
const columns = await api("GET", `/projects/${project}/columns`);
|
|
const id = {};
|
|
for (const [key, title] of Object.entries(COLUMNS)) {
|
|
const column = columns.find((c) => c.title === title);
|
|
if (!column) throw new Error(`Project ${project} has no "${title}" column`);
|
|
id[key] = column.id;
|
|
}
|
|
for (const [key, title] of Object.entries(OPTIONAL_COLUMNS)) {
|
|
const column = columns.find((c) => c.title === title);
|
|
if (column) id[key] = column.id;
|
|
}
|
|
const membership = new Map();
|
|
for (const [key, columnId] of Object.entries(id))
|
|
for (let page = 1; ; page++) {
|
|
const issues = await api("GET", `/projects/${project}/columns/${columnId}/issues?limit=50&page=${page}`);
|
|
for (const issue of issues) membership.set(issue.number, key);
|
|
if (issues.length < 50) break;
|
|
}
|
|
return { project, id, membership };
|
|
}
|
|
|
|
// Adds the short hash to the card's "Commit:"/"Commits:" line, or starts one.
|
|
function withCommit(body, short) {
|
|
if (body.includes(short)) return body;
|
|
const lines = body.trimEnd().split("\n");
|
|
const last = lines.findLastIndex((l) => /^Commits?: /.test(l));
|
|
if (last < 0) return `${body.trimEnd()}${body.trim() ? "\n\n" : ""}Commit: ${short}`;
|
|
lines[last] = lines[last].replace(/^Commits?: (.*)$/, (_, list) => `Commits: ${list}, ${short}`);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
async function onCommit() {
|
|
if (gitConfig("board.disabled", "false") === "true") return;
|
|
// Amends and rebases run post-commit too; post-rewrite moves their hashes instead.
|
|
if (rev === "HEAD" && /^(commit \(amend\)|rebase)/.test(git("reflog", "-1", "--format=%gs", "HEAD")))
|
|
return;
|
|
const hash = git("rev-parse", rev);
|
|
const short = git("rev-parse", "--short=7", hash);
|
|
if (git("rev-list", "--parents", "-n", "1", hash).split(" ").length > 2) return;
|
|
const message = git("log", "-1", "--format=%B", hash);
|
|
if (message.includes("[no-card]")) return;
|
|
|
|
const branch = git("rev-parse", "--abbrev-ref", "HEAD");
|
|
const onDefault = branch === gitConfig("board.branch", "main");
|
|
const closing = new Set([...message.matchAll(CLOSING)].map((m) => +m[1]));
|
|
const referenced = new Set([...message.matchAll(REFERENCE)].map((m) => +m[1]));
|
|
|
|
const api = forge();
|
|
const { project, id, membership } = await board(api);
|
|
const touched = [];
|
|
|
|
for (const number of referenced) {
|
|
let issue;
|
|
try {
|
|
issue = await api("GET", `/issues/${number}`);
|
|
} catch {
|
|
console.warn(`project-board: #${number} not found, skipped`);
|
|
continue;
|
|
}
|
|
const done = onDefault && closing.has(number);
|
|
// A card already done stays there when a later commit only mentions it.
|
|
const target = done || membership.get(number) === "done" ? "done" : "doing";
|
|
if (membership.get(number) === undefined)
|
|
await api("POST", `/projects/${project}/columns/${id[target]}/issues/${issue.id}`);
|
|
else if (membership.get(number) !== target)
|
|
await api("POST", `/projects/${project}/issues/${issue.id}/move`, { column_id: id[target] });
|
|
const patch = { body: withCommit(issue.body ?? "", short) };
|
|
if (done && issue.state !== "closed") patch.state = "closed";
|
|
if (patch.body !== issue.body || patch.state) await api("PATCH", `/issues/${number}`, patch);
|
|
touched.push(number);
|
|
console.log(`project-board: #${number} → ${COLUMNS[target]}`);
|
|
}
|
|
|
|
if (!referenced.size && onDefault) {
|
|
const [title, ...rest] = message.replace(/^Co-Authored-By:.*$/gim, "").trim().split("\n");
|
|
const body = withCommit(rest.join("\n").trim(), short);
|
|
const issue = await api("POST", "/issues", { title, body });
|
|
await api("POST", `/projects/${project}/columns/${id.done}/issues/${issue.id}`);
|
|
await api("PATCH", `/issues/${issue.number}`, { state: "closed" });
|
|
touched.push(issue.number);
|
|
console.log(`project-board: created #${issue.number} in ${COLUMNS.done}`);
|
|
}
|
|
|
|
if (touched.length) {
|
|
const map = loadMap();
|
|
map[hash] = touched;
|
|
saveMap(map);
|
|
}
|
|
}
|
|
|
|
async function onRewrite() {
|
|
const map = loadMap();
|
|
const pairs = readFileSync(0, "utf8")
|
|
.split("\n")
|
|
.map((line) => line.split(" "))
|
|
.filter(([from, to]) => from && to && map[from]);
|
|
if (!pairs.length) return;
|
|
const api = forge();
|
|
for (const [from, to] of pairs) {
|
|
const [oldShort, newShort] = [from, to].map((h) => h.slice(0, 7));
|
|
for (const number of map[from]) {
|
|
const issue = await api("GET", `/issues/${number}`);
|
|
if (issue.body?.includes(oldShort))
|
|
await api("PATCH", `/issues/${number}`, { body: issue.body.replaceAll(oldShort, newShort) });
|
|
}
|
|
map[to] = map[from];
|
|
delete map[from];
|
|
console.log(`project-board: ${oldShort} → ${newShort} on #${map[to].join(", #")}`);
|
|
}
|
|
saveMap(map);
|
|
}
|
|
|
|
try {
|
|
await (mode === "rewrite" ? onRewrite() : onCommit());
|
|
} catch (error) {
|
|
// A board failure must never get in the way of committing.
|
|
console.warn(`project-board: ${error.message}`);
|
|
console.warn(`project-board: rerun with "node scripts/project-board.mjs commit <rev>"`);
|
|
}
|