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:
1 parent
209778cabe
commit
38b9562bd3
4 files changed
+204
No files matched your search
@@ -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>"`);
|
||||
}
|
||||
Reference in new issue
Block a user