@gitpm/cli 0.1.0 → 0.1.15
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 +2149 -467
- package/package.json +61 -8
- 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,936 @@ 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
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
const previewSpinner = ora3("Calculating changes...").start();
|
|
1409
|
+
const preview = await adapter.export({
|
|
1410
|
+
token,
|
|
1411
|
+
email,
|
|
1412
|
+
metaDir,
|
|
1413
|
+
teamId: opts.team,
|
|
1414
|
+
apiKey: opts.apiKey,
|
|
1415
|
+
dryRun: true
|
|
1416
|
+
});
|
|
1417
|
+
if (!preview.ok) {
|
|
1418
|
+
previewSpinner.fail("Failed to calculate changes.");
|
|
1419
|
+
printError(preview.error.message);
|
|
1420
|
+
process.exit(1);
|
|
1421
|
+
}
|
|
1422
|
+
previewSpinner.succeed("Changes calculated.");
|
|
1423
|
+
printPreview(preview.value);
|
|
1424
|
+
if (preview.value.totalChanges === 0) {
|
|
1425
|
+
printSuccess("Nothing to push \u2014 already in sync.");
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (!opts.yes) {
|
|
1429
|
+
const confirmed = await confirm({
|
|
1430
|
+
message: `Push these changes to ${adapter.displayName}?`,
|
|
1431
|
+
default: false
|
|
1432
|
+
});
|
|
1433
|
+
if (!confirmed) {
|
|
1434
|
+
console.log(chalk9.dim("Push cancelled."));
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
const preHook = await runHooks3(config, "pre-export", {
|
|
1439
|
+
metaDir,
|
|
1440
|
+
event: "pre-export",
|
|
1441
|
+
adapterName: adapter.name
|
|
1442
|
+
});
|
|
1443
|
+
if (!preHook.ok) {
|
|
1444
|
+
printError(`Pre-export hook failed: ${preHook.error.message}`);
|
|
331
1445
|
process.exit(1);
|
|
332
1446
|
}
|
|
333
|
-
const
|
|
334
|
-
const result = await
|
|
1447
|
+
const pushSpinner = ora3(`Pushing to ${adapter.displayName}...`).start();
|
|
1448
|
+
const result = await adapter.export({
|
|
335
1449
|
token,
|
|
336
|
-
|
|
337
|
-
projectId: config.project_id,
|
|
338
|
-
baseUrl: config.base_url,
|
|
1450
|
+
email,
|
|
339
1451
|
metaDir,
|
|
340
|
-
|
|
1452
|
+
teamId: opts.team,
|
|
1453
|
+
apiKey: opts.apiKey,
|
|
1454
|
+
dryRun: false
|
|
341
1455
|
});
|
|
342
1456
|
if (!result.ok) {
|
|
343
|
-
|
|
1457
|
+
pushSpinner.fail("Push failed.");
|
|
344
1458
|
printError(result.error.message);
|
|
345
1459
|
process.exit(1);
|
|
346
1460
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
1461
|
+
pushSpinner.succeed("Push complete.");
|
|
1462
|
+
const { created, updated } = result.value;
|
|
1463
|
+
console.log(
|
|
1464
|
+
` Created ${created.milestones + created.issues}, Updated ${updated.milestones + updated.issues}`
|
|
1465
|
+
);
|
|
1466
|
+
await runHooks3(config, "post-export", {
|
|
1467
|
+
metaDir,
|
|
1468
|
+
event: "post-export",
|
|
1469
|
+
adapterName: adapter.name,
|
|
1470
|
+
result: result.value
|
|
1471
|
+
});
|
|
1472
|
+
});
|
|
1473
|
+
function printPreview(result) {
|
|
1474
|
+
console.log();
|
|
1475
|
+
console.log(chalk9.bold("Changes to push:"));
|
|
1476
|
+
console.log(` New milestones: ${result.created.milestones}`);
|
|
1477
|
+
console.log(` New issues: ${result.created.issues}`);
|
|
1478
|
+
console.log(` Updated milestones: ${result.updated.milestones}`);
|
|
1479
|
+
console.log(` Updated issues: ${result.updated.issues}`);
|
|
1480
|
+
console.log(` Total changes: ${result.totalChanges}`);
|
|
1481
|
+
console.log();
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/commands/quality.ts
|
|
1485
|
+
import { relative as relative6 } from "path";
|
|
1486
|
+
import {
|
|
1487
|
+
loadQualityConfig,
|
|
1488
|
+
parseTree as parseTree5,
|
|
1489
|
+
resolveRefs as resolveRefs3,
|
|
1490
|
+
scoreEntities
|
|
1491
|
+
} from "@gitpm/core";
|
|
1492
|
+
import chalk10 from "chalk";
|
|
1493
|
+
import { Command as Command12 } from "commander";
|
|
1494
|
+
function gradeColor(grade) {
|
|
1495
|
+
switch (grade) {
|
|
1496
|
+
case "A":
|
|
1497
|
+
return chalk10.green;
|
|
1498
|
+
case "B":
|
|
1499
|
+
return chalk10.cyan;
|
|
1500
|
+
case "C":
|
|
1501
|
+
return chalk10.yellow;
|
|
1502
|
+
case "D":
|
|
1503
|
+
return chalk10.magenta;
|
|
1504
|
+
default:
|
|
1505
|
+
return chalk10.red;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
function gradeForAverage(avg) {
|
|
1509
|
+
if (avg >= 8) return "A";
|
|
1510
|
+
if (avg >= 6) return "B";
|
|
1511
|
+
if (avg >= 4) return "C";
|
|
1512
|
+
if (avg >= 2) return "D";
|
|
1513
|
+
return "F";
|
|
1514
|
+
}
|
|
1515
|
+
function printReport2(report) {
|
|
1516
|
+
console.log();
|
|
1517
|
+
console.log(chalk10.bold(" Entity Quality Report"));
|
|
1518
|
+
console.log(chalk10.dim(` ${"\u2500".repeat(21)}`));
|
|
1519
|
+
const grades = [
|
|
1520
|
+
{ grade: "A", range: "8-9", count: report.distribution.A },
|
|
1521
|
+
{ grade: "B", range: "6-7", count: report.distribution.B },
|
|
1522
|
+
{ grade: "C", range: "4-5", count: report.distribution.C },
|
|
1523
|
+
{ grade: "D", range: "2-3", count: report.distribution.D },
|
|
1524
|
+
{ grade: "F", range: "0-1", count: report.distribution.F }
|
|
1525
|
+
];
|
|
1526
|
+
for (const { grade, range, count } of grades) {
|
|
1527
|
+
const label = `${grade} (${range}):`;
|
|
1528
|
+
const unit = count === 1 ? "entity" : "entities";
|
|
1529
|
+
console.log(` ${gradeColor(grade)(label.padEnd(10))} ${count} ${unit}`);
|
|
1530
|
+
}
|
|
1531
|
+
const avgGrade = gradeForAverage(report.average);
|
|
1532
|
+
console.log();
|
|
1533
|
+
console.log(
|
|
1534
|
+
` Average: ${report.average.toFixed(1)} (${gradeColor(avgGrade)(avgGrade)})`
|
|
1535
|
+
);
|
|
1536
|
+
const lowest = report.entities.filter((e) => e.score < 6).sort((a, b) => a.score - b.score).slice(0, 10);
|
|
1537
|
+
if (lowest.length > 0) {
|
|
1538
|
+
console.log();
|
|
1539
|
+
console.log(chalk10.bold(" Lowest scoring:"));
|
|
1540
|
+
for (const e of lowest) {
|
|
1541
|
+
const rel = relative6(process.cwd(), e.filePath);
|
|
1542
|
+
const color = gradeColor(e.grade);
|
|
1543
|
+
console.log(
|
|
1544
|
+
` ${color(`[${e.grade}]`)} ${e.id} (${e.score}/${e.maxScore}) ${chalk10.dim("\u2014")} ${chalk10.dim(rel)}`
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
console.log();
|
|
1549
|
+
}
|
|
1550
|
+
var qualityCommand = new Command12("quality").description("Score entity quality across the .meta/ tree").option(
|
|
1551
|
+
"--threshold <number>",
|
|
1552
|
+
"Minimum average score (overrides config)",
|
|
1553
|
+
Number.parseFloat
|
|
1554
|
+
).action(async (opts, cmd) => {
|
|
1555
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1556
|
+
const parseResult = await parseTree5(metaDir);
|
|
1557
|
+
if (!parseResult.ok) {
|
|
1558
|
+
printError(parseResult.error.message);
|
|
1559
|
+
process.exit(1);
|
|
1560
|
+
}
|
|
1561
|
+
const resolveResult = resolveRefs3(parseResult.value);
|
|
1562
|
+
if (!resolveResult.ok) {
|
|
1563
|
+
printError(resolveResult.error.message);
|
|
1564
|
+
process.exit(1);
|
|
1565
|
+
}
|
|
1566
|
+
const configResult = await loadQualityConfig(metaDir);
|
|
1567
|
+
if (!configResult.ok) {
|
|
1568
|
+
printError(configResult.error.message);
|
|
1569
|
+
process.exit(1);
|
|
1570
|
+
}
|
|
1571
|
+
const config = configResult.value;
|
|
1572
|
+
const report = scoreEntities(resolveResult.value, config);
|
|
1573
|
+
if (report.entities.length === 0) {
|
|
1574
|
+
console.log(chalk10.dim(" No scorable entities found in .meta/"));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
printReport2(report);
|
|
1578
|
+
const threshold = opts.threshold ?? config?.threshold?.min_average;
|
|
1579
|
+
if (threshold != null && report.average < threshold) {
|
|
1580
|
+
printError(
|
|
1581
|
+
`Average score ${report.average.toFixed(1)} is below threshold ${threshold}`
|
|
1582
|
+
);
|
|
1583
|
+
process.exit(1);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
// src/commands/query.ts
|
|
1588
|
+
import { filterEntities, formatEntities, parseTree as parseTree6 } from "@gitpm/core";
|
|
1589
|
+
import { Command as Command13 } from "commander";
|
|
1590
|
+
var DEFAULT_FIELDS = [
|
|
1591
|
+
"id",
|
|
1592
|
+
"title",
|
|
1593
|
+
"type",
|
|
1594
|
+
"status",
|
|
1595
|
+
"priority",
|
|
1596
|
+
"filePath"
|
|
1597
|
+
];
|
|
1598
|
+
var queryCommand = new Command13("query").description("Query and filter .meta/ entities").option(
|
|
1599
|
+
"--type <types>",
|
|
1600
|
+
"Entity types (comma-separated: story,epic,milestone,prd)"
|
|
1601
|
+
).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(
|
|
1602
|
+
"--fields <fields>",
|
|
1603
|
+
"Fields to display (comma-separated)",
|
|
1604
|
+
DEFAULT_FIELDS.join(",")
|
|
1605
|
+
).option("--format <format>", "Output format: table, json, csv", "table").action(async (opts, cmd) => {
|
|
1606
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1607
|
+
const parseResult = await parseTree6(metaDir);
|
|
1608
|
+
if (!parseResult.ok) {
|
|
1609
|
+
printError(parseResult.error.message);
|
|
1610
|
+
process.exit(1);
|
|
1611
|
+
}
|
|
1612
|
+
const filter = {};
|
|
1613
|
+
if (opts.type) {
|
|
1614
|
+
filter.type = opts.type.split(",").map((s) => s.trim());
|
|
1615
|
+
}
|
|
1616
|
+
if (opts.status) {
|
|
1617
|
+
filter.status = opts.status.split(",").map((s) => s.trim());
|
|
1618
|
+
}
|
|
1619
|
+
if (opts.priority) {
|
|
1620
|
+
filter.priority = opts.priority.split(",").map((s) => s.trim());
|
|
1621
|
+
}
|
|
1622
|
+
if (opts.label) {
|
|
1623
|
+
filter.labels = opts.label.split(",").map((s) => s.trim());
|
|
1624
|
+
}
|
|
1625
|
+
if (opts.epic) {
|
|
1626
|
+
filter.epic = opts.epic;
|
|
1627
|
+
}
|
|
1628
|
+
if (opts.assignee) {
|
|
1629
|
+
filter.assignee = opts.assignee;
|
|
1630
|
+
}
|
|
1631
|
+
if (opts.search) {
|
|
1632
|
+
filter.search = opts.search;
|
|
1633
|
+
}
|
|
1634
|
+
const entities = filterEntities(parseResult.value, filter);
|
|
1635
|
+
const fields = opts.fields.split(",").map((s) => s.trim());
|
|
1636
|
+
const output = formatEntities(entities, { fields, format: opts.format });
|
|
1637
|
+
console.log(output);
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
// src/commands/set.ts
|
|
1641
|
+
import { resolve as resolve5 } from "path";
|
|
1642
|
+
import {
|
|
1643
|
+
applyAssignments,
|
|
1644
|
+
parseAssignment,
|
|
1645
|
+
parseFile,
|
|
1646
|
+
writeFile as writeFile4
|
|
1647
|
+
} from "@gitpm/core";
|
|
1648
|
+
import { Command as Command14 } from "commander";
|
|
1649
|
+
var setCommand = new Command14("set").description("Update frontmatter fields on a .meta/ entity").argument("<file>", "Path to the entity file").argument(
|
|
1650
|
+
"<assignments...>",
|
|
1651
|
+
"Field assignments (field=value, field+=value, field-=value)"
|
|
1652
|
+
).action(async (file, assignments) => {
|
|
1653
|
+
const filePath = resolve5(process.cwd(), file);
|
|
1654
|
+
const parseResult = await parseFile(filePath);
|
|
1655
|
+
if (!parseResult.ok) {
|
|
1656
|
+
printError(parseResult.error.message);
|
|
1657
|
+
process.exit(1);
|
|
1658
|
+
}
|
|
1659
|
+
const parsed = [];
|
|
1660
|
+
for (const expr of assignments) {
|
|
1661
|
+
const result = parseAssignment(expr);
|
|
1662
|
+
if (!result.ok) {
|
|
1663
|
+
printError(result.error.message);
|
|
1664
|
+
process.exit(1);
|
|
1665
|
+
}
|
|
1666
|
+
parsed.push(result.value);
|
|
1667
|
+
}
|
|
1668
|
+
const applyResult = applyAssignments(parseResult.value, parsed);
|
|
1669
|
+
if (!applyResult.ok) {
|
|
1670
|
+
printError(applyResult.error.message);
|
|
1671
|
+
process.exit(1);
|
|
1672
|
+
}
|
|
1673
|
+
const writeResult = await writeFile4(applyResult.value, filePath);
|
|
1674
|
+
if (!writeResult.ok) {
|
|
1675
|
+
printError(writeResult.error.message);
|
|
1676
|
+
process.exit(1);
|
|
1677
|
+
}
|
|
1678
|
+
printSuccess(`Updated ${file}`);
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
// src/commands/show.ts
|
|
1682
|
+
import { relative as relative7, resolve as resolve6 } from "path";
|
|
1683
|
+
import { parseTree as parseTree7, resolveRefs as resolveRefs4 } from "@gitpm/core";
|
|
1684
|
+
import chalk11 from "chalk";
|
|
1685
|
+
import { Command as Command15 } from "commander";
|
|
1686
|
+
function formatFieldValue(key, value) {
|
|
1687
|
+
if (value === null || value === void 0) return chalk11.dim("\u2014");
|
|
1688
|
+
if (Array.isArray(value)) {
|
|
1689
|
+
return value.length > 0 ? value.join(", ") : chalk11.dim("[]");
|
|
1690
|
+
}
|
|
1691
|
+
if (typeof value === "object") {
|
|
1692
|
+
return JSON.stringify(value);
|
|
1693
|
+
}
|
|
1694
|
+
if (key === "priority") {
|
|
1695
|
+
const p = String(value);
|
|
1696
|
+
switch (p) {
|
|
1697
|
+
case "critical":
|
|
1698
|
+
return chalk11.red(p);
|
|
1699
|
+
case "high":
|
|
1700
|
+
return chalk11.yellow(p);
|
|
1701
|
+
case "medium":
|
|
1702
|
+
return chalk11.blue(p);
|
|
1703
|
+
default:
|
|
1704
|
+
return chalk11.dim(p);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
if (key === "status") {
|
|
1708
|
+
const s = String(value);
|
|
1709
|
+
switch (s) {
|
|
1710
|
+
case "done":
|
|
1711
|
+
return chalk11.green(s);
|
|
1712
|
+
case "in_progress":
|
|
1713
|
+
case "in_review":
|
|
1714
|
+
return chalk11.cyan(s);
|
|
1715
|
+
case "cancelled":
|
|
1716
|
+
return chalk11.strikethrough(chalk11.dim(s));
|
|
1717
|
+
default:
|
|
1718
|
+
return s;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return String(value);
|
|
1722
|
+
}
|
|
1723
|
+
var RESOLVED_KEYS = /* @__PURE__ */ new Set([
|
|
1724
|
+
"resolvedStories",
|
|
1725
|
+
"resolvedEpic",
|
|
1726
|
+
"resolvedEpics",
|
|
1727
|
+
"resolvedMilestone",
|
|
1728
|
+
"resolvedMilestones",
|
|
1729
|
+
"resolvedPrds"
|
|
1730
|
+
]);
|
|
1731
|
+
function displayEntity(entity, full) {
|
|
1732
|
+
const { filePath, body, ...frontmatter } = entity;
|
|
1733
|
+
console.log(chalk11.bold(entity.title));
|
|
1734
|
+
console.log(chalk11.dim(relative7(process.cwd(), entity.filePath)));
|
|
1735
|
+
console.log();
|
|
1736
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
1737
|
+
if (key === "title" || RESOLVED_KEYS.has(key)) continue;
|
|
1738
|
+
console.log(` ${chalk11.dim(`${key}:`)} ${formatFieldValue(key, value)}`);
|
|
1739
|
+
}
|
|
1740
|
+
if (full && typeof body === "string" && body.trim()) {
|
|
1741
|
+
console.log();
|
|
1742
|
+
console.log(body);
|
|
1743
|
+
}
|
|
1744
|
+
console.log();
|
|
1745
|
+
}
|
|
1746
|
+
function displayStoryRow(story) {
|
|
1747
|
+
const pri = story.priority;
|
|
1748
|
+
let priBullet;
|
|
1749
|
+
switch (pri) {
|
|
1750
|
+
case "critical":
|
|
1751
|
+
priBullet = chalk11.red("\u25CF");
|
|
1752
|
+
break;
|
|
1753
|
+
case "high":
|
|
1754
|
+
priBullet = chalk11.yellow("\u25CF");
|
|
1755
|
+
break;
|
|
1756
|
+
case "medium":
|
|
1757
|
+
priBullet = chalk11.blue("\u25CF");
|
|
1758
|
+
break;
|
|
1759
|
+
default:
|
|
1760
|
+
priBullet = chalk11.dim("\u25CF");
|
|
1761
|
+
}
|
|
1762
|
+
const status = chalk11.dim(`[${story.status}]`);
|
|
1763
|
+
const id = chalk11.dim(`(${story.id})`);
|
|
1764
|
+
console.log(` ${priBullet} ${status} ${story.title} ${id}`);
|
|
1765
|
+
}
|
|
1766
|
+
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(
|
|
1767
|
+
"--milestone <milestone>",
|
|
1768
|
+
"Show a milestone and linked epics (by ID)"
|
|
1769
|
+
).option("--full", "Include body content", false).option("--format <format>", "Output format: pretty, json", "pretty").action(async (target, opts, cmd) => {
|
|
1770
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1771
|
+
const parseResult = await parseTree7(metaDir);
|
|
1772
|
+
if (!parseResult.ok) {
|
|
1773
|
+
printError(parseResult.error.message);
|
|
1774
|
+
process.exit(1);
|
|
1775
|
+
}
|
|
1776
|
+
const tree = parseResult.value;
|
|
1777
|
+
if (!opts.epic && !opts.milestone) {
|
|
1778
|
+
if (!target) {
|
|
1779
|
+
printError("Specify a target, --epic, or --milestone");
|
|
1780
|
+
process.exit(1);
|
|
1781
|
+
}
|
|
1782
|
+
const entity = findEntity(tree, target);
|
|
1783
|
+
if (!entity) {
|
|
1784
|
+
printError(`Entity not found: ${target}`);
|
|
1785
|
+
process.exit(1);
|
|
1786
|
+
}
|
|
1787
|
+
if (opts.format === "json") {
|
|
1788
|
+
console.log(JSON.stringify(entity, null, 2));
|
|
1789
|
+
} else {
|
|
1790
|
+
displayEntity(entity, opts.full);
|
|
1791
|
+
}
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
const resolveResult = resolveRefs4(tree);
|
|
1795
|
+
if (!resolveResult.ok) {
|
|
1796
|
+
printError(resolveResult.error.message);
|
|
1797
|
+
process.exit(1);
|
|
1798
|
+
}
|
|
1799
|
+
const resolved = resolveResult.value;
|
|
1800
|
+
if (opts.epic) {
|
|
1801
|
+
const epic = findEpic(resolved, opts.epic);
|
|
1802
|
+
if (!epic) {
|
|
1803
|
+
printError(`Epic not found: ${opts.epic}`);
|
|
1804
|
+
process.exit(1);
|
|
1805
|
+
}
|
|
1806
|
+
if (opts.format === "json") {
|
|
1807
|
+
console.log(
|
|
1808
|
+
JSON.stringify({ epic, stories: epic.resolvedStories }, null, 2)
|
|
1809
|
+
);
|
|
1810
|
+
} else {
|
|
1811
|
+
displayEntity(epic, opts.full);
|
|
1812
|
+
if (epic.resolvedStories.length > 0) {
|
|
1813
|
+
console.log(chalk11.bold(`Stories (${epic.resolvedStories.length}):`));
|
|
1814
|
+
for (const story of epic.resolvedStories) {
|
|
1815
|
+
displayStoryRow(story);
|
|
1816
|
+
}
|
|
1817
|
+
console.log();
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
if (opts.milestone) {
|
|
1823
|
+
const ms = findMilestone(resolved, opts.milestone);
|
|
1824
|
+
if (!ms) {
|
|
1825
|
+
printError(`Milestone not found: ${opts.milestone}`);
|
|
1826
|
+
process.exit(1);
|
|
1827
|
+
}
|
|
1828
|
+
if (opts.format === "json") {
|
|
1829
|
+
console.log(
|
|
1830
|
+
JSON.stringify({ milestone: ms, epics: ms.resolvedEpics }, null, 2)
|
|
1831
|
+
);
|
|
1832
|
+
} else {
|
|
1833
|
+
displayEntity(ms, opts.full);
|
|
1834
|
+
if (ms.resolvedEpics.length > 0) {
|
|
1835
|
+
console.log(chalk11.bold(`Epics (${ms.resolvedEpics.length}):`));
|
|
1836
|
+
for (const epic of ms.resolvedEpics) {
|
|
1837
|
+
displayStoryRow(epic);
|
|
1838
|
+
}
|
|
1839
|
+
console.log();
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
function findEpic(resolved, epicRef) {
|
|
1846
|
+
return resolved.epics.find(
|
|
1847
|
+
(e) => e.id === epicRef || e.filePath.includes(`/epics/${epicRef}/`)
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
function findMilestone(resolved, milestoneRef) {
|
|
1851
|
+
return resolved.milestones.find((m) => m.id === milestoneRef);
|
|
1852
|
+
}
|
|
1853
|
+
function findEntity(tree, target) {
|
|
1854
|
+
const all = [
|
|
1855
|
+
...tree.stories,
|
|
1856
|
+
...tree.epics,
|
|
1857
|
+
...tree.milestones,
|
|
1858
|
+
...tree.roadmaps,
|
|
1859
|
+
...tree.prds
|
|
1860
|
+
];
|
|
1861
|
+
const resolved = resolve6(process.cwd(), target);
|
|
1862
|
+
const byPath = all.find((e) => e.filePath === resolved);
|
|
1863
|
+
if (byPath) return byPath;
|
|
1864
|
+
return all.find((e) => e.id === target);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// src/commands/sprint.ts
|
|
1868
|
+
import { relative as relative8, resolve as resolvePath } from "path";
|
|
1869
|
+
import {
|
|
1870
|
+
createSprint,
|
|
1871
|
+
parseTree as parseTree8,
|
|
1872
|
+
resolveRefs as resolveRefs5,
|
|
1873
|
+
writeFile as writeFile5
|
|
1874
|
+
} from "@gitpm/core";
|
|
1875
|
+
import { checkbox, confirm as confirm2 } from "@inquirer/prompts";
|
|
1876
|
+
import chalk12 from "chalk";
|
|
1877
|
+
import { Command as Command16 } from "commander";
|
|
1878
|
+
function sprintProgress(sprint) {
|
|
1879
|
+
const total = sprint.resolvedStories.length;
|
|
1880
|
+
const done = sprint.resolvedStories.filter(
|
|
1881
|
+
(s) => s.status === "done" || s.status === "cancelled"
|
|
1882
|
+
).length;
|
|
1883
|
+
return { done, total, ratio: total > 0 ? done / total : 0 };
|
|
1884
|
+
}
|
|
1885
|
+
function isPickableStory(story) {
|
|
1886
|
+
return story.status === "todo" || story.status === "backlog";
|
|
1887
|
+
}
|
|
1888
|
+
function findSprintByIdOrPath(sprints, idOrPath) {
|
|
1889
|
+
const looksLikePath = idOrPath.includes("/") || idOrPath.endsWith(".md");
|
|
1890
|
+
if (looksLikePath) {
|
|
1891
|
+
const target = resolvePath(process.cwd(), idOrPath);
|
|
1892
|
+
return sprints.find((s) => resolvePath(s.filePath) === target);
|
|
1893
|
+
}
|
|
1894
|
+
return sprints.find((s) => s.id === idOrPath);
|
|
1895
|
+
}
|
|
1896
|
+
function printPointsSummary(totalPoints, capacity) {
|
|
1897
|
+
if (capacity === void 0) {
|
|
1898
|
+
console.log(chalk12.dim(` Points: ${totalPoints} pts (no capacity set)`));
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const ratio = capacity > 0 ? totalPoints / capacity : totalPoints > 0 ? 1 : 0;
|
|
1902
|
+
const bar = progressBar(Math.min(ratio, 1), 16);
|
|
1903
|
+
const label = `${totalPoints}/${capacity} pts`;
|
|
1904
|
+
if (totalPoints > capacity) {
|
|
1905
|
+
console.log(` ${bar} ${chalk12.red(`${label} \u2014 over capacity`)}`);
|
|
1906
|
+
} else {
|
|
1907
|
+
console.log(` ${bar} ${label}`);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
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) => {
|
|
1911
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1912
|
+
const result = await createSprint(metaDir, {
|
|
1913
|
+
title: opts.title,
|
|
1914
|
+
startDate: opts.start,
|
|
1915
|
+
endDate: opts.end,
|
|
1916
|
+
capacity: opts.capacity
|
|
1917
|
+
});
|
|
1918
|
+
if (!result.ok) {
|
|
1919
|
+
printError(result.error.message);
|
|
1920
|
+
process.exit(1);
|
|
1921
|
+
}
|
|
1922
|
+
const rel = relative8(process.cwd(), result.value.filePath);
|
|
1923
|
+
printSuccess(`Created sprint: ${rel} (${result.value.id})`);
|
|
1924
|
+
});
|
|
1925
|
+
var sprintListCommand = new Command16("list").description("List all sprints with progress").option("--json", "Output as JSON").action(async (opts, cmd) => {
|
|
1926
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
1927
|
+
const parseResult = await parseTree8(metaDir);
|
|
1928
|
+
if (!parseResult.ok) {
|
|
1929
|
+
printError(parseResult.error.message);
|
|
1930
|
+
process.exit(1);
|
|
1931
|
+
}
|
|
1932
|
+
const resolveResult = resolveRefs5(parseResult.value);
|
|
1933
|
+
if (!resolveResult.ok) {
|
|
1934
|
+
printError(resolveResult.error.message);
|
|
1935
|
+
process.exit(1);
|
|
1936
|
+
}
|
|
1937
|
+
const sprints = resolveResult.value.sprints;
|
|
1938
|
+
if (opts.json) {
|
|
1939
|
+
const data = sprints.map((sp) => {
|
|
1940
|
+
const { done, total, ratio } = sprintProgress(sp);
|
|
1941
|
+
return {
|
|
1942
|
+
id: sp.id,
|
|
1943
|
+
title: sp.title,
|
|
1944
|
+
status: sp.status,
|
|
1945
|
+
start_date: sp.start_date,
|
|
1946
|
+
end_date: sp.end_date,
|
|
1947
|
+
capacity: sp.capacity,
|
|
1948
|
+
stories: total,
|
|
1949
|
+
done,
|
|
1950
|
+
progress: ratio
|
|
1951
|
+
};
|
|
1952
|
+
});
|
|
1953
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (sprints.length === 0) {
|
|
354
1957
|
console.log(
|
|
355
|
-
|
|
1958
|
+
chalk12.dim(" No sprints found. Create one with: gitpm sprint create")
|
|
356
1959
|
);
|
|
357
|
-
|
|
358
|
-
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
console.log();
|
|
1963
|
+
console.log(chalk12.bold(" Sprints"));
|
|
1964
|
+
console.log(chalk12.dim(` ${"\u2500".repeat(7)}`));
|
|
1965
|
+
for (const sp of sprints) {
|
|
1966
|
+
const { done, total, ratio } = sprintProgress(sp);
|
|
1967
|
+
const pct3 = Math.round(ratio * 100);
|
|
1968
|
+
const dateRange = chalk12.dim(
|
|
1969
|
+
`${sp.start_date.slice(0, 10)} \u2192 ${sp.end_date.slice(0, 10)}`
|
|
359
1970
|
);
|
|
1971
|
+
const cap = sp.capacity ? chalk12.dim(` (cap: ${sp.capacity}pts)`) : "";
|
|
360
1972
|
console.log(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1973
|
+
` ${progressBar(ratio, 16)} ${pct3}% ${sp.title} ${dateRange}${cap}`
|
|
1974
|
+
);
|
|
1975
|
+
console.log(
|
|
1976
|
+
` ${" ".repeat(16)} ${chalk12.dim(`${done}/${total} stories`)} ${chalk12.cyan(sp.status)}`
|
|
364
1977
|
);
|
|
365
1978
|
}
|
|
366
1979
|
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) => {
|
|
1980
|
+
});
|
|
1981
|
+
var sprintShowCommand = new Command16("show").description("Show sprint details").argument("<sprint-id>", "Sprint ID").action(async (sprintId, _opts, cmd) => {
|
|
389
1982
|
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
|
-
);
|
|
1983
|
+
const parseResult = await parseTree8(metaDir);
|
|
1984
|
+
if (!parseResult.ok) {
|
|
1985
|
+
printError(parseResult.error.message);
|
|
400
1986
|
process.exit(1);
|
|
401
1987
|
}
|
|
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.");
|
|
1988
|
+
const resolveResult = resolveRefs5(parseResult.value);
|
|
1989
|
+
if (!resolveResult.ok) {
|
|
1990
|
+
printError(resolveResult.error.message);
|
|
409
1991
|
process.exit(1);
|
|
410
1992
|
}
|
|
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);
|
|
1993
|
+
const sprint = resolveResult.value.sprints.find((s) => s.id === sprintId);
|
|
1994
|
+
if (!sprint) {
|
|
1995
|
+
printError(`Sprint not found: ${sprintId}`);
|
|
433
1996
|
process.exit(1);
|
|
434
1997
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1998
|
+
const { done, total, ratio } = sprintProgress(sprint);
|
|
1999
|
+
const pct3 = Math.round(ratio * 100);
|
|
2000
|
+
console.log();
|
|
2001
|
+
console.log(chalk12.bold(` ${sprint.title}`));
|
|
2002
|
+
console.log(chalk12.dim(` ${"\u2500".repeat(sprint.title.length)}`));
|
|
2003
|
+
console.log(` Status: ${chalk12.cyan(sprint.status)}`);
|
|
2004
|
+
console.log(
|
|
2005
|
+
` Period: ${sprint.start_date.slice(0, 10)} \u2192 ${sprint.end_date.slice(0, 10)}`
|
|
2006
|
+
);
|
|
2007
|
+
if (sprint.capacity) console.log(` Capacity: ${sprint.capacity} pts`);
|
|
2008
|
+
console.log(
|
|
2009
|
+
` Progress: ${progressBar(ratio, 16)} ${pct3}% (${done}/${total})`
|
|
2010
|
+
);
|
|
2011
|
+
console.log();
|
|
2012
|
+
if (sprint.resolvedStories.length > 0) {
|
|
2013
|
+
console.log(chalk12.bold(" Stories:"));
|
|
2014
|
+
for (const story of sprint.resolvedStories) {
|
|
2015
|
+
const statusColor = story.status === "done" ? chalk12.green : story.status === "in_progress" ? chalk12.yellow : chalk12.dim;
|
|
2016
|
+
console.log(
|
|
2017
|
+
` ${statusColor("\u25CF")} ${story.title} ${chalk12.dim(`[${story.status}]`)}`
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
} else {
|
|
2021
|
+
console.log(chalk12.dim(" No stories assigned."));
|
|
440
2022
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
2023
|
+
console.log();
|
|
2024
|
+
});
|
|
2025
|
+
var sprintPlanCommand = new Command16("plan").description(
|
|
2026
|
+
"Plan a sprint by picking stories to include (interactively, or via --add/--remove)"
|
|
2027
|
+
).argument("<sprint>", "Sprint ID or file path").option(
|
|
2028
|
+
"--add <story-id...>",
|
|
2029
|
+
"Add story IDs to the sprint (non-interactive)"
|
|
2030
|
+
).option(
|
|
2031
|
+
"--remove <story-id...>",
|
|
2032
|
+
"Remove story IDs from the sprint (non-interactive)"
|
|
2033
|
+
).action(
|
|
2034
|
+
async (sprintArg, opts, cmd) => {
|
|
2035
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
2036
|
+
const parseResult = await parseTree8(metaDir);
|
|
2037
|
+
if (!parseResult.ok) {
|
|
2038
|
+
printError(parseResult.error.message);
|
|
2039
|
+
process.exit(1);
|
|
2040
|
+
}
|
|
2041
|
+
const resolveResult = resolveRefs5(parseResult.value);
|
|
2042
|
+
if (!resolveResult.ok) {
|
|
2043
|
+
printError(resolveResult.error.message);
|
|
2044
|
+
process.exit(1);
|
|
2045
|
+
}
|
|
2046
|
+
const sprint = findSprintByIdOrPath(
|
|
2047
|
+
resolveResult.value.sprints,
|
|
2048
|
+
sprintArg
|
|
2049
|
+
);
|
|
2050
|
+
if (!sprint) {
|
|
2051
|
+
printError(`Sprint not found: ${sprintArg}`);
|
|
2052
|
+
process.exit(1);
|
|
2053
|
+
}
|
|
2054
|
+
const allStories = resolveResult.value.stories;
|
|
2055
|
+
const storyById = new Map(allStories.map((s) => [s.id, s]));
|
|
2056
|
+
const currentIds = new Set(sprint.stories.map((ref) => ref.id));
|
|
2057
|
+
const otherSprintsByStory = /* @__PURE__ */ new Map();
|
|
2058
|
+
for (const other of resolveResult.value.sprints) {
|
|
2059
|
+
if (other.id === sprint.id) continue;
|
|
2060
|
+
if (other.status === "done" || other.status === "cancelled") continue;
|
|
2061
|
+
for (const ref of other.stories) {
|
|
2062
|
+
const titles = otherSprintsByStory.get(ref.id) ?? [];
|
|
2063
|
+
if (!titles.includes(other.title)) titles.push(other.title);
|
|
2064
|
+
otherSprintsByStory.set(ref.id, titles);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
const pointsFor = (ids) => [...ids].reduce(
|
|
2068
|
+
(sum, id) => sum + (storyById.get(id)?.estimate ?? 0),
|
|
2069
|
+
0
|
|
2070
|
+
);
|
|
2071
|
+
const warnAlreadyPlanned = (ids) => {
|
|
2072
|
+
for (const id of ids) {
|
|
2073
|
+
if (currentIds.has(id)) continue;
|
|
2074
|
+
const others = otherSprintsByStory.get(id);
|
|
2075
|
+
if (others) {
|
|
2076
|
+
const title = storyById.get(id)?.title ?? id;
|
|
2077
|
+
const names = others.map((t) => `"${t}"`).join(", ");
|
|
2078
|
+
printWarning(
|
|
2079
|
+
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.`
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
let nextIds;
|
|
2085
|
+
const nonInteractive = Boolean(opts.add?.length || opts.remove?.length);
|
|
2086
|
+
if (nonInteractive) {
|
|
2087
|
+
const addIds = opts.add ?? [];
|
|
2088
|
+
const unknownIds = addIds.filter((id) => !storyById.has(id));
|
|
2089
|
+
if (unknownIds.length > 0) {
|
|
2090
|
+
printError(
|
|
2091
|
+
`Unknown story ID${unknownIds.length > 1 ? "s" : ""}: ${unknownIds.join(", ")}`
|
|
2092
|
+
);
|
|
2093
|
+
process.exit(1);
|
|
2094
|
+
}
|
|
2095
|
+
nextIds = new Set(currentIds);
|
|
2096
|
+
for (const id of addIds) nextIds.add(id);
|
|
2097
|
+
for (const id of opts.remove ?? []) nextIds.delete(id);
|
|
2098
|
+
warnAlreadyPlanned(nextIds);
|
|
2099
|
+
} else {
|
|
2100
|
+
const pickable = allStories.filter(isPickableStory);
|
|
2101
|
+
const untouchedIds = new Set(
|
|
2102
|
+
[...currentIds].filter(
|
|
2103
|
+
(id) => !storyById.has(id) || !isPickableStory(storyById.get(id))
|
|
2104
|
+
)
|
|
2105
|
+
);
|
|
2106
|
+
if (pickable.length === 0) {
|
|
2107
|
+
console.log(
|
|
2108
|
+
chalk12.dim(" No todo/backlog stories available to plan.")
|
|
2109
|
+
);
|
|
2110
|
+
nextIds = currentIds;
|
|
2111
|
+
} else {
|
|
2112
|
+
const selected = await checkbox({
|
|
2113
|
+
message: `Select stories for "${sprint.title}"`,
|
|
2114
|
+
choices: pickable.map((story) => {
|
|
2115
|
+
const others = otherSprintsByStory.get(story.id);
|
|
2116
|
+
const inOthers = others ? ` ${chalk12.yellow(`(in ${others.join(", ")})`)}` : "";
|
|
2117
|
+
return {
|
|
2118
|
+
name: `${story.title} ${chalk12.dim(`[${story.priority}]`)} ${chalk12.dim(`(${story.estimate ?? "?"} pts)`)}${inOthers}`,
|
|
2119
|
+
value: story.id,
|
|
2120
|
+
checked: currentIds.has(story.id)
|
|
2121
|
+
};
|
|
2122
|
+
})
|
|
2123
|
+
});
|
|
2124
|
+
nextIds = /* @__PURE__ */ new Set([...untouchedIds, ...selected]);
|
|
2125
|
+
warnAlreadyPlanned(nextIds);
|
|
2126
|
+
console.log();
|
|
2127
|
+
printPointsSummary(pointsFor(nextIds), sprint.capacity);
|
|
2128
|
+
const proceed = await confirm2({
|
|
2129
|
+
message: `Save this selection to "${sprint.title}"?`,
|
|
2130
|
+
default: true
|
|
2131
|
+
});
|
|
2132
|
+
if (!proceed) {
|
|
2133
|
+
console.log(chalk12.dim(" Cancelled \u2014 no changes written."));
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
const totalPoints = pointsFor(nextIds);
|
|
2139
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2140
|
+
const keptRefs = sprint.stories.filter((ref) => {
|
|
2141
|
+
if (!nextIds.has(ref.id) || seenIds.has(ref.id)) return false;
|
|
2142
|
+
seenIds.add(ref.id);
|
|
2143
|
+
return true;
|
|
445
2144
|
});
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
2145
|
+
const addedRefs = [...nextIds].filter((id) => !currentIds.has(id)).map((id) => ({ id }));
|
|
2146
|
+
const { resolvedStories: _resolvedStories, ...sprintBase } = sprint;
|
|
2147
|
+
const updatedSprint = {
|
|
2148
|
+
...sprintBase,
|
|
2149
|
+
stories: [...keptRefs, ...addedRefs],
|
|
2150
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2151
|
+
};
|
|
2152
|
+
const writeResult = await writeFile5(updatedSprint, sprint.filePath);
|
|
2153
|
+
if (!writeResult.ok) {
|
|
2154
|
+
printError(writeResult.error.message);
|
|
2155
|
+
process.exit(1);
|
|
449
2156
|
}
|
|
2157
|
+
console.log();
|
|
2158
|
+
printSuccess(
|
|
2159
|
+
`Updated sprint "${sprint.title}" (${nextIds.size} ${nextIds.size === 1 ? "story" : "stories"})`
|
|
2160
|
+
);
|
|
2161
|
+
printPointsSummary(totalPoints, sprint.capacity);
|
|
2162
|
+
console.log();
|
|
450
2163
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
2164
|
+
);
|
|
2165
|
+
var sprintCommand = new Command16("sprint").description("Manage sprints (time-boxed iterations)").addCommand(sprintCreateCommand).addCommand(sprintListCommand).addCommand(sprintShowCommand).addCommand(sprintPlanCommand);
|
|
2166
|
+
|
|
2167
|
+
// src/commands/status.ts
|
|
2168
|
+
import {
|
|
2169
|
+
computeProjectProgress,
|
|
2170
|
+
parseTree as parseTree9,
|
|
2171
|
+
resolveRefs as resolveRefs6
|
|
2172
|
+
} from "@gitpm/core";
|
|
2173
|
+
import chalk13 from "chalk";
|
|
2174
|
+
import { Command as Command17 } from "commander";
|
|
2175
|
+
function pct2(ratio) {
|
|
2176
|
+
return `${Math.round(ratio * 100)}%`;
|
|
2177
|
+
}
|
|
2178
|
+
function printProjectProgress(progress) {
|
|
2179
|
+
console.log();
|
|
2180
|
+
console.log(chalk13.bold(" Project Status"));
|
|
2181
|
+
console.log(chalk13.dim(` ${"\u2500".repeat(14)}`));
|
|
2182
|
+
const { overall } = progress;
|
|
460
2183
|
console.log(
|
|
461
|
-
`
|
|
2184
|
+
` ${progressBar(overall.progress, 20)} ${pct2(overall.progress)} (${overall.done}/${overall.total} stories)`
|
|
462
2185
|
);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
"No GitLab token found. Provide via --token flag or GITLAB_TOKEN env var."
|
|
2186
|
+
console.log();
|
|
2187
|
+
for (const ms of progress.milestones) {
|
|
2188
|
+
const dateStr = ms.targetDate ? chalk13.dim(` \u2014 due ${ms.targetDate.slice(0, 10)}`) : "";
|
|
2189
|
+
console.log(
|
|
2190
|
+
chalk13.bold(` \u{1F4CC} ${ms.title}`) + dateStr + chalk13.dim(` (${pct2(ms.progress)})`)
|
|
469
2191
|
);
|
|
470
|
-
|
|
2192
|
+
for (const ep of ms.epics) {
|
|
2193
|
+
const counts = chalk13.dim(
|
|
2194
|
+
`${ep.done}/${ep.total} done` + (ep.inProgress > 0 ? `, ${ep.inProgress} active` : "") + (ep.blocked > 0 ? `, ${ep.blocked} blocked` : "")
|
|
2195
|
+
);
|
|
2196
|
+
console.log(
|
|
2197
|
+
` ${progressBar(ep.progress, 16)} ${pct2(ep.progress)} ${ep.title}`
|
|
2198
|
+
);
|
|
2199
|
+
console.log(` ${" ".repeat(16)} ${counts}`);
|
|
2200
|
+
}
|
|
2201
|
+
if (ms.epics.length === 0) {
|
|
2202
|
+
console.log(chalk13.dim(" (no epics linked)"));
|
|
2203
|
+
}
|
|
2204
|
+
console.log();
|
|
471
2205
|
}
|
|
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);
|
|
2206
|
+
if (progress.orphanEpics.length > 0) {
|
|
2207
|
+
console.log(chalk13.bold(" Unlinked Epics"));
|
|
2208
|
+
for (const ep of progress.orphanEpics) {
|
|
2209
|
+
const counts = chalk13.dim(`${ep.done}/${ep.total} done`);
|
|
2210
|
+
console.log(
|
|
2211
|
+
` ${progressBar(ep.progress, 16)} ${pct2(ep.progress)} ${ep.title} ${counts}`
|
|
2212
|
+
);
|
|
486
2213
|
}
|
|
487
|
-
|
|
488
|
-
printPreview(result2.value);
|
|
489
|
-
return;
|
|
2214
|
+
console.log();
|
|
490
2215
|
}
|
|
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);
|
|
2216
|
+
}
|
|
2217
|
+
var statusCommand = new Command17("status").description("Show project progress across milestones and epics").option("--json", "Output as JSON").action(async (opts, cmd) => {
|
|
2218
|
+
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
2219
|
+
const parseResult = await parseTree9(metaDir);
|
|
2220
|
+
if (!parseResult.ok) {
|
|
2221
|
+
printError(parseResult.error.message);
|
|
503
2222
|
process.exit(1);
|
|
504
2223
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
return;
|
|
2224
|
+
const resolveResult = resolveRefs6(parseResult.value);
|
|
2225
|
+
if (!resolveResult.ok) {
|
|
2226
|
+
printError(resolveResult.error.message);
|
|
2227
|
+
process.exit(1);
|
|
510
2228
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
});
|
|
516
|
-
if (!confirmed) {
|
|
517
|
-
console.log(chalk6.dim("Push cancelled."));
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
2229
|
+
const progress = computeProjectProgress(resolveResult.value);
|
|
2230
|
+
if (opts.json) {
|
|
2231
|
+
console.log(JSON.stringify(progress, null, 2));
|
|
2232
|
+
return;
|
|
520
2233
|
}
|
|
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);
|
|
2234
|
+
if (progress.overall.total === 0) {
|
|
2235
|
+
console.log(chalk13.dim(" No stories found in .meta/"));
|
|
2236
|
+
return;
|
|
534
2237
|
}
|
|
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
|
-
}
|
|
2238
|
+
printProjectProgress(progress);
|
|
2239
|
+
});
|
|
551
2240
|
|
|
552
2241
|
// 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";
|
|
2242
|
+
import { runHooks as runHooks4 } from "@gitpm/core";
|
|
2243
|
+
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2244
|
+
import chalk14 from "chalk";
|
|
2245
|
+
import { Command as Command18 } from "commander";
|
|
564
2246
|
import ora4 from "ora";
|
|
565
|
-
var
|
|
2247
|
+
var VALID_STRATEGIES2 = ["local-wins", "remote-wins", "ask"];
|
|
2248
|
+
var syncCommand = new Command18("sync").description("Bidirectional sync between local .meta/ and remote platform").option("--token <token>", "Personal access token").option(
|
|
566
2249
|
"--strategy <strategy>",
|
|
567
2250
|
"Conflict resolution strategy (local-wins, remote-wins, ask)",
|
|
568
2251
|
"ask"
|
|
569
|
-
).option("--
|
|
2252
|
+
).option("--team <id>", "Linear team ID").option(
|
|
2253
|
+
"--api-key <key>",
|
|
2254
|
+
"Linear API key (default: LINEAR_API_KEY environment variable)"
|
|
2255
|
+
).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
2256
|
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 {
|
|
2257
|
+
if (!VALID_STRATEGIES2.includes(opts.strategy)) {
|
|
579
2258
|
printError(
|
|
580
|
-
|
|
2259
|
+
`Invalid strategy "${opts.strategy}". Must be one of: ${VALID_STRATEGIES2.join(", ")}`
|
|
581
2260
|
);
|
|
582
2261
|
process.exit(1);
|
|
583
2262
|
}
|
|
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
|
-
}
|
|
2263
|
+
const strategy = opts.strategy;
|
|
2264
|
+
const { adapter, config } = await resolveAdapter(metaDir, opts.adapter);
|
|
2265
|
+
const { token, email } = resolveCredentials(adapter.name, opts.token);
|
|
593
2266
|
if (opts.dryRun) {
|
|
594
2267
|
const spinner2 = ora4("Calculating sync changes...").start();
|
|
595
|
-
const result2 = await
|
|
2268
|
+
const result2 = await adapter.sync({
|
|
596
2269
|
token,
|
|
597
|
-
|
|
2270
|
+
email,
|
|
598
2271
|
metaDir,
|
|
2272
|
+
teamId: opts.team,
|
|
2273
|
+
apiKey: opts.apiKey,
|
|
599
2274
|
strategy,
|
|
600
2275
|
dryRun: true
|
|
601
2276
|
});
|
|
@@ -605,81 +2280,36 @@ async function syncGitHub(opts, metaDir, repo, strategy) {
|
|
|
605
2280
|
process.exit(1);
|
|
606
2281
|
}
|
|
607
2282
|
spinner2.succeed("Dry run complete.");
|
|
608
|
-
printSyncSummary(result2.value,
|
|
2283
|
+
printSyncSummary(result2.value, adapter.displayName);
|
|
609
2284
|
return;
|
|
610
2285
|
}
|
|
611
2286
|
if (!opts.yes) {
|
|
612
|
-
const confirmed = await
|
|
613
|
-
message:
|
|
2287
|
+
const confirmed = await confirm3({
|
|
2288
|
+
message: `Run bidirectional sync with ${adapter.displayName}?`,
|
|
614
2289
|
default: true
|
|
615
2290
|
});
|
|
616
2291
|
if (!confirmed) {
|
|
617
|
-
console.log(
|
|
2292
|
+
console.log(chalk14.dim("Sync cancelled."));
|
|
618
2293
|
return;
|
|
619
2294
|
}
|
|
620
2295
|
}
|
|
621
|
-
const
|
|
622
|
-
const result = await syncWithGitHub2({
|
|
623
|
-
token,
|
|
624
|
-
repo,
|
|
2296
|
+
const preHook = await runHooks4(config, "pre-sync", {
|
|
625
2297
|
metaDir,
|
|
626
|
-
|
|
2298
|
+
event: "pre-sync",
|
|
2299
|
+
adapterName: adapter.name
|
|
627
2300
|
});
|
|
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
|
-
);
|
|
2301
|
+
if (!preHook.ok) {
|
|
2302
|
+
printError(`Pre-sync hook failed: ${preHook.error.message}`);
|
|
643
2303
|
process.exit(1);
|
|
644
2304
|
}
|
|
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({
|
|
2305
|
+
const spinner = ora4(`Syncing with ${adapter.displayName}...`).start();
|
|
2306
|
+
const result = await adapter.sync({
|
|
677
2307
|
token,
|
|
678
|
-
|
|
679
|
-
projectId: config.project_id,
|
|
680
|
-
baseUrl: config.base_url,
|
|
2308
|
+
email,
|
|
681
2309
|
metaDir,
|
|
682
|
-
|
|
2310
|
+
teamId: opts.team,
|
|
2311
|
+
apiKey: opts.apiKey,
|
|
2312
|
+
strategy
|
|
683
2313
|
});
|
|
684
2314
|
if (!result.ok) {
|
|
685
2315
|
spinner.fail("Sync failed.");
|
|
@@ -688,20 +2318,26 @@ async function syncGitLab(opts, metaDir, config, strategy) {
|
|
|
688
2318
|
}
|
|
689
2319
|
spinner.succeed("Sync complete.");
|
|
690
2320
|
await handleConflicts(result.value, strategy);
|
|
691
|
-
printSyncSummary(result.value,
|
|
692
|
-
|
|
2321
|
+
printSyncSummary(result.value, adapter.displayName);
|
|
2322
|
+
await runHooks4(config, "post-sync", {
|
|
2323
|
+
metaDir,
|
|
2324
|
+
event: "post-sync",
|
|
2325
|
+
adapterName: adapter.name,
|
|
2326
|
+
result: result.value
|
|
2327
|
+
});
|
|
2328
|
+
});
|
|
693
2329
|
async function handleConflicts(result, strategy) {
|
|
694
2330
|
const { conflicts } = result;
|
|
695
2331
|
if (strategy === "ask" && conflicts.length > 0) {
|
|
696
2332
|
console.log();
|
|
697
2333
|
console.log(
|
|
698
|
-
|
|
2334
|
+
chalk14.yellow(`${conflicts.length} conflict(s) require resolution:`)
|
|
699
2335
|
);
|
|
700
2336
|
const resolutions = await promptConflictResolution(
|
|
701
2337
|
conflicts
|
|
702
2338
|
);
|
|
703
2339
|
console.log(
|
|
704
|
-
|
|
2340
|
+
chalk14.dim(
|
|
705
2341
|
`Resolved ${resolutions.length}, skipped ${conflicts.length - resolutions.length}`
|
|
706
2342
|
)
|
|
707
2343
|
);
|
|
@@ -710,35 +2346,68 @@ async function handleConflicts(result, strategy) {
|
|
|
710
2346
|
function printSyncSummary(result, platform) {
|
|
711
2347
|
const pushedTotal = result.pushed.milestones + result.pushed.issues;
|
|
712
2348
|
const pulledTotal = result.pulled.milestones + result.pulled.issues;
|
|
2349
|
+
const failedCount = result.failedEntities?.length ?? 0;
|
|
2350
|
+
if (result.resumedFromCheckpoint) {
|
|
2351
|
+
console.log();
|
|
2352
|
+
console.log(chalk14.yellow("Resumed from previous checkpoint."));
|
|
2353
|
+
}
|
|
713
2354
|
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
2355
|
console.log(
|
|
718
|
-
|
|
2356
|
+
chalk14.bold(
|
|
2357
|
+
"\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"
|
|
2358
|
+
)
|
|
2359
|
+
);
|
|
2360
|
+
console.log(chalk14.bold("\u2502 Sync Complete \u2502"));
|
|
2361
|
+
console.log(
|
|
2362
|
+
chalk14.bold(
|
|
2363
|
+
"\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"
|
|
2364
|
+
)
|
|
2365
|
+
);
|
|
2366
|
+
console.log(
|
|
2367
|
+
`${chalk14.bold("\u2502")} Pushed to ${platform.padEnd(6)} \u2502 ${String(pushedTotal).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
2368
|
+
);
|
|
2369
|
+
console.log(
|
|
2370
|
+
`${chalk14.bold("\u2502")} Pulled to local \u2502 ${String(pulledTotal).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
719
2371
|
);
|
|
720
2372
|
console.log(
|
|
721
|
-
`${
|
|
2373
|
+
`${chalk14.bold("\u2502")} Conflicts \u2502 ${String(`${result.resolved} resolved`).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
722
2374
|
);
|
|
723
2375
|
console.log(
|
|
724
|
-
`${
|
|
2376
|
+
`${chalk14.bold("\u2502")} Skipped \u2502 ${String(result.skipped).padEnd(17)} ${chalk14.bold("\u2502")}`
|
|
725
2377
|
);
|
|
2378
|
+
if (failedCount > 0) {
|
|
2379
|
+
console.log(
|
|
2380
|
+
`${chalk14.bold("\u2502")} ${chalk14.red("Failed")} \u2502 ${chalk14.red(String(failedCount).padEnd(17))} ${chalk14.bold("\u2502")}`
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
726
2383
|
console.log(
|
|
727
|
-
|
|
2384
|
+
chalk14.bold(
|
|
2385
|
+
"\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"
|
|
2386
|
+
)
|
|
728
2387
|
);
|
|
729
|
-
|
|
2388
|
+
if (failedCount > 0 && result.failedEntities) {
|
|
2389
|
+
console.log();
|
|
2390
|
+
console.log(
|
|
2391
|
+
chalk14.red(
|
|
2392
|
+
`${failedCount} entity sync(s) failed. A checkpoint was saved \u2014 re-run sync to resume.`
|
|
2393
|
+
)
|
|
2394
|
+
);
|
|
2395
|
+
for (const f of result.failedEntities) {
|
|
2396
|
+
console.log(chalk14.dim(` ${f.entityId}: ${f.error}`));
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
730
2399
|
console.log();
|
|
731
2400
|
printSuccess("Sync complete.");
|
|
732
2401
|
}
|
|
733
2402
|
|
|
734
2403
|
// 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
|
|
2404
|
+
import { relative as relative9 } from "path";
|
|
2405
|
+
import { parseTree as parseTree10, resolveRefs as resolveRefs7, validateTree } from "@gitpm/core";
|
|
2406
|
+
import chalk15 from "chalk";
|
|
2407
|
+
import { Command as Command19 } from "commander";
|
|
2408
|
+
var validateCommand = new Command19("validate").description("Validate the .meta/ project tree").action(async (_opts, cmd) => {
|
|
740
2409
|
const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
|
|
741
|
-
const parseResult = await
|
|
2410
|
+
const parseResult = await parseTree10(metaDir);
|
|
742
2411
|
if (!parseResult.ok) {
|
|
743
2412
|
printError(parseResult.error.message);
|
|
744
2413
|
process.exit(1);
|
|
@@ -746,28 +2415,28 @@ var validateCommand = new Command6("validate").description("Validate the .meta/
|
|
|
746
2415
|
const tree = parseResult.value;
|
|
747
2416
|
if (tree.errors.length > 0) {
|
|
748
2417
|
for (const err of tree.errors) {
|
|
749
|
-
const rel =
|
|
750
|
-
console.log(
|
|
2418
|
+
const rel = relative9(process.cwd(), err.filePath);
|
|
2419
|
+
console.log(chalk15.red(` \u2717 ${rel}: ${err.message}`));
|
|
751
2420
|
}
|
|
752
2421
|
}
|
|
753
|
-
const resolveResult =
|
|
2422
|
+
const resolveResult = resolveRefs7(tree);
|
|
754
2423
|
if (!resolveResult.ok) {
|
|
755
2424
|
printError(resolveResult.error.message);
|
|
756
2425
|
process.exit(1);
|
|
757
2426
|
}
|
|
758
2427
|
const result = validateTree(resolveResult.value);
|
|
759
2428
|
for (const err of result.errors) {
|
|
760
|
-
const rel = err.filePath ?
|
|
2429
|
+
const rel = err.filePath ? relative9(process.cwd(), err.filePath) : "";
|
|
761
2430
|
const location = rel ? `${rel}: ` : "";
|
|
762
2431
|
const id = err.entityId ? `[${err.entityId}] ` : "";
|
|
763
|
-
console.log(
|
|
2432
|
+
console.log(chalk15.red(` \u2717 ${location}${id}${err.code}: ${err.message}`));
|
|
764
2433
|
}
|
|
765
2434
|
for (const warn of result.warnings) {
|
|
766
|
-
const rel = warn.filePath ?
|
|
2435
|
+
const rel = warn.filePath ? relative9(process.cwd(), warn.filePath) : "";
|
|
767
2436
|
const location = rel ? `${rel}: ` : "";
|
|
768
2437
|
const id = warn.entityId ? `[${warn.entityId}] ` : "";
|
|
769
2438
|
console.log(
|
|
770
|
-
|
|
2439
|
+
chalk15.yellow(` \u26A0 ${location}${id}${warn.code}: ${warn.message}`)
|
|
771
2440
|
);
|
|
772
2441
|
}
|
|
773
2442
|
if (!result.valid) {
|
|
@@ -786,16 +2455,29 @@ var validateCommand = new Command6("validate").description("Validate the .meta/
|
|
|
786
2455
|
});
|
|
787
2456
|
|
|
788
2457
|
// src/index.ts
|
|
789
|
-
var __dirname =
|
|
2458
|
+
var __dirname = dirname4(fileURLToPath(import.meta.url));
|
|
790
2459
|
var pkg = JSON.parse(
|
|
791
|
-
readFileSync(
|
|
2460
|
+
readFileSync(join3(__dirname, "..", "package.json"), "utf-8")
|
|
792
2461
|
);
|
|
793
|
-
var program = new
|
|
2462
|
+
var program = new Command20();
|
|
794
2463
|
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
2464
|
program.addCommand(initCommand);
|
|
796
2465
|
program.addCommand(validateCommand);
|
|
2466
|
+
program.addCommand(qualityCommand);
|
|
2467
|
+
program.addCommand(nextCommand);
|
|
2468
|
+
program.addCommand(queryCommand);
|
|
2469
|
+
program.addCommand(showCommand);
|
|
2470
|
+
program.addCommand(setCommand);
|
|
2471
|
+
program.addCommand(createCommand);
|
|
2472
|
+
program.addCommand(moveCommand);
|
|
2473
|
+
program.addCommand(commitCommand);
|
|
797
2474
|
program.addCommand(importCommand);
|
|
798
2475
|
program.addCommand(pushCommand);
|
|
799
2476
|
program.addCommand(pullCommand);
|
|
800
2477
|
program.addCommand(syncCommand);
|
|
2478
|
+
program.addCommand(statusCommand);
|
|
2479
|
+
program.addCommand(sprintCommand);
|
|
2480
|
+
program.addCommand(auditCommand);
|
|
2481
|
+
program.addCommand(archiveCommand);
|
|
2482
|
+
program.addCommand(contextCommand);
|
|
801
2483
|
program.parse();
|