@cliangdev/flux-plugin 0.3.0-dev.18744c2 → 0.3.1-dev.8c219d5

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 (29) 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 +1552 -0
  11. package/src/server/adapters/github/client.ts +33 -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 +75 -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/types.ts +1 -1
  23. package/src/server/index.ts +2 -0
  24. package/src/server/tools/__tests__/mcp-interface.test.ts +6 -0
  25. package/src/server/tools/__tests__/z-configure-github.test.ts +509 -0
  26. package/src/server/tools/__tests__/z-init-project.test.ts +168 -0
  27. package/src/server/tools/configure-github.ts +411 -0
  28. package/src/server/tools/index.ts +2 -1
  29. package/src/server/tools/init-project.ts +26 -12
@@ -0,0 +1,411 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { graphql } from "@octokit/graphql";
3
+ import { Octokit } from "@octokit/rest";
4
+ import { z } from "zod";
5
+ import { GITHUB_LABELS } from "../adapters/github/types.js";
6
+ import { clearAdapterCache } from "../adapters/index.js";
7
+ import { config } from "../config.js";
8
+ import type { ToolDefinition } from "./index.js";
9
+
10
+ const inputSchema = z.object({
11
+ token: z.string().optional(),
12
+ owner: z.string(),
13
+ repo: z.string(),
14
+ ref_prefix: z.string().optional().default("FLUX"),
15
+ visibility: z.enum(["private", "public"]).optional().default("private"),
16
+ });
17
+
18
+ type ConfigureMode = "setup" | "join" | "update";
19
+
20
+ interface SharedConfig {
21
+ owner: string;
22
+ repo: string;
23
+ projectId: string;
24
+ refPrefix: string;
25
+ }
26
+
27
+ interface ConfigureResult {
28
+ mode: ConfigureMode;
29
+ board_url: string | null;
30
+ repo_url: string;
31
+ labels_created: number;
32
+ }
33
+
34
+ const LABEL_DEFINITIONS: Array<{ name: string; color: string }> = [
35
+ { name: GITHUB_LABELS.ENTITY_PRD, color: "0075ca" },
36
+ { name: GITHUB_LABELS.ENTITY_EPIC, color: "7057ff" },
37
+ { name: GITHUB_LABELS.ENTITY_TASK, color: "0e8a16" },
38
+ { name: GITHUB_LABELS.STATUS_DRAFT, color: "e4e669" },
39
+ { name: GITHUB_LABELS.STATUS_PENDING_REVIEW, color: "fbca04" },
40
+ { name: GITHUB_LABELS.STATUS_REVIEWED, color: "d4c5f9" },
41
+ { name: GITHUB_LABELS.STATUS_APPROVED, color: "0e8a16" },
42
+ { name: GITHUB_LABELS.STATUS_BREAKDOWN_READY, color: "1d76db" },
43
+ { name: GITHUB_LABELS.STATUS_COMPLETED, color: "0075ca" },
44
+ { name: GITHUB_LABELS.STATUS_IN_PROGRESS, color: "e99695" },
45
+ { name: GITHUB_LABELS.PRIORITY_LOW, color: "c5def5" },
46
+ { name: GITHUB_LABELS.PRIORITY_MEDIUM, color: "bfd4f2" },
47
+ { name: GITHUB_LABELS.PRIORITY_HIGH, color: "b60205" },
48
+ ];
49
+
50
+ async function validateToken(octokit: Octokit): Promise<string> {
51
+ try {
52
+ const { data } = await octokit.users.getAuthenticated();
53
+ return data.login;
54
+ } catch (err: any) {
55
+ const status = err.status ?? 0;
56
+ throw new Error(
57
+ `GitHub token validation failed (HTTP ${status}): ${err.message}. Ensure the token has 'repo' scope.`,
58
+ );
59
+ }
60
+ }
61
+
62
+ function detectUpdateMode(): boolean {
63
+ if (!existsSync(config.projectJsonPath)) return false;
64
+ try {
65
+ const content = readFileSync(config.projectJsonPath, "utf-8");
66
+ const project = JSON.parse(content);
67
+ return (
68
+ project?.adapter?.type === "github" &&
69
+ Boolean(project?.adapter?.config?.owner)
70
+ );
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ async function fetchRemoteConfig(
77
+ octokit: Octokit,
78
+ owner: string,
79
+ repo: string,
80
+ ): Promise<SharedConfig | null> {
81
+ try {
82
+ const response = await octokit.request(
83
+ "GET /repos/{owner}/{repo}/contents/{path}",
84
+ {
85
+ owner,
86
+ repo,
87
+ path: ".flux/github-config.json",
88
+ },
89
+ );
90
+ const data = response.data as { content: string; encoding: string };
91
+ const decoded = Buffer.from(data.content, "base64").toString("utf-8");
92
+ return JSON.parse(decoded) as SharedConfig;
93
+ } catch (err: any) {
94
+ if (err.status === 404 || err.status === 403) {
95
+ return null;
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ async function detectMode(
102
+ octokit: Octokit,
103
+ owner: string,
104
+ repo: string,
105
+ ): Promise<{ mode: ConfigureMode; remoteConfig: SharedConfig | null }> {
106
+ if (detectUpdateMode()) {
107
+ return { mode: "update", remoteConfig: null };
108
+ }
109
+
110
+ const remoteConfig = await fetchRemoteConfig(octokit, owner, repo);
111
+ if (remoteConfig) {
112
+ return { mode: "join", remoteConfig };
113
+ }
114
+
115
+ return { mode: "setup", remoteConfig: null };
116
+ }
117
+
118
+ async function ensureRepo(
119
+ octokit: Octokit,
120
+ owner: string,
121
+ repo: string,
122
+ visibility: "private" | "public",
123
+ ): Promise<string> {
124
+ try {
125
+ const { data } = await octokit.repos.get({ owner, repo });
126
+ return data.html_url;
127
+ } catch (err: any) {
128
+ if (err.status !== 404) throw err;
129
+ }
130
+
131
+ const { data } = await octokit.repos.createForAuthenticatedUser({
132
+ name: repo,
133
+ private: visibility === "private",
134
+ auto_init: true,
135
+ });
136
+ return data.html_url;
137
+ }
138
+
139
+ async function ensureLabels(
140
+ octokit: Octokit,
141
+ owner: string,
142
+ repo: string,
143
+ ): Promise<number> {
144
+ const { data: existingLabels } = await octokit.issues.listLabelsForRepo({
145
+ owner,
146
+ repo,
147
+ });
148
+ const existingNames = new Set(existingLabels.map((l) => l.name));
149
+
150
+ let created = 0;
151
+ for (const label of LABEL_DEFINITIONS) {
152
+ if (!existingNames.has(label.name)) {
153
+ await octokit.issues.createLabel({
154
+ owner,
155
+ repo,
156
+ name: label.name,
157
+ color: label.color,
158
+ });
159
+ created++;
160
+ }
161
+ }
162
+ return created;
163
+ }
164
+
165
+ async function createProjectsBoard(
166
+ gql: (query: string, vars: any) => Promise<any>,
167
+ ownerLogin: string,
168
+ title: string,
169
+ ): Promise<{ id: string; url: string }> {
170
+ const ownerResult = await gql(
171
+ `query GetOwnerId($login: String!) { user(login: $login) { id } }`,
172
+ { login: ownerLogin },
173
+ );
174
+ const ownerId = ownerResult.user.id;
175
+
176
+ const projectResult = await gql(
177
+ `mutation CreateProject($ownerId: ID!, $title: String!) {
178
+ createProjectV2(input: { ownerId: $ownerId, title: $title }) {
179
+ projectV2 { id url }
180
+ }
181
+ }`,
182
+ { ownerId, title },
183
+ );
184
+
185
+ return projectResult.createProjectV2.projectV2;
186
+ }
187
+
188
+ async function commitSharedConfig(
189
+ octokit: Octokit,
190
+ owner: string,
191
+ repo: string,
192
+ sharedConfig: SharedConfig,
193
+ ): Promise<void> {
194
+ const content = Buffer.from(JSON.stringify(sharedConfig, null, 2)).toString(
195
+ "base64",
196
+ );
197
+ await octokit.request("PUT /repos/{owner}/{repo}/contents/{path}", {
198
+ owner,
199
+ repo,
200
+ path: ".flux/github-config.json",
201
+ message: "chore: add Flux shared config",
202
+ content,
203
+ });
204
+ }
205
+
206
+ function writeProjectJson(params: {
207
+ token: string;
208
+ owner: string;
209
+ repo: string;
210
+ projectId: string;
211
+ refPrefix: string;
212
+ }): void {
213
+ const projectJsonPath = config.projectJsonPath;
214
+ const existing = existsSync(projectJsonPath)
215
+ ? JSON.parse(readFileSync(projectJsonPath, "utf-8"))
216
+ : {};
217
+
218
+ existing.adapter = {
219
+ type: "github",
220
+ config: {
221
+ token: params.token,
222
+ owner: params.owner,
223
+ repo: params.repo,
224
+ projectId: params.projectId,
225
+ refPrefix: params.refPrefix,
226
+ },
227
+ };
228
+
229
+ writeFileSync(projectJsonPath, JSON.stringify(existing, null, 2));
230
+ }
231
+
232
+ async function runSetup(params: {
233
+ octokit: Octokit;
234
+ gql: (query: string, vars: any) => Promise<any>;
235
+ ownerLogin: string;
236
+ owner: string;
237
+ repo: string;
238
+ refPrefix: string;
239
+ visibility: "private" | "public";
240
+ token: string;
241
+ }): Promise<ConfigureResult> {
242
+ const repoUrl = await ensureRepo(
243
+ params.octokit,
244
+ params.owner,
245
+ params.repo,
246
+ params.visibility,
247
+ );
248
+ const labelsCreated = await ensureLabels(
249
+ params.octokit,
250
+ params.owner,
251
+ params.repo,
252
+ );
253
+ const project = await createProjectsBoard(
254
+ params.gql,
255
+ params.ownerLogin,
256
+ `Flux: ${params.repo}`,
257
+ );
258
+
259
+ const sharedConfig: SharedConfig = {
260
+ owner: params.owner,
261
+ repo: params.repo,
262
+ projectId: project.id,
263
+ refPrefix: params.refPrefix,
264
+ };
265
+
266
+ await commitSharedConfig(
267
+ params.octokit,
268
+ params.owner,
269
+ params.repo,
270
+ sharedConfig,
271
+ );
272
+
273
+ writeProjectJson({
274
+ token: params.token,
275
+ owner: params.owner,
276
+ repo: params.repo,
277
+ projectId: project.id,
278
+ refPrefix: params.refPrefix,
279
+ });
280
+
281
+ return {
282
+ mode: "setup",
283
+ board_url: project.url,
284
+ repo_url: repoUrl,
285
+ labels_created: labelsCreated,
286
+ };
287
+ }
288
+
289
+ async function runJoin(params: {
290
+ token: string;
291
+ owner: string;
292
+ repo: string;
293
+ remoteConfig: SharedConfig;
294
+ }): Promise<ConfigureResult> {
295
+ writeProjectJson({
296
+ token: params.token,
297
+ owner: params.remoteConfig.owner,
298
+ repo: params.remoteConfig.repo,
299
+ projectId: params.remoteConfig.projectId,
300
+ refPrefix: params.remoteConfig.refPrefix,
301
+ });
302
+
303
+ const repoUrl = `https://github.com/${params.remoteConfig.owner}/${params.remoteConfig.repo}`;
304
+
305
+ return {
306
+ mode: "join",
307
+ board_url: null,
308
+ repo_url: repoUrl,
309
+ labels_created: 0,
310
+ };
311
+ }
312
+
313
+ async function runUpdate(params: {
314
+ token: string;
315
+ owner: string;
316
+ repo: string;
317
+ }): Promise<ConfigureResult> {
318
+ const projectJsonPath = config.projectJsonPath;
319
+ const existing = JSON.parse(readFileSync(projectJsonPath, "utf-8"));
320
+ existing.adapter.config.token = params.token;
321
+ writeFileSync(projectJsonPath, JSON.stringify(existing, null, 2));
322
+
323
+ const repoUrl = `https://github.com/${params.owner}/${params.repo}`;
324
+
325
+ return {
326
+ mode: "update",
327
+ board_url: null,
328
+ repo_url: repoUrl,
329
+ labels_created: 0,
330
+ };
331
+ }
332
+
333
+ function resolveToken(inputToken?: string): string {
334
+ if (inputToken) return inputToken;
335
+ const envToken = process.env.FLUX_GITHUB_TOKEN;
336
+ if (envToken) return envToken;
337
+ throw new Error(
338
+ "GitHub token not found. Set the FLUX_GITHUB_TOKEN environment variable " +
339
+ "(recommended: export FLUX_GITHUB_TOKEN=ghp_... in your shell before starting Claude Code), " +
340
+ "or pass it via the token parameter.",
341
+ );
342
+ }
343
+
344
+ async function handler(input: unknown) {
345
+ const parsed = inputSchema.parse(input);
346
+ const token = resolveToken(parsed.token);
347
+
348
+ const octokit = new Octokit({ auth: token });
349
+ const gqlWithAuth = graphql.defaults({
350
+ headers: { authorization: `token ${token}` },
351
+ });
352
+
353
+ const ownerLogin = await validateToken(octokit);
354
+
355
+ const { mode, remoteConfig } = await detectMode(
356
+ octokit,
357
+ parsed.owner,
358
+ parsed.repo,
359
+ );
360
+
361
+ let result: ConfigureResult;
362
+
363
+ if (mode === "update") {
364
+ result = await runUpdate({
365
+ token,
366
+ owner: parsed.owner,
367
+ repo: parsed.repo,
368
+ });
369
+ } else if (mode === "join" && remoteConfig) {
370
+ result = await runJoin({
371
+ token,
372
+ owner: parsed.owner,
373
+ repo: parsed.repo,
374
+ remoteConfig,
375
+ });
376
+ } else {
377
+ result = await runSetup({
378
+ octokit,
379
+ gql: gqlWithAuth,
380
+ ownerLogin,
381
+ owner: parsed.owner,
382
+ repo: parsed.repo,
383
+ refPrefix: parsed.ref_prefix,
384
+ visibility: parsed.visibility,
385
+ token,
386
+ });
387
+ }
388
+
389
+ clearAdapterCache();
390
+ config.clearCache();
391
+
392
+ return result;
393
+ }
394
+
395
+ export const configureGithubTool: ToolDefinition = {
396
+ name: "configure_github",
397
+ description:
398
+ "Configure GitHub integration for the Flux project. " +
399
+ "Automatically detects the correct mode: " +
400
+ "'setup' (first-time: creates tracking repo, labels, Projects v2 board, commits shared config), " +
401
+ "'join' (remote .flux/github-config.json exists: reads shared config, writes local project.json), " +
402
+ "'update' (local project.json already has github adapter: rotates token only). " +
403
+ "Required: owner (GitHub username/org), repo (tracking repo name). " +
404
+ "Token resolution order: 1) FLUX_GITHUB_TOKEN environment variable (recommended — set with 'export FLUX_GITHUB_TOKEN=ghp_...' before starting Claude Code, never paste tokens in chat), " +
405
+ "2) token parameter (avoid — visible in chat history). " +
406
+ "To create a PAT: github.com → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token → select 'repo' scope. " +
407
+ "Optional: ref_prefix (default 'FLUX'), visibility (default 'private'). " +
408
+ "Returns {mode, board_url, repo_url, labels_created}.",
409
+ inputSchema,
410
+ handler,
411
+ };
@@ -64,7 +64,7 @@ export function createProjectNotInitializedError(
64
64
  params: {
65
65
  name: "Project name (required)",
66
66
  vision: "Brief project description (required)",
67
- adapter: "local | linear (default: local)",
67
+ adapter: "local | linear | github (default: local)",
68
68
  },
69
69
  },
70
70
  ],
@@ -140,6 +140,7 @@ export function registerTools(server: Server, tools: ToolDefinition[]) {
140
140
  });
141
141
  }
142
142
 
143
+ export { configureGithubTool } from "./configure-github.js";
143
144
  export { configureLinearTool } from "./configure-linear.js";
144
145
  export { createEpicTool } from "./create-epic.js";
145
146
  export { createPrdTool } from "./create-prd.js";
@@ -48,7 +48,13 @@ function setupClaudeSettings(projectRoot: string): void {
48
48
  }
49
49
  }
50
50
 
51
- const adapterTypes = ["local", "specflux", "linear", "notion"] as const;
51
+ const adapterTypes = [
52
+ "local",
53
+ "specflux",
54
+ "linear",
55
+ "notion",
56
+ "github",
57
+ ] as const;
52
58
 
53
59
  const inputSchema = z.object({
54
60
  name: z.string().min(1, "Project name is required"),
@@ -80,29 +86,37 @@ async function handler(input: unknown) {
80
86
  writeFileSync(config.projectJsonPath, JSON.stringify(projectJson, null, 2));
81
87
  config.clearCache();
82
88
 
83
- initDb();
84
-
85
- const projectId = generateId("proj");
86
- const db = getDb();
87
- insert(db, "projects", {
88
- id: projectId,
89
- name: parsed.name,
90
- ref_prefix: refPrefix,
91
- });
89
+ if (parsed.adapter === "local") {
90
+ initDb();
91
+ const projectId = generateId("proj");
92
+ const db = getDb();
93
+ insert(db, "projects", {
94
+ id: projectId,
95
+ name: parsed.name,
96
+ ref_prefix: refPrefix,
97
+ });
98
+ }
92
99
 
93
100
  setupClaudeSettings(config.projectRoot);
94
101
 
102
+ const nextStep =
103
+ parsed.adapter === "github"
104
+ ? "Run configure_github to connect your GitHub tracking repository."
105
+ : parsed.adapter === "linear"
106
+ ? "Run configure_linear to connect your Linear workspace."
107
+ : "Run /flux:prd to start planning.";
108
+
95
109
  return {
96
110
  success: true,
97
111
  project: projectJson,
98
- message: `Flux project initialized. Run /flux:prd to start planning.`,
112
+ message: `Flux project initialized. ${nextStep}`,
99
113
  };
100
114
  }
101
115
 
102
116
  export const initProjectTool: ToolDefinition = {
103
117
  name: "init_project",
104
118
  description:
105
- "Initialize a new Flux project. Required: name, vision. Optional: adapter (local|specflux|linear|notion, default 'local'). Creates .flux/ directory with project.json and SQLite database. Returns {success, project, message}. Fails if .flux/ already exists.",
119
+ "Initialize a new Flux project. Required: name, vision. Optional: adapter (local|linear|github, default 'local'). Creates .flux/ directory with project.json. For local adapter, also initializes SQLite database. For github or linear, run configure_github or configure_linear next. Returns {success, project, message}. Fails if .flux/ already exists.",
106
120
  inputSchema,
107
121
  handler,
108
122
  };