diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100755 index 0000000..4d7aa86 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +# Moves or creates the project board card for this commit (scripts/project-board.mjs). +exec node "$(git rev-parse --show-toplevel)/scripts/project-board.mjs" commit diff --git a/.githooks/post-rewrite b/.githooks/post-rewrite new file mode 100755 index 0000000..88e7888 --- /dev/null +++ b/.githooks/post-rewrite @@ -0,0 +1,3 @@ +#!/bin/sh +# Carries the board's commit hashes over amends and rebases (scripts/project-board.mjs). +exec node "$(git rev-parse --show-toplevel)/scripts/project-board.mjs" rewrite diff --git a/README.md b/README.md index 5214d68..a6f477e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ npm run check:vendor Local-ROM tests use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root, or `CRYSTAL_ROM_PATH`. They skip when absent; no test downloads a ROM. Game files, private reference builds and gameplay screenshots must remain ignored. +`git config core.hooksPath .githooks` keeps the forge's project board in step with commits: `Closes #N` in a message moves card N to Fait and closes it, any other `#N` moves it to En cours, and a commit on `main` without a reference adds a closed card to Fait. `[no-card]` skips a commit. See [project-board.mjs](scripts/project-board.mjs). + The [full port plan](docs/PLAN.md) covers remaining game systems, save slots, the world map, Pokédex, internet Link, friends and optional mobile step support for egg hatching. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks. References: PRET [documentation](https://pret.github.io/pokecrystal/), [wiki](https://github.com/pret/pokecrystal/wiki) and [source](https://github.com/pret/pokecrystal). The development reference uses [binjgb](https://github.com/binji/binjgb) under its [MIT license](reference/public/vendor/binjgb/LICENSE); see [provenance](reference/public/vendor/binjgb/PROVENANCE.md). diff --git a/scripts/project-board.mjs b/scripts/project-board.mjs new file mode 100644 index 0000000..0fe0dc4 --- /dev/null +++ b/scripts/project-board.mjs @@ -0,0 +1,196 @@ +// 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. 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 = /(? !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 "`); +}