pipe-kan 0.9.1
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 +92 -0
- package/dist/pipe-kan.js +817 -0
- package/dist/ui/assets/index-DHk68jTP.js +14 -0
- package/dist/ui/assets/index-HGWYfGrG.css +2 -0
- package/dist/ui/index.html +26 -0
- package/fixtures/issues.json +208 -0
- package/package.json +54 -0
package/dist/pipe-kan.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join as join4 } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/board.ts
|
|
10
|
+
function formatDueDate(value) {
|
|
11
|
+
if (typeof value !== "string" || !value)
|
|
12
|
+
return;
|
|
13
|
+
const day = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
14
|
+
const date = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(value);
|
|
15
|
+
if (Number.isNaN(date.getTime()))
|
|
16
|
+
return value;
|
|
17
|
+
return date.toLocaleDateString("en-US", {
|
|
18
|
+
month: "short",
|
|
19
|
+
day: "numeric",
|
|
20
|
+
year: "numeric"
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function createdAt(fields) {
|
|
24
|
+
return typeof fields?.created === "string" && fields.created ? fields.created : undefined;
|
|
25
|
+
}
|
|
26
|
+
function issueType(fields) {
|
|
27
|
+
const name = fields?.issuetype?.name ?? fields?.issueType?.name;
|
|
28
|
+
return typeof name === "string" ? name : undefined;
|
|
29
|
+
}
|
|
30
|
+
function epicKey(fields) {
|
|
31
|
+
return typeof fields?.parent?.key === "string" ? fields.parent.key : undefined;
|
|
32
|
+
}
|
|
33
|
+
function labelName(item) {
|
|
34
|
+
if (typeof item === "string" && item)
|
|
35
|
+
return item;
|
|
36
|
+
if (item && typeof item === "object" && "name" in item) {
|
|
37
|
+
const name = item.name;
|
|
38
|
+
if (typeof name === "string" && name)
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
function issueLabels(fields) {
|
|
44
|
+
const raw = [
|
|
45
|
+
...Array.isArray(fields?.labels) ? fields.labels : [],
|
|
46
|
+
...Array.isArray(fields?.components) ? fields.components : []
|
|
47
|
+
];
|
|
48
|
+
const labels = [...new Set(raw.map(labelName).filter((name) => !!name))];
|
|
49
|
+
return labels.length > 0 ? labels : undefined;
|
|
50
|
+
}
|
|
51
|
+
function issueStatus(fields) {
|
|
52
|
+
return typeof fields?.status?.name === "string" && fields.status.name ? fields.status.name : undefined;
|
|
53
|
+
}
|
|
54
|
+
function toEpic(face) {
|
|
55
|
+
return {
|
|
56
|
+
key: face.key,
|
|
57
|
+
summary: face.summary,
|
|
58
|
+
...face.status ? { status: face.status } : {},
|
|
59
|
+
...face.priority ? { priority: face.priority } : {},
|
|
60
|
+
...face.assignee ? { assignee: face.assignee } : {},
|
|
61
|
+
...face.dueDate ? { dueDate: face.dueDate } : {}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function toCard(issue, key) {
|
|
65
|
+
const summary = typeof issue.fields?.summary === "string" ? issue.fields.summary : "";
|
|
66
|
+
const priority = typeof issue.fields?.priority?.name === "string" ? issue.fields.priority.name : undefined;
|
|
67
|
+
const assignee = typeof issue.fields?.assignee?.displayName === "string" ? issue.fields.assignee.displayName : undefined;
|
|
68
|
+
const dueDate = formatDueDate(issue.fields?.duedate);
|
|
69
|
+
const type = issueType(issue.fields);
|
|
70
|
+
const epic = epicKey(issue.fields);
|
|
71
|
+
const labels = issueLabels(issue.fields);
|
|
72
|
+
const created = createdAt(issue.fields);
|
|
73
|
+
return {
|
|
74
|
+
key,
|
|
75
|
+
summary,
|
|
76
|
+
...priority ? { priority } : {},
|
|
77
|
+
...assignee ? { assignee } : {},
|
|
78
|
+
...dueDate ? { dueDate } : {},
|
|
79
|
+
...type ? { type } : {},
|
|
80
|
+
...epic ? { epic } : {},
|
|
81
|
+
...labels ? { labels } : {},
|
|
82
|
+
...created ? { created } : {}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function issuesToBoard(raw) {
|
|
86
|
+
if (!Array.isArray(raw)) {
|
|
87
|
+
throw new Error("jira-cli --raw payload must be a JSON array");
|
|
88
|
+
}
|
|
89
|
+
const columns = [];
|
|
90
|
+
const byStatus = new Map;
|
|
91
|
+
const epics = [];
|
|
92
|
+
const epicByKey = new Map;
|
|
93
|
+
function rememberEpic(epic) {
|
|
94
|
+
const existing = epicByKey.get(epic.key);
|
|
95
|
+
if (!existing) {
|
|
96
|
+
epicByKey.set(epic.key, epic);
|
|
97
|
+
epics.push(epic);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!existing.summary && epic.summary) {
|
|
101
|
+
Object.assign(existing, epic);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const issue of raw) {
|
|
105
|
+
const key = typeof issue?.key === "string" ? issue.key : "";
|
|
106
|
+
const status = typeof issue?.fields?.status?.name === "string" ? issue.fields.status.name : "";
|
|
107
|
+
if (!key || !status)
|
|
108
|
+
continue;
|
|
109
|
+
const card = toCard(issue, key);
|
|
110
|
+
if ((card.type ?? "").toLowerCase() === "epic") {
|
|
111
|
+
rememberEpic(toEpic({ ...card, status: issueStatus(issue.fields) }));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (card.epic) {
|
|
115
|
+
rememberEpic({ key: card.epic, summary: card.epic });
|
|
116
|
+
}
|
|
117
|
+
let column = byStatus.get(status);
|
|
118
|
+
if (!column) {
|
|
119
|
+
column = { id: status, title: status, cards: [] };
|
|
120
|
+
byStatus.set(status, column);
|
|
121
|
+
columns.push(column);
|
|
122
|
+
}
|
|
123
|
+
column.cards.push(card);
|
|
124
|
+
}
|
|
125
|
+
return { columns, epics };
|
|
126
|
+
}
|
|
127
|
+
function mergeEpics(listed, fromBoard) {
|
|
128
|
+
const byKey = new Map;
|
|
129
|
+
for (const epic of listed)
|
|
130
|
+
byKey.set(epic.key, { ...epic });
|
|
131
|
+
for (const epic of fromBoard) {
|
|
132
|
+
const existing = byKey.get(epic.key);
|
|
133
|
+
if (!existing) {
|
|
134
|
+
byKey.set(epic.key, { ...epic });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!existing.summary && epic.summary)
|
|
138
|
+
Object.assign(existing, epic);
|
|
139
|
+
}
|
|
140
|
+
return [...byKey.values()];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/cli.ts
|
|
144
|
+
import { spawn } from "node:child_process";
|
|
145
|
+
import { existsSync } from "node:fs";
|
|
146
|
+
import { delimiter, join, resolve } from "node:path";
|
|
147
|
+
|
|
148
|
+
// src/flags.ts
|
|
149
|
+
var DEFAULT_FLAGS = "";
|
|
150
|
+
function tokens(input) {
|
|
151
|
+
const out = [];
|
|
152
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
153
|
+
let match;
|
|
154
|
+
while (match = re.exec(input)) {
|
|
155
|
+
out.push(match[1] ?? match[2] ?? match[3]);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function takeValue(list, index, flag) {
|
|
160
|
+
const glued = list[index].slice(flag.length);
|
|
161
|
+
if (glued)
|
|
162
|
+
return [glued, index];
|
|
163
|
+
return [list[index + 1] ?? "", index + 1];
|
|
164
|
+
}
|
|
165
|
+
function flagsToJql(flags) {
|
|
166
|
+
const list = tokens(flags.trim() || DEFAULT_FLAGS);
|
|
167
|
+
let assignee = "";
|
|
168
|
+
let epic = "";
|
|
169
|
+
let type = "";
|
|
170
|
+
let raw = "";
|
|
171
|
+
const statusEq = [];
|
|
172
|
+
const statusNeq = [];
|
|
173
|
+
for (let i = 0;i < list.length; i++) {
|
|
174
|
+
const token = list[i];
|
|
175
|
+
if (token === "--raw")
|
|
176
|
+
continue;
|
|
177
|
+
if (token.startsWith("-a")) {
|
|
178
|
+
[assignee, i] = takeValue(list, i, "-a");
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (token.startsWith("-P")) {
|
|
182
|
+
[epic, i] = takeValue(list, i, "-P");
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (token.startsWith("-t")) {
|
|
186
|
+
[type, i] = takeValue(list, i, "-t");
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (token.startsWith("-s")) {
|
|
190
|
+
const [status, next] = takeValue(list, i, "-s");
|
|
191
|
+
i = next;
|
|
192
|
+
if (status.startsWith("~"))
|
|
193
|
+
statusNeq.push(status.slice(1));
|
|
194
|
+
else
|
|
195
|
+
statusEq.push(status);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (token === "-q" || token === "--jql") {
|
|
199
|
+
raw = list[++i] ?? "";
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (token.startsWith("-q")) {
|
|
203
|
+
raw = token.slice(2);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (raw)
|
|
207
|
+
return raw;
|
|
208
|
+
const clauses = ['project="DEMO"'];
|
|
209
|
+
if (assignee)
|
|
210
|
+
clauses.push(`assignee="${assignee}"`);
|
|
211
|
+
if (type)
|
|
212
|
+
clauses.push(`type="${type}"`);
|
|
213
|
+
if (epic)
|
|
214
|
+
clauses.push(`parent="${epic}"`);
|
|
215
|
+
for (const status of statusEq)
|
|
216
|
+
clauses.push(`status="${status}"`);
|
|
217
|
+
for (const status of statusNeq)
|
|
218
|
+
clauses.push(`status!="${status}"`);
|
|
219
|
+
return clauses.join(" AND ");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/cli.ts
|
|
223
|
+
function emptyList(text) {
|
|
224
|
+
return /no result found/i.test(text);
|
|
225
|
+
}
|
|
226
|
+
function createStoreCli(store) {
|
|
227
|
+
return {
|
|
228
|
+
async list(flags) {
|
|
229
|
+
const issues = store.list(flagsToJql(flags || DEFAULT_FLAGS));
|
|
230
|
+
return JSON.stringify(issues, null, 2);
|
|
231
|
+
},
|
|
232
|
+
async listEpics() {
|
|
233
|
+
const issues = store.list(flagsToJql("-tEpic"));
|
|
234
|
+
return JSON.stringify(issues, null, 2);
|
|
235
|
+
},
|
|
236
|
+
async listEpic(key) {
|
|
237
|
+
const issues = store.list(`project="DEMO" AND parent="${key}"`);
|
|
238
|
+
return JSON.stringify(issues, null, 2);
|
|
239
|
+
},
|
|
240
|
+
async move(key, status) {
|
|
241
|
+
return store.move(key, status);
|
|
242
|
+
},
|
|
243
|
+
async open(key) {
|
|
244
|
+
return `/browse/${key}`;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function resolveJiraBin(bin = process.env.JIRA_BIN ?? "jira", pathVar = process.env.PATH ?? "") {
|
|
249
|
+
if (!bin)
|
|
250
|
+
return;
|
|
251
|
+
if (bin.includes("/") || bin.includes("\\")) {
|
|
252
|
+
const abs = resolve(bin);
|
|
253
|
+
return existsSync(abs) ? abs : undefined;
|
|
254
|
+
}
|
|
255
|
+
for (const dir of pathVar.split(delimiter)) {
|
|
256
|
+
if (!dir)
|
|
257
|
+
continue;
|
|
258
|
+
const candidate = join(dir, bin);
|
|
259
|
+
if (existsSync(candidate))
|
|
260
|
+
return candidate;
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
function createJiraCli(opts = {}) {
|
|
265
|
+
const bin = opts.bin ?? "jira";
|
|
266
|
+
function run(args) {
|
|
267
|
+
return new Promise((resolveRun, reject) => {
|
|
268
|
+
const env = { ...process.env };
|
|
269
|
+
if (opts.configPath)
|
|
270
|
+
env.JIRA_CONFIG_FILE = resolve(opts.configPath);
|
|
271
|
+
if (opts.token)
|
|
272
|
+
env.JIRA_API_TOKEN = opts.token;
|
|
273
|
+
const child = spawn(bin, args, { env });
|
|
274
|
+
let stdout = "";
|
|
275
|
+
let stderr = "";
|
|
276
|
+
child.stdout.on("data", (chunk) => {
|
|
277
|
+
stdout += chunk;
|
|
278
|
+
});
|
|
279
|
+
child.stderr.on("data", (chunk) => {
|
|
280
|
+
stderr += chunk;
|
|
281
|
+
});
|
|
282
|
+
child.on("error", reject);
|
|
283
|
+
child.on("close", (code) => resolveRun({ code: code ?? 1, stdout, stderr }));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
async list(flags) {
|
|
288
|
+
const extra = (flags || DEFAULT_FLAGS).split(/\s+/).filter(Boolean);
|
|
289
|
+
const result = await run(["issue", "list", ...extra, "--raw"]);
|
|
290
|
+
if (result.code !== 0) {
|
|
291
|
+
const text = result.stderr || result.stdout || "jira issue list failed";
|
|
292
|
+
if (emptyList(text))
|
|
293
|
+
return "[]";
|
|
294
|
+
throw new Error(text);
|
|
295
|
+
}
|
|
296
|
+
return result.stdout;
|
|
297
|
+
},
|
|
298
|
+
async listEpics() {
|
|
299
|
+
const result = await run(["issue", "list", "-tEpic", "--raw"]);
|
|
300
|
+
if (result.code !== 0) {
|
|
301
|
+
const text = result.stderr || result.stdout || "jira issue list failed";
|
|
302
|
+
if (emptyList(text))
|
|
303
|
+
return "[]";
|
|
304
|
+
throw new Error(text);
|
|
305
|
+
}
|
|
306
|
+
return result.stdout;
|
|
307
|
+
},
|
|
308
|
+
async listEpic(key) {
|
|
309
|
+
const result = await run([
|
|
310
|
+
"issue",
|
|
311
|
+
"list",
|
|
312
|
+
"-q",
|
|
313
|
+
`parent="${key}" OR "Epic Link"="${key}"`,
|
|
314
|
+
"--raw"
|
|
315
|
+
]);
|
|
316
|
+
if (result.code !== 0) {
|
|
317
|
+
const text = result.stderr || result.stdout || "jira issue list failed";
|
|
318
|
+
if (emptyList(text))
|
|
319
|
+
return "[]";
|
|
320
|
+
throw new Error(text);
|
|
321
|
+
}
|
|
322
|
+
return result.stdout;
|
|
323
|
+
},
|
|
324
|
+
async move(key, status) {
|
|
325
|
+
const result = await run(["issue", "move", key, status]);
|
|
326
|
+
if (result.code !== 0) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
error: (result.stderr || result.stdout).trim()
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return { ok: true };
|
|
333
|
+
},
|
|
334
|
+
async open(key) {
|
|
335
|
+
const result = await run(["open", key, "--no-browser"]);
|
|
336
|
+
return result.stdout.trim().split(`
|
|
337
|
+
`).pop() ?? `/browse/${key}`;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/app.ts
|
|
343
|
+
function createApp(opts) {
|
|
344
|
+
const cli = opts.cli ?? createStoreCli(opts.store);
|
|
345
|
+
let flags = DEFAULT_FLAGS;
|
|
346
|
+
let payload = [];
|
|
347
|
+
let epicsPayload = [];
|
|
348
|
+
const app = {
|
|
349
|
+
get flags() {
|
|
350
|
+
return flags;
|
|
351
|
+
},
|
|
352
|
+
board() {
|
|
353
|
+
const board = issuesToBoard(payload);
|
|
354
|
+
return {
|
|
355
|
+
columns: board.columns,
|
|
356
|
+
epics: mergeEpics(issuesToBoard(epicsPayload).epics, board.epics)
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
hydrate(raw) {
|
|
360
|
+
payload = Array.isArray(raw) ? raw : [];
|
|
361
|
+
epicsPayload = [];
|
|
362
|
+
return app.board();
|
|
363
|
+
},
|
|
364
|
+
async refresh(next) {
|
|
365
|
+
if (next !== undefined)
|
|
366
|
+
flags = next;
|
|
367
|
+
const [issues, epics] = await Promise.all([
|
|
368
|
+
cli.list(flags),
|
|
369
|
+
cli.listEpics()
|
|
370
|
+
]);
|
|
371
|
+
payload = JSON.parse(issues);
|
|
372
|
+
epicsPayload = JSON.parse(epics);
|
|
373
|
+
return app.board();
|
|
374
|
+
},
|
|
375
|
+
async children(epic) {
|
|
376
|
+
const board = issuesToBoard(JSON.parse(await cli.listEpic(epic)));
|
|
377
|
+
for (const column of board.columns) {
|
|
378
|
+
for (const card of column.cards) {
|
|
379
|
+
if (!card.epic)
|
|
380
|
+
card.epic = epic;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return board;
|
|
384
|
+
},
|
|
385
|
+
async move(key, status) {
|
|
386
|
+
const current = payload.find((issue) => issue.key === key)?.fields?.status?.name;
|
|
387
|
+
if (current === status) {
|
|
388
|
+
return { ok: true, noop: true, board: app.board() };
|
|
389
|
+
}
|
|
390
|
+
const result = await cli.move(key, status);
|
|
391
|
+
if (!result.ok) {
|
|
392
|
+
return { ok: false, error: result.error, board: app.board() };
|
|
393
|
+
}
|
|
394
|
+
await app.refresh();
|
|
395
|
+
return { ok: true, board: app.board() };
|
|
396
|
+
},
|
|
397
|
+
async open(key) {
|
|
398
|
+
return cli.open(key);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
return app;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/store.ts
|
|
405
|
+
var ME = {
|
|
406
|
+
displayName: "Person A",
|
|
407
|
+
emailAddress: "user@test.com",
|
|
408
|
+
name: "user@test.com"
|
|
409
|
+
};
|
|
410
|
+
var TRANSITIONS = [
|
|
411
|
+
{ id: "11", name: "To Do" },
|
|
412
|
+
{ id: "21", name: "In Progress" },
|
|
413
|
+
{ id: "31", name: "Done" }
|
|
414
|
+
];
|
|
415
|
+
var ALLOWED = {
|
|
416
|
+
"To Do": ["In Progress"],
|
|
417
|
+
"In Progress": ["To Do", "Done"],
|
|
418
|
+
Done: ["In Progress"]
|
|
419
|
+
};
|
|
420
|
+
function fieldValue(issue, field) {
|
|
421
|
+
switch (field) {
|
|
422
|
+
case "project":
|
|
423
|
+
return issue.key.split("-")[0] ?? "";
|
|
424
|
+
case "assignee":
|
|
425
|
+
return [
|
|
426
|
+
issue.fields.assignee?.emailAddress,
|
|
427
|
+
issue.fields.assignee?.displayName
|
|
428
|
+
].filter(Boolean).join(`
|
|
429
|
+
`);
|
|
430
|
+
case "status":
|
|
431
|
+
return issue.fields.status.name;
|
|
432
|
+
case "parent":
|
|
433
|
+
return issue.fields.parent?.key ?? "";
|
|
434
|
+
case "type": {
|
|
435
|
+
const named = issue.fields.issuetype ?? issue.fields.issueType;
|
|
436
|
+
if (named && typeof named === "object" && "name" in named && typeof named.name === "string") {
|
|
437
|
+
return named.name;
|
|
438
|
+
}
|
|
439
|
+
return "";
|
|
440
|
+
}
|
|
441
|
+
case "key":
|
|
442
|
+
return issue.key;
|
|
443
|
+
default:
|
|
444
|
+
return "";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function matchesClause(issue, clause) {
|
|
448
|
+
const match = clause.trim().match(/^(\w+)\s*(!=|=)\s*"?([^"]+)"?$/);
|
|
449
|
+
if (!match)
|
|
450
|
+
return true;
|
|
451
|
+
const [, field, op, raw] = match;
|
|
452
|
+
const expected = raw.trim();
|
|
453
|
+
const actual = fieldValue(issue, field);
|
|
454
|
+
const hit = field === "assignee" ? actual.split(`
|
|
455
|
+
`).some((value) => value.toLowerCase() === expected.toLowerCase()) : actual.toLowerCase() === expected.toLowerCase();
|
|
456
|
+
return op === "!=" ? !hit : hit;
|
|
457
|
+
}
|
|
458
|
+
function matchJql(issue, jql) {
|
|
459
|
+
const stripped = jql.replace(/\s+ORDER BY\s+.+$/i, "").trim();
|
|
460
|
+
if (!stripped)
|
|
461
|
+
return true;
|
|
462
|
+
return stripped.split(/\s+AND\s+/i).every((clause) => matchesClause(issue, clause));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
class IssueStore {
|
|
466
|
+
issues;
|
|
467
|
+
constructor(issues) {
|
|
468
|
+
this.issues = issues;
|
|
469
|
+
}
|
|
470
|
+
static fromRaw(raw) {
|
|
471
|
+
if (!Array.isArray(raw)) {
|
|
472
|
+
throw new Error("Fixture must be a JSON array");
|
|
473
|
+
}
|
|
474
|
+
return new IssueStore(structuredClone(raw));
|
|
475
|
+
}
|
|
476
|
+
all() {
|
|
477
|
+
return this.issues;
|
|
478
|
+
}
|
|
479
|
+
rawJson() {
|
|
480
|
+
return JSON.stringify(this.issues, null, 2);
|
|
481
|
+
}
|
|
482
|
+
get(key) {
|
|
483
|
+
return this.issues.find((issue) => issue.key.toUpperCase() === key.toUpperCase());
|
|
484
|
+
}
|
|
485
|
+
list(jql = "") {
|
|
486
|
+
return this.issues.filter((issue) => matchJql(issue, jql));
|
|
487
|
+
}
|
|
488
|
+
transitions(key) {
|
|
489
|
+
const issue = this.get(key);
|
|
490
|
+
if (!issue)
|
|
491
|
+
return [];
|
|
492
|
+
const allowed = ALLOWED[issue.fields.status.name] ?? [];
|
|
493
|
+
return TRANSITIONS.map((transition) => ({
|
|
494
|
+
...transition,
|
|
495
|
+
isAvailable: allowed.includes(transition.name)
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
move(key, status) {
|
|
499
|
+
const issue = this.get(key);
|
|
500
|
+
if (!issue)
|
|
501
|
+
return { ok: false, error: `Issue ${key} not found` };
|
|
502
|
+
const transition = this.transitions(key).find((item) => item.name.toLowerCase() === status.toLowerCase() || item.id === status);
|
|
503
|
+
if (!transition || !transition.isAvailable) {
|
|
504
|
+
const available = this.transitions(key).filter((item) => item.isAvailable).map((item) => item.name).join(", ");
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: `invalid transition state "${status}"
|
|
508
|
+
Available states for issue ${key}: ${available}`
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
issue.fields.status.name = transition.name;
|
|
512
|
+
if (transition.name === "Done") {
|
|
513
|
+
issue.fields.resolution = { name: "Done" };
|
|
514
|
+
} else {
|
|
515
|
+
delete issue.fields.resolution;
|
|
516
|
+
}
|
|
517
|
+
return { ok: true };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/boot.ts
|
|
522
|
+
async function createBoardApp(opts) {
|
|
523
|
+
const env = opts.env ?? process.env;
|
|
524
|
+
const store = IssueStore.fromRaw(opts.raw);
|
|
525
|
+
const bin = resolveJiraBin(env.JIRA_BIN ?? "jira", env.PATH ?? "");
|
|
526
|
+
const cli = bin ? createJiraCli({
|
|
527
|
+
bin,
|
|
528
|
+
configPath: env.JIRA_CONFIG_FILE,
|
|
529
|
+
token: env.JIRA_API_TOKEN
|
|
530
|
+
}) : createStoreCli(store);
|
|
531
|
+
const app = createApp({ store, cli });
|
|
532
|
+
if (opts.piped) {
|
|
533
|
+
app.hydrate(opts.raw);
|
|
534
|
+
} else {
|
|
535
|
+
const local = createStoreCli(store);
|
|
536
|
+
app.hydrate(JSON.parse(await local.list(app.flags)));
|
|
537
|
+
}
|
|
538
|
+
return { app, store, kind: bin ? "jira" : "store" };
|
|
539
|
+
}
|
|
540
|
+
async function refreshFromJira(app, kind, opts = {}) {
|
|
541
|
+
if (kind !== "jira" || opts.piped)
|
|
542
|
+
return;
|
|
543
|
+
await app.refresh();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/app-api.ts
|
|
547
|
+
function json(res, status, body) {
|
|
548
|
+
res.statusCode = status;
|
|
549
|
+
res.setHeader("content-type", "application/json");
|
|
550
|
+
res.end(JSON.stringify(body));
|
|
551
|
+
}
|
|
552
|
+
function readBody(req) {
|
|
553
|
+
return new Promise((resolve2, reject) => {
|
|
554
|
+
const chunks = [];
|
|
555
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
556
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
|
|
557
|
+
req.on("error", reject);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
function pathOf(req) {
|
|
561
|
+
return new URL(req.url ?? "/", "http://127.0.0.1");
|
|
562
|
+
}
|
|
563
|
+
function handleAppApi(req, res, app) {
|
|
564
|
+
const url = pathOf(req);
|
|
565
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
566
|
+
if (url.pathname === "/api/board" && method === "GET") {
|
|
567
|
+
json(res, 200, { ...app.board(), flags: app.flags });
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
if (url.pathname === "/api/refresh" && method === "POST") {
|
|
571
|
+
readBody(req).then(async (text) => {
|
|
572
|
+
const body = text ? JSON.parse(text) : {};
|
|
573
|
+
const board = await app.refresh(body.flags);
|
|
574
|
+
json(res, 200, board);
|
|
575
|
+
});
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
if (url.pathname === "/api/move" && method === "POST") {
|
|
579
|
+
readBody(req).then(async (text) => {
|
|
580
|
+
const body = text ? JSON.parse(text) : {};
|
|
581
|
+
const result = await app.move(String(body.key ?? ""), String(body.status ?? ""));
|
|
582
|
+
json(res, result.ok ? 200 : 409, result);
|
|
583
|
+
});
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
if (url.pathname === "/api/epic" && method === "POST") {
|
|
587
|
+
readBody(req).then(async (text) => {
|
|
588
|
+
const body = text ? JSON.parse(text) : {};
|
|
589
|
+
json(res, 200, await app.children(String(body.key ?? "")));
|
|
590
|
+
});
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
if (url.pathname === "/api/open" && method === "POST") {
|
|
594
|
+
readBody(req).then(async (text) => {
|
|
595
|
+
const body = text ? JSON.parse(text) : {};
|
|
596
|
+
const url2 = await app.open(String(body.key ?? ""));
|
|
597
|
+
json(res, 200, { url: url2 });
|
|
598
|
+
});
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/fake-jira.ts
|
|
605
|
+
function json2(res, status, body) {
|
|
606
|
+
res.statusCode = status;
|
|
607
|
+
res.setHeader("content-type", "application/json");
|
|
608
|
+
res.end(JSON.stringify(body));
|
|
609
|
+
}
|
|
610
|
+
function readBody2(req) {
|
|
611
|
+
return new Promise((resolve2, reject) => {
|
|
612
|
+
const chunks = [];
|
|
613
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
614
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
|
|
615
|
+
req.on("error", reject);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function pathOf2(req) {
|
|
619
|
+
return new URL(req.url ?? "/", "http://127.0.0.1");
|
|
620
|
+
}
|
|
621
|
+
function handleFakeJira(req, res, store) {
|
|
622
|
+
const url = pathOf2(req);
|
|
623
|
+
const path = url.pathname;
|
|
624
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
625
|
+
if (path === "/rest/api/2/myself" || path === "/rest/api/3/myself") {
|
|
626
|
+
json2(res, 200, ME);
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
if ((path === "/rest/api/3/search/jql" || path === "/rest/api/2/search" || path === "/rest/api/3/search") && method === "GET") {
|
|
630
|
+
const issues = store.list(url.searchParams.get("jql") ?? "");
|
|
631
|
+
json2(res, 200, { expand: "schema,names", isLast: true, issues });
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
const transition = path.match(/^\/rest\/api\/[23]\/issue\/([^/]+)\/transitions$/);
|
|
635
|
+
if (transition) {
|
|
636
|
+
const key = decodeURIComponent(transition[1]);
|
|
637
|
+
if (method === "GET") {
|
|
638
|
+
if (!store.get(key)) {
|
|
639
|
+
json2(res, 404, { errorMessages: [`Issue ${key} not found`] });
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
json2(res, 200, {
|
|
643
|
+
expand: "transitions",
|
|
644
|
+
transitions: store.transitions(key)
|
|
645
|
+
});
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
if (method === "POST") {
|
|
649
|
+
readBody2(req).then((text) => {
|
|
650
|
+
const body = text ? JSON.parse(text) : {};
|
|
651
|
+
const target = body.transition?.name ?? body.transition?.id ?? "";
|
|
652
|
+
const result = store.move(key, String(target));
|
|
653
|
+
if (!result.ok) {
|
|
654
|
+
json2(res, 400, { errorMessages: [result.error] });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
res.statusCode = 204;
|
|
658
|
+
res.end();
|
|
659
|
+
});
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const browse = path.match(/^\/browse\/([^/]+)$/);
|
|
664
|
+
if (browse && method === "GET") {
|
|
665
|
+
const key = decodeURIComponent(browse[1]);
|
|
666
|
+
const issue = store.get(key);
|
|
667
|
+
res.statusCode = issue ? 200 : 404;
|
|
668
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
669
|
+
const fields = issue?.fields;
|
|
670
|
+
const type = fields?.issuetype?.name ?? fields?.issueType?.name ?? "Issue";
|
|
671
|
+
res.end(issue ? `<!doctype html><html><head><meta charset="utf-8"><title>${issue.key}</title>
|
|
672
|
+
<style>
|
|
673
|
+
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
674
|
+
body { margin: 0; padding: 1.25rem; }
|
|
675
|
+
.key { font-size: 12px; letter-spacing: .02em; opacity: .7; }
|
|
676
|
+
h1 { font-size: 1.25rem; margin: .35rem 0 1rem; }
|
|
677
|
+
dl { display: grid; grid-template-columns: 7rem 1fr; gap: .4rem 1rem; font-size: 14px; }
|
|
678
|
+
dt { opacity: .65; }
|
|
679
|
+
</style></head><body>
|
|
680
|
+
<div class="key">${issue.key} · ${type}</div>
|
|
681
|
+
<h1>${issue.fields.summary}</h1>
|
|
682
|
+
<dl>
|
|
683
|
+
<dt>Status</dt><dd>${issue.fields.status.name}</dd>
|
|
684
|
+
<dt>Assignee</dt><dd>${issue.fields.assignee?.displayName ?? "Unassigned"}</dd>
|
|
685
|
+
<dt>Priority</dt><dd>${fields?.priority?.name ?? "—"}</dd>
|
|
686
|
+
</dl>
|
|
687
|
+
</body></html>` : "not found");
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/http.ts
|
|
694
|
+
function handleRequest(req, res, ctx) {
|
|
695
|
+
return handleAppApi(req, res, ctx.app) || handleFakeJira(req, res, ctx.store);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/jira-config.ts
|
|
699
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
700
|
+
import { join as join2 } from "node:path";
|
|
701
|
+
function writeJiraConfig(dir, server) {
|
|
702
|
+
mkdirSync(dir, { recursive: true });
|
|
703
|
+
const path = join2(dir, "jira.config.yml");
|
|
704
|
+
writeFileSync(path, [
|
|
705
|
+
"installation: Cloud",
|
|
706
|
+
`server: ${server}`,
|
|
707
|
+
`login: ${ME.emailAddress}`,
|
|
708
|
+
"auth_type: basic",
|
|
709
|
+
"project:",
|
|
710
|
+
" key: DEMO",
|
|
711
|
+
" type: classic",
|
|
712
|
+
'board: ""',
|
|
713
|
+
""
|
|
714
|
+
].join(`
|
|
715
|
+
`));
|
|
716
|
+
return path;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/listen.ts
|
|
720
|
+
function resolveListen(env = process.env) {
|
|
721
|
+
return {
|
|
722
|
+
host: env.HOST ?? "127.0.0.1",
|
|
723
|
+
port: Number(env.PORT ?? 5173)
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/ui.ts
|
|
728
|
+
import { createReadStream, existsSync as existsSync2, statSync } from "node:fs";
|
|
729
|
+
import { dirname, extname, join as join3, resolve as resolve2, sep } from "node:path";
|
|
730
|
+
import { fileURLToPath } from "node:url";
|
|
731
|
+
var types = {
|
|
732
|
+
".css": "text/css; charset=utf-8",
|
|
733
|
+
".html": "text/html; charset=utf-8",
|
|
734
|
+
".ico": "image/x-icon",
|
|
735
|
+
".js": "text/javascript; charset=utf-8",
|
|
736
|
+
".json": "application/json",
|
|
737
|
+
".map": "application/json",
|
|
738
|
+
".svg": "image/svg+xml",
|
|
739
|
+
".woff": "font/woff",
|
|
740
|
+
".woff2": "font/woff2"
|
|
741
|
+
};
|
|
742
|
+
function packageRoot(from = import.meta.url) {
|
|
743
|
+
return resolve2(dirname(fileURLToPath(from)), "..");
|
|
744
|
+
}
|
|
745
|
+
function uiDir(root) {
|
|
746
|
+
return join3(root, "dist", "ui");
|
|
747
|
+
}
|
|
748
|
+
function inside(root, file) {
|
|
749
|
+
const base = resolve2(root);
|
|
750
|
+
const target = resolve2(file);
|
|
751
|
+
return target === base || target.startsWith(base + sep);
|
|
752
|
+
}
|
|
753
|
+
function sendUi(root, req, res) {
|
|
754
|
+
const ui = uiDir(root);
|
|
755
|
+
const index = join3(ui, "index.html");
|
|
756
|
+
if (!existsSync2(index))
|
|
757
|
+
return false;
|
|
758
|
+
const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
759
|
+
const wanted = resolve2(ui, `.${decodeURIComponent(path)}`);
|
|
760
|
+
const file = inside(ui, wanted) && existsSync2(wanted) && statSync(wanted).isFile() ? wanted : index;
|
|
761
|
+
res.setHeader("content-type", types[extname(file)] ?? "application/octet-stream");
|
|
762
|
+
createReadStream(file).pipe(res);
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/server.ts
|
|
767
|
+
async function readPipe() {
|
|
768
|
+
if (process.stdin.isTTY)
|
|
769
|
+
return null;
|
|
770
|
+
const chunks = [];
|
|
771
|
+
for await (const chunk of process.stdin)
|
|
772
|
+
chunks.push(chunk);
|
|
773
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
774
|
+
return text ? JSON.parse(text) : null;
|
|
775
|
+
}
|
|
776
|
+
async function runServer(opts) {
|
|
777
|
+
const piped = await readPipe();
|
|
778
|
+
const raw = piped ?? JSON.parse(readFileSync(join4(opts.root, "fixtures/issues.json"), "utf8"));
|
|
779
|
+
const { app, store, kind } = await createBoardApp({
|
|
780
|
+
raw,
|
|
781
|
+
piped: Boolean(piped)
|
|
782
|
+
});
|
|
783
|
+
const server = createServer((req, res) => {
|
|
784
|
+
if (handleRequest(req, res, { app, store }))
|
|
785
|
+
return;
|
|
786
|
+
if (opts.fallback) {
|
|
787
|
+
opts.fallback(req, res);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (!sendUi(opts.root, req, res)) {
|
|
791
|
+
res.statusCode = 404;
|
|
792
|
+
res.end("pipe-kan UI is missing. Run bun run build.");
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
const { host, port } = resolveListen();
|
|
796
|
+
await new Promise((resolve3) => server.listen(port, host, resolve3));
|
|
797
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
798
|
+
const fakeConfig = writeJiraConfig(join4(tmpdir(), "pipe-kan"), origin);
|
|
799
|
+
if (kind === "jira") {
|
|
800
|
+
try {
|
|
801
|
+
await refreshFromJira(app, kind, { piped: Boolean(piped) });
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.error("jira Refresh failed; keeping Fixture Board");
|
|
804
|
+
console.error(err);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
console.log(`pipe-kan http://${host}:${port}`);
|
|
808
|
+
console.log(`cli ${kind === "jira" ? resolveJiraBin() : "store"}`);
|
|
809
|
+
console.log(`Fake Jira ${origin}/rest/api/2/search`);
|
|
810
|
+
console.log(`Fake Jira config ${fakeConfig}`);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/bin.ts
|
|
814
|
+
runServer({ root: packageRoot() }).catch((err) => {
|
|
815
|
+
console.error(err);
|
|
816
|
+
process.exit(1);
|
|
817
|
+
});
|