Keep the project board in step with commits
Checks / web (push) Canceled after 0s

A post-commit hook moves the cards a commit references (Closes #N to
Fait, other #N to En cours) and adds a closed Fait card for an
unreferenced commit on main. A post-rewrite hook carries the commit
hashes over amends and rebases. Enable with
git config core.hooksPath .githooks.

[no-card]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-01 11:48:19 +02:00
1 parent 209778cabe
commit 38b9562bd3
4 files changed
+204

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
// 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. 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" };
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;
}
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>"`);
}