@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,103 @@
|
|
|
1
|
+
// scripts/migrate/merge.mjs — pure: fold approved merges. For each ledger
|
|
2
|
+
// `merge-into:A` on B, the survivor A absorbs B's worklog + typed links + any
|
|
3
|
+
// unique AC, plus a `Duplicate` breadcrumb pointing at B. B is folded away (logged,
|
|
4
|
+
// not written). Survivors excludes dropped + merged-away keys.
|
|
5
|
+
const AC_HEADING = "## Acceptance Criteria";
|
|
6
|
+
|
|
7
|
+
export function extractAC(description) {
|
|
8
|
+
const lines = String(description ?? "").split("\n");
|
|
9
|
+
const out = [];
|
|
10
|
+
let inAC = false;
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
if (line.trim() === AC_HEADING) { inAC = true; continue; }
|
|
13
|
+
if (inAC && /^##\s/.test(line.trim())) break;
|
|
14
|
+
if (inAC && /^\s*-\s*\[.?\]/.test(line)) out.push(line.trim());
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function appendAC(description, acLines) {
|
|
20
|
+
if (acLines.length === 0) return description;
|
|
21
|
+
const existing = new Set(extractAC(description).map((s) => s.replace(/\[.?\]/, "[ ]")));
|
|
22
|
+
const fresh = acLines.filter((s) => !existing.has(s.replace(/\[.?\]/, "[ ]")));
|
|
23
|
+
if (fresh.length === 0) return description;
|
|
24
|
+
if (!description.includes(AC_HEADING)) return `${description}\n\n${AC_HEADING}\n${fresh.join("\n")}\n`;
|
|
25
|
+
// Insert after the AC heading line.
|
|
26
|
+
const lines = description.split("\n");
|
|
27
|
+
const idx = lines.findIndex((l) => l.trim() === AC_HEADING);
|
|
28
|
+
lines.splice(idx + 1, 0, ...fresh);
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function foldMerges(byKey, dispositions) {
|
|
33
|
+
const dispById = new Map(dispositions.map((d) => [d.id, d.disposition]));
|
|
34
|
+
const folded = new Set();
|
|
35
|
+
const survivors = new Map();
|
|
36
|
+
|
|
37
|
+
// Seed survivors with kept/re-parented items (clone so we can mutate safely).
|
|
38
|
+
for (const [key, issue] of byKey) {
|
|
39
|
+
const d = dispById.get(key) || "keep";
|
|
40
|
+
if (d === "drop" || d.startsWith("merge-into:")) continue;
|
|
41
|
+
survivors.set(key, { ...issue, worklog: [...(issue.worklog || [])], links: [...(issue.links || [])] });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const disp of dispositions) {
|
|
45
|
+
if (!disp.disposition.startsWith("merge-into:")) continue;
|
|
46
|
+
const survivorKey = disp.disposition.slice("merge-into:".length);
|
|
47
|
+
const loser = byKey.get(disp.id);
|
|
48
|
+
const survivor = survivors.get(survivorKey);
|
|
49
|
+
folded.add(disp.id);
|
|
50
|
+
if (!loser || !survivor) continue;
|
|
51
|
+
survivor.worklog.push(...(loser.worklog || []));
|
|
52
|
+
for (const l of loser.links || []) {
|
|
53
|
+
// FIX 1: skip any link from the loser whose target is the survivor itself (self-link guard)
|
|
54
|
+
if (l.target === survivorKey) continue;
|
|
55
|
+
if (!survivor.links.some((x) => x.type === l.type && x.target === l.target)) survivor.links.push(l);
|
|
56
|
+
}
|
|
57
|
+
if (!survivor.links.some((x) => x.type === "Duplicate" && x.target === disp.id)) {
|
|
58
|
+
survivor.links.push({ type: "Duplicate", target: disp.id });
|
|
59
|
+
}
|
|
60
|
+
survivor.description = appendAC(survivor.description, extractAC(loser.description));
|
|
61
|
+
}
|
|
62
|
+
return { survivors, folded };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// FIX 2: resolve a survivor set's links against the final written-id set:
|
|
66
|
+
// - rewrite a NON-Duplicate link whose target is a merged-away loser → that loser's survivor
|
|
67
|
+
// - drop a NON-Duplicate link whose (post-rewrite) target is the survivor's own id (self-link)
|
|
68
|
+
// - drop a NON-Duplicate link whose (post-rewrite) target is not in the written-id set (dangling)
|
|
69
|
+
// - dedup links by type+target
|
|
70
|
+
// `Duplicate` links are EXEMPT (they are intentional breadcrumbs that may reference a
|
|
71
|
+
// merged-away / dropped id). Pure; returns cleaned survivors + an integrity report.
|
|
72
|
+
export function resolveLinkIntegrity(survivors, dispositions) {
|
|
73
|
+
const mergeMap = new Map();
|
|
74
|
+
for (const d of dispositions) {
|
|
75
|
+
if (typeof d.disposition === "string" && d.disposition.startsWith("merge-into:")) {
|
|
76
|
+
mergeMap.set(d.id, d.disposition.slice("merge-into:".length));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const writtenIds = new Set(survivors.keys());
|
|
80
|
+
const rewritten = [], dropped = [];
|
|
81
|
+
const out = new Map();
|
|
82
|
+
for (const [key, issue] of survivors) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const cleaned = [];
|
|
85
|
+
for (const link of issue.links || []) {
|
|
86
|
+
if (link.type === "Duplicate") { // breadcrumb — keep as-is (deduped)
|
|
87
|
+
const sig = `Duplicate::${link.target}`;
|
|
88
|
+
if (!seen.has(sig)) { seen.add(sig); cleaned.push({ type: link.type, target: link.target }); }
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
let target = link.target;
|
|
92
|
+
if (mergeMap.has(target)) { rewritten.push({ on: key, from: target, to: mergeMap.get(target), type: link.type }); target = mergeMap.get(target); }
|
|
93
|
+
if (target === key) { dropped.push({ on: key, target, type: link.type, reason: "self-link" }); continue; }
|
|
94
|
+
if (!writtenIds.has(target)) { dropped.push({ on: key, target, type: link.type, reason: "target not written" }); continue; }
|
|
95
|
+
const sig = `${link.type}::${target}`;
|
|
96
|
+
if (seen.has(sig)) continue;
|
|
97
|
+
seen.add(sig);
|
|
98
|
+
cleaned.push({ type: link.type, target });
|
|
99
|
+
}
|
|
100
|
+
out.set(key, { ...issue, links: cleaned });
|
|
101
|
+
}
|
|
102
|
+
return { survivors: out, integrity: { rewritten, dropped } };
|
|
103
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// scripts/migrate/normalize.mjs — pure: raw Jira-Cloud issue JSON → NormalizedIssue.
|
|
2
|
+
// Quarantines the raw payload shape so the downstream cores work on a stable shape.
|
|
3
|
+
// CUSTOM_FIELDS holds Risk custom-field IDs — real IDs discovered from a live
|
|
4
|
+
// payload at build time and edited here (placeholders until then).
|
|
5
|
+
export const CUSTOM_FIELDS = { likelihood: "customfield_10040", impact: "customfield_10004" };
|
|
6
|
+
|
|
7
|
+
// Best-effort ADF → text. A string passes through; null → "".
|
|
8
|
+
export function adfToText(node) {
|
|
9
|
+
if (node == null) return "";
|
|
10
|
+
if (typeof node === "string") return node;
|
|
11
|
+
let out = "";
|
|
12
|
+
if (node.text) out += node.text;
|
|
13
|
+
if (Array.isArray(node.content)) {
|
|
14
|
+
for (const child of node.content) out += adfToText(child);
|
|
15
|
+
if (node.type === "paragraph" || node.type === "heading") out += "\n";
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dateOnly(s) { return s ? String(s).slice(0, 10) : null; }
|
|
21
|
+
function fieldVal(v) { return v == null ? null : (typeof v === "object" ? (v.value ?? v.name ?? null) : v); }
|
|
22
|
+
// Worklog notes are stored as a single-line frontmatter inline-object value, so
|
|
23
|
+
// collapse any newlines (a multi-line value would break the YAML-subset re-parse).
|
|
24
|
+
function cleanNote(s) { if (!s) return null; const t = String(s).replace(/\s*\n\s*/g, " ").trim(); return t || null; }
|
|
25
|
+
|
|
26
|
+
export function normalizeIssue(raw, fields = CUSTOM_FIELDS) {
|
|
27
|
+
const f = raw.fields || {};
|
|
28
|
+
const worklogs = (f.worklog && f.worklog.worklogs) || [];
|
|
29
|
+
const links = [];
|
|
30
|
+
for (const l of f.issuelinks || []) {
|
|
31
|
+
const other = l.outwardIssue || l.inwardIssue;
|
|
32
|
+
if (l.type && l.type.name && other && other.key) links.push({ type: l.type.name, target: other.key });
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
key: raw.key,
|
|
36
|
+
project: (f.project && f.project.key) || (raw.key ? String(raw.key).split("-")[0] : null),
|
|
37
|
+
type: (f.issuetype && f.issuetype.name) || null,
|
|
38
|
+
summary: f.summary || "",
|
|
39
|
+
description: adfToText(f.description),
|
|
40
|
+
status: (f.status && f.status.name) || null,
|
|
41
|
+
statusCategory: (f.status && f.status.statusCategory && f.status.statusCategory.key) || null,
|
|
42
|
+
resolution: (f.resolution && f.resolution.name) || null,
|
|
43
|
+
priority: (f.priority && f.priority.name) || null,
|
|
44
|
+
assignee: (f.assignee && f.assignee.displayName) || null,
|
|
45
|
+
labels: f.labels || [],
|
|
46
|
+
components: (f.components || []).map((c) => c.name),
|
|
47
|
+
parent: (f.parent && f.parent.key) || null,
|
|
48
|
+
estimateSeconds: typeof f.timeoriginalestimate === "number" ? f.timeoriginalestimate : null,
|
|
49
|
+
worklog: worklogs.map((w) => ({
|
|
50
|
+
date: dateOnly(w.started), seconds: w.timeSpentSeconds || 0,
|
|
51
|
+
author: (w.author && w.author.displayName) || null,
|
|
52
|
+
note: cleanNote(typeof w.comment === "string" ? w.comment : adfToText(w.comment)),
|
|
53
|
+
})),
|
|
54
|
+
links,
|
|
55
|
+
likelihood: fieldVal(f[fields.likelihood]),
|
|
56
|
+
impact: fieldVal(f[fields.impact]),
|
|
57
|
+
created: dateOnly(f.created),
|
|
58
|
+
updated: dateOnly(f.updated),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// scripts/migrate/report.mjs — pure: render the human-readable MIGRATION-AUDIT.md
|
|
2
|
+
// and the disposition-ledger object. The report is the reviewable proposal; the
|
|
3
|
+
// ledger is the editable, authoritative sign-off artifact.
|
|
4
|
+
export function renderLedger(dispositions, source) {
|
|
5
|
+
return { source, items: dispositions };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function countByProjectType(norms) {
|
|
9
|
+
const m = {};
|
|
10
|
+
for (const n of norms) {
|
|
11
|
+
m[n.project] ??= {};
|
|
12
|
+
m[n.project][n.type] = (m[n.project][n.type] || 0) + 1;
|
|
13
|
+
}
|
|
14
|
+
return m;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderAudit({ norms, dispositions, restructure, warnings = [], integrity = { rewritten: [], dropped: [] } }) {
|
|
18
|
+
const drops = dispositions.filter((d) => d.disposition === "drop");
|
|
19
|
+
const merges = dispositions.filter((d) => d.disposition.startsWith("merge-into:"));
|
|
20
|
+
const keeps = dispositions.filter((d) => d.disposition === "keep" || d.disposition.startsWith("re-parent:"));
|
|
21
|
+
const counts = countByProjectType(norms);
|
|
22
|
+
|
|
23
|
+
const out = [];
|
|
24
|
+
out.push("# Migration Audit", "");
|
|
25
|
+
out.push(`**Source:** ${norms.length} issues · **kept:** ${keeps.length} · **dropped:** ${drops.length} · **merged:** ${merges.length}`, "");
|
|
26
|
+
|
|
27
|
+
out.push("## Counts (per project × type)", "");
|
|
28
|
+
for (const project of Object.keys(counts).sort()) {
|
|
29
|
+
out.push(`### ${project}`);
|
|
30
|
+
for (const type of Object.keys(counts[project]).sort()) out.push(`- ${type}: ${counts[project][type]}`);
|
|
31
|
+
out.push("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
out.push("## Dropped (non-Done terminal — NOT written)", "");
|
|
35
|
+
if (drops.length === 0) out.push("_none_", "");
|
|
36
|
+
for (const d of drops) out.push(`- ${d.id} — ${d.reason}`);
|
|
37
|
+
out.push("");
|
|
38
|
+
|
|
39
|
+
out.push("## Merge candidates (folded into survivor — NOT written)", "");
|
|
40
|
+
if (merges.length === 0) out.push("_none_", "");
|
|
41
|
+
for (const d of merges) out.push(`- ${d.id} → ${d.disposition.slice("merge-into:".length)} (${d.reason})`);
|
|
42
|
+
out.push("");
|
|
43
|
+
|
|
44
|
+
out.push("## Restructure flags", "");
|
|
45
|
+
const f = restructure.flags;
|
|
46
|
+
out.push(`- orphans: ${f.orphans.join(", ") || "_none_"}`);
|
|
47
|
+
out.push(`- mis-levelled: ${f.misLevelled.join(", ") || "_none_"}`);
|
|
48
|
+
out.push(`- ambiguous parent: ${f.ambiguous.join(", ") || "_none_"}`);
|
|
49
|
+
out.push(`- Relates→parent normalised: ${f.relatesNormalised.join(", ") || "_none_"}`, "");
|
|
50
|
+
|
|
51
|
+
out.push("## Warnings (unmapped / validation — review)", "");
|
|
52
|
+
if (warnings.length === 0) out.push("_none_", "");
|
|
53
|
+
for (const w of warnings) out.push(`- ${w}`);
|
|
54
|
+
out.push("");
|
|
55
|
+
|
|
56
|
+
out.push("## Link integrity", "");
|
|
57
|
+
if (integrity.rewritten.length === 0 && integrity.dropped.length === 0) {
|
|
58
|
+
out.push("_none_", "");
|
|
59
|
+
} else {
|
|
60
|
+
for (const r of integrity.rewritten) out.push(`- ${r.on}: ${r.type} ${r.from} → ${r.to}`);
|
|
61
|
+
for (const d of integrity.dropped) out.push(`- ${d.on}: dropped ${d.type} → ${d.target} (${d.reason})`);
|
|
62
|
+
out.push("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
out.push("---", "_Edit `migration/disposition-ledger.json` to override any disposition, then run `blaze migrate --live`._");
|
|
66
|
+
return out.join("\n");
|
|
67
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// scripts/migrate/restructure.mjs — pure: propose a coherent Goal/Epic hierarchy.
|
|
2
|
+
// Keeps legal native parents; normalises the free-tier Goal↔Epic Relates-link
|
|
3
|
+
// workaround (used when a paid Jira tier's native Epic-link isn't available)
|
|
4
|
+
// into a native parent; flags orphans / mis-levelled / ambiguous items.
|
|
5
|
+
// Output feeds the ledger's proposed_parent (the user signs off).
|
|
6
|
+
import { canParent, isType } from "../model/schema.mjs";
|
|
7
|
+
import { mapType } from "./map.mjs";
|
|
8
|
+
|
|
9
|
+
export function proposeStructure(norms) {
|
|
10
|
+
const byKey = new Map(norms.map((n) => [n.key, n]));
|
|
11
|
+
const typeOf = (key) => { const n = byKey.get(key); return n ? mapType(n.type) : null; };
|
|
12
|
+
|
|
13
|
+
const parents = new Map();
|
|
14
|
+
const flags = { orphans: [], misLevelled: [], ambiguous: [], relatesNormalised: [] };
|
|
15
|
+
|
|
16
|
+
for (const n of norms) {
|
|
17
|
+
const childType = mapType(n.type);
|
|
18
|
+
if (!isType(childType)) { parents.set(n.key, null); continue; }
|
|
19
|
+
|
|
20
|
+
// 1) Native parent, if legal.
|
|
21
|
+
if (n.parent) {
|
|
22
|
+
const pt = typeOf(n.parent);
|
|
23
|
+
if (pt && canParent(childType, pt)) { parents.set(n.key, n.parent); continue; }
|
|
24
|
+
if (pt) { parents.set(n.key, null); flags.misLevelled.push(n.key); continue; }
|
|
25
|
+
// unknown native parent key → fall through to Relates / orphan handling
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2) Relates-link candidates whose type is a legal parent.
|
|
29
|
+
const candidates = (n.links || [])
|
|
30
|
+
.filter((l) => l.type === "Relates")
|
|
31
|
+
.map((l) => l.target)
|
|
32
|
+
.filter((k) => { const pt = typeOf(k); return pt && canParent(childType, pt); });
|
|
33
|
+
|
|
34
|
+
if (candidates.length === 1) {
|
|
35
|
+
parents.set(n.key, candidates[0]);
|
|
36
|
+
flags.relatesNormalised.push(n.key);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (candidates.length > 1) { parents.set(n.key, null); flags.ambiguous.push(n.key); continue; }
|
|
40
|
+
|
|
41
|
+
// 3) No resolved parent. Orphan only if this type expects one. Goals are
|
|
42
|
+
// top-level; Risks may parent to goal/epic but are allowed top-level here →
|
|
43
|
+
// neither is orphaned for "no parent".
|
|
44
|
+
parents.set(n.key, null);
|
|
45
|
+
const needsParent = ["epic", "story", "task", "bug", "subtask"].includes(childType);
|
|
46
|
+
if (needsParent) flags.orphans.push(n.key);
|
|
47
|
+
}
|
|
48
|
+
return { parents, flags };
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// scripts/migrate-runner.mjs — CLI entry for `blaze migrate`. Dry-run (default)
|
|
2
|
+
// runs the audit pipeline over .migration-cache/ and writes migration/MIGRATION-
|
|
3
|
+
// AUDIT.md + migration/disposition-ledger.json. The MCP pull that populates the
|
|
4
|
+
// cache is performed by the jira-export-migrator agent, not this script.
|
|
5
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { runDryRun, runLive } from "./migrate/jira-import.mjs";
|
|
9
|
+
import { resolveRoots, loadConfig } from "./config.mjs";
|
|
10
|
+
|
|
11
|
+
const { dataRoot, projectsDir } = resolveRoots();
|
|
12
|
+
const CACHE = join(dataRoot, ".migration-cache");
|
|
13
|
+
const MIGRATION = join(dataRoot, "migration");
|
|
14
|
+
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
const mode = argv.includes("--live") ? "live" : "dry-run";
|
|
17
|
+
const projIdx = argv.indexOf("--project");
|
|
18
|
+
if (projIdx !== -1 && argv[projIdx + 1] === undefined) {
|
|
19
|
+
console.error("usage: blaze migrate [--dry-run|--live] [--project KEY] [--merge]");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
// No explicit --project: fall back to blaze.config.json's configured projects
|
|
23
|
+
// list rather than a hardcoded guess.
|
|
24
|
+
const configuredKeys = loadConfig({ root: dataRoot }).projects;
|
|
25
|
+
const keys = projIdx !== -1 ? [argv[projIdx + 1]] : configuredKeys;
|
|
26
|
+
if (keys.length === 0) {
|
|
27
|
+
console.error("usage: blaze migrate [--dry-run|--live] --project KEY [--merge] (no projects configured in blaze.config.json)");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const enableMerges = argv.includes("--merge");
|
|
31
|
+
|
|
32
|
+
if (mode === "dry-run") {
|
|
33
|
+
const { auditMd, ledger, stats } = runDryRun({ cacheDir: CACHE, keys, detectMerges: enableMerges });
|
|
34
|
+
mkdirSync(MIGRATION, { recursive: true });
|
|
35
|
+
ledger.generated = new Date().toISOString().slice(0, 10);
|
|
36
|
+
writeFileSync(join(MIGRATION, "MIGRATION-AUDIT.md"), auditMd);
|
|
37
|
+
writeFileSync(join(MIGRATION, "disposition-ledger.json"), JSON.stringify(ledger, null, 2) + "\n");
|
|
38
|
+
console.log(`dry-run: source ${stats.source} · kept ${stats.kept} · dropped ${stats.dropped} · merged ${stats.merged}`);
|
|
39
|
+
console.log(`wrote migration/MIGRATION-AUDIT.md + migration/disposition-ledger.json — review + edit, then: blaze migrate --live`);
|
|
40
|
+
} else {
|
|
41
|
+
const ledgerPath = join(MIGRATION, "disposition-ledger.json");
|
|
42
|
+
if (!existsSync(ledgerPath)) {
|
|
43
|
+
console.error(`refusing --live: ${ledgerPath} not found. Run a --dry-run, review + edit the ledger first.`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const ledger = JSON.parse(readFileSync(ledgerPath, "utf8"));
|
|
47
|
+
const res = runLive({ cacheDir: CACHE, projectsDir, keys, ledger });
|
|
48
|
+
spawnSync("git", ["-C", dataRoot, "add", "-A"], { stdio: "ignore" });
|
|
49
|
+
spawnSync("git", ["-C", dataRoot, "commit", "-m", `migrate: import ${keys.join("+")} from Jira (${res.written.length} tickets)`], { stdio: "inherit" });
|
|
50
|
+
console.log(`live: wrote ${res.written.length} tickets · dropped ${res.dropped} · merged ${res.merged}`);
|
|
51
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// scripts/model/ids.mjs — per-project sequential ID allocation by filesystem
|
|
2
|
+
// scan. Zero stored state: the highest <KEY>-N on disk (closed tickets stay in
|
|
3
|
+
// terminal dirs, so the high-water mark survives) + 1. (Phase-3 design §1.2.)
|
|
4
|
+
import { readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
function* walkFiles(dir) {
|
|
8
|
+
let entries = [];
|
|
9
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
10
|
+
for (const e of entries) {
|
|
11
|
+
const p = join(dir, e);
|
|
12
|
+
let s;
|
|
13
|
+
try { s = statSync(p); } catch { continue; }
|
|
14
|
+
if (s.isDirectory()) yield* walkFiles(p);
|
|
15
|
+
else yield p;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function maxId(projectsDir, key) {
|
|
20
|
+
const re = new RegExp("(?:^|[/\\\\])" + key + "-(\\d+)\\b", "i");
|
|
21
|
+
let max = 0;
|
|
22
|
+
for (const f of walkFiles(join(projectsDir, key))) {
|
|
23
|
+
if (!f.endsWith(".md")) continue;
|
|
24
|
+
const m = re.exec(f);
|
|
25
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
26
|
+
}
|
|
27
|
+
return max;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function nextId(projectsDir, key) {
|
|
31
|
+
return `${key}-${maxId(projectsDir, key) + 1}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// scripts/model/index.mjs — build a queryable read model from all ticket files
|
|
2
|
+
// across all projects. The index is DISPOSABLE: rebuild any time from markdown
|
|
3
|
+
// (the source of truth). Pure-JS / zero-dep so it runs on blaze's Node floor.
|
|
4
|
+
// The Index interface is storage-agnostic — a future node:sqlite implementation
|
|
5
|
+
// must satisfy the same shape (spec §13, revised), so the swap stays contained.
|
|
6
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parseTicket } from "./ticket.mjs";
|
|
9
|
+
|
|
10
|
+
function safeReaddir(p) { try { return readdirSync(p); } catch { return []; } }
|
|
11
|
+
function isDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } }
|
|
12
|
+
|
|
13
|
+
// Yields every ticket under projectsDir/<KEY>/<status>/<id>.md
|
|
14
|
+
export function* walkTickets(projectsDir) {
|
|
15
|
+
for (const project of safeReaddir(projectsDir)) {
|
|
16
|
+
const projPath = join(projectsDir, project);
|
|
17
|
+
if (!isDir(projPath)) continue;
|
|
18
|
+
for (const status of safeReaddir(projPath)) {
|
|
19
|
+
const statusPath = join(projPath, status);
|
|
20
|
+
if (!isDir(statusPath)) continue;
|
|
21
|
+
for (const f of safeReaddir(statusPath)) {
|
|
22
|
+
if (!f.endsWith(".md")) continue;
|
|
23
|
+
const file = join(statusPath, f);
|
|
24
|
+
const { frontmatter, body } = parseTicket(readFileSync(file, "utf8"));
|
|
25
|
+
yield { frontmatter, body, status, file };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeIndex(rows, links) {
|
|
32
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
33
|
+
return {
|
|
34
|
+
rows,
|
|
35
|
+
links,
|
|
36
|
+
get: (id) => byId.get(id),
|
|
37
|
+
count: () => rows.length,
|
|
38
|
+
byProject: (project) => rows.filter((r) => r.project === project),
|
|
39
|
+
countByProject: () => rows.reduce((acc, r) => {
|
|
40
|
+
acc[r.project] = (acc[r.project] || 0) + 1; return acc;
|
|
41
|
+
}, {}),
|
|
42
|
+
linksFrom: (id) => links.filter((l) => l.src === id),
|
|
43
|
+
toJSON: () => ({ tickets: rows, links }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildIndex(projectsDir) {
|
|
48
|
+
const rows = [];
|
|
49
|
+
const links = [];
|
|
50
|
+
for (const t of walkTickets(projectsDir)) {
|
|
51
|
+
const fm = t.frontmatter;
|
|
52
|
+
const worklog_minutes = Array.isArray(fm.worklog)
|
|
53
|
+
? fm.worklog.reduce((s, w) => s + (Number(w.minutes) || 0), 0) : 0;
|
|
54
|
+
rows.push({
|
|
55
|
+
id: fm.id, project: fm.project ?? null, type: fm.type ?? null, title: fm.title ?? null,
|
|
56
|
+
status: t.status, priority: fm.priority ?? null, resolution: fm.resolution ?? null,
|
|
57
|
+
parent: fm.parent ?? null, assignee: fm.assignee ?? null, estimate: fm.estimate ?? null,
|
|
58
|
+
worklog_minutes, file: t.file,
|
|
59
|
+
});
|
|
60
|
+
for (const link of fm.links ?? []) links.push({ src: fm.id, type: link.type, target: link.target });
|
|
61
|
+
}
|
|
62
|
+
return makeIndex(rows, links);
|
|
63
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// scripts/model/move-plan.mjs — pure planner for a status move: validate the
|
|
2
|
+
// transition, apply the optional worklog-before-terminal guard, and compute the
|
|
3
|
+
// new frontmatter with resolution set/cleared by the post-function. No I/O.
|
|
4
|
+
import { validateTransition } from "./rules.mjs";
|
|
5
|
+
import { isTerminal, resolutionForTerminal } from "./workflows.mjs";
|
|
6
|
+
import { hierarchyLevel } from "./schema.mjs";
|
|
7
|
+
|
|
8
|
+
export function planMove(ticket, currentStatus, toStatus, opts = {}) {
|
|
9
|
+
const { hasWorklog = true, requireWorklog = false } = opts;
|
|
10
|
+
const type = ticket.frontmatter.type;
|
|
11
|
+
const errors = validateTransition(type, currentStatus, toStatus);
|
|
12
|
+
// The worklog-before-terminal guard applies to LEAF work items only
|
|
13
|
+
// (story/task/bug/subtask, hierarchyLevel <= 0). Epics/goals/risks aggregate —
|
|
14
|
+
// their time rolls up from children, so they carry no direct worklog and must
|
|
15
|
+
// not be blocked from reaching a terminal status.
|
|
16
|
+
const enforceWorklog = requireWorklog && hierarchyLevel(type) <= 0;
|
|
17
|
+
if (errors.length === 0 && enforceWorklog && isTerminal(type, toStatus) && !hasWorklog) {
|
|
18
|
+
errors.push(`worklog required before entering terminal status ${toStatus}`);
|
|
19
|
+
}
|
|
20
|
+
if (errors.length) return { ok: false, errors };
|
|
21
|
+
|
|
22
|
+
const frontmatter = { ...ticket.frontmatter };
|
|
23
|
+
frontmatter.resolution = isTerminal(type, toStatus) ? resolutionForTerminal(type, toStatus) : null;
|
|
24
|
+
return { ok: true, errors: [], frontmatter, body: ticket.body, resolution: frontmatter.resolution };
|
|
25
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// scripts/model/rollup.mjs — derived time roll-up over the index. Pure; reads
|
|
2
|
+
// only index.rows. buildIndex stays leaf-only (the storage-agnostic seam); the
|
|
3
|
+
// roll-up is computed on top. (Spec §2/§3.)
|
|
4
|
+
//
|
|
5
|
+
// rolled(node) = node.own + Σ rolled(child) over the transitive subtree
|
|
6
|
+
//
|
|
7
|
+
// own_* is kept separate from rolled_*. Cycle-guarded (a per-traversal visited
|
|
8
|
+
// set), orphan-parent rows are roots, null estimate contributes 0.
|
|
9
|
+
|
|
10
|
+
export function rollUp(index) {
|
|
11
|
+
const rows = index.rows || [];
|
|
12
|
+
const own = new Map(); // id → { est, log }
|
|
13
|
+
const children = new Map(); // parentId → id[]
|
|
14
|
+
for (const r of rows) {
|
|
15
|
+
own.set(r.id, { est: Number(r.estimate) || 0, log: Number(r.worklog_minutes) || 0 });
|
|
16
|
+
}
|
|
17
|
+
for (const r of rows) {
|
|
18
|
+
// Only treat parent as an edge when it resolves to a known row; otherwise
|
|
19
|
+
// the row is a root (orphan parent).
|
|
20
|
+
if (r.parent && own.has(r.parent)) {
|
|
21
|
+
if (!children.has(r.parent)) children.set(r.parent, []);
|
|
22
|
+
children.get(r.parent).push(r.id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Subtree sum from `start`, guarded so a malformed cycle terminates and no node
|
|
27
|
+
// is counted twice within one traversal.
|
|
28
|
+
function subtree(start) {
|
|
29
|
+
let est = 0, log = 0, count = -1; // -1 so `start` itself isn't a descendant
|
|
30
|
+
const visited = new Set();
|
|
31
|
+
const stack = [start];
|
|
32
|
+
while (stack.length) {
|
|
33
|
+
const id = stack.pop();
|
|
34
|
+
if (visited.has(id)) continue;
|
|
35
|
+
visited.add(id);
|
|
36
|
+
const o = own.get(id);
|
|
37
|
+
if (o) { est += o.est; log += o.log; }
|
|
38
|
+
count++;
|
|
39
|
+
for (const c of children.get(id) || []) if (!visited.has(c)) stack.push(c);
|
|
40
|
+
}
|
|
41
|
+
return { est, log, count };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = new Map();
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
const o = own.get(r.id);
|
|
47
|
+
const s = subtree(r.id);
|
|
48
|
+
result.set(r.id, {
|
|
49
|
+
own_estimate: o.est, own_worklog: o.log,
|
|
50
|
+
rolled_estimate: s.est, rolled_worklog: s.log,
|
|
51
|
+
descendant_count: s.count,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// scripts/model/rules.mjs — the single home for Blaze business rules.
|
|
2
|
+
// Phase 1: required-field + parent-integrity validation. (Transitions: Phase 2.)
|
|
3
|
+
import { isType, requiredFields, canParent, PRIORITIES } from "./schema.mjs";
|
|
4
|
+
import { statusesFor, canTransition, RESOLUTIONS } from "./workflows.mjs";
|
|
5
|
+
|
|
6
|
+
// ticket = { frontmatter, body }; lookup(id) => ticket | null (for parent checks).
|
|
7
|
+
export function validateTicket(ticket, lookup = () => null) {
|
|
8
|
+
const errors = [];
|
|
9
|
+
const fm = ticket.frontmatter || {};
|
|
10
|
+
const type = fm.type;
|
|
11
|
+
|
|
12
|
+
if (!isType(type)) {
|
|
13
|
+
errors.push(`unknown or missing type: ${type}`);
|
|
14
|
+
return errors;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const field of requiredFields(type)) {
|
|
18
|
+
if (field === "description") {
|
|
19
|
+
if (!ticket.body || ticket.body.trim() === "") errors.push("missing required: description (body)");
|
|
20
|
+
} else if (fm[field] === undefined || fm[field] === null || fm[field] === "") {
|
|
21
|
+
errors.push(`missing required: ${field}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Enum validation for priority and resolution.
|
|
26
|
+
if (fm.priority && !PRIORITIES.includes(fm.priority)) {
|
|
27
|
+
errors.push(`invalid priority: ${fm.priority}`);
|
|
28
|
+
}
|
|
29
|
+
if (fm.resolution && !RESOLUTIONS.includes(fm.resolution)) {
|
|
30
|
+
errors.push(`invalid resolution: ${fm.resolution}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (fm.parent) {
|
|
34
|
+
const parent = lookup(fm.parent);
|
|
35
|
+
if (!parent) {
|
|
36
|
+
errors.push(`parent not found: ${fm.parent}`);
|
|
37
|
+
} else {
|
|
38
|
+
if (!canParent(type, parent.frontmatter.type)) {
|
|
39
|
+
errors.push(`invalid parent: ${type} cannot be a child of ${parent.frontmatter.type}`);
|
|
40
|
+
}
|
|
41
|
+
const seen = new Set([fm.id]);
|
|
42
|
+
let cur = parent;
|
|
43
|
+
while (cur && cur.frontmatter.parent) {
|
|
44
|
+
if (seen.has(cur.frontmatter.id)) { errors.push(`parent cycle detected at ${cur.frontmatter.id}`); break; }
|
|
45
|
+
seen.add(cur.frontmatter.id);
|
|
46
|
+
cur = lookup(cur.frontmatter.parent);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Type-scoped transition validation. Separate from resolution (which is a post-
|
|
54
|
+
// function, not a transition gate). Returns [] when the move is legal.
|
|
55
|
+
export function validateTransition(type, from, to) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
if (!isType(type)) { errors.push(`unknown type: ${type}`); return errors; }
|
|
58
|
+
if (!statusesFor(type).includes(to)) {
|
|
59
|
+
errors.push(`invalid status '${to}' for type ${type}`);
|
|
60
|
+
} else if (!canTransition(type, from, to)) {
|
|
61
|
+
errors.push(`illegal transition: ${from} → ${to} for type ${type}`);
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// scripts/model/schema.mjs — Blaze type registry: hierarchy, parent rules,
|
|
2
|
+
// required fields, and the workflow that governs each type. Pure data + helpers.
|
|
3
|
+
|
|
4
|
+
/** Canonical priority enum — single source of truth across rules, serve, and client. */
|
|
5
|
+
export const PRIORITIES = ["highest", "high", "medium", "low", "lowest", "none", "urgent"];
|
|
6
|
+
|
|
7
|
+
export const TYPES = {
|
|
8
|
+
goal: { level: 2, workflow: "goal", parentTypes: [], required: ["title", "description"] },
|
|
9
|
+
epic: { level: 1, workflow: "delivery", parentTypes: ["goal"], required: ["title", "description"] },
|
|
10
|
+
risk: { level: 1, workflow: "risk", parentTypes: ["goal", "epic"], required: ["title", "description", "likelihood", "impact"] },
|
|
11
|
+
story: { level: 0, workflow: "delivery", parentTypes: ["epic"], required: ["title", "description", "estimate"] },
|
|
12
|
+
task: { level: 0, workflow: "delivery", parentTypes: ["epic"], required: ["title", "description", "estimate"] },
|
|
13
|
+
bug: { level: 0, workflow: "delivery", parentTypes: ["epic"], required: ["title", "description", "estimate"] },
|
|
14
|
+
subtask: { level: -1, workflow: "delivery", parentTypes: ["story", "task", "bug"], required: ["title", "description"] },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function allTypes() { return Object.keys(TYPES); }
|
|
18
|
+
|
|
19
|
+
export function isType(t) { return Object.prototype.hasOwnProperty.call(TYPES, t); }
|
|
20
|
+
|
|
21
|
+
function must(type) { if (!isType(type)) throw new Error(`unknown type: ${type}`); }
|
|
22
|
+
|
|
23
|
+
export function hierarchyLevel(type) { must(type); return TYPES[type].level; }
|
|
24
|
+
export function workflowFor(type) { must(type); return TYPES[type].workflow; }
|
|
25
|
+
export function requiredFields(type) { must(type); return TYPES[type].required; }
|
|
26
|
+
|
|
27
|
+
export function canParent(childType, parentType) {
|
|
28
|
+
must(childType); must(parentType);
|
|
29
|
+
return TYPES[childType].parentTypes.includes(parentType);
|
|
30
|
+
}
|