@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,38 @@
|
|
|
1
|
+
// scripts/log-runner.mjs — CLI entry for `blaze log`. Parses the positional
|
|
2
|
+
// id + minutes and --date/--note flags, calls applyLog against the resolved
|
|
3
|
+
// data tree, then commits (or queues). Mirrors new-runner.mjs's commit pattern.
|
|
4
|
+
import { applyLog } from "./log.mjs";
|
|
5
|
+
import { formatMinutes } from "./model/time.mjs";
|
|
6
|
+
import { loadConfig, resolveRoots } from "./config.mjs";
|
|
7
|
+
import { commitOrQueue } from "./commit-or-queue.mjs";
|
|
8
|
+
|
|
9
|
+
const { dataRoot, projectsDir } = resolveRoots();
|
|
10
|
+
const argv = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
const opts = {};
|
|
13
|
+
const positional = [];
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const a = argv[i];
|
|
16
|
+
switch (a) {
|
|
17
|
+
case "--date": opts.date = argv[++i]; break;
|
|
18
|
+
case "--note": opts.note = argv[++i]; break;
|
|
19
|
+
default:
|
|
20
|
+
if (a.startsWith("--")) { console.error(`unknown flag: ${a}`); process.exit(1); }
|
|
21
|
+
positional.push(a);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const [id, minutesRaw] = positional;
|
|
25
|
+
opts.today = new Date().toISOString().slice(0, 10);
|
|
26
|
+
|
|
27
|
+
if (!id || minutesRaw === undefined) {
|
|
28
|
+
console.error('usage: blaze log <id> <minutes> [--date YYYY-MM-DD] [--note "..."]');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const r = applyLog(projectsDir, id, Number(minutesRaw), opts);
|
|
33
|
+
if (!r.ok) { console.error(`blaze log failed:\n ${r.errors.join("\n ")}`); process.exit(1); }
|
|
34
|
+
|
|
35
|
+
const cfg = loadConfig({ root: dataRoot });
|
|
36
|
+
const c = commitOrQueue({ root: dataRoot, mode: cfg.commitMode, op: "log", id: r.id, message: `${r.id}: log ${r.minutes}m`, files: [r.file] });
|
|
37
|
+
if (!c.ok) { console.error(`blaze log: file written but commit failed (status ${c.status}) — commit manually`); process.exit(1); }
|
|
38
|
+
console.log(`logged ${r.minutes}m to ${r.id} (total ${formatMinutes(r.total_worklog_minutes)})${c.queued ? " (queued for blaze commit)" : ""}`);
|
package/scripts/log.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// scripts/log.mjs — `blaze log <id> <minutes>`: append a worklog entry to a
|
|
2
|
+
// ticket. applyLog() is pure-fs (no git) for tests; the CLI wrapper commits.
|
|
3
|
+
// Worklog minutes round to 1m and must be positive (model/time.roundWorklog).
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname } from "node:path";
|
|
6
|
+
import { walkTickets } from "./model/index.mjs";
|
|
7
|
+
import { serializeTicket } from "./model/ticket.mjs";
|
|
8
|
+
import { roundWorklog } from "./model/time.mjs";
|
|
9
|
+
|
|
10
|
+
// Same id resolution as move.mjs: prefer the ticket whose project dir matches
|
|
11
|
+
// the id prefix; fall back to the first id match.
|
|
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)));
|
|
17
|
+
if (id.startsWith(`${projectKey}-`)) return t;
|
|
18
|
+
fallback ??= t;
|
|
19
|
+
}
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function applyLog(projectsDir, id, minutes, opts = {}) {
|
|
24
|
+
const { date = null, note = null, today = null } = opts;
|
|
25
|
+
const found = locate(projectsDir, id);
|
|
26
|
+
if (!found) return { ok: false, errors: [`ticket not found: ${id}`] };
|
|
27
|
+
|
|
28
|
+
let rounded;
|
|
29
|
+
try { rounded = roundWorklog(minutes); }
|
|
30
|
+
catch (e) { return { ok: false, errors: [e.message] }; }
|
|
31
|
+
|
|
32
|
+
const entry = { date: date ?? today, minutes: rounded };
|
|
33
|
+
if (note) entry.note = note;
|
|
34
|
+
|
|
35
|
+
const fm = { ...found.frontmatter };
|
|
36
|
+
const worklog = Array.isArray(fm.worklog) ? [...fm.worklog] : [];
|
|
37
|
+
worklog.push(entry);
|
|
38
|
+
fm.worklog = worklog;
|
|
39
|
+
if (today) fm.updated = today;
|
|
40
|
+
|
|
41
|
+
const total = worklog.reduce((s, w) => s + (Number(w.minutes) || 0), 0);
|
|
42
|
+
writeFileSync(found.file, serializeTicket({ frontmatter: fm, body: found.body }));
|
|
43
|
+
return { ok: true, id, minutes: rounded, total_worklog_minutes: total, file: found.file };
|
|
44
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// groomer.mjs — the agentic board-keeper loop: pick an ungroomed ticket, drive the
|
|
2
|
+
// configured agent command to edit it, then auto-commit the change.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { spawnSync, execFileSync } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { parseTicket } from "../model/ticket.mjs";
|
|
11
|
+
|
|
12
|
+
export function hashContent(s) {
|
|
13
|
+
return createHash("sha1").update(s).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadState(root) {
|
|
17
|
+
const p = join(root, ".blaze", "state.json");
|
|
18
|
+
if (!existsSync(p)) return { groomed: {} };
|
|
19
|
+
try {
|
|
20
|
+
const s = JSON.parse(readFileSync(p, "utf8"));
|
|
21
|
+
return s && s.groomed ? s : { groomed: {} };
|
|
22
|
+
} catch {
|
|
23
|
+
return { groomed: {} };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveState(root, state) {
|
|
28
|
+
const dir = join(root, ".blaze");
|
|
29
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
30
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify(state, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function selectNextTicket(root, cfg, state) {
|
|
34
|
+
for (const col of cfg.loops.groomer.columns) {
|
|
35
|
+
let files = [];
|
|
36
|
+
try {
|
|
37
|
+
files = readdirSync(join(root, col)).filter((f) => cfg.fileRegex.test(f));
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
files.sort();
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
const rel = `${col}/${file}`;
|
|
44
|
+
const raw = readFileSync(join(root, rel), "utf8");
|
|
45
|
+
const m = cfg.idLineRegex.exec(raw);
|
|
46
|
+
if (!m) continue;
|
|
47
|
+
const id = m[1];
|
|
48
|
+
if (state.groomed[id] !== hashContent(raw)) return { id, file, col, rel, raw };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractGroomingRules(agentsMd) {
|
|
55
|
+
const m = /## Grooming rules[\s\S]*?(?=\n## |\n# |$)/.exec(agentsMd || "");
|
|
56
|
+
return m ? m[0].trim() : "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildPrompt(ticket, rules, cfg) {
|
|
60
|
+
const labels = (cfg.defaultLabels || []).join(", ");
|
|
61
|
+
const guard = [
|
|
62
|
+
"You are a groomer. PROPOSE improvements only — never transition, never resolve, never move the file.",
|
|
63
|
+
"Draft Acceptance Criteria, suggest an estimate, and suggest a parent/links.",
|
|
64
|
+
`Write suggestions ONLY as a subsection under \`## Notes\` titled \`Groomer proposals (${cfg.today || ""})\`.`,
|
|
65
|
+
"Do NOT change the `status`, `resolution`, `parent`, or `estimate` frontmatter fields — a human/agent applies accepted proposals via `blaze move`/`blaze edit`.",
|
|
66
|
+
].join("\n");
|
|
67
|
+
return [
|
|
68
|
+
guard,
|
|
69
|
+
``,
|
|
70
|
+
`You are grooming an issue-tracker ticket. Edit ONLY the file at ${ticket.rel} and no other file.`,
|
|
71
|
+
labels ? `Use only these labels: ${labels}.` : "",
|
|
72
|
+
``,
|
|
73
|
+
rules,
|
|
74
|
+
``,
|
|
75
|
+
`--- ticket: ${ticket.rel} ---`,
|
|
76
|
+
ticket.raw,
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseChangedFiles(diffOut) {
|
|
81
|
+
return diffOut.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse `git status --porcelain --untracked-files=all` output into a list of
|
|
86
|
+
* affected paths. Handles:
|
|
87
|
+
* " M path" — unstaged modification
|
|
88
|
+
* "M path" — staged modification
|
|
89
|
+
* "A path" — staged add (new file)
|
|
90
|
+
* "?? path" — untracked new file
|
|
91
|
+
* " D path" — unstaged deletion
|
|
92
|
+
* "D path" — staged deletion
|
|
93
|
+
* "R old -> new" — staged rename (take the new path)
|
|
94
|
+
* Returns deduplicated list of paths.
|
|
95
|
+
*/
|
|
96
|
+
export function parsePorcelain(porcelain) {
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const line of porcelain.split("\n")) {
|
|
99
|
+
if (!line) continue;
|
|
100
|
+
const xy = line.slice(0, 2);
|
|
101
|
+
const rest = line.slice(3);
|
|
102
|
+
let path;
|
|
103
|
+
// Rename: "R old -> new" or "R old\0new" — porcelain v1 uses " -> "
|
|
104
|
+
if (xy[0] === "R" || xy[1] === "R") {
|
|
105
|
+
const arrow = rest.indexOf(" -> ");
|
|
106
|
+
path = arrow >= 0 ? rest.slice(arrow + 4) : rest;
|
|
107
|
+
} else {
|
|
108
|
+
path = rest;
|
|
109
|
+
}
|
|
110
|
+
path = path.trim();
|
|
111
|
+
if (path) seen.add(path);
|
|
112
|
+
}
|
|
113
|
+
return [...seen];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Returns true if the before/after content represents a structural change:
|
|
118
|
+
* - resolution frontmatter value changed
|
|
119
|
+
* - status frontmatter value changed
|
|
120
|
+
* These fields must only be mutated by explicit human/agent `blaze move`/`blaze edit`.
|
|
121
|
+
*
|
|
122
|
+
* Uses parseTicket (the real parser) to extract field values so that a duplicated
|
|
123
|
+
* key in the frontmatter cannot evade the guard via first-match regex.
|
|
124
|
+
*/
|
|
125
|
+
export function isStructuralChange(before, after) {
|
|
126
|
+
let parsedBefore = null;
|
|
127
|
+
let parsedAfter = null;
|
|
128
|
+
try { parsedBefore = parseTicket(before); } catch { /* no frontmatter */ }
|
|
129
|
+
try { parsedAfter = parseTicket(after); } catch { /* no frontmatter */ }
|
|
130
|
+
|
|
131
|
+
// If before had frontmatter but after does not → structural (gutted ticket).
|
|
132
|
+
if (parsedBefore && !parsedAfter) return true;
|
|
133
|
+
// If neither had frontmatter → no structural change to detect.
|
|
134
|
+
if (!parsedBefore && !parsedAfter) return false;
|
|
135
|
+
// If after has frontmatter but before didn't → treat as non-structural (new frontmatter added).
|
|
136
|
+
if (!parsedBefore) return false;
|
|
137
|
+
|
|
138
|
+
const fmBefore = parsedBefore.frontmatter;
|
|
139
|
+
const fmAfter = parsedAfter.frontmatter;
|
|
140
|
+
for (const field of ["resolution", "status"]) {
|
|
141
|
+
// Normalise to string for comparison: null/undefined both mean "absent".
|
|
142
|
+
const vBefore = fmBefore[field] ?? null;
|
|
143
|
+
const vAfter = fmAfter[field] ?? null;
|
|
144
|
+
if (String(vBefore) !== String(vAfter)) return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function commitMessage(id, files) {
|
|
150
|
+
return `chore(groom): ${id} ${files.length} file(s) groomed`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function groomOnce({ root, cfg, agentsMd, today }) {
|
|
154
|
+
const state = loadState(root);
|
|
155
|
+
const ticket = selectNextTicket(root, cfg, state);
|
|
156
|
+
if (!ticket) return null;
|
|
157
|
+
|
|
158
|
+
const prompt = buildPrompt(ticket, extractGroomingRules(agentsMd), cfg);
|
|
159
|
+
const [cmd, ...args] = cfg.agentCommand.split(" ");
|
|
160
|
+
const r = spawnSync(cmd, [...args, prompt], {
|
|
161
|
+
cwd: root,
|
|
162
|
+
encoding: "utf8",
|
|
163
|
+
env: { ...process.env, BLAZE_GROOM_TARGET: ticket.rel },
|
|
164
|
+
});
|
|
165
|
+
if (r.status !== 0) {
|
|
166
|
+
return { type: "groom", id: ticket.id, error: ((r.stderr || "agent command failed") + "").slice(0, 200), ts: today };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const porcelain = execFileSync("git", ["-C", root, "status", "--porcelain", "--untracked-files=all"], { encoding: "utf8" });
|
|
170
|
+
const changed = parsePorcelain(porcelain).filter((f) => cfg.columns.some((c) => f.startsWith(`${c}/`)));
|
|
171
|
+
const record = () => {
|
|
172
|
+
const raw = readFileSync(join(root, ticket.rel), "utf8");
|
|
173
|
+
state.groomed[ticket.id] = hashContent(raw);
|
|
174
|
+
saveState(root, state);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (!changed.length) {
|
|
178
|
+
record(); // mark groomed so we don't re-run on a no-op
|
|
179
|
+
return { type: "groom", id: ticket.id, noop: true, ts: today };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Guard: detect renames (status-dir change) or structural frontmatter mutations.
|
|
183
|
+
// A rename means any changed path lands in a different column dir than the ticket's.
|
|
184
|
+
const ticketDir = ticket.rel.split("/")[0];
|
|
185
|
+
const hasRename = changed.some((f) => f.split("/")[0] !== ticketDir);
|
|
186
|
+
const afterRaw = existsSync(join(root, ticket.rel)) ? readFileSync(join(root, ticket.rel), "utf8") : "";
|
|
187
|
+
const hasStructuralFmChange = isStructuralChange(ticket.raw, afterRaw);
|
|
188
|
+
if (hasRename || hasStructuralFmChange) {
|
|
189
|
+
// Reset all changes so the tree stays clean.
|
|
190
|
+
// Staged changes must be unstaged first; untracked new files must be removed.
|
|
191
|
+
try { execFileSync("git", ["-C", root, "restore", "--staged", "--", ...changed]); } catch {}
|
|
192
|
+
try { execFileSync("git", ["-C", root, "checkout", "--", ...changed]); } catch {}
|
|
193
|
+
try { execFileSync("git", ["-C", root, "clean", "-f", "--", ...changed]); } catch {}
|
|
194
|
+
console.error(`groomer: refused structural change on ${ticket.id}`);
|
|
195
|
+
return { type: "groom", id: ticket.id, refused: true, ts: today };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
execFileSync("git", ["-C", root, "add", ...changed]);
|
|
199
|
+
execFileSync("git", ["-C", root, "commit", "-m", commitMessage(ticket.id, changed), "--", ...changed]);
|
|
200
|
+
const sha = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
201
|
+
record();
|
|
202
|
+
return { type: "groom", id: ticket.id, sha, files: changed, ts: today };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// CLI: `node scripts/loops/groomer.mjs` runs one grooming pass.
|
|
206
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
207
|
+
const { loadConfig, resolveRoots } = await import("../config.mjs");
|
|
208
|
+
const root = resolveRoots().dataRoot;
|
|
209
|
+
const cfg = loadConfig({ root });
|
|
210
|
+
let agentsMd = "";
|
|
211
|
+
try { agentsMd = readFileSync(join(root, "AGENTS.md"), "utf8"); } catch {}
|
|
212
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
213
|
+
const evt = groomOnce({ root, cfg, agentsMd, today });
|
|
214
|
+
console.log(evt ? JSON.stringify(evt) : "groomer: nothing to groom.");
|
|
215
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// scripts/migrate/audit.mjs — pure: per-item disposition (drop / keep / merge-into
|
|
2
|
+
// / re-parent) for the curated migration. Drop = non-Done terminal (Won't Do /
|
|
3
|
+
// Duplicate / Cannot Reproduce / abandoned). Merges are PROPOSED here (heuristic);
|
|
4
|
+
// the user confirms via the ledger. proposed_status/proposed_parent come from the
|
|
5
|
+
// status map + the restructure proposal.
|
|
6
|
+
import { mapType, mapStatus } from "./map.mjs";
|
|
7
|
+
import { isTerminal } from "../model/workflows.mjs";
|
|
8
|
+
import { isType } from "../model/schema.mjs";
|
|
9
|
+
|
|
10
|
+
export const DROP_RESOLUTIONS = new Set(["won't do", "wont do", "duplicate", "cannot reproduce"]);
|
|
11
|
+
|
|
12
|
+
function bigrams(s) {
|
|
13
|
+
const t = String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
14
|
+
const grams = new Set();
|
|
15
|
+
for (let i = 0; i < t.length - 1; i++) grams.add(t.slice(i, i + 2));
|
|
16
|
+
return grams;
|
|
17
|
+
}
|
|
18
|
+
export function titleSimilarity(a, b) {
|
|
19
|
+
const ba = bigrams(a), bb = bigrams(b);
|
|
20
|
+
if (ba.size === 0 || bb.size === 0) return 0;
|
|
21
|
+
let inter = 0;
|
|
22
|
+
for (const g of ba) if (bb.has(g)) inter++;
|
|
23
|
+
return (2 * inter) / (ba.size + bb.size);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function worklogSeconds(n) { return (n.worklog || []).reduce((s, w) => s + (w.seconds || 0), 0); }
|
|
27
|
+
function numId(key) { const m = /-(\d+)$/.exec(key); return m ? Number(m[1]) : Infinity; }
|
|
28
|
+
|
|
29
|
+
export function detectMerges(norms) {
|
|
30
|
+
const merges = new Map(); // loser → survivor
|
|
31
|
+
const folded = new Set();
|
|
32
|
+
for (let i = 0; i < norms.length; i++) {
|
|
33
|
+
for (let j = i + 1; j < norms.length; j++) {
|
|
34
|
+
const a = norms[i], b = norms[j];
|
|
35
|
+
if (a.project !== b.project) continue;
|
|
36
|
+
if (folded.has(a.key) || folded.has(b.key)) continue;
|
|
37
|
+
const sharedComp = (a.components || []).some((c) => (b.components || []).includes(c));
|
|
38
|
+
const linked = (a.links || []).some((l) => (l.type === "Relates" || l.type === "Duplicate") && l.target === b.key)
|
|
39
|
+
|| (b.links || []).some((l) => (l.type === "Relates" || l.type === "Duplicate") && l.target === a.key);
|
|
40
|
+
if (titleSimilarity(a.summary, b.summary) >= 0.6 && (sharedComp || linked)) {
|
|
41
|
+
// survivor = more worklog, tie → earlier numeric key
|
|
42
|
+
const aw = worklogSeconds(a), bw = worklogSeconds(b);
|
|
43
|
+
const survivor = aw !== bw ? (aw > bw ? a : b) : (numId(a.key) <= numId(b.key) ? a : b);
|
|
44
|
+
const loser = survivor === a ? b : a;
|
|
45
|
+
merges.set(loser.key, survivor.key);
|
|
46
|
+
folded.add(loser.key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return merges;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function auditIssues(norms, restructure, opts = {}) {
|
|
54
|
+
const merges = opts.detectMerges === true ? detectMerges(norms) : new Map();
|
|
55
|
+
const dispositions = [];
|
|
56
|
+
let kept = 0, dropped = 0, merged = 0;
|
|
57
|
+
|
|
58
|
+
for (const n of norms) {
|
|
59
|
+
const type = mapType(n.type);
|
|
60
|
+
const res = n.resolution ? String(n.resolution).toLowerCase() : null;
|
|
61
|
+
const terminalNoRes = isType(type) && n.status && isTerminal(type, mapStatus(type, n.status, n.statusCategory).status) && !res;
|
|
62
|
+
|
|
63
|
+
if (res && DROP_RESOLUTIONS.has(res)) {
|
|
64
|
+
dispositions.push({ id: n.key, type, disposition: "drop", reason: `resolution: ${n.resolution}`,
|
|
65
|
+
proposed_status: null, proposed_parent: null });
|
|
66
|
+
dropped++; continue;
|
|
67
|
+
}
|
|
68
|
+
if (terminalNoRes) {
|
|
69
|
+
dispositions.push({ id: n.key, type, disposition: "drop", reason: "abandoned (terminal status, no resolution)",
|
|
70
|
+
proposed_status: null, proposed_parent: null });
|
|
71
|
+
dropped++; continue;
|
|
72
|
+
}
|
|
73
|
+
if (merges.has(n.key)) {
|
|
74
|
+
const survivor = merges.get(n.key);
|
|
75
|
+
dispositions.push({ id: n.key, type, disposition: `merge-into:${survivor}`,
|
|
76
|
+
reason: `duplicate of ${survivor}`, proposed_status: null, proposed_parent: null });
|
|
77
|
+
merged++; continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const proposedParent = restructure.parents.get(n.key) ?? null;
|
|
81
|
+
const { status } = isType(type) ? mapStatus(type, n.status, n.statusCategory) : { status: null };
|
|
82
|
+
const reParented = proposedParent !== (n.parent ?? null) && proposedParent !== null;
|
|
83
|
+
dispositions.push({
|
|
84
|
+
id: n.key, type,
|
|
85
|
+
disposition: reParented ? `re-parent:${proposedParent}` : "keep",
|
|
86
|
+
reason: reParented ? `parent ${n.parent ?? "∅"} → ${proposedParent}` : (n.resolution ? `resolution: ${n.resolution}` : "in-flight"),
|
|
87
|
+
proposed_status: status, proposed_parent: proposedParent,
|
|
88
|
+
});
|
|
89
|
+
kept++;
|
|
90
|
+
}
|
|
91
|
+
return { dispositions, stats: { source: norms.length, kept, dropped, merged } };
|
|
92
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// scripts/migrate/jira-client.mjs — the migration I/O boundary. The raw Jira
|
|
2
|
+
// pull is performed by the jira-export-migrator AGENT (a node script has no
|
|
3
|
+
// access to mcp__atlassian__* tools); the agent writes raw issues here. This
|
|
4
|
+
// module only reads/writes the .migration-cache/ files. Pure-fs, zero-dep.
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export function cacheFile(cacheDir, key) {
|
|
9
|
+
return join(cacheDir, `${key}.json`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function writeRawCache(cacheDir, key, rawIssues) {
|
|
13
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
14
|
+
writeFileSync(cacheFile(cacheDir, key), JSON.stringify({ key, issues: rawIssues }, null, 2));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readRawCache(cacheDir, key) {
|
|
18
|
+
const file = cacheFile(cacheDir, key);
|
|
19
|
+
if (!existsSync(file)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`migration cache missing: ${file}\n` +
|
|
22
|
+
`Populate it with the jira-export-migrator agent (paginated MCP pull) before running blaze migrate.`);
|
|
23
|
+
}
|
|
24
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
25
|
+
return Array.isArray(parsed) ? parsed : (parsed.issues ?? []);
|
|
26
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// scripts/migrate/jira-import.mjs — the migration orchestrator. runDryRun (this
|
|
2
|
+
// task) runs the audit pipeline over the cache and returns the audit markdown +
|
|
3
|
+
// ledger object WITHOUT writing tickets. runLive (Task 9) executes the edited
|
|
4
|
+
// ledger. Pure of git; the runner does file writes + the bulk commit.
|
|
5
|
+
import { writeFileSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readRawCache } from "./jira-client.mjs";
|
|
8
|
+
import { normalizeIssue } from "./normalize.mjs";
|
|
9
|
+
import { mapIssue } from "./map.mjs";
|
|
10
|
+
import { proposeStructure } from "./restructure.mjs";
|
|
11
|
+
import { auditIssues } from "./audit.mjs";
|
|
12
|
+
import { renderAudit, renderLedger } from "./report.mjs";
|
|
13
|
+
import { serializeTicket } from "../model/ticket.mjs";
|
|
14
|
+
import { foldMerges, resolveLinkIntegrity } from "./merge.mjs";
|
|
15
|
+
|
|
16
|
+
export function loadNormalized(cacheDir, keys) {
|
|
17
|
+
const norms = [];
|
|
18
|
+
for (const key of keys) for (const raw of readRawCache(cacheDir, key)) norms.push(normalizeIssue(raw));
|
|
19
|
+
return norms;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runDryRun({ cacheDir, keys, detectMerges = false }) {
|
|
23
|
+
const norms = loadNormalized(cacheDir, keys);
|
|
24
|
+
const restructure = proposeStructure(norms);
|
|
25
|
+
const { dispositions, stats } = auditIssues(norms, restructure, { detectMerges });
|
|
26
|
+
|
|
27
|
+
// Collect warnings by mapping each kept/re-parented item.
|
|
28
|
+
const byKey = new Map(norms.map((n) => [n.key, n]));
|
|
29
|
+
const warnings = [];
|
|
30
|
+
for (const d of dispositions) {
|
|
31
|
+
if (d.disposition !== "keep" && !d.disposition.startsWith("re-parent:")) continue;
|
|
32
|
+
const { warnings: w } = mapIssue(byKey.get(d.id), { proposedParent: d.proposed_parent, proposedStatus: d.proposed_status });
|
|
33
|
+
warnings.push(...w);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Compute link-integrity report for the audit output.
|
|
37
|
+
const { survivors: drySurvivors } = foldMerges(new Map(norms.map((n) => [n.key, n])), dispositions);
|
|
38
|
+
const { integrity } = resolveLinkIntegrity(drySurvivors, dispositions);
|
|
39
|
+
|
|
40
|
+
const source = {};
|
|
41
|
+
for (const n of norms) source[n.project] = (source[n.project] || 0) + 1;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
auditMd: renderAudit({ norms, dispositions, restructure, warnings, integrity }),
|
|
45
|
+
ledger: renderLedger(dispositions, source),
|
|
46
|
+
stats,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function slugify(s) { return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); }
|
|
51
|
+
|
|
52
|
+
// Remove any existing file for `id` under projectsDir/<KEY>/* so a re-run with a
|
|
53
|
+
// changed status doesn't leave a duplicate (idempotency).
|
|
54
|
+
function removeExisting(projectsDir, project, id) {
|
|
55
|
+
const projDir = join(projectsDir, project);
|
|
56
|
+
let statuses = [];
|
|
57
|
+
try { statuses = readdirSync(projDir); } catch { return; }
|
|
58
|
+
for (const st of statuses) {
|
|
59
|
+
const dir = join(projDir, st);
|
|
60
|
+
let files = [];
|
|
61
|
+
try { files = readdirSync(dir); } catch { continue; }
|
|
62
|
+
for (const f of files) if (f.startsWith(`${id}-`) || f === `${id}.md`) rmSync(join(dir, f), { force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function runLive({ cacheDir, projectsDir, keys, ledger }) {
|
|
67
|
+
const norms = loadNormalized(cacheDir, keys);
|
|
68
|
+
const byKey = new Map(norms.map((n) => [n.key, n]));
|
|
69
|
+
const dispositions = ledger.items;
|
|
70
|
+
const dispById = new Map(dispositions.map((d) => [d.id, d]));
|
|
71
|
+
|
|
72
|
+
const { survivors: rawSurvivors } = foldMerges(byKey, dispositions);
|
|
73
|
+
const { survivors } = resolveLinkIntegrity(rawSurvivors, dispositions);
|
|
74
|
+
|
|
75
|
+
const written = [];
|
|
76
|
+
const files = [];
|
|
77
|
+
let dropped = 0, merged = 0;
|
|
78
|
+
for (const d of dispositions) {
|
|
79
|
+
if (d.disposition === "drop") { dropped++; continue; }
|
|
80
|
+
if (d.disposition.startsWith("merge-into:")) { merged++; continue; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [key, survivor] of survivors) {
|
|
84
|
+
const d = dispById.get(key) || {};
|
|
85
|
+
const { frontmatter, body, status } = mapIssue(survivor, { proposedParent: d.proposed_parent, proposedStatus: d.proposed_status });
|
|
86
|
+
const project = frontmatter.project;
|
|
87
|
+
removeExisting(projectsDir, project, key);
|
|
88
|
+
const dir = join(projectsDir, project, status);
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
const file = join(dir, `${key}-${slugify(frontmatter.title)}.md`);
|
|
91
|
+
writeFileSync(file, serializeTicket({ frontmatter, body }));
|
|
92
|
+
written.push(key);
|
|
93
|
+
files.push(file);
|
|
94
|
+
}
|
|
95
|
+
return { written, dropped, merged, files };
|
|
96
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// scripts/migrate/map.mjs — pure: mapping tables + NormalizedIssue → a Blaze
|
|
2
|
+
// ticket object { frontmatter, body, status, warnings }. The single home for the
|
|
3
|
+
// Jira→blaze field/status/resolution/priority tables. Reuses model/* for rounding,
|
|
4
|
+
// the type set, workflow statuses, and validation. Does not serialize or write —
|
|
5
|
+
// the live orchestrator does that (Task 9).
|
|
6
|
+
import { roundEstimate, roundWorklog } from "../model/time.mjs";
|
|
7
|
+
import { isType } from "../model/schema.mjs";
|
|
8
|
+
import { initialStatus, statusesFor, isTerminal } from "../model/workflows.mjs";
|
|
9
|
+
import { validateTicket } from "../model/rules.mjs";
|
|
10
|
+
|
|
11
|
+
const TYPE_MAP = { goal: "goal", epic: "epic", risk: "risk", story: "story", task: "task", bug: "bug", "sub-task": "subtask", subtask: "subtask" };
|
|
12
|
+
const PRIORITY_SET = new Set(["highest", "high", "medium", "low", "lowest"]);
|
|
13
|
+
const RESOLUTION_MAP = { "done": "done", "won't do": "wont-do", "wont do": "wont-do", "duplicate": "duplicate", "cannot reproduce": "cannot-reproduce" };
|
|
14
|
+
// Jira status NAME → blaze status, per workflow. Lowercased lookup.
|
|
15
|
+
const STATUS_MAP = {
|
|
16
|
+
delivery: { "defined": "defined", "to do": "defined", "in progress": "in-progress", "in review": "in-review", "done": "done" },
|
|
17
|
+
goal: { "defined": "defined", "in progress": "in-progress", "achieved": "achieved" },
|
|
18
|
+
risk: { "identified": "identified", "mitigated": "mitigated", "accepted": "accepted", "obsolete": "obsolete" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function mapType(t) {
|
|
22
|
+
const key = String(t ?? "").toLowerCase();
|
|
23
|
+
return TYPE_MAP[key] ?? key;
|
|
24
|
+
}
|
|
25
|
+
export function mapPriority(p) {
|
|
26
|
+
const key = String(p ?? "").toLowerCase();
|
|
27
|
+
return PRIORITY_SET.has(key) ? key : "medium";
|
|
28
|
+
}
|
|
29
|
+
export function mapResolution(r) {
|
|
30
|
+
if (r == null) return null;
|
|
31
|
+
return RESOLUTION_MAP[String(r).toLowerCase()] ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// workflow name for a blaze type, mirroring schema.mjs (avoids importing the full
|
|
35
|
+
// registry just for the workflow tag).
|
|
36
|
+
function workflowOf(blazeType) {
|
|
37
|
+
if (blazeType === "goal") return "goal";
|
|
38
|
+
if (blazeType === "risk") return "risk";
|
|
39
|
+
return "delivery"; // epic/story/task/bug/subtask
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function categoryFallback(blazeType, category) {
|
|
43
|
+
const statuses = statusesFor(blazeType);
|
|
44
|
+
if (category === "done") return statuses.find((s) => isTerminal(blazeType, s)) ?? statuses[statuses.length - 1];
|
|
45
|
+
if (category === "indeterminate") return statuses.find((s) => s !== statuses[0] && !isTerminal(blazeType, s)) ?? statuses[0];
|
|
46
|
+
return statuses[0]; // "new" or null/unknown → initial
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function mapStatus(blazeType, jiraStatus, statusCategory) {
|
|
50
|
+
const wf = workflowOf(blazeType);
|
|
51
|
+
const table = STATUS_MAP[wf] || {};
|
|
52
|
+
const hit = table[String(jiraStatus ?? "").toLowerCase()];
|
|
53
|
+
if (hit && (statusesFor(blazeType).includes(hit))) return { status: hit, unmapped: false };
|
|
54
|
+
if (statusCategory) return { status: categoryFallback(blazeType, String(statusCategory).toLowerCase()), unmapped: false };
|
|
55
|
+
return { status: initialStatus(blazeType), unmapped: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const IMPACT_WORDS = new Set(["extensive", "significant", "moderate", "minor"]);
|
|
59
|
+
const LIKELIHOOD_WORDS = new Set(["low", "medium", "high"]);
|
|
60
|
+
function firstWord(v) { return String(v ?? "").toLowerCase().split(/[^a-z]+/).find(Boolean) ?? null; }
|
|
61
|
+
function mapImpact(v) { if (v == null) return null; const w = firstWord(v); return IMPACT_WORDS.has(w) ? w : String(v).toLowerCase(); }
|
|
62
|
+
function mapLikelihood(v) { if (v == null) return null; const w = firstWord(v); return LIKELIHOOD_WORDS.has(w) ? w : String(v).toLowerCase(); }
|
|
63
|
+
|
|
64
|
+
export function mapIssue(norm, { proposedParent, proposedStatus } = {}) {
|
|
65
|
+
const type = mapType(norm.type);
|
|
66
|
+
const known = isType(type);
|
|
67
|
+
const mapped = known ? mapStatus(type, norm.status, norm.statusCategory) : { status: null, unmapped: true };
|
|
68
|
+
const status = proposedStatus ?? mapped.status;
|
|
69
|
+
|
|
70
|
+
const estimate = roundEstimate(norm.estimateSeconds != null ? norm.estimateSeconds / 60 : null);
|
|
71
|
+
const worklogWarnings = [];
|
|
72
|
+
const worklog = (norm.worklog || []).flatMap((w) => {
|
|
73
|
+
const mins = (w.seconds || 0) / 60;
|
|
74
|
+
if (!Number.isFinite(mins) || mins <= 0) {
|
|
75
|
+
worklogWarnings.push(`${norm.key}: skipped zero/negative worklog entry`);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const entry = { date: w.date, minutes: roundWorklog(mins) };
|
|
79
|
+
if (w.note) entry.note = w.note;
|
|
80
|
+
return [entry];
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const fm = {
|
|
84
|
+
id: norm.key, title: norm.summary, type, project: norm.project,
|
|
85
|
+
priority: mapPriority(norm.priority),
|
|
86
|
+
resolution: mapResolution(norm.resolution),
|
|
87
|
+
parent: proposedParent !== undefined ? proposedParent : (norm.parent ?? null),
|
|
88
|
+
assignee: norm.assignee ?? "unassigned",
|
|
89
|
+
labels: norm.labels || [], components: norm.components || [],
|
|
90
|
+
estimate,
|
|
91
|
+
worklog,
|
|
92
|
+
links: norm.links || [],
|
|
93
|
+
created: norm.created, updated: norm.updated,
|
|
94
|
+
};
|
|
95
|
+
if (type === "risk") {
|
|
96
|
+
fm.likelihood = mapLikelihood(norm.likelihood);
|
|
97
|
+
fm.impact = mapImpact(norm.impact);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const body = norm.description && norm.description.trim() ? norm.description : "## Context\n\n## Acceptance Criteria\n\n- [ ] \n\n## Notes\n";
|
|
101
|
+
|
|
102
|
+
const warnings = [...worklogWarnings];
|
|
103
|
+
if (!known) warnings.push(`unknown type '${norm.type}' for ${norm.key} (mapped to '${type}')`);
|
|
104
|
+
if (mapped.unmapped && known) warnings.push(`unmapped status '${norm.status}' for ${norm.key} (parked in '${mapped.status}')`);
|
|
105
|
+
for (const e of validateTicket({ frontmatter: fm, body })) {
|
|
106
|
+
if (/parent not found/.test(e)) continue; // parent integrity is a reindex concern
|
|
107
|
+
warnings.push(`${norm.key}: ${e}`);
|
|
108
|
+
}
|
|
109
|
+
return { frontmatter: fm, body, status, warnings };
|
|
110
|
+
}
|