@gitpm/cli 0.1.0 → 0.1.16
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/dist/index.js +2175 -467
- package/package.json +62 -9
- package/src/__tests__/import.test.ts +0 -220
- package/src/__tests__/init.test.ts +0 -112
- package/src/__tests__/pull.test.ts +0 -225
- package/src/__tests__/push.test.ts +0 -242
- package/src/__tests__/sync.test.ts +0 -264
- package/src/__tests__/validate.test.ts +0 -178
- package/src/commands/import.ts +0 -184
- package/src/commands/init.ts +0 -62
- package/src/commands/pull.ts +0 -145
- package/src/commands/push.ts +0 -224
- package/src/commands/sync.ts +0 -238
- package/src/commands/validate.ts +0 -79
- package/src/index.ts +0 -33
- package/src/utils/auth.ts +0 -28
- package/src/utils/config.ts +0 -5
- package/src/utils/conflict-ui.ts +0 -45
- package/src/utils/output.ts +0 -23
- package/tsconfig.json +0 -8
- package/tsup.config.ts +0 -11
package/dist/index.js
CHANGED
|
@@ -2,35 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { readFileSync } from "fs";
|
|
5
|
-
import { dirname, join as
|
|
5
|
+
import { dirname as dirname4, join as join3 } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { Command as
|
|
7
|
+
import { Command as Command20 } from "commander";
|
|
8
8
|
|
|
9
|
-
// src/commands/
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
// src/commands/archive.ts
|
|
10
|
+
import { readFile, writeFile } from "fs/promises";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { archiveOldEntities } from "@gitpm/core";
|
|
12
13
|
import chalk2 from "chalk";
|
|
13
14
|
import { Command } from "commander";
|
|
14
|
-
import ora from "ora";
|
|
15
|
-
|
|
16
|
-
// src/utils/auth.ts
|
|
17
|
-
import { execSync } from "child_process";
|
|
18
|
-
async function resolveToken(cliToken) {
|
|
19
|
-
if (cliToken) return cliToken;
|
|
20
|
-
const envToken = process.env.GITHUB_TOKEN;
|
|
21
|
-
if (envToken) return envToken;
|
|
22
|
-
try {
|
|
23
|
-
const ghToken = execSync("gh auth token", {
|
|
24
|
-
encoding: "utf-8",
|
|
25
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
26
|
-
}).trim();
|
|
27
|
-
if (ghToken) return ghToken;
|
|
28
|
-
} catch {
|
|
29
|
-
}
|
|
30
|
-
throw new Error(
|
|
31
|
-
"No GitHub token found. Provide via --token flag, GITHUB_TOKEN env var, or install GitHub CLI (gh) and run 'gh auth login'."
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
15
|
|
|
35
16
|
// src/utils/config.ts
|
|
36
17
|
import { resolve } from "path";
|
|
@@ -49,6 +30,681 @@ function printError(msg) {
|
|
|
49
30
|
function printWarning(msg) {
|
|
50
31
|
console.warn(chalk.yellow(`\u26A0 ${msg}`));
|
|
51
32
|
}
|
|
33
|
+
function progressBar(ratio, width) {
|
|
34
|
+
const filled = Math.round(ratio * width);
|
|
35
|
+
const empty = width - filled;
|
|
36
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(empty);
|
|
37
|
+
if (ratio >= 0.75) return chalk.green(bar);
|
|
38
|
+
if (ratio >= 0.25) return chalk.yellow(bar);
|
|
39
|
+
return chalk.red(bar);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/commands/archive.ts
|
|
43
|
+
async function cleanupSyncState(metaDir, entityIds) {
|
|
44
|
+
const statePath = join(metaDir, "sync", "github-state.json");
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = await readFile(statePath, "utf-8");
|
|
48
|
+
} catch {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
const state = JSON.parse(raw);
|
|
52
|
+
let removed = 0;
|
|
53
|
+
for (const id of entityIds) {
|
|
54
|
+
if (state.entities?.[id]) {
|
|
55
|
+
delete state.entities[id];
|
|
56
|
+
removed++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (removed > 0) {
|
|
60
|
+
await writeFile(statePath, `${JSON.stringify(state, null, 2)}
|
|
61
|
+
`, "utf-8");
|
|
62
|
+
}
|
|
63
|
+
return removed;
|
|
64
|
+
}
|
|
65
|
+
var archiveCommand = new Command("archive").description(
|
|
66
|
+
"Move done/cancelled entities older than N days to .meta/archive/"
|
|
67
|
+
).option(
|
|
68
|
+
"-d, --days <number>",
|
|
69
|
+
"Number of days after which done items are archived",
|
|
70
|
+
"7"
|
|
71
|
+
).option("--dry-run", "List files that would be archived without moving them").action(async (opts, cmd) => {
|
|
72
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
73
|
+
const daysOld = Number.parseInt(opts.days, 10);
|
|
74
|
+
const dryRun = opts.dryRun ?? false;
|
|
75
|
+
if (Number.isNaN(daysOld) || daysOld < 0) {
|
|
76
|
+
printError("--days must be a non-negative integer");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (dryRun) {
|
|
80
|
+
printWarning("Dry run \u2014 no files will be moved");
|
|
81
|
+
}
|
|
82
|
+
const result = await archiveOldEntities(metaDir, { daysOld, dryRun });
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
printError(result.error.message);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const { archivedFiles, archivedEntityIds } = result.value;
|
|
88
|
+
if (archivedFiles.length === 0) {
|
|
89
|
+
console.log(
|
|
90
|
+
chalk2.dim(
|
|
91
|
+
`No done/cancelled entities older than ${daysOld} day(s) found.`
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const file of archivedFiles) {
|
|
97
|
+
const label = dryRun ? "would archive" : "archived";
|
|
98
|
+
console.log(chalk2.dim(` ${label}: ${file}`));
|
|
99
|
+
}
|
|
100
|
+
if (!dryRun && archivedEntityIds.length > 0) {
|
|
101
|
+
const removed = await cleanupSyncState(metaDir, archivedEntityIds);
|
|
102
|
+
if (removed > 0) {
|
|
103
|
+
console.log(chalk2.dim(` removed ${removed} entries from sync state`));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
console.log();
|
|
107
|
+
if (dryRun) {
|
|
108
|
+
printWarning(
|
|
109
|
+
`${archivedFiles.length} file(s) would be archived (dry run)`
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
printSuccess(
|
|
113
|
+
`Archived ${archivedFiles.length} file(s) to .meta/archive/`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// src/commands/audit.ts
|
|
119
|
+
import { relative } from "path";
|
|
120
|
+
import {
|
|
121
|
+
auditTree,
|
|
122
|
+
parseTree,
|
|
123
|
+
resolveRefs
|
|
124
|
+
} from "@gitpm/core";
|
|
125
|
+
import chalk3 from "chalk";
|
|
126
|
+
import { Command as Command2 } from "commander";
|
|
127
|
+
function printSection(title, items) {
|
|
128
|
+
if (items.length === 0) return;
|
|
129
|
+
console.log(chalk3.bold.yellow(` ${title} (${items.length})`));
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
const rel = relative(process.cwd(), item.filePath);
|
|
132
|
+
console.log(
|
|
133
|
+
` ${chalk3.dim("\u2022")} ${item.title} ${chalk3.dim("\u2014")} ${item.reason}`
|
|
134
|
+
);
|
|
135
|
+
console.log(` ${chalk3.dim(rel)}`);
|
|
136
|
+
}
|
|
137
|
+
console.log();
|
|
138
|
+
}
|
|
139
|
+
function printDuplicates(pairs) {
|
|
140
|
+
if (pairs.length === 0) return;
|
|
141
|
+
console.log(chalk3.bold.yellow(` Duplicate Candidates (${pairs.length})`));
|
|
142
|
+
for (const { a, b, similarity } of pairs) {
|
|
143
|
+
const pct3 = Math.round(similarity * 100);
|
|
144
|
+
console.log(
|
|
145
|
+
` ${chalk3.dim("\u2022")} ${a.title} ${chalk3.dim("\u2194")} ${b.title} ${chalk3.cyan(`${pct3}%`)}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
console.log();
|
|
149
|
+
}
|
|
150
|
+
function printReport(report) {
|
|
151
|
+
console.log();
|
|
152
|
+
console.log(chalk3.bold(" Project Audit"));
|
|
153
|
+
console.log(chalk3.dim(` ${"\u2500".repeat(13)}`));
|
|
154
|
+
console.log(
|
|
155
|
+
` ${report.summary.total} entities scanned, ${report.summary.issues} issue(s) found`
|
|
156
|
+
);
|
|
157
|
+
console.log();
|
|
158
|
+
printSection("Stale Stories", report.stale);
|
|
159
|
+
printSection("Orphan Stories (no epic)", report.orphans);
|
|
160
|
+
printSection("Empty Bodies", report.emptyBodies);
|
|
161
|
+
printSection("Zombie Epics (all stories done)", report.zombieEpics);
|
|
162
|
+
printDuplicates(report.duplicates);
|
|
163
|
+
}
|
|
164
|
+
var STALE_BUCKETS = [
|
|
165
|
+
{ label: "30-59 days", min: 30, max: 60 },
|
|
166
|
+
{ label: "60-89 days", min: 60, max: 90 },
|
|
167
|
+
{ label: "90+ days", min: 90, max: Number.POSITIVE_INFINITY }
|
|
168
|
+
];
|
|
169
|
+
var MARKDOWN_DEFAULT_STALE_DAYS = STALE_BUCKETS[0].min;
|
|
170
|
+
function staleBucketLabel(item) {
|
|
171
|
+
if (item.daysSinceUpdate == null) return "Unknown age";
|
|
172
|
+
if (item.daysSinceUpdate < STALE_BUCKETS[0].min) return "< 30 days";
|
|
173
|
+
for (const bucket of STALE_BUCKETS) {
|
|
174
|
+
if (item.daysSinceUpdate >= bucket.min && item.daysSinceUpdate < bucket.max) {
|
|
175
|
+
return bucket.label;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return STALE_BUCKETS[STALE_BUCKETS.length - 1].label;
|
|
179
|
+
}
|
|
180
|
+
function groupStaleByBucket(items) {
|
|
181
|
+
const order = [
|
|
182
|
+
"< 30 days",
|
|
183
|
+
...STALE_BUCKETS.map((b) => b.label),
|
|
184
|
+
"Unknown age"
|
|
185
|
+
];
|
|
186
|
+
const groups = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const item of items) {
|
|
188
|
+
const label = staleBucketLabel(item);
|
|
189
|
+
const bucket = groups.get(label) ?? [];
|
|
190
|
+
bucket.push(item);
|
|
191
|
+
groups.set(label, bucket);
|
|
192
|
+
}
|
|
193
|
+
return order.filter((label) => groups.has(label)).map((label) => [label, groups.get(label)]);
|
|
194
|
+
}
|
|
195
|
+
function escapeMdCell(value) {
|
|
196
|
+
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
197
|
+
}
|
|
198
|
+
function mdTable(headers, rows) {
|
|
199
|
+
const headerLine = `| ${headers.join(" | ")} |`;
|
|
200
|
+
const sepLine = `| ${headers.map(() => "---").join(" | ")} |`;
|
|
201
|
+
const rowLines = rows.map((row) => `| ${row.join(" | ")} |`);
|
|
202
|
+
return [headerLine, sepLine, ...rowLines].join("\n");
|
|
203
|
+
}
|
|
204
|
+
function itemToRow(item) {
|
|
205
|
+
return [
|
|
206
|
+
escapeMdCell(item.id),
|
|
207
|
+
escapeMdCell(item.title),
|
|
208
|
+
escapeMdCell(relative(process.cwd(), item.filePath)),
|
|
209
|
+
escapeMdCell(item.reason)
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
function mdItemSection(title, items) {
|
|
213
|
+
const lines = [`## ${title} (${items.length})`, ""];
|
|
214
|
+
if (items.length === 0) {
|
|
215
|
+
lines.push("None.", "");
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
lines.push(
|
|
219
|
+
mdTable(["ID", "Title", "File", "Reason"], items.map(itemToRow)),
|
|
220
|
+
""
|
|
221
|
+
);
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
function buildMarkdownReport(report) {
|
|
225
|
+
const lines = [
|
|
226
|
+
"# Project Audit",
|
|
227
|
+
"",
|
|
228
|
+
`${report.summary.total} entities scanned, ${report.summary.issues} issue(s) found.`,
|
|
229
|
+
""
|
|
230
|
+
];
|
|
231
|
+
lines.push(`## Stale (${report.stale.length})`, "");
|
|
232
|
+
if (report.stale.length === 0) {
|
|
233
|
+
lines.push("None.", "");
|
|
234
|
+
} else {
|
|
235
|
+
for (const [label, items] of groupStaleByBucket(report.stale)) {
|
|
236
|
+
lines.push(
|
|
237
|
+
`### ${label} (${items.length})`,
|
|
238
|
+
"",
|
|
239
|
+
mdTable(["ID", "Title", "File", "Reason"], items.map(itemToRow)),
|
|
240
|
+
""
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
lines.push(...mdItemSection("Orphans", report.orphans));
|
|
245
|
+
lines.push(`## Duplicates (${report.duplicates.length})`, "");
|
|
246
|
+
if (report.duplicates.length === 0) {
|
|
247
|
+
lines.push("None.", "");
|
|
248
|
+
} else {
|
|
249
|
+
lines.push(
|
|
250
|
+
mdTable(
|
|
251
|
+
["A", "B", "Similarity"],
|
|
252
|
+
report.duplicates.map((d) => [
|
|
253
|
+
escapeMdCell(`${d.a.title} (${d.a.id})`),
|
|
254
|
+
escapeMdCell(`${d.b.title} (${d.b.id})`),
|
|
255
|
+
`${Math.round(d.similarity * 100)}%`
|
|
256
|
+
])
|
|
257
|
+
),
|
|
258
|
+
""
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
lines.push(...mdItemSection("Empty bodies", report.emptyBodies));
|
|
262
|
+
lines.push(...mdItemSection("Zombie epics", report.zombieEpics));
|
|
263
|
+
return lines.join("\n");
|
|
264
|
+
}
|
|
265
|
+
var auditCommand = new Command2("audit").description(
|
|
266
|
+
"Detect stale issues, orphans, duplicates, and project hygiene issues"
|
|
267
|
+
).option(
|
|
268
|
+
"--stale-days <days>",
|
|
269
|
+
`Days before a todo is considered stale (${MARKDOWN_DEFAULT_STALE_DAYS} with --markdown unless set)`,
|
|
270
|
+
(value) => Number.parseInt(value, 10),
|
|
271
|
+
90
|
|
272
|
+
).option("--json", "Output as JSON").option(
|
|
273
|
+
"--markdown",
|
|
274
|
+
"Output as a Markdown report with age-tiered stale sections (for CI artifacts)"
|
|
275
|
+
).action(async (opts, cmd) => {
|
|
276
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
277
|
+
const parseResult = await parseTree(metaDir);
|
|
278
|
+
if (!parseResult.ok) {
|
|
279
|
+
printError(parseResult.error.message);
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
const resolveResult = resolveRefs(parseResult.value);
|
|
283
|
+
if (!resolveResult.ok) {
|
|
284
|
+
printError(resolveResult.error.message);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
const staleDaysIsDefault = cmd.getOptionValueSource("staleDays") === "default";
|
|
288
|
+
const staleDays = opts.markdown && staleDaysIsDefault ? MARKDOWN_DEFAULT_STALE_DAYS : opts.staleDays;
|
|
289
|
+
const report = auditTree(resolveResult.value, { staleDays });
|
|
290
|
+
if (opts.markdown) {
|
|
291
|
+
console.log(buildMarkdownReport(report));
|
|
292
|
+
} else if (opts.json) {
|
|
293
|
+
console.log(JSON.stringify(report, null, 2));
|
|
294
|
+
} else if (report.summary.issues === 0) {
|
|
295
|
+
printSuccess("No issues found \u2014 project is clean!");
|
|
296
|
+
} else {
|
|
297
|
+
printReport(report);
|
|
298
|
+
}
|
|
299
|
+
if (report.summary.issues > 0) {
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// src/commands/commit.ts
|
|
305
|
+
import { execFileSync, execSync } from "child_process";
|
|
306
|
+
import { Command as Command3 } from "commander";
|
|
307
|
+
var commitCommand = new Command3("commit").description("Commit all .meta/ changes atomically").requiredOption("-m, --message <message>", "Commit message").option("--author <author>", 'Git author (e.g. "Name <email>")').action(async (opts, cmd) => {
|
|
308
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
309
|
+
try {
|
|
310
|
+
execFileSync("git", ["add", metaDir], { stdio: "pipe" });
|
|
311
|
+
try {
|
|
312
|
+
execSync("git diff --cached --quiet", { stdio: "pipe" });
|
|
313
|
+
printError("No changes to commit in .meta/");
|
|
314
|
+
process.exit(1);
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
const args = ["commit", "-m", opts.message];
|
|
318
|
+
if (opts.author) {
|
|
319
|
+
args.push("--author", opts.author);
|
|
320
|
+
}
|
|
321
|
+
const env = { ...process.env, SKIP: "commitizen" };
|
|
322
|
+
const result = execFileSync("git", args, {
|
|
323
|
+
stdio: "pipe",
|
|
324
|
+
env,
|
|
325
|
+
encoding: "utf-8"
|
|
326
|
+
});
|
|
327
|
+
const hashMatch = result.match(/\[[\w-/]+ ([a-f0-9]+)\]/);
|
|
328
|
+
const hash = hashMatch ? hashMatch[1] : "";
|
|
329
|
+
printSuccess(`Committed${hash ? ` (${hash})` : ""}: ${opts.message}`);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const message = err instanceof Error ? err.stderr || err.message : String(err);
|
|
332
|
+
printError(`Commit failed: ${message}`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// src/commands/context.ts
|
|
338
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
339
|
+
import { resolve as resolve2 } from "path";
|
|
340
|
+
import {
|
|
341
|
+
computeEpicProgress,
|
|
342
|
+
computeMilestoneProgress,
|
|
343
|
+
parseTree as parseTree2,
|
|
344
|
+
resolveRefs as resolveRefs2
|
|
345
|
+
} from "@gitpm/core";
|
|
346
|
+
import { Command as Command4 } from "commander";
|
|
347
|
+
var CONTEXT_START_MARKER = "<!-- gitpm:context:start -->";
|
|
348
|
+
var CONTEXT_END_MARKER = "<!-- gitpm:context:end -->";
|
|
349
|
+
var DONE_STATUSES = /* @__PURE__ */ new Set(["done", "cancelled"]);
|
|
350
|
+
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["in_progress", "in_review"]);
|
|
351
|
+
var MAX_UNASSIGNED_ITEMS = 10;
|
|
352
|
+
function sanitizeTitle(title) {
|
|
353
|
+
let current = title;
|
|
354
|
+
let previous;
|
|
355
|
+
do {
|
|
356
|
+
previous = current;
|
|
357
|
+
current = current.replace(/<!--[\s\S]*?-->/g, " ");
|
|
358
|
+
} while (current !== previous);
|
|
359
|
+
return current.replace(/</g, "<").replace(/>/g, ">").replace(/\s+/g, " ").trim();
|
|
360
|
+
}
|
|
361
|
+
function localDate(date = /* @__PURE__ */ new Date()) {
|
|
362
|
+
const y = date.getFullYear();
|
|
363
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
364
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
365
|
+
return `${y}-${m}-${d}`;
|
|
366
|
+
}
|
|
367
|
+
function pct(ratio) {
|
|
368
|
+
return `${Math.round(ratio * 100)}%`;
|
|
369
|
+
}
|
|
370
|
+
function findActiveMilestone(tree) {
|
|
371
|
+
const active = tree.milestones.filter((m) => !DONE_STATUSES.has(m.status));
|
|
372
|
+
const sorted = [...active].sort((a, b) => {
|
|
373
|
+
if (!a.target_date && !b.target_date) return 0;
|
|
374
|
+
if (!a.target_date) return 1;
|
|
375
|
+
if (!b.target_date) return -1;
|
|
376
|
+
return a.target_date.localeCompare(b.target_date);
|
|
377
|
+
});
|
|
378
|
+
return sorted[0];
|
|
379
|
+
}
|
|
380
|
+
function findCurrentSprint(tree, today) {
|
|
381
|
+
const inRange = tree.sprints.filter((s) => {
|
|
382
|
+
if (DONE_STATUSES.has(s.status)) return false;
|
|
383
|
+
const start = s.start_date.slice(0, 10);
|
|
384
|
+
const end = s.end_date.slice(0, 10);
|
|
385
|
+
return today >= start && today <= end;
|
|
386
|
+
});
|
|
387
|
+
return inRange.find((s) => s.status === "in_progress") ?? inRange[0];
|
|
388
|
+
}
|
|
389
|
+
function findActiveEpics(tree) {
|
|
390
|
+
return tree.epics.filter((e) => ACTIVE_STATUSES.has(e.status));
|
|
391
|
+
}
|
|
392
|
+
function findUnassignedItems(tree) {
|
|
393
|
+
return tree.stories.filter(
|
|
394
|
+
(s) => !DONE_STATUSES.has(s.status) && !ACTIVE_STATUSES.has(s.status) && s.assignee == null
|
|
395
|
+
).map((s) => ({ title: s.title, epicTitle: s.resolvedEpic?.title }));
|
|
396
|
+
}
|
|
397
|
+
function renderContext(tree, today = localDate()) {
|
|
398
|
+
const lines = [];
|
|
399
|
+
lines.push("## Project Context (generated by gitpm)");
|
|
400
|
+
lines.push("");
|
|
401
|
+
lines.push("### Active Milestone");
|
|
402
|
+
const milestone = findActiveMilestone(tree);
|
|
403
|
+
if (milestone) {
|
|
404
|
+
const progress = computeMilestoneProgress(milestone, tree.epics);
|
|
405
|
+
const due = milestone.target_date ? ` \u2014 due ${milestone.target_date.slice(0, 10)}` : "";
|
|
406
|
+
lines.push(
|
|
407
|
+
`**${sanitizeTitle(milestone.title)}** \u2014 ${pct(progress.progress)} done (${progress.done}/${progress.total} stories)${due}`
|
|
408
|
+
);
|
|
409
|
+
} else {
|
|
410
|
+
lines.push("No active milestone.");
|
|
411
|
+
}
|
|
412
|
+
lines.push("");
|
|
413
|
+
lines.push("### Current Sprint");
|
|
414
|
+
const sprint = findCurrentSprint(tree, today);
|
|
415
|
+
if (sprint) {
|
|
416
|
+
const total = sprint.resolvedStories.length;
|
|
417
|
+
const done = sprint.resolvedStories.filter(
|
|
418
|
+
(s) => DONE_STATUSES.has(s.status)
|
|
419
|
+
).length;
|
|
420
|
+
const start = sprint.start_date.slice(0, 10);
|
|
421
|
+
const end = sprint.end_date.slice(0, 10);
|
|
422
|
+
lines.push(
|
|
423
|
+
`**${sanitizeTitle(sprint.title)}** \u2014 ${done}/${total} stories done (${start} \u2192 ${end})`
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
lines.push("No sprint currently active.");
|
|
427
|
+
}
|
|
428
|
+
lines.push("");
|
|
429
|
+
lines.push("### Active Epics");
|
|
430
|
+
const activeEpics = findActiveEpics(tree);
|
|
431
|
+
if (activeEpics.length > 0) {
|
|
432
|
+
for (const epic of activeEpics) {
|
|
433
|
+
const progress = computeEpicProgress(epic);
|
|
434
|
+
lines.push(
|
|
435
|
+
`- **${sanitizeTitle(epic.title)}** \u2014 ${progress.done}/${progress.total} done`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
lines.push("No active epics.");
|
|
440
|
+
}
|
|
441
|
+
lines.push("");
|
|
442
|
+
lines.push("### Unassigned Backlog");
|
|
443
|
+
const unassigned = findUnassignedItems(tree);
|
|
444
|
+
if (unassigned.length > 0) {
|
|
445
|
+
for (const item of unassigned.slice(0, MAX_UNASSIGNED_ITEMS)) {
|
|
446
|
+
const epic = item.epicTitle ? ` (${sanitizeTitle(item.epicTitle)})` : "";
|
|
447
|
+
lines.push(`- ${sanitizeTitle(item.title)}${epic}`);
|
|
448
|
+
}
|
|
449
|
+
if (unassigned.length > MAX_UNASSIGNED_ITEMS) {
|
|
450
|
+
lines.push(`- \u2026and ${unassigned.length - MAX_UNASSIGNED_ITEMS} more`);
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
lines.push("None.");
|
|
454
|
+
}
|
|
455
|
+
lines.push("");
|
|
456
|
+
return lines.join("\n");
|
|
457
|
+
}
|
|
458
|
+
function escapeRegExp(text) {
|
|
459
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
460
|
+
}
|
|
461
|
+
function findOwnLineMarkers(haystack, marker) {
|
|
462
|
+
const pattern = new RegExp(
|
|
463
|
+
`^[ \\t]*${escapeRegExp(marker)}[ \\t]*(?=\\r?$)`,
|
|
464
|
+
"gm"
|
|
465
|
+
);
|
|
466
|
+
return [...haystack.matchAll(pattern)].map((m) => ({
|
|
467
|
+
start: m.index,
|
|
468
|
+
end: m.index + m[0].length
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
function spliceIntoClaudeMd(existing, section) {
|
|
472
|
+
const block = `${CONTEXT_START_MARKER}
|
|
473
|
+
|
|
474
|
+
${section}
|
|
475
|
+
${CONTEXT_END_MARKER}`;
|
|
476
|
+
const starts = findOwnLineMarkers(existing, CONTEXT_START_MARKER);
|
|
477
|
+
const ends = findOwnLineMarkers(existing, CONTEXT_END_MARKER);
|
|
478
|
+
if (starts.length === 0 && ends.length === 0) {
|
|
479
|
+
const trimmed = existing.replace(/\n+$/, "");
|
|
480
|
+
const separator = trimmed.length > 0 ? "\n\n" : "";
|
|
481
|
+
return { ok: true, value: `${trimmed}${separator}${block}
|
|
482
|
+
` };
|
|
483
|
+
}
|
|
484
|
+
const [start] = starts;
|
|
485
|
+
const [end] = ends;
|
|
486
|
+
if (starts.length !== 1 || ends.length !== 1 || start === void 0 || end === void 0 || end.start < start.end) {
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
error: new Error(
|
|
490
|
+
`CLAUDE.md has unbalanced gitpm context markers (found ${starts.length} start, ${ends.length} end on their own lines; expected exactly one "${CONTEXT_START_MARKER}" line followed by one "${CONTEXT_END_MARKER}" line). Fix the markers manually and re-run.`
|
|
491
|
+
)
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const before = existing.slice(0, start.start);
|
|
495
|
+
const after = existing.slice(end.end);
|
|
496
|
+
return { ok: true, value: `${before}${block}${after}` };
|
|
497
|
+
}
|
|
498
|
+
function errorMessage(err) {
|
|
499
|
+
return err instanceof Error ? err.message : String(err);
|
|
500
|
+
}
|
|
501
|
+
function isEnoent(err) {
|
|
502
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
503
|
+
}
|
|
504
|
+
var contextCommand = new Command4("context").description(
|
|
505
|
+
"Generate project context (milestone, sprint, epics, unassigned backlog) from the .meta tree"
|
|
506
|
+
).option("--output <file>", "Write context to a file instead of stdout").option(
|
|
507
|
+
"--update",
|
|
508
|
+
"Splice context into ./CLAUDE.md between gitpm:context markers"
|
|
509
|
+
).action(async (opts, cmd) => {
|
|
510
|
+
if (opts.update && opts.output) {
|
|
511
|
+
printError("--update and --output cannot be used together");
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
515
|
+
const parseResult = await parseTree2(metaDir);
|
|
516
|
+
if (!parseResult.ok) {
|
|
517
|
+
printError(parseResult.error.message);
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
const resolveResult = resolveRefs2(parseResult.value);
|
|
521
|
+
if (!resolveResult.ok) {
|
|
522
|
+
printError(resolveResult.error.message);
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
const section = renderContext(resolveResult.value);
|
|
526
|
+
if (opts.update) {
|
|
527
|
+
const claudeMdPath = resolve2(process.cwd(), "CLAUDE.md");
|
|
528
|
+
let existing = "";
|
|
529
|
+
try {
|
|
530
|
+
existing = await readFile2(claudeMdPath, "utf-8");
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (!isEnoent(err)) {
|
|
533
|
+
printError(`Failed to read ${claudeMdPath}: ${errorMessage(err)}`);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const updated = spliceIntoClaudeMd(existing, section);
|
|
538
|
+
if (!updated.ok) {
|
|
539
|
+
printError(updated.error.message);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
await writeFile2(claudeMdPath, updated.value, "utf-8");
|
|
544
|
+
} catch (err) {
|
|
545
|
+
printError(`Failed to write ${claudeMdPath}: ${errorMessage(err)}`);
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
printSuccess(`Updated ${claudeMdPath}`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (opts.output) {
|
|
552
|
+
const outputPath = resolve2(process.cwd(), opts.output);
|
|
553
|
+
try {
|
|
554
|
+
await writeFile2(outputPath, `${section}
|
|
555
|
+
`, "utf-8");
|
|
556
|
+
} catch (err) {
|
|
557
|
+
printError(`Failed to write ${outputPath}: ${errorMessage(err)}`);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
printSuccess(`Wrote context to ${outputPath}`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
console.log(section);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// src/commands/create.ts
|
|
567
|
+
import { relative as relative2 } from "path";
|
|
568
|
+
import {
|
|
569
|
+
createEpic,
|
|
570
|
+
createMilestone,
|
|
571
|
+
createStory,
|
|
572
|
+
parseTree as parseTree3
|
|
573
|
+
} from "@gitpm/core";
|
|
574
|
+
import { Command as Command5 } from "commander";
|
|
575
|
+
var storySubcommand = new Command5("story").description("Create a new story").requiredOption("--title <title>", "Story title").option(
|
|
576
|
+
"--priority <priority>",
|
|
577
|
+
"Priority (low, medium, high, critical)",
|
|
578
|
+
"medium"
|
|
579
|
+
).option("--status <status>", "Initial status", "backlog").option("--labels <labels>", "Comma-separated labels").option("--epic <epic>", "Epic ID or directory slug to place story under").option("--assignee <assignee>", "Assignee").option("--estimate <estimate>", "Estimate (number)").option("--body <body>", "Body content").action(async (opts, cmd) => {
|
|
580
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
581
|
+
let epicId;
|
|
582
|
+
let epicSlug;
|
|
583
|
+
if (opts.epic) {
|
|
584
|
+
const parseResult = await parseTree3(metaDir);
|
|
585
|
+
if (!parseResult.ok) {
|
|
586
|
+
printError(parseResult.error.message);
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
const matchById = parseResult.value.epics.find((e) => e.id === opts.epic);
|
|
590
|
+
if (matchById) {
|
|
591
|
+
epicId = matchById.id;
|
|
592
|
+
const parts = matchById.filePath.split("/");
|
|
593
|
+
const epicIdx = parts.indexOf("epics");
|
|
594
|
+
if (epicIdx >= 0 && epicIdx + 1 < parts.length) {
|
|
595
|
+
epicSlug = parts[epicIdx + 1];
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
const matchBySlug = parseResult.value.epics.find(
|
|
599
|
+
(e) => e.filePath.includes(`/epics/${opts.epic}/`)
|
|
600
|
+
);
|
|
601
|
+
if (matchBySlug) {
|
|
602
|
+
epicId = matchBySlug.id;
|
|
603
|
+
epicSlug = opts.epic;
|
|
604
|
+
} else {
|
|
605
|
+
printError(`Epic not found: ${opts.epic}`);
|
|
606
|
+
process.exit(1);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const result = await createStory(metaDir, {
|
|
611
|
+
title: opts.title,
|
|
612
|
+
priority: opts.priority,
|
|
613
|
+
status: opts.status,
|
|
614
|
+
labels: opts.labels ? opts.labels.split(",").map((s) => s.trim()) : void 0,
|
|
615
|
+
epicId,
|
|
616
|
+
epicSlug,
|
|
617
|
+
assignee: opts.assignee,
|
|
618
|
+
estimate: opts.estimate ? Number(opts.estimate) : void 0,
|
|
619
|
+
body: opts.body
|
|
620
|
+
});
|
|
621
|
+
if (!result.ok) {
|
|
622
|
+
printError(result.error.message);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
const rel = relative2(process.cwd(), result.value.filePath);
|
|
626
|
+
printSuccess(`Created story ${result.value.id} at ${rel}`);
|
|
627
|
+
});
|
|
628
|
+
var epicSubcommand = new Command5("epic").description("Create a new epic").requiredOption("--title <title>", "Epic title").option(
|
|
629
|
+
"--priority <priority>",
|
|
630
|
+
"Priority (low, medium, high, critical)",
|
|
631
|
+
"medium"
|
|
632
|
+
).option("--status <status>", "Initial status", "backlog").option("--labels <labels>", "Comma-separated labels").option("--milestone <milestone>", "Milestone ID to link").option("--owner <owner>", "Owner").option("--body <body>", "Body content").action(async (opts, cmd) => {
|
|
633
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
634
|
+
const result = await createEpic(metaDir, {
|
|
635
|
+
title: opts.title,
|
|
636
|
+
priority: opts.priority,
|
|
637
|
+
status: opts.status,
|
|
638
|
+
labels: opts.labels ? opts.labels.split(",").map((s) => s.trim()) : void 0,
|
|
639
|
+
milestoneId: opts.milestone,
|
|
640
|
+
owner: opts.owner,
|
|
641
|
+
body: opts.body
|
|
642
|
+
});
|
|
643
|
+
if (!result.ok) {
|
|
644
|
+
printError(result.error.message);
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|
|
647
|
+
const rel = relative2(process.cwd(), result.value.filePath);
|
|
648
|
+
printSuccess(`Created epic ${result.value.id} at ${rel}`);
|
|
649
|
+
});
|
|
650
|
+
var milestoneSubcommand = new Command5("milestone").description("Create a new milestone").requiredOption("--title <title>", "Milestone title").option("--status <status>", "Initial status", "backlog").option("--target-date <date>", "Target date (ISO format)").option("--body <body>", "Body content").action(async (opts, cmd) => {
|
|
651
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
652
|
+
const result = await createMilestone(metaDir, {
|
|
653
|
+
title: opts.title,
|
|
654
|
+
status: opts.status,
|
|
655
|
+
targetDate: opts.targetDate,
|
|
656
|
+
body: opts.body
|
|
657
|
+
});
|
|
658
|
+
if (!result.ok) {
|
|
659
|
+
printError(result.error.message);
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
const rel = relative2(process.cwd(), result.value.filePath);
|
|
663
|
+
printSuccess(`Created milestone ${result.value.id} at ${rel}`);
|
|
664
|
+
});
|
|
665
|
+
var createCommand = new Command5("create").description("Create a new .meta/ entity").addCommand(storySubcommand).addCommand(epicSubcommand).addCommand(milestoneSubcommand);
|
|
666
|
+
|
|
667
|
+
// src/commands/import.ts
|
|
668
|
+
import { dirname } from "path";
|
|
669
|
+
import {
|
|
670
|
+
findAdapterByName,
|
|
671
|
+
loadAdapters,
|
|
672
|
+
loadGitpmConfig,
|
|
673
|
+
runHooks
|
|
674
|
+
} from "@gitpm/core";
|
|
675
|
+
import chalk4 from "chalk";
|
|
676
|
+
import { Command as Command6 } from "commander";
|
|
677
|
+
import ora from "ora";
|
|
678
|
+
|
|
679
|
+
// src/utils/auth.ts
|
|
680
|
+
import { execSync as execSync2 } from "child_process";
|
|
681
|
+
function ghAuthToken() {
|
|
682
|
+
try {
|
|
683
|
+
const token = execSync2("gh auth token", {
|
|
684
|
+
encoding: "utf-8",
|
|
685
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
686
|
+
}).trim();
|
|
687
|
+
return token || void 0;
|
|
688
|
+
} catch {
|
|
689
|
+
return void 0;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
var ADAPTER_CREDENTIALS = {
|
|
693
|
+
github: { tokenEnv: "GITHUB_TOKEN", fallback: ghAuthToken },
|
|
694
|
+
gitlab: { tokenEnv: "GITLAB_TOKEN" },
|
|
695
|
+
jira: { tokenEnv: "JIRA_TOKEN", emailEnv: "JIRA_EMAIL" },
|
|
696
|
+
bitbucket: { tokenEnv: "BITBUCKET_TOKEN", emailEnv: "BITBUCKET_EMAIL" }
|
|
697
|
+
};
|
|
698
|
+
function envValue(name) {
|
|
699
|
+
if (!name) return void 0;
|
|
700
|
+
return process.env[name] || void 0;
|
|
701
|
+
}
|
|
702
|
+
function resolveCredentials(adapterName, cliToken) {
|
|
703
|
+
const spec = ADAPTER_CREDENTIALS[adapterName];
|
|
704
|
+
const token = cliToken || envValue(spec?.tokenEnv) || spec?.fallback?.() || void 0;
|
|
705
|
+
const email = envValue(spec?.emailEnv);
|
|
706
|
+
return { token, email };
|
|
707
|
+
}
|
|
52
708
|
|
|
53
709
|
// src/commands/import.ts
|
|
54
710
|
var VALID_LINK_STRATEGIES = [
|
|
@@ -56,48 +712,55 @@ var VALID_LINK_STRATEGIES = [
|
|
|
56
712
|
"sub-issues",
|
|
57
713
|
"milestone",
|
|
58
714
|
"labels",
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"body-refs",
|
|
63
|
-
"native-epics",
|
|
64
|
-
"milestone",
|
|
65
|
-
"labels",
|
|
66
|
-
"all"
|
|
715
|
+
"score",
|
|
716
|
+
"all",
|
|
717
|
+
"native-epics"
|
|
67
718
|
];
|
|
68
|
-
var importCommand = new
|
|
719
|
+
var importCommand = new Command6("import").description("Import project data from a remote platform into .meta/").option(
|
|
69
720
|
"--source <source>",
|
|
70
|
-
"Source platform: github or
|
|
721
|
+
"Source platform: github, gitlab, jira, linear, or bitbucket (default: github)",
|
|
71
722
|
"github"
|
|
72
723
|
).option("--repo <owner/repo>", "GitHub repository (owner/repo)").option(
|
|
73
724
|
"--project <value>",
|
|
74
|
-
"GitHub Project number
|
|
75
|
-
).option("--token <token>", "Personal access token").option(
|
|
725
|
+
"GitHub Project number, GitLab project path (namespace/project), or Jira project key"
|
|
726
|
+
).option("--token <token>", "Personal access token").option(
|
|
727
|
+
"--base-url <url>",
|
|
728
|
+
"GitLab base URL (default: https://gitlab.com) or Jira site URL"
|
|
729
|
+
).option("--site <host>", "Jira site, e.g. mycompany.atlassian.net").option("--project-key <key>", "Jira project key, e.g. PROJ").option("--board <id>", "Jira board ID (optional, for sprint import)").option("--team <id>", "Linear team ID").option(
|
|
730
|
+
"--api-key <key>",
|
|
731
|
+
"Linear API key (default: LINEAR_API_KEY environment variable)"
|
|
732
|
+
).option(
|
|
76
733
|
"--link-strategy <strategy>",
|
|
77
734
|
"Epic-story linkage strategy (default: all)"
|
|
78
|
-
).action(async (opts, cmd) => {
|
|
735
|
+
).option("--adapter <name>", "Force a specific adapter by name").action(async (opts, cmd) => {
|
|
79
736
|
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
80
|
-
const source = opts.source;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} else {
|
|
86
|
-
printError(`Unknown source "${source}". Use "github" or "gitlab".`);
|
|
737
|
+
const source = opts.adapter ?? opts.source;
|
|
738
|
+
const rootDir = dirname(metaDir);
|
|
739
|
+
const configResult = await loadGitpmConfig(rootDir);
|
|
740
|
+
if (!configResult.ok) {
|
|
741
|
+
printError(`Failed to load config: ${configResult.error.message}`);
|
|
87
742
|
process.exit(1);
|
|
88
743
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
printError('--repo is required for GitHub import. Expected "owner/repo".');
|
|
744
|
+
const config = configResult.value;
|
|
745
|
+
const adaptersResult = await loadAdapters(config, rootDir);
|
|
746
|
+
if (!adaptersResult.ok) {
|
|
747
|
+
printError(`Failed to load adapters: ${adaptersResult.error.message}`);
|
|
94
748
|
process.exit(1);
|
|
95
749
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
750
|
+
const adapter = findAdapterByName(adaptersResult.value, source);
|
|
751
|
+
if (!adapter) {
|
|
752
|
+
const available = adaptersResult.value.map((a) => a.name);
|
|
753
|
+
if (available.length === 0) {
|
|
754
|
+
printError(
|
|
755
|
+
`Adapter "${source}" is not installed. Install it with:
|
|
756
|
+
npm install -g @gitpm/sync-${source}`
|
|
757
|
+
);
|
|
758
|
+
} else {
|
|
759
|
+
printError(
|
|
760
|
+
`Adapter "${source}" not found. Available: ${available.join(", ")}
|
|
761
|
+
Install it with: npm install -g @gitpm/sync-${source}`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
101
764
|
process.exit(1);
|
|
102
765
|
}
|
|
103
766
|
const linkStrategy = opts.linkStrategy;
|
|
@@ -109,49 +772,28 @@ async function importGitHub(opts, metaDir) {
|
|
|
109
772
|
);
|
|
110
773
|
process.exit(1);
|
|
111
774
|
}
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
token,
|
|
115
|
-
repo,
|
|
116
|
-
projectNumber: opts.project ? Number.parseInt(opts.project, 10) : void 0,
|
|
775
|
+
const { token, email } = resolveCredentials(adapter.name, opts.token);
|
|
776
|
+
const preHook = await runHooks(config, "pre-import", {
|
|
117
777
|
metaDir,
|
|
118
|
-
|
|
778
|
+
event: "pre-import",
|
|
779
|
+
adapterName: adapter.name
|
|
119
780
|
});
|
|
120
|
-
if (!
|
|
121
|
-
|
|
122
|
-
printError(result.error.message);
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
spinner.succeed("Import complete.");
|
|
126
|
-
printImportSummary(result.value);
|
|
127
|
-
}
|
|
128
|
-
async function importGitLab(opts, metaDir) {
|
|
129
|
-
const project = opts.project ?? opts.repo;
|
|
130
|
-
if (!project) {
|
|
131
|
-
printError("--project (namespace/project) is required for GitLab import.");
|
|
132
|
-
process.exit(1);
|
|
133
|
-
}
|
|
134
|
-
const token = opts.token ?? process.env.GITLAB_TOKEN;
|
|
135
|
-
if (!token) {
|
|
136
|
-
printError(
|
|
137
|
-
"No GitLab token found. Provide via --token flag or GITLAB_TOKEN env var."
|
|
138
|
-
);
|
|
139
|
-
process.exit(1);
|
|
140
|
-
}
|
|
141
|
-
const linkStrategy = opts.linkStrategy;
|
|
142
|
-
if (linkStrategy && !VALID_GITLAB_LINK_STRATEGIES.includes(
|
|
143
|
-
linkStrategy
|
|
144
|
-
)) {
|
|
145
|
-
printError(
|
|
146
|
-
`Invalid link strategy "${linkStrategy}". Must be one of: ${VALID_GITLAB_LINK_STRATEGIES.join(", ")}`
|
|
147
|
-
);
|
|
781
|
+
if (!preHook.ok) {
|
|
782
|
+
printError(`Pre-import hook failed: ${preHook.error.message}`);
|
|
148
783
|
process.exit(1);
|
|
149
784
|
}
|
|
150
|
-
const spinner = ora(
|
|
151
|
-
const
|
|
785
|
+
const spinner = ora(`Importing from ${adapter.displayName}...`).start();
|
|
786
|
+
const jira = resolveJiraOptions(adapter.name, opts);
|
|
787
|
+
const result = await adapter.import({
|
|
152
788
|
token,
|
|
153
|
-
|
|
789
|
+
email,
|
|
790
|
+
repo: opts.repo,
|
|
791
|
+
project: opts.project ?? opts.repo,
|
|
792
|
+
projectNumber: opts.project && !Number.isNaN(Number.parseInt(opts.project, 10)) ? Number.parseInt(opts.project, 10) : void 0,
|
|
154
793
|
baseUrl: opts.baseUrl,
|
|
794
|
+
...jira,
|
|
795
|
+
teamId: opts.team,
|
|
796
|
+
apiKey: opts.apiKey,
|
|
155
797
|
metaDir,
|
|
156
798
|
linkStrategy
|
|
157
799
|
});
|
|
@@ -162,11 +804,27 @@ async function importGitLab(opts, metaDir) {
|
|
|
162
804
|
}
|
|
163
805
|
spinner.succeed("Import complete.");
|
|
164
806
|
printImportSummary(result.value);
|
|
807
|
+
await runHooks(config, "post-import", {
|
|
808
|
+
metaDir,
|
|
809
|
+
event: "post-import",
|
|
810
|
+
adapterName: adapter.name,
|
|
811
|
+
result: result.value
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
function resolveJiraOptions(adapterName, opts) {
|
|
815
|
+
const isJira = adapterName === "jira";
|
|
816
|
+
const site = opts.site ?? (isJira && opts.baseUrl ? siteFromUrl(opts.baseUrl) : void 0);
|
|
817
|
+
const projectKey = opts.projectKey ?? (isJira ? opts.project : void 0);
|
|
818
|
+
const boardId = opts.board !== void 0 && !Number.isNaN(Number.parseInt(opts.board, 10)) ? Number.parseInt(opts.board, 10) : void 0;
|
|
819
|
+
return { site, projectKey, boardId };
|
|
820
|
+
}
|
|
821
|
+
function siteFromUrl(url) {
|
|
822
|
+
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/\/+$/, "");
|
|
165
823
|
}
|
|
166
824
|
function printImportSummary(value) {
|
|
167
825
|
const { milestones, epics, stories, totalFiles } = value;
|
|
168
826
|
console.log();
|
|
169
|
-
console.log(
|
|
827
|
+
console.log(chalk4.bold("Summary:"));
|
|
170
828
|
console.log(` Milestones: ${milestones}`);
|
|
171
829
|
console.log(` Epics: ${epics}`);
|
|
172
830
|
console.log(` Stories: ${stories}`);
|
|
@@ -175,16 +833,203 @@ function printImportSummary(value) {
|
|
|
175
833
|
printSuccess(
|
|
176
834
|
`Imported ${milestones + epics + stories} entities (${totalFiles} files).`
|
|
177
835
|
);
|
|
178
|
-
console.log(
|
|
836
|
+
console.log(chalk4.dim("Run `gitpm validate` to verify the imported tree."));
|
|
179
837
|
}
|
|
180
838
|
|
|
181
839
|
// src/commands/init.ts
|
|
182
|
-
import { readdir } from "fs/promises";
|
|
183
|
-
import { join } from "path";
|
|
840
|
+
import { mkdir, readdir, stat, writeFile as writeFile3 } from "fs/promises";
|
|
841
|
+
import { dirname as dirname2, join as join2, relative as relative3, resolve as resolve3 } from "path";
|
|
184
842
|
import { scaffoldMeta } from "@gitpm/core";
|
|
185
843
|
import { input } from "@inquirer/prompts";
|
|
186
|
-
import
|
|
187
|
-
import { Command as
|
|
844
|
+
import chalk5 from "chalk";
|
|
845
|
+
import { Command as Command7 } from "commander";
|
|
846
|
+
|
|
847
|
+
// src/templates/claude-skill.ts
|
|
848
|
+
var GITPM_SKILL_TEMPLATE = `---
|
|
849
|
+
name: gitpm
|
|
850
|
+
description: Use when working with .meta/ project management entities (stories, epics, milestones, roadmaps, PRDs) in a project that uses GitPM. Provides commands to query, filter, read, update fields, create, move, and commit entities without manual file parsing. Prefer these over grep/read/Edit on .meta/ files.
|
|
851
|
+
---
|
|
852
|
+
|
|
853
|
+
# gitpm \u2014 Project management via .meta/ files
|
|
854
|
+
|
|
855
|
+
This project uses [GitPM](https://github.com/yevheniidehtiar/gitpm) to manage project management data (stories, epics, milestones, roadmaps, PRDs) as files under \`.meta/\`. Entities are Markdown files with YAML frontmatter, synced bidirectionally with GitHub Issues.
|
|
856
|
+
|
|
857
|
+
## When to use the \`gitpm\` CLI vs reading files directly
|
|
858
|
+
|
|
859
|
+
**Prefer \`gitpm\` commands** \u2014 they are 10\xD7 fewer tool calls than grep+read+Edit loops and validate writes against the schema:
|
|
860
|
+
|
|
861
|
+
| Goal | Use |
|
|
862
|
+
|---|---|
|
|
863
|
+
| Find tasks by status / priority / label / epic | \`gitpm query\` |
|
|
864
|
+
| Display one entity (or an epic + its stories) | \`gitpm show\` |
|
|
865
|
+
| Update a frontmatter field (status, priority, labels, assignee) | \`gitpm set\` |
|
|
866
|
+
| Create a new story / epic / milestone | \`gitpm create\` |
|
|
867
|
+
| Move a story between epics (or to/from orphan) | \`gitpm move\` |
|
|
868
|
+
| Stage + commit all .meta/ changes atomically | \`gitpm commit\` |
|
|
869
|
+
| Push local .meta/ to GitHub Issues | \`gitpm push\` |
|
|
870
|
+
| Pull GitHub Issues into local .meta/ | \`gitpm pull\` |
|
|
871
|
+
| Bidirectional sync | \`gitpm sync\` |
|
|
872
|
+
| Validate the whole tree | \`gitpm validate\` |
|
|
873
|
+
|
|
874
|
+
**Avoid** \`grep\` / \`cat\` / \`Edit\` directly on \`.meta/*.md\` unless you truly need a raw view. The CLI preserves frontmatter ordering, validates against Zod schemas, and keeps GitHub sync state consistent.
|
|
875
|
+
|
|
876
|
+
## Common recipes
|
|
877
|
+
|
|
878
|
+
### Find all in-progress stories
|
|
879
|
+
\`\`\`bash
|
|
880
|
+
gitpm query --type story --status in_progress
|
|
881
|
+
\`\`\`
|
|
882
|
+
|
|
883
|
+
### Find high/critical stories across all types
|
|
884
|
+
\`\`\`bash
|
|
885
|
+
gitpm query --priority high,critical --format table
|
|
886
|
+
\`\`\`
|
|
887
|
+
|
|
888
|
+
### Show an epic and every story under it
|
|
889
|
+
\`\`\`bash
|
|
890
|
+
gitpm show --epic auth-system
|
|
891
|
+
\`\`\`
|
|
892
|
+
|
|
893
|
+
### Show a specific story with its full body
|
|
894
|
+
\`\`\`bash
|
|
895
|
+
gitpm show .meta/epics/auth/stories/login.md --full
|
|
896
|
+
\`\`\`
|
|
897
|
+
|
|
898
|
+
### Change a story's status to in_progress
|
|
899
|
+
\`\`\`bash
|
|
900
|
+
gitpm set .meta/epics/auth/stories/login.md status=in_progress
|
|
901
|
+
\`\`\`
|
|
902
|
+
|
|
903
|
+
### Append labels (array operation)
|
|
904
|
+
\`\`\`bash
|
|
905
|
+
gitpm set .meta/stories/onboarding.md labels+=backend labels+=security
|
|
906
|
+
\`\`\`
|
|
907
|
+
|
|
908
|
+
### Remove a label
|
|
909
|
+
\`\`\`bash
|
|
910
|
+
gitpm set .meta/stories/onboarding.md labels-=stale
|
|
911
|
+
\`\`\`
|
|
912
|
+
|
|
913
|
+
### Create a new story under an existing epic
|
|
914
|
+
\`\`\`bash
|
|
915
|
+
gitpm create story \\
|
|
916
|
+
--title "Add OAuth callback handler" \\
|
|
917
|
+
--epic auth-system \\
|
|
918
|
+
--priority high \\
|
|
919
|
+
--labels backend,oauth
|
|
920
|
+
\`\`\`
|
|
921
|
+
|
|
922
|
+
### Create a standalone story (no epic)
|
|
923
|
+
\`\`\`bash
|
|
924
|
+
gitpm create story --title "Flaky test in checkout" --priority medium
|
|
925
|
+
\`\`\`
|
|
926
|
+
|
|
927
|
+
### Move a story from orphan into an epic
|
|
928
|
+
\`\`\`bash
|
|
929
|
+
gitpm move .meta/stories/orphan.md --to-epic auth-system
|
|
930
|
+
\`\`\`
|
|
931
|
+
|
|
932
|
+
### Commit all .meta/ changes
|
|
933
|
+
\`\`\`bash
|
|
934
|
+
gitpm commit -m "feat(pm): progress auth-system stories"
|
|
935
|
+
\`\`\`
|
|
936
|
+
|
|
937
|
+
### Push local changes to GitHub Issues
|
|
938
|
+
\`\`\`bash
|
|
939
|
+
gitpm push --token "$GITHUB_TOKEN"
|
|
940
|
+
\`\`\`
|
|
941
|
+
|
|
942
|
+
## Field update syntax for \`gitpm set\`
|
|
943
|
+
|
|
944
|
+
| Syntax | Effect |
|
|
945
|
+
|---|---|
|
|
946
|
+
| \`field=value\` | Replace the value |
|
|
947
|
+
| \`field+=value\` | Append to an array field (labels, dependencies) |
|
|
948
|
+
| \`field-=value\` | Remove from an array field |
|
|
949
|
+
| \`field=null\` | Clear the value |
|
|
950
|
+
|
|
951
|
+
Multiple assignments in one call are applied atomically:
|
|
952
|
+
\`\`\`bash
|
|
953
|
+
gitpm set .meta/stories/auth.md status=in_progress assignee=alice labels+=urgent
|
|
954
|
+
\`\`\`
|
|
955
|
+
|
|
956
|
+
## Entity layout
|
|
957
|
+
|
|
958
|
+
\`\`\`
|
|
959
|
+
.meta/
|
|
960
|
+
\u251C\u2500\u2500 roadmap/
|
|
961
|
+
\u2502 \u251C\u2500\u2500 roadmap.yaml # top-level roadmap
|
|
962
|
+
\u2502 \u2514\u2500\u2500 milestones/*.md # milestones
|
|
963
|
+
\u251C\u2500\u2500 epics/
|
|
964
|
+
\u2502 \u2514\u2500\u2500 <slug>/
|
|
965
|
+
\u2502 \u251C\u2500\u2500 epic.md # the epic
|
|
966
|
+
\u2502 \u2514\u2500\u2500 stories/*.md # stories nested under this epic
|
|
967
|
+
\u251C\u2500\u2500 stories/*.md # orphan stories (no epic)
|
|
968
|
+
\u251C\u2500\u2500 prds/*.md # product requirements docs
|
|
969
|
+
\u2514\u2500\u2500 sync/
|
|
970
|
+
\u251C\u2500\u2500 github-config.yaml
|
|
971
|
+
\u2514\u2500\u2500 github-state.json
|
|
972
|
+
\`\`\`
|
|
973
|
+
|
|
974
|
+
## Allowed field values
|
|
975
|
+
|
|
976
|
+
- **status**: \`backlog\` \xB7 \`todo\` \xB7 \`in_progress\` \xB7 \`in_review\` \xB7 \`done\` \xB7 \`cancelled\`
|
|
977
|
+
- **priority**: \`low\` \xB7 \`medium\` \xB7 \`high\` \xB7 \`critical\`
|
|
978
|
+
|
|
979
|
+
## After modifying .meta/ files
|
|
980
|
+
|
|
981
|
+
Always run \`gitpm push\` so GitHub Issues stay in sync:
|
|
982
|
+
|
|
983
|
+
\`\`\`bash
|
|
984
|
+
gitpm push --token "$GITHUB_TOKEN"
|
|
985
|
+
\`\`\`
|
|
986
|
+
|
|
987
|
+
Use \`gitpm sync --strategy local-wins\` when you have both local edits and remote changes.
|
|
988
|
+
|
|
989
|
+
## Story lifecycle (three-state flow)
|
|
990
|
+
|
|
991
|
+
Stories move through four states tied to the release pipeline:
|
|
992
|
+
|
|
993
|
+
\`\`\`
|
|
994
|
+
todo \u2500\u2500\u25BA in_progress \u2500\u2500\u25BA in_review \u2500\u2500\u25BA done
|
|
995
|
+
\`\`\`
|
|
996
|
+
|
|
997
|
+
**You are responsible for the first two transitions only** \u2014 the release-please
|
|
998
|
+
CI job promotes \`in_review\` \u2192 \`done\` automatically, and \`post-merge-sync.yml\`
|
|
999
|
+
closes the GitHub issue once the release PR merges.
|
|
1000
|
+
|
|
1001
|
+
| Transition | Who | When |
|
|
1002
|
+
|---|---|---|
|
|
1003
|
+
| \`todo\` \u2192 \`in_progress\` | **You** | Before writing any code for the story |
|
|
1004
|
+
| \`in_progress\` \u2192 \`in_review\` | **You** | Inside the work PR itself, committed with the code |
|
|
1005
|
+
| \`in_review\` \u2192 \`done\` | release-please bot | When the release PR is created/updated |
|
|
1006
|
+
| GitHub issue closes | post-merge-sync bot | After the release PR merges |
|
|
1007
|
+
|
|
1008
|
+
**Rule 1 \u2014 When you start work on a story, flip it to in_progress:**
|
|
1009
|
+
\`\`\`bash
|
|
1010
|
+
gitpm set .meta/epics/<epic>/stories/<story>.md status=in_progress
|
|
1011
|
+
\`\`\`
|
|
1012
|
+
|
|
1013
|
+
**Rule 2 \u2014 When you raise the PR that implements the story, do two things in the same PR:**
|
|
1014
|
+
|
|
1015
|
+
1. Flip the story's status in the diff:
|
|
1016
|
+
\`\`\`bash
|
|
1017
|
+
gitpm set .meta/epics/<epic>/stories/<story>.md status=in_review
|
|
1018
|
+
\`\`\`
|
|
1019
|
+
2. Reference the story file path(s) in the PR body under "Related GitPM stories" (the PR template has a section for it).
|
|
1020
|
+
|
|
1021
|
+
**Do NOT manually set status=done or close GitHub issues** \u2014 both are handled by CI. Setting \`status=done\` yourself short-circuits the release PR audit surface.
|
|
1022
|
+
|
|
1023
|
+
If a PR has no \`.meta/\` story (docs, tooling, typo fix): skip both rules. CI will find 0 \`in_review\` stories and exit cleanly.
|
|
1024
|
+
|
|
1025
|
+
## Help
|
|
1026
|
+
|
|
1027
|
+
- \`gitpm --help\` \u2014 list all commands
|
|
1028
|
+
- \`gitpm <command> --help\` \u2014 flags for a specific command
|
|
1029
|
+
- \`gitpm validate\` \u2014 run before committing to catch schema errors
|
|
1030
|
+
`;
|
|
1031
|
+
|
|
1032
|
+
// src/commands/init.ts
|
|
188
1033
|
async function collectFiles(dir, prefix = "") {
|
|
189
1034
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
190
1035
|
const results = [];
|
|
@@ -192,7 +1037,7 @@ async function collectFiles(dir, prefix = "") {
|
|
|
192
1037
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
193
1038
|
if (entry.isDirectory()) {
|
|
194
1039
|
results.push(rel);
|
|
195
|
-
const children = await collectFiles(
|
|
1040
|
+
const children = await collectFiles(join2(dir, entry.name), rel);
|
|
196
1041
|
results.push(...children);
|
|
197
1042
|
} else {
|
|
198
1043
|
results.push(rel);
|
|
@@ -200,65 +1045,243 @@ async function collectFiles(dir, prefix = "") {
|
|
|
200
1045
|
}
|
|
201
1046
|
return results;
|
|
202
1047
|
}
|
|
203
|
-
|
|
1048
|
+
async function fileExists(path) {
|
|
1049
|
+
try {
|
|
1050
|
+
await stat(path);
|
|
1051
|
+
return true;
|
|
1052
|
+
} catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
async function scaffoldClaudeSkill(metaDir) {
|
|
1057
|
+
const projectRoot = dirname2(resolve3(metaDir));
|
|
1058
|
+
const skillDir = join2(projectRoot, ".claude", "skills", "gitpm");
|
|
1059
|
+
const skillPath = join2(skillDir, "SKILL.md");
|
|
1060
|
+
if (await fileExists(skillPath)) {
|
|
1061
|
+
return {
|
|
1062
|
+
status: "skipped",
|
|
1063
|
+
path: skillPath,
|
|
1064
|
+
reason: "already exists"
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
await mkdir(skillDir, { recursive: true });
|
|
1068
|
+
await writeFile3(skillPath, GITPM_SKILL_TEMPLATE, "utf-8");
|
|
1069
|
+
return { status: "created", path: skillPath };
|
|
1070
|
+
}
|
|
1071
|
+
var initCommand = new Command7("init").description("Initialize a new .meta/ project structure").argument("[project-name]", "Name of the project").option(
|
|
1072
|
+
"--no-claude-skill",
|
|
1073
|
+
"Skip writing the Claude Code skill into .claude/skills/gitpm/"
|
|
1074
|
+
).action(
|
|
1075
|
+
async (nameArg, opts, cmd) => {
|
|
1076
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1077
|
+
let projectName = nameArg;
|
|
1078
|
+
if (!projectName) {
|
|
1079
|
+
projectName = await input({
|
|
1080
|
+
message: "Project name:",
|
|
1081
|
+
required: true
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const result = await scaffoldMeta(metaDir, projectName);
|
|
1085
|
+
if (!result.ok) {
|
|
1086
|
+
printError(result.error.message);
|
|
1087
|
+
process.exit(1);
|
|
1088
|
+
}
|
|
1089
|
+
const files = await collectFiles(metaDir);
|
|
1090
|
+
console.log();
|
|
1091
|
+
console.log(chalk5.bold(".meta/"));
|
|
1092
|
+
for (const file of files) {
|
|
1093
|
+
const parts = file.split("/");
|
|
1094
|
+
const indent = " ".repeat(parts.length);
|
|
1095
|
+
const name = parts[parts.length - 1];
|
|
1096
|
+
const isDir = !name.includes(".");
|
|
1097
|
+
console.log(`${indent}${isDir ? chalk5.blue(`${name}/`) : name}`);
|
|
1098
|
+
}
|
|
1099
|
+
const fileCount = files.filter((f) => f.includes(".")).length;
|
|
1100
|
+
console.log();
|
|
1101
|
+
printSuccess(`Created ${fileCount} files in .meta/`);
|
|
1102
|
+
if (opts.claudeSkill !== false) {
|
|
1103
|
+
const skillResult = await scaffoldClaudeSkill(metaDir);
|
|
1104
|
+
const relPath = relative3(process.cwd(), skillResult.path) || skillResult.path;
|
|
1105
|
+
if (skillResult.status === "created") {
|
|
1106
|
+
printSuccess(`Installed Claude Code skill at ${relPath}`);
|
|
1107
|
+
} else {
|
|
1108
|
+
console.log(
|
|
1109
|
+
chalk5.dim(
|
|
1110
|
+
`\u2022 Claude Code skill at ${relPath} ${skillResult.reason} \u2014 skipped`
|
|
1111
|
+
)
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
// src/commands/move.ts
|
|
1119
|
+
import { relative as relative4, resolve as resolve4 } from "path";
|
|
1120
|
+
import { moveStory } from "@gitpm/core";
|
|
1121
|
+
import { Command as Command8 } from "commander";
|
|
1122
|
+
var moveCommand = new Command8("move").description("Move a story between epics or to standalone").argument("<file>", "Path to the story file").option("--to-epic <epic>", "Move to an epic (by ID or directory slug)").option("--to-orphan", "Move out of an epic to .meta/stories/").action(async (file, opts, cmd) => {
|
|
204
1123
|
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
205
|
-
|
|
206
|
-
if (!
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
required: true
|
|
210
|
-
});
|
|
1124
|
+
const filePath = resolve4(process.cwd(), file);
|
|
1125
|
+
if (!opts.toEpic && !opts.toOrphan) {
|
|
1126
|
+
printError("Specify --to-epic or --to-orphan");
|
|
1127
|
+
process.exit(1);
|
|
211
1128
|
}
|
|
212
|
-
const result = await
|
|
1129
|
+
const result = await moveStory(metaDir, filePath, {
|
|
1130
|
+
toEpic: opts.toEpic,
|
|
1131
|
+
toOrphan: opts.toOrphan
|
|
1132
|
+
});
|
|
213
1133
|
if (!result.ok) {
|
|
214
1134
|
printError(result.error.message);
|
|
215
1135
|
process.exit(1);
|
|
216
1136
|
}
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
1137
|
+
const oldRel = relative4(process.cwd(), result.value.oldPath);
|
|
1138
|
+
const newRel = relative4(process.cwd(), result.value.newPath);
|
|
1139
|
+
printSuccess(`Moved ${oldRel} \u2192 ${newRel}`);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// src/commands/next.ts
|
|
1143
|
+
import { relative as relative5 } from "path";
|
|
1144
|
+
import { parseTree as parseTree4 } from "@gitpm/core";
|
|
1145
|
+
import chalk6 from "chalk";
|
|
1146
|
+
import { Command as Command9 } from "commander";
|
|
1147
|
+
var PRIORITY_ORDER = {
|
|
1148
|
+
critical: 0,
|
|
1149
|
+
high: 1,
|
|
1150
|
+
medium: 2,
|
|
1151
|
+
low: 3
|
|
1152
|
+
};
|
|
1153
|
+
var PICKABLE_STATUSES = /* @__PURE__ */ new Set(["backlog", "todo"]);
|
|
1154
|
+
var nextCommand = new Command9("next").description("Show the next stories ready to be picked up").option("-n, --count <number>", "Number of stories to show", "5").option(
|
|
1155
|
+
"-a, --assignee <name>",
|
|
1156
|
+
"Filter by assignee (case-insensitive match)"
|
|
1157
|
+
).action(async (opts, cmd) => {
|
|
1158
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1159
|
+
const count = Number.parseInt(opts.count, 10);
|
|
1160
|
+
const parseResult = await parseTree4(metaDir);
|
|
1161
|
+
if (!parseResult.ok) {
|
|
1162
|
+
printError(parseResult.error.message);
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
}
|
|
1165
|
+
let stories = parseResult.value.stories.filter((s) => PICKABLE_STATUSES.has(s.status)).sort((a, b) => {
|
|
1166
|
+
const pa = PRIORITY_ORDER[a.priority] ?? 99;
|
|
1167
|
+
const pb = PRIORITY_ORDER[b.priority] ?? 99;
|
|
1168
|
+
if (pa !== pb) return pa - pb;
|
|
1169
|
+
if (a.status !== b.status) return a.status === "todo" ? -1 : 1;
|
|
1170
|
+
return 0;
|
|
1171
|
+
});
|
|
1172
|
+
if (opts.assignee) {
|
|
1173
|
+
const needle = opts.assignee.toLowerCase();
|
|
1174
|
+
stories = stories.filter(
|
|
1175
|
+
(s) => s.assignee != null && s.assignee.toLowerCase() === needle
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
stories = stories.slice(0, count);
|
|
1179
|
+
if (stories.length === 0) {
|
|
1180
|
+
console.log(chalk6.yellow("No stories ready to be picked up."));
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
console.log(chalk6.bold(`Next ${stories.length} stories to pick up:
|
|
1184
|
+
`));
|
|
1185
|
+
for (const story of stories) {
|
|
1186
|
+
const file = relative5(process.cwd(), story.filePath);
|
|
1187
|
+
const pri = formatPriority(story.priority);
|
|
1188
|
+
const status = chalk6.dim(`[${story.status}]`);
|
|
1189
|
+
const assignee = story.assignee ? chalk6.cyan(`@${story.assignee}`) : chalk6.dim("unassigned");
|
|
1190
|
+
console.log(` ${pri} ${status} ${story.title} ${assignee}`);
|
|
1191
|
+
console.log(` ${chalk6.dim(file)}
|
|
1192
|
+
`);
|
|
1193
|
+
}
|
|
230
1194
|
});
|
|
1195
|
+
function formatPriority(p) {
|
|
1196
|
+
switch (p) {
|
|
1197
|
+
case "critical":
|
|
1198
|
+
return chalk6.red("\u25CF");
|
|
1199
|
+
case "high":
|
|
1200
|
+
return chalk6.yellow("\u25CF");
|
|
1201
|
+
case "medium":
|
|
1202
|
+
return chalk6.blue("\u25CF");
|
|
1203
|
+
case "low":
|
|
1204
|
+
return chalk6.dim("\u25CF");
|
|
1205
|
+
default:
|
|
1206
|
+
return chalk6.dim("\u25CB");
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
231
1209
|
|
|
232
1210
|
// src/commands/pull.ts
|
|
233
|
-
import {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
} from "@gitpm/sync-github";
|
|
237
|
-
import {
|
|
238
|
-
loadConfig as loadGitLabConfig,
|
|
239
|
-
syncWithGitLab
|
|
240
|
-
} from "@gitpm/sync-gitlab";
|
|
241
|
-
import chalk5 from "chalk";
|
|
242
|
-
import { Command as Command3 } from "commander";
|
|
1211
|
+
import { runHooks as runHooks2 } from "@gitpm/core";
|
|
1212
|
+
import chalk8 from "chalk";
|
|
1213
|
+
import { Command as Command10 } from "commander";
|
|
243
1214
|
import ora2 from "ora";
|
|
244
1215
|
|
|
1216
|
+
// src/utils/adapters.ts
|
|
1217
|
+
import { dirname as dirname3 } from "path";
|
|
1218
|
+
import {
|
|
1219
|
+
detectAdapter,
|
|
1220
|
+
findAdapterByName as findAdapterByName2,
|
|
1221
|
+
loadAdapters as loadAdapters2,
|
|
1222
|
+
loadGitpmConfig as loadGitpmConfig2
|
|
1223
|
+
} from "@gitpm/core";
|
|
1224
|
+
async function resolveAdapter(metaDir, adapterName) {
|
|
1225
|
+
const rootDir = dirname3(metaDir);
|
|
1226
|
+
const configResult = await loadGitpmConfig2(rootDir);
|
|
1227
|
+
if (!configResult.ok) {
|
|
1228
|
+
printError(`Failed to load config: ${configResult.error.message}`);
|
|
1229
|
+
process.exit(1);
|
|
1230
|
+
}
|
|
1231
|
+
const config = configResult.value;
|
|
1232
|
+
const adaptersResult = await loadAdapters2(config, rootDir);
|
|
1233
|
+
if (!adaptersResult.ok) {
|
|
1234
|
+
printError(adaptersResult.error.message);
|
|
1235
|
+
printError(
|
|
1236
|
+
"No sync adapters installed. Install at least one adapter package, e.g.:\n npm install -g @gitpm/sync-github\n npm install -g @gitpm/sync-gitlab\n npm install -g @gitpm/sync-jira\n npm install -g @gitpm/sync-linear\n npm install -g @gitpm/sync-bitbucket"
|
|
1237
|
+
);
|
|
1238
|
+
process.exit(1);
|
|
1239
|
+
}
|
|
1240
|
+
const adapters = adaptersResult.value;
|
|
1241
|
+
if (adapters.length === 0) {
|
|
1242
|
+
printError(
|
|
1243
|
+
"No sync adapters installed. Install at least one adapter package, e.g.:\n npm install -g @gitpm/sync-github"
|
|
1244
|
+
);
|
|
1245
|
+
process.exit(1);
|
|
1246
|
+
}
|
|
1247
|
+
let adapter = null;
|
|
1248
|
+
if (adapterName) {
|
|
1249
|
+
adapter = findAdapterByName2(adapters, adapterName);
|
|
1250
|
+
if (!adapter) {
|
|
1251
|
+
printError(
|
|
1252
|
+
`Adapter "${adapterName}" not found. Available: ${adapters.map((a) => a.name).join(", ")}`
|
|
1253
|
+
);
|
|
1254
|
+
process.exit(1);
|
|
1255
|
+
}
|
|
1256
|
+
} else {
|
|
1257
|
+
adapter = await detectAdapter(adapters, metaDir);
|
|
1258
|
+
if (!adapter) {
|
|
1259
|
+
printError(
|
|
1260
|
+
"No sync config found. Run `gitpm import` first to set up sync."
|
|
1261
|
+
);
|
|
1262
|
+
process.exit(1);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return { adapter, config };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
245
1268
|
// src/utils/conflict-ui.ts
|
|
246
1269
|
import { select } from "@inquirer/prompts";
|
|
247
|
-
import
|
|
1270
|
+
import chalk7 from "chalk";
|
|
248
1271
|
async function promptConflictResolution(conflicts) {
|
|
249
1272
|
const resolutions = [];
|
|
250
1273
|
for (const conflict of conflicts) {
|
|
251
1274
|
console.log();
|
|
252
|
-
console.log(
|
|
1275
|
+
console.log(chalk7.yellow("\u2501\u2501\u2501 CONFLICT \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"));
|
|
253
1276
|
console.log(
|
|
254
|
-
`Entity: ${
|
|
1277
|
+
`Entity: ${chalk7.bold(`"${conflict.entityTitle}"`)} (${conflict.entityId})`
|
|
255
1278
|
);
|
|
256
1279
|
console.log(`Type: ${conflict.entityType}`);
|
|
257
1280
|
console.log(`Field: ${conflict.field}`);
|
|
258
1281
|
console.log();
|
|
259
|
-
console.log(` Local: ${
|
|
260
|
-
console.log(` Remote: ${
|
|
261
|
-
console.log(
|
|
1282
|
+
console.log(` Local: ${chalk7.green(String(conflict.localValue))}`);
|
|
1283
|
+
console.log(` Remote: ${chalk7.blue(String(conflict.remoteValue))}`);
|
|
1284
|
+
console.log(chalk7.yellow("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"));
|
|
262
1285
|
const pick = await select({
|
|
263
1286
|
message: "How do you want to resolve this conflict?",
|
|
264
1287
|
choices: [
|
|
@@ -279,40 +1302,37 @@ async function promptConflictResolution(conflicts) {
|
|
|
279
1302
|
}
|
|
280
1303
|
|
|
281
1304
|
// src/commands/pull.ts
|
|
282
|
-
var
|
|
1305
|
+
var VALID_STRATEGIES = ["local-wins", "remote-wins", "ask"];
|
|
1306
|
+
var pullCommand = new Command10("pull").description("Pull changes from remote platform into local .meta/").option("--token <token>", "Personal access token").option(
|
|
283
1307
|
"--strategy <strategy>",
|
|
284
1308
|
"Conflict resolution strategy (local-wins, remote-wins, ask)",
|
|
285
1309
|
"remote-wins"
|
|
286
|
-
).action(async (opts, cmd) => {
|
|
1310
|
+
).option("--adapter <name>", "Force a specific adapter (e.g. github, gitlab)").action(async (opts, cmd) => {
|
|
287
1311
|
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
288
|
-
|
|
289
|
-
const ghConfig = await loadGitHubConfig(metaDir);
|
|
290
|
-
const glConfig = await loadGitLabConfig(metaDir);
|
|
291
|
-
if (glConfig.ok) {
|
|
292
|
-
await pullFromGitLab(opts, metaDir, glConfig.value, strategy);
|
|
293
|
-
} else if (ghConfig.ok) {
|
|
294
|
-
await pullFromGitHub(opts, metaDir, ghConfig.value.repo, strategy);
|
|
295
|
-
} else {
|
|
1312
|
+
if (!VALID_STRATEGIES.includes(opts.strategy)) {
|
|
296
1313
|
printError(
|
|
297
|
-
|
|
1314
|
+
`Invalid strategy "${opts.strategy}". Must be one of: ${VALID_STRATEGIES.join(", ")}`
|
|
298
1315
|
);
|
|
299
1316
|
process.exit(1);
|
|
300
1317
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
1318
|
+
const strategy = opts.strategy;
|
|
1319
|
+
const { adapter, config } = await resolveAdapter(metaDir, opts.adapter);
|
|
1320
|
+
const { token, email } = resolveCredentials(adapter.name, opts.token);
|
|
1321
|
+
const preHook = await runHooks2(config, "pre-sync", {
|
|
1322
|
+
metaDir,
|
|
1323
|
+
event: "pre-sync",
|
|
1324
|
+
adapterName: adapter.name
|
|
1325
|
+
});
|
|
1326
|
+
if (!preHook.ok) {
|
|
1327
|
+
printError(`Pre-sync hook failed: ${preHook.error.message}`);
|
|
308
1328
|
process.exit(1);
|
|
309
1329
|
}
|
|
310
|
-
const spinner = ora2(
|
|
311
|
-
const result = await
|
|
1330
|
+
const spinner = ora2(`Pulling from ${adapter.displayName}...`).start();
|
|
1331
|
+
const result = await adapter.sync({
|
|
312
1332
|
token,
|
|
313
|
-
|
|
1333
|
+
email,
|
|
314
1334
|
metaDir,
|
|
315
|
-
strategy
|
|
1335
|
+
strategy
|
|
316
1336
|
});
|
|
317
1337
|
if (!result.ok) {
|
|
318
1338
|
spinner.fail("Pull failed.");
|
|
@@ -321,281 +1341,962 @@ async function pullFromGitHub(opts, metaDir, repo, strategy) {
|
|
|
321
1341
|
}
|
|
322
1342
|
spinner.succeed("Pull complete.");
|
|
323
1343
|
await handleConflictsAndPrint(result.value, strategy);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
1344
|
+
await runHooks2(config, "post-sync", {
|
|
1345
|
+
metaDir,
|
|
1346
|
+
event: "post-sync",
|
|
1347
|
+
adapterName: adapter.name,
|
|
1348
|
+
result: result.value
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
async function handleConflictsAndPrint(result, strategy) {
|
|
1352
|
+
const { pulled, conflicts, resolved, skipped } = result;
|
|
1353
|
+
if (strategy === "ask" && conflicts.length > 0) {
|
|
1354
|
+
console.log();
|
|
1355
|
+
console.log(
|
|
1356
|
+
chalk8.yellow(`${conflicts.length} conflict(s) require resolution:`)
|
|
1357
|
+
);
|
|
1358
|
+
const resolutions = await promptConflictResolution(
|
|
1359
|
+
conflicts
|
|
1360
|
+
);
|
|
1361
|
+
console.log(
|
|
1362
|
+
chalk8.dim(
|
|
1363
|
+
`Resolved ${resolutions.length}, skipped ${conflicts.length - resolutions.length}`
|
|
1364
|
+
)
|
|
330
1365
|
);
|
|
1366
|
+
}
|
|
1367
|
+
console.log();
|
|
1368
|
+
console.log(chalk8.bold("Pull Summary:"));
|
|
1369
|
+
console.log(` Pulled: ${pulled.milestones + pulled.issues} changes`);
|
|
1370
|
+
console.log(` Conflicts resolved: ${resolved}`);
|
|
1371
|
+
console.log(` Skipped: ${skipped}`);
|
|
1372
|
+
console.log();
|
|
1373
|
+
printSuccess("Pull complete.");
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/commands/push.ts
|
|
1377
|
+
import { runHooks as runHooks3 } from "@gitpm/core";
|
|
1378
|
+
import { confirm } from "@inquirer/prompts";
|
|
1379
|
+
import chalk9 from "chalk";
|
|
1380
|
+
import { Command as Command11 } from "commander";
|
|
1381
|
+
import ora3 from "ora";
|
|
1382
|
+
var pushCommand = new Command11("push").description("Push local .meta/ changes to remote platform").option("--token <token>", "Personal access token").option("--team <id>", "Linear team ID").option(
|
|
1383
|
+
"--api-key <key>",
|
|
1384
|
+
"Linear API key (default: LINEAR_API_KEY environment variable)"
|
|
1385
|
+
).option("--dry-run", "Preview changes without pushing").option("--yes", "Skip confirmation prompt").option("--adapter <name>", "Force a specific adapter (e.g. github, gitlab)").action(async (opts, cmd) => {
|
|
1386
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1387
|
+
const { adapter, config } = await resolveAdapter(metaDir, opts.adapter);
|
|
1388
|
+
const { token, email } = resolveCredentials(adapter.name, opts.token);
|
|
1389
|
+
if (opts.dryRun) {
|
|
1390
|
+
const spinner = ora3("Calculating changes...").start();
|
|
1391
|
+
const result2 = await adapter.export({
|
|
1392
|
+
token,
|
|
1393
|
+
email,
|
|
1394
|
+
metaDir,
|
|
1395
|
+
teamId: opts.team,
|
|
1396
|
+
apiKey: opts.apiKey,
|
|
1397
|
+
dryRun: true
|
|
1398
|
+
});
|
|
1399
|
+
if (!result2.ok) {
|
|
1400
|
+
spinner.fail("Dry run failed.");
|
|
1401
|
+
printError(result2.error.message);
|
|
1402
|
+
process.exit(1);
|
|
1403
|
+
}
|
|
1404
|
+
spinner.succeed("Dry run complete.");
|
|
1405
|
+
printPreview(result2.value);
|
|
1406
|
+
if (result2.value.failures?.length) {
|
|
1407
|
+
printFailures(result2.value.failures, "Would fail:");
|
|
1408
|
+
process.exit(1);
|
|
1409
|
+
}
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const previewSpinner = ora3("Calculating changes...").start();
|
|
1413
|
+
const preview = await adapter.export({
|
|
1414
|
+
token,
|
|
1415
|
+
email,
|
|
1416
|
+
metaDir,
|
|
1417
|
+
teamId: opts.team,
|
|
1418
|
+
apiKey: opts.apiKey,
|
|
1419
|
+
dryRun: true
|
|
1420
|
+
});
|
|
1421
|
+
if (!preview.ok) {
|
|
1422
|
+
previewSpinner.fail("Failed to calculate changes.");
|
|
1423
|
+
printError(preview.error.message);
|
|
331
1424
|
process.exit(1);
|
|
332
1425
|
}
|
|
333
|
-
|
|
334
|
-
|
|
1426
|
+
previewSpinner.succeed("Changes calculated.");
|
|
1427
|
+
printPreview(preview.value);
|
|
1428
|
+
printFailures(preview.value.failures ?? [], "Would fail:");
|
|
1429
|
+
if (preview.value.totalChanges === 0) {
|
|
1430
|
+
printSuccess("Nothing to push \u2014 already in sync.");
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (!opts.yes) {
|
|
1434
|
+
const confirmed = await confirm({
|
|
1435
|
+
message: `Push these changes to ${adapter.displayName}?`,
|
|
1436
|
+
default: false
|
|
1437
|
+
});
|
|
1438
|
+
if (!confirmed) {
|
|
1439
|
+
console.log(chalk9.dim("Push cancelled."));
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
const preHook = await runHooks3(config, "pre-export", {
|
|
1444
|
+
metaDir,
|
|
1445
|
+
event: "pre-export",
|
|
1446
|
+
adapterName: adapter.name
|
|
1447
|
+
});
|
|
1448
|
+
if (!preHook.ok) {
|
|
1449
|
+
printError(`Pre-export hook failed: ${preHook.error.message}`);
|
|
1450
|
+
process.exit(1);
|
|
1451
|
+
}
|
|
1452
|
+
const pushSpinner = ora3(`Pushing to ${adapter.displayName}...`).start();
|
|
1453
|
+
const result = await adapter.export({
|
|
335
1454
|
token,
|
|
336
|
-
|
|
337
|
-
projectId: config.project_id,
|
|
338
|
-
baseUrl: config.base_url,
|
|
1455
|
+
email,
|
|
339
1456
|
metaDir,
|
|
340
|
-
|
|
1457
|
+
teamId: opts.team,
|
|
1458
|
+
apiKey: opts.apiKey,
|
|
1459
|
+
dryRun: false
|
|
1460
|
+
});
|
|
1461
|
+
if (!result.ok) {
|
|
1462
|
+
pushSpinner.fail("Push failed.");
|
|
1463
|
+
printError(result.error.message);
|
|
1464
|
+
process.exit(1);
|
|
1465
|
+
}
|
|
1466
|
+
const { created, updated, failures = [] } = result.value;
|
|
1467
|
+
if (failures.length > 0) {
|
|
1468
|
+
pushSpinner.warn(
|
|
1469
|
+
`Push finished with ${failures.length} failed ${failures.length === 1 ? "entity" : "entities"}.`
|
|
1470
|
+
);
|
|
1471
|
+
} else {
|
|
1472
|
+
pushSpinner.succeed("Push complete.");
|
|
1473
|
+
}
|
|
1474
|
+
console.log(
|
|
1475
|
+
` Created ${created.milestones + created.issues}, Updated ${updated.milestones + updated.issues}`
|
|
1476
|
+
);
|
|
1477
|
+
printFailures(failures);
|
|
1478
|
+
await runHooks3(config, "post-export", {
|
|
1479
|
+
metaDir,
|
|
1480
|
+
event: "post-export",
|
|
1481
|
+
adapterName: adapter.name,
|
|
1482
|
+
result: result.value
|
|
1483
|
+
});
|
|
1484
|
+
if (failures.length > 0) {
|
|
1485
|
+
process.exit(1);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
function printFailures(failures, heading = "Failed:") {
|
|
1489
|
+
if (failures.length === 0) return;
|
|
1490
|
+
console.log();
|
|
1491
|
+
console.log(chalk9.red.bold(heading));
|
|
1492
|
+
for (const failure of failures) {
|
|
1493
|
+
console.log(
|
|
1494
|
+
` ${chalk9.red("\u2717")} ${failure.title} ${chalk9.dim(`(${failure.entityId})`)}: ${failure.message}`
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
console.log();
|
|
1498
|
+
}
|
|
1499
|
+
function printPreview(result) {
|
|
1500
|
+
console.log();
|
|
1501
|
+
console.log(chalk9.bold("Changes to push:"));
|
|
1502
|
+
console.log(` New milestones: ${result.created.milestones}`);
|
|
1503
|
+
console.log(` New issues: ${result.created.issues}`);
|
|
1504
|
+
console.log(` Updated milestones: ${result.updated.milestones}`);
|
|
1505
|
+
console.log(` Updated issues: ${result.updated.issues}`);
|
|
1506
|
+
console.log(` Total changes: ${result.totalChanges}`);
|
|
1507
|
+
console.log();
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// src/commands/quality.ts
|
|
1511
|
+
import { relative as relative6 } from "path";
|
|
1512
|
+
import {
|
|
1513
|
+
loadQualityConfig,
|
|
1514
|
+
parseTree as parseTree5,
|
|
1515
|
+
resolveRefs as resolveRefs3,
|
|
1516
|
+
scoreEntities
|
|
1517
|
+
} from "@gitpm/core";
|
|
1518
|
+
import chalk10 from "chalk";
|
|
1519
|
+
import { Command as Command12 } from "commander";
|
|
1520
|
+
function gradeColor(grade) {
|
|
1521
|
+
switch (grade) {
|
|
1522
|
+
case "A":
|
|
1523
|
+
return chalk10.green;
|
|
1524
|
+
case "B":
|
|
1525
|
+
return chalk10.cyan;
|
|
1526
|
+
case "C":
|
|
1527
|
+
return chalk10.yellow;
|
|
1528
|
+
case "D":
|
|
1529
|
+
return chalk10.magenta;
|
|
1530
|
+
default:
|
|
1531
|
+
return chalk10.red;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
function gradeForAverage(avg) {
|
|
1535
|
+
if (avg >= 8) return "A";
|
|
1536
|
+
if (avg >= 6) return "B";
|
|
1537
|
+
if (avg >= 4) return "C";
|
|
1538
|
+
if (avg >= 2) return "D";
|
|
1539
|
+
return "F";
|
|
1540
|
+
}
|
|
1541
|
+
function printReport2(report) {
|
|
1542
|
+
console.log();
|
|
1543
|
+
console.log(chalk10.bold(" Entity Quality Report"));
|
|
1544
|
+
console.log(chalk10.dim(` ${"\u2500".repeat(21)}`));
|
|
1545
|
+
const grades = [
|
|
1546
|
+
{ grade: "A", range: "8-9", count: report.distribution.A },
|
|
1547
|
+
{ grade: "B", range: "6-7", count: report.distribution.B },
|
|
1548
|
+
{ grade: "C", range: "4-5", count: report.distribution.C },
|
|
1549
|
+
{ grade: "D", range: "2-3", count: report.distribution.D },
|
|
1550
|
+
{ grade: "F", range: "0-1", count: report.distribution.F }
|
|
1551
|
+
];
|
|
1552
|
+
for (const { grade, range, count } of grades) {
|
|
1553
|
+
const label = `${grade} (${range}):`;
|
|
1554
|
+
const unit = count === 1 ? "entity" : "entities";
|
|
1555
|
+
console.log(` ${gradeColor(grade)(label.padEnd(10))} ${count} ${unit}`);
|
|
1556
|
+
}
|
|
1557
|
+
const avgGrade = gradeForAverage(report.average);
|
|
1558
|
+
console.log();
|
|
1559
|
+
console.log(
|
|
1560
|
+
` Average: ${report.average.toFixed(1)} (${gradeColor(avgGrade)(avgGrade)})`
|
|
1561
|
+
);
|
|
1562
|
+
const lowest = report.entities.filter((e) => e.score < 6).sort((a, b) => a.score - b.score).slice(0, 10);
|
|
1563
|
+
if (lowest.length > 0) {
|
|
1564
|
+
console.log();
|
|
1565
|
+
console.log(chalk10.bold(" Lowest scoring:"));
|
|
1566
|
+
for (const e of lowest) {
|
|
1567
|
+
const rel = relative6(process.cwd(), e.filePath);
|
|
1568
|
+
const color = gradeColor(e.grade);
|
|
1569
|
+
console.log(
|
|
1570
|
+
` ${color(`[${e.grade}]`)} ${e.id} (${e.score}/${e.maxScore}) ${chalk10.dim("\u2014")} ${chalk10.dim(rel)}`
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
console.log();
|
|
1575
|
+
}
|
|
1576
|
+
var qualityCommand = new Command12("quality").description("Score entity quality across the .meta/ tree").option(
|
|
1577
|
+
"--threshold <number>",
|
|
1578
|
+
"Minimum average score (overrides config)",
|
|
1579
|
+
Number.parseFloat
|
|
1580
|
+
).action(async (opts, cmd) => {
|
|
1581
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1582
|
+
const parseResult = await parseTree5(metaDir);
|
|
1583
|
+
if (!parseResult.ok) {
|
|
1584
|
+
printError(parseResult.error.message);
|
|
1585
|
+
process.exit(1);
|
|
1586
|
+
}
|
|
1587
|
+
const resolveResult = resolveRefs3(parseResult.value);
|
|
1588
|
+
if (!resolveResult.ok) {
|
|
1589
|
+
printError(resolveResult.error.message);
|
|
1590
|
+
process.exit(1);
|
|
1591
|
+
}
|
|
1592
|
+
const configResult = await loadQualityConfig(metaDir);
|
|
1593
|
+
if (!configResult.ok) {
|
|
1594
|
+
printError(configResult.error.message);
|
|
1595
|
+
process.exit(1);
|
|
1596
|
+
}
|
|
1597
|
+
const config = configResult.value;
|
|
1598
|
+
const report = scoreEntities(resolveResult.value, config);
|
|
1599
|
+
if (report.entities.length === 0) {
|
|
1600
|
+
console.log(chalk10.dim(" No scorable entities found in .meta/"));
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
printReport2(report);
|
|
1604
|
+
const threshold = opts.threshold ?? config?.threshold?.min_average;
|
|
1605
|
+
if (threshold != null && report.average < threshold) {
|
|
1606
|
+
printError(
|
|
1607
|
+
`Average score ${report.average.toFixed(1)} is below threshold ${threshold}`
|
|
1608
|
+
);
|
|
1609
|
+
process.exit(1);
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
// src/commands/query.ts
|
|
1614
|
+
import { filterEntities, formatEntities, parseTree as parseTree6 } from "@gitpm/core";
|
|
1615
|
+
import { Command as Command13 } from "commander";
|
|
1616
|
+
var DEFAULT_FIELDS = [
|
|
1617
|
+
"id",
|
|
1618
|
+
"title",
|
|
1619
|
+
"type",
|
|
1620
|
+
"status",
|
|
1621
|
+
"priority",
|
|
1622
|
+
"filePath"
|
|
1623
|
+
];
|
|
1624
|
+
var queryCommand = new Command13("query").description("Query and filter .meta/ entities").option(
|
|
1625
|
+
"--type <types>",
|
|
1626
|
+
"Entity types (comma-separated: story,epic,milestone,prd)"
|
|
1627
|
+
).option("--status <statuses>", "Filter by status (comma-separated)").option("--priority <priorities>", "Filter by priority (comma-separated)").option("--label <labels>", "Filter by labels (comma-separated, match any)").option("--epic <epic>", "Filter stories by epic ID or directory slug").option("--assignee <assignee>", "Filter by assignee").option("--search <text>", "Text search in title and body").option(
|
|
1628
|
+
"--fields <fields>",
|
|
1629
|
+
"Fields to display (comma-separated)",
|
|
1630
|
+
DEFAULT_FIELDS.join(",")
|
|
1631
|
+
).option("--format <format>", "Output format: table, json, csv", "table").action(async (opts, cmd) => {
|
|
1632
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1633
|
+
const parseResult = await parseTree6(metaDir);
|
|
1634
|
+
if (!parseResult.ok) {
|
|
1635
|
+
printError(parseResult.error.message);
|
|
1636
|
+
process.exit(1);
|
|
1637
|
+
}
|
|
1638
|
+
const filter = {};
|
|
1639
|
+
if (opts.type) {
|
|
1640
|
+
filter.type = opts.type.split(",").map((s) => s.trim());
|
|
1641
|
+
}
|
|
1642
|
+
if (opts.status) {
|
|
1643
|
+
filter.status = opts.status.split(",").map((s) => s.trim());
|
|
1644
|
+
}
|
|
1645
|
+
if (opts.priority) {
|
|
1646
|
+
filter.priority = opts.priority.split(",").map((s) => s.trim());
|
|
1647
|
+
}
|
|
1648
|
+
if (opts.label) {
|
|
1649
|
+
filter.labels = opts.label.split(",").map((s) => s.trim());
|
|
1650
|
+
}
|
|
1651
|
+
if (opts.epic) {
|
|
1652
|
+
filter.epic = opts.epic;
|
|
1653
|
+
}
|
|
1654
|
+
if (opts.assignee) {
|
|
1655
|
+
filter.assignee = opts.assignee;
|
|
1656
|
+
}
|
|
1657
|
+
if (opts.search) {
|
|
1658
|
+
filter.search = opts.search;
|
|
1659
|
+
}
|
|
1660
|
+
const entities = filterEntities(parseResult.value, filter);
|
|
1661
|
+
const fields = opts.fields.split(",").map((s) => s.trim());
|
|
1662
|
+
const output = formatEntities(entities, { fields, format: opts.format });
|
|
1663
|
+
console.log(output);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
// src/commands/set.ts
|
|
1667
|
+
import { resolve as resolve5 } from "path";
|
|
1668
|
+
import {
|
|
1669
|
+
applyAssignments,
|
|
1670
|
+
parseAssignment,
|
|
1671
|
+
parseFile,
|
|
1672
|
+
writeFile as writeFile4
|
|
1673
|
+
} from "@gitpm/core";
|
|
1674
|
+
import { Command as Command14 } from "commander";
|
|
1675
|
+
var setCommand = new Command14("set").description("Update frontmatter fields on a .meta/ entity").argument("<file>", "Path to the entity file").argument(
|
|
1676
|
+
"<assignments...>",
|
|
1677
|
+
"Field assignments (field=value, field+=value, field-=value)"
|
|
1678
|
+
).action(async (file, assignments) => {
|
|
1679
|
+
const filePath = resolve5(process.cwd(), file);
|
|
1680
|
+
const parseResult = await parseFile(filePath);
|
|
1681
|
+
if (!parseResult.ok) {
|
|
1682
|
+
printError(parseResult.error.message);
|
|
1683
|
+
process.exit(1);
|
|
1684
|
+
}
|
|
1685
|
+
const parsed = [];
|
|
1686
|
+
for (const expr of assignments) {
|
|
1687
|
+
const result = parseAssignment(expr);
|
|
1688
|
+
if (!result.ok) {
|
|
1689
|
+
printError(result.error.message);
|
|
1690
|
+
process.exit(1);
|
|
1691
|
+
}
|
|
1692
|
+
parsed.push(result.value);
|
|
1693
|
+
}
|
|
1694
|
+
const applyResult = applyAssignments(parseResult.value, parsed);
|
|
1695
|
+
if (!applyResult.ok) {
|
|
1696
|
+
printError(applyResult.error.message);
|
|
1697
|
+
process.exit(1);
|
|
1698
|
+
}
|
|
1699
|
+
const writeResult = await writeFile4(applyResult.value, filePath);
|
|
1700
|
+
if (!writeResult.ok) {
|
|
1701
|
+
printError(writeResult.error.message);
|
|
1702
|
+
process.exit(1);
|
|
1703
|
+
}
|
|
1704
|
+
printSuccess(`Updated ${file}`);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
// src/commands/show.ts
|
|
1708
|
+
import { relative as relative7, resolve as resolve6 } from "path";
|
|
1709
|
+
import { parseTree as parseTree7, resolveRefs as resolveRefs4 } from "@gitpm/core";
|
|
1710
|
+
import chalk11 from "chalk";
|
|
1711
|
+
import { Command as Command15 } from "commander";
|
|
1712
|
+
function formatFieldValue(key, value) {
|
|
1713
|
+
if (value === null || value === void 0) return chalk11.dim("\u2014");
|
|
1714
|
+
if (Array.isArray(value)) {
|
|
1715
|
+
return value.length > 0 ? value.join(", ") : chalk11.dim("[]");
|
|
1716
|
+
}
|
|
1717
|
+
if (typeof value === "object") {
|
|
1718
|
+
return JSON.stringify(value);
|
|
1719
|
+
}
|
|
1720
|
+
if (key === "priority") {
|
|
1721
|
+
const p = String(value);
|
|
1722
|
+
switch (p) {
|
|
1723
|
+
case "critical":
|
|
1724
|
+
return chalk11.red(p);
|
|
1725
|
+
case "high":
|
|
1726
|
+
return chalk11.yellow(p);
|
|
1727
|
+
case "medium":
|
|
1728
|
+
return chalk11.blue(p);
|
|
1729
|
+
default:
|
|
1730
|
+
return chalk11.dim(p);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (key === "status") {
|
|
1734
|
+
const s = String(value);
|
|
1735
|
+
switch (s) {
|
|
1736
|
+
case "done":
|
|
1737
|
+
return chalk11.green(s);
|
|
1738
|
+
case "in_progress":
|
|
1739
|
+
case "in_review":
|
|
1740
|
+
return chalk11.cyan(s);
|
|
1741
|
+
case "cancelled":
|
|
1742
|
+
return chalk11.strikethrough(chalk11.dim(s));
|
|
1743
|
+
default:
|
|
1744
|
+
return s;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return String(value);
|
|
1748
|
+
}
|
|
1749
|
+
var RESOLVED_KEYS = /* @__PURE__ */ new Set([
|
|
1750
|
+
"resolvedStories",
|
|
1751
|
+
"resolvedEpic",
|
|
1752
|
+
"resolvedEpics",
|
|
1753
|
+
"resolvedMilestone",
|
|
1754
|
+
"resolvedMilestones",
|
|
1755
|
+
"resolvedPrds"
|
|
1756
|
+
]);
|
|
1757
|
+
function displayEntity(entity, full) {
|
|
1758
|
+
const { filePath, body, ...frontmatter } = entity;
|
|
1759
|
+
console.log(chalk11.bold(entity.title));
|
|
1760
|
+
console.log(chalk11.dim(relative7(process.cwd(), entity.filePath)));
|
|
1761
|
+
console.log();
|
|
1762
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
1763
|
+
if (key === "title" || RESOLVED_KEYS.has(key)) continue;
|
|
1764
|
+
console.log(` ${chalk11.dim(`${key}:`)} ${formatFieldValue(key, value)}`);
|
|
1765
|
+
}
|
|
1766
|
+
if (full && typeof body === "string" && body.trim()) {
|
|
1767
|
+
console.log();
|
|
1768
|
+
console.log(body);
|
|
1769
|
+
}
|
|
1770
|
+
console.log();
|
|
1771
|
+
}
|
|
1772
|
+
function displayStoryRow(story) {
|
|
1773
|
+
const pri = story.priority;
|
|
1774
|
+
let priBullet;
|
|
1775
|
+
switch (pri) {
|
|
1776
|
+
case "critical":
|
|
1777
|
+
priBullet = chalk11.red("\u25CF");
|
|
1778
|
+
break;
|
|
1779
|
+
case "high":
|
|
1780
|
+
priBullet = chalk11.yellow("\u25CF");
|
|
1781
|
+
break;
|
|
1782
|
+
case "medium":
|
|
1783
|
+
priBullet = chalk11.blue("\u25CF");
|
|
1784
|
+
break;
|
|
1785
|
+
default:
|
|
1786
|
+
priBullet = chalk11.dim("\u25CF");
|
|
1787
|
+
}
|
|
1788
|
+
const status = chalk11.dim(`[${story.status}]`);
|
|
1789
|
+
const id = chalk11.dim(`(${story.id})`);
|
|
1790
|
+
console.log(` ${priBullet} ${status} ${story.title} ${id}`);
|
|
1791
|
+
}
|
|
1792
|
+
var showCommand = new Command15("show").description("Display .meta/ entities with structured output").argument("[target]", "Entity file path or ID").option("--epic <epic>", "Show an epic and all its stories (by ID or slug)").option(
|
|
1793
|
+
"--milestone <milestone>",
|
|
1794
|
+
"Show a milestone and linked epics (by ID)"
|
|
1795
|
+
).option("--full", "Include body content", false).option("--format <format>", "Output format: pretty, json", "pretty").action(async (target, opts, cmd) => {
|
|
1796
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1797
|
+
const parseResult = await parseTree7(metaDir);
|
|
1798
|
+
if (!parseResult.ok) {
|
|
1799
|
+
printError(parseResult.error.message);
|
|
1800
|
+
process.exit(1);
|
|
1801
|
+
}
|
|
1802
|
+
const tree = parseResult.value;
|
|
1803
|
+
if (!opts.epic && !opts.milestone) {
|
|
1804
|
+
if (!target) {
|
|
1805
|
+
printError("Specify a target, --epic, or --milestone");
|
|
1806
|
+
process.exit(1);
|
|
1807
|
+
}
|
|
1808
|
+
const entity = findEntity(tree, target);
|
|
1809
|
+
if (!entity) {
|
|
1810
|
+
printError(`Entity not found: ${target}`);
|
|
1811
|
+
process.exit(1);
|
|
1812
|
+
}
|
|
1813
|
+
if (opts.format === "json") {
|
|
1814
|
+
console.log(JSON.stringify(entity, null, 2));
|
|
1815
|
+
} else {
|
|
1816
|
+
displayEntity(entity, opts.full);
|
|
1817
|
+
}
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
const resolveResult = resolveRefs4(tree);
|
|
1821
|
+
if (!resolveResult.ok) {
|
|
1822
|
+
printError(resolveResult.error.message);
|
|
1823
|
+
process.exit(1);
|
|
1824
|
+
}
|
|
1825
|
+
const resolved = resolveResult.value;
|
|
1826
|
+
if (opts.epic) {
|
|
1827
|
+
const epic = findEpic(resolved, opts.epic);
|
|
1828
|
+
if (!epic) {
|
|
1829
|
+
printError(`Epic not found: ${opts.epic}`);
|
|
1830
|
+
process.exit(1);
|
|
1831
|
+
}
|
|
1832
|
+
if (opts.format === "json") {
|
|
1833
|
+
console.log(
|
|
1834
|
+
JSON.stringify({ epic, stories: epic.resolvedStories }, null, 2)
|
|
1835
|
+
);
|
|
1836
|
+
} else {
|
|
1837
|
+
displayEntity(epic, opts.full);
|
|
1838
|
+
if (epic.resolvedStories.length > 0) {
|
|
1839
|
+
console.log(chalk11.bold(`Stories (${epic.resolvedStories.length}):`));
|
|
1840
|
+
for (const story of epic.resolvedStories) {
|
|
1841
|
+
displayStoryRow(story);
|
|
1842
|
+
}
|
|
1843
|
+
console.log();
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (opts.milestone) {
|
|
1849
|
+
const ms = findMilestone(resolved, opts.milestone);
|
|
1850
|
+
if (!ms) {
|
|
1851
|
+
printError(`Milestone not found: ${opts.milestone}`);
|
|
1852
|
+
process.exit(1);
|
|
1853
|
+
}
|
|
1854
|
+
if (opts.format === "json") {
|
|
1855
|
+
console.log(
|
|
1856
|
+
JSON.stringify({ milestone: ms, epics: ms.resolvedEpics }, null, 2)
|
|
1857
|
+
);
|
|
1858
|
+
} else {
|
|
1859
|
+
displayEntity(ms, opts.full);
|
|
1860
|
+
if (ms.resolvedEpics.length > 0) {
|
|
1861
|
+
console.log(chalk11.bold(`Epics (${ms.resolvedEpics.length}):`));
|
|
1862
|
+
for (const epic of ms.resolvedEpics) {
|
|
1863
|
+
displayStoryRow(epic);
|
|
1864
|
+
}
|
|
1865
|
+
console.log();
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
function findEpic(resolved, epicRef) {
|
|
1872
|
+
return resolved.epics.find(
|
|
1873
|
+
(e) => e.id === epicRef || e.filePath.includes(`/epics/${epicRef}/`)
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
function findMilestone(resolved, milestoneRef) {
|
|
1877
|
+
return resolved.milestones.find((m) => m.id === milestoneRef);
|
|
1878
|
+
}
|
|
1879
|
+
function findEntity(tree, target) {
|
|
1880
|
+
const all = [
|
|
1881
|
+
...tree.stories,
|
|
1882
|
+
...tree.epics,
|
|
1883
|
+
...tree.milestones,
|
|
1884
|
+
...tree.roadmaps,
|
|
1885
|
+
...tree.prds
|
|
1886
|
+
];
|
|
1887
|
+
const resolved = resolve6(process.cwd(), target);
|
|
1888
|
+
const byPath = all.find((e) => e.filePath === resolved);
|
|
1889
|
+
if (byPath) return byPath;
|
|
1890
|
+
return all.find((e) => e.id === target);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// src/commands/sprint.ts
|
|
1894
|
+
import { relative as relative8, resolve as resolvePath } from "path";
|
|
1895
|
+
import {
|
|
1896
|
+
createSprint,
|
|
1897
|
+
parseTree as parseTree8,
|
|
1898
|
+
resolveRefs as resolveRefs5,
|
|
1899
|
+
writeFile as writeFile5
|
|
1900
|
+
} from "@gitpm/core";
|
|
1901
|
+
import { checkbox, confirm as confirm2 } from "@inquirer/prompts";
|
|
1902
|
+
import chalk12 from "chalk";
|
|
1903
|
+
import { Command as Command16 } from "commander";
|
|
1904
|
+
function sprintProgress(sprint) {
|
|
1905
|
+
const total = sprint.resolvedStories.length;
|
|
1906
|
+
const done = sprint.resolvedStories.filter(
|
|
1907
|
+
(s) => s.status === "done" || s.status === "cancelled"
|
|
1908
|
+
).length;
|
|
1909
|
+
return { done, total, ratio: total > 0 ? done / total : 0 };
|
|
1910
|
+
}
|
|
1911
|
+
function isPickableStory(story) {
|
|
1912
|
+
return story.status === "todo" || story.status === "backlog";
|
|
1913
|
+
}
|
|
1914
|
+
function findSprintByIdOrPath(sprints, idOrPath) {
|
|
1915
|
+
const looksLikePath = idOrPath.includes("/") || idOrPath.endsWith(".md");
|
|
1916
|
+
if (looksLikePath) {
|
|
1917
|
+
const target = resolvePath(process.cwd(), idOrPath);
|
|
1918
|
+
return sprints.find((s) => resolvePath(s.filePath) === target);
|
|
1919
|
+
}
|
|
1920
|
+
return sprints.find((s) => s.id === idOrPath);
|
|
1921
|
+
}
|
|
1922
|
+
function printPointsSummary(totalPoints, capacity) {
|
|
1923
|
+
if (capacity === void 0) {
|
|
1924
|
+
console.log(chalk12.dim(` Points: ${totalPoints} pts (no capacity set)`));
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const ratio = capacity > 0 ? totalPoints / capacity : totalPoints > 0 ? 1 : 0;
|
|
1928
|
+
const bar = progressBar(Math.min(ratio, 1), 16);
|
|
1929
|
+
const label = `${totalPoints}/${capacity} pts`;
|
|
1930
|
+
if (totalPoints > capacity) {
|
|
1931
|
+
console.log(` ${bar} ${chalk12.red(`${label} \u2014 over capacity`)}`);
|
|
1932
|
+
} else {
|
|
1933
|
+
console.log(` ${bar} ${label}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
var sprintCreateCommand = new Command16("create").description("Create a new sprint").requiredOption("--title <title>", "Sprint title").requiredOption("--start <date>", "Start date (YYYY-MM-DD)").requiredOption("--end <date>", "End date (YYYY-MM-DD)").option("--capacity <points>", "Story point capacity", Number.parseInt).action(async (opts, cmd) => {
|
|
1937
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1938
|
+
const result = await createSprint(metaDir, {
|
|
1939
|
+
title: opts.title,
|
|
1940
|
+
startDate: opts.start,
|
|
1941
|
+
endDate: opts.end,
|
|
1942
|
+
capacity: opts.capacity
|
|
341
1943
|
});
|
|
342
1944
|
if (!result.ok) {
|
|
343
|
-
spinner.fail("Pull failed.");
|
|
344
1945
|
printError(result.error.message);
|
|
345
1946
|
process.exit(1);
|
|
346
1947
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
1948
|
+
const rel = relative8(process.cwd(), result.value.filePath);
|
|
1949
|
+
printSuccess(`Created sprint: ${rel} (${result.value.id})`);
|
|
1950
|
+
});
|
|
1951
|
+
var sprintListCommand = new Command16("list").description("List all sprints with progress").option("--json", "Output as JSON").action(async (opts, cmd) => {
|
|
1952
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1953
|
+
const parseResult = await parseTree8(metaDir);
|
|
1954
|
+
if (!parseResult.ok) {
|
|
1955
|
+
printError(parseResult.error.message);
|
|
1956
|
+
process.exit(1);
|
|
1957
|
+
}
|
|
1958
|
+
const resolveResult = resolveRefs5(parseResult.value);
|
|
1959
|
+
if (!resolveResult.ok) {
|
|
1960
|
+
printError(resolveResult.error.message);
|
|
1961
|
+
process.exit(1);
|
|
1962
|
+
}
|
|
1963
|
+
const sprints = resolveResult.value.sprints;
|
|
1964
|
+
if (opts.json) {
|
|
1965
|
+
const data = sprints.map((sp) => {
|
|
1966
|
+
const { done, total, ratio } = sprintProgress(sp);
|
|
1967
|
+
return {
|
|
1968
|
+
id: sp.id,
|
|
1969
|
+
title: sp.title,
|
|
1970
|
+
status: sp.status,
|
|
1971
|
+
start_date: sp.start_date,
|
|
1972
|
+
end_date: sp.end_date,
|
|
1973
|
+
capacity: sp.capacity,
|
|
1974
|
+
stories: total,
|
|
1975
|
+
done,
|
|
1976
|
+
progress: ratio
|
|
1977
|
+
};
|
|
1978
|
+
});
|
|
1979
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
if (sprints.length === 0) {
|
|
354
1983
|
console.log(
|
|
355
|
-
|
|
1984
|
+
chalk12.dim(" No sprints found. Create one with: gitpm sprint create")
|
|
356
1985
|
);
|
|
357
|
-
|
|
358
|
-
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
console.log();
|
|
1989
|
+
console.log(chalk12.bold(" Sprints"));
|
|
1990
|
+
console.log(chalk12.dim(` ${"\u2500".repeat(7)}`));
|
|
1991
|
+
for (const sp of sprints) {
|
|
1992
|
+
const { done, total, ratio } = sprintProgress(sp);
|
|
1993
|
+
const pct3 = Math.round(ratio * 100);
|
|
1994
|
+
const dateRange = chalk12.dim(
|
|
1995
|
+
`${sp.start_date.slice(0, 10)} \u2192 ${sp.end_date.slice(0, 10)}`
|
|
359
1996
|
);
|
|
1997
|
+
const cap = sp.capacity ? chalk12.dim(` (cap: ${sp.capacity}pts)`) : "";
|
|
360
1998
|
console.log(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1999
|
+
` ${progressBar(ratio, 16)} ${pct3}% ${sp.title} ${dateRange}${cap}`
|
|
2000
|
+
);
|
|
2001
|
+
console.log(
|
|
2002
|
+
` ${" ".repeat(16)} ${chalk12.dim(`${done}/${total} stories`)} ${chalk12.cyan(sp.status)}`
|
|
364
2003
|
);
|
|
365
2004
|
}
|
|
366
2005
|
console.log();
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
console.log(` Conflicts resolved: ${resolved}`);
|
|
370
|
-
console.log(` Skipped: ${skipped}`);
|
|
371
|
-
console.log();
|
|
372
|
-
printSuccess("Pull complete.");
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// src/commands/push.ts
|
|
376
|
-
import {
|
|
377
|
-
exportToGitHub,
|
|
378
|
-
loadConfig as loadGitHubConfig2
|
|
379
|
-
} from "@gitpm/sync-github";
|
|
380
|
-
import {
|
|
381
|
-
exportToGitLab,
|
|
382
|
-
loadConfig as loadGitLabConfig2
|
|
383
|
-
} from "@gitpm/sync-gitlab";
|
|
384
|
-
import { confirm } from "@inquirer/prompts";
|
|
385
|
-
import chalk6 from "chalk";
|
|
386
|
-
import { Command as Command4 } from "commander";
|
|
387
|
-
import ora3 from "ora";
|
|
388
|
-
var pushCommand = new Command4("push").description("Push local .meta/ changes to GitHub or GitLab").option("--token <token>", "Personal access token").option("--dry-run", "Preview changes without pushing").option("--yes", "Skip confirmation prompt").action(async (opts, cmd) => {
|
|
2006
|
+
});
|
|
2007
|
+
var sprintShowCommand = new Command16("show").description("Show sprint details").argument("<sprint-id>", "Sprint ID").action(async (sprintId, _opts, cmd) => {
|
|
389
2008
|
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
await pushToGitLab(opts, metaDir, glConfig.value);
|
|
394
|
-
} else if (ghConfig.ok) {
|
|
395
|
-
await pushToGitHub(opts, metaDir, ghConfig.value.repo);
|
|
396
|
-
} else {
|
|
397
|
-
printError(
|
|
398
|
-
"No sync config found. Run `gitpm import` first to set up sync."
|
|
399
|
-
);
|
|
2009
|
+
const parseResult = await parseTree8(metaDir);
|
|
2010
|
+
if (!parseResult.ok) {
|
|
2011
|
+
printError(parseResult.error.message);
|
|
400
2012
|
process.exit(1);
|
|
401
2013
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
try {
|
|
406
|
-
token = await resolveToken(opts.token);
|
|
407
|
-
} catch (err) {
|
|
408
|
-
printError(err instanceof Error ? err.message : "Failed to resolve token.");
|
|
2014
|
+
const resolveResult = resolveRefs5(parseResult.value);
|
|
2015
|
+
if (!resolveResult.ok) {
|
|
2016
|
+
printError(resolveResult.error.message);
|
|
409
2017
|
process.exit(1);
|
|
410
2018
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (!result2.ok) {
|
|
415
|
-
spinner.fail("Dry run failed.");
|
|
416
|
-
printError(result2.error.message);
|
|
417
|
-
process.exit(1);
|
|
418
|
-
}
|
|
419
|
-
spinner.succeed("Dry run complete.");
|
|
420
|
-
printPreview(result2.value);
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
const previewSpinner = ora3("Calculating changes...").start();
|
|
424
|
-
const preview = await exportToGitHub({
|
|
425
|
-
token,
|
|
426
|
-
repo,
|
|
427
|
-
metaDir,
|
|
428
|
-
dryRun: true
|
|
429
|
-
});
|
|
430
|
-
if (!preview.ok) {
|
|
431
|
-
previewSpinner.fail("Failed to calculate changes.");
|
|
432
|
-
printError(preview.error.message);
|
|
2019
|
+
const sprint = resolveResult.value.sprints.find((s) => s.id === sprintId);
|
|
2020
|
+
if (!sprint) {
|
|
2021
|
+
printError(`Sprint not found: ${sprintId}`);
|
|
433
2022
|
process.exit(1);
|
|
434
2023
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
2024
|
+
const { done, total, ratio } = sprintProgress(sprint);
|
|
2025
|
+
const pct3 = Math.round(ratio * 100);
|
|
2026
|
+
console.log();
|
|
2027
|
+
console.log(chalk12.bold(` ${sprint.title}`));
|
|
2028
|
+
console.log(chalk12.dim(` ${"\u2500".repeat(sprint.title.length)}`));
|
|
2029
|
+
console.log(` Status: ${chalk12.cyan(sprint.status)}`);
|
|
2030
|
+
console.log(
|
|
2031
|
+
` Period: ${sprint.start_date.slice(0, 10)} \u2192 ${sprint.end_date.slice(0, 10)}`
|
|
2032
|
+
);
|
|
2033
|
+
if (sprint.capacity) console.log(` Capacity: ${sprint.capacity} pts`);
|
|
2034
|
+
console.log(
|
|
2035
|
+
` Progress: ${progressBar(ratio, 16)} ${pct3}% (${done}/${total})`
|
|
2036
|
+
);
|
|
2037
|
+
console.log();
|
|
2038
|
+
if (sprint.resolvedStories.length > 0) {
|
|
2039
|
+
console.log(chalk12.bold(" Stories:"));
|
|
2040
|
+
for (const story of sprint.resolvedStories) {
|
|
2041
|
+
const statusColor = story.status === "done" ? chalk12.green : story.status === "in_progress" ? chalk12.yellow : chalk12.dim;
|
|
2042
|
+
console.log(
|
|
2043
|
+
` ${statusColor("\u25CF")} ${story.title} ${chalk12.dim(`[${story.status}]`)}`
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
} else {
|
|
2047
|
+
console.log(chalk12.dim(" No stories assigned."));
|
|
440
2048
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
2049
|
+
console.log();
|
|
2050
|
+
});
|
|
2051
|
+
var sprintPlanCommand = new Command16("plan").description(
|
|
2052
|
+
"Plan a sprint by picking stories to include (interactively, or via --add/--remove)"
|
|
2053
|
+
).argument("<sprint>", "Sprint ID or file path").option(
|
|
2054
|
+
"--add <story-id...>",
|
|
2055
|
+
"Add story IDs to the sprint (non-interactive)"
|
|
2056
|
+
).option(
|
|
2057
|
+
"--remove <story-id...>",
|
|
2058
|
+
"Remove story IDs from the sprint (non-interactive)"
|
|
2059
|
+
).action(
|
|
2060
|
+
async (sprintArg, opts, cmd) => {
|
|
2061
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
2062
|
+
const parseResult = await parseTree8(metaDir);
|
|
2063
|
+
if (!parseResult.ok) {
|
|
2064
|
+
printError(parseResult.error.message);
|
|
2065
|
+
process.exit(1);
|
|
2066
|
+
}
|
|
2067
|
+
const resolveResult = resolveRefs5(parseResult.value);
|
|
2068
|
+
if (!resolveResult.ok) {
|
|
2069
|
+
printError(resolveResult.error.message);
|
|
2070
|
+
process.exit(1);
|
|
2071
|
+
}
|
|
2072
|
+
const sprint = findSprintByIdOrPath(
|
|
2073
|
+
resolveResult.value.sprints,
|
|
2074
|
+
sprintArg
|
|
2075
|
+
);
|
|
2076
|
+
if (!sprint) {
|
|
2077
|
+
printError(`Sprint not found: ${sprintArg}`);
|
|
2078
|
+
process.exit(1);
|
|
2079
|
+
}
|
|
2080
|
+
const allStories = resolveResult.value.stories;
|
|
2081
|
+
const storyById = new Map(allStories.map((s) => [s.id, s]));
|
|
2082
|
+
const currentIds = new Set(sprint.stories.map((ref) => ref.id));
|
|
2083
|
+
const otherSprintsByStory = /* @__PURE__ */ new Map();
|
|
2084
|
+
for (const other of resolveResult.value.sprints) {
|
|
2085
|
+
if (other.id === sprint.id) continue;
|
|
2086
|
+
if (other.status === "done" || other.status === "cancelled") continue;
|
|
2087
|
+
for (const ref of other.stories) {
|
|
2088
|
+
const titles = otherSprintsByStory.get(ref.id) ?? [];
|
|
2089
|
+
if (!titles.includes(other.title)) titles.push(other.title);
|
|
2090
|
+
otherSprintsByStory.set(ref.id, titles);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
const pointsFor = (ids) => [...ids].reduce(
|
|
2094
|
+
(sum, id) => sum + (storyById.get(id)?.estimate ?? 0),
|
|
2095
|
+
0
|
|
2096
|
+
);
|
|
2097
|
+
const warnAlreadyPlanned = (ids) => {
|
|
2098
|
+
for (const id of ids) {
|
|
2099
|
+
if (currentIds.has(id)) continue;
|
|
2100
|
+
const others = otherSprintsByStory.get(id);
|
|
2101
|
+
if (others) {
|
|
2102
|
+
const title = storyById.get(id)?.title ?? id;
|
|
2103
|
+
const names = others.map((t) => `"${t}"`).join(", ");
|
|
2104
|
+
printWarning(
|
|
2105
|
+
others.length === 1 ? `Story "${title}" is already in sprint ${names}; its points will count toward both.` : `Story "${title}" is already in ${others.length} other sprints (${names}); its points will count toward all of them.`
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
let nextIds;
|
|
2111
|
+
const nonInteractive = Boolean(opts.add?.length || opts.remove?.length);
|
|
2112
|
+
if (nonInteractive) {
|
|
2113
|
+
const addIds = opts.add ?? [];
|
|
2114
|
+
const unknownIds = addIds.filter((id) => !storyById.has(id));
|
|
2115
|
+
if (unknownIds.length > 0) {
|
|
2116
|
+
printError(
|
|
2117
|
+
`Unknown story ID${unknownIds.length > 1 ? "s" : ""}: ${unknownIds.join(", ")}`
|
|
2118
|
+
);
|
|
2119
|
+
process.exit(1);
|
|
2120
|
+
}
|
|
2121
|
+
nextIds = new Set(currentIds);
|
|
2122
|
+
for (const id of addIds) nextIds.add(id);
|
|
2123
|
+
for (const id of opts.remove ?? []) nextIds.delete(id);
|
|
2124
|
+
warnAlreadyPlanned(nextIds);
|
|
2125
|
+
} else {
|
|
2126
|
+
const pickable = allStories.filter(isPickableStory);
|
|
2127
|
+
const untouchedIds = new Set(
|
|
2128
|
+
[...currentIds].filter(
|
|
2129
|
+
(id) => !storyById.has(id) || !isPickableStory(storyById.get(id))
|
|
2130
|
+
)
|
|
2131
|
+
);
|
|
2132
|
+
if (pickable.length === 0) {
|
|
2133
|
+
console.log(
|
|
2134
|
+
chalk12.dim(" No todo/backlog stories available to plan.")
|
|
2135
|
+
);
|
|
2136
|
+
nextIds = currentIds;
|
|
2137
|
+
} else {
|
|
2138
|
+
const selected = await checkbox({
|
|
2139
|
+
message: `Select stories for "${sprint.title}"`,
|
|
2140
|
+
choices: pickable.map((story) => {
|
|
2141
|
+
const others = otherSprintsByStory.get(story.id);
|
|
2142
|
+
const inOthers = others ? ` ${chalk12.yellow(`(in ${others.join(", ")})`)}` : "";
|
|
2143
|
+
return {
|
|
2144
|
+
name: `${story.title} ${chalk12.dim(`[${story.priority}]`)} ${chalk12.dim(`(${story.estimate ?? "?"} pts)`)}${inOthers}`,
|
|
2145
|
+
value: story.id,
|
|
2146
|
+
checked: currentIds.has(story.id)
|
|
2147
|
+
};
|
|
2148
|
+
})
|
|
2149
|
+
});
|
|
2150
|
+
nextIds = /* @__PURE__ */ new Set([...untouchedIds, ...selected]);
|
|
2151
|
+
warnAlreadyPlanned(nextIds);
|
|
2152
|
+
console.log();
|
|
2153
|
+
printPointsSummary(pointsFor(nextIds), sprint.capacity);
|
|
2154
|
+
const proceed = await confirm2({
|
|
2155
|
+
message: `Save this selection to "${sprint.title}"?`,
|
|
2156
|
+
default: true
|
|
2157
|
+
});
|
|
2158
|
+
if (!proceed) {
|
|
2159
|
+
console.log(chalk12.dim(" Cancelled \u2014 no changes written."));
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
const totalPoints = pointsFor(nextIds);
|
|
2165
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2166
|
+
const keptRefs = sprint.stories.filter((ref) => {
|
|
2167
|
+
if (!nextIds.has(ref.id) || seenIds.has(ref.id)) return false;
|
|
2168
|
+
seenIds.add(ref.id);
|
|
2169
|
+
return true;
|
|
445
2170
|
});
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
2171
|
+
const addedRefs = [...nextIds].filter((id) => !currentIds.has(id)).map((id) => ({ id }));
|
|
2172
|
+
const { resolvedStories: _resolvedStories, ...sprintBase } = sprint;
|
|
2173
|
+
const updatedSprint = {
|
|
2174
|
+
...sprintBase,
|
|
2175
|
+
stories: [...keptRefs, ...addedRefs],
|
|
2176
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2177
|
+
};
|
|
2178
|
+
const writeResult = await writeFile5(updatedSprint, sprint.filePath);
|
|
2179
|
+
if (!writeResult.ok) {
|
|
2180
|
+
printError(writeResult.error.message);
|
|
2181
|
+
process.exit(1);
|
|
449
2182
|
}
|
|
2183
|
+
console.log();
|
|
2184
|
+
printSuccess(
|
|
2185
|
+
`Updated sprint "${sprint.title}" (${nextIds.size} ${nextIds.size === 1 ? "story" : "stories"})`
|
|
2186
|
+
);
|
|
2187
|
+
printPointsSummary(totalPoints, sprint.capacity);
|
|
2188
|
+
console.log();
|
|
450
2189
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
2190
|
+
);
|
|
2191
|
+
var sprintCommand = new Command16("sprint").description("Manage sprints (time-boxed iterations)").addCommand(sprintCreateCommand).addCommand(sprintListCommand).addCommand(sprintShowCommand).addCommand(sprintPlanCommand);
|
|
2192
|
+
|
|
2193
|
+
// src/commands/status.ts
|
|
2194
|
+
import {
|
|
2195
|
+
computeProjectProgress,
|
|
2196
|
+
parseTree as parseTree9,
|
|
2197
|
+
resolveRefs as resolveRefs6
|
|
2198
|
+
} from "@gitpm/core";
|
|
2199
|
+
import chalk13 from "chalk";
|
|
2200
|
+
import { Command as Command17 } from "commander";
|
|
2201
|
+
function pct2(ratio) {
|
|
2202
|
+
return `${Math.round(ratio * 100)}%`;
|
|
2203
|
+
}
|
|
2204
|
+
function printProjectProgress(progress) {
|
|
2205
|
+
console.log();
|
|
2206
|
+
console.log(chalk13.bold(" Project Status"));
|
|
2207
|
+
console.log(chalk13.dim(` ${"\u2500".repeat(14)}`));
|
|
2208
|
+
const { overall } = progress;
|
|
460
2209
|
console.log(
|
|
461
|
-
`
|
|
2210
|
+
` ${progressBar(overall.progress, 20)} ${pct2(overall.progress)} (${overall.done}/${overall.total} stories)`
|
|
462
2211
|
);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
"No GitLab token found. Provide via --token flag or GITLAB_TOKEN env var."
|
|
2212
|
+
console.log();
|
|
2213
|
+
for (const ms of progress.milestones) {
|
|
2214
|
+
const dateStr = ms.targetDate ? chalk13.dim(` \u2014 due ${ms.targetDate.slice(0, 10)}`) : "";
|
|
2215
|
+
console.log(
|
|
2216
|
+
chalk13.bold(` \u{1F4CC} ${ms.title}`) + dateStr + chalk13.dim(` (${pct2(ms.progress)})`)
|
|
469
2217
|
);
|
|
470
|
-
|
|
2218
|
+
for (const ep of ms.epics) {
|
|
2219
|
+
const counts = chalk13.dim(
|
|
2220
|
+
`${ep.done}/${ep.total} done` + (ep.inProgress > 0 ? `, ${ep.inProgress} active` : "") + (ep.blocked > 0 ? `, ${ep.blocked} blocked` : "")
|
|
2221
|
+
);
|
|
2222
|
+
console.log(
|
|
2223
|
+
` ${progressBar(ep.progress, 16)} ${pct2(ep.progress)} ${ep.title}`
|
|
2224
|
+
);
|
|
2225
|
+
console.log(` ${" ".repeat(16)} ${counts}`);
|
|
2226
|
+
}
|
|
2227
|
+
if (ms.epics.length === 0) {
|
|
2228
|
+
console.log(chalk13.dim(" (no epics linked)"));
|
|
2229
|
+
}
|
|
2230
|
+
console.log();
|
|
471
2231
|
}
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
metaDir,
|
|
480
|
-
dryRun: true
|
|
481
|
-
});
|
|
482
|
-
if (!result2.ok) {
|
|
483
|
-
spinner.fail("Dry run failed.");
|
|
484
|
-
printError(result2.error.message);
|
|
485
|
-
process.exit(1);
|
|
2232
|
+
if (progress.orphanEpics.length > 0) {
|
|
2233
|
+
console.log(chalk13.bold(" Unlinked Epics"));
|
|
2234
|
+
for (const ep of progress.orphanEpics) {
|
|
2235
|
+
const counts = chalk13.dim(`${ep.done}/${ep.total} done`);
|
|
2236
|
+
console.log(
|
|
2237
|
+
` ${progressBar(ep.progress, 16)} ${pct2(ep.progress)} ${ep.title} ${counts}`
|
|
2238
|
+
);
|
|
486
2239
|
}
|
|
487
|
-
|
|
488
|
-
printPreview(result2.value);
|
|
489
|
-
return;
|
|
2240
|
+
console.log();
|
|
490
2241
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
metaDir,
|
|
498
|
-
dryRun: true
|
|
499
|
-
});
|
|
500
|
-
if (!preview.ok) {
|
|
501
|
-
previewSpinner.fail("Failed to calculate changes.");
|
|
502
|
-
printError(preview.error.message);
|
|
2242
|
+
}
|
|
2243
|
+
var statusCommand = new Command17("status").description("Show project progress across milestones and epics").option("--json", "Output as JSON").action(async (opts, cmd) => {
|
|
2244
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
2245
|
+
const parseResult = await parseTree9(metaDir);
|
|
2246
|
+
if (!parseResult.ok) {
|
|
2247
|
+
printError(parseResult.error.message);
|
|
503
2248
|
process.exit(1);
|
|
504
2249
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
return;
|
|
2250
|
+
const resolveResult = resolveRefs6(parseResult.value);
|
|
2251
|
+
if (!resolveResult.ok) {
|
|
2252
|
+
printError(resolveResult.error.message);
|
|
2253
|
+
process.exit(1);
|
|
510
2254
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
});
|
|
516
|
-
if (!confirmed) {
|
|
517
|
-
console.log(chalk6.dim("Push cancelled."));
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
2255
|
+
const progress = computeProjectProgress(resolveResult.value);
|
|
2256
|
+
if (opts.json) {
|
|
2257
|
+
console.log(JSON.stringify(progress, null, 2));
|
|
2258
|
+
return;
|
|
520
2259
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
project: config.project,
|
|
525
|
-
projectId: config.project_id,
|
|
526
|
-
baseUrl: config.base_url,
|
|
527
|
-
metaDir,
|
|
528
|
-
dryRun: false
|
|
529
|
-
});
|
|
530
|
-
if (!result.ok) {
|
|
531
|
-
pushSpinner.fail("Push failed.");
|
|
532
|
-
printError(result.error.message);
|
|
533
|
-
process.exit(1);
|
|
2260
|
+
if (progress.overall.total === 0) {
|
|
2261
|
+
console.log(chalk13.dim(" No stories found in .meta/"));
|
|
2262
|
+
return;
|
|
534
2263
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
console.log(
|
|
538
|
-
` Created ${created.milestones + created.issues}, Updated ${updated.milestones + updated.issues}`
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
function printPreview(result) {
|
|
542
|
-
console.log();
|
|
543
|
-
console.log(chalk6.bold("Changes to push:"));
|
|
544
|
-
console.log(` New milestones: ${result.created.milestones}`);
|
|
545
|
-
console.log(` New issues: ${result.created.issues}`);
|
|
546
|
-
console.log(` Updated milestones: ${result.updated.milestones}`);
|
|
547
|
-
console.log(` Updated issues: ${result.updated.issues}`);
|
|
548
|
-
console.log(` Total changes: ${result.totalChanges}`);
|
|
549
|
-
console.log();
|
|
550
|
-
}
|
|
2264
|
+
printProjectProgress(progress);
|
|
2265
|
+
});
|
|
551
2266
|
|
|
552
2267
|
// src/commands/sync.ts
|
|
553
|
-
import {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
} from "
|
|
557
|
-
import {
|
|
558
|
-
loadConfig as loadGitLabConfig3,
|
|
559
|
-
syncWithGitLab as syncWithGitLab2
|
|
560
|
-
} from "@gitpm/sync-gitlab";
|
|
561
|
-
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
562
|
-
import chalk7 from "chalk";
|
|
563
|
-
import { Command as Command5 } from "commander";
|
|
2268
|
+
import { runHooks as runHooks4 } from "@gitpm/core";
|
|
2269
|
+
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2270
|
+
import chalk14 from "chalk";
|
|
2271
|
+
import { Command as Command18 } from "commander";
|
|
564
2272
|
import ora4 from "ora";
|
|
565
|
-
var
|
|
2273
|
+
var VALID_STRATEGIES2 = ["local-wins", "remote-wins", "ask"];
|
|
2274
|
+
var syncCommand = new Command18("sync").description("Bidirectional sync between local .meta/ and remote platform").option("--token <token>", "Personal access token").option(
|
|
566
2275
|
"--strategy <strategy>",
|
|
567
2276
|
"Conflict resolution strategy (local-wins, remote-wins, ask)",
|
|
568
2277
|
"ask"
|
|
569
|
-
).option("--
|
|
2278
|
+
).option("--team <id>", "Linear team ID").option(
|
|
2279
|
+
"--api-key <key>",
|
|
2280
|
+
"Linear API key (default: LINEAR_API_KEY environment variable)"
|
|
2281
|
+
).option("--dry-run", "Preview changes without syncing").option("--yes", "Skip confirmation prompt").option("--adapter <name>", "Force a specific adapter (e.g. github, gitlab)").action(async (opts, cmd) => {
|
|
570
2282
|
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
571
|
-
|
|
572
|
-
const ghConfig = await loadGitHubConfig3(metaDir);
|
|
573
|
-
const glConfig = await loadGitLabConfig3(metaDir);
|
|
574
|
-
if (glConfig.ok) {
|
|
575
|
-
await syncGitLab(opts, metaDir, glConfig.value, strategy);
|
|
576
|
-
} else if (ghConfig.ok) {
|
|
577
|
-
await syncGitHub(opts, metaDir, ghConfig.value.repo, strategy);
|
|
578
|
-
} else {
|
|
2283
|
+
if (!VALID_STRATEGIES2.includes(opts.strategy)) {
|
|
579
2284
|
printError(
|
|
580
|
-
|
|
2285
|
+
`Invalid strategy "${opts.strategy}". Must be one of: ${VALID_STRATEGIES2.join(", ")}`
|
|
581
2286
|
);
|
|
582
2287
|
process.exit(1);
|
|
583
2288
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
try {
|
|
588
|
-
token = await resolveToken(opts.token);
|
|
589
|
-
} catch (err) {
|
|
590
|
-
printError(err instanceof Error ? err.message : "Failed to resolve token.");
|
|
591
|
-
process.exit(1);
|
|
592
|
-
}
|
|
2289
|
+
const strategy = opts.strategy;
|
|
2290
|
+
const { adapter, config } = await resolveAdapter(metaDir, opts.adapter);
|
|
2291
|
+
const { token, email } = resolveCredentials(adapter.name, opts.token);
|
|
593
2292
|
if (opts.dryRun) {
|
|
594
2293
|
const spinner2 = ora4("Calculating sync changes...").start();
|
|
595
|
-
const result2 = await
|
|
2294
|
+
const result2 = await adapter.sync({
|
|
596
2295
|
token,
|
|
597
|
-
|
|
2296
|
+
email,
|
|
598
2297
|
metaDir,
|
|
2298
|
+
teamId: opts.team,
|
|
2299
|
+
apiKey: opts.apiKey,
|
|
599
2300
|
strategy,
|
|
600
2301
|
dryRun: true
|
|
601
2302
|
});
|
|
@@ -605,81 +2306,36 @@ async function syncGitHub(opts, metaDir, repo, strategy) {
|
|
|
605
2306
|
process.exit(1);
|
|
606
2307
|
}
|
|
607
2308
|
spinner2.succeed("Dry run complete.");
|
|
608
|
-
printSyncSummary(result2.value,
|
|
2309
|
+
printSyncSummary(result2.value, adapter.displayName);
|
|
609
2310
|
return;
|
|
610
2311
|
}
|
|
611
2312
|
if (!opts.yes) {
|
|
612
|
-
const confirmed = await
|
|
613
|
-
message:
|
|
2313
|
+
const confirmed = await confirm3({
|
|
2314
|
+
message: `Run bidirectional sync with ${adapter.displayName}?`,
|
|
614
2315
|
default: true
|
|
615
2316
|
});
|
|
616
2317
|
if (!confirmed) {
|
|
617
|
-
console.log(
|
|
2318
|
+
console.log(chalk14.dim("Sync cancelled."));
|
|
618
2319
|
return;
|
|
619
2320
|
}
|
|
620
2321
|
}
|
|
621
|
-
const
|
|
622
|
-
const result = await syncWithGitHub2({
|
|
623
|
-
token,
|
|
624
|
-
repo,
|
|
2322
|
+
const preHook = await runHooks4(config, "pre-sync", {
|
|
625
2323
|
metaDir,
|
|
626
|
-
|
|
2324
|
+
event: "pre-sync",
|
|
2325
|
+
adapterName: adapter.name
|
|
627
2326
|
});
|
|
628
|
-
if (!
|
|
629
|
-
|
|
630
|
-
printError(result.error.message);
|
|
631
|
-
process.exit(1);
|
|
632
|
-
}
|
|
633
|
-
spinner.succeed("Sync complete.");
|
|
634
|
-
await handleConflicts(result.value, strategy);
|
|
635
|
-
printSyncSummary(result.value, "GitHub");
|
|
636
|
-
}
|
|
637
|
-
async function syncGitLab(opts, metaDir, config, strategy) {
|
|
638
|
-
const token = opts.token ?? process.env.GITLAB_TOKEN;
|
|
639
|
-
if (!token) {
|
|
640
|
-
printError(
|
|
641
|
-
"No GitLab token found. Provide via --token flag or GITLAB_TOKEN env var."
|
|
642
|
-
);
|
|
2327
|
+
if (!preHook.ok) {
|
|
2328
|
+
printError(`Pre-sync hook failed: ${preHook.error.message}`);
|
|
643
2329
|
process.exit(1);
|
|
644
2330
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const result2 = await syncWithGitLab2({
|
|
648
|
-
token,
|
|
649
|
-
project: config.project,
|
|
650
|
-
projectId: config.project_id,
|
|
651
|
-
baseUrl: config.base_url,
|
|
652
|
-
metaDir,
|
|
653
|
-
strategy,
|
|
654
|
-
dryRun: true
|
|
655
|
-
});
|
|
656
|
-
if (!result2.ok) {
|
|
657
|
-
spinner2.fail("Dry run failed.");
|
|
658
|
-
printError(result2.error.message);
|
|
659
|
-
process.exit(1);
|
|
660
|
-
}
|
|
661
|
-
spinner2.succeed("Dry run complete.");
|
|
662
|
-
printSyncSummary(result2.value, "GitLab");
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
if (!opts.yes) {
|
|
666
|
-
const confirmed = await confirm2({
|
|
667
|
-
message: "Run bidirectional sync with GitLab?",
|
|
668
|
-
default: true
|
|
669
|
-
});
|
|
670
|
-
if (!confirmed) {
|
|
671
|
-
console.log(chalk7.dim("Sync cancelled."));
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
const spinner = ora4("Syncing with GitLab...").start();
|
|
676
|
-
const result = await syncWithGitLab2({
|
|
2331
|
+
const spinner = ora4(`Syncing with ${adapter.displayName}...`).start();
|
|
2332
|
+
const result = await adapter.sync({
|
|
677
2333
|
token,
|
|
678
|
-
|
|
679
|
-
projectId: config.project_id,
|
|
680
|
-
baseUrl: config.base_url,
|
|
2334
|
+
email,
|
|
681
2335
|
metaDir,
|
|
682
|
-
|
|
2336
|
+
teamId: opts.team,
|
|
2337
|
+
apiKey: opts.apiKey,
|
|
2338
|
+
strategy
|
|
683
2339
|
});
|
|
684
2340
|
if (!result.ok) {
|
|
685
2341
|
spinner.fail("Sync failed.");
|
|
@@ -688,20 +2344,26 @@ async function syncGitLab(opts, metaDir, config, strategy) {
|
|
|
688
2344
|
}
|
|
689
2345
|
spinner.succeed("Sync complete.");
|
|
690
2346
|
await handleConflicts(result.value, strategy);
|
|
691
|
-
printSyncSummary(result.value,
|
|
692
|
-
|
|
2347
|
+
printSyncSummary(result.value, adapter.displayName);
|
|
2348
|
+
await runHooks4(config, "post-sync", {
|
|
2349
|
+
metaDir,
|
|
2350
|
+
event: "post-sync",
|
|
2351
|
+
adapterName: adapter.name,
|
|
2352
|
+
result: result.value
|
|
2353
|
+
});
|
|
2354
|
+
});
|
|
693
2355
|
async function handleConflicts(result, strategy) {
|
|
694
2356
|
const { conflicts } = result;
|
|
695
2357
|
if (strategy === "ask" && conflicts.length > 0) {
|
|
696
2358
|
console.log();
|
|
697
2359
|
console.log(
|
|
698
|
-
|
|
2360
|
+
chalk14.yellow(`${conflicts.length} conflict(s) require resolution:`)
|
|
699
2361
|
);
|
|
700
2362
|
const resolutions = await promptConflictResolution(
|
|
701
2363
|
conflicts
|
|
702
2364
|
);
|
|
703
2365
|
console.log(
|
|
704
|
-
|
|
2366
|
+
chalk14.dim(
|
|
705
2367
|
`Resolved ${resolutions.length}, skipped ${conflicts.length - resolutions.length}`
|
|
706
2368
|
)
|
|
707
2369
|
);
|
|
@@ -710,35 +2372,68 @@ async function handleConflicts(result, strategy) {
|
|
|
710
2372
|
function printSyncSummary(result, platform) {
|
|
711
2373
|
const pushedTotal = result.pushed.milestones + result.pushed.issues;
|
|
712
2374
|
const pulledTotal = result.pulled.milestones + result.pulled.issues;
|
|
2375
|
+
const failedCount = result.failedEntities?.length ?? 0;
|
|
2376
|
+
if (result.resumedFromCheckpoint) {
|
|
2377
|
+
console.log();
|
|
2378
|
+
console.log(chalk14.yellow("Resumed from previous checkpoint."));
|
|
2379
|
+
}
|
|
713
2380
|
console.log();
|
|
714
|
-
console.log(chalk7.bold("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510"));
|
|
715
|
-
console.log(chalk7.bold("\u2502 Sync Complete \u2502"));
|
|
716
|
-
console.log(chalk7.bold("\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"));
|
|
717
2381
|
console.log(
|
|
718
|
-
|
|
2382
|
+
chalk14.bold(
|
|
2383
|
+
"\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510"
|
|
2384
|
+
)
|
|
2385
|
+
);
|
|
2386
|
+
console.log(chalk14.bold("\u2502 Sync Complete \u2502"));
|
|
2387
|
+
console.log(
|
|
2388
|
+
chalk14.bold(
|
|
2389
|
+
"\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524"
|
|
2390
|
+
)
|
|
2391
|
+
);
|
|
2392
|
+
console.log(
|
|
2393
|
+
`${chalk14.bold("\u2502")} Pushed to ${platform.padEnd(6)} \u2502 ${String(pushedTotal).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
2394
|
+
);
|
|
2395
|
+
console.log(
|
|
2396
|
+
`${chalk14.bold("\u2502")} Pulled to local \u2502 ${String(pulledTotal).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
719
2397
|
);
|
|
720
2398
|
console.log(
|
|
721
|
-
`${
|
|
2399
|
+
`${chalk14.bold("\u2502")} Conflicts \u2502 ${String(`${result.resolved} resolved`).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
722
2400
|
);
|
|
723
2401
|
console.log(
|
|
724
|
-
`${
|
|
2402
|
+
`${chalk14.bold("\u2502")} Skipped \u2502 ${String(result.skipped).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
725
2403
|
);
|
|
2404
|
+
if (failedCount > 0) {
|
|
2405
|
+
console.log(
|
|
2406
|
+
`${chalk14.bold("\u2502")} ${chalk14.red("Failed")} \u2502 ${chalk14.red(String(failedCount).padEnd(17))} ${chalk14.bold("\u2502")}`
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
726
2409
|
console.log(
|
|
727
|
-
|
|
2410
|
+
chalk14.bold(
|
|
2411
|
+
"\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"
|
|
2412
|
+
)
|
|
728
2413
|
);
|
|
729
|
-
|
|
2414
|
+
if (failedCount > 0 && result.failedEntities) {
|
|
2415
|
+
console.log();
|
|
2416
|
+
console.log(
|
|
2417
|
+
chalk14.red(
|
|
2418
|
+
`${failedCount} entity sync(s) failed. A checkpoint was saved \u2014 re-run sync to resume.`
|
|
2419
|
+
)
|
|
2420
|
+
);
|
|
2421
|
+
for (const f of result.failedEntities) {
|
|
2422
|
+
console.log(chalk14.dim(` ${f.entityId}: ${f.error}`));
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
730
2425
|
console.log();
|
|
731
2426
|
printSuccess("Sync complete.");
|
|
732
2427
|
}
|
|
733
2428
|
|
|
734
2429
|
// src/commands/validate.ts
|
|
735
|
-
import { relative } from "path";
|
|
736
|
-
import { parseTree, resolveRefs, validateTree } from "@gitpm/core";
|
|
737
|
-
import
|
|
738
|
-
import { Command as
|
|
739
|
-
var validateCommand = new
|
|
2430
|
+
import { relative as relative9 } from "path";
|
|
2431
|
+
import { parseTree as parseTree10, resolveRefs as resolveRefs7, validateTree } from "@gitpm/core";
|
|
2432
|
+
import chalk15 from "chalk";
|
|
2433
|
+
import { Command as Command19 } from "commander";
|
|
2434
|
+
var validateCommand = new Command19("validate").description("Validate the .meta/ project tree").action(async (_opts, cmd) => {
|
|
740
2435
|
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
741
|
-
const parseResult = await
|
|
2436
|
+
const parseResult = await parseTree10(metaDir);
|
|
742
2437
|
if (!parseResult.ok) {
|
|
743
2438
|
printError(parseResult.error.message);
|
|
744
2439
|
process.exit(1);
|
|
@@ -746,28 +2441,28 @@ var validateCommand = new Command6("validate").description("Validate the .meta/
|
|
|
746
2441
|
const tree = parseResult.value;
|
|
747
2442
|
if (tree.errors.length > 0) {
|
|
748
2443
|
for (const err of tree.errors) {
|
|
749
|
-
const rel =
|
|
750
|
-
console.log(
|
|
2444
|
+
const rel = relative9(process.cwd(), err.filePath);
|
|
2445
|
+
console.log(chalk15.red(` \u2717 ${rel}: ${err.message}`));
|
|
751
2446
|
}
|
|
752
2447
|
}
|
|
753
|
-
const resolveResult =
|
|
2448
|
+
const resolveResult = resolveRefs7(tree);
|
|
754
2449
|
if (!resolveResult.ok) {
|
|
755
2450
|
printError(resolveResult.error.message);
|
|
756
2451
|
process.exit(1);
|
|
757
2452
|
}
|
|
758
2453
|
const result = validateTree(resolveResult.value);
|
|
759
2454
|
for (const err of result.errors) {
|
|
760
|
-
const rel = err.filePath ?
|
|
2455
|
+
const rel = err.filePath ? relative9(process.cwd(), err.filePath) : "";
|
|
761
2456
|
const location = rel ? `${rel}: ` : "";
|
|
762
2457
|
const id = err.entityId ? `[${err.entityId}] ` : "";
|
|
763
|
-
console.log(
|
|
2458
|
+
console.log(chalk15.red(` \u2717 ${location}${id}${err.code}: ${err.message}`));
|
|
764
2459
|
}
|
|
765
2460
|
for (const warn of result.warnings) {
|
|
766
|
-
const rel = warn.filePath ?
|
|
2461
|
+
const rel = warn.filePath ? relative9(process.cwd(), warn.filePath) : "";
|
|
767
2462
|
const location = rel ? `${rel}: ` : "";
|
|
768
2463
|
const id = warn.entityId ? `[${warn.entityId}] ` : "";
|
|
769
2464
|
console.log(
|
|
770
|
-
|
|
2465
|
+
chalk15.yellow(` \u26A0 ${location}${id}${warn.code}: ${warn.message}`)
|
|
771
2466
|
);
|
|
772
2467
|
}
|
|
773
2468
|
if (!result.valid) {
|
|
@@ -786,16 +2481,29 @@ var validateCommand = new Command6("validate").description("Validate the .meta/
|
|
|
786
2481
|
});
|
|
787
2482
|
|
|
788
2483
|
// src/index.ts
|
|
789
|
-
var __dirname =
|
|
2484
|
+
var __dirname = dirname4(fileURLToPath(import.meta.url));
|
|
790
2485
|
var pkg = JSON.parse(
|
|
791
|
-
readFileSync(
|
|
2486
|
+
readFileSync(join3(__dirname, "..", "package.json"), "utf-8")
|
|
792
2487
|
);
|
|
793
|
-
var program = new
|
|
2488
|
+
var program = new Command20();
|
|
794
2489
|
program.name("gitpm").description("Git-native project management").version(pkg.version).option("--meta-dir <path>", "Path to .meta directory", ".meta").option("--token <token>", "Personal access token (GitHub or GitLab)");
|
|
795
2490
|
program.addCommand(initCommand);
|
|
796
2491
|
program.addCommand(validateCommand);
|
|
2492
|
+
program.addCommand(qualityCommand);
|
|
2493
|
+
program.addCommand(nextCommand);
|
|
2494
|
+
program.addCommand(queryCommand);
|
|
2495
|
+
program.addCommand(showCommand);
|
|
2496
|
+
program.addCommand(setCommand);
|
|
2497
|
+
program.addCommand(createCommand);
|
|
2498
|
+
program.addCommand(moveCommand);
|
|
2499
|
+
program.addCommand(commitCommand);
|
|
797
2500
|
program.addCommand(importCommand);
|
|
798
2501
|
program.addCommand(pushCommand);
|
|
799
2502
|
program.addCommand(pullCommand);
|
|
800
2503
|
program.addCommand(syncCommand);
|
|
2504
|
+
program.addCommand(statusCommand);
|
|
2505
|
+
program.addCommand(sprintCommand);
|
|
2506
|
+
program.addCommand(auditCommand);
|
|
2507
|
+
program.addCommand(archiveCommand);
|
|
2508
|
+
program.addCommand(contextCommand);
|
|
801
2509
|
program.parse();
|