@cliangdev/flux-plugin 0.2.0 → 0.3.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.
Files changed (108) hide show
  1. package/README.md +11 -7
  2. package/agents/coder.md +150 -25
  3. package/bin/install.cjs +171 -16
  4. package/commands/breakdown.md +47 -10
  5. package/commands/dashboard.md +29 -0
  6. package/commands/flux.md +92 -12
  7. package/commands/implement.md +166 -17
  8. package/commands/linear.md +6 -5
  9. package/commands/prd.md +996 -82
  10. package/manifest.json +2 -1
  11. package/package.json +9 -11
  12. package/skills/flux-orchestrator/SKILL.md +11 -3
  13. package/skills/prd-writer/SKILL.md +761 -0
  14. package/skills/ux-ui-design/SKILL.md +346 -0
  15. package/skills/ux-ui-design/references/design-tokens.md +359 -0
  16. package/src/__tests__/version.test.ts +37 -0
  17. package/src/adapters/local/.gitkeep +0 -0
  18. package/src/dashboard/__tests__/api.test.ts +211 -0
  19. package/src/dashboard/browser.ts +35 -0
  20. package/src/dashboard/public/app.js +869 -0
  21. package/src/dashboard/public/index.html +90 -0
  22. package/src/dashboard/public/styles.css +807 -0
  23. package/src/dashboard/public/vendor/highlight.css +10 -0
  24. package/src/dashboard/public/vendor/highlight.min.js +8422 -0
  25. package/src/dashboard/public/vendor/marked.min.js +2210 -0
  26. package/src/dashboard/server.ts +296 -0
  27. package/src/dashboard/watchers.ts +83 -0
  28. package/src/server/__tests__/config.test.ts +163 -0
  29. package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
  30. package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
  31. package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
  32. package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
  33. package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
  34. package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
  35. package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
  36. package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
  37. package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
  38. package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
  39. package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
  40. package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
  41. package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
  42. package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
  43. package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
  44. package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
  45. package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
  46. package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
  47. package/src/server/adapters/factory.ts +90 -0
  48. package/src/server/adapters/index.ts +9 -0
  49. package/src/server/adapters/linear/adapter.ts +1141 -0
  50. package/src/server/adapters/linear/client.ts +169 -0
  51. package/src/server/adapters/linear/config.ts +152 -0
  52. package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
  53. package/src/server/adapters/linear/helpers/index.ts +7 -0
  54. package/src/server/adapters/linear/index.ts +16 -0
  55. package/src/server/adapters/linear/mappers/description.ts +136 -0
  56. package/src/server/adapters/linear/mappers/epic.ts +81 -0
  57. package/src/server/adapters/linear/mappers/index.ts +27 -0
  58. package/src/server/adapters/linear/mappers/prd.ts +178 -0
  59. package/src/server/adapters/linear/mappers/task.ts +82 -0
  60. package/src/server/adapters/linear/types.ts +264 -0
  61. package/src/server/adapters/local-adapter.ts +1009 -0
  62. package/src/server/adapters/types.ts +293 -0
  63. package/src/server/config.ts +73 -0
  64. package/src/server/db/__tests__/queries.test.ts +473 -0
  65. package/src/server/db/ids.ts +17 -0
  66. package/src/server/db/index.ts +69 -0
  67. package/src/server/db/queries.ts +142 -0
  68. package/src/server/db/refs.ts +60 -0
  69. package/src/server/db/schema.ts +97 -0
  70. package/src/server/db/sqlite.ts +10 -0
  71. package/src/server/index.ts +81 -0
  72. package/src/server/tools/__tests__/crud.test.ts +411 -0
  73. package/src/server/tools/__tests__/get-version.test.ts +27 -0
  74. package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
  75. package/src/server/tools/__tests__/query.test.ts +405 -0
  76. package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
  77. package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
  78. package/src/server/tools/configure-linear.ts +373 -0
  79. package/src/server/tools/create-epic.ts +44 -0
  80. package/src/server/tools/create-prd.ts +40 -0
  81. package/src/server/tools/create-task.ts +47 -0
  82. package/src/server/tools/criteria.ts +50 -0
  83. package/src/server/tools/delete-entity.ts +76 -0
  84. package/src/server/tools/dependencies.ts +55 -0
  85. package/src/server/tools/get-entity.ts +240 -0
  86. package/src/server/tools/get-linear-url.ts +28 -0
  87. package/src/server/tools/get-stats.ts +52 -0
  88. package/src/server/tools/get-version.ts +20 -0
  89. package/src/server/tools/index.ts +158 -0
  90. package/src/server/tools/init-project.ts +108 -0
  91. package/src/server/tools/query-entities.ts +167 -0
  92. package/src/server/tools/render-status.ts +219 -0
  93. package/src/server/tools/update-entity.ts +140 -0
  94. package/src/server/tools/update-status.ts +166 -0
  95. package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
  96. package/src/server/utils/logger.ts +9 -0
  97. package/src/server/utils/mcp-response.ts +254 -0
  98. package/src/server/utils/status-transitions.ts +160 -0
  99. package/src/status-line/__tests__/status-line.test.ts +215 -0
  100. package/src/status-line/index.ts +147 -0
  101. package/src/utils/__tests__/chalk-import.test.ts +32 -0
  102. package/src/utils/__tests__/display.test.ts +97 -0
  103. package/src/utils/__tests__/status-renderer.test.ts +310 -0
  104. package/src/utils/display.ts +62 -0
  105. package/src/utils/status-renderer.ts +214 -0
  106. package/src/version.ts +5 -0
  107. package/dist/server/index.js +0 -87063
  108. package/skills/prd-template/SKILL.md +0 -242
@@ -0,0 +1,620 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type { HydratedIssue, LinearAdapter } from "../linear/adapter.js";
3
+ import type { LinearConfig } from "../linear/types.js";
4
+
5
+ /**
6
+ * Helper to create a mock HydratedIssue with PRD defaults
7
+ */
8
+ function createMockPrdIssue(
9
+ overrides: Partial<HydratedIssue> = {},
10
+ ): HydratedIssue {
11
+ return {
12
+ id: "issue_abc123",
13
+ identifier: "ENG-42",
14
+ title: "Test PRD",
15
+ description: "Test description",
16
+ stateName: "Backlog",
17
+ stateType: "backlog",
18
+ labels: ["prd"],
19
+ parentIdentifier: undefined,
20
+ priority: 3,
21
+ createdAt: new Date("2024-01-01T00:00:00Z"),
22
+ updatedAt: new Date("2024-01-01T00:00:00Z"),
23
+ _raw: {},
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("LinearAdapter - PRD CRUD Operations", () => {
29
+ const mockConfig: LinearConfig = {
30
+ apiKey: "lin_api_test123",
31
+ teamId: "team_123",
32
+ projectId: "proj_container",
33
+ defaultLabels: {
34
+ prd: "prd",
35
+ epic: "epic",
36
+ task: "task",
37
+ },
38
+ };
39
+
40
+ let adapter: LinearAdapter;
41
+
42
+ beforeEach(async () => {
43
+ // Dynamic import to avoid module caching issues
44
+ const { LinearAdapter: LA } = await import("../linear/adapter.js");
45
+ adapter = new LA(mockConfig);
46
+ });
47
+
48
+ describe("createPrd", () => {
49
+ test("creates Linear Issue with prd label and returns Prd", async () => {
50
+ const mockCreatedIssue = createMockPrdIssue({
51
+ id: "issue_abc123",
52
+ identifier: "ENG-42",
53
+ title: "User Authentication",
54
+ description: "Implement user login and registration",
55
+ stateName: "Backlog",
56
+ });
57
+
58
+ // Mock getLabelId
59
+ (adapter as any).getLabelId = mock(async () => "label_prd_123");
60
+
61
+ // Mock client.execute and createIssue
62
+ (adapter as any).client = {
63
+ execute: mock(async (fn: () => Promise<any>) => fn()),
64
+ client: {
65
+ createIssue: mock(async () => ({ issue: { id: "issue_abc123" } })),
66
+ },
67
+ };
68
+
69
+ // Mock hydrateIssue to return our prepared result
70
+ (adapter as any).hydrateIssue = mock(async () => mockCreatedIssue);
71
+
72
+ const prd = await adapter.createPrd({
73
+ title: "User Authentication",
74
+ description: "Implement user login and registration",
75
+ });
76
+
77
+ expect(prd.id).toBe("issue_abc123");
78
+ expect(prd.projectId).toBe("proj_container");
79
+ expect(prd.ref).toBe("ENG-42");
80
+ expect(prd.title).toBe("User Authentication");
81
+ expect(prd.description).toBe("Implement user login and registration");
82
+ expect(prd.status).toBe("DRAFT"); // Backlog -> DRAFT
83
+ });
84
+
85
+ test("creates PRD without optional description", async () => {
86
+ const mockCreatedIssue = createMockPrdIssue({
87
+ id: "issue_xyz",
88
+ identifier: "ENG-99",
89
+ title: "Minimal PRD",
90
+ description: undefined,
91
+ });
92
+
93
+ (adapter as any).getLabelId = mock(async () => "label_prd_123");
94
+ (adapter as any).client = {
95
+ execute: mock(async (fn: () => Promise<any>) => fn()),
96
+ client: {
97
+ createIssue: mock(async () => ({ issue: { id: "issue_xyz" } })),
98
+ },
99
+ };
100
+ (adapter as any).hydrateIssue = mock(async () => mockCreatedIssue);
101
+
102
+ const prd = await adapter.createPrd({
103
+ title: "Minimal PRD",
104
+ });
105
+
106
+ expect(prd.title).toBe("Minimal PRD");
107
+ expect(prd.description).toBeUndefined();
108
+ expect(prd.status).toBe("DRAFT");
109
+ });
110
+ });
111
+
112
+ describe("getPrd", () => {
113
+ test("returns Prd when issue exists and has prd label", async () => {
114
+ const mockIssue = createMockPrdIssue({
115
+ id: "issue_abc123",
116
+ identifier: "ENG-42",
117
+ title: "User Authentication",
118
+ description: "Implement user login",
119
+ stateName: "In Progress",
120
+ stateType: "started",
121
+ labels: ["prd"],
122
+ });
123
+
124
+ (adapter as any).fetchIssue = mock(async () => mockIssue);
125
+
126
+ const prd = await adapter.getPrd("ENG-42");
127
+
128
+ expect(prd).not.toBeNull();
129
+ expect(prd?.id).toBe("issue_abc123");
130
+ expect(prd?.ref).toBe("ENG-42");
131
+ expect(prd?.title).toBe("User Authentication");
132
+ expect(prd?.status).toBe("APPROVED"); // In Progress -> APPROVED
133
+ });
134
+
135
+ test("returns null when issue does not exist", async () => {
136
+ (adapter as any).fetchIssue = mock(async () => null);
137
+
138
+ const prd = await adapter.getPrd("ENG-999");
139
+
140
+ expect(prd).toBeNull();
141
+ });
142
+
143
+ test("returns null when issue exists but lacks prd label", async () => {
144
+ const mockIssue = createMockPrdIssue({
145
+ identifier: "ENG-42",
146
+ labels: ["epic"], // epic label, not prd
147
+ });
148
+
149
+ (adapter as any).fetchIssue = mock(async () => mockIssue);
150
+
151
+ const prd = await adapter.getPrd("ENG-42");
152
+
153
+ expect(prd).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe("updatePrd", () => {
158
+ test("updates issue and returns updated Prd", async () => {
159
+ const mockIssue = createMockPrdIssue({
160
+ identifier: "ENG-42",
161
+ title: "Old Title",
162
+ labels: ["prd"],
163
+ _raw: {
164
+ update: mock(async () => ({})),
165
+ },
166
+ });
167
+
168
+ const mockUpdatedIssue = createMockPrdIssue({
169
+ identifier: "ENG-42",
170
+ title: "User Authentication v2",
171
+ description: "Updated description",
172
+ stateName: "In Progress",
173
+ stateType: "started",
174
+ labels: ["prd"],
175
+ });
176
+
177
+ // fetchIssue returns original first, then updated
178
+ let fetchCount = 0;
179
+ (adapter as any).fetchIssue = mock(async () => {
180
+ fetchCount++;
181
+ return fetchCount === 1 ? mockIssue : mockUpdatedIssue;
182
+ });
183
+ (adapter as any).client = {
184
+ execute: mock(async (fn: () => Promise<any>) => fn()),
185
+ };
186
+
187
+ const prd = await adapter.updatePrd("ENG-42", {
188
+ title: "User Authentication v2",
189
+ description: "Updated description",
190
+ });
191
+
192
+ expect(prd.title).toBe("User Authentication v2");
193
+ expect(prd.description).toBe("Updated description");
194
+ expect(prd.status).toBe("APPROVED"); // In Progress -> APPROVED
195
+ });
196
+
197
+ test("throws error when PRD not found", async () => {
198
+ (adapter as any).fetchIssue = mock(async () => null);
199
+
200
+ await expect(
201
+ adapter.updatePrd("ENG-999", { title: "New Title" }),
202
+ ).rejects.toThrow("PRD not found: ENG-999");
203
+ });
204
+
205
+ test("throws error when issue is not a PRD", async () => {
206
+ const mockIssue = createMockPrdIssue({
207
+ identifier: "ENG-42",
208
+ labels: ["epic"], // Not a PRD
209
+ });
210
+
211
+ (adapter as any).fetchIssue = mock(async () => mockIssue);
212
+
213
+ await expect(
214
+ adapter.updatePrd("ENG-42", { title: "New Title" }),
215
+ ).rejects.toThrow("Issue ENG-42 is not a PRD");
216
+ });
217
+ });
218
+
219
+ describe("listPrds", () => {
220
+ test("returns paginated list of PRDs filtered by prd label", async () => {
221
+ const mockIssues = [
222
+ createMockPrdIssue({
223
+ id: "issue_1",
224
+ identifier: "ENG-1",
225
+ title: "PRD 1",
226
+ description: "Description 1",
227
+ stateName: "Backlog",
228
+ labels: ["prd"],
229
+ }),
230
+ createMockPrdIssue({
231
+ id: "issue_2",
232
+ identifier: "ENG-2",
233
+ title: "PRD 2",
234
+ description: "Description 2",
235
+ stateName: "In Progress",
236
+ labels: ["prd"],
237
+ }),
238
+ ];
239
+
240
+ (adapter as any).fetchIssues = mock(async () => mockIssues);
241
+
242
+ const result = await adapter.listPrds({}, { limit: 2, offset: 0 });
243
+
244
+ expect(result.items.length).toBe(2);
245
+ expect(result.items[0].ref).toBe("ENG-1");
246
+ expect(result.items[0].title).toBe("PRD 1");
247
+ expect(result.items[0].status).toBe("DRAFT");
248
+ expect(result.items[1].ref).toBe("ENG-2");
249
+ expect(result.items[1].title).toBe("PRD 2");
250
+ expect(result.items[1].status).toBe("APPROVED");
251
+ expect(result.limit).toBe(2);
252
+ expect(result.offset).toBe(0);
253
+ });
254
+
255
+ test("returns empty list when no PRDs exist", async () => {
256
+ (adapter as any).fetchIssues = mock(async () => []);
257
+
258
+ const result = await adapter.listPrds({}, { limit: 10, offset: 0 });
259
+
260
+ expect(result.items.length).toBe(0);
261
+ expect(result.total).toBe(0);
262
+ expect(result.hasMore).toBe(false);
263
+ });
264
+
265
+ test("uses default pagination when not specified", async () => {
266
+ (adapter as any).fetchIssues = mock(async () => []);
267
+
268
+ const result = await adapter.listPrds();
269
+
270
+ expect(result.limit).toBe(50); // default limit
271
+ expect(result.offset).toBe(0); // default offset
272
+ });
273
+
274
+ test("filters out non-PRD issues", async () => {
275
+ const mockIssues = [
276
+ createMockPrdIssue({
277
+ id: "issue_1",
278
+ identifier: "ENG-1",
279
+ title: "PRD 1",
280
+ labels: ["prd"],
281
+ }),
282
+ createMockPrdIssue({
283
+ id: "issue_2",
284
+ identifier: "ENG-2",
285
+ title: "Epic (not a PRD)",
286
+ labels: ["epic"], // This should be filtered out
287
+ }),
288
+ ];
289
+
290
+ (adapter as any).fetchIssues = mock(async () => mockIssues);
291
+
292
+ const result = await adapter.listPrds({}, { limit: 10, offset: 0 });
293
+
294
+ expect(result.items.length).toBe(1);
295
+ expect(result.items[0].ref).toBe("ENG-1");
296
+ });
297
+ });
298
+
299
+ describe("deletePrd", () => {
300
+ test("archives PRD issue and its children", async () => {
301
+ const mockTaskArchive = mock(async () => ({ success: true }));
302
+ const mockEpicArchive = mock(async () => ({ success: true }));
303
+ const mockPrdArchive = mock(async () => ({ success: true }));
304
+
305
+ const mockChildEpic = createMockPrdIssue({
306
+ id: "epic_1",
307
+ identifier: "ENG-2",
308
+ labels: ["epic"],
309
+ _raw: {
310
+ archive: mockEpicArchive,
311
+ children: mock(async () => ({
312
+ nodes: [
313
+ {
314
+ id: "task_1",
315
+ archive: mockTaskArchive,
316
+ },
317
+ ],
318
+ })),
319
+ },
320
+ });
321
+
322
+ const mockPrdIssue = createMockPrdIssue({
323
+ id: "issue_abc123",
324
+ identifier: "ENG-1",
325
+ labels: ["prd"],
326
+ _raw: {
327
+ archive: mockPrdArchive,
328
+ children: mock(async () => ({
329
+ nodes: [mockChildEpic._raw],
330
+ })),
331
+ },
332
+ });
333
+
334
+ (adapter as any).fetchIssue = mock(async () => mockPrdIssue);
335
+ (adapter as any).hydrateIssue = mock(async () => mockChildEpic);
336
+ (adapter as any).client = {
337
+ execute: mock(async (fn: () => Promise<any>) => fn()),
338
+ };
339
+
340
+ const result = await adapter.deletePrd("ENG-1");
341
+
342
+ expect(result.deleted).toBe("ENG-1");
343
+ expect(result.cascade.epics).toBe(1);
344
+ expect(result.cascade.tasks).toBe(1);
345
+ expect(mockPrdArchive).toHaveBeenCalled();
346
+ });
347
+
348
+ test("throws error when PRD does not exist", async () => {
349
+ (adapter as any).fetchIssue = mock(async () => null);
350
+
351
+ await expect(adapter.deletePrd("ENG-999")).rejects.toThrow(
352
+ "PRD not found: ENG-999",
353
+ );
354
+ });
355
+
356
+ test("handles PRD with no children", async () => {
357
+ const mockPrdArchive = mock(async () => ({ success: true }));
358
+ const mockPrdIssue = createMockPrdIssue({
359
+ id: "issue_abc123",
360
+ identifier: "ENG-1",
361
+ labels: ["prd"],
362
+ _raw: {
363
+ archive: mockPrdArchive,
364
+ children: mock(async () => ({ nodes: [] })),
365
+ },
366
+ });
367
+
368
+ (adapter as any).fetchIssue = mock(async () => mockPrdIssue);
369
+ (adapter as any).client = {
370
+ execute: mock(async (fn: () => Promise<any>) => fn()),
371
+ };
372
+
373
+ const result = await adapter.deletePrd("ENG-1");
374
+
375
+ expect(result.deleted).toBe("ENG-1");
376
+ expect(result.cascade.epics).toBe(0);
377
+ expect(result.cascade.tasks).toBe(0);
378
+ expect(mockPrdArchive).toHaveBeenCalled();
379
+ });
380
+ });
381
+
382
+ describe("PRD Tag Support", () => {
383
+ test("createPrd with tag adds milestone label", async () => {
384
+ const mockCreatedIssue = createMockPrdIssue({
385
+ id: "issue_abc123",
386
+ identifier: "ENG-42",
387
+ title: "MVP Feature",
388
+ labels: ["prd", "flux:milestone:mvp-phase-1"],
389
+ });
390
+
391
+ let capturedCreateInput: any = null;
392
+ (adapter as any).getLabelId = mock(async () => "label_prd_123");
393
+ (adapter as any).getOrCreateLabel = mock(async (name: string) => {
394
+ if (name === "flux:milestone:mvp-phase-1") return "label_milestone_123";
395
+ return "label_other";
396
+ });
397
+ (adapter as any).client = {
398
+ execute: mock(async (fn: () => Promise<any>) => fn()),
399
+ client: {
400
+ createIssue: mock(async (input: any) => {
401
+ capturedCreateInput = input;
402
+ return { issue: { id: "issue_abc123" } };
403
+ }),
404
+ },
405
+ };
406
+ (adapter as any).hydrateIssue = mock(async () => mockCreatedIssue);
407
+
408
+ const prd = await adapter.createPrd({
409
+ title: "MVP Feature",
410
+ tag: "mvp-phase-1",
411
+ });
412
+
413
+ expect(prd.tag).toBe("mvp-phase-1");
414
+ expect(capturedCreateInput.labelIds).toContain("label_prd_123");
415
+ expect(capturedCreateInput.labelIds).toContain("label_milestone_123");
416
+ });
417
+
418
+ test("createPrd without tag does not add milestone label", async () => {
419
+ const mockCreatedIssue = createMockPrdIssue({
420
+ id: "issue_abc123",
421
+ identifier: "ENG-42",
422
+ title: "No Tag PRD",
423
+ labels: ["prd"],
424
+ });
425
+
426
+ let capturedCreateInput: any = null;
427
+ (adapter as any).getLabelId = mock(async () => "label_prd_123");
428
+ (adapter as any).client = {
429
+ execute: mock(async (fn: () => Promise<any>) => fn()),
430
+ client: {
431
+ createIssue: mock(async (input: any) => {
432
+ capturedCreateInput = input;
433
+ return { issue: { id: "issue_abc123" } };
434
+ }),
435
+ },
436
+ };
437
+ (adapter as any).hydrateIssue = mock(async () => mockCreatedIssue);
438
+
439
+ const prd = await adapter.createPrd({
440
+ title: "No Tag PRD",
441
+ });
442
+
443
+ expect(prd.tag).toBeUndefined();
444
+ expect(capturedCreateInput.labelIds).toHaveLength(1);
445
+ expect(capturedCreateInput.labelIds).toContain("label_prd_123");
446
+ });
447
+
448
+ test("getPrd extracts tag from milestone label", async () => {
449
+ const mockIssue = createMockPrdIssue({
450
+ id: "issue_abc123",
451
+ identifier: "ENG-42",
452
+ title: "Tagged PRD",
453
+ labels: ["prd", "flux:milestone:q1-release"],
454
+ });
455
+
456
+ (adapter as any).fetchIssue = mock(async () => mockIssue);
457
+
458
+ const prd = await adapter.getPrd("ENG-42");
459
+
460
+ expect(prd).not.toBeNull();
461
+ expect(prd?.tag).toBe("q1-release");
462
+ });
463
+
464
+ test("updatePrd can update tag", async () => {
465
+ const mockIssue = createMockPrdIssue({
466
+ identifier: "ENG-42",
467
+ title: "Test PRD",
468
+ labels: ["prd", "flux:milestone:old-tag"],
469
+ _raw: {
470
+ update: mock(async () => ({})),
471
+ },
472
+ });
473
+
474
+ const mockUpdatedIssue = createMockPrdIssue({
475
+ identifier: "ENG-42",
476
+ title: "Test PRD",
477
+ labels: ["prd", "flux:milestone:new-tag"],
478
+ });
479
+
480
+ let fetchCount = 0;
481
+ (adapter as any).fetchIssue = mock(async () => {
482
+ fetchCount++;
483
+ return fetchCount === 1 ? mockIssue : mockUpdatedIssue;
484
+ });
485
+ (adapter as any).client = {
486
+ execute: mock(async (fn: () => Promise<any>) => fn()),
487
+ };
488
+ (adapter as any).getOrCreateLabel = mock(async (name: string) => {
489
+ if (name === "prd") return "label_prd";
490
+ if (name === "flux:milestone:new-tag") return "label_new_tag";
491
+ return `label_${name}`;
492
+ });
493
+
494
+ const prd = await adapter.updatePrd("ENG-42", {
495
+ tag: "new-tag",
496
+ });
497
+
498
+ expect(prd.tag).toBe("new-tag");
499
+ });
500
+
501
+ test("updatePrd can remove tag by setting to null", async () => {
502
+ const mockIssue = createMockPrdIssue({
503
+ identifier: "ENG-42",
504
+ title: "Test PRD",
505
+ labels: ["prd", "flux:milestone:old-tag"],
506
+ _raw: {
507
+ update: mock(async () => ({})),
508
+ },
509
+ });
510
+
511
+ const mockUpdatedIssue = createMockPrdIssue({
512
+ identifier: "ENG-42",
513
+ title: "Test PRD",
514
+ labels: ["prd"], // No milestone label
515
+ });
516
+
517
+ let fetchCount = 0;
518
+ const capturedLabelIds: string[] = [];
519
+ (adapter as any).fetchIssue = mock(async () => {
520
+ fetchCount++;
521
+ return fetchCount === 1 ? mockIssue : mockUpdatedIssue;
522
+ });
523
+ (adapter as any).client = {
524
+ execute: mock(async (fn: () => Promise<any>) => fn()),
525
+ };
526
+ (adapter as any).getOrCreateLabel = mock(async (name: string) => {
527
+ const id = `label_${name.replace(/[^a-z]/g, "_")}`;
528
+ capturedLabelIds.push(id);
529
+ return id;
530
+ });
531
+
532
+ const prd = await adapter.updatePrd("ENG-42", {
533
+ tag: null as any, // Explicitly setting to null removes the tag
534
+ });
535
+
536
+ expect(prd.tag).toBeUndefined();
537
+ // Should not contain any milestone label IDs
538
+ expect(capturedLabelIds.some((id) => id.includes("milestone"))).toBe(
539
+ false,
540
+ );
541
+ });
542
+
543
+ test("updatePrd preserves tag when only updating status", async () => {
544
+ const mockIssue = createMockPrdIssue({
545
+ identifier: "ENG-42",
546
+ title: "Test PRD",
547
+ stateName: "Backlog",
548
+ labels: ["prd", "flux:milestone:mvp"],
549
+ _raw: {
550
+ update: mock(async () => ({})),
551
+ },
552
+ });
553
+
554
+ const mockUpdatedIssue = createMockPrdIssue({
555
+ identifier: "ENG-42",
556
+ title: "Test PRD",
557
+ stateName: "In Progress",
558
+ labels: ["prd", "flux:milestone:mvp"],
559
+ });
560
+
561
+ let fetchCount = 0;
562
+ const labelNames: string[] = [];
563
+ (adapter as any).fetchIssue = mock(async () => {
564
+ fetchCount++;
565
+ return fetchCount === 1 ? mockIssue : mockUpdatedIssue;
566
+ });
567
+ (adapter as any).client = {
568
+ execute: mock(async (fn: () => Promise<any>) => fn()),
569
+ };
570
+ (adapter as any).getStateId = mock(async () => "state_in_progress");
571
+ (adapter as any).getOrCreateLabel = mock(async (name: string) => {
572
+ labelNames.push(name);
573
+ return `label_${name.replace(/[^a-z]/g, "_")}`;
574
+ });
575
+
576
+ const prd = await adapter.updatePrd("ENG-42", {
577
+ status: "APPROVED",
578
+ });
579
+
580
+ expect(prd.tag).toBe("mvp");
581
+ // Milestone label should be preserved
582
+ expect(labelNames).toContain("flux:milestone:mvp");
583
+ });
584
+
585
+ test("listPrds filters by tag", async () => {
586
+ const mockIssues = [
587
+ createMockPrdIssue({
588
+ id: "issue_1",
589
+ identifier: "ENG-1",
590
+ title: "MVP PRD",
591
+ labels: ["prd", "flux:milestone:mvp"],
592
+ }),
593
+ createMockPrdIssue({
594
+ id: "issue_2",
595
+ identifier: "ENG-2",
596
+ title: "Q2 PRD",
597
+ labels: ["prd", "flux:milestone:q2-release"],
598
+ }),
599
+ ];
600
+
601
+ let capturedFilter: any = null;
602
+ (adapter as any).fetchIssues = mock(async (filter: any) => {
603
+ capturedFilter = filter;
604
+ // Return only the MVP PRD when filtering by tag
605
+ return mockIssues.filter((i) =>
606
+ i.labels.includes("flux:milestone:mvp"),
607
+ );
608
+ });
609
+
610
+ const result = await adapter.listPrds({ tag: "mvp" });
611
+
612
+ expect(result.items.length).toBe(1);
613
+ expect(result.items[0].ref).toBe("ENG-1");
614
+ expect(result.items[0].tag).toBe("mvp");
615
+ // Verify the filter was constructed with AND condition
616
+ expect(capturedFilter.labels.and).toBeDefined();
617
+ expect(capturedFilter.labels.and).toHaveLength(2);
618
+ });
619
+ });
620
+ });