@cliangdev/flux-plugin 0.3.1 → 0.4.0-dev.38b2bd1

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 (32) hide show
  1. package/commands/dashboard.md +1 -1
  2. package/package.json +3 -1
  3. package/src/server/adapters/factory.ts +6 -28
  4. package/src/server/adapters/github/__tests__/criteria-deps.test.ts +579 -0
  5. package/src/server/adapters/github/__tests__/documents-stats.test.ts +789 -0
  6. package/src/server/adapters/github/__tests__/epic-task-crud.test.ts +1072 -0
  7. package/src/server/adapters/github/__tests__/foundation.test.ts +537 -0
  8. package/src/server/adapters/github/__tests__/index-store.test.ts +319 -0
  9. package/src/server/adapters/github/__tests__/prd-crud.test.ts +836 -0
  10. package/src/server/adapters/github/adapter.ts +1680 -0
  11. package/src/server/adapters/github/client.ts +34 -0
  12. package/src/server/adapters/github/config.ts +59 -0
  13. package/src/server/adapters/github/helpers/criteria.ts +157 -0
  14. package/src/server/adapters/github/helpers/index-store.ts +79 -0
  15. package/src/server/adapters/github/helpers/meta.ts +26 -0
  16. package/src/server/adapters/github/index.ts +5 -0
  17. package/src/server/adapters/github/mappers/epic.ts +21 -0
  18. package/src/server/adapters/github/mappers/index.ts +15 -0
  19. package/src/server/adapters/github/mappers/prd.ts +50 -0
  20. package/src/server/adapters/github/mappers/task.ts +37 -0
  21. package/src/server/adapters/github/types.ts +33 -0
  22. package/src/server/adapters/linear/adapter.ts +121 -105
  23. package/src/server/adapters/linear/client.ts +21 -14
  24. package/src/server/adapters/types.ts +1 -1
  25. package/src/server/index.ts +2 -0
  26. package/src/server/tools/__tests__/mcp-interface.test.ts +6 -0
  27. package/src/server/tools/__tests__/z-configure-github.test.ts +560 -0
  28. package/src/server/tools/__tests__/z-get-linear-url.test.ts +2 -2
  29. package/src/server/tools/__tests__/z-init-project.test.ts +168 -0
  30. package/src/server/tools/configure-github.ts +550 -0
  31. package/src/server/tools/index.ts +2 -1
  32. package/src/server/tools/init-project.ts +26 -12
@@ -0,0 +1,560 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const TEST_DIR = `/tmp/flux-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
12
+ const FLUX_DIR = join(TEST_DIR, ".flux");
13
+ process.env.FLUX_PROJECT_ROOT = TEST_DIR;
14
+
15
+ let mockTokenValid = true;
16
+ let mockRepoExists = false;
17
+ let mockRemoteConfigExists = false;
18
+ let mockRemoteConfig: Record<string, unknown> | null = null;
19
+ let mockExistingLabels: string[] = [];
20
+ let mockProjectId = "PVT_kwDOtest123";
21
+ let mockProjectUrl = "https://github.com/orgs/testowner/projects/1";
22
+ let mockOwnerId = "U_kgDOtest123";
23
+ let createdLabels: string[] = [];
24
+ let repoCreated = false;
25
+ let configCommitted = false;
26
+
27
+ mock.module("@octokit/rest", () => ({
28
+ Octokit: class MockOctokit {
29
+ users = {
30
+ getAuthenticated: async () => {
31
+ if (!mockTokenValid) {
32
+ const err: any = new Error("Bad credentials");
33
+ err.status = 401;
34
+ throw err;
35
+ }
36
+ return { data: { login: "testowner", id: 123456 } };
37
+ },
38
+ };
39
+
40
+ repos = {
41
+ get: async (_opts: { owner: string; repo: string }) => {
42
+ if (!mockRepoExists) {
43
+ const err: any = new Error("Not Found");
44
+ err.status = 404;
45
+ throw err;
46
+ }
47
+ return {
48
+ data: { html_url: `https://github.com/${_opts.owner}/${_opts.repo}` },
49
+ };
50
+ },
51
+ createForAuthenticatedUser: async (opts: {
52
+ name: string;
53
+ private: boolean;
54
+ auto_init: boolean;
55
+ }) => {
56
+ repoCreated = true;
57
+ return {
58
+ data: { html_url: `https://github.com/testowner/${opts.name}` },
59
+ };
60
+ },
61
+ };
62
+
63
+ issues = {
64
+ listLabelsForRepo: async (_opts: { owner: string; repo: string }) => {
65
+ return {
66
+ data: mockExistingLabels.map((name) => ({ name, color: "000000" })),
67
+ };
68
+ },
69
+ createLabel: async (opts: {
70
+ owner: string;
71
+ repo: string;
72
+ name: string;
73
+ color: string;
74
+ }) => {
75
+ createdLabels.push(opts.name);
76
+ return { data: { name: opts.name, color: opts.color } };
77
+ },
78
+ };
79
+
80
+ repos_getContent = {};
81
+
82
+ request = async (route: string, _opts: any) => {
83
+ if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
84
+ if (!mockRemoteConfigExists) {
85
+ const err: any = new Error("Not Found");
86
+ err.status = 404;
87
+ throw err;
88
+ }
89
+ const content = Buffer.from(JSON.stringify(mockRemoteConfig)).toString(
90
+ "base64",
91
+ );
92
+ return { data: { content, encoding: "base64" } };
93
+ }
94
+ if (route === "PUT /repos/{owner}/{repo}/contents/{path}") {
95
+ configCommitted = true;
96
+ return { data: { content: { sha: "abc123" } } };
97
+ }
98
+ throw new Error(`Unhandled route: ${route}`);
99
+ };
100
+ },
101
+ }));
102
+
103
+ mock.module("@octokit/graphql", () => ({
104
+ graphql: Object.assign(
105
+ async (_query: string, _vars: any) => {
106
+ throw new Error("Use graphql.defaults instead");
107
+ },
108
+ {
109
+ defaults: (_opts: any) => {
110
+ return async (query: string, _vars: any) => {
111
+ if (query.includes("GetOwnerId")) {
112
+ return { user: { id: mockOwnerId } };
113
+ }
114
+ if (query.includes("CreateProject")) {
115
+ return {
116
+ createProjectV2: {
117
+ projectV2: { id: mockProjectId, url: mockProjectUrl },
118
+ },
119
+ };
120
+ }
121
+ if (query.includes("GetRepoId")) {
122
+ return { repository: { id: "REPO_NODE_ID" } };
123
+ }
124
+ if (query.includes("LinkProject")) {
125
+ return {
126
+ linkProjectV2ToRepository: { repository: { id: "REPO_NODE_ID" } },
127
+ };
128
+ }
129
+ if (query.includes("GetProjectFields")) {
130
+ return {
131
+ node: {
132
+ fields: {
133
+ nodes: [
134
+ {
135
+ id: "PVTSSF_STATUS",
136
+ name: "Status",
137
+ options: [
138
+ { id: "OPT_TODO", name: "Todo" },
139
+ { id: "OPT_INPROGRESS", name: "In Progress" },
140
+ { id: "OPT_DONE", name: "Done" },
141
+ ],
142
+ },
143
+ ],
144
+ },
145
+ },
146
+ };
147
+ }
148
+ if (
149
+ query.includes("UpdateStatusOptions") ||
150
+ query.includes("updateProjectV2Field")
151
+ ) {
152
+ return {
153
+ updateProjectV2Field: {
154
+ projectV2Field: {
155
+ options: [
156
+ { id: "OPT_DRAFT", name: "Draft" },
157
+ { id: "OPT_PENDING_REVIEW", name: "Pending Review" },
158
+ { id: "OPT_REVIEWED", name: "Reviewed" },
159
+ { id: "OPT_APPROVED", name: "Approved" },
160
+ { id: "OPT_BREAKDOWN_READY", name: "Breakdown Ready" },
161
+ { id: "OPT_IN_PROGRESS", name: "In Progress" },
162
+ { id: "OPT_COMPLETED", name: "Completed" },
163
+ ],
164
+ },
165
+ },
166
+ };
167
+ }
168
+ throw new Error(`Unhandled GraphQL query: ${query}`);
169
+ };
170
+ },
171
+ },
172
+ ),
173
+ }));
174
+
175
+ import { clearAdapterCache } from "../../adapters/index.js";
176
+ import { config } from "../../config.js";
177
+ import { configureGithubTool } from "../configure-github.js";
178
+
179
+ describe("configure_github MCP Tool", () => {
180
+ beforeEach(() => {
181
+ config.clearCache();
182
+ clearAdapterCache();
183
+ process.env.FLUX_PROJECT_ROOT = TEST_DIR;
184
+
185
+ mockTokenValid = true;
186
+ mockRepoExists = false;
187
+ mockRemoteConfigExists = false;
188
+ mockRemoteConfig = null;
189
+ mockExistingLabels = [];
190
+ mockProjectId = "PVT_kwDOtest123";
191
+ mockProjectUrl = "https://github.com/orgs/testowner/projects/1";
192
+ mockOwnerId = "U_kgDOtest123";
193
+ createdLabels = [];
194
+ repoCreated = false;
195
+ configCommitted = false;
196
+
197
+ mkdirSync(FLUX_DIR, { recursive: true });
198
+
199
+ const projectJsonPath = join(FLUX_DIR, "project.json");
200
+ const initialProject = {
201
+ name: "test-project",
202
+ ref_prefix: "TEST",
203
+ adapter: { type: "local" },
204
+ };
205
+ writeFileSync(projectJsonPath, JSON.stringify(initialProject, null, 2));
206
+ });
207
+
208
+ afterEach(() => {
209
+ clearAdapterCache();
210
+ config.clearCache();
211
+ if (existsSync(TEST_DIR)) {
212
+ rmSync(TEST_DIR, { recursive: true, force: true });
213
+ }
214
+ });
215
+
216
+ describe("input validation", () => {
217
+ test("rejects missing token", async () => {
218
+ const saved = process.env.FLUX_GITHUB_TOKEN;
219
+ delete process.env.FLUX_GITHUB_TOKEN;
220
+ try {
221
+ await expect(
222
+ configureGithubTool.handler({
223
+ owner: "testowner",
224
+ repo: "flux-tracking",
225
+ }),
226
+ ).rejects.toThrow();
227
+ } finally {
228
+ if (saved !== undefined) process.env.FLUX_GITHUB_TOKEN = saved;
229
+ }
230
+ });
231
+
232
+ test("rejects missing owner", async () => {
233
+ await expect(
234
+ configureGithubTool.handler({
235
+ token: "ghp_test123",
236
+ repo: "flux-tracking",
237
+ }),
238
+ ).rejects.toThrow();
239
+ });
240
+
241
+ test("rejects missing repo", async () => {
242
+ await expect(
243
+ configureGithubTool.handler({
244
+ token: "ghp_test123",
245
+ owner: "testowner",
246
+ }),
247
+ ).rejects.toThrow();
248
+ });
249
+
250
+ test("visibility defaults to private when omitted", async () => {
251
+ const result = (await configureGithubTool.handler({
252
+ token: "ghp_test123",
253
+ owner: "testowner",
254
+ repo: "flux-tracking",
255
+ })) as any;
256
+
257
+ expect(result.mode).toBeDefined();
258
+ });
259
+ });
260
+
261
+ describe("token validation", () => {
262
+ test("returns descriptive error when PAT validation fails", async () => {
263
+ mockTokenValid = false;
264
+
265
+ await expect(
266
+ configureGithubTool.handler({
267
+ token: "ghp_invalid",
268
+ owner: "testowner",
269
+ repo: "flux-tracking",
270
+ }),
271
+ ).rejects.toThrow(/token|credentials|auth/i);
272
+ });
273
+ });
274
+
275
+ describe("mode detection", () => {
276
+ test("returns mode: setup when neither local config nor remote config exists", async () => {
277
+ const result = (await configureGithubTool.handler({
278
+ token: "ghp_test123",
279
+ owner: "testowner",
280
+ repo: "flux-tracking",
281
+ })) as any;
282
+
283
+ expect(result.mode).toBe("setup");
284
+ });
285
+
286
+ test("returns mode: join when remote .flux/github-config.json is readable", async () => {
287
+ mockRemoteConfigExists = true;
288
+ mockRemoteConfig = {
289
+ owner: "testowner",
290
+ repo: "flux-tracking",
291
+ projectId: "PVT_kwDOremote123",
292
+ refPrefix: "FLUX",
293
+ };
294
+
295
+ const result = (await configureGithubTool.handler({
296
+ token: "ghp_test123",
297
+ owner: "testowner",
298
+ repo: "flux-tracking",
299
+ })) as any;
300
+
301
+ expect(result.mode).toBe("join");
302
+ });
303
+
304
+ test("returns mode: update when local project.json already has github adapter config", async () => {
305
+ const projectJsonPath = join(FLUX_DIR, "project.json");
306
+ const existingProject = {
307
+ name: "test-project",
308
+ ref_prefix: "FLUX",
309
+ adapter: {
310
+ type: "github",
311
+ config: {
312
+ token: "ghp_old_token",
313
+ owner: "testowner",
314
+ repo: "flux-tracking",
315
+ projectId: "PVT_kwDOtest123",
316
+ refPrefix: "FLUX",
317
+ },
318
+ },
319
+ };
320
+ writeFileSync(projectJsonPath, JSON.stringify(existingProject, null, 2));
321
+
322
+ const result = (await configureGithubTool.handler({
323
+ token: "ghp_new_token",
324
+ owner: "testowner",
325
+ repo: "flux-tracking",
326
+ })) as any;
327
+
328
+ expect(result.mode).toBe("update");
329
+ });
330
+
331
+ test("returns mode: setup when project.json has adapter type github but no config (partial init)", async () => {
332
+ const projectJsonPath = join(FLUX_DIR, "project.json");
333
+ const partialProject = {
334
+ name: "test-project",
335
+ ref_prefix: "FLUX",
336
+ adapter: { type: "github" },
337
+ };
338
+ writeFileSync(projectJsonPath, JSON.stringify(partialProject, null, 2));
339
+
340
+ const result = (await configureGithubTool.handler({
341
+ token: "ghp_test123",
342
+ owner: "testowner",
343
+ repo: "flux-tracking",
344
+ })) as any;
345
+
346
+ expect(result.mode).toBe("setup");
347
+ });
348
+ });
349
+
350
+ describe("setup mode", () => {
351
+ test("skips repo creation if repo already exists", async () => {
352
+ mockRepoExists = true;
353
+
354
+ const result = (await configureGithubTool.handler({
355
+ token: "ghp_test123",
356
+ owner: "testowner",
357
+ repo: "flux-tracking",
358
+ })) as any;
359
+
360
+ expect(result.mode).toBe("setup");
361
+ expect(repoCreated).toBe(false);
362
+ });
363
+
364
+ test("creates repo when it does not exist", async () => {
365
+ mockRepoExists = false;
366
+
367
+ await configureGithubTool.handler({
368
+ token: "ghp_test123",
369
+ owner: "testowner",
370
+ repo: "flux-tracking",
371
+ });
372
+
373
+ expect(repoCreated).toBe(true);
374
+ });
375
+
376
+ test("skips label creation for labels that already exist", async () => {
377
+ mockExistingLabels = ["flux:prd", "flux:epic", "flux:task"];
378
+
379
+ await configureGithubTool.handler({
380
+ token: "ghp_test123",
381
+ owner: "testowner",
382
+ repo: "flux-tracking",
383
+ });
384
+
385
+ expect(createdLabels).not.toContain("flux:prd");
386
+ expect(createdLabels).not.toContain("flux:epic");
387
+ expect(createdLabels).not.toContain("flux:task");
388
+ });
389
+
390
+ test("creates only missing labels", async () => {
391
+ mockExistingLabels = ["flux:prd"];
392
+ mockRepoExists = true;
393
+
394
+ await configureGithubTool.handler({
395
+ token: "ghp_test123",
396
+ owner: "testowner",
397
+ repo: "flux-tracking",
398
+ });
399
+
400
+ expect(createdLabels).not.toContain("flux:prd");
401
+ expect(createdLabels).toContain("flux:epic");
402
+ expect(createdLabels).toContain("flux:task");
403
+ });
404
+
405
+ test("commits .flux/github-config.json with required fields", async () => {
406
+ await configureGithubTool.handler({
407
+ token: "ghp_test123",
408
+ owner: "testowner",
409
+ repo: "flux-tracking",
410
+ });
411
+
412
+ expect(configCommitted).toBe(true);
413
+ });
414
+
415
+ test("returns board_url, repo_url, labels_created, mode: setup", async () => {
416
+ const result = (await configureGithubTool.handler({
417
+ token: "ghp_test123",
418
+ owner: "testowner",
419
+ repo: "flux-tracking",
420
+ })) as any;
421
+
422
+ expect(result.mode).toBe("setup");
423
+ expect(result.board_url).toBeDefined();
424
+ expect(result.repo_url).toBeDefined();
425
+ expect(typeof result.labels_created).toBe("number");
426
+ });
427
+ });
428
+
429
+ describe("join mode", () => {
430
+ beforeEach(() => {
431
+ mockRemoteConfigExists = true;
432
+ mockRemoteConfig = {
433
+ owner: "testowner",
434
+ repo: "flux-tracking",
435
+ projectId: "PVT_kwDOremote123",
436
+ refPrefix: "FLUX",
437
+ };
438
+ });
439
+
440
+ test("reads remote config and writes correct local project.json", async () => {
441
+ const result = (await configureGithubTool.handler({
442
+ token: "ghp_test123",
443
+ owner: "testowner",
444
+ repo: "flux-tracking",
445
+ })) as any;
446
+
447
+ expect(result.mode).toBe("join");
448
+
449
+ const projectJsonPath = join(FLUX_DIR, "project.json");
450
+ const saved = JSON.parse(readFileSync(projectJsonPath, "utf-8"));
451
+ expect(saved.adapter.type).toBe("github");
452
+ expect(saved.adapter.config.token).toBe("ghp_test123");
453
+ expect(saved.adapter.config.projectId).toBe("PVT_kwDOremote123");
454
+ expect(saved.adapter.config.refPrefix).toBe("FLUX");
455
+ });
456
+
457
+ test("does not trigger label or board creation in join mode", async () => {
458
+ await configureGithubTool.handler({
459
+ token: "ghp_test123",
460
+ owner: "testowner",
461
+ repo: "flux-tracking",
462
+ });
463
+
464
+ expect(createdLabels).toHaveLength(0);
465
+ expect(configCommitted).toBe(false);
466
+ });
467
+
468
+ test("returns mode: join with labels_created: 0", async () => {
469
+ const result = (await configureGithubTool.handler({
470
+ token: "ghp_test123",
471
+ owner: "testowner",
472
+ repo: "flux-tracking",
473
+ })) as any;
474
+
475
+ expect(result.mode).toBe("join");
476
+ expect(result.labels_created).toBe(0);
477
+ expect(result.repo_url).toBeDefined();
478
+ });
479
+
480
+ test("returns error with collaborator instructions when remote config is 404", async () => {
481
+ mockRemoteConfigExists = false;
482
+ mockRemoteConfig = null;
483
+
484
+ const result = (await configureGithubTool.handler({
485
+ token: "ghp_test123",
486
+ owner: "testowner",
487
+ repo: "flux-tracking",
488
+ })) as any;
489
+
490
+ expect(result.mode).toBe("setup");
491
+ });
492
+ });
493
+
494
+ describe("update mode (token rotation)", () => {
495
+ beforeEach(() => {
496
+ const projectJsonPath = join(FLUX_DIR, "project.json");
497
+ const existingProject = {
498
+ name: "test-project",
499
+ ref_prefix: "FLUX",
500
+ adapter: {
501
+ type: "github",
502
+ config: {
503
+ token: "ghp_old_token",
504
+ owner: "testowner",
505
+ repo: "flux-tracking",
506
+ projectId: "PVT_kwDOtest123",
507
+ refPrefix: "FLUX",
508
+ },
509
+ },
510
+ };
511
+ writeFileSync(projectJsonPath, JSON.stringify(existingProject, null, 2));
512
+ });
513
+
514
+ test("updates only the token field, leaves other fields unchanged", async () => {
515
+ const result = (await configureGithubTool.handler({
516
+ token: "ghp_new_token",
517
+ owner: "testowner",
518
+ repo: "flux-tracking",
519
+ })) as any;
520
+
521
+ expect(result.mode).toBe("update");
522
+
523
+ const projectJsonPath = join(FLUX_DIR, "project.json");
524
+ const saved = JSON.parse(readFileSync(projectJsonPath, "utf-8"));
525
+ expect(saved.adapter.config.token).toBe("ghp_new_token");
526
+ expect(saved.adapter.config.projectId).toBe("PVT_kwDOtest123");
527
+ expect(saved.adapter.config.refPrefix).toBe("FLUX");
528
+ });
529
+
530
+ test("returns mode: update with labels_created: 0", async () => {
531
+ const result = (await configureGithubTool.handler({
532
+ token: "ghp_new_token",
533
+ owner: "testowner",
534
+ repo: "flux-tracking",
535
+ })) as any;
536
+
537
+ expect(result.mode).toBe("update");
538
+ expect(result.labels_created).toBe(0);
539
+ });
540
+ });
541
+
542
+ describe("response shape", () => {
543
+ test("contains board_url, repo_url, labels_created, mode in setup", async () => {
544
+ const result = (await configureGithubTool.handler({
545
+ token: "ghp_test123",
546
+ owner: "testowner",
547
+ repo: "flux-tracking",
548
+ })) as any;
549
+
550
+ expect(result).toHaveProperty("board_url");
551
+ expect(result).toHaveProperty("repo_url");
552
+ expect(result).toHaveProperty("labels_created");
553
+ expect(result).toHaveProperty("mode");
554
+ });
555
+
556
+ test("tool appears with correct name", () => {
557
+ expect(configureGithubTool.name).toBe("configure_github");
558
+ });
559
+ });
560
+ });
@@ -49,7 +49,7 @@ describe("get_linear_url MCP Tool", () => {
49
49
  test("returns URL for project ref", async () => {
50
50
  // Get the adapter and mock its getLinearUrl method
51
51
  const adapter = getAdapter() as any;
52
- adapter.getLinearUrl = mock(async (ref: string) => ({
52
+ adapter.getLinearUrl = mock(async (_ref: string) => ({
53
53
  url: "https://linear.app/myteam/project/my-project-abc123",
54
54
  type: "project",
55
55
  name: "My Project",
@@ -69,7 +69,7 @@ describe("get_linear_url MCP Tool", () => {
69
69
  test("returns URL for issue identifier", async () => {
70
70
  // Get the adapter and mock its getLinearUrl method
71
71
  const adapter = getAdapter() as any;
72
- adapter.getLinearUrl = mock(async (ref: string) => ({
72
+ adapter.getLinearUrl = mock(async (_ref: string) => ({
73
73
  url: "https://linear.app/myteam/issue/ENG-42",
74
74
  type: "issue",
75
75
  identifier: "ENG-42",