@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.
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 +1574 -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 +27 -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 +513 -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 +422 -0
  31. package/src/server/tools/index.ts +2 -1
  32. package/src/server/tools/init-project.ts +26 -12
@@ -0,0 +1,537 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ realpathSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+
11
+ const TEST_DIR = `${realpathSync(tmpdir())}/flux-github-test-${Date.now()}`;
12
+ const FLUX_DIR = `${TEST_DIR}/.flux`;
13
+ const PROJECT_JSON_PATH = `${FLUX_DIR}/project.json`;
14
+
15
+ let mockRequestCount = 0;
16
+ let lastOctokitToken = "";
17
+ let mockGraphqlFn: (
18
+ query: string,
19
+ variables?: Record<string, unknown>,
20
+ ) => Promise<unknown>;
21
+
22
+ mock.module("@octokit/rest", () => ({
23
+ Octokit: class MockOctokit {
24
+ constructor(opts: { auth?: string }) {
25
+ lastOctokitToken = opts.auth ?? "";
26
+ }
27
+ },
28
+ }));
29
+
30
+ mock.module("@octokit/graphql", () => {
31
+ return {
32
+ graphql: {
33
+ defaults: (_opts: { headers?: Record<string, string> }) => {
34
+ return async (query: string, variables?: Record<string, unknown>) => {
35
+ mockRequestCount++;
36
+ if (mockGraphqlFn) {
37
+ return mockGraphqlFn(query, variables);
38
+ }
39
+ return {};
40
+ };
41
+ },
42
+ },
43
+ };
44
+ });
45
+
46
+ describe("GitHub Adapter Foundation", () => {
47
+ const originalEnv = process.env.FLUX_PROJECT_ROOT;
48
+
49
+ beforeEach(async () => {
50
+ mockRequestCount = 0;
51
+ lastOctokitToken = "";
52
+ mockGraphqlFn = async () => ({});
53
+
54
+ if (existsSync(TEST_DIR)) {
55
+ rmSync(TEST_DIR, { recursive: true });
56
+ }
57
+
58
+ mkdirSync(FLUX_DIR, { recursive: true });
59
+ process.env.FLUX_PROJECT_ROOT = TEST_DIR;
60
+
61
+ const { config } = await import("../../../config.js");
62
+ config.clearCache();
63
+
64
+ const { clearAdapterCache } = await import("../../factory.js");
65
+ clearAdapterCache();
66
+ });
67
+
68
+ afterEach(async () => {
69
+ if (originalEnv !== undefined) {
70
+ process.env.FLUX_PROJECT_ROOT = originalEnv;
71
+ } else {
72
+ delete process.env.FLUX_PROJECT_ROOT;
73
+ }
74
+
75
+ const { config } = await import("../../../config.js");
76
+ config.clearCache();
77
+
78
+ const { clearAdapterCache } = await import("../../factory.js");
79
+ clearAdapterCache();
80
+
81
+ if (existsSync(TEST_DIR)) {
82
+ rmSync(TEST_DIR, { recursive: true });
83
+ }
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Label Constants
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe("Label Constants", () => {
91
+ test("entity type labels are defined", async () => {
92
+ const { GITHUB_LABELS } = await import("../types.js");
93
+
94
+ expect(GITHUB_LABELS.ENTITY_PRD).toBe("flux:prd");
95
+ expect(GITHUB_LABELS.ENTITY_EPIC).toBe("flux:epic");
96
+ expect(GITHUB_LABELS.ENTITY_TASK).toBe("flux:task");
97
+ });
98
+
99
+ test("status labels are defined", async () => {
100
+ const { GITHUB_LABELS } = await import("../types.js");
101
+
102
+ expect(GITHUB_LABELS.STATUS_DRAFT).toBe("status:draft");
103
+ expect(GITHUB_LABELS.STATUS_PENDING_REVIEW).toBe("status:pending-review");
104
+ expect(GITHUB_LABELS.STATUS_REVIEWED).toBe("status:reviewed");
105
+ expect(GITHUB_LABELS.STATUS_APPROVED).toBe("status:approved");
106
+ expect(GITHUB_LABELS.STATUS_BREAKDOWN_READY).toBe(
107
+ "status:breakdown-ready",
108
+ );
109
+ expect(GITHUB_LABELS.STATUS_COMPLETED).toBe("status:completed");
110
+ expect(GITHUB_LABELS.STATUS_IN_PROGRESS).toBe("status:in-progress");
111
+ });
112
+
113
+ test("priority labels are defined", async () => {
114
+ const { GITHUB_LABELS } = await import("../types.js");
115
+
116
+ expect(GITHUB_LABELS.PRIORITY_LOW).toBe("priority:low");
117
+ expect(GITHUB_LABELS.PRIORITY_MEDIUM).toBe("priority:medium");
118
+ expect(GITHUB_LABELS.PRIORITY_HIGH).toBe("priority:high");
119
+ });
120
+
121
+ test("tag label prefix is defined", async () => {
122
+ const { GITHUB_LABELS } = await import("../types.js");
123
+
124
+ expect(GITHUB_LABELS.TAG_PREFIX).toBe("tag:");
125
+ });
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // GitHubClient
130
+ // ---------------------------------------------------------------------------
131
+
132
+ describe("GitHubClient", () => {
133
+ const validConfig = {
134
+ token: "ghp_test_token",
135
+ owner: "test-owner",
136
+ repo: "test-repo",
137
+ projectId: "PVT_kwDOABCD123",
138
+ refPrefix: "FLUX",
139
+ };
140
+
141
+ test("initializes with token and exposes rest client", async () => {
142
+ const { GitHubClient } = await import("../client.js");
143
+ const client = new GitHubClient(validConfig);
144
+
145
+ expect(client).toBeDefined();
146
+ expect(client.rest).toBeDefined();
147
+ });
148
+
149
+ test("passes token to Octokit", async () => {
150
+ const { GitHubClient } = await import("../client.js");
151
+ new GitHubClient(validConfig);
152
+
153
+ expect(lastOctokitToken).toBe("ghp_test_token");
154
+ });
155
+
156
+ test("graphql helper calls the graphql function", async () => {
157
+ const { GitHubClient } = await import("../client.js");
158
+ const client = new GitHubClient(validConfig);
159
+
160
+ mockGraphqlFn = async () => ({ viewer: { login: "testuser" } });
161
+
162
+ const result = await client.graphql<{ viewer: { login: string } }>(
163
+ "{ viewer { login } }",
164
+ );
165
+
166
+ expect(result).toEqual({ viewer: { login: "testuser" } });
167
+ expect(mockRequestCount).toBe(1);
168
+ });
169
+
170
+ test("graphql helper passes variables", async () => {
171
+ const { GitHubClient } = await import("../client.js");
172
+ const client = new GitHubClient(validConfig);
173
+
174
+ let capturedVariables: Record<string, unknown> | undefined;
175
+ mockGraphqlFn = async (_query, variables) => {
176
+ capturedVariables = variables;
177
+ return {};
178
+ };
179
+
180
+ await client.graphql("query($id: ID!) { node(id: $id) { id } }", {
181
+ id: "PVT_kwDO123",
182
+ });
183
+
184
+ expect(capturedVariables).toEqual({ id: "PVT_kwDO123" });
185
+ });
186
+
187
+ test("surfaces 401 error with descriptive auth message", async () => {
188
+ const { GitHubClient } = await import("../client.js");
189
+ const client = new GitHubClient(validConfig);
190
+
191
+ mockGraphqlFn = async () => {
192
+ const err: any = new Error("Bad credentials");
193
+ err.status = 401;
194
+ throw err;
195
+ };
196
+
197
+ await expect(client.graphql("{ viewer { login } }")).rejects.toThrow(
198
+ "GitHub API auth error",
199
+ );
200
+ });
201
+
202
+ test("surfaces 403 error with descriptive auth message", async () => {
203
+ const { GitHubClient } = await import("../client.js");
204
+ const client = new GitHubClient(validConfig);
205
+
206
+ mockGraphqlFn = async () => {
207
+ const err: any = new Error("Forbidden");
208
+ err.status = 403;
209
+ throw err;
210
+ };
211
+
212
+ await expect(client.graphql("{ viewer { login } }")).rejects.toThrow(
213
+ "GitHub API auth error",
214
+ );
215
+ });
216
+
217
+ test("auth error message mentions token scope", async () => {
218
+ const { GitHubClient } = await import("../client.js");
219
+ const client = new GitHubClient(validConfig);
220
+
221
+ mockGraphqlFn = async () => {
222
+ const err: any = new Error("Bad credentials");
223
+ err.status = 401;
224
+ throw err;
225
+ };
226
+
227
+ await expect(client.graphql("{ viewer { login } }")).rejects.toThrow(
228
+ /repo.*scope|scope.*repo/i,
229
+ );
230
+ });
231
+ });
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // githubConfigExists
235
+ // ---------------------------------------------------------------------------
236
+
237
+ describe("githubConfigExists", () => {
238
+ test("returns false when project.json does not exist", async () => {
239
+ const { githubConfigExists } = await import("../config.js");
240
+ expect(githubConfigExists()).toBe(false);
241
+ });
242
+
243
+ test("returns false when adapter type is local", async () => {
244
+ writeFileSync(
245
+ PROJECT_JSON_PATH,
246
+ JSON.stringify({ adapter: { type: "local" } }),
247
+ );
248
+
249
+ const { githubConfigExists } = await import("../config.js");
250
+ expect(githubConfigExists()).toBe(false);
251
+ });
252
+
253
+ test("returns false when adapter type is linear", async () => {
254
+ writeFileSync(
255
+ PROJECT_JSON_PATH,
256
+ JSON.stringify({ adapter: { type: "linear" } }),
257
+ );
258
+
259
+ const { githubConfigExists } = await import("../config.js");
260
+ expect(githubConfigExists()).toBe(false);
261
+ });
262
+
263
+ test("returns true when adapter type is github", async () => {
264
+ writeFileSync(
265
+ PROJECT_JSON_PATH,
266
+ JSON.stringify({
267
+ adapter: {
268
+ type: "github",
269
+ config: {
270
+ token: "ghp_test",
271
+ owner: "test-owner",
272
+ repo: "test-repo",
273
+ projectId: "PVT_kwDO123",
274
+ refPrefix: "FLUX",
275
+ },
276
+ },
277
+ }),
278
+ );
279
+
280
+ const { githubConfigExists } = await import("../config.js");
281
+ expect(githubConfigExists()).toBe(true);
282
+ });
283
+
284
+ test("returns false when project.json has no adapter field", async () => {
285
+ writeFileSync(
286
+ PROJECT_JSON_PATH,
287
+ JSON.stringify({ name: "test-project" }),
288
+ );
289
+
290
+ const { githubConfigExists } = await import("../config.js");
291
+ expect(githubConfigExists()).toBe(false);
292
+ });
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // loadGithubConfig
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe("loadGithubConfig", () => {
300
+ test("throws when project.json is missing", async () => {
301
+ const { loadGithubConfig } = await import("../config.js");
302
+ expect(() => loadGithubConfig()).toThrow();
303
+ });
304
+
305
+ test("returns config when project.json has valid github adapter config", async () => {
306
+ const githubConfig = {
307
+ token: "ghp_test_token",
308
+ owner: "acme",
309
+ repo: "my-repo",
310
+ projectId: "PVT_kwDOABCD123",
311
+ refPrefix: "FLUX",
312
+ };
313
+
314
+ writeFileSync(
315
+ PROJECT_JSON_PATH,
316
+ JSON.stringify({
317
+ adapter: {
318
+ type: "github",
319
+ config: githubConfig,
320
+ },
321
+ }),
322
+ );
323
+
324
+ const { loadGithubConfig } = await import("../config.js");
325
+ const loaded = loadGithubConfig();
326
+
327
+ expect(loaded.token).toBe("ghp_test_token");
328
+ expect(loaded.owner).toBe("acme");
329
+ expect(loaded.repo).toBe("my-repo");
330
+ expect(loaded.projectId).toBe("PVT_kwDOABCD123");
331
+ expect(loaded.refPrefix).toBe("FLUX");
332
+ });
333
+
334
+ test("throws when token is missing", async () => {
335
+ writeFileSync(
336
+ PROJECT_JSON_PATH,
337
+ JSON.stringify({
338
+ adapter: {
339
+ type: "github",
340
+ config: {
341
+ owner: "acme",
342
+ repo: "my-repo",
343
+ projectId: "PVT_kwDO123",
344
+ refPrefix: "FLUX",
345
+ },
346
+ },
347
+ }),
348
+ );
349
+
350
+ const { loadGithubConfig } = await import("../config.js");
351
+ expect(() => loadGithubConfig()).toThrow();
352
+ });
353
+
354
+ test("throws when owner is missing", async () => {
355
+ writeFileSync(
356
+ PROJECT_JSON_PATH,
357
+ JSON.stringify({
358
+ adapter: {
359
+ type: "github",
360
+ config: {
361
+ token: "ghp_test",
362
+ repo: "my-repo",
363
+ projectId: "PVT_kwDO123",
364
+ refPrefix: "FLUX",
365
+ },
366
+ },
367
+ }),
368
+ );
369
+
370
+ const { loadGithubConfig } = await import("../config.js");
371
+ expect(() => loadGithubConfig()).toThrow();
372
+ });
373
+
374
+ test("throws when repo is missing", async () => {
375
+ writeFileSync(
376
+ PROJECT_JSON_PATH,
377
+ JSON.stringify({
378
+ adapter: {
379
+ type: "github",
380
+ config: {
381
+ token: "ghp_test",
382
+ owner: "acme",
383
+ projectId: "PVT_kwDO123",
384
+ refPrefix: "FLUX",
385
+ },
386
+ },
387
+ }),
388
+ );
389
+
390
+ const { loadGithubConfig } = await import("../config.js");
391
+ expect(() => loadGithubConfig()).toThrow();
392
+ });
393
+
394
+ test("throws when projectId is missing", async () => {
395
+ writeFileSync(
396
+ PROJECT_JSON_PATH,
397
+ JSON.stringify({
398
+ adapter: {
399
+ type: "github",
400
+ config: {
401
+ token: "ghp_test",
402
+ owner: "acme",
403
+ repo: "my-repo",
404
+ refPrefix: "FLUX",
405
+ },
406
+ },
407
+ }),
408
+ );
409
+
410
+ const { loadGithubConfig } = await import("../config.js");
411
+ expect(() => loadGithubConfig()).toThrow();
412
+ });
413
+
414
+ test("throws when refPrefix is missing", async () => {
415
+ writeFileSync(
416
+ PROJECT_JSON_PATH,
417
+ JSON.stringify({
418
+ adapter: {
419
+ type: "github",
420
+ config: {
421
+ token: "ghp_test",
422
+ owner: "acme",
423
+ repo: "my-repo",
424
+ projectId: "PVT_kwDO123",
425
+ },
426
+ },
427
+ }),
428
+ );
429
+
430
+ const { loadGithubConfig } = await import("../config.js");
431
+ expect(() => loadGithubConfig()).toThrow();
432
+ });
433
+ });
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Factory
437
+ // ---------------------------------------------------------------------------
438
+
439
+ describe("Factory - GitHub adapter", () => {
440
+ test("createAdapter returns GitHubAdapter when type is 'github'", async () => {
441
+ writeFileSync(
442
+ PROJECT_JSON_PATH,
443
+ JSON.stringify({
444
+ adapter: {
445
+ type: "github",
446
+ config: {
447
+ token: "ghp_test_token",
448
+ owner: "acme",
449
+ repo: "my-repo",
450
+ projectId: "PVT_kwDOABCD123",
451
+ refPrefix: "FLUX",
452
+ },
453
+ },
454
+ }),
455
+ );
456
+
457
+ const { createAdapter } = await import("../../factory.js");
458
+ const { GitHubAdapter } = await import("../adapter.js");
459
+
460
+ const adapter = createAdapter({
461
+ type: "github",
462
+ config: {
463
+ token: "ghp_test_token",
464
+ owner: "acme",
465
+ repo: "my-repo",
466
+ projectId: "PVT_kwDOABCD123",
467
+ refPrefix: "FLUX",
468
+ } as Record<string, unknown>,
469
+ });
470
+
471
+ expect(adapter).toBeInstanceOf(GitHubAdapter);
472
+ });
473
+
474
+ test("getAdapter returns GitHubAdapter when project.json has adapter.type='github'", async () => {
475
+ writeFileSync(
476
+ PROJECT_JSON_PATH,
477
+ JSON.stringify({
478
+ adapter: {
479
+ type: "github",
480
+ config: {
481
+ token: "ghp_test_token",
482
+ owner: "acme",
483
+ repo: "my-repo",
484
+ projectId: "PVT_kwDOABCD123",
485
+ refPrefix: "FLUX",
486
+ },
487
+ },
488
+ }),
489
+ );
490
+
491
+ const { getAdapter } = await import("../../factory.js");
492
+ const { GitHubAdapter } = await import("../adapter.js");
493
+
494
+ const adapter = getAdapter(true);
495
+
496
+ expect(adapter).toBeInstanceOf(GitHubAdapter);
497
+ });
498
+
499
+ test("GitHubAdapter implements BackendAdapter interface", async () => {
500
+ const { GitHubAdapter } = await import("../adapter.js");
501
+
502
+ const adapter = new GitHubAdapter({
503
+ token: "ghp_test",
504
+ owner: "acme",
505
+ repo: "my-repo",
506
+ projectId: "PVT_kwDO123",
507
+ refPrefix: "FLUX",
508
+ });
509
+
510
+ expect(typeof adapter.createPrd).toBe("function");
511
+ expect(typeof adapter.updatePrd).toBe("function");
512
+ expect(typeof adapter.getPrd).toBe("function");
513
+ expect(typeof adapter.listPrds).toBe("function");
514
+ expect(typeof adapter.deletePrd).toBe("function");
515
+ expect(typeof adapter.createEpic).toBe("function");
516
+ expect(typeof adapter.updateEpic).toBe("function");
517
+ expect(typeof adapter.getEpic).toBe("function");
518
+ expect(typeof adapter.listEpics).toBe("function");
519
+ expect(typeof adapter.deleteEpic).toBe("function");
520
+ expect(typeof adapter.createTask).toBe("function");
521
+ expect(typeof adapter.updateTask).toBe("function");
522
+ expect(typeof adapter.getTask).toBe("function");
523
+ expect(typeof adapter.listTasks).toBe("function");
524
+ expect(typeof adapter.deleteTask).toBe("function");
525
+ expect(typeof adapter.addCriterion).toBe("function");
526
+ expect(typeof adapter.markCriterionMet).toBe("function");
527
+ expect(typeof adapter.getCriteria).toBe("function");
528
+ expect(typeof adapter.addDependency).toBe("function");
529
+ expect(typeof adapter.removeDependency).toBe("function");
530
+ expect(typeof adapter.getDependencies).toBe("function");
531
+ expect(typeof adapter.saveDocument).toBe("function");
532
+ expect(typeof adapter.getDocuments).toBe("function");
533
+ expect(typeof adapter.deleteDocument).toBe("function");
534
+ expect(typeof adapter.getStats).toBe("function");
535
+ });
536
+ });
537
+ });