@hjr15/blaze-board 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +141 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +38 -0
- package/scripts/cli.mjs +31 -0
- package/scripts/commit-or-queue.mjs +22 -0
- package/scripts/commit-runner.mjs +40 -0
- package/scripts/config.mjs +135 -0
- package/scripts/edit-runner.mjs +18 -0
- package/scripts/edit.mjs +85 -0
- package/scripts/event-bus.mjs +16 -0
- package/scripts/log-runner.mjs +38 -0
- package/scripts/log.mjs +44 -0
- package/scripts/loops/groomer.mjs +215 -0
- package/scripts/migrate/audit.mjs +92 -0
- package/scripts/migrate/jira-client.mjs +26 -0
- package/scripts/migrate/jira-import.mjs +96 -0
- package/scripts/migrate/map.mjs +110 -0
- package/scripts/migrate/merge.mjs +103 -0
- package/scripts/migrate/normalize.mjs +60 -0
- package/scripts/migrate/report.mjs +67 -0
- package/scripts/migrate/restructure.mjs +49 -0
- package/scripts/migrate-runner.mjs +51 -0
- package/scripts/model/.gitkeep +0 -0
- package/scripts/model/ids.mjs +32 -0
- package/scripts/model/index.mjs +63 -0
- package/scripts/model/move-plan.mjs +25 -0
- package/scripts/model/rollup.mjs +55 -0
- package/scripts/model/rules.mjs +64 -0
- package/scripts/model/schema.mjs +30 -0
- package/scripts/model/ticket.mjs +136 -0
- package/scripts/model/time.mjs +38 -0
- package/scripts/model/workflows.mjs +54 -0
- package/scripts/move-runner.mjs +18 -0
- package/scripts/move.mjs +56 -0
- package/scripts/new-runner.mjs +43 -0
- package/scripts/new.mjs +54 -0
- package/scripts/pending-ledger.mjs +36 -0
- package/scripts/reconcile.mjs +181 -0
- package/scripts/reindex.mjs +22 -0
- package/scripts/resolve-runner.mjs +17 -0
- package/scripts/resolve.mjs +21 -0
- package/scripts/rollup-runner.mjs +53 -0
- package/scripts/serve-commit.mjs +18 -0
- package/scripts/serve.mjs +658 -0
- package/scripts/supervisor.mjs +192 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// scripts/model/ticket.mjs — parse/serialize a Blaze ticket: a YAML-subset
|
|
2
|
+
// frontmatter block + a markdown body. Zero-dependency; handles exactly the
|
|
3
|
+
// field shapes Blaze uses (scalars, flow arrays [a,b], block lists of inline
|
|
4
|
+
// {k: v} objects). Not a general YAML parser — round-trip safe for our schema.
|
|
5
|
+
|
|
6
|
+
const DELIM = "---";
|
|
7
|
+
|
|
8
|
+
function coerceScalar(raw) {
|
|
9
|
+
const s = raw.trim();
|
|
10
|
+
if (s === "" || s === "null" || s === "~") return null;
|
|
11
|
+
if (s === "true") return true;
|
|
12
|
+
if (s === "false") return false;
|
|
13
|
+
if (/^-?\d+$/.test(s)) return Number(s);
|
|
14
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
15
|
+
// Double-quoted: decode JSON escape sequences (dumpScalar uses JSON.stringify to
|
|
16
|
+
// quote strings containing commas, so we must reverse that here).
|
|
17
|
+
try { return JSON.parse(s); } catch { return s.slice(1, -1); }
|
|
18
|
+
}
|
|
19
|
+
if (s.startsWith("'") && s.endsWith("'")) {
|
|
20
|
+
return s.slice(1, -1);
|
|
21
|
+
}
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function splitTopLevel(s, sep) {
|
|
26
|
+
const out = [];
|
|
27
|
+
let depth = 0, quote = null, cur = "";
|
|
28
|
+
for (const ch of s) {
|
|
29
|
+
if (quote) { if (ch === quote) quote = null; cur += ch; continue; }
|
|
30
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
|
31
|
+
if (ch === "{" || ch === "[") depth++;
|
|
32
|
+
else if (ch === "}" || ch === "]") depth--;
|
|
33
|
+
if (ch === sep && depth === 0) { out.push(cur); cur = ""; continue; }
|
|
34
|
+
cur += ch;
|
|
35
|
+
}
|
|
36
|
+
if (cur.trim() !== "") out.push(cur);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseFlowArray(raw) {
|
|
41
|
+
const inner = raw.trim().slice(1, -1).trim();
|
|
42
|
+
if (inner === "") return [];
|
|
43
|
+
return splitTopLevel(inner, ",").map(coerceScalar);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Note: inline-object values containing a comma must be quoted in the source file
|
|
47
|
+
// (e.g. `{ k: "a, b" }`). The serializer (dumpScalar) always quotes such values;
|
|
48
|
+
// hand-written files must do the same — an unquoted comma splits the value.
|
|
49
|
+
function parseInlineObject(raw) {
|
|
50
|
+
const inner = raw.trim().slice(1, -1).trim();
|
|
51
|
+
const obj = {};
|
|
52
|
+
for (const part of splitTopLevel(inner, ",")) {
|
|
53
|
+
const idx = part.indexOf(":");
|
|
54
|
+
if (idx === -1) continue;
|
|
55
|
+
obj[part.slice(0, idx).trim()] = coerceScalar(part.slice(idx + 1));
|
|
56
|
+
}
|
|
57
|
+
return obj;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseTicket(text) {
|
|
61
|
+
const lines = text.split("\n");
|
|
62
|
+
if (lines[0].trim() !== DELIM) throw new Error("ticket: missing frontmatter (--- on line 1)");
|
|
63
|
+
const fm = {};
|
|
64
|
+
let i = 1;
|
|
65
|
+
for (; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i];
|
|
67
|
+
if (line.trim() === DELIM) { i++; break; }
|
|
68
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
|
|
69
|
+
// Only identifier-style keys (alphanumeric + underscore) are matched.
|
|
70
|
+
// Hyphenated YAML keys (e.g. "some-key") are intentionally ignored — they
|
|
71
|
+
// are not part of the Blaze schema.
|
|
72
|
+
const m = /^([A-Za-z0-9_]+):(.*)$/.exec(line);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
const key = m[1];
|
|
75
|
+
const rest = m[2].trim();
|
|
76
|
+
if (rest === "") {
|
|
77
|
+
const items = [];
|
|
78
|
+
let j = i + 1;
|
|
79
|
+
while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
|
|
80
|
+
const itemRaw = lines[j].replace(/^\s+-\s+/, "").trim();
|
|
81
|
+
items.push(itemRaw.startsWith("{") ? parseInlineObject(itemRaw) : coerceScalar(itemRaw));
|
|
82
|
+
j++;
|
|
83
|
+
}
|
|
84
|
+
if (items.length > 0) { fm[key] = items; i = j - 1; } else fm[key] = null;
|
|
85
|
+
} else if (rest.startsWith("[")) {
|
|
86
|
+
fm[key] = parseFlowArray(rest);
|
|
87
|
+
} else {
|
|
88
|
+
fm[key] = coerceScalar(rest);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const body = lines.slice(i).join("\n").replace(/^\n+/, "");
|
|
92
|
+
return { frontmatter: fm, body };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const FIELD_ORDER = [
|
|
96
|
+
"id", "title", "type", "project", "priority", "resolution", "parent",
|
|
97
|
+
"assignee", "labels", "components", "estimate", "worklog", "links",
|
|
98
|
+
"likelihood", "impact", "branch", "pr", "created", "updated",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
function dumpScalar(v) {
|
|
102
|
+
if (v === null || v === undefined) return "";
|
|
103
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
104
|
+
const s = String(v);
|
|
105
|
+
// Quote strings containing a comma — an unquoted comma would break re-parse of a
|
|
106
|
+
// flow array [a, b] or an inline object { k: v, ... }. Colons in values are fine.
|
|
107
|
+
return s.includes(",") ? JSON.stringify(s) : s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function dumpInlineObject(obj) {
|
|
111
|
+
return `{ ${Object.entries(obj).map(([k, v]) => `${k}: ${dumpScalar(v)}`).join(", ")} }`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function serializeTicket({ frontmatter, body }) {
|
|
115
|
+
const keys = [
|
|
116
|
+
...FIELD_ORDER.filter((k) => k in frontmatter),
|
|
117
|
+
...Object.keys(frontmatter).filter((k) => !FIELD_ORDER.includes(k)),
|
|
118
|
+
];
|
|
119
|
+
const out = [DELIM];
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
const v = frontmatter[key];
|
|
122
|
+
if (Array.isArray(v)) {
|
|
123
|
+
if (v.length === 0) { out.push(`${key}: []`); continue; }
|
|
124
|
+
if (typeof v[0] === "object" && v[0] !== null) {
|
|
125
|
+
out.push(`${key}:`);
|
|
126
|
+
for (const item of v) out.push(` - ${dumpInlineObject(item)}`);
|
|
127
|
+
} else {
|
|
128
|
+
out.push(`${key}: [${v.map(dumpScalar).join(", ")}]`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
out.push(`${key}: ${dumpScalar(v)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
out.push(DELIM, "", body.replace(/\n+$/, ""), "");
|
|
135
|
+
return out.join("\n");
|
|
136
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// scripts/model/time.mjs — the single home for Blaze time policy: estimate
|
|
2
|
+
// rounding (5m), worklog rounding (1m, positive-only), and human formatting.
|
|
3
|
+
// Pure, zero-dependency. Consumed by new.mjs (estimate at create), log.mjs
|
|
4
|
+
// (worklog), and the rollup/board display.
|
|
5
|
+
|
|
6
|
+
// Estimate: round to the nearest 5 minutes. null/absent/non-finite/≤0 → null.
|
|
7
|
+
// A positive value that would round to 0 is bumped to 5 — a positive estimate
|
|
8
|
+
// never silently becomes "no estimate". (Spec §4.1.)
|
|
9
|
+
export function roundEstimate(min) {
|
|
10
|
+
const n = Number(min);
|
|
11
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
12
|
+
const r = Math.round(n / 5) * 5;
|
|
13
|
+
return r === 0 ? 5 : r;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Worklog: round to the nearest whole minute. Positive-only — throws on ≤0 or
|
|
17
|
+
// non-finite (the positive-minutes guard for `blaze log`). (Spec §4.1.)
|
|
18
|
+
export function roundWorklog(min) {
|
|
19
|
+
const n = Number(min);
|
|
20
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
21
|
+
throw new RangeError(`worklog minutes must be a positive number, got: ${min}`);
|
|
22
|
+
}
|
|
23
|
+
return Math.round(n);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Human display: "1h 30m" / "45m" / "2h". null/undefined → "" (board renders
|
|
27
|
+
// blank); 0 → "0m".
|
|
28
|
+
export function formatMinutes(min) {
|
|
29
|
+
if (min === null || min === undefined) return "";
|
|
30
|
+
const n = Number(min);
|
|
31
|
+
if (!Number.isFinite(n)) return "";
|
|
32
|
+
if (n === 0) return "0m";
|
|
33
|
+
const h = Math.floor(n / 60);
|
|
34
|
+
const m = n % 60;
|
|
35
|
+
if (h === 0) return `${m}m`;
|
|
36
|
+
if (m === 0) return `${h}h`;
|
|
37
|
+
return `${h}h ${m}m`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// scripts/model/workflows.mjs — Blaze type-scoped workflow definitions and
|
|
2
|
+
// the pure resolvers over them (statuses, terminal, transitions, resolution
|
|
3
|
+
// post-function). Config-driven; no I/O. Consumes the schema's type→workflow map.
|
|
4
|
+
import { workflowFor } from "./schema.mjs";
|
|
5
|
+
|
|
6
|
+
export const RESOLUTIONS = ["done", "wont-do", "duplicate", "cannot-reproduce"];
|
|
7
|
+
|
|
8
|
+
export const WORKFLOWS = {
|
|
9
|
+
delivery: {
|
|
10
|
+
statuses: ["defined", "in-progress", "in-review", "done"],
|
|
11
|
+
terminal: ["done"],
|
|
12
|
+
transitions: [["defined", "in-progress"], ["in-progress", "in-review"], ["in-review", "done"]],
|
|
13
|
+
reopenTo: "defined",
|
|
14
|
+
resolutionOnTerminal: { done: "done" },
|
|
15
|
+
},
|
|
16
|
+
goal: {
|
|
17
|
+
statuses: ["defined", "in-progress", "achieved"],
|
|
18
|
+
terminal: ["achieved"],
|
|
19
|
+
transitions: [["defined", "in-progress"], ["in-progress", "achieved"]],
|
|
20
|
+
reopenTo: "defined",
|
|
21
|
+
resolutionOnTerminal: { achieved: "done" },
|
|
22
|
+
},
|
|
23
|
+
risk: {
|
|
24
|
+
statuses: ["identified", "mitigated", "accepted", "obsolete"],
|
|
25
|
+
terminal: ["mitigated", "accepted", "obsolete"],
|
|
26
|
+
transitions: [["identified", "mitigated"], ["identified", "accepted"], ["identified", "obsolete"]],
|
|
27
|
+
reopenTo: "identified",
|
|
28
|
+
resolutionOnTerminal: { mitigated: "done", accepted: "done", obsolete: "wont-do" },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function workflowDef(type) {
|
|
33
|
+
const name = workflowFor(type); // throws on unknown type
|
|
34
|
+
const def = WORKFLOWS[name];
|
|
35
|
+
if (!def) throw new Error(`no workflow definition for "${name}" (type "${type}")`);
|
|
36
|
+
return def;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function statusesFor(type) { return workflowDef(type).statuses; }
|
|
40
|
+
export function isTerminal(type, status) { return workflowDef(type).terminal.includes(status); }
|
|
41
|
+
export function initialStatus(type) { return workflowDef(type).statuses[0]; }
|
|
42
|
+
|
|
43
|
+
export function canTransition(type, from, to) {
|
|
44
|
+
const def = workflowDef(type);
|
|
45
|
+
if (!def.statuses.includes(to)) return false;
|
|
46
|
+
if (to === def.reopenTo && from !== to) return true; // reopen from any other status
|
|
47
|
+
return def.transitions.some(([f, t]) => f === from && t === to);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolutionForTerminal(type, status) {
|
|
51
|
+
const def = workflowDef(type);
|
|
52
|
+
return Object.prototype.hasOwnProperty.call(def.resolutionOnTerminal, status)
|
|
53
|
+
? def.resolutionOnTerminal[status] : null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// scripts/move-runner.mjs — CLI entry for `blaze move <id> <status>`: applyMove
|
|
2
|
+
// against the resolved data tree, then commit (or queue) the relocation.
|
|
3
|
+
import { applyMove } from "./move.mjs";
|
|
4
|
+
import { loadConfig, resolveRoots } from "./config.mjs";
|
|
5
|
+
import { commitOrQueue } from "./commit-or-queue.mjs";
|
|
6
|
+
|
|
7
|
+
const { dataRoot, projectsDir } = resolveRoots();
|
|
8
|
+
const [id, toStatus] = process.argv.slice(2);
|
|
9
|
+
if (!id || !toStatus) { console.error("usage: blaze move <id> <status>"); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
12
|
+
const r = applyMove(projectsDir, id, toStatus, { today });
|
|
13
|
+
if (!r.ok) { console.error(`blaze move failed:\n ${r.errors.join("\n ")}`); process.exit(1); }
|
|
14
|
+
|
|
15
|
+
const cfg = loadConfig({ root: dataRoot });
|
|
16
|
+
const c = commitOrQueue({ root: dataRoot, mode: cfg.commitMode, op: "move", id, message: `${id}: ${r.from} → ${r.to}`, files: [r.fromFile, r.file] });
|
|
17
|
+
if (!c.ok) { console.error(`blaze move: file relocated but commit failed (status ${c.status}) — commit manually`); process.exit(1); }
|
|
18
|
+
console.log(`${id}: ${r.from} → ${r.to}${r.resolution ? ` (resolution: ${r.resolution})` : ""}${c.queued ? " (queued for blaze commit)" : ""}`);
|
package/scripts/move.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// scripts/move.mjs — `blaze move <id> <status>`: validate the transition, set
|
|
2
|
+
// resolution on terminal entry, rewrite frontmatter, and relocate the ticket file
|
|
3
|
+
// between status directories. applyMove() is pure-ish (fs only, no git) for tests;
|
|
4
|
+
// the CLI wrapper adds git add/commit.
|
|
5
|
+
import { writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { join, dirname, basename } from "node:path";
|
|
7
|
+
import { walkTickets } from "./model/index.mjs";
|
|
8
|
+
import { serializeTicket } from "./model/ticket.mjs";
|
|
9
|
+
import { planMove } from "./model/move-plan.mjs";
|
|
10
|
+
import { loadProject } from "./config.mjs";
|
|
11
|
+
|
|
12
|
+
function locate(projectsDir, id) {
|
|
13
|
+
let fallback = null;
|
|
14
|
+
for (const t of walkTickets(projectsDir)) {
|
|
15
|
+
if (t.frontmatter.id !== id) continue;
|
|
16
|
+
const projectKey = basename(dirname(dirname(t.file))); // projects/<KEY>/<status>/<file>
|
|
17
|
+
if (id.startsWith(`${projectKey}-`)) return t; // canonical: id prefix matches project dir
|
|
18
|
+
fallback ??= t;
|
|
19
|
+
}
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function applyMove(projectsDir, id, toStatus, opts = {}) {
|
|
24
|
+
const { today = null } = opts;
|
|
25
|
+
const found = locate(projectsDir, id);
|
|
26
|
+
if (!found) return { ok: false, errors: [`ticket not found: ${id}`] };
|
|
27
|
+
|
|
28
|
+
// requireWorklog: explicit opt wins; otherwise read the ticket's project config.
|
|
29
|
+
let requireWorklog = opts.requireWorklog;
|
|
30
|
+
if (requireWorklog === undefined) {
|
|
31
|
+
try {
|
|
32
|
+
const proj = loadProject(found.frontmatter.project, { root: dirname(projectsDir), projectsDir });
|
|
33
|
+
requireWorklog = proj.requireWorklogBeforeTerminal;
|
|
34
|
+
} catch { requireWorklog = false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hasWorklog = Array.isArray(found.frontmatter.worklog) && found.frontmatter.worklog.length > 0;
|
|
38
|
+
const plan = planMove({ frontmatter: found.frontmatter, body: found.body }, found.status, toStatus,
|
|
39
|
+
{ hasWorklog, requireWorklog });
|
|
40
|
+
if (!plan.ok) return { ok: false, errors: plan.errors };
|
|
41
|
+
|
|
42
|
+
const fm = { ...plan.frontmatter };
|
|
43
|
+
if (today) fm.updated = today;
|
|
44
|
+
const text = serializeTicket({ frontmatter: fm, body: plan.body });
|
|
45
|
+
|
|
46
|
+
// project key = the directory two levels up from the file (projects/<KEY>/<status>/file)
|
|
47
|
+
const statusDir = dirname(found.file);
|
|
48
|
+
const projectDir = dirname(statusDir);
|
|
49
|
+
const destDir = join(projectDir, toStatus);
|
|
50
|
+
const destFile = join(destDir, basename(found.file));
|
|
51
|
+
mkdirSync(destDir, { recursive: true });
|
|
52
|
+
writeFileSync(found.file, text);
|
|
53
|
+
if (destFile !== found.file) renameSync(found.file, destFile);
|
|
54
|
+
|
|
55
|
+
return { ok: true, id, from: found.status, to: toStatus, fromFile: found.file, file: destFile, resolution: plan.resolution };
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// scripts/new-runner.mjs — CLI entry for `blaze new`. Parses flags, calls
|
|
2
|
+
// applyNew against the resolved data tree, then commits (or queues) the ticket.
|
|
3
|
+
import { applyNew } from "./new.mjs";
|
|
4
|
+
import { loadConfig, resolveRoots } from "./config.mjs";
|
|
5
|
+
import { commitOrQueue } from "./commit-or-queue.mjs";
|
|
6
|
+
|
|
7
|
+
const { dataRoot, projectsDir } = resolveRoots();
|
|
8
|
+
const argv = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
const opts = { priority: "medium", labels: [], extra: {} };
|
|
11
|
+
const positional = [];
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const a = argv[i];
|
|
14
|
+
switch (a) {
|
|
15
|
+
case "--project": opts.project = argv[++i]; break;
|
|
16
|
+
case "--type": opts.type = argv[++i]; break;
|
|
17
|
+
case "--priority": opts.priority = argv[++i]; break;
|
|
18
|
+
case "--labels": opts.labels = argv[++i].split(",").map((s) => s.trim()).filter(Boolean); break;
|
|
19
|
+
case "--estimate": opts.extra.estimate = Number(argv[++i]); break;
|
|
20
|
+
case "--parent": opts.extra.parent = argv[++i]; break;
|
|
21
|
+
case "--assignee": opts.extra.assignee = argv[++i]; break;
|
|
22
|
+
case "--likelihood": opts.extra.likelihood = argv[++i]; break;
|
|
23
|
+
case "--impact": opts.extra.impact = argv[++i]; break;
|
|
24
|
+
default:
|
|
25
|
+
if (a.startsWith("--")) { console.error(`unknown flag: ${a}`); process.exit(1); }
|
|
26
|
+
positional.push(a);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
opts.title = positional.join(" ");
|
|
30
|
+
opts.today = new Date().toISOString().slice(0, 10);
|
|
31
|
+
|
|
32
|
+
if (!opts.project || !opts.type || !opts.title) {
|
|
33
|
+
console.error('usage: blaze new --project <KEY> --type <type> "<title>" [--priority p] [--labels a,b] [--estimate m] [--parent ID]');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const r = applyNew(projectsDir, opts);
|
|
38
|
+
if (!r.ok) { console.error(`blaze new failed:\n ${r.errors.join("\n ")}`); process.exit(1); }
|
|
39
|
+
|
|
40
|
+
const cfg = loadConfig({ root: dataRoot });
|
|
41
|
+
const c = commitOrQueue({ root: dataRoot, mode: cfg.commitMode, op: "new", id: r.id, message: `${r.id}: create ${r.type}`, files: [r.file] });
|
|
42
|
+
if (!c.ok) { console.error(`blaze new: file written but commit failed (status ${c.status}) — commit manually`); process.exit(1); }
|
|
43
|
+
console.log(`created ${r.id} → ${r.file}${c.queued ? " (queued for blaze commit)" : ""}`);
|
package/scripts/new.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// scripts/new.mjs — `blaze new`: allocate the next per-project id, build a
|
|
2
|
+
// schema-correct ticket, validate it, and write it into the type's initial
|
|
3
|
+
// status dir. Pure-fs (no git); the CLI wrapper adds the commit.
|
|
4
|
+
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { nextId } from "./model/ids.mjs";
|
|
7
|
+
import { isType } from "./model/schema.mjs";
|
|
8
|
+
import { initialStatus } from "./model/workflows.mjs";
|
|
9
|
+
import { serializeTicket } from "./model/ticket.mjs";
|
|
10
|
+
import { validateTicket } from "./model/rules.mjs";
|
|
11
|
+
import { roundEstimate } from "./model/time.mjs";
|
|
12
|
+
|
|
13
|
+
function slugify(s) {
|
|
14
|
+
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function applyNew(projectsDir, opts = {}) {
|
|
18
|
+
const { project, type, title, priority = "medium", labels = [], today = null, extra = {} } = opts;
|
|
19
|
+
const pre = [];
|
|
20
|
+
if (!project) pre.push("missing project (use --project <KEY>)");
|
|
21
|
+
if (!isType(type)) pre.push(`unknown or missing type: ${type}`);
|
|
22
|
+
if (!title) pre.push("missing title");
|
|
23
|
+
if (pre.length) return { ok: false, errors: pre };
|
|
24
|
+
|
|
25
|
+
const id = nextId(projectsDir, project);
|
|
26
|
+
const status = initialStatus(type);
|
|
27
|
+
const frontmatter = {
|
|
28
|
+
id, title, type, project, priority,
|
|
29
|
+
resolution: null,
|
|
30
|
+
parent: extra.parent ?? null,
|
|
31
|
+
assignee: extra.assignee ?? "unassigned",
|
|
32
|
+
labels, components: extra.components ?? [],
|
|
33
|
+
estimate: roundEstimate(extra.estimate),
|
|
34
|
+
likelihood: extra.likelihood ?? undefined,
|
|
35
|
+
impact: extra.impact ?? undefined,
|
|
36
|
+
created: today, updated: today,
|
|
37
|
+
};
|
|
38
|
+
// Drop undefined risk-only keys so they don't serialize for non-risk types.
|
|
39
|
+
if (frontmatter.likelihood === undefined) delete frontmatter.likelihood;
|
|
40
|
+
if (frontmatter.impact === undefined) delete frontmatter.impact;
|
|
41
|
+
|
|
42
|
+
const body = "## Context\n\n## Acceptance Criteria\n\n- [ ] \n\n## Notes\n";
|
|
43
|
+
// Validate everything except parent-existence (parent integrity is a reindex
|
|
44
|
+
// concern; at create time the parent may legitimately be created later).
|
|
45
|
+
const errors = validateTicket({ frontmatter, body }).filter((e) => !/parent not found/.test(e));
|
|
46
|
+
if (errors.length) return { ok: false, errors };
|
|
47
|
+
|
|
48
|
+
const dir = join(projectsDir, project, status);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
const file = join(dir, `${id}-${slugify(title)}.md`);
|
|
51
|
+
if (existsSync(file)) return { ok: false, errors: [`refusing to overwrite ${file}`] };
|
|
52
|
+
writeFileSync(file, serializeTicket({ frontmatter, body }));
|
|
53
|
+
return { ok: true, id, type, project, status, file };
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// scripts/pending-ledger.mjs — append-only JSONL ledger of pending board ops
|
|
2
|
+
// for batch commit mode. Lives in .blaze/ (gitignored); drained by `blaze commit`.
|
|
3
|
+
import { appendFileSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
export function ledgerPath(root) {
|
|
7
|
+
return join(root, ".blaze", "pending-commit.jsonl");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function appendEntry(root, entry) {
|
|
11
|
+
const path = ledgerPath(root);
|
|
12
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
13
|
+
appendFileSync(path, JSON.stringify(entry) + "\n"); // append-mode: atomic for the small single-line writes this ledger produces
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readEntries(root) {
|
|
17
|
+
const path = ledgerPath(root);
|
|
18
|
+
if (!existsSync(path)) return [];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
21
|
+
if (line.trim() === "") continue;
|
|
22
|
+
try {
|
|
23
|
+
out.push(JSON.parse(line));
|
|
24
|
+
} catch {
|
|
25
|
+
// A partial final line (process killed mid-append) or a corrupt line:
|
|
26
|
+
// skip rather than throw so a good ledger still drains. Warn so the drop is visible.
|
|
27
|
+
process.stderr.write("blaze: skipping unparseable pending-commit ledger line\n");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearLedger(root) {
|
|
34
|
+
const path = ledgerPath(root);
|
|
35
|
+
if (existsSync(path)) writeFileSync(path, "");
|
|
36
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// reconcile.mjs — make the board mirror git/PR state across every project and
|
|
3
|
+
// every repo a project spans. Git is the source of truth; the board is a live
|
|
4
|
+
// mirror. The join key is the <KEY>-<n> in each branch/PR head ref, resolved
|
|
5
|
+
// per project. (Phase-3 design §3.)
|
|
6
|
+
//
|
|
7
|
+
// node scripts/reconcile.mjs # dry-run: print would-be moves (default)
|
|
8
|
+
// node scripts/reconcile.mjs --apply # commit locally (never pushes)
|
|
9
|
+
// node scripts/reconcile.mjs --fetch # fetch from remotes before reconciling
|
|
10
|
+
// node scripts/reconcile.mjs --quiet # print only on change
|
|
11
|
+
//
|
|
12
|
+
// Only DELIVERY-workflow types (epic/story/task/bug) mirror git state; goal/risk
|
|
13
|
+
// are manual. A ticket with no branch and no PR is never touched. Terminal status
|
|
14
|
+
// is sticky (a done ticket stays done). With no projects configured, it's a no-op.
|
|
15
|
+
//
|
|
16
|
+
// Zero dependencies — Node built-ins + shelling to `git`/`gh`.
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
|
|
20
|
+
import { join, dirname, basename } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { loadConfig, listProjects, loadProject, resolveRoots } from "./config.mjs";
|
|
23
|
+
import { walkTickets } from "./model/index.mjs";
|
|
24
|
+
import { serializeTicket } from "./model/ticket.mjs";
|
|
25
|
+
import { isType, workflowFor } from "./model/schema.mjs";
|
|
26
|
+
import { isTerminal, resolutionForTerminal } from "./model/workflows.mjs";
|
|
27
|
+
|
|
28
|
+
const PR_RANK = { MERGED: 3, OPEN: 2, CLOSED: 1 };
|
|
29
|
+
|
|
30
|
+
function sh(cmd, args, opts = {}) {
|
|
31
|
+
try {
|
|
32
|
+
return execFileSync(cmd, args, {
|
|
33
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
34
|
+
maxBuffer: 64 * 1024 * 1024, ...opts,
|
|
35
|
+
}).trim();
|
|
36
|
+
} catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- pure decision: git signal + current status + type → target status --------
|
|
40
|
+
export function decide({ pr, branch }, currentStatus, type) {
|
|
41
|
+
// Only delivery-workflow types mirror git state; goal/risk stay manual.
|
|
42
|
+
if (!isType(type) || workflowFor(type) !== "delivery") {
|
|
43
|
+
return { target: currentStatus, branchVal: null, prVal: null, moved: false, skip: true, resolution: undefined };
|
|
44
|
+
}
|
|
45
|
+
let target, branchVal = null, prVal = null;
|
|
46
|
+
if (pr) {
|
|
47
|
+
// Delivery workflow middle statuses ("in-review"/"in-progress") are intentional literals here;
|
|
48
|
+
// this function is already delivery-guarded above, so there's no need to re-derive them from rules.
|
|
49
|
+
target = pr.state === "MERGED" ? "done" : pr.state === "OPEN" ? "in-review" : "in-progress";
|
|
50
|
+
branchVal = pr.headRefName;
|
|
51
|
+
prVal = `#${pr.number} — ${pr.url}`;
|
|
52
|
+
} else if (branch) {
|
|
53
|
+
target = "in-progress";
|
|
54
|
+
branchVal = branch;
|
|
55
|
+
} else {
|
|
56
|
+
return { target: currentStatus, branchVal: null, prVal: null, moved: false, skip: true, resolution: undefined };
|
|
57
|
+
}
|
|
58
|
+
// Terminal-sticky: never pull a ticket out of a terminal status automatically.
|
|
59
|
+
if (isTerminal(type, currentStatus)) target = currentStatus;
|
|
60
|
+
const moved = target !== currentStatus;
|
|
61
|
+
const resolution = isTerminal(type, target) ? resolutionForTerminal(type, target) : undefined;
|
|
62
|
+
return { target, branchVal, prVal, moved, skip: false, resolution };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- gather one repo's PR + branch signal, keyed by a project's idFromRef ------
|
|
66
|
+
function gatherRepo(repoPath, idFromRef, { fetch }) {
|
|
67
|
+
const empty = { prMap: new Map(), branchMap: new Map() };
|
|
68
|
+
if (!existsSync(repoPath) || !existsSync(join(repoPath, ".git"))) return empty;
|
|
69
|
+
if (fetch) sh("git", ["-C", repoPath, "fetch", "--prune", "--quiet"], { timeout: 30000 });
|
|
70
|
+
|
|
71
|
+
const prMap = new Map();
|
|
72
|
+
const prJson = sh("gh", ["pr", "list", "--state", "all", "--limit", "1000",
|
|
73
|
+
"--json", "number,url,headRefName,state"], { cwd: repoPath });
|
|
74
|
+
for (const pr of JSON.parse(prJson || "[]")) {
|
|
75
|
+
const id = idFromRef(pr.headRefName);
|
|
76
|
+
if (!id) continue;
|
|
77
|
+
const cur = prMap.get(id);
|
|
78
|
+
const better = !cur || (PR_RANK[pr.state] || 0) > (PR_RANK[cur.state] || 0) ||
|
|
79
|
+
((PR_RANK[pr.state] || 0) === (PR_RANK[cur.state] || 0) && pr.number > cur.number);
|
|
80
|
+
if (better) prMap.set(id, pr);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const branchMap = new Map();
|
|
84
|
+
const refs = sh("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short)",
|
|
85
|
+
"refs/heads", "refs/remotes/origin"]) || "";
|
|
86
|
+
for (let ref of refs.split("\n")) {
|
|
87
|
+
ref = ref.replace(/^origin\//, "").trim();
|
|
88
|
+
if (!ref || ref === "HEAD") continue;
|
|
89
|
+
const id = idFromRef(ref);
|
|
90
|
+
if (id && !branchMap.has(id)) branchMap.set(id, ref);
|
|
91
|
+
}
|
|
92
|
+
return { prMap, branchMap };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- aggregate the most-advanced signal across all of a project's repos -------
|
|
96
|
+
function gatherProject(project, { fetch }) {
|
|
97
|
+
const prMap = new Map(), branchMap = new Map();
|
|
98
|
+
for (const repo of project.codeRepoPaths) {
|
|
99
|
+
const r = gatherRepo(repo, project.idFromRef, { fetch });
|
|
100
|
+
for (const [id, pr] of r.prMap) {
|
|
101
|
+
const cur = prMap.get(id);
|
|
102
|
+
if (!cur || (PR_RANK[pr.state] || 0) > (PR_RANK[cur.state] || 0)) prMap.set(id, pr);
|
|
103
|
+
}
|
|
104
|
+
for (const [id, b] of r.branchMap) if (!branchMap.has(id)) branchMap.set(id, b);
|
|
105
|
+
}
|
|
106
|
+
return { prMap, branchMap };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- the reconcile pass -------------------------------------------------------
|
|
110
|
+
export function reconcile({
|
|
111
|
+
fetch = false, commit = false, push = false, dryRun = true, root, projectsDir,
|
|
112
|
+
} = {}) {
|
|
113
|
+
// root left unset → honour BOTH resolved values (dataRoot + projectsDir, even
|
|
114
|
+
// when custom-named via BLAZE_PROJECTS_DIR). An explicit root (existing
|
|
115
|
+
// callers/tests) keeps the pre-existing join(root, "projects") behaviour.
|
|
116
|
+
const explicitRoot = root !== undefined;
|
|
117
|
+
const resolved = resolveRoots();
|
|
118
|
+
root ??= resolved.dataRoot;
|
|
119
|
+
projectsDir ??= explicitRoot ? join(root, "projects") : resolved.projectsDir;
|
|
120
|
+
|
|
121
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
122
|
+
const cfg = loadConfig({ root });
|
|
123
|
+
const keys = listProjects(cfg);
|
|
124
|
+
if (!keys.length) return { ok: true, standalone: true, changes: [], committed: false, pushed: false };
|
|
125
|
+
|
|
126
|
+
const sig = new Map();
|
|
127
|
+
for (const key of keys) sig.set(key, gatherProject(loadProject(key, { root, projectsDir }), { fetch }));
|
|
128
|
+
|
|
129
|
+
const changes = [];
|
|
130
|
+
for (const t of walkTickets(projectsDir)) {
|
|
131
|
+
const type = t.frontmatter.type;
|
|
132
|
+
const s = sig.get(t.frontmatter.project);
|
|
133
|
+
if (!s) continue;
|
|
134
|
+
const d = decide({ pr: s.prMap.get(t.frontmatter.id), branch: s.branchMap.get(t.frontmatter.id) }, t.status, type);
|
|
135
|
+
if (d.skip) continue;
|
|
136
|
+
|
|
137
|
+
const fm = { ...t.frontmatter };
|
|
138
|
+
let dirty = false;
|
|
139
|
+
if (d.branchVal && fm.branch !== d.branchVal) { fm.branch = d.branchVal; dirty = true; }
|
|
140
|
+
if (d.prVal && fm.pr !== d.prVal) { fm.pr = d.prVal; dirty = true; }
|
|
141
|
+
if (d.resolution !== undefined && fm.resolution !== d.resolution) { fm.resolution = d.resolution; dirty = true; }
|
|
142
|
+
if (d.moved) { fm.updated = today; dirty = true; }
|
|
143
|
+
if (!dirty) continue;
|
|
144
|
+
|
|
145
|
+
// Always record the would-be change; only write files when not a dry-run
|
|
146
|
+
changes.push({ id: t.frontmatter.id, from: t.status, to: d.target, moved: d.moved });
|
|
147
|
+
|
|
148
|
+
if (!dryRun) {
|
|
149
|
+
writeFileSync(t.file, serializeTicket({ frontmatter: fm, body: t.body }));
|
|
150
|
+
if (d.moved) {
|
|
151
|
+
const projectDir = dirname(dirname(t.file));
|
|
152
|
+
const destDir = join(projectDir, d.target);
|
|
153
|
+
mkdirSync(destDir, { recursive: true });
|
|
154
|
+
const dest = join(destDir, basename(t.file));
|
|
155
|
+
if (dest !== t.file) renameSync(t.file, dest);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let committed = false;
|
|
161
|
+
if (!dryRun && commit && changes.length) {
|
|
162
|
+
sh("git", ["-C", root, "add", "-A"]);
|
|
163
|
+
committed = sh("git", ["-C", root, "commit", "-m",
|
|
164
|
+
`chore(board): reconcile ${changes.length} ticket(s) to git state`]) !== null;
|
|
165
|
+
}
|
|
166
|
+
// push is never performed — hardcoded false regardless of the push param
|
|
167
|
+
return { ok: true, changes, committed, pushed: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- CLI ----------------------------------------------------------------------
|
|
171
|
+
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
172
|
+
const args = process.argv.slice(2);
|
|
173
|
+
const apply = args.includes("--apply");
|
|
174
|
+
const quiet = args.includes("--quiet");
|
|
175
|
+
const r = reconcile({ fetch: args.includes("--fetch"), commit: apply, push: false, dryRun: !apply });
|
|
176
|
+
if (!r.ok) { console.error(`reconcile: ${r.error}`); process.exit(1); }
|
|
177
|
+
if (r.standalone) { if (!quiet) console.log("reconcile: no projects configured — nothing to reconcile."); process.exit(0); }
|
|
178
|
+
if (!r.changes.length) { if (!quiet) console.log("reconcile: already in sync — nothing to do."); process.exit(0); }
|
|
179
|
+
for (const c of r.changes) console.log(`${apply ? "moved" : "would move"} ${c.id}: ${c.from} → ${c.to}`);
|
|
180
|
+
if (!apply) console.log(`(dry-run: ${r.changes.length} change(s); rerun with --apply to write locally — reconcile never pushes)`);
|
|
181
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// scripts/reindex.mjs — rebuild the derived index from projects/ markdown and
|
|
2
|
+
// cache it to .blaze/index.json. Run: node scripts/reindex.mjs [projectsDir]
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { buildIndex } from "./model/index.mjs";
|
|
6
|
+
import { resolveRoots } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
const { dataRoot, projectsDir: defaultProjectsDir } = resolveRoots();
|
|
9
|
+
const projectsDir = process.argv[2] || defaultProjectsDir;
|
|
10
|
+
const dbDir = process.env.BLAZE_DB_DIR || join(dataRoot, ".blaze");
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
mkdirSync(dbDir, { recursive: true });
|
|
14
|
+
const idx = buildIndex(projectsDir);
|
|
15
|
+
const out = join(dbDir, "index.json");
|
|
16
|
+
writeFileSync(out, JSON.stringify(idx.toJSON(), null, 2));
|
|
17
|
+
const c = idx.count();
|
|
18
|
+
console.log(`indexed ${c} ticket${c === 1 ? "" : "s"} → ${out}`);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.error(`blaze reindex failed: ${e.message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// scripts/resolve-runner.mjs — CLI entry for `blaze resolve <id> <resolution>`.
|
|
2
|
+
import { applyResolve } from "./resolve.mjs";
|
|
3
|
+
import { loadConfig, resolveRoots } from "./config.mjs";
|
|
4
|
+
import { commitOrQueue } from "./commit-or-queue.mjs";
|
|
5
|
+
|
|
6
|
+
const { dataRoot, projectsDir } = resolveRoots();
|
|
7
|
+
const [id, resolution] = process.argv.slice(2);
|
|
8
|
+
if (!id || !resolution) { console.error("usage: blaze resolve <id> <resolution>"); process.exit(1); }
|
|
9
|
+
|
|
10
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
11
|
+
const r = applyResolve(projectsDir, id, resolution, { today });
|
|
12
|
+
if (!r.ok) { console.error(`blaze resolve failed:\n ${r.errors.join("\n ")}`); process.exit(1); }
|
|
13
|
+
|
|
14
|
+
const cfg = loadConfig({ root: dataRoot });
|
|
15
|
+
const c = commitOrQueue({ root: dataRoot, mode: cfg.commitMode, op: "resolve", id, message: `${id}: resolution → ${resolution}`, files: [r.file] });
|
|
16
|
+
if (!c.ok) { console.error(`blaze resolve: file updated but commit failed (status ${c.status}) — commit manually`); process.exit(1); }
|
|
17
|
+
console.log(`${id}: resolution → ${resolution}${c.queued ? " (queued for blaze commit)" : ""}`);
|