@markmdev/pebble 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +2207 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/ui/assets/index-CdQQtrFF.js +328 -0
- package/dist/ui/assets/index-ZZBUE9NI.css +1 -0
- package/dist/ui/index.html +13 -0
- package/package.json +72 -0
|
@@ -0,0 +1,2207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/shared/types.ts
|
|
7
|
+
var ISSUE_TYPES = ["task", "bug", "epic"];
|
|
8
|
+
var PRIORITIES = [0, 1, 2, 3, 4];
|
|
9
|
+
var STATUSES = ["open", "in_progress", "blocked", "closed"];
|
|
10
|
+
var PRIORITY_LABELS = {
|
|
11
|
+
0: "critical",
|
|
12
|
+
1: "high",
|
|
13
|
+
2: "medium",
|
|
14
|
+
3: "low",
|
|
15
|
+
4: "backlog"
|
|
16
|
+
};
|
|
17
|
+
var STATUS_LABELS = {
|
|
18
|
+
open: "Open",
|
|
19
|
+
in_progress: "In Progress",
|
|
20
|
+
blocked: "Blocked",
|
|
21
|
+
closed: "Closed"
|
|
22
|
+
};
|
|
23
|
+
var TYPE_LABELS = {
|
|
24
|
+
task: "Task",
|
|
25
|
+
bug: "Bug",
|
|
26
|
+
epic: "Epic"
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/cli/lib/storage.ts
|
|
30
|
+
import * as fs from "fs";
|
|
31
|
+
import * as path from "path";
|
|
32
|
+
|
|
33
|
+
// src/cli/lib/id.ts
|
|
34
|
+
import * as crypto from "crypto";
|
|
35
|
+
var ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
36
|
+
function randomAlphanumeric(length) {
|
|
37
|
+
const bytes = crypto.randomBytes(length);
|
|
38
|
+
let result = "";
|
|
39
|
+
for (let i = 0; i < length; i++) {
|
|
40
|
+
result += ID_CHARS[bytes[i] % ID_CHARS.length];
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function generateId(prefix) {
|
|
45
|
+
const suffix = randomAlphanumeric(6);
|
|
46
|
+
return `${prefix}-${suffix}`;
|
|
47
|
+
}
|
|
48
|
+
function derivePrefix(folderName) {
|
|
49
|
+
const clean = folderName.replace(/[^a-zA-Z0-9]/g, "");
|
|
50
|
+
return clean.slice(0, 4).toUpperCase().padEnd(4, "X");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/cli/lib/storage.ts
|
|
54
|
+
var PEBBLE_DIR = ".pebble";
|
|
55
|
+
var ISSUES_FILE = "issues.jsonl";
|
|
56
|
+
var CONFIG_FILE = "config.json";
|
|
57
|
+
function discoverPebbleDir(startDir = process.cwd()) {
|
|
58
|
+
let currentDir = path.resolve(startDir);
|
|
59
|
+
const root = path.parse(currentDir).root;
|
|
60
|
+
while (currentDir !== root) {
|
|
61
|
+
const pebbleDir = path.join(currentDir, PEBBLE_DIR);
|
|
62
|
+
if (fs.existsSync(pebbleDir) && fs.statSync(pebbleDir).isDirectory()) {
|
|
63
|
+
return pebbleDir;
|
|
64
|
+
}
|
|
65
|
+
currentDir = path.dirname(currentDir);
|
|
66
|
+
}
|
|
67
|
+
const rootPebble = path.join(root, PEBBLE_DIR);
|
|
68
|
+
if (fs.existsSync(rootPebble) && fs.statSync(rootPebble).isDirectory()) {
|
|
69
|
+
return rootPebble;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function getPebbleDir() {
|
|
74
|
+
const dir = discoverPebbleDir();
|
|
75
|
+
if (!dir) {
|
|
76
|
+
throw new Error("No .pebble directory found. Run a create command to initialize.");
|
|
77
|
+
}
|
|
78
|
+
return dir;
|
|
79
|
+
}
|
|
80
|
+
function ensurePebbleDir(baseDir = process.cwd()) {
|
|
81
|
+
const pebbleDir = path.join(baseDir, PEBBLE_DIR);
|
|
82
|
+
if (!fs.existsSync(pebbleDir)) {
|
|
83
|
+
fs.mkdirSync(pebbleDir, { recursive: true });
|
|
84
|
+
const folderName = path.basename(baseDir);
|
|
85
|
+
const config = {
|
|
86
|
+
prefix: derivePrefix(folderName),
|
|
87
|
+
version: "0.1.0"
|
|
88
|
+
};
|
|
89
|
+
setConfig(config, pebbleDir);
|
|
90
|
+
const issuesPath = path.join(pebbleDir, ISSUES_FILE);
|
|
91
|
+
fs.writeFileSync(issuesPath, "", "utf-8");
|
|
92
|
+
}
|
|
93
|
+
return pebbleDir;
|
|
94
|
+
}
|
|
95
|
+
function getIssuesPath(pebbleDir) {
|
|
96
|
+
const dir = pebbleDir ?? getPebbleDir();
|
|
97
|
+
return path.join(dir, ISSUES_FILE);
|
|
98
|
+
}
|
|
99
|
+
function appendEvent(event, pebbleDir) {
|
|
100
|
+
const issuesPath = getIssuesPath(pebbleDir);
|
|
101
|
+
const line = JSON.stringify(event) + "\n";
|
|
102
|
+
fs.appendFileSync(issuesPath, line, "utf-8");
|
|
103
|
+
}
|
|
104
|
+
function readEventsFromFile(filePath) {
|
|
105
|
+
if (!fs.existsSync(filePath)) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
109
|
+
const lines = content.split("\n").filter((line) => line.trim() !== "");
|
|
110
|
+
return lines.map((line, index) => {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(line);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(`Invalid JSON at line ${index + 1} in ${filePath}: ${line}`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function readEvents(pebbleDir) {
|
|
119
|
+
const dir = pebbleDir ?? discoverPebbleDir();
|
|
120
|
+
if (!dir) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const issuesPath = path.join(dir, ISSUES_FILE);
|
|
124
|
+
if (!fs.existsSync(issuesPath)) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const content = fs.readFileSync(issuesPath, "utf-8");
|
|
128
|
+
const lines = content.split("\n").filter((line) => line.trim() !== "");
|
|
129
|
+
return lines.map((line, index) => {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(line);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(`Invalid JSON at line ${index + 1}: ${line}`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function getConfigPath(pebbleDir) {
|
|
138
|
+
const dir = pebbleDir ?? getPebbleDir();
|
|
139
|
+
return path.join(dir, CONFIG_FILE);
|
|
140
|
+
}
|
|
141
|
+
function getConfig(pebbleDir) {
|
|
142
|
+
const configPath = getConfigPath(pebbleDir);
|
|
143
|
+
if (!fs.existsSync(configPath)) {
|
|
144
|
+
throw new Error("Config file not found. Initialize .pebble first.");
|
|
145
|
+
}
|
|
146
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
147
|
+
return JSON.parse(content);
|
|
148
|
+
}
|
|
149
|
+
function setConfig(config, pebbleDir) {
|
|
150
|
+
const dir = pebbleDir ?? getPebbleDir();
|
|
151
|
+
const configPath = path.join(dir, CONFIG_FILE);
|
|
152
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
153
|
+
}
|
|
154
|
+
function getOrCreatePebbleDir() {
|
|
155
|
+
const existing = discoverPebbleDir();
|
|
156
|
+
if (existing) {
|
|
157
|
+
return existing;
|
|
158
|
+
}
|
|
159
|
+
return ensurePebbleDir();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/cli/lib/state.ts
|
|
163
|
+
function computeState(events) {
|
|
164
|
+
const issues = /* @__PURE__ */ new Map();
|
|
165
|
+
for (const event of events) {
|
|
166
|
+
switch (event.type) {
|
|
167
|
+
case "create": {
|
|
168
|
+
const createEvent = event;
|
|
169
|
+
const issue = {
|
|
170
|
+
id: event.issueId,
|
|
171
|
+
title: createEvent.data.title,
|
|
172
|
+
type: createEvent.data.type,
|
|
173
|
+
priority: createEvent.data.priority,
|
|
174
|
+
status: "open",
|
|
175
|
+
description: createEvent.data.description,
|
|
176
|
+
parent: createEvent.data.parent,
|
|
177
|
+
blockedBy: [],
|
|
178
|
+
comments: [],
|
|
179
|
+
createdAt: event.timestamp,
|
|
180
|
+
updatedAt: event.timestamp
|
|
181
|
+
};
|
|
182
|
+
issues.set(event.issueId, issue);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "update": {
|
|
186
|
+
const updateEvent = event;
|
|
187
|
+
const issue = issues.get(event.issueId);
|
|
188
|
+
if (issue) {
|
|
189
|
+
if (updateEvent.data.title !== void 0) {
|
|
190
|
+
issue.title = updateEvent.data.title;
|
|
191
|
+
}
|
|
192
|
+
if (updateEvent.data.type !== void 0) {
|
|
193
|
+
issue.type = updateEvent.data.type;
|
|
194
|
+
}
|
|
195
|
+
if (updateEvent.data.priority !== void 0) {
|
|
196
|
+
issue.priority = updateEvent.data.priority;
|
|
197
|
+
}
|
|
198
|
+
if (updateEvent.data.status !== void 0) {
|
|
199
|
+
issue.status = updateEvent.data.status;
|
|
200
|
+
}
|
|
201
|
+
if (updateEvent.data.description !== void 0) {
|
|
202
|
+
issue.description = updateEvent.data.description;
|
|
203
|
+
}
|
|
204
|
+
if (updateEvent.data.parent !== void 0) {
|
|
205
|
+
issue.parent = updateEvent.data.parent;
|
|
206
|
+
}
|
|
207
|
+
if (updateEvent.data.blockedBy !== void 0) {
|
|
208
|
+
issue.blockedBy = updateEvent.data.blockedBy;
|
|
209
|
+
}
|
|
210
|
+
issue.updatedAt = event.timestamp;
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "close": {
|
|
215
|
+
const issue = issues.get(event.issueId);
|
|
216
|
+
if (issue) {
|
|
217
|
+
issue.status = "closed";
|
|
218
|
+
issue.updatedAt = event.timestamp;
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case "reopen": {
|
|
223
|
+
const issue = issues.get(event.issueId);
|
|
224
|
+
if (issue) {
|
|
225
|
+
issue.status = "open";
|
|
226
|
+
issue.updatedAt = event.timestamp;
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case "comment": {
|
|
231
|
+
const commentEvent = event;
|
|
232
|
+
const issue = issues.get(event.issueId);
|
|
233
|
+
if (issue) {
|
|
234
|
+
issue.comments.push(commentEvent.data);
|
|
235
|
+
issue.updatedAt = event.timestamp;
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return issues;
|
|
242
|
+
}
|
|
243
|
+
function getIssues(filters) {
|
|
244
|
+
const events = readEvents();
|
|
245
|
+
const state = computeState(events);
|
|
246
|
+
let issues = Array.from(state.values());
|
|
247
|
+
if (filters) {
|
|
248
|
+
if (filters.status !== void 0) {
|
|
249
|
+
issues = issues.filter((i) => i.status === filters.status);
|
|
250
|
+
}
|
|
251
|
+
if (filters.type !== void 0) {
|
|
252
|
+
issues = issues.filter((i) => i.type === filters.type);
|
|
253
|
+
}
|
|
254
|
+
if (filters.priority !== void 0) {
|
|
255
|
+
issues = issues.filter((i) => i.priority === filters.priority);
|
|
256
|
+
}
|
|
257
|
+
if (filters.parent !== void 0) {
|
|
258
|
+
issues = issues.filter((i) => i.parent === filters.parent);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return issues;
|
|
262
|
+
}
|
|
263
|
+
function getIssue(id) {
|
|
264
|
+
const events = readEvents();
|
|
265
|
+
const state = computeState(events);
|
|
266
|
+
return state.get(id);
|
|
267
|
+
}
|
|
268
|
+
function resolveId(partial) {
|
|
269
|
+
const events = readEvents();
|
|
270
|
+
const state = computeState(events);
|
|
271
|
+
const allIds = Array.from(state.keys());
|
|
272
|
+
const partialLower = partial.toLowerCase();
|
|
273
|
+
const exactMatch = allIds.find((id) => id.toLowerCase() === partialLower);
|
|
274
|
+
if (exactMatch) {
|
|
275
|
+
return exactMatch;
|
|
276
|
+
}
|
|
277
|
+
const prefixMatches = allIds.filter(
|
|
278
|
+
(id) => id.toLowerCase().startsWith(partialLower)
|
|
279
|
+
);
|
|
280
|
+
if (prefixMatches.length === 1) {
|
|
281
|
+
return prefixMatches[0];
|
|
282
|
+
}
|
|
283
|
+
if (prefixMatches.length > 1) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Ambiguous issue ID '${partial}'. Matches: ${prefixMatches.join(", ")}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const suffixMatches = allIds.filter((id) => {
|
|
289
|
+
const hyphenIndex = id.indexOf("-");
|
|
290
|
+
if (hyphenIndex === -1) return false;
|
|
291
|
+
const suffix = id.substring(hyphenIndex + 1).toLowerCase();
|
|
292
|
+
return suffix === partialLower;
|
|
293
|
+
});
|
|
294
|
+
if (suffixMatches.length === 1) {
|
|
295
|
+
return suffixMatches[0];
|
|
296
|
+
}
|
|
297
|
+
if (suffixMatches.length > 1) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`Ambiguous issue ID '${partial}'. Matches: ${suffixMatches.join(", ")}`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`Issue not found: ${partial}`);
|
|
303
|
+
}
|
|
304
|
+
function getReady() {
|
|
305
|
+
const events = readEvents();
|
|
306
|
+
const state = computeState(events);
|
|
307
|
+
const issues = Array.from(state.values());
|
|
308
|
+
return issues.filter((issue) => {
|
|
309
|
+
if (issue.status === "closed") {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
for (const blockerId of issue.blockedBy) {
|
|
313
|
+
const blocker = state.get(blockerId);
|
|
314
|
+
if (blocker && blocker.status !== "closed") {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return true;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function getBlocked() {
|
|
322
|
+
const events = readEvents();
|
|
323
|
+
const state = computeState(events);
|
|
324
|
+
const issues = Array.from(state.values());
|
|
325
|
+
return issues.filter((issue) => {
|
|
326
|
+
if (issue.status === "closed") {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
for (const blockerId of issue.blockedBy) {
|
|
330
|
+
const blocker = state.get(blockerId);
|
|
331
|
+
if (blocker && blocker.status !== "closed") {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
function buildDependencyGraph() {
|
|
339
|
+
const events = readEvents();
|
|
340
|
+
const state = computeState(events);
|
|
341
|
+
const graph = /* @__PURE__ */ new Map();
|
|
342
|
+
for (const id of state.keys()) {
|
|
343
|
+
graph.set(id, []);
|
|
344
|
+
}
|
|
345
|
+
for (const [id, issue] of state) {
|
|
346
|
+
for (const blockerId of issue.blockedBy) {
|
|
347
|
+
const blockerEdges = graph.get(blockerId);
|
|
348
|
+
if (blockerEdges) {
|
|
349
|
+
blockerEdges.push(id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return graph;
|
|
354
|
+
}
|
|
355
|
+
function detectCycle(issueId, newBlockerId) {
|
|
356
|
+
if (issueId === newBlockerId) {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
const graph = buildDependencyGraph();
|
|
360
|
+
const blockerEdges = graph.get(newBlockerId) ?? [];
|
|
361
|
+
const testGraph = new Map(graph);
|
|
362
|
+
testGraph.set(newBlockerId, [...blockerEdges, issueId]);
|
|
363
|
+
const visited = /* @__PURE__ */ new Set();
|
|
364
|
+
const stack = [issueId];
|
|
365
|
+
while (stack.length > 0) {
|
|
366
|
+
const current = stack.pop();
|
|
367
|
+
if (current === newBlockerId) {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
if (visited.has(current)) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
visited.add(current);
|
|
374
|
+
const edges = testGraph.get(current) ?? [];
|
|
375
|
+
for (const next of edges) {
|
|
376
|
+
if (!visited.has(next)) {
|
|
377
|
+
stack.push(next);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
function getBlockers(issueId) {
|
|
384
|
+
const issue = getIssue(issueId);
|
|
385
|
+
if (!issue) {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
const events = readEvents();
|
|
389
|
+
const state = computeState(events);
|
|
390
|
+
return issue.blockedBy.map((id) => state.get(id)).filter((i) => i !== void 0);
|
|
391
|
+
}
|
|
392
|
+
function getBlocking(issueId) {
|
|
393
|
+
const events = readEvents();
|
|
394
|
+
const state = computeState(events);
|
|
395
|
+
return Array.from(state.values()).filter(
|
|
396
|
+
(issue) => issue.blockedBy.includes(issueId)
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
function getChildren(epicId) {
|
|
400
|
+
const events = readEvents();
|
|
401
|
+
const state = computeState(events);
|
|
402
|
+
return Array.from(state.values()).filter((issue) => issue.parent === epicId);
|
|
403
|
+
}
|
|
404
|
+
function hasOpenChildren(epicId) {
|
|
405
|
+
const children = getChildren(epicId);
|
|
406
|
+
return children.some((child) => child.status !== "closed");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/cli/lib/output.ts
|
|
410
|
+
function formatJson(data) {
|
|
411
|
+
return JSON.stringify(data, null, 2);
|
|
412
|
+
}
|
|
413
|
+
function formatPriority(priority) {
|
|
414
|
+
return `P${priority} (${PRIORITY_LABELS[priority]})`;
|
|
415
|
+
}
|
|
416
|
+
function formatStatus(status) {
|
|
417
|
+
return STATUS_LABELS[status];
|
|
418
|
+
}
|
|
419
|
+
function formatType(type) {
|
|
420
|
+
return TYPE_LABELS[type];
|
|
421
|
+
}
|
|
422
|
+
function truncate(str, maxLength) {
|
|
423
|
+
if (str.length <= maxLength) return str;
|
|
424
|
+
return str.slice(0, maxLength - 3) + "...";
|
|
425
|
+
}
|
|
426
|
+
function pad(str, width) {
|
|
427
|
+
return str.padEnd(width);
|
|
428
|
+
}
|
|
429
|
+
function formatIssuePretty(issue) {
|
|
430
|
+
const lines = [];
|
|
431
|
+
lines.push(`${issue.id} - ${issue.title}`);
|
|
432
|
+
lines.push("\u2500".repeat(60));
|
|
433
|
+
lines.push(`Type: ${formatType(issue.type)}`);
|
|
434
|
+
lines.push(`Priority: ${formatPriority(issue.priority)}`);
|
|
435
|
+
lines.push(`Status: ${formatStatus(issue.status)}`);
|
|
436
|
+
if (issue.parent) {
|
|
437
|
+
lines.push(`Parent: ${issue.parent}`);
|
|
438
|
+
}
|
|
439
|
+
if (issue.description) {
|
|
440
|
+
lines.push("");
|
|
441
|
+
lines.push("Description:");
|
|
442
|
+
lines.push(issue.description);
|
|
443
|
+
}
|
|
444
|
+
if (issue.blockedBy.length > 0) {
|
|
445
|
+
lines.push("");
|
|
446
|
+
lines.push(`Blocked by: ${issue.blockedBy.join(", ")}`);
|
|
447
|
+
}
|
|
448
|
+
if (issue.comments.length > 0) {
|
|
449
|
+
lines.push("");
|
|
450
|
+
lines.push("Comments:");
|
|
451
|
+
for (const comment of issue.comments) {
|
|
452
|
+
const author = comment.author ?? "unknown";
|
|
453
|
+
const date = new Date(comment.timestamp).toLocaleString();
|
|
454
|
+
lines.push(` [${date}] ${author}: ${comment.text}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
lines.push("");
|
|
458
|
+
lines.push(`Created: ${new Date(issue.createdAt).toLocaleString()}`);
|
|
459
|
+
lines.push(`Updated: ${new Date(issue.updatedAt).toLocaleString()}`);
|
|
460
|
+
return lines.join("\n");
|
|
461
|
+
}
|
|
462
|
+
function formatIssueListPretty(issues) {
|
|
463
|
+
if (issues.length === 0) {
|
|
464
|
+
return "No issues found.";
|
|
465
|
+
}
|
|
466
|
+
const lines = [];
|
|
467
|
+
const idWidth = 12;
|
|
468
|
+
const typeWidth = 6;
|
|
469
|
+
const prioWidth = 4;
|
|
470
|
+
const statusWidth = 12;
|
|
471
|
+
const titleWidth = 40;
|
|
472
|
+
const header = [
|
|
473
|
+
pad("ID", idWidth),
|
|
474
|
+
pad("Type", typeWidth),
|
|
475
|
+
pad("Pri", prioWidth),
|
|
476
|
+
pad("Status", statusWidth),
|
|
477
|
+
pad("Title", titleWidth)
|
|
478
|
+
].join(" \u2502 ");
|
|
479
|
+
lines.push(header);
|
|
480
|
+
lines.push("\u2500".repeat(header.length));
|
|
481
|
+
for (const issue of issues) {
|
|
482
|
+
const row = [
|
|
483
|
+
pad(issue.id, idWidth),
|
|
484
|
+
pad(issue.type, typeWidth),
|
|
485
|
+
pad(`P${issue.priority}`, prioWidth),
|
|
486
|
+
pad(issue.status, statusWidth),
|
|
487
|
+
truncate(issue.title, titleWidth)
|
|
488
|
+
].join(" \u2502 ");
|
|
489
|
+
lines.push(row);
|
|
490
|
+
}
|
|
491
|
+
lines.push("");
|
|
492
|
+
lines.push(`Total: ${issues.length} issue(s)`);
|
|
493
|
+
return lines.join("\n");
|
|
494
|
+
}
|
|
495
|
+
function formatDepsPretty(issueId, blockedBy, blocking) {
|
|
496
|
+
const lines = [];
|
|
497
|
+
lines.push(`Dependencies for ${issueId}`);
|
|
498
|
+
lines.push("\u2500".repeat(40));
|
|
499
|
+
lines.push("");
|
|
500
|
+
lines.push("Blocked by:");
|
|
501
|
+
if (blockedBy.length === 0) {
|
|
502
|
+
lines.push(" (none)");
|
|
503
|
+
} else {
|
|
504
|
+
for (const issue of blockedBy) {
|
|
505
|
+
const status = issue.status === "closed" ? "\u2713" : "\u25CB";
|
|
506
|
+
lines.push(` ${status} ${issue.id} - ${truncate(issue.title, 30)}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
lines.push("");
|
|
510
|
+
lines.push("Blocking:");
|
|
511
|
+
if (blocking.length === 0) {
|
|
512
|
+
lines.push(" (none)");
|
|
513
|
+
} else {
|
|
514
|
+
for (const issue of blocking) {
|
|
515
|
+
lines.push(` \u25CB ${issue.id} - ${truncate(issue.title, 30)}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return lines.join("\n");
|
|
519
|
+
}
|
|
520
|
+
function formatError(error) {
|
|
521
|
+
const message = error instanceof Error ? error.message : error;
|
|
522
|
+
return JSON.stringify({ error: message });
|
|
523
|
+
}
|
|
524
|
+
function formatErrorPretty(error) {
|
|
525
|
+
const message = error instanceof Error ? error.message : error;
|
|
526
|
+
return `Error: ${message}`;
|
|
527
|
+
}
|
|
528
|
+
function outputIssue(issue, pretty) {
|
|
529
|
+
if (pretty) {
|
|
530
|
+
console.log(formatIssuePretty(issue));
|
|
531
|
+
} else {
|
|
532
|
+
console.log(formatJson(issue));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function outputMutationSuccess(id, pretty) {
|
|
536
|
+
if (pretty) {
|
|
537
|
+
console.log(`\u2713 ${id}`);
|
|
538
|
+
} else {
|
|
539
|
+
console.log(JSON.stringify({ id, success: true }));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function outputIssueList(issues, pretty) {
|
|
543
|
+
if (pretty) {
|
|
544
|
+
console.log(formatIssueListPretty(issues));
|
|
545
|
+
} else {
|
|
546
|
+
console.log(formatJson(issues));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function outputError(error, pretty) {
|
|
550
|
+
if (pretty) {
|
|
551
|
+
console.error(formatErrorPretty(error));
|
|
552
|
+
} else {
|
|
553
|
+
console.error(formatError(error));
|
|
554
|
+
}
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/cli/commands/create.ts
|
|
559
|
+
function createCommand(program2) {
|
|
560
|
+
program2.command("create <title>").description("Create a new issue").option("-t, --type <type>", "Issue type (task, bug, epic)", "task").option("-p, --priority <priority>", "Priority (0-4)", "2").option("-d, --description <desc>", "Description").option("--parent <id>", "Parent epic ID").action(async (title, options) => {
|
|
561
|
+
const pretty = program2.opts().pretty ?? false;
|
|
562
|
+
try {
|
|
563
|
+
const type = options.type;
|
|
564
|
+
if (!ISSUE_TYPES.includes(type)) {
|
|
565
|
+
throw new Error(`Invalid type: ${type}. Must be one of: ${ISSUE_TYPES.join(", ")}`);
|
|
566
|
+
}
|
|
567
|
+
const priority = parseInt(options.priority, 10);
|
|
568
|
+
if (!PRIORITIES.includes(priority)) {
|
|
569
|
+
throw new Error(`Invalid priority: ${options.priority}. Must be 0-4`);
|
|
570
|
+
}
|
|
571
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
572
|
+
const config = getConfig(pebbleDir);
|
|
573
|
+
let parentId;
|
|
574
|
+
if (options.parent) {
|
|
575
|
+
parentId = resolveId(options.parent);
|
|
576
|
+
const parent = getIssue(parentId);
|
|
577
|
+
if (!parent) {
|
|
578
|
+
throw new Error(`Parent issue not found: ${options.parent}`);
|
|
579
|
+
}
|
|
580
|
+
if (parent.type !== "epic") {
|
|
581
|
+
throw new Error(`Parent must be an epic, got: ${parent.type}`);
|
|
582
|
+
}
|
|
583
|
+
if (parent.status === "closed") {
|
|
584
|
+
throw new Error(`Cannot add children to closed epic: ${parentId}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const id = generateId(config.prefix);
|
|
588
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
589
|
+
const event = {
|
|
590
|
+
type: "create",
|
|
591
|
+
issueId: id,
|
|
592
|
+
timestamp,
|
|
593
|
+
data: {
|
|
594
|
+
title,
|
|
595
|
+
type,
|
|
596
|
+
priority,
|
|
597
|
+
description: options.description,
|
|
598
|
+
parent: parentId
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
appendEvent(event, pebbleDir);
|
|
602
|
+
outputMutationSuccess(id, pretty);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
outputError(error, pretty);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/cli/commands/update.ts
|
|
610
|
+
function updateCommand(program2) {
|
|
611
|
+
program2.command("update <ids...>").description("Update issues. Supports multiple IDs.").option("--status <status>", "Status (open, in_progress, blocked, closed)").option("--priority <priority>", "Priority (0-4)").option("--title <title>", "Title").option("--description <desc>", "Description").action(async (ids, options) => {
|
|
612
|
+
const pretty = program2.opts().pretty ?? false;
|
|
613
|
+
try {
|
|
614
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
615
|
+
const allIds = ids.flatMap((id) => id.split(",").map((s) => s.trim()).filter(Boolean));
|
|
616
|
+
if (allIds.length === 0) {
|
|
617
|
+
throw new Error("No issue IDs provided");
|
|
618
|
+
}
|
|
619
|
+
const data = {};
|
|
620
|
+
let hasChanges = false;
|
|
621
|
+
if (options.status !== void 0) {
|
|
622
|
+
const status = options.status;
|
|
623
|
+
if (!STATUSES.includes(status)) {
|
|
624
|
+
throw new Error(`Invalid status: ${status}. Must be one of: ${STATUSES.join(", ")}`);
|
|
625
|
+
}
|
|
626
|
+
data.status = status;
|
|
627
|
+
hasChanges = true;
|
|
628
|
+
}
|
|
629
|
+
if (options.priority !== void 0) {
|
|
630
|
+
const priority = parseInt(options.priority, 10);
|
|
631
|
+
if (!PRIORITIES.includes(priority)) {
|
|
632
|
+
throw new Error(`Invalid priority: ${options.priority}. Must be 0-4`);
|
|
633
|
+
}
|
|
634
|
+
data.priority = priority;
|
|
635
|
+
hasChanges = true;
|
|
636
|
+
}
|
|
637
|
+
if (options.title !== void 0) {
|
|
638
|
+
data.title = options.title;
|
|
639
|
+
hasChanges = true;
|
|
640
|
+
}
|
|
641
|
+
if (options.description !== void 0) {
|
|
642
|
+
data.description = options.description;
|
|
643
|
+
hasChanges = true;
|
|
644
|
+
}
|
|
645
|
+
if (!hasChanges) {
|
|
646
|
+
throw new Error("No changes specified. Use --status, --priority, --title, or --description");
|
|
647
|
+
}
|
|
648
|
+
const results = [];
|
|
649
|
+
for (const id of allIds) {
|
|
650
|
+
try {
|
|
651
|
+
const resolvedId = resolveId(id);
|
|
652
|
+
const issue = getIssue(resolvedId);
|
|
653
|
+
if (!issue) {
|
|
654
|
+
results.push({ id, success: false, error: `Issue not found: ${id}` });
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const event = {
|
|
658
|
+
type: "update",
|
|
659
|
+
issueId: resolvedId,
|
|
660
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
661
|
+
data
|
|
662
|
+
};
|
|
663
|
+
appendEvent(event, pebbleDir);
|
|
664
|
+
results.push({ id: resolvedId, success: true });
|
|
665
|
+
} catch (error) {
|
|
666
|
+
results.push({ id, success: false, error: error.message });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (allIds.length === 1) {
|
|
670
|
+
const result = results[0];
|
|
671
|
+
if (result.success) {
|
|
672
|
+
outputMutationSuccess(result.id, pretty);
|
|
673
|
+
} else {
|
|
674
|
+
throw new Error(result.error || "Unknown error");
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
if (pretty) {
|
|
678
|
+
for (const result of results) {
|
|
679
|
+
if (result.success) {
|
|
680
|
+
console.log(`\u2713 ${result.id}`);
|
|
681
|
+
} else {
|
|
682
|
+
console.log(`\u2717 ${result.id}: ${result.error}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} else {
|
|
686
|
+
console.log(formatJson(results.map((r) => ({
|
|
687
|
+
id: r.id,
|
|
688
|
+
success: r.success,
|
|
689
|
+
...r.error && { error: r.error }
|
|
690
|
+
}))));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
outputError(error, pretty);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/cli/commands/close.ts
|
|
700
|
+
function closeCommand(program2) {
|
|
701
|
+
program2.command("close <ids...>").description("Close issues. Supports multiple IDs.").option("--reason <reason>", "Reason for closing").option("--comment <text>", "Add a comment before closing").action(async (ids, options) => {
|
|
702
|
+
const pretty = program2.opts().pretty ?? false;
|
|
703
|
+
try {
|
|
704
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
705
|
+
const allIds = ids.flatMap((id) => id.split(",").map((s) => s.trim()).filter(Boolean));
|
|
706
|
+
if (allIds.length === 0) {
|
|
707
|
+
throw new Error("No issue IDs provided");
|
|
708
|
+
}
|
|
709
|
+
const results = [];
|
|
710
|
+
for (const id of allIds) {
|
|
711
|
+
try {
|
|
712
|
+
const resolvedId = resolveId(id);
|
|
713
|
+
const issue = getIssue(resolvedId);
|
|
714
|
+
if (!issue) {
|
|
715
|
+
results.push({ id, success: false, error: `Issue not found: ${id}` });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (issue.status === "closed") {
|
|
719
|
+
results.push({ id: resolvedId, success: false, error: `Issue is already closed: ${resolvedId}` });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (issue.type === "epic" && hasOpenChildren(resolvedId)) {
|
|
723
|
+
results.push({ id: resolvedId, success: false, error: `Cannot close epic with open children: ${resolvedId}` });
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
727
|
+
if (options.comment) {
|
|
728
|
+
const commentEvent = {
|
|
729
|
+
type: "comment",
|
|
730
|
+
issueId: resolvedId,
|
|
731
|
+
timestamp,
|
|
732
|
+
data: {
|
|
733
|
+
text: options.comment,
|
|
734
|
+
timestamp
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
appendEvent(commentEvent, pebbleDir);
|
|
738
|
+
}
|
|
739
|
+
const closeEvent = {
|
|
740
|
+
type: "close",
|
|
741
|
+
issueId: resolvedId,
|
|
742
|
+
timestamp,
|
|
743
|
+
data: {
|
|
744
|
+
reason: options.reason
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
appendEvent(closeEvent, pebbleDir);
|
|
748
|
+
results.push({ id: resolvedId, success: true });
|
|
749
|
+
} catch (error) {
|
|
750
|
+
results.push({ id, success: false, error: error.message });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (allIds.length === 1) {
|
|
754
|
+
const result = results[0];
|
|
755
|
+
if (result.success) {
|
|
756
|
+
outputMutationSuccess(result.id, pretty);
|
|
757
|
+
} else {
|
|
758
|
+
throw new Error(result.error || "Unknown error");
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
if (pretty) {
|
|
762
|
+
for (const result of results) {
|
|
763
|
+
if (result.success) {
|
|
764
|
+
console.log(`\u2713 ${result.id}`);
|
|
765
|
+
} else {
|
|
766
|
+
console.log(`\u2717 ${result.id}: ${result.error}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
} else {
|
|
770
|
+
console.log(formatJson(results.map((r) => ({
|
|
771
|
+
id: r.id,
|
|
772
|
+
success: r.success,
|
|
773
|
+
...r.error && { error: r.error }
|
|
774
|
+
}))));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} catch (error) {
|
|
778
|
+
outputError(error, pretty);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/cli/commands/reopen.ts
|
|
784
|
+
function reopenCommand(program2) {
|
|
785
|
+
program2.command("reopen <id>").description("Reopen a closed issue").option("--reason <reason>", "Reason for reopening").action(async (id, options) => {
|
|
786
|
+
const pretty = program2.opts().pretty ?? false;
|
|
787
|
+
try {
|
|
788
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
789
|
+
const resolvedId = resolveId(id);
|
|
790
|
+
const issue = getIssue(resolvedId);
|
|
791
|
+
if (!issue) {
|
|
792
|
+
throw new Error(`Issue not found: ${id}`);
|
|
793
|
+
}
|
|
794
|
+
if (issue.status !== "closed") {
|
|
795
|
+
throw new Error(`Issue is not closed: ${resolvedId} (status: ${issue.status})`);
|
|
796
|
+
}
|
|
797
|
+
const event = {
|
|
798
|
+
type: "reopen",
|
|
799
|
+
issueId: resolvedId,
|
|
800
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
801
|
+
data: {
|
|
802
|
+
reason: options.reason
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
appendEvent(event, pebbleDir);
|
|
806
|
+
outputMutationSuccess(resolvedId, pretty);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
outputError(error, pretty);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/cli/commands/claim.ts
|
|
814
|
+
function claimCommand(program2) {
|
|
815
|
+
program2.command("claim <ids...>").description("Claim issues (set status to in_progress). Supports multiple IDs.").action(async (ids) => {
|
|
816
|
+
const pretty = program2.opts().pretty ?? false;
|
|
817
|
+
try {
|
|
818
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
819
|
+
const allIds = ids.flatMap((id) => id.split(",").map((s) => s.trim()).filter(Boolean));
|
|
820
|
+
if (allIds.length === 0) {
|
|
821
|
+
throw new Error("No issue IDs provided");
|
|
822
|
+
}
|
|
823
|
+
const results = [];
|
|
824
|
+
for (const id of allIds) {
|
|
825
|
+
try {
|
|
826
|
+
const resolvedId = resolveId(id);
|
|
827
|
+
const issue = getIssue(resolvedId);
|
|
828
|
+
if (!issue) {
|
|
829
|
+
results.push({ id, success: false, error: `Issue not found: ${id}` });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (issue.status === "in_progress") {
|
|
833
|
+
results.push({ id: resolvedId, success: true });
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (issue.status === "closed") {
|
|
837
|
+
results.push({ id: resolvedId, success: false, error: `Cannot claim closed issue: ${resolvedId}` });
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const event = {
|
|
841
|
+
type: "update",
|
|
842
|
+
issueId: resolvedId,
|
|
843
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
844
|
+
data: {
|
|
845
|
+
status: "in_progress"
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
appendEvent(event, pebbleDir);
|
|
849
|
+
results.push({ id: resolvedId, success: true });
|
|
850
|
+
} catch (error) {
|
|
851
|
+
results.push({ id, success: false, error: error.message });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (allIds.length === 1) {
|
|
855
|
+
const result = results[0];
|
|
856
|
+
if (result.success) {
|
|
857
|
+
outputMutationSuccess(result.id, pretty);
|
|
858
|
+
} else {
|
|
859
|
+
throw new Error(result.error || "Unknown error");
|
|
860
|
+
}
|
|
861
|
+
} else {
|
|
862
|
+
if (pretty) {
|
|
863
|
+
for (const result of results) {
|
|
864
|
+
if (result.success) {
|
|
865
|
+
console.log(`\u2713 ${result.id}`);
|
|
866
|
+
} else {
|
|
867
|
+
console.log(`\u2717 ${result.id}: ${result.error}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} else {
|
|
871
|
+
console.log(formatJson(results.map((r) => ({
|
|
872
|
+
id: r.id,
|
|
873
|
+
success: r.success,
|
|
874
|
+
...r.error && { error: r.error }
|
|
875
|
+
}))));
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
} catch (error) {
|
|
879
|
+
outputError(error, pretty);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/cli/commands/list.ts
|
|
885
|
+
function listCommand(program2) {
|
|
886
|
+
program2.command("list").description("List issues").option("--status <status>", "Filter by status").option("-t, --type <type>", "Filter by type").option("--priority <priority>", "Filter by priority").option("--parent <id>", "Filter by parent epic").action(async (options) => {
|
|
887
|
+
const pretty = program2.opts().pretty ?? false;
|
|
888
|
+
try {
|
|
889
|
+
const filters = {};
|
|
890
|
+
if (options.status !== void 0) {
|
|
891
|
+
const status = options.status;
|
|
892
|
+
if (!STATUSES.includes(status)) {
|
|
893
|
+
throw new Error(`Invalid status: ${status}. Must be one of: ${STATUSES.join(", ")}`);
|
|
894
|
+
}
|
|
895
|
+
filters.status = status;
|
|
896
|
+
}
|
|
897
|
+
if (options.type !== void 0) {
|
|
898
|
+
const type = options.type;
|
|
899
|
+
if (!ISSUE_TYPES.includes(type)) {
|
|
900
|
+
throw new Error(`Invalid type: ${type}. Must be one of: ${ISSUE_TYPES.join(", ")}`);
|
|
901
|
+
}
|
|
902
|
+
filters.type = type;
|
|
903
|
+
}
|
|
904
|
+
if (options.priority !== void 0) {
|
|
905
|
+
const priority = parseInt(options.priority, 10);
|
|
906
|
+
if (!PRIORITIES.includes(priority)) {
|
|
907
|
+
throw new Error(`Invalid priority: ${options.priority}. Must be 0-4`);
|
|
908
|
+
}
|
|
909
|
+
filters.priority = priority;
|
|
910
|
+
}
|
|
911
|
+
if (options.parent !== void 0) {
|
|
912
|
+
filters.parent = resolveId(options.parent);
|
|
913
|
+
}
|
|
914
|
+
const issues = getIssues(filters);
|
|
915
|
+
outputIssueList(issues, pretty);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
outputError(error, pretty);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// src/cli/commands/show.ts
|
|
923
|
+
function showCommand(program2) {
|
|
924
|
+
program2.command("show <id>").description("Show issue details").action(async (id) => {
|
|
925
|
+
const pretty = program2.opts().pretty ?? false;
|
|
926
|
+
try {
|
|
927
|
+
const resolvedId = resolveId(id);
|
|
928
|
+
const issue = getIssue(resolvedId);
|
|
929
|
+
if (!issue) {
|
|
930
|
+
throw new Error(`Issue not found: ${id}`);
|
|
931
|
+
}
|
|
932
|
+
outputIssue(issue, pretty);
|
|
933
|
+
} catch (error) {
|
|
934
|
+
outputError(error, pretty);
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/cli/commands/ready.ts
|
|
940
|
+
function readyCommand(program2) {
|
|
941
|
+
program2.command("ready").description("Show issues ready for work (no open blockers)").action(async () => {
|
|
942
|
+
const pretty = program2.opts().pretty ?? false;
|
|
943
|
+
try {
|
|
944
|
+
const issues = getReady();
|
|
945
|
+
outputIssueList(issues, pretty);
|
|
946
|
+
} catch (error) {
|
|
947
|
+
outputError(error, pretty);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/cli/commands/blocked.ts
|
|
953
|
+
function blockedCommand(program2) {
|
|
954
|
+
program2.command("blocked").description("Show blocked issues (have open blockers)").action(async () => {
|
|
955
|
+
const pretty = program2.opts().pretty ?? false;
|
|
956
|
+
try {
|
|
957
|
+
const issues = getBlocked();
|
|
958
|
+
outputIssueList(issues, pretty);
|
|
959
|
+
} catch (error) {
|
|
960
|
+
outputError(error, pretty);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/cli/commands/dep.ts
|
|
966
|
+
function depCommand(program2) {
|
|
967
|
+
const dep = program2.command("dep").description("Manage dependencies");
|
|
968
|
+
dep.command("add <id> <blockerId>").description("Add a blocking dependency").action(async (id, blockerId) => {
|
|
969
|
+
const pretty = program2.opts().pretty ?? false;
|
|
970
|
+
try {
|
|
971
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
972
|
+
const resolvedId = resolveId(id);
|
|
973
|
+
const resolvedBlockerId = resolveId(blockerId);
|
|
974
|
+
const issue = getIssue(resolvedId);
|
|
975
|
+
if (!issue) {
|
|
976
|
+
throw new Error(`Issue not found: ${id}`);
|
|
977
|
+
}
|
|
978
|
+
const blocker = getIssue(resolvedBlockerId);
|
|
979
|
+
if (!blocker) {
|
|
980
|
+
throw new Error(`Blocker issue not found: ${blockerId}`);
|
|
981
|
+
}
|
|
982
|
+
if (resolvedId === resolvedBlockerId) {
|
|
983
|
+
throw new Error("Cannot add self as blocker");
|
|
984
|
+
}
|
|
985
|
+
if (issue.blockedBy.includes(resolvedBlockerId)) {
|
|
986
|
+
throw new Error(`Dependency already exists: ${resolvedId} is blocked by ${resolvedBlockerId}`);
|
|
987
|
+
}
|
|
988
|
+
if (detectCycle(resolvedId, resolvedBlockerId)) {
|
|
989
|
+
throw new Error(`Adding this dependency would create a cycle`);
|
|
990
|
+
}
|
|
991
|
+
const event = {
|
|
992
|
+
type: "update",
|
|
993
|
+
issueId: resolvedId,
|
|
994
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
995
|
+
data: {
|
|
996
|
+
blockedBy: [...issue.blockedBy, resolvedBlockerId]
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
appendEvent(event, pebbleDir);
|
|
1000
|
+
outputMutationSuccess(resolvedId, pretty);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
outputError(error, pretty);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
dep.command("remove <id> <blockerId>").description("Remove a blocking dependency").action(async (id, blockerId) => {
|
|
1006
|
+
const pretty = program2.opts().pretty ?? false;
|
|
1007
|
+
try {
|
|
1008
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1009
|
+
const resolvedId = resolveId(id);
|
|
1010
|
+
const resolvedBlockerId = resolveId(blockerId);
|
|
1011
|
+
const issue = getIssue(resolvedId);
|
|
1012
|
+
if (!issue) {
|
|
1013
|
+
throw new Error(`Issue not found: ${id}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (!issue.blockedBy.includes(resolvedBlockerId)) {
|
|
1016
|
+
throw new Error(`Dependency does not exist: ${resolvedId} is not blocked by ${resolvedBlockerId}`);
|
|
1017
|
+
}
|
|
1018
|
+
const newBlockedBy = issue.blockedBy.filter((b) => b !== resolvedBlockerId);
|
|
1019
|
+
const event = {
|
|
1020
|
+
type: "update",
|
|
1021
|
+
issueId: resolvedId,
|
|
1022
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1023
|
+
data: {
|
|
1024
|
+
blockedBy: newBlockedBy
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
appendEvent(event, pebbleDir);
|
|
1028
|
+
outputMutationSuccess(resolvedId, pretty);
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
outputError(error, pretty);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
dep.command("list <id>").description("List dependencies for an issue").action(async (id) => {
|
|
1034
|
+
const pretty = program2.opts().pretty ?? false;
|
|
1035
|
+
try {
|
|
1036
|
+
const resolvedId = resolveId(id);
|
|
1037
|
+
const issue = getIssue(resolvedId);
|
|
1038
|
+
if (!issue) {
|
|
1039
|
+
throw new Error(`Issue not found: ${id}`);
|
|
1040
|
+
}
|
|
1041
|
+
const blockedBy = getBlockers(resolvedId);
|
|
1042
|
+
const blocking = getBlocking(resolvedId);
|
|
1043
|
+
if (pretty) {
|
|
1044
|
+
console.log(formatDepsPretty(resolvedId, blockedBy, blocking));
|
|
1045
|
+
} else {
|
|
1046
|
+
console.log(formatJson({
|
|
1047
|
+
issueId: resolvedId,
|
|
1048
|
+
blockedBy: blockedBy.map((i) => ({ id: i.id, title: i.title, status: i.status })),
|
|
1049
|
+
blocking: blocking.map((i) => ({ id: i.id, title: i.title, status: i.status }))
|
|
1050
|
+
}));
|
|
1051
|
+
}
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
outputError(error, pretty);
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
dep.command("tree <id>").description("Show dependency tree").action(async (id) => {
|
|
1057
|
+
const pretty = program2.opts().pretty ?? false;
|
|
1058
|
+
try {
|
|
1059
|
+
const resolvedId = resolveId(id);
|
|
1060
|
+
const issue = getIssue(resolvedId);
|
|
1061
|
+
if (!issue) {
|
|
1062
|
+
throw new Error(`Issue not found: ${id}`);
|
|
1063
|
+
}
|
|
1064
|
+
const events = readEvents();
|
|
1065
|
+
const state = computeState(events);
|
|
1066
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1067
|
+
const tree = buildDepTree(resolvedId, visited, 0, state);
|
|
1068
|
+
if (pretty) {
|
|
1069
|
+
console.log(formatDepTree(tree));
|
|
1070
|
+
} else {
|
|
1071
|
+
console.log(formatJson(tree));
|
|
1072
|
+
}
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
outputError(error, pretty);
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
function buildDepTree(issueId, visited, depth, state) {
|
|
1079
|
+
if (visited.has(issueId)) {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
visited.add(issueId);
|
|
1083
|
+
const issue = state.get(issueId);
|
|
1084
|
+
if (!issue) {
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
const blockedBy = [];
|
|
1088
|
+
for (const blockerId of issue.blockedBy) {
|
|
1089
|
+
const child = buildDepTree(blockerId, visited, depth + 1, state);
|
|
1090
|
+
if (child) {
|
|
1091
|
+
blockedBy.push(child);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return {
|
|
1095
|
+
id: issue.id,
|
|
1096
|
+
title: issue.title,
|
|
1097
|
+
status: issue.status,
|
|
1098
|
+
depth,
|
|
1099
|
+
blockedBy
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function formatDepTree(node, prefix = "", isRoot = true) {
|
|
1103
|
+
if (!node) {
|
|
1104
|
+
return "";
|
|
1105
|
+
}
|
|
1106
|
+
const lines = [];
|
|
1107
|
+
const statusIcon = node.status === "closed" ? "\u2713" : "\u25CB";
|
|
1108
|
+
if (isRoot) {
|
|
1109
|
+
lines.push(`${statusIcon} ${node.id} - ${node.title}`);
|
|
1110
|
+
}
|
|
1111
|
+
for (let i = 0; i < node.blockedBy.length; i++) {
|
|
1112
|
+
const child = node.blockedBy[i];
|
|
1113
|
+
const isLast = i === node.blockedBy.length - 1;
|
|
1114
|
+
const connector = isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
|
|
1115
|
+
const childPrefix = prefix + (isLast ? " " : "\u2502 ");
|
|
1116
|
+
const childStatusIcon = child.status === "closed" ? "\u2713" : "\u25CB";
|
|
1117
|
+
lines.push(`${prefix}${connector}${childStatusIcon} ${child.id} - ${child.title}`);
|
|
1118
|
+
if (child.blockedBy.length > 0) {
|
|
1119
|
+
lines.push(formatDepTree(child, childPrefix, false));
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return lines.join("\n");
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// src/cli/commands/comments.ts
|
|
1126
|
+
function commentsCommand(program2) {
|
|
1127
|
+
const comments = program2.command("comments").description("Manage comments");
|
|
1128
|
+
comments.command("add <id> <text>").description("Add a comment to an issue").action(async (id, text) => {
|
|
1129
|
+
const pretty = program2.opts().pretty ?? false;
|
|
1130
|
+
try {
|
|
1131
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1132
|
+
const resolvedId = resolveId(id);
|
|
1133
|
+
const issue = getIssue(resolvedId);
|
|
1134
|
+
if (!issue) {
|
|
1135
|
+
throw new Error(`Issue not found: ${id}`);
|
|
1136
|
+
}
|
|
1137
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1138
|
+
const event = {
|
|
1139
|
+
type: "comment",
|
|
1140
|
+
issueId: resolvedId,
|
|
1141
|
+
timestamp,
|
|
1142
|
+
data: {
|
|
1143
|
+
text,
|
|
1144
|
+
timestamp
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
appendEvent(event, pebbleDir);
|
|
1148
|
+
outputMutationSuccess(resolvedId, pretty);
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
outputError(error, pretty);
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/cli/commands/graph.ts
|
|
1156
|
+
function graphCommand(program2) {
|
|
1157
|
+
program2.command("graph").description("Show dependency graph").option("--root <id>", "Filter to subtree rooted at issue").action(async (options) => {
|
|
1158
|
+
const pretty = program2.opts().pretty ?? false;
|
|
1159
|
+
try {
|
|
1160
|
+
let issues;
|
|
1161
|
+
if (options.root) {
|
|
1162
|
+
const rootId = resolveId(options.root);
|
|
1163
|
+
const rootIssue = getIssue(rootId);
|
|
1164
|
+
if (!rootIssue) {
|
|
1165
|
+
throw new Error(`Issue not found: ${options.root}`);
|
|
1166
|
+
}
|
|
1167
|
+
issues = getSubtree(rootId);
|
|
1168
|
+
} else {
|
|
1169
|
+
issues = getIssues({});
|
|
1170
|
+
}
|
|
1171
|
+
if (pretty) {
|
|
1172
|
+
console.log(formatGraphPretty(issues));
|
|
1173
|
+
} else {
|
|
1174
|
+
console.log(formatJson({
|
|
1175
|
+
nodes: issues.map((i) => ({
|
|
1176
|
+
id: i.id,
|
|
1177
|
+
title: i.title,
|
|
1178
|
+
status: i.status,
|
|
1179
|
+
blockedBy: i.blockedBy
|
|
1180
|
+
}))
|
|
1181
|
+
}));
|
|
1182
|
+
}
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
outputError(error, pretty);
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
function getSubtree(rootId) {
|
|
1189
|
+
const allIssues = getIssues({});
|
|
1190
|
+
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
|
1191
|
+
const neighborhood = /* @__PURE__ */ new Set();
|
|
1192
|
+
function traverseUpstream(id) {
|
|
1193
|
+
if (neighborhood.has(id)) return;
|
|
1194
|
+
neighborhood.add(id);
|
|
1195
|
+
const issue = issueMap.get(id);
|
|
1196
|
+
if (issue) {
|
|
1197
|
+
for (const blockerId of issue.blockedBy) {
|
|
1198
|
+
traverseUpstream(blockerId);
|
|
1199
|
+
}
|
|
1200
|
+
if (issue.parent) {
|
|
1201
|
+
traverseUpstream(issue.parent);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function traverseDownstream(id) {
|
|
1206
|
+
if (neighborhood.has(id)) return;
|
|
1207
|
+
neighborhood.add(id);
|
|
1208
|
+
for (const issue of allIssues) {
|
|
1209
|
+
if (issue.blockedBy.includes(id)) {
|
|
1210
|
+
traverseDownstream(issue.id);
|
|
1211
|
+
}
|
|
1212
|
+
if (issue.parent === id) {
|
|
1213
|
+
traverseDownstream(issue.id);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
traverseUpstream(rootId);
|
|
1218
|
+
traverseDownstream(rootId);
|
|
1219
|
+
return allIssues.filter((i) => neighborhood.has(i.id));
|
|
1220
|
+
}
|
|
1221
|
+
function formatGraphPretty(issues) {
|
|
1222
|
+
if (issues.length === 0) {
|
|
1223
|
+
return "No issues found.";
|
|
1224
|
+
}
|
|
1225
|
+
const lines = [];
|
|
1226
|
+
lines.push("Dependency Graph");
|
|
1227
|
+
lines.push("================");
|
|
1228
|
+
lines.push("");
|
|
1229
|
+
const blockedByMap = /* @__PURE__ */ new Map();
|
|
1230
|
+
const blockingMap = /* @__PURE__ */ new Map();
|
|
1231
|
+
const issueMap = /* @__PURE__ */ new Map();
|
|
1232
|
+
for (const issue of issues) {
|
|
1233
|
+
issueMap.set(issue.id, issue);
|
|
1234
|
+
blockedByMap.set(issue.id, issue.blockedBy);
|
|
1235
|
+
for (const blockerId of issue.blockedBy) {
|
|
1236
|
+
if (!blockingMap.has(blockerId)) {
|
|
1237
|
+
blockingMap.set(blockerId, []);
|
|
1238
|
+
}
|
|
1239
|
+
blockingMap.get(blockerId).push(issue.id);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const levels = /* @__PURE__ */ new Map();
|
|
1243
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1244
|
+
function calculateLevel(id) {
|
|
1245
|
+
if (levels.has(id)) return levels.get(id);
|
|
1246
|
+
if (visited.has(id)) return 0;
|
|
1247
|
+
visited.add(id);
|
|
1248
|
+
const blockedBy = blockedByMap.get(id) || [];
|
|
1249
|
+
let maxBlockerLevel = -1;
|
|
1250
|
+
for (const blockerId of blockedBy) {
|
|
1251
|
+
const blockerLevel = calculateLevel(blockerId);
|
|
1252
|
+
maxBlockerLevel = Math.max(maxBlockerLevel, blockerLevel);
|
|
1253
|
+
}
|
|
1254
|
+
const level = maxBlockerLevel + 1;
|
|
1255
|
+
levels.set(id, level);
|
|
1256
|
+
return level;
|
|
1257
|
+
}
|
|
1258
|
+
for (const issue of issues) {
|
|
1259
|
+
calculateLevel(issue.id);
|
|
1260
|
+
}
|
|
1261
|
+
const byLevel = /* @__PURE__ */ new Map();
|
|
1262
|
+
for (const issue of issues) {
|
|
1263
|
+
const level = levels.get(issue.id) || 0;
|
|
1264
|
+
if (!byLevel.has(level)) {
|
|
1265
|
+
byLevel.set(level, []);
|
|
1266
|
+
}
|
|
1267
|
+
byLevel.get(level).push(issue);
|
|
1268
|
+
}
|
|
1269
|
+
const maxLevel = Math.max(...Array.from(levels.values()));
|
|
1270
|
+
for (let level = 0; level <= maxLevel; level++) {
|
|
1271
|
+
const levelIssues = byLevel.get(level) || [];
|
|
1272
|
+
if (levelIssues.length === 0) continue;
|
|
1273
|
+
lines.push(`Level ${level}:`);
|
|
1274
|
+
for (const issue of levelIssues) {
|
|
1275
|
+
const statusIcon = issue.status === "closed" ? "\u2713" : "\u25CB";
|
|
1276
|
+
const blockers = issue.blockedBy.length > 0 ? ` \u2190 [${issue.blockedBy.join(", ")}]` : "";
|
|
1277
|
+
lines.push(` ${statusIcon} ${issue.id} - ${issue.title}${blockers}`);
|
|
1278
|
+
}
|
|
1279
|
+
lines.push("");
|
|
1280
|
+
}
|
|
1281
|
+
return lines.join("\n");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// src/cli/commands/ui.ts
|
|
1285
|
+
import express from "express";
|
|
1286
|
+
import cors from "cors";
|
|
1287
|
+
import { fileURLToPath } from "url";
|
|
1288
|
+
import path2 from "path";
|
|
1289
|
+
import fs2 from "fs";
|
|
1290
|
+
import net from "net";
|
|
1291
|
+
import open from "open";
|
|
1292
|
+
import chokidar from "chokidar";
|
|
1293
|
+
function isPortAvailable(port) {
|
|
1294
|
+
return new Promise((resolve3) => {
|
|
1295
|
+
const server = net.createServer();
|
|
1296
|
+
server.once("error", () => resolve3(false));
|
|
1297
|
+
server.once("listening", () => {
|
|
1298
|
+
server.close();
|
|
1299
|
+
resolve3(true);
|
|
1300
|
+
});
|
|
1301
|
+
server.listen(port);
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
1305
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
1306
|
+
const port = startPort + i;
|
|
1307
|
+
if (await isPortAvailable(port)) {
|
|
1308
|
+
return port;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
throw new Error(`No available port found (tried ${startPort}-${startPort + maxAttempts - 1})`);
|
|
1312
|
+
}
|
|
1313
|
+
function readEventsFromFiles(filePaths) {
|
|
1314
|
+
const allEvents = [];
|
|
1315
|
+
for (const filePath of filePaths) {
|
|
1316
|
+
const events = readEventsFromFile(filePath);
|
|
1317
|
+
for (const event of events) {
|
|
1318
|
+
allEvents.push({ ...event, _source: filePath });
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return allEvents;
|
|
1322
|
+
}
|
|
1323
|
+
function mergeIssuesFromFiles(filePaths) {
|
|
1324
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1325
|
+
for (const filePath of filePaths) {
|
|
1326
|
+
const events = readEventsFromFile(filePath);
|
|
1327
|
+
const state = computeState(events);
|
|
1328
|
+
for (const [id, issue] of state) {
|
|
1329
|
+
const existing = merged.get(id);
|
|
1330
|
+
if (!existing) {
|
|
1331
|
+
merged.set(id, { issue, sources: /* @__PURE__ */ new Set([filePath]) });
|
|
1332
|
+
} else {
|
|
1333
|
+
existing.sources.add(filePath);
|
|
1334
|
+
if (new Date(issue.updatedAt) > new Date(existing.issue.updatedAt)) {
|
|
1335
|
+
merged.set(id, { issue, sources: existing.sources });
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return Array.from(merged.values()).map(({ issue, sources }) => ({
|
|
1341
|
+
...issue,
|
|
1342
|
+
_sources: Array.from(sources)
|
|
1343
|
+
}));
|
|
1344
|
+
}
|
|
1345
|
+
function uiCommand(program2) {
|
|
1346
|
+
const defaultPort = process.env.PEBBLE_UI_PORT || "3333";
|
|
1347
|
+
program2.command("ui").description("Serve the React UI").option("--port <port>", "Port to serve on", defaultPort).option("--no-open", "Do not open browser automatically").option("--files <paths>", "Comma-separated paths to issues.jsonl files for multi-worktree view").action(async (options) => {
|
|
1348
|
+
const pretty = program2.opts().pretty ?? false;
|
|
1349
|
+
try {
|
|
1350
|
+
let issueFiles = [];
|
|
1351
|
+
if (options.files) {
|
|
1352
|
+
issueFiles = options.files.split(",").map((p) => p.trim()).filter(Boolean);
|
|
1353
|
+
if (issueFiles.length === 0) {
|
|
1354
|
+
console.error("Error: --files option requires at least one path");
|
|
1355
|
+
process.exit(1);
|
|
1356
|
+
}
|
|
1357
|
+
issueFiles = issueFiles.map((p) => path2.resolve(process.cwd(), p));
|
|
1358
|
+
console.log(`Multi-worktree mode: watching ${issueFiles.length} file(s)`);
|
|
1359
|
+
for (const f of issueFiles) {
|
|
1360
|
+
console.log(` - ${f}`);
|
|
1361
|
+
}
|
|
1362
|
+
} else {
|
|
1363
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1364
|
+
issueFiles = [path2.join(pebbleDir, "issues.jsonl")];
|
|
1365
|
+
}
|
|
1366
|
+
if (!options.files) {
|
|
1367
|
+
getOrCreatePebbleDir();
|
|
1368
|
+
}
|
|
1369
|
+
const app = express();
|
|
1370
|
+
app.use(cors());
|
|
1371
|
+
app.use(express.json());
|
|
1372
|
+
const isMultiWorktree = () => issueFiles.length > 1;
|
|
1373
|
+
app.get("/api/sources", (_req, res) => {
|
|
1374
|
+
try {
|
|
1375
|
+
res.json({ files: issueFiles, isMultiWorktree: isMultiWorktree() });
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
res.status(500).json({ error: error.message });
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
app.post("/api/sources", (req, res) => {
|
|
1381
|
+
try {
|
|
1382
|
+
const { path: filePath } = req.body;
|
|
1383
|
+
if (!filePath || typeof filePath !== "string") {
|
|
1384
|
+
res.status(400).json({ error: "path is required" });
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const resolved = path2.resolve(process.cwd(), filePath);
|
|
1388
|
+
if (!fs2.existsSync(resolved)) {
|
|
1389
|
+
res.status(400).json({ error: `File not found: ${filePath}` });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (issueFiles.includes(resolved)) {
|
|
1393
|
+
res.status(400).json({ error: "File already being watched" });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
issueFiles.push(resolved);
|
|
1397
|
+
watcher.add(resolved);
|
|
1398
|
+
console.log(`Added source: ${resolved}`);
|
|
1399
|
+
res.json({ files: issueFiles, isMultiWorktree: isMultiWorktree() });
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
res.status(500).json({ error: error.message });
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
app.delete("/api/sources/:index", (req, res) => {
|
|
1405
|
+
try {
|
|
1406
|
+
const index = parseInt(req.params.index, 10);
|
|
1407
|
+
if (isNaN(index) || index < 0 || index >= issueFiles.length) {
|
|
1408
|
+
res.status(400).json({ error: `Invalid index: ${req.params.index}` });
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (issueFiles.length === 1) {
|
|
1412
|
+
res.status(400).json({ error: "Cannot remove the last source file" });
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
const removed = issueFiles.splice(index, 1)[0];
|
|
1416
|
+
watcher.unwatch(removed);
|
|
1417
|
+
console.log(`Removed source: ${removed}`);
|
|
1418
|
+
res.json({ files: issueFiles, isMultiWorktree: isMultiWorktree() });
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
res.status(500).json({ error: error.message });
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
app.get("/api/issues", (_req, res) => {
|
|
1424
|
+
try {
|
|
1425
|
+
if (isMultiWorktree()) {
|
|
1426
|
+
const issues = mergeIssuesFromFiles(issueFiles);
|
|
1427
|
+
res.json(issues);
|
|
1428
|
+
} else {
|
|
1429
|
+
const issues = getIssues({});
|
|
1430
|
+
res.json(issues);
|
|
1431
|
+
}
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
res.status(500).json({ error: error.message });
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
app.get("/api/events", (_req, res) => {
|
|
1437
|
+
try {
|
|
1438
|
+
if (isMultiWorktree()) {
|
|
1439
|
+
const events = readEventsFromFiles(issueFiles);
|
|
1440
|
+
res.json(events);
|
|
1441
|
+
} else {
|
|
1442
|
+
const events = readEvents();
|
|
1443
|
+
res.json(events);
|
|
1444
|
+
}
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
res.status(500).json({ error: error.message });
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
1450
|
+
let eventCounter = 0;
|
|
1451
|
+
app.get("/api/events/stream", (req, res) => {
|
|
1452
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1453
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1454
|
+
res.setHeader("Connection", "keep-alive");
|
|
1455
|
+
res.flushHeaders();
|
|
1456
|
+
sseClients.add(res);
|
|
1457
|
+
eventCounter++;
|
|
1458
|
+
res.write(`id: ${eventCounter}
|
|
1459
|
+
data: {"type":"connected"}
|
|
1460
|
+
|
|
1461
|
+
`);
|
|
1462
|
+
req.on("close", () => {
|
|
1463
|
+
sseClients.delete(res);
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
const heartbeatInterval = setInterval(() => {
|
|
1467
|
+
for (const client of sseClients) {
|
|
1468
|
+
client.write(": heartbeat\n\n");
|
|
1469
|
+
}
|
|
1470
|
+
}, 3e4);
|
|
1471
|
+
const watcher = chokidar.watch(issueFiles, {
|
|
1472
|
+
persistent: true,
|
|
1473
|
+
ignoreInitial: true
|
|
1474
|
+
});
|
|
1475
|
+
watcher.on("change", () => {
|
|
1476
|
+
eventCounter++;
|
|
1477
|
+
const message = JSON.stringify({ type: "change", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1478
|
+
for (const client of sseClients) {
|
|
1479
|
+
client.write(`id: ${eventCounter}
|
|
1480
|
+
data: ${message}
|
|
1481
|
+
|
|
1482
|
+
`);
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
const shutdown = () => {
|
|
1486
|
+
clearInterval(heartbeatInterval);
|
|
1487
|
+
watcher.close();
|
|
1488
|
+
process.exit(0);
|
|
1489
|
+
};
|
|
1490
|
+
process.on("SIGTERM", shutdown);
|
|
1491
|
+
process.on("SIGINT", shutdown);
|
|
1492
|
+
app.post("/api/issues", (req, res) => {
|
|
1493
|
+
try {
|
|
1494
|
+
let targetFile = null;
|
|
1495
|
+
if (isMultiWorktree() && req.query.target !== void 0) {
|
|
1496
|
+
const targetIndex = parseInt(req.query.target, 10);
|
|
1497
|
+
if (isNaN(targetIndex) || targetIndex < 0 || targetIndex >= issueFiles.length) {
|
|
1498
|
+
res.status(400).json({ error: `Invalid target index: ${req.query.target}` });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
targetFile = issueFiles[targetIndex];
|
|
1502
|
+
}
|
|
1503
|
+
const pebbleDir = targetFile ? path2.dirname(targetFile) : getOrCreatePebbleDir();
|
|
1504
|
+
const config = getConfig(pebbleDir);
|
|
1505
|
+
const { title, type, priority, description, parent } = req.body;
|
|
1506
|
+
if (!title || typeof title !== "string") {
|
|
1507
|
+
res.status(400).json({ error: "Title is required" });
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
const issueType = type || "task";
|
|
1511
|
+
if (!ISSUE_TYPES.includes(issueType)) {
|
|
1512
|
+
res.status(400).json({ error: `Invalid type. Must be one of: ${ISSUE_TYPES.join(", ")}` });
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
const issuePriority = priority ?? 2;
|
|
1516
|
+
if (!PRIORITIES.includes(issuePriority)) {
|
|
1517
|
+
res.status(400).json({ error: "Priority must be 0-4" });
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
if (parent) {
|
|
1521
|
+
const parentIssue = getIssue(parent);
|
|
1522
|
+
if (!parentIssue) {
|
|
1523
|
+
res.status(400).json({ error: `Parent issue not found: ${parent}` });
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (parentIssue.type !== "epic") {
|
|
1527
|
+
res.status(400).json({ error: "Parent must be an epic" });
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (parentIssue.status === "closed") {
|
|
1531
|
+
res.status(400).json({ error: "Cannot add children to a closed epic" });
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
const issueId = generateId(config.prefix);
|
|
1536
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1537
|
+
const event = {
|
|
1538
|
+
type: "create",
|
|
1539
|
+
issueId,
|
|
1540
|
+
timestamp,
|
|
1541
|
+
data: {
|
|
1542
|
+
title,
|
|
1543
|
+
type: issueType,
|
|
1544
|
+
priority: issuePriority,
|
|
1545
|
+
description,
|
|
1546
|
+
parent
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
appendEvent(event, pebbleDir);
|
|
1550
|
+
const issue = getIssue(issueId);
|
|
1551
|
+
res.status(201).json(issue);
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
res.status(500).json({ error: error.message });
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
app.post("/api/issues/bulk/close", (req, res) => {
|
|
1557
|
+
try {
|
|
1558
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1559
|
+
const { ids } = req.body;
|
|
1560
|
+
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
1561
|
+
res.status(400).json({ error: "ids array is required" });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
const results = [];
|
|
1565
|
+
for (const rawId of ids) {
|
|
1566
|
+
try {
|
|
1567
|
+
const issueId = resolveId(rawId);
|
|
1568
|
+
const issue = getIssue(issueId);
|
|
1569
|
+
if (!issue) {
|
|
1570
|
+
results.push({ id: rawId, success: false, error: `Issue not found: ${rawId}` });
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
if (issue.status === "closed") {
|
|
1574
|
+
results.push({ id: issueId, success: true });
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
if (issue.type === "epic" && hasOpenChildren(issueId)) {
|
|
1578
|
+
results.push({ id: issueId, success: false, error: "Cannot close epic with open children" });
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
const event = {
|
|
1582
|
+
issueId,
|
|
1583
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1584
|
+
type: "close",
|
|
1585
|
+
data: { reason: "Bulk close" }
|
|
1586
|
+
};
|
|
1587
|
+
appendEvent(event, pebbleDir);
|
|
1588
|
+
results.push({ id: issueId, success: true });
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
results.push({ id: rawId, success: false, error: error.message });
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
res.json({ results });
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
res.status(500).json({ error: error.message });
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
app.post("/api/issues/bulk/update", (req, res) => {
|
|
1599
|
+
try {
|
|
1600
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1601
|
+
const { ids, updates } = req.body;
|
|
1602
|
+
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
1603
|
+
res.status(400).json({ error: "ids array is required" });
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (!updates || Object.keys(updates).length === 0) {
|
|
1607
|
+
res.status(400).json({ error: "updates object is required" });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (updates.status) {
|
|
1611
|
+
const validStatuses = ["open", "in_progress", "blocked"];
|
|
1612
|
+
if (!validStatuses.includes(updates.status)) {
|
|
1613
|
+
res.status(400).json({
|
|
1614
|
+
error: `Invalid status: ${updates.status}. Use close endpoint to close issues.`
|
|
1615
|
+
});
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (updates.priority !== void 0) {
|
|
1620
|
+
if (typeof updates.priority !== "number" || updates.priority < 0 || updates.priority > 4) {
|
|
1621
|
+
res.status(400).json({ error: "Priority must be 0-4" });
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
const results = [];
|
|
1626
|
+
for (const rawId of ids) {
|
|
1627
|
+
try {
|
|
1628
|
+
const issueId = resolveId(rawId);
|
|
1629
|
+
const issue = getIssue(issueId);
|
|
1630
|
+
if (!issue) {
|
|
1631
|
+
results.push({ id: rawId, success: false, error: `Issue not found: ${rawId}` });
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
const event = {
|
|
1635
|
+
issueId,
|
|
1636
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1637
|
+
type: "update",
|
|
1638
|
+
data: {
|
|
1639
|
+
...updates.status && { status: updates.status },
|
|
1640
|
+
...updates.priority !== void 0 && { priority: updates.priority }
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
appendEvent(event, pebbleDir);
|
|
1644
|
+
results.push({ id: issueId, success: true });
|
|
1645
|
+
} catch (error) {
|
|
1646
|
+
results.push({ id: rawId, success: false, error: error.message });
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
res.json({ results });
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
res.status(500).json({ error: error.message });
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
app.put("/api/issues/:id", (req, res) => {
|
|
1655
|
+
try {
|
|
1656
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1657
|
+
const issueId = resolveId(req.params.id);
|
|
1658
|
+
const issue = getIssue(issueId);
|
|
1659
|
+
if (!issue) {
|
|
1660
|
+
res.status(404).json({ error: `Issue not found: ${req.params.id}` });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const { title, type, priority, status, description, parent } = req.body;
|
|
1664
|
+
const updates = {};
|
|
1665
|
+
if (title !== void 0) {
|
|
1666
|
+
if (typeof title !== "string" || title.trim() === "") {
|
|
1667
|
+
res.status(400).json({ error: "Title cannot be empty" });
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
updates.title = title;
|
|
1671
|
+
}
|
|
1672
|
+
if (type !== void 0) {
|
|
1673
|
+
if (!ISSUE_TYPES.includes(type)) {
|
|
1674
|
+
res.status(400).json({ error: `Invalid type. Must be one of: ${ISSUE_TYPES.join(", ")}` });
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
updates.type = type;
|
|
1678
|
+
}
|
|
1679
|
+
if (priority !== void 0) {
|
|
1680
|
+
if (!PRIORITIES.includes(priority)) {
|
|
1681
|
+
res.status(400).json({ error: "Priority must be 0-4" });
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
updates.priority = priority;
|
|
1685
|
+
}
|
|
1686
|
+
if (status !== void 0) {
|
|
1687
|
+
if (!STATUSES.includes(status)) {
|
|
1688
|
+
res.status(400).json({ error: `Invalid status. Must be one of: ${STATUSES.join(", ")}` });
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
updates.status = status;
|
|
1692
|
+
}
|
|
1693
|
+
if (description !== void 0) {
|
|
1694
|
+
updates.description = description;
|
|
1695
|
+
}
|
|
1696
|
+
if (parent !== void 0) {
|
|
1697
|
+
if (parent !== null) {
|
|
1698
|
+
const parentIssue = getIssue(parent);
|
|
1699
|
+
if (!parentIssue) {
|
|
1700
|
+
res.status(400).json({ error: `Parent issue not found: ${parent}` });
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (parentIssue.type !== "epic") {
|
|
1704
|
+
res.status(400).json({ error: "Parent must be an epic" });
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
updates.parent = parent;
|
|
1709
|
+
}
|
|
1710
|
+
if (Object.keys(updates).length === 0) {
|
|
1711
|
+
res.status(400).json({ error: "No valid updates provided" });
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1715
|
+
const event = {
|
|
1716
|
+
type: "update",
|
|
1717
|
+
issueId,
|
|
1718
|
+
timestamp,
|
|
1719
|
+
data: updates
|
|
1720
|
+
};
|
|
1721
|
+
appendEvent(event, pebbleDir);
|
|
1722
|
+
const updatedIssue = getIssue(issueId);
|
|
1723
|
+
res.json(updatedIssue);
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
res.status(500).json({ error: error.message });
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
app.post("/api/issues/:id/close", (req, res) => {
|
|
1729
|
+
try {
|
|
1730
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1731
|
+
const issueId = resolveId(req.params.id);
|
|
1732
|
+
const issue = getIssue(issueId);
|
|
1733
|
+
if (!issue) {
|
|
1734
|
+
res.status(404).json({ error: `Issue not found: ${req.params.id}` });
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (issue.status === "closed") {
|
|
1738
|
+
res.status(400).json({ error: "Issue is already closed" });
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (issue.type === "epic" && hasOpenChildren(issueId)) {
|
|
1742
|
+
res.status(400).json({ error: "Cannot close epic with open children" });
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
const { reason } = req.body;
|
|
1746
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1747
|
+
const event = {
|
|
1748
|
+
type: "close",
|
|
1749
|
+
issueId,
|
|
1750
|
+
timestamp,
|
|
1751
|
+
data: { reason }
|
|
1752
|
+
};
|
|
1753
|
+
appendEvent(event, pebbleDir);
|
|
1754
|
+
const closedIssue = getIssue(issueId);
|
|
1755
|
+
res.json(closedIssue);
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
res.status(500).json({ error: error.message });
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
app.post("/api/issues/:id/reopen", (req, res) => {
|
|
1761
|
+
try {
|
|
1762
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1763
|
+
const issueId = resolveId(req.params.id);
|
|
1764
|
+
const issue = getIssue(issueId);
|
|
1765
|
+
if (!issue) {
|
|
1766
|
+
res.status(404).json({ error: `Issue not found: ${req.params.id}` });
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (issue.status !== "closed") {
|
|
1770
|
+
res.status(400).json({ error: "Issue is not closed" });
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const { reason } = req.body;
|
|
1774
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1775
|
+
const event = {
|
|
1776
|
+
type: "reopen",
|
|
1777
|
+
issueId,
|
|
1778
|
+
timestamp,
|
|
1779
|
+
data: { reason }
|
|
1780
|
+
};
|
|
1781
|
+
appendEvent(event, pebbleDir);
|
|
1782
|
+
const reopenedIssue = getIssue(issueId);
|
|
1783
|
+
res.json(reopenedIssue);
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
res.status(500).json({ error: error.message });
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1788
|
+
app.post("/api/issues/:id/comments", (req, res) => {
|
|
1789
|
+
try {
|
|
1790
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1791
|
+
const issueId = resolveId(req.params.id);
|
|
1792
|
+
const issue = getIssue(issueId);
|
|
1793
|
+
if (!issue) {
|
|
1794
|
+
res.status(404).json({ error: `Issue not found: ${req.params.id}` });
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const { text, author } = req.body;
|
|
1798
|
+
if (!text || typeof text !== "string" || text.trim() === "") {
|
|
1799
|
+
res.status(400).json({ error: "Comment text is required" });
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1803
|
+
const event = {
|
|
1804
|
+
type: "comment",
|
|
1805
|
+
issueId,
|
|
1806
|
+
timestamp,
|
|
1807
|
+
data: {
|
|
1808
|
+
text,
|
|
1809
|
+
timestamp,
|
|
1810
|
+
author
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
appendEvent(event, pebbleDir);
|
|
1814
|
+
const updatedIssue = getIssue(issueId);
|
|
1815
|
+
res.json(updatedIssue);
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
res.status(500).json({ error: error.message });
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
app.post("/api/issues/:id/deps", (req, res) => {
|
|
1821
|
+
try {
|
|
1822
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1823
|
+
const issueId = resolveId(req.params.id);
|
|
1824
|
+
const issue = getIssue(issueId);
|
|
1825
|
+
if (!issue) {
|
|
1826
|
+
res.status(404).json({ error: `Issue not found: ${req.params.id}` });
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
const { blockerId } = req.body;
|
|
1830
|
+
if (!blockerId) {
|
|
1831
|
+
res.status(400).json({ error: "blockerId is required" });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const resolvedBlockerId = resolveId(blockerId);
|
|
1835
|
+
const blockerIssue = getIssue(resolvedBlockerId);
|
|
1836
|
+
if (!blockerIssue) {
|
|
1837
|
+
res.status(404).json({ error: `Blocker issue not found: ${blockerId}` });
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
if (issue.blockedBy.includes(resolvedBlockerId)) {
|
|
1841
|
+
res.status(400).json({ error: "Dependency already exists" });
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (detectCycle(issueId, resolvedBlockerId)) {
|
|
1845
|
+
res.status(400).json({ error: "Adding this dependency would create a cycle" });
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1849
|
+
const event = {
|
|
1850
|
+
type: "update",
|
|
1851
|
+
issueId,
|
|
1852
|
+
timestamp,
|
|
1853
|
+
data: {
|
|
1854
|
+
blockedBy: [...issue.blockedBy, resolvedBlockerId]
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
appendEvent(event, pebbleDir);
|
|
1858
|
+
const updatedIssue = getIssue(issueId);
|
|
1859
|
+
res.json(updatedIssue);
|
|
1860
|
+
} catch (error) {
|
|
1861
|
+
res.status(500).json({ error: error.message });
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1864
|
+
app.delete("/api/issues/:id/deps/:blockerId", (req, res) => {
|
|
1865
|
+
try {
|
|
1866
|
+
const pebbleDir = getOrCreatePebbleDir();
|
|
1867
|
+
const issueId = resolveId(req.params.id);
|
|
1868
|
+
const issue = getIssue(issueId);
|
|
1869
|
+
if (!issue) {
|
|
1870
|
+
res.status(404).json({ error: `Issue not found: ${req.params.id}` });
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const resolvedBlockerId = resolveId(req.params.blockerId);
|
|
1874
|
+
if (!issue.blockedBy.includes(resolvedBlockerId)) {
|
|
1875
|
+
res.status(400).json({ error: "Dependency does not exist" });
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1879
|
+
const event = {
|
|
1880
|
+
type: "update",
|
|
1881
|
+
issueId,
|
|
1882
|
+
timestamp,
|
|
1883
|
+
data: {
|
|
1884
|
+
blockedBy: issue.blockedBy.filter((id) => id !== resolvedBlockerId)
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1887
|
+
appendEvent(event, pebbleDir);
|
|
1888
|
+
const updatedIssue = getIssue(issueId);
|
|
1889
|
+
res.json(updatedIssue);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
res.status(500).json({ error: error.message });
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
const __filename2 = fileURLToPath(import.meta.url);
|
|
1895
|
+
const __dirname2 = path2.dirname(__filename2);
|
|
1896
|
+
const uiPath = path2.resolve(__dirname2, "../ui");
|
|
1897
|
+
app.use(express.static(uiPath));
|
|
1898
|
+
app.get("*", (_req, res) => {
|
|
1899
|
+
res.sendFile(path2.join(uiPath, "index.html"));
|
|
1900
|
+
});
|
|
1901
|
+
const requestedPort = parseInt(options.port, 10);
|
|
1902
|
+
const actualPort = await findAvailablePort(requestedPort);
|
|
1903
|
+
if (actualPort !== requestedPort) {
|
|
1904
|
+
console.log(`Port ${requestedPort} is busy, using ${actualPort} instead`);
|
|
1905
|
+
}
|
|
1906
|
+
app.listen(actualPort, () => {
|
|
1907
|
+
const url = `http://localhost:${actualPort}`;
|
|
1908
|
+
console.log(`Pebble UI running at ${url}`);
|
|
1909
|
+
if (options.open !== false) {
|
|
1910
|
+
open(url);
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
outputError(error, pretty);
|
|
1915
|
+
process.exit(1);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// src/cli/commands/import.ts
|
|
1921
|
+
import * as fs3 from "fs";
|
|
1922
|
+
import * as readline from "readline";
|
|
1923
|
+
import * as path3 from "path";
|
|
1924
|
+
function importCommand(program2) {
|
|
1925
|
+
program2.command("import <file>").description("Import issues from a Beads issues.jsonl file").option("--dry-run", "Show what would be imported without writing").option("--prefix <prefix>", "Override the ID prefix (default: derive from folder name)").action(async (file, options) => {
|
|
1926
|
+
const pretty = program2.opts().pretty ?? false;
|
|
1927
|
+
try {
|
|
1928
|
+
if (!fs3.existsSync(file)) {
|
|
1929
|
+
throw new Error(`File not found: ${file}`);
|
|
1930
|
+
}
|
|
1931
|
+
const beadsIssues = await parseBeadsFile(file);
|
|
1932
|
+
if (beadsIssues.length === 0) {
|
|
1933
|
+
console.log(pretty ? "No issues found in file." : formatJson({ imported: 0 }));
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
const prefix = options.prefix ?? derivePrefix(path3.basename(process.cwd()));
|
|
1937
|
+
const { events, idMap, stats } = convertToPebbleEvents(beadsIssues, prefix);
|
|
1938
|
+
if (options.dryRun) {
|
|
1939
|
+
if (pretty) {
|
|
1940
|
+
console.log("Dry run - would import:");
|
|
1941
|
+
console.log(` ${stats.created} issues`);
|
|
1942
|
+
console.log(` ${stats.closed} closed issues`);
|
|
1943
|
+
console.log(` ${stats.dependencies} block dependencies`);
|
|
1944
|
+
console.log(` ${stats.parentChild} parent-child relationships`);
|
|
1945
|
+
console.log(` ${stats.comments} comments`);
|
|
1946
|
+
console.log("\nID mapping (beads -> pebble):");
|
|
1947
|
+
for (const [beadsId, pebbleId] of idMap) {
|
|
1948
|
+
console.log(` ${beadsId} -> ${pebbleId}`);
|
|
1949
|
+
}
|
|
1950
|
+
} else {
|
|
1951
|
+
console.log(formatJson({
|
|
1952
|
+
dryRun: true,
|
|
1953
|
+
stats,
|
|
1954
|
+
idMap: Object.fromEntries(idMap)
|
|
1955
|
+
}));
|
|
1956
|
+
}
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const pebbleDir = ensurePebbleDir();
|
|
1960
|
+
for (const event of events) {
|
|
1961
|
+
appendEvent(event, pebbleDir);
|
|
1962
|
+
}
|
|
1963
|
+
if (pretty) {
|
|
1964
|
+
console.log(`Imported ${stats.created} issues from ${file}`);
|
|
1965
|
+
console.log(` ${stats.closed} closed`);
|
|
1966
|
+
console.log(` ${stats.dependencies} block dependencies`);
|
|
1967
|
+
console.log(` ${stats.parentChild} parent-child relationships`);
|
|
1968
|
+
console.log(` ${stats.comments} comments`);
|
|
1969
|
+
} else {
|
|
1970
|
+
console.log(formatJson({
|
|
1971
|
+
imported: stats.created,
|
|
1972
|
+
closed: stats.closed,
|
|
1973
|
+
dependencies: stats.dependencies,
|
|
1974
|
+
parentChild: stats.parentChild,
|
|
1975
|
+
comments: stats.comments,
|
|
1976
|
+
idMap: Object.fromEntries(idMap)
|
|
1977
|
+
}));
|
|
1978
|
+
}
|
|
1979
|
+
} catch (error) {
|
|
1980
|
+
outputError(error, pretty);
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
async function parseBeadsFile(filePath) {
|
|
1985
|
+
const issues = [];
|
|
1986
|
+
const fileStream = fs3.createReadStream(filePath);
|
|
1987
|
+
const rl = readline.createInterface({
|
|
1988
|
+
input: fileStream,
|
|
1989
|
+
crlfDelay: Infinity
|
|
1990
|
+
});
|
|
1991
|
+
for await (const line of rl) {
|
|
1992
|
+
if (line.trim()) {
|
|
1993
|
+
try {
|
|
1994
|
+
const issue = JSON.parse(line);
|
|
1995
|
+
issues.push(issue);
|
|
1996
|
+
} catch {
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return issues;
|
|
2001
|
+
}
|
|
2002
|
+
function convertToPebbleEvents(beadsIssues, prefix) {
|
|
2003
|
+
const events = [];
|
|
2004
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
2005
|
+
const issueTypeMap = /* @__PURE__ */ new Map();
|
|
2006
|
+
const stats = { created: 0, closed: 0, dependencies: 0, comments: 0, parentChild: 0 };
|
|
2007
|
+
for (const issue of beadsIssues) {
|
|
2008
|
+
const suffix = generateSuffix();
|
|
2009
|
+
const pebbleId = `${prefix}-${suffix}`;
|
|
2010
|
+
idMap.set(issue.id, pebbleId);
|
|
2011
|
+
issueTypeMap.set(issue.id, issue.issue_type);
|
|
2012
|
+
}
|
|
2013
|
+
for (const issue of beadsIssues) {
|
|
2014
|
+
const pebbleId = idMap.get(issue.id);
|
|
2015
|
+
let type = "task";
|
|
2016
|
+
if (issue.issue_type === "bug") {
|
|
2017
|
+
type = "bug";
|
|
2018
|
+
} else if (issue.issue_type === "epic") {
|
|
2019
|
+
type = "epic";
|
|
2020
|
+
}
|
|
2021
|
+
const priority = Math.max(0, Math.min(4, issue.priority));
|
|
2022
|
+
let parent;
|
|
2023
|
+
const blockedBy = [];
|
|
2024
|
+
if (issue.dependencies) {
|
|
2025
|
+
for (const dep of issue.dependencies) {
|
|
2026
|
+
if (dep.type === "parent-child") {
|
|
2027
|
+
const targetType = issueTypeMap.get(dep.depends_on_id);
|
|
2028
|
+
if (targetType === "epic") {
|
|
2029
|
+
const parentPebbleId = idMap.get(dep.depends_on_id);
|
|
2030
|
+
if (parentPebbleId) {
|
|
2031
|
+
parent = parentPebbleId;
|
|
2032
|
+
stats.parentChild++;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
} else if (dep.type === "blocks" && dep.depends_on_id !== issue.id) {
|
|
2036
|
+
const blockerPebbleId = idMap.get(dep.depends_on_id);
|
|
2037
|
+
if (blockerPebbleId) {
|
|
2038
|
+
blockedBy.push(blockerPebbleId);
|
|
2039
|
+
stats.dependencies++;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
const createEvent = {
|
|
2045
|
+
type: "create",
|
|
2046
|
+
issueId: pebbleId,
|
|
2047
|
+
timestamp: issue.created_at,
|
|
2048
|
+
data: {
|
|
2049
|
+
title: issue.title,
|
|
2050
|
+
type,
|
|
2051
|
+
priority,
|
|
2052
|
+
description: issue.description,
|
|
2053
|
+
parent
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
events.push(createEvent);
|
|
2057
|
+
stats.created++;
|
|
2058
|
+
if (blockedBy.length > 0) {
|
|
2059
|
+
const updateEvent = {
|
|
2060
|
+
type: "update",
|
|
2061
|
+
issueId: pebbleId,
|
|
2062
|
+
timestamp: issue.created_at,
|
|
2063
|
+
data: {
|
|
2064
|
+
blockedBy
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
events.push(updateEvent);
|
|
2068
|
+
}
|
|
2069
|
+
let status = "open";
|
|
2070
|
+
if (issue.status === "in_progress") {
|
|
2071
|
+
status = "in_progress";
|
|
2072
|
+
} else if (issue.status === "blocked") {
|
|
2073
|
+
status = "blocked";
|
|
2074
|
+
} else if (issue.status === "closed") {
|
|
2075
|
+
status = "closed";
|
|
2076
|
+
}
|
|
2077
|
+
if (status !== "open" && status !== "closed") {
|
|
2078
|
+
const statusEvent = {
|
|
2079
|
+
type: "update",
|
|
2080
|
+
issueId: pebbleId,
|
|
2081
|
+
timestamp: issue.updated_at,
|
|
2082
|
+
data: {
|
|
2083
|
+
status
|
|
2084
|
+
}
|
|
2085
|
+
};
|
|
2086
|
+
events.push(statusEvent);
|
|
2087
|
+
}
|
|
2088
|
+
if (status === "closed") {
|
|
2089
|
+
const closeEvent = {
|
|
2090
|
+
type: "close",
|
|
2091
|
+
issueId: pebbleId,
|
|
2092
|
+
timestamp: issue.closed_at ?? issue.updated_at,
|
|
2093
|
+
data: {
|
|
2094
|
+
reason: issue.close_reason
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
events.push(closeEvent);
|
|
2098
|
+
stats.closed++;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
return { events, idMap, stats };
|
|
2102
|
+
}
|
|
2103
|
+
function generateSuffix() {
|
|
2104
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
2105
|
+
let result = "";
|
|
2106
|
+
for (let i = 0; i < 6; i++) {
|
|
2107
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
2108
|
+
}
|
|
2109
|
+
return result;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// src/cli/commands/merge.ts
|
|
2113
|
+
import * as fs4 from "fs";
|
|
2114
|
+
import * as path4 from "path";
|
|
2115
|
+
function mergeEvents(filePaths) {
|
|
2116
|
+
const allEvents = [];
|
|
2117
|
+
for (const filePath of filePaths) {
|
|
2118
|
+
const events = readEventsFromFile(filePath);
|
|
2119
|
+
for (const event of events) {
|
|
2120
|
+
allEvents.push({ ...event, _source: filePath });
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
allEvents.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
2124
|
+
return allEvents.map(({ _source, ...event }) => event);
|
|
2125
|
+
}
|
|
2126
|
+
function mergeIssues(filePaths) {
|
|
2127
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2128
|
+
for (const filePath of filePaths) {
|
|
2129
|
+
const events = readEventsFromFile(filePath);
|
|
2130
|
+
const state = computeState(events);
|
|
2131
|
+
for (const [id, issue] of state) {
|
|
2132
|
+
const existing = merged.get(id);
|
|
2133
|
+
if (!existing) {
|
|
2134
|
+
merged.set(id, { issue, sources: /* @__PURE__ */ new Set([filePath]) });
|
|
2135
|
+
} else {
|
|
2136
|
+
existing.sources.add(filePath);
|
|
2137
|
+
if (new Date(issue.updatedAt) > new Date(existing.issue.updatedAt)) {
|
|
2138
|
+
merged.set(id, { issue, sources: existing.sources });
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return Array.from(merged.values()).map(({ issue, sources }) => ({
|
|
2144
|
+
...issue,
|
|
2145
|
+
_sources: Array.from(sources)
|
|
2146
|
+
}));
|
|
2147
|
+
}
|
|
2148
|
+
function mergeCommand(program2) {
|
|
2149
|
+
program2.command("merge <files...>").description("Merge multiple issues.jsonl files into one").option("-o, --output <file>", "Output file (default: stdout)").option("--events", "Output raw events instead of computed state").option("--show-sources", "Include _sources field showing which files contained each issue").action((files, options) => {
|
|
2150
|
+
const pretty = program2.opts().pretty ?? false;
|
|
2151
|
+
const filePaths = [];
|
|
2152
|
+
for (const file of files) {
|
|
2153
|
+
const resolved = path4.resolve(process.cwd(), file);
|
|
2154
|
+
if (!fs4.existsSync(resolved)) {
|
|
2155
|
+
console.error(`Error: File not found: ${file}`);
|
|
2156
|
+
process.exit(1);
|
|
2157
|
+
}
|
|
2158
|
+
filePaths.push(resolved);
|
|
2159
|
+
}
|
|
2160
|
+
if (filePaths.length < 2) {
|
|
2161
|
+
console.error("Error: At least 2 files required for merge");
|
|
2162
|
+
process.exit(1);
|
|
2163
|
+
}
|
|
2164
|
+
try {
|
|
2165
|
+
let output;
|
|
2166
|
+
if (options.events) {
|
|
2167
|
+
const events = mergeEvents(filePaths);
|
|
2168
|
+
output = events.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
2169
|
+
} else {
|
|
2170
|
+
const issues = mergeIssues(filePaths);
|
|
2171
|
+
const outputIssues = options.showSources ? issues : issues.map(({ _sources, ...issue }) => issue);
|
|
2172
|
+
output = pretty ? JSON.stringify(outputIssues, null, 2) : JSON.stringify(outputIssues);
|
|
2173
|
+
}
|
|
2174
|
+
if (options.output) {
|
|
2175
|
+
fs4.writeFileSync(options.output, output + "\n", "utf-8");
|
|
2176
|
+
console.error(`Merged ${filePaths.length} files to ${options.output}`);
|
|
2177
|
+
} else {
|
|
2178
|
+
console.log(output);
|
|
2179
|
+
}
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
console.error(`Error: ${error.message}`);
|
|
2182
|
+
process.exit(1);
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// src/cli/index.ts
|
|
2188
|
+
var program = new Command();
|
|
2189
|
+
program.name("pebble").description("A lightweight JSONL-based issue tracker").version("0.1.0");
|
|
2190
|
+
program.option("--pretty", "Human-readable output (default: JSON)");
|
|
2191
|
+
createCommand(program);
|
|
2192
|
+
updateCommand(program);
|
|
2193
|
+
closeCommand(program);
|
|
2194
|
+
reopenCommand(program);
|
|
2195
|
+
claimCommand(program);
|
|
2196
|
+
listCommand(program);
|
|
2197
|
+
showCommand(program);
|
|
2198
|
+
readyCommand(program);
|
|
2199
|
+
blockedCommand(program);
|
|
2200
|
+
depCommand(program);
|
|
2201
|
+
commentsCommand(program);
|
|
2202
|
+
graphCommand(program);
|
|
2203
|
+
uiCommand(program);
|
|
2204
|
+
importCommand(program);
|
|
2205
|
+
mergeCommand(program);
|
|
2206
|
+
program.parse();
|
|
2207
|
+
//# sourceMappingURL=index.js.map
|