// Keeps the forge's project board in step with commits. Run by .githooks. // // node scripts/project-board.mjs commit [] [--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 = /(? !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 "`); }