@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.
- 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 +1680 -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 +33 -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 +560 -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 +550 -0
- package/src/server/tools/index.ts +2 -1
- package/src/server/tools/init-project.ts +26 -12
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const TEST_DIR = `/tmp/flux-init-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
6
|
+
const FLUX_DIR = join(TEST_DIR, ".flux");
|
|
7
|
+
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
8
|
+
|
|
9
|
+
import { config } from "../../config.js";
|
|
10
|
+
import { closeDb } from "../../db/index.js";
|
|
11
|
+
import { initProjectTool } from "../init-project.js";
|
|
12
|
+
|
|
13
|
+
describe("init_project", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
closeDb();
|
|
16
|
+
config.clearCache();
|
|
17
|
+
if (existsSync(FLUX_DIR)) {
|
|
18
|
+
rmSync(FLUX_DIR, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
closeDb();
|
|
25
|
+
config.clearCache();
|
|
26
|
+
if (existsSync(TEST_DIR)) {
|
|
27
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("local adapter", () => {
|
|
32
|
+
test("creates .flux directory and project.json", async () => {
|
|
33
|
+
await initProjectTool.handler({
|
|
34
|
+
name: "Test Project",
|
|
35
|
+
vision: "Test vision",
|
|
36
|
+
adapter: "local",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(existsSync(FLUX_DIR)).toBe(true);
|
|
40
|
+
expect(existsSync(join(FLUX_DIR, "project.json"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("initializes SQLite database for local adapter", async () => {
|
|
44
|
+
await initProjectTool.handler({
|
|
45
|
+
name: "Test Project",
|
|
46
|
+
vision: "Test vision",
|
|
47
|
+
adapter: "local",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(existsSync(join(FLUX_DIR, "flux.db"))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns planning message for local adapter", async () => {
|
|
54
|
+
const result = (await initProjectTool.handler({
|
|
55
|
+
name: "Test Project",
|
|
56
|
+
vision: "Test vision",
|
|
57
|
+
})) as any;
|
|
58
|
+
|
|
59
|
+
expect(result.success).toBe(true);
|
|
60
|
+
expect(result.message).toContain("/flux:prd");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("github adapter", () => {
|
|
65
|
+
test("creates .flux directory and project.json", async () => {
|
|
66
|
+
await initProjectTool.handler({
|
|
67
|
+
name: "Test Project",
|
|
68
|
+
vision: "Test vision",
|
|
69
|
+
adapter: "github",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(existsSync(FLUX_DIR)).toBe(true);
|
|
73
|
+
expect(existsSync(join(FLUX_DIR, "project.json"))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("does not create SQLite database for github adapter", async () => {
|
|
77
|
+
await initProjectTool.handler({
|
|
78
|
+
name: "Test Project",
|
|
79
|
+
vision: "Test vision",
|
|
80
|
+
adapter: "github",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(existsSync(join(FLUX_DIR, "flux.db"))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("project.json records adapter type as github", async () => {
|
|
87
|
+
const result = (await initProjectTool.handler({
|
|
88
|
+
name: "Test Project",
|
|
89
|
+
vision: "Test vision",
|
|
90
|
+
adapter: "github",
|
|
91
|
+
})) as any;
|
|
92
|
+
|
|
93
|
+
expect(result.project.adapter.type).toBe("github");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns configure_github next-step message", async () => {
|
|
97
|
+
const result = (await initProjectTool.handler({
|
|
98
|
+
name: "Test Project",
|
|
99
|
+
vision: "Test vision",
|
|
100
|
+
adapter: "github",
|
|
101
|
+
})) as any;
|
|
102
|
+
|
|
103
|
+
expect(result.success).toBe(true);
|
|
104
|
+
expect(result.message).toContain("configure_github");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("fails if project already initialized", async () => {
|
|
108
|
+
await initProjectTool.handler({
|
|
109
|
+
name: "Test Project",
|
|
110
|
+
vision: "Test vision",
|
|
111
|
+
adapter: "github",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(
|
|
115
|
+
initProjectTool.handler({
|
|
116
|
+
name: "Test Project",
|
|
117
|
+
vision: "Test vision",
|
|
118
|
+
adapter: "github",
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow(/already initialized/i);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("linear adapter", () => {
|
|
125
|
+
test("does not create SQLite database for linear adapter", async () => {
|
|
126
|
+
await initProjectTool.handler({
|
|
127
|
+
name: "Test Project",
|
|
128
|
+
vision: "Test vision",
|
|
129
|
+
adapter: "linear",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(existsSync(join(FLUX_DIR, "flux.db"))).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns configure_linear next-step message", async () => {
|
|
136
|
+
const result = (await initProjectTool.handler({
|
|
137
|
+
name: "Test Project",
|
|
138
|
+
vision: "Test vision",
|
|
139
|
+
adapter: "linear",
|
|
140
|
+
})) as any;
|
|
141
|
+
|
|
142
|
+
expect(result.message).toContain("configure_linear");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("input validation", () => {
|
|
147
|
+
test("rejects missing name", async () => {
|
|
148
|
+
await expect(
|
|
149
|
+
initProjectTool.handler({ vision: "Test vision" }),
|
|
150
|
+
).rejects.toThrow();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("rejects missing vision", async () => {
|
|
154
|
+
await expect(
|
|
155
|
+
initProjectTool.handler({ name: "Test Project" }),
|
|
156
|
+
).rejects.toThrow();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("defaults to local adapter when not specified", async () => {
|
|
160
|
+
const result = (await initProjectTool.handler({
|
|
161
|
+
name: "Test Project",
|
|
162
|
+
vision: "Test vision",
|
|
163
|
+
})) as any;
|
|
164
|
+
|
|
165
|
+
expect(result.project.adapter.type).toBe("local");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,550 @@
|
|
|
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 StatusFieldConfig {
|
|
21
|
+
fieldId: string;
|
|
22
|
+
optionIds: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SharedConfig {
|
|
26
|
+
owner: string;
|
|
27
|
+
repo: string;
|
|
28
|
+
projectId: string;
|
|
29
|
+
refPrefix: string;
|
|
30
|
+
statusField?: StatusFieldConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ConfigureResult {
|
|
34
|
+
mode: ConfigureMode;
|
|
35
|
+
board_url: string | null;
|
|
36
|
+
repo_url: string;
|
|
37
|
+
labels_created: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const LABEL_DEFINITIONS: Array<{ name: string; color: string }> = [
|
|
41
|
+
{ name: GITHUB_LABELS.ENTITY_PRD, color: "0075ca" },
|
|
42
|
+
{ name: GITHUB_LABELS.ENTITY_EPIC, color: "7057ff" },
|
|
43
|
+
{ name: GITHUB_LABELS.ENTITY_TASK, color: "0e8a16" },
|
|
44
|
+
{ name: GITHUB_LABELS.STATUS_DRAFT, color: "e4e669" },
|
|
45
|
+
{ name: GITHUB_LABELS.STATUS_PENDING_REVIEW, color: "fbca04" },
|
|
46
|
+
{ name: GITHUB_LABELS.STATUS_REVIEWED, color: "d4c5f9" },
|
|
47
|
+
{ name: GITHUB_LABELS.STATUS_APPROVED, color: "0e8a16" },
|
|
48
|
+
{ name: GITHUB_LABELS.STATUS_BREAKDOWN_READY, color: "1d76db" },
|
|
49
|
+
{ name: GITHUB_LABELS.STATUS_COMPLETED, color: "0075ca" },
|
|
50
|
+
{ name: GITHUB_LABELS.STATUS_IN_PROGRESS, color: "e99695" },
|
|
51
|
+
{ name: GITHUB_LABELS.PRIORITY_LOW, color: "c5def5" },
|
|
52
|
+
{ name: GITHUB_LABELS.PRIORITY_MEDIUM, color: "bfd4f2" },
|
|
53
|
+
{ name: GITHUB_LABELS.PRIORITY_HIGH, color: "b60205" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
async function validateToken(octokit: Octokit): Promise<string> {
|
|
57
|
+
try {
|
|
58
|
+
const { data } = await octokit.users.getAuthenticated();
|
|
59
|
+
return data.login;
|
|
60
|
+
} catch (error: unknown) {
|
|
61
|
+
const err = error as { status?: number; message?: string };
|
|
62
|
+
const status = err.status ?? 0;
|
|
63
|
+
throw new Error(
|
|
64
|
+
`GitHub token validation failed (HTTP ${status}): ${err.message}. Ensure the token has 'repo' scope.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function detectUpdateMode(): boolean {
|
|
70
|
+
if (!existsSync(config.projectJsonPath)) return false;
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(config.projectJsonPath, "utf-8");
|
|
73
|
+
const project = JSON.parse(content);
|
|
74
|
+
return (
|
|
75
|
+
project?.adapter?.type === "github" &&
|
|
76
|
+
Boolean(project?.adapter?.config?.owner)
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchRemoteConfig(
|
|
84
|
+
octokit: Octokit,
|
|
85
|
+
owner: string,
|
|
86
|
+
repo: string,
|
|
87
|
+
): Promise<SharedConfig | null> {
|
|
88
|
+
try {
|
|
89
|
+
const response = await octokit.request(
|
|
90
|
+
"GET /repos/{owner}/{repo}/contents/{path}",
|
|
91
|
+
{
|
|
92
|
+
owner,
|
|
93
|
+
repo,
|
|
94
|
+
path: ".flux/github-config.json",
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
const data = response.data as { content: string; encoding: string };
|
|
98
|
+
const decoded = Buffer.from(data.content, "base64").toString("utf-8");
|
|
99
|
+
return JSON.parse(decoded) as SharedConfig;
|
|
100
|
+
} catch (error: unknown) {
|
|
101
|
+
const status = (error as { status?: number }).status;
|
|
102
|
+
if (status === 404 || status === 403) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function detectMode(
|
|
110
|
+
octokit: Octokit,
|
|
111
|
+
owner: string,
|
|
112
|
+
repo: string,
|
|
113
|
+
): Promise<{ mode: ConfigureMode; remoteConfig: SharedConfig | null }> {
|
|
114
|
+
if (detectUpdateMode()) {
|
|
115
|
+
return { mode: "update", remoteConfig: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const remoteConfig = await fetchRemoteConfig(octokit, owner, repo);
|
|
119
|
+
if (remoteConfig) {
|
|
120
|
+
return { mode: "join", remoteConfig };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { mode: "setup", remoteConfig: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function ensureRepo(
|
|
127
|
+
octokit: Octokit,
|
|
128
|
+
owner: string,
|
|
129
|
+
repo: string,
|
|
130
|
+
visibility: "private" | "public",
|
|
131
|
+
): Promise<string> {
|
|
132
|
+
try {
|
|
133
|
+
const { data } = await octokit.repos.get({ owner, repo });
|
|
134
|
+
return data.html_url;
|
|
135
|
+
} catch (error: unknown) {
|
|
136
|
+
if ((error as { status?: number }).status !== 404) throw error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { data } = await octokit.repos.createForAuthenticatedUser({
|
|
140
|
+
name: repo,
|
|
141
|
+
private: visibility === "private",
|
|
142
|
+
auto_init: true,
|
|
143
|
+
});
|
|
144
|
+
return data.html_url;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function ensureLabels(
|
|
148
|
+
octokit: Octokit,
|
|
149
|
+
owner: string,
|
|
150
|
+
repo: string,
|
|
151
|
+
): Promise<number> {
|
|
152
|
+
const { data: existingLabels } = await octokit.issues.listLabelsForRepo({
|
|
153
|
+
owner,
|
|
154
|
+
repo,
|
|
155
|
+
});
|
|
156
|
+
const existingNames = new Set(existingLabels.map((l) => l.name));
|
|
157
|
+
|
|
158
|
+
let created = 0;
|
|
159
|
+
for (const label of LABEL_DEFINITIONS) {
|
|
160
|
+
if (!existingNames.has(label.name)) {
|
|
161
|
+
await octokit.issues.createLabel({
|
|
162
|
+
owner,
|
|
163
|
+
repo,
|
|
164
|
+
name: label.name,
|
|
165
|
+
color: label.color,
|
|
166
|
+
});
|
|
167
|
+
created++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return created;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function createProjectsBoard(
|
|
174
|
+
gql: (
|
|
175
|
+
query: string,
|
|
176
|
+
vars: Record<string, unknown>,
|
|
177
|
+
) => Promise<Record<string, Record<string, unknown>>>,
|
|
178
|
+
ownerLogin: string,
|
|
179
|
+
title: string,
|
|
180
|
+
repoOwner: string,
|
|
181
|
+
repoName: string,
|
|
182
|
+
): Promise<{ id: string; url: string }> {
|
|
183
|
+
const ownerResult = await gql(
|
|
184
|
+
`query GetOwnerId($login: String!) { user(login: $login) { id } }`,
|
|
185
|
+
{ login: ownerLogin },
|
|
186
|
+
);
|
|
187
|
+
const ownerId = ownerResult.user.id;
|
|
188
|
+
|
|
189
|
+
const projectResult = await gql(
|
|
190
|
+
`mutation CreateProject($ownerId: ID!, $title: String!) {
|
|
191
|
+
createProjectV2(input: { ownerId: $ownerId, title: $title }) {
|
|
192
|
+
projectV2 { id url }
|
|
193
|
+
}
|
|
194
|
+
}`,
|
|
195
|
+
{ ownerId, title },
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const project = projectResult.createProjectV2.projectV2 as unknown as {
|
|
199
|
+
id: string;
|
|
200
|
+
url: string;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const repoResult = await gql(
|
|
204
|
+
`query GetRepoId($owner: String!, $name: String!) {
|
|
205
|
+
repository(owner: $owner, name: $name) { id }
|
|
206
|
+
}`,
|
|
207
|
+
{ owner: repoOwner, name: repoName },
|
|
208
|
+
);
|
|
209
|
+
const repositoryId = repoResult.repository.id;
|
|
210
|
+
|
|
211
|
+
await gql(
|
|
212
|
+
`mutation LinkProject($projectId: ID!, $repositoryId: ID!) {
|
|
213
|
+
linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) {
|
|
214
|
+
repository { id }
|
|
215
|
+
}
|
|
216
|
+
}`,
|
|
217
|
+
{ projectId: project.id, repositoryId },
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return project;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const BOARD_STATUS_OPTIONS: Array<{
|
|
224
|
+
name: string;
|
|
225
|
+
color: string;
|
|
226
|
+
description: string;
|
|
227
|
+
}> = [
|
|
228
|
+
{ name: "Draft", color: "GRAY", description: "" },
|
|
229
|
+
{ name: "Pending Review", color: "YELLOW", description: "" },
|
|
230
|
+
{ name: "Reviewed", color: "PURPLE", description: "" },
|
|
231
|
+
{ name: "Approved", color: "GREEN", description: "" },
|
|
232
|
+
{ name: "Breakdown Ready", color: "BLUE", description: "" },
|
|
233
|
+
{ name: "In Progress", color: "ORANGE", description: "" },
|
|
234
|
+
{ name: "Completed", color: "GREEN", description: "" },
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
async function configureStatusField(
|
|
238
|
+
gql: (
|
|
239
|
+
query: string,
|
|
240
|
+
vars: Record<string, unknown>,
|
|
241
|
+
) => Promise<Record<string, Record<string, unknown>>>,
|
|
242
|
+
projectId: string,
|
|
243
|
+
): Promise<StatusFieldConfig | undefined> {
|
|
244
|
+
const result = await gql(
|
|
245
|
+
`query GetProjectFields($projectId: ID!) {
|
|
246
|
+
node(id: $projectId) {
|
|
247
|
+
... on ProjectV2 {
|
|
248
|
+
fields(first: 20) {
|
|
249
|
+
nodes {
|
|
250
|
+
... on ProjectV2SingleSelectField {
|
|
251
|
+
id
|
|
252
|
+
name
|
|
253
|
+
options { id name }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}`,
|
|
260
|
+
{ projectId },
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const fields = (
|
|
264
|
+
result.node as {
|
|
265
|
+
fields: {
|
|
266
|
+
nodes: Array<{
|
|
267
|
+
id?: string;
|
|
268
|
+
name?: string;
|
|
269
|
+
options?: Array<{ id: string; name: string }>;
|
|
270
|
+
}>;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
).fields.nodes;
|
|
274
|
+
|
|
275
|
+
const statusField = fields.find(
|
|
276
|
+
(f) => f.name === "Status" && f.options && f.id,
|
|
277
|
+
);
|
|
278
|
+
if (!statusField?.id) return undefined;
|
|
279
|
+
|
|
280
|
+
const updateResult = await gql(
|
|
281
|
+
`mutation UpdateStatusOptions($fieldId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
|
|
282
|
+
updateProjectV2Field(input: {
|
|
283
|
+
fieldId: $fieldId
|
|
284
|
+
singleSelectOptions: $options
|
|
285
|
+
}) {
|
|
286
|
+
projectV2Field {
|
|
287
|
+
... on ProjectV2SingleSelectField {
|
|
288
|
+
options { id name }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}`,
|
|
293
|
+
{
|
|
294
|
+
fieldId: statusField.id,
|
|
295
|
+
options: BOARD_STATUS_OPTIONS,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const updatedOptions = (
|
|
300
|
+
updateResult.updateProjectV2Field as {
|
|
301
|
+
projectV2Field: {
|
|
302
|
+
options: Array<{ id: string; name: string }>;
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
).projectV2Field.options;
|
|
306
|
+
|
|
307
|
+
const optionIds: Record<string, string> = {};
|
|
308
|
+
for (const opt of updatedOptions) {
|
|
309
|
+
optionIds[opt.name] = opt.id;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { fieldId: statusField.id, optionIds };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function commitSharedConfig(
|
|
316
|
+
octokit: Octokit,
|
|
317
|
+
owner: string,
|
|
318
|
+
repo: string,
|
|
319
|
+
sharedConfig: SharedConfig,
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
const content = Buffer.from(JSON.stringify(sharedConfig, null, 2)).toString(
|
|
322
|
+
"base64",
|
|
323
|
+
);
|
|
324
|
+
await octokit.request("PUT /repos/{owner}/{repo}/contents/{path}", {
|
|
325
|
+
owner,
|
|
326
|
+
repo,
|
|
327
|
+
path: ".flux/github-config.json",
|
|
328
|
+
message: "chore: add Flux shared config",
|
|
329
|
+
content,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function writeProjectJson(params: {
|
|
334
|
+
token: string;
|
|
335
|
+
owner: string;
|
|
336
|
+
repo: string;
|
|
337
|
+
projectId: string;
|
|
338
|
+
refPrefix: string;
|
|
339
|
+
statusField?: StatusFieldConfig;
|
|
340
|
+
}): void {
|
|
341
|
+
const projectJsonPath = config.projectJsonPath;
|
|
342
|
+
const existing = existsSync(projectJsonPath)
|
|
343
|
+
? JSON.parse(readFileSync(projectJsonPath, "utf-8"))
|
|
344
|
+
: {};
|
|
345
|
+
|
|
346
|
+
existing.adapter = {
|
|
347
|
+
type: "github",
|
|
348
|
+
config: {
|
|
349
|
+
token: params.token,
|
|
350
|
+
owner: params.owner,
|
|
351
|
+
repo: params.repo,
|
|
352
|
+
projectId: params.projectId,
|
|
353
|
+
refPrefix: params.refPrefix,
|
|
354
|
+
...(params.statusField ? { statusField: params.statusField } : {}),
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
writeFileSync(projectJsonPath, JSON.stringify(existing, null, 2));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function runSetup(params: {
|
|
362
|
+
octokit: Octokit;
|
|
363
|
+
gql: (
|
|
364
|
+
query: string,
|
|
365
|
+
vars: Record<string, unknown>,
|
|
366
|
+
) => Promise<Record<string, Record<string, unknown>>>;
|
|
367
|
+
ownerLogin: string;
|
|
368
|
+
owner: string;
|
|
369
|
+
repo: string;
|
|
370
|
+
refPrefix: string;
|
|
371
|
+
visibility: "private" | "public";
|
|
372
|
+
token: string;
|
|
373
|
+
}): Promise<ConfigureResult> {
|
|
374
|
+
const repoUrl = await ensureRepo(
|
|
375
|
+
params.octokit,
|
|
376
|
+
params.owner,
|
|
377
|
+
params.repo,
|
|
378
|
+
params.visibility,
|
|
379
|
+
);
|
|
380
|
+
const labelsCreated = await ensureLabels(
|
|
381
|
+
params.octokit,
|
|
382
|
+
params.owner,
|
|
383
|
+
params.repo,
|
|
384
|
+
);
|
|
385
|
+
const project = await createProjectsBoard(
|
|
386
|
+
params.gql,
|
|
387
|
+
params.ownerLogin,
|
|
388
|
+
`Flux: ${params.repo}`,
|
|
389
|
+
params.owner,
|
|
390
|
+
params.repo,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const statusField = await configureStatusField(params.gql, project.id);
|
|
394
|
+
|
|
395
|
+
const sharedConfig: SharedConfig = {
|
|
396
|
+
owner: params.owner,
|
|
397
|
+
repo: params.repo,
|
|
398
|
+
projectId: project.id,
|
|
399
|
+
refPrefix: params.refPrefix,
|
|
400
|
+
...(statusField ? { statusField } : {}),
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
await commitSharedConfig(
|
|
404
|
+
params.octokit,
|
|
405
|
+
params.owner,
|
|
406
|
+
params.repo,
|
|
407
|
+
sharedConfig,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
writeProjectJson({
|
|
411
|
+
token: params.token,
|
|
412
|
+
owner: params.owner,
|
|
413
|
+
repo: params.repo,
|
|
414
|
+
projectId: project.id,
|
|
415
|
+
refPrefix: params.refPrefix,
|
|
416
|
+
statusField,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
mode: "setup",
|
|
421
|
+
board_url: project.url,
|
|
422
|
+
repo_url: repoUrl,
|
|
423
|
+
labels_created: labelsCreated,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function runJoin(params: {
|
|
428
|
+
token: string;
|
|
429
|
+
owner: string;
|
|
430
|
+
repo: string;
|
|
431
|
+
remoteConfig: SharedConfig;
|
|
432
|
+
}): Promise<ConfigureResult> {
|
|
433
|
+
writeProjectJson({
|
|
434
|
+
token: params.token,
|
|
435
|
+
owner: params.remoteConfig.owner,
|
|
436
|
+
repo: params.remoteConfig.repo,
|
|
437
|
+
projectId: params.remoteConfig.projectId,
|
|
438
|
+
refPrefix: params.remoteConfig.refPrefix,
|
|
439
|
+
statusField: params.remoteConfig.statusField,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const repoUrl = `https://github.com/${params.remoteConfig.owner}/${params.remoteConfig.repo}`;
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
mode: "join",
|
|
446
|
+
board_url: null,
|
|
447
|
+
repo_url: repoUrl,
|
|
448
|
+
labels_created: 0,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function runUpdate(params: {
|
|
453
|
+
token: string;
|
|
454
|
+
owner: string;
|
|
455
|
+
repo: string;
|
|
456
|
+
}): Promise<ConfigureResult> {
|
|
457
|
+
const projectJsonPath = config.projectJsonPath;
|
|
458
|
+
const existing = JSON.parse(readFileSync(projectJsonPath, "utf-8"));
|
|
459
|
+
existing.adapter.config.token = params.token;
|
|
460
|
+
writeFileSync(projectJsonPath, JSON.stringify(existing, null, 2));
|
|
461
|
+
|
|
462
|
+
const repoUrl = `https://github.com/${params.owner}/${params.repo}`;
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
mode: "update",
|
|
466
|
+
board_url: null,
|
|
467
|
+
repo_url: repoUrl,
|
|
468
|
+
labels_created: 0,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function resolveToken(inputToken?: string): string {
|
|
473
|
+
if (inputToken) return inputToken;
|
|
474
|
+
const envToken = process.env.FLUX_GITHUB_TOKEN;
|
|
475
|
+
if (envToken) return envToken;
|
|
476
|
+
throw new Error(
|
|
477
|
+
"GitHub token not found. Set the FLUX_GITHUB_TOKEN environment variable " +
|
|
478
|
+
"(recommended: export FLUX_GITHUB_TOKEN=ghp_... in your shell before starting Claude Code), " +
|
|
479
|
+
"or pass it via the token parameter.",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function handler(input: unknown) {
|
|
484
|
+
const parsed = inputSchema.parse(input);
|
|
485
|
+
const token = resolveToken(parsed.token);
|
|
486
|
+
|
|
487
|
+
const octokit = new Octokit({ auth: token });
|
|
488
|
+
const gqlWithAuth = graphql.defaults({
|
|
489
|
+
headers: { authorization: `token ${token}` },
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const ownerLogin = await validateToken(octokit);
|
|
493
|
+
|
|
494
|
+
const { mode, remoteConfig } = await detectMode(
|
|
495
|
+
octokit,
|
|
496
|
+
parsed.owner,
|
|
497
|
+
parsed.repo,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
let result: ConfigureResult;
|
|
501
|
+
|
|
502
|
+
if (mode === "update") {
|
|
503
|
+
result = await runUpdate({
|
|
504
|
+
token,
|
|
505
|
+
owner: parsed.owner,
|
|
506
|
+
repo: parsed.repo,
|
|
507
|
+
});
|
|
508
|
+
} else if (mode === "join" && remoteConfig) {
|
|
509
|
+
result = await runJoin({
|
|
510
|
+
token,
|
|
511
|
+
owner: parsed.owner,
|
|
512
|
+
repo: parsed.repo,
|
|
513
|
+
remoteConfig,
|
|
514
|
+
});
|
|
515
|
+
} else {
|
|
516
|
+
result = await runSetup({
|
|
517
|
+
octokit,
|
|
518
|
+
gql: gqlWithAuth,
|
|
519
|
+
ownerLogin,
|
|
520
|
+
owner: parsed.owner,
|
|
521
|
+
repo: parsed.repo,
|
|
522
|
+
refPrefix: parsed.ref_prefix,
|
|
523
|
+
visibility: parsed.visibility,
|
|
524
|
+
token,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
clearAdapterCache();
|
|
529
|
+
config.clearCache();
|
|
530
|
+
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export const configureGithubTool: ToolDefinition = {
|
|
535
|
+
name: "configure_github",
|
|
536
|
+
description:
|
|
537
|
+
"Configure GitHub integration for the Flux project. " +
|
|
538
|
+
"Automatically detects the correct mode: " +
|
|
539
|
+
"'setup' (first-time: creates tracking repo, labels, Projects v2 board, commits shared config), " +
|
|
540
|
+
"'join' (remote .flux/github-config.json exists: reads shared config, writes local project.json), " +
|
|
541
|
+
"'update' (local project.json already has github adapter: rotates token only). " +
|
|
542
|
+
"Required: owner (GitHub username/org), repo (tracking repo name). " +
|
|
543
|
+
"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), " +
|
|
544
|
+
"2) token parameter (avoid — visible in chat history). " +
|
|
545
|
+
"To create a PAT: github.com → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token → select 'repo' scope. " +
|
|
546
|
+
"Optional: ref_prefix (default 'FLUX'), visibility (default 'private'). " +
|
|
547
|
+
"Returns {mode, board_url, repo_url, labels_created}.",
|
|
548
|
+
inputSchema,
|
|
549
|
+
handler,
|
|
550
|
+
};
|