@cliangdev/flux-plugin 0.3.1 → 0.4.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/commands/dashboard.md +1 -1
- package/package.json +3 -1
- package/src/server/adapters/factory.ts +6 -28
- package/src/server/adapters/github/__tests__/criteria-deps.test.ts +579 -0
- package/src/server/adapters/github/__tests__/documents-stats.test.ts +789 -0
- package/src/server/adapters/github/__tests__/epic-task-crud.test.ts +1072 -0
- package/src/server/adapters/github/__tests__/foundation.test.ts +537 -0
- package/src/server/adapters/github/__tests__/index-store.test.ts +319 -0
- package/src/server/adapters/github/__tests__/prd-crud.test.ts +836 -0
- package/src/server/adapters/github/adapter.ts +1574 -0
- package/src/server/adapters/github/client.ts +34 -0
- package/src/server/adapters/github/config.ts +59 -0
- package/src/server/adapters/github/helpers/criteria.ts +157 -0
- package/src/server/adapters/github/helpers/index-store.ts +79 -0
- package/src/server/adapters/github/helpers/meta.ts +26 -0
- package/src/server/adapters/github/index.ts +5 -0
- package/src/server/adapters/github/mappers/epic.ts +21 -0
- package/src/server/adapters/github/mappers/index.ts +15 -0
- package/src/server/adapters/github/mappers/prd.ts +50 -0
- package/src/server/adapters/github/mappers/task.ts +37 -0
- package/src/server/adapters/github/types.ts +27 -0
- package/src/server/adapters/linear/adapter.ts +121 -105
- package/src/server/adapters/linear/client.ts +21 -14
- package/src/server/adapters/types.ts +1 -1
- package/src/server/index.ts +2 -0
- package/src/server/tools/__tests__/mcp-interface.test.ts +6 -0
- package/src/server/tools/__tests__/z-configure-github.test.ts +513 -0
- package/src/server/tools/__tests__/z-get-linear-url.test.ts +2 -2
- package/src/server/tools/__tests__/z-init-project.test.ts +168 -0
- package/src/server/tools/configure-github.ts +422 -0
- package/src/server/tools/index.ts +2 -1
- package/src/server/tools/init-project.ts +26 -12
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
const TEST_DIR = `${realpathSync(tmpdir())}/flux-prd-crud-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
6
|
+
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
7
|
+
|
|
8
|
+
let mockIssuesCreate: any = null;
|
|
9
|
+
let mockIssuesUpdate: any = null;
|
|
10
|
+
let mockIssuesGet: any = null;
|
|
11
|
+
let mockIssuesList: any[] = [];
|
|
12
|
+
let mockSubIssues: Record<number, any[]> = {};
|
|
13
|
+
let mockGetContent: any = null;
|
|
14
|
+
let mockGraphqlFn: (
|
|
15
|
+
query: string,
|
|
16
|
+
variables?: Record<string, unknown>,
|
|
17
|
+
) => Promise<unknown> = async () => ({});
|
|
18
|
+
let lastCreateParams: any = null;
|
|
19
|
+
let _lastUpdateParams: any = null;
|
|
20
|
+
let lastUpdateByNumberParams: Record<number, any> = {};
|
|
21
|
+
let lastPutParams: any = null;
|
|
22
|
+
let issueNodeIdCounter = 0;
|
|
23
|
+
|
|
24
|
+
mock.module("@octokit/rest", () => ({
|
|
25
|
+
Octokit: class MockOctokit {
|
|
26
|
+
repos = {
|
|
27
|
+
getContent: async (_params: any) => {
|
|
28
|
+
if (mockGetContent === null) {
|
|
29
|
+
const err: any = new Error("Not Found");
|
|
30
|
+
err.status = 404;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
return mockGetContent;
|
|
34
|
+
},
|
|
35
|
+
createOrUpdateFileContents: async (params: any) => {
|
|
36
|
+
lastPutParams = params;
|
|
37
|
+
return { data: { content: { sha: "new-sha" } } };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
issues = {
|
|
42
|
+
create: async (params: any) => {
|
|
43
|
+
lastCreateParams = params;
|
|
44
|
+
return { data: mockIssuesCreate };
|
|
45
|
+
},
|
|
46
|
+
update: async (params: any) => {
|
|
47
|
+
const num = params.issue_number;
|
|
48
|
+
lastUpdateByNumberParams[num] = params;
|
|
49
|
+
_lastUpdateParams = params;
|
|
50
|
+
return { data: mockIssuesUpdate ?? mockIssuesCreate };
|
|
51
|
+
},
|
|
52
|
+
get: async (_params: any) => {
|
|
53
|
+
if (mockIssuesGet === null) {
|
|
54
|
+
const err: any = new Error("Not Found");
|
|
55
|
+
err.status = 404;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
return { data: mockIssuesGet };
|
|
59
|
+
},
|
|
60
|
+
listForRepo: async (_params: any) => {
|
|
61
|
+
return { data: mockIssuesList };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
mock.module("@octokit/graphql", () => ({
|
|
68
|
+
graphql: {
|
|
69
|
+
defaults: (_opts: any) => {
|
|
70
|
+
return async (query: string, variables?: Record<string, unknown>) => {
|
|
71
|
+
return mockGraphqlFn(query, variables);
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
function makeIssue(
|
|
78
|
+
overrides: Partial<{
|
|
79
|
+
number: number;
|
|
80
|
+
id: number;
|
|
81
|
+
title: string;
|
|
82
|
+
body: string;
|
|
83
|
+
labels: string[];
|
|
84
|
+
state: "open" | "closed";
|
|
85
|
+
created_at: string;
|
|
86
|
+
updated_at: string;
|
|
87
|
+
node_id: string;
|
|
88
|
+
}> = {},
|
|
89
|
+
) {
|
|
90
|
+
issueNodeIdCounter++;
|
|
91
|
+
const number = overrides.number ?? issueNodeIdCounter;
|
|
92
|
+
return {
|
|
93
|
+
number,
|
|
94
|
+
id: overrides.id ?? number * 1000,
|
|
95
|
+
title: overrides.title ?? "Test PRD",
|
|
96
|
+
body: overrides.body ?? "",
|
|
97
|
+
labels: (overrides.labels ?? ["flux:prd", "status:draft"]).map((name) => ({
|
|
98
|
+
name,
|
|
99
|
+
})),
|
|
100
|
+
state: overrides.state ?? "open",
|
|
101
|
+
created_at: overrides.created_at ?? "2026-01-01T00:00:00Z",
|
|
102
|
+
updated_at: overrides.updated_at ?? "2026-01-01T00:00:00Z",
|
|
103
|
+
node_id: overrides.node_id ?? `MDU6SXNzdWU${number}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function makeAdapter() {
|
|
108
|
+
const { GitHubAdapter } = require("../adapter.js");
|
|
109
|
+
const adapter = new GitHubAdapter({
|
|
110
|
+
token: "ghp_test",
|
|
111
|
+
owner: "test-owner",
|
|
112
|
+
repo: "test-repo",
|
|
113
|
+
projectId: "PVT_kwDO123",
|
|
114
|
+
refPrefix: "FP",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
(adapter as any).client.rest.issues = {
|
|
118
|
+
create: async (params: any) => {
|
|
119
|
+
lastCreateParams = params;
|
|
120
|
+
return { data: mockIssuesCreate };
|
|
121
|
+
},
|
|
122
|
+
update: async (params: any) => {
|
|
123
|
+
const num = params.issue_number;
|
|
124
|
+
lastUpdateByNumberParams[num] = params;
|
|
125
|
+
_lastUpdateParams = params;
|
|
126
|
+
if (mockIssuesUpdate !== null) {
|
|
127
|
+
return { data: mockIssuesUpdate };
|
|
128
|
+
}
|
|
129
|
+
return { data: mockIssuesCreate };
|
|
130
|
+
},
|
|
131
|
+
get: async (_params: any) => {
|
|
132
|
+
if (mockIssuesGet === null) {
|
|
133
|
+
const err: any = new Error("Not Found");
|
|
134
|
+
err.status = 404;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
return { data: mockIssuesGet };
|
|
138
|
+
},
|
|
139
|
+
listForRepo: async (_params: any) => {
|
|
140
|
+
return { data: mockIssuesList };
|
|
141
|
+
},
|
|
142
|
+
listSubIssues: async (params: any) => {
|
|
143
|
+
const issueNum = params.issue_number;
|
|
144
|
+
return { data: mockSubIssues[issueNum] ?? [] };
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
(adapter as any).client.rest.repos = {
|
|
149
|
+
getContent: async (_params: any) => {
|
|
150
|
+
if (mockGetContent === null) {
|
|
151
|
+
const err: any = new Error("Not Found");
|
|
152
|
+
err.status = 404;
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
return mockGetContent;
|
|
156
|
+
},
|
|
157
|
+
createOrUpdateFileContents: async (params: any) => {
|
|
158
|
+
lastPutParams = params;
|
|
159
|
+
return { data: { content: { sha: "new-sha" } } };
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
(adapter as any).client.rest.request = async (url: string, params: any) => {
|
|
164
|
+
if (url.includes("sub_issues") && url.startsWith("GET")) {
|
|
165
|
+
return { data: mockSubIssues[params.issue_number] ?? [] };
|
|
166
|
+
}
|
|
167
|
+
if (url.includes("sub_issues") && url.startsWith("POST")) {
|
|
168
|
+
return { data: {} };
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Unmocked request: ${url}`);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return adapter;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("flux-meta helpers", () => {
|
|
177
|
+
test("encodeMeta produces valid HTML comment with JSON on its own lines", async () => {
|
|
178
|
+
const { encodeMeta } = await import("../helpers/meta.js");
|
|
179
|
+
const result = encodeMeta({ ref: "FP-P1", tag: "feature" });
|
|
180
|
+
expect(result).toBe(`<!-- flux-meta\n{"ref":"FP-P1","tag":"feature"}\n-->`);
|
|
181
|
+
expect(result.startsWith("<!-- flux-meta\n")).toBe(true);
|
|
182
|
+
expect(result.endsWith("\n-->")).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("decodeMeta parses back to original object", async () => {
|
|
186
|
+
const { encodeMeta, decodeMeta } = await import("../helpers/meta.js");
|
|
187
|
+
const original = { ref: "FP-P6", tag: "mvp", dependencies: ["FP-P1"] };
|
|
188
|
+
const encoded = encodeMeta(original);
|
|
189
|
+
const decoded = decodeMeta(encoded);
|
|
190
|
+
expect(decoded).toEqual(original);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("decodeMeta returns null when no flux-meta block present", async () => {
|
|
194
|
+
const { decodeMeta } = await import("../helpers/meta.js");
|
|
195
|
+
const result = decodeMeta("Just a regular issue body with no meta block.");
|
|
196
|
+
expect(result).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("decodeMeta returns null for invalid JSON in meta block", async () => {
|
|
200
|
+
const { decodeMeta } = await import("../helpers/meta.js");
|
|
201
|
+
const result = decodeMeta("<!-- flux-meta\n{invalid json}\n-->");
|
|
202
|
+
expect(result).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("decodeMeta works when body has content before and after block", async () => {
|
|
206
|
+
const { encodeMeta, decodeMeta } = await import("../helpers/meta.js");
|
|
207
|
+
const meta = { ref: "FP-P2" };
|
|
208
|
+
const body = `Some description here\n\n${encodeMeta(meta)}\n\nMore content`;
|
|
209
|
+
const decoded = decodeMeta(body);
|
|
210
|
+
expect(decoded?.ref).toBe("FP-P2");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("prd status mappers", () => {
|
|
215
|
+
test("prdStatusToLabel returns correct label for each status", async () => {
|
|
216
|
+
const { prdStatusToLabel } = await import("../mappers/prd.js");
|
|
217
|
+
expect(prdStatusToLabel("DRAFT")).toBe("status:draft");
|
|
218
|
+
expect(prdStatusToLabel("PENDING_REVIEW")).toBe("status:pending-review");
|
|
219
|
+
expect(prdStatusToLabel("REVIEWED")).toBe("status:reviewed");
|
|
220
|
+
expect(prdStatusToLabel("APPROVED")).toBe("status:approved");
|
|
221
|
+
expect(prdStatusToLabel("BREAKDOWN_READY")).toBe("status:breakdown-ready");
|
|
222
|
+
expect(prdStatusToLabel("COMPLETED")).toBe("status:completed");
|
|
223
|
+
expect(prdStatusToLabel("ARCHIVED")).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("labelToPrdStatus detects ARCHIVED from closed state", async () => {
|
|
227
|
+
const { labelToPrdStatus } = await import("../mappers/prd.js");
|
|
228
|
+
expect(labelToPrdStatus(["flux:prd", "status:draft"], true)).toBe(
|
|
229
|
+
"ARCHIVED",
|
|
230
|
+
);
|
|
231
|
+
expect(labelToPrdStatus(["flux:prd"], true)).toBe("ARCHIVED");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("all non-ARCHIVED statuses round-trip through label encoding", async () => {
|
|
235
|
+
const { prdStatusToLabel, labelToPrdStatus } = await import(
|
|
236
|
+
"../mappers/prd.js"
|
|
237
|
+
);
|
|
238
|
+
const statuses = [
|
|
239
|
+
"DRAFT",
|
|
240
|
+
"PENDING_REVIEW",
|
|
241
|
+
"REVIEWED",
|
|
242
|
+
"APPROVED",
|
|
243
|
+
"BREAKDOWN_READY",
|
|
244
|
+
"COMPLETED",
|
|
245
|
+
] as const;
|
|
246
|
+
for (const status of statuses) {
|
|
247
|
+
const label = prdStatusToLabel(status);
|
|
248
|
+
expect(label).not.toBeNull();
|
|
249
|
+
const result = labelToPrdStatus([label as string], false);
|
|
250
|
+
expect(result).toBe(status);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("ARCHIVED round-trips via closed issue state", async () => {
|
|
255
|
+
const { labelToPrdStatus } = await import("../mappers/prd.js");
|
|
256
|
+
expect(labelToPrdStatus([], true)).toBe("ARCHIVED");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("tag round-trips through label encoding", async () => {
|
|
260
|
+
const { tagToLabel, labelToTag } = await import("../mappers/prd.js");
|
|
261
|
+
const tag = "mvp";
|
|
262
|
+
const label = tagToLabel(tag);
|
|
263
|
+
expect(label).toBe("tag:mvp");
|
|
264
|
+
const recovered = labelToTag([label]);
|
|
265
|
+
expect(recovered).toBe(tag);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("labelToTag returns undefined when no tag label", async () => {
|
|
269
|
+
const { labelToTag } = await import("../mappers/prd.js");
|
|
270
|
+
expect(labelToTag(["flux:prd", "status:draft"])).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("GitHubAdapter PRD CRUD", () => {
|
|
275
|
+
beforeEach(() => {
|
|
276
|
+
mockIssuesCreate = null;
|
|
277
|
+
mockIssuesUpdate = null;
|
|
278
|
+
mockIssuesGet = null;
|
|
279
|
+
mockIssuesList = [];
|
|
280
|
+
mockSubIssues = {};
|
|
281
|
+
mockGetContent = null;
|
|
282
|
+
lastCreateParams = null;
|
|
283
|
+
_lastUpdateParams = null;
|
|
284
|
+
lastUpdateByNumberParams = {};
|
|
285
|
+
lastPutParams = null;
|
|
286
|
+
issueNodeIdCounter = 0;
|
|
287
|
+
mockGraphqlFn = async () => ({
|
|
288
|
+
addProjectV2ItemById: { item: { id: "PVTI_123" } },
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
afterEach(() => {});
|
|
293
|
+
|
|
294
|
+
describe("createPrd", () => {
|
|
295
|
+
test("returns Prd with DRAFT status and correct folderPath", async () => {
|
|
296
|
+
const issue = makeIssue({
|
|
297
|
+
number: 42,
|
|
298
|
+
title: "My New PRD",
|
|
299
|
+
labels: ["flux:prd", "status:draft"],
|
|
300
|
+
body: '<!-- flux-meta\n{"ref":"FP-P42"}\n-->',
|
|
301
|
+
node_id: "MDU6SXNzdWU0Mg==",
|
|
302
|
+
});
|
|
303
|
+
mockIssuesCreate = issue;
|
|
304
|
+
|
|
305
|
+
const afterUpdate = {
|
|
306
|
+
...issue,
|
|
307
|
+
body: '<!-- flux-meta\n{"ref":"FP-P42"}\n-->',
|
|
308
|
+
labels: issue.labels,
|
|
309
|
+
};
|
|
310
|
+
mockIssuesUpdate = afterUpdate;
|
|
311
|
+
mockIssuesGet = afterUpdate;
|
|
312
|
+
|
|
313
|
+
const adapter = makeAdapter();
|
|
314
|
+
const prd = await adapter.createPrd({ title: "My New PRD" });
|
|
315
|
+
|
|
316
|
+
expect(prd.status).toBe("DRAFT");
|
|
317
|
+
expect(prd.ref).toBe("FP-P42");
|
|
318
|
+
expect(prd.folderPath).toBe("prds/fp-p42/");
|
|
319
|
+
expect(prd.title).toBe("My New PRD");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("created issue has flux:prd and status:draft labels", async () => {
|
|
323
|
+
const issue = makeIssue({
|
|
324
|
+
number: 1,
|
|
325
|
+
labels: ["flux:prd", "status:draft"],
|
|
326
|
+
node_id: "NODE_1",
|
|
327
|
+
});
|
|
328
|
+
mockIssuesCreate = issue;
|
|
329
|
+
mockIssuesUpdate = issue;
|
|
330
|
+
mockIssuesGet = issue;
|
|
331
|
+
|
|
332
|
+
const adapter = makeAdapter();
|
|
333
|
+
await adapter.createPrd({ title: "Test PRD" });
|
|
334
|
+
|
|
335
|
+
expect(lastCreateParams.labels).toContain("flux:prd");
|
|
336
|
+
expect(lastCreateParams.labels).toContain("status:draft");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("flux-meta block in issue body contains ref and tag", async () => {
|
|
340
|
+
const issue = makeIssue({
|
|
341
|
+
number: 5,
|
|
342
|
+
labels: ["flux:prd", "status:draft", "tag:mvp"],
|
|
343
|
+
body: '<!-- flux-meta\n{"ref":"FP-P5","tag":"mvp"}\n-->',
|
|
344
|
+
node_id: "NODE_5",
|
|
345
|
+
});
|
|
346
|
+
mockIssuesCreate = issue;
|
|
347
|
+
mockIssuesUpdate = issue;
|
|
348
|
+
mockIssuesGet = issue;
|
|
349
|
+
|
|
350
|
+
const adapter = makeAdapter();
|
|
351
|
+
const prd = await adapter.createPrd({ title: "Tagged PRD", tag: "mvp" });
|
|
352
|
+
|
|
353
|
+
expect(lastCreateParams.labels).toContain("tag:mvp");
|
|
354
|
+
expect(prd.tag).toBe("mvp");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("adds issue to Projects v2 board via graphql", async () => {
|
|
358
|
+
let capturedMutation: string | null = null;
|
|
359
|
+
let capturedVariables: Record<string, unknown> | undefined;
|
|
360
|
+
|
|
361
|
+
mockGraphqlFn = async (query, variables) => {
|
|
362
|
+
capturedMutation = query;
|
|
363
|
+
capturedVariables = variables;
|
|
364
|
+
return { addProjectV2ItemById: { item: { id: "PVTI_abc" } } };
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const issue = makeIssue({
|
|
368
|
+
number: 10,
|
|
369
|
+
node_id: "NODE_10",
|
|
370
|
+
labels: ["flux:prd", "status:draft"],
|
|
371
|
+
body: '<!-- flux-meta\n{"ref":"FP-P10"}\n-->',
|
|
372
|
+
});
|
|
373
|
+
mockIssuesCreate = issue;
|
|
374
|
+
mockIssuesUpdate = issue;
|
|
375
|
+
mockIssuesGet = issue;
|
|
376
|
+
|
|
377
|
+
const adapter = makeAdapter();
|
|
378
|
+
await adapter.createPrd({ title: "Board PRD" });
|
|
379
|
+
|
|
380
|
+
expect(capturedMutation).not.toBeNull();
|
|
381
|
+
expect(String(capturedMutation)).toContain("addProjectV2ItemById");
|
|
382
|
+
expect(capturedVariables?.contentId).toBe("NODE_10");
|
|
383
|
+
expect(capturedVariables?.projectId).toBe("PVT_kwDO123");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("updatePrd", () => {
|
|
388
|
+
test("swaps old status label for new one without duplicating labels", async () => {
|
|
389
|
+
const existingIssue = makeIssue({
|
|
390
|
+
number: 3,
|
|
391
|
+
labels: ["flux:prd", "status:draft"],
|
|
392
|
+
body: '<!-- flux-meta\n{"ref":"FP-P3"}\n-->',
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const updatedIssue = makeIssue({
|
|
396
|
+
number: 3,
|
|
397
|
+
labels: ["flux:prd", "status:approved"],
|
|
398
|
+
body: '<!-- flux-meta\n{"ref":"FP-P3"}\n-->',
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
mockGetContent = {
|
|
402
|
+
data: {
|
|
403
|
+
sha: "sha1",
|
|
404
|
+
content: Buffer.from(JSON.stringify({ "FP-P3": 3 })).toString(
|
|
405
|
+
"base64",
|
|
406
|
+
),
|
|
407
|
+
encoding: "base64",
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
mockIssuesGet = existingIssue;
|
|
411
|
+
mockIssuesUpdate = updatedIssue;
|
|
412
|
+
|
|
413
|
+
const adapter = makeAdapter();
|
|
414
|
+
const prd = await adapter.updatePrd("FP-P3", { status: "APPROVED" });
|
|
415
|
+
|
|
416
|
+
expect(prd.status).toBe("APPROVED");
|
|
417
|
+
const updatedLabels: string[] = lastUpdateByNumberParams[3]?.labels ?? [];
|
|
418
|
+
expect(updatedLabels).toContain("status:approved");
|
|
419
|
+
expect(updatedLabels).not.toContain("status:draft");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("updatePrd with new tag replaces old tag label", async () => {
|
|
423
|
+
const existingIssue = makeIssue({
|
|
424
|
+
number: 4,
|
|
425
|
+
labels: ["flux:prd", "status:draft", "tag:mvp"],
|
|
426
|
+
body: '<!-- flux-meta\n{"ref":"FP-P4","tag":"mvp"}\n-->',
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const updatedIssue = makeIssue({
|
|
430
|
+
number: 4,
|
|
431
|
+
labels: ["flux:prd", "status:draft", "tag:v2"],
|
|
432
|
+
body: '<!-- flux-meta\n{"ref":"FP-P4","tag":"v2"}\n-->',
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
mockGetContent = {
|
|
436
|
+
data: {
|
|
437
|
+
sha: "sha1",
|
|
438
|
+
content: Buffer.from(JSON.stringify({ "FP-P4": 4 })).toString(
|
|
439
|
+
"base64",
|
|
440
|
+
),
|
|
441
|
+
encoding: "base64",
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
mockIssuesGet = existingIssue;
|
|
445
|
+
mockIssuesUpdate = updatedIssue;
|
|
446
|
+
|
|
447
|
+
const adapter = makeAdapter();
|
|
448
|
+
const prd = await adapter.updatePrd("FP-P4", { tag: "v2" });
|
|
449
|
+
|
|
450
|
+
expect(prd.tag).toBe("v2");
|
|
451
|
+
const updatedLabels: string[] = lastUpdateByNumberParams[4]?.labels ?? [];
|
|
452
|
+
expect(updatedLabels).toContain("tag:v2");
|
|
453
|
+
expect(updatedLabels).not.toContain("tag:mvp");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("updatePrd with ARCHIVED status closes the issue", async () => {
|
|
457
|
+
const existingIssue = makeIssue({
|
|
458
|
+
number: 7,
|
|
459
|
+
labels: ["flux:prd", "status:approved"],
|
|
460
|
+
body: '<!-- flux-meta\n{"ref":"FP-P7"}\n-->',
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const closedIssue = {
|
|
464
|
+
...makeIssue({
|
|
465
|
+
number: 7,
|
|
466
|
+
labels: ["flux:prd"],
|
|
467
|
+
body: '<!-- flux-meta\n{"ref":"FP-P7"}\n-->',
|
|
468
|
+
state: "closed",
|
|
469
|
+
}),
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
mockGetContent = {
|
|
473
|
+
data: {
|
|
474
|
+
sha: "sha1",
|
|
475
|
+
content: Buffer.from(JSON.stringify({ "FP-P7": 7 })).toString(
|
|
476
|
+
"base64",
|
|
477
|
+
),
|
|
478
|
+
encoding: "base64",
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
mockIssuesGet = existingIssue;
|
|
482
|
+
mockIssuesUpdate = closedIssue;
|
|
483
|
+
|
|
484
|
+
const adapter = makeAdapter();
|
|
485
|
+
const prd = await adapter.updatePrd("FP-P7", { status: "ARCHIVED" });
|
|
486
|
+
|
|
487
|
+
expect(prd.status).toBe("ARCHIVED");
|
|
488
|
+
expect(lastUpdateByNumberParams[7]?.state).toBe("closed");
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("getPrd", () => {
|
|
493
|
+
test("returns null for unknown ref", async () => {
|
|
494
|
+
mockGetContent = null;
|
|
495
|
+
mockIssuesList = [];
|
|
496
|
+
|
|
497
|
+
const adapter = makeAdapter();
|
|
498
|
+
const result = await adapter.getPrd("FP-P999");
|
|
499
|
+
|
|
500
|
+
expect(result).toBeNull();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("returns correct Prd with status parsed from labels", async () => {
|
|
504
|
+
const issue = makeIssue({
|
|
505
|
+
number: 8,
|
|
506
|
+
title: "My PRD",
|
|
507
|
+
labels: ["flux:prd", "status:reviewed"],
|
|
508
|
+
body: '<!-- flux-meta\n{"ref":"FP-P8","tag":"feature"}\n-->',
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
mockGetContent = {
|
|
512
|
+
data: {
|
|
513
|
+
sha: "sha1",
|
|
514
|
+
content: Buffer.from(JSON.stringify({ "FP-P8": 8 })).toString(
|
|
515
|
+
"base64",
|
|
516
|
+
),
|
|
517
|
+
encoding: "base64",
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
mockIssuesGet = issue;
|
|
521
|
+
|
|
522
|
+
const adapter = makeAdapter();
|
|
523
|
+
const prd = await adapter.getPrd("FP-P8");
|
|
524
|
+
|
|
525
|
+
expect(prd).not.toBeNull();
|
|
526
|
+
expect(prd?.ref).toBe("FP-P8");
|
|
527
|
+
expect(prd?.status).toBe("REVIEWED");
|
|
528
|
+
expect(prd?.tag).toBe("feature");
|
|
529
|
+
expect(prd?.title).toBe("My PRD");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("returns Prd with correct folderPath derived from ref", async () => {
|
|
533
|
+
const issue = makeIssue({
|
|
534
|
+
number: 9,
|
|
535
|
+
title: "Folder PRD",
|
|
536
|
+
labels: ["flux:prd", "status:draft"],
|
|
537
|
+
body: '<!-- flux-meta\n{"ref":"FP-P9"}\n-->',
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
mockGetContent = {
|
|
541
|
+
data: {
|
|
542
|
+
sha: "sha1",
|
|
543
|
+
content: Buffer.from(JSON.stringify({ "FP-P9": 9 })).toString(
|
|
544
|
+
"base64",
|
|
545
|
+
),
|
|
546
|
+
encoding: "base64",
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
mockIssuesGet = issue;
|
|
550
|
+
|
|
551
|
+
const adapter = makeAdapter();
|
|
552
|
+
const prd = await adapter.getPrd("FP-P9");
|
|
553
|
+
|
|
554
|
+
expect(prd?.folderPath).toBe("prds/fp-p9/");
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe("listPrds", () => {
|
|
559
|
+
test("returns all PRDs when no filters", async () => {
|
|
560
|
+
const issues = [
|
|
561
|
+
makeIssue({
|
|
562
|
+
number: 11,
|
|
563
|
+
labels: ["flux:prd", "status:draft"],
|
|
564
|
+
body: '<!-- flux-meta\n{"ref":"FP-P11"}\n-->',
|
|
565
|
+
}),
|
|
566
|
+
makeIssue({
|
|
567
|
+
number: 12,
|
|
568
|
+
labels: ["flux:prd", "status:approved"],
|
|
569
|
+
body: '<!-- flux-meta\n{"ref":"FP-P12"}\n-->',
|
|
570
|
+
}),
|
|
571
|
+
];
|
|
572
|
+
mockIssuesList = issues;
|
|
573
|
+
|
|
574
|
+
const adapter = makeAdapter();
|
|
575
|
+
const result = await adapter.listPrds();
|
|
576
|
+
|
|
577
|
+
expect(result.items.length).toBe(2);
|
|
578
|
+
expect(result.total).toBe(2);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("listPrds with status filter returns only PRDs with matching status label", async () => {
|
|
582
|
+
const issues = [
|
|
583
|
+
makeIssue({
|
|
584
|
+
number: 13,
|
|
585
|
+
labels: ["flux:prd", "status:draft"],
|
|
586
|
+
body: '<!-- flux-meta\n{"ref":"FP-P13"}\n-->',
|
|
587
|
+
}),
|
|
588
|
+
makeIssue({
|
|
589
|
+
number: 14,
|
|
590
|
+
labels: ["flux:prd", "status:approved"],
|
|
591
|
+
body: '<!-- flux-meta\n{"ref":"FP-P14"}\n-->',
|
|
592
|
+
}),
|
|
593
|
+
];
|
|
594
|
+
mockIssuesList = issues;
|
|
595
|
+
|
|
596
|
+
const adapter = makeAdapter();
|
|
597
|
+
|
|
598
|
+
let lastListParams: any;
|
|
599
|
+
(adapter as any).client.rest.issues.listForRepo = async (params: any) => {
|
|
600
|
+
lastListParams = params;
|
|
601
|
+
return { data: [issues[0]] };
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const result = await adapter.listPrds({ status: "DRAFT" });
|
|
605
|
+
|
|
606
|
+
expect(lastListParams.labels).toContain("status:draft");
|
|
607
|
+
expect(result.items.length).toBe(1);
|
|
608
|
+
expect(result.items[0].status).toBe("DRAFT");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("listPrds with tag filter returns only PRDs with matching tag label", async () => {
|
|
612
|
+
const issues = [
|
|
613
|
+
makeIssue({
|
|
614
|
+
number: 15,
|
|
615
|
+
labels: ["flux:prd", "status:draft", "tag:mvp"],
|
|
616
|
+
body: '<!-- flux-meta\n{"ref":"FP-P15","tag":"mvp"}\n-->',
|
|
617
|
+
}),
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
const adapter = makeAdapter();
|
|
621
|
+
|
|
622
|
+
let lastListParams: any;
|
|
623
|
+
(adapter as any).client.rest.issues.listForRepo = async (params: any) => {
|
|
624
|
+
lastListParams = params;
|
|
625
|
+
return { data: issues };
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const result = await adapter.listPrds({ tag: "mvp" });
|
|
629
|
+
|
|
630
|
+
expect(lastListParams.labels).toContain("tag:mvp");
|
|
631
|
+
expect(result.items.length).toBe(1);
|
|
632
|
+
expect(result.items[0].tag).toBe("mvp");
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("listPrds pagination: limit and offset return correct slice with correct total and has_more", async () => {
|
|
636
|
+
const issues = Array.from({ length: 5 }, (_, i) =>
|
|
637
|
+
makeIssue({
|
|
638
|
+
number: 20 + i,
|
|
639
|
+
labels: ["flux:prd", "status:draft"],
|
|
640
|
+
body: `<!-- flux-meta\n{"ref":"FP-P${20 + i}"}\n-->`,
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
mockIssuesList = issues;
|
|
644
|
+
|
|
645
|
+
const adapter = makeAdapter();
|
|
646
|
+
const result = await adapter.listPrds({}, { limit: 2, offset: 1 });
|
|
647
|
+
|
|
648
|
+
expect(result.limit).toBe(2);
|
|
649
|
+
expect(result.offset).toBe(1);
|
|
650
|
+
expect(result.total).toBe(5);
|
|
651
|
+
expect(result.items.length).toBe(2);
|
|
652
|
+
expect(result.hasMore).toBe(true);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
describe("deletePrd", () => {
|
|
657
|
+
test("deletePrd on unknown ref throws descriptive error", async () => {
|
|
658
|
+
mockGetContent = null;
|
|
659
|
+
mockIssuesList = [];
|
|
660
|
+
|
|
661
|
+
const adapter = makeAdapter();
|
|
662
|
+
|
|
663
|
+
await expect(adapter.deletePrd("FP-P999")).rejects.toThrow("FP-P999");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("deletePrd closes PRD issue and all child epic and task issues", async () => {
|
|
667
|
+
const prdIssue = makeIssue({
|
|
668
|
+
number: 30,
|
|
669
|
+
labels: ["flux:prd", "status:approved"],
|
|
670
|
+
body: '<!-- flux-meta\n{"ref":"FP-P30"}\n-->',
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const epicIssue = makeIssue({
|
|
674
|
+
number: 31,
|
|
675
|
+
labels: ["flux:epic"],
|
|
676
|
+
body: '<!-- flux-meta\n{"ref":"FP-E31","prd_ref":"FP-P30"}\n-->',
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const taskIssue = makeIssue({
|
|
680
|
+
number: 32,
|
|
681
|
+
labels: ["flux:task"],
|
|
682
|
+
body: '<!-- flux-meta\n{"ref":"FP-T32","epic_ref":"FP-E31"}\n-->',
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
mockGetContent = {
|
|
686
|
+
data: {
|
|
687
|
+
sha: "sha1",
|
|
688
|
+
content: Buffer.from(JSON.stringify({ "FP-P30": 30 })).toString(
|
|
689
|
+
"base64",
|
|
690
|
+
),
|
|
691
|
+
encoding: "base64",
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
mockIssuesList = [epicIssue, taskIssue];
|
|
696
|
+
|
|
697
|
+
const closedIssues: number[] = [];
|
|
698
|
+
|
|
699
|
+
const adapter = makeAdapter();
|
|
700
|
+
(adapter as any).client.rest.issues.get = async (params: any) => {
|
|
701
|
+
const issueNum = params.issue_number;
|
|
702
|
+
if (issueNum === 30) return { data: prdIssue };
|
|
703
|
+
if (issueNum === 31) return { data: epicIssue };
|
|
704
|
+
if (issueNum === 32) return { data: taskIssue };
|
|
705
|
+
throw Object.assign(new Error("Not Found"), { status: 404 });
|
|
706
|
+
};
|
|
707
|
+
(adapter as any).client.rest.issues.update = async (params: any) => {
|
|
708
|
+
closedIssues.push(params.issue_number);
|
|
709
|
+
return { data: { ...prdIssue, state: "closed" } };
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const result = await adapter.deletePrd("FP-P30");
|
|
713
|
+
|
|
714
|
+
expect(result.deleted).toBe("FP-P30");
|
|
715
|
+
expect(result.cascade.epics).toBe(1);
|
|
716
|
+
expect(result.cascade.tasks).toBe(1);
|
|
717
|
+
expect(closedIssues).toContain(30);
|
|
718
|
+
expect(closedIssues).toContain(31);
|
|
719
|
+
expect(closedIssues).toContain(32);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("returns CascadeResult with correct epics, tasks counts", async () => {
|
|
723
|
+
const prdIssue = makeIssue({
|
|
724
|
+
number: 40,
|
|
725
|
+
labels: ["flux:prd", "status:draft"],
|
|
726
|
+
body: '<!-- flux-meta\n{"ref":"FP-P40"}\n-->',
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const epicIssue1 = makeIssue({
|
|
730
|
+
number: 41,
|
|
731
|
+
labels: ["flux:epic"],
|
|
732
|
+
body: '<!-- flux-meta\n{"ref":"FP-E41","prd_ref":"FP-P40"}\n-->',
|
|
733
|
+
});
|
|
734
|
+
const epicIssue2 = makeIssue({
|
|
735
|
+
number: 42,
|
|
736
|
+
labels: ["flux:epic"],
|
|
737
|
+
body: '<!-- flux-meta\n{"ref":"FP-E42","prd_ref":"FP-P40"}\n-->',
|
|
738
|
+
});
|
|
739
|
+
const taskIssue1 = makeIssue({
|
|
740
|
+
number: 43,
|
|
741
|
+
labels: ["flux:task"],
|
|
742
|
+
body: '<!-- flux-meta\n{"ref":"FP-T43","epic_ref":"FP-E41"}\n-->',
|
|
743
|
+
});
|
|
744
|
+
const taskIssue2 = makeIssue({
|
|
745
|
+
number: 44,
|
|
746
|
+
labels: ["flux:task"],
|
|
747
|
+
body: '<!-- flux-meta\n{"ref":"FP-T44","epic_ref":"FP-E41"}\n-->',
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
mockGetContent = {
|
|
751
|
+
data: {
|
|
752
|
+
sha: "sha1",
|
|
753
|
+
content: Buffer.from(JSON.stringify({ "FP-P40": 40 })).toString(
|
|
754
|
+
"base64",
|
|
755
|
+
),
|
|
756
|
+
encoding: "base64",
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
mockIssuesList = [epicIssue1, epicIssue2, taskIssue1, taskIssue2];
|
|
761
|
+
|
|
762
|
+
const adapter = makeAdapter();
|
|
763
|
+
(adapter as any).client.rest.issues.get = async (params: any) => {
|
|
764
|
+
const n = params.issue_number;
|
|
765
|
+
const map: Record<number, any> = {
|
|
766
|
+
40: prdIssue,
|
|
767
|
+
41: epicIssue1,
|
|
768
|
+
42: epicIssue2,
|
|
769
|
+
43: taskIssue1,
|
|
770
|
+
44: taskIssue2,
|
|
771
|
+
};
|
|
772
|
+
if (map[n]) return { data: map[n] };
|
|
773
|
+
throw Object.assign(new Error("Not Found"), { status: 404 });
|
|
774
|
+
};
|
|
775
|
+
(adapter as any).client.rest.issues.update = async (_params: any) => ({
|
|
776
|
+
data: { ...prdIssue, state: "closed" },
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const result = await adapter.deletePrd("FP-P40");
|
|
780
|
+
|
|
781
|
+
expect(result.cascade.epics).toBe(2);
|
|
782
|
+
expect(result.cascade.tasks).toBe(2);
|
|
783
|
+
expect(result.cascade.criteria).toBe(0);
|
|
784
|
+
expect(result.cascade.dependencies).toBe(0);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("all deleted refs removed from index", async () => {
|
|
788
|
+
const prdIssue = makeIssue({
|
|
789
|
+
number: 50,
|
|
790
|
+
labels: ["flux:prd"],
|
|
791
|
+
body: '<!-- flux-meta\n{"ref":"FP-P50"}\n-->',
|
|
792
|
+
});
|
|
793
|
+
const epicIssue = makeIssue({
|
|
794
|
+
number: 51,
|
|
795
|
+
labels: ["flux:epic"],
|
|
796
|
+
body: '<!-- flux-meta\n{"ref":"FP-E51","prd_ref":"FP-P50"}\n-->',
|
|
797
|
+
});
|
|
798
|
+
const taskIssue = makeIssue({
|
|
799
|
+
number: 52,
|
|
800
|
+
labels: ["flux:task"],
|
|
801
|
+
body: '<!-- flux-meta\n{"ref":"FP-T52","epic_ref":"FP-E51"}\n-->',
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const indexData = { "FP-P50": 50, "FP-E51": 51, "FP-T52": 52 };
|
|
805
|
+
mockGetContent = {
|
|
806
|
+
data: {
|
|
807
|
+
sha: "sha1",
|
|
808
|
+
content: Buffer.from(JSON.stringify(indexData)).toString("base64"),
|
|
809
|
+
encoding: "base64",
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
mockSubIssues[50] = [epicIssue];
|
|
814
|
+
mockSubIssues[51] = [taskIssue];
|
|
815
|
+
|
|
816
|
+
const adapter = makeAdapter();
|
|
817
|
+
(adapter as any).client.rest.issues.get = async (params: any) => {
|
|
818
|
+
const n = params.issue_number;
|
|
819
|
+
if (n === 50) return { data: prdIssue };
|
|
820
|
+
if (n === 51) return { data: epicIssue };
|
|
821
|
+
if (n === 52) return { data: taskIssue };
|
|
822
|
+
throw Object.assign(new Error("Not Found"), { status: 404 });
|
|
823
|
+
};
|
|
824
|
+
(adapter as any).client.rest.issues.update = async (_params: any) => ({
|
|
825
|
+
data: { ...prdIssue, state: "closed" },
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
await adapter.deletePrd("FP-P50");
|
|
829
|
+
|
|
830
|
+
const written = JSON.parse(
|
|
831
|
+
Buffer.from(lastPutParams.content, "base64").toString("utf-8"),
|
|
832
|
+
);
|
|
833
|
+
expect(written["FP-P50"]).toBeUndefined();
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
});
|