@cliangdev/flux-plugin 0.4.0-dev.0892a21 → 0.4.0-dev.236e9f1
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/package.json +1 -1
- package/src/server/adapters/github/__tests__/prd-crud.test.ts +12 -5
- package/src/server/adapters/github/adapter.ts +114 -8
- package/src/server/adapters/github/mappers/prd.ts +4 -1
- package/src/server/adapters/github/types.ts +6 -0
- package/src/server/index.ts +2 -0
- package/src/server/tools/__tests__/get-project-context.test.ts +81 -0
- package/src/server/tools/__tests__/z-configure-github.test.ts +39 -0
- package/src/server/tools/configure-github.ts +122 -0
- package/src/server/tools/get-project-context.ts +28 -0
- package/src/server/tools/index.ts +1 -0
package/package.json
CHANGED
|
@@ -223,7 +223,14 @@ describe("prd status mappers", () => {
|
|
|
223
223
|
expect(prdStatusToLabel("ARCHIVED")).toBeNull();
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
-
test("labelToPrdStatus detects
|
|
226
|
+
test("labelToPrdStatus detects COMPLETED from closed state with completed label", async () => {
|
|
227
|
+
const { labelToPrdStatus } = await import("../mappers/prd.js");
|
|
228
|
+
expect(labelToPrdStatus(["flux:prd", "status:completed"], true)).toBe(
|
|
229
|
+
"COMPLETED",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("labelToPrdStatus detects ARCHIVED from closed state without completed label", async () => {
|
|
227
234
|
const { labelToPrdStatus } = await import("../mappers/prd.js");
|
|
228
235
|
expect(labelToPrdStatus(["flux:prd", "status:draft"], true)).toBe(
|
|
229
236
|
"ARCHIVED",
|
|
@@ -292,7 +299,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
292
299
|
afterEach(() => {});
|
|
293
300
|
|
|
294
301
|
describe("createPrd", () => {
|
|
295
|
-
test("returns Prd with DRAFT status and
|
|
302
|
+
test("returns Prd with DRAFT status and no folderPath", async () => {
|
|
296
303
|
const issue = makeIssue({
|
|
297
304
|
number: 42,
|
|
298
305
|
title: "My New PRD",
|
|
@@ -315,7 +322,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
315
322
|
|
|
316
323
|
expect(prd.status).toBe("DRAFT");
|
|
317
324
|
expect(prd.ref).toBe("FP-P42");
|
|
318
|
-
expect(prd.folderPath).
|
|
325
|
+
expect(prd.folderPath).toBeUndefined();
|
|
319
326
|
expect(prd.title).toBe("My New PRD");
|
|
320
327
|
});
|
|
321
328
|
|
|
@@ -529,7 +536,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
529
536
|
expect(prd?.title).toBe("My PRD");
|
|
530
537
|
});
|
|
531
538
|
|
|
532
|
-
test("returns Prd
|
|
539
|
+
test("returns Prd without folderPath (GitHub adapter stores content in issue body)", async () => {
|
|
533
540
|
const issue = makeIssue({
|
|
534
541
|
number: 9,
|
|
535
542
|
title: "Folder PRD",
|
|
@@ -551,7 +558,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
551
558
|
const adapter = makeAdapter();
|
|
552
559
|
const prd = await adapter.getPrd("FP-P9");
|
|
553
560
|
|
|
554
|
-
expect(prd?.folderPath).
|
|
561
|
+
expect(prd?.folderPath).toBeUndefined();
|
|
555
562
|
});
|
|
556
563
|
});
|
|
557
564
|
|
|
@@ -50,6 +50,17 @@ import {
|
|
|
50
50
|
} from "./mappers/index.js";
|
|
51
51
|
import { GITHUB_LABELS, type GitHubConfig } from "./types.js";
|
|
52
52
|
|
|
53
|
+
const STATUS_TO_BOARD_COLUMN: Record<string, string> = {
|
|
54
|
+
DRAFT: "Draft",
|
|
55
|
+
PENDING: "Draft",
|
|
56
|
+
PENDING_REVIEW: "Pending Review",
|
|
57
|
+
REVIEWED: "Reviewed",
|
|
58
|
+
APPROVED: "Approved",
|
|
59
|
+
BREAKDOWN_READY: "Breakdown Ready",
|
|
60
|
+
IN_PROGRESS: "In Progress",
|
|
61
|
+
COMPLETED: "Completed",
|
|
62
|
+
};
|
|
63
|
+
|
|
53
64
|
interface GitHubIssue {
|
|
54
65
|
number: number;
|
|
55
66
|
id: number;
|
|
@@ -72,7 +83,6 @@ function issueToPrd(issue: GitHubIssue, config: GitHubConfig): Prd {
|
|
|
72
83
|
const meta = decodeMeta(issue.body || "");
|
|
73
84
|
const labels = issue.labels.map((l) => l.name);
|
|
74
85
|
const ref = meta?.ref ?? `${config.refPrefix}-P${issue.number}`;
|
|
75
|
-
const refSlug = ref.toLowerCase().replace(":", "-");
|
|
76
86
|
const tag = labelToTag(labels) ?? meta?.tag;
|
|
77
87
|
return {
|
|
78
88
|
id: String(issue.number),
|
|
@@ -82,7 +92,6 @@ function issueToPrd(issue: GitHubIssue, config: GitHubConfig): Prd {
|
|
|
82
92
|
description: extractDescription(issue.body || ""),
|
|
83
93
|
status: labelToPrdStatus(labels, issue.state === "closed"),
|
|
84
94
|
tag,
|
|
85
|
-
folderPath: `prds/${refSlug}/`,
|
|
86
95
|
createdAt: issue.created_at,
|
|
87
96
|
updatedAt: issue.updated_at,
|
|
88
97
|
};
|
|
@@ -149,8 +158,8 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
149
158
|
return null;
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
private async addToProjectsBoard(issueNodeId: string): Promise<
|
|
153
|
-
await this.client.graphql(
|
|
161
|
+
private async addToProjectsBoard(issueNodeId: string): Promise<string> {
|
|
162
|
+
const result = await this.client.graphql(
|
|
154
163
|
`
|
|
155
164
|
mutation AddToProject($projectId: ID!, $contentId: ID!) {
|
|
156
165
|
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
|
|
@@ -160,6 +169,79 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
160
169
|
`,
|
|
161
170
|
{ projectId: this.config.projectId, contentId: issueNodeId },
|
|
162
171
|
);
|
|
172
|
+
return (result as { addProjectV2ItemById: { item: { id: string } } })
|
|
173
|
+
.addProjectV2ItemById.item.id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async setProjectBoardStatus(
|
|
177
|
+
itemId: string,
|
|
178
|
+
status: string,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const sf = this.config.statusField;
|
|
181
|
+
if (!sf) return;
|
|
182
|
+
|
|
183
|
+
const columnName = STATUS_TO_BOARD_COLUMN[status];
|
|
184
|
+
if (!columnName) return;
|
|
185
|
+
|
|
186
|
+
const optionId = sf.optionIds[columnName];
|
|
187
|
+
if (!optionId) return;
|
|
188
|
+
|
|
189
|
+
await this.client.graphql(
|
|
190
|
+
`
|
|
191
|
+
mutation SetStatus($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
192
|
+
updateProjectV2ItemFieldValue(input: {
|
|
193
|
+
projectId: $projectId
|
|
194
|
+
itemId: $itemId
|
|
195
|
+
fieldId: $fieldId
|
|
196
|
+
value: { singleSelectOptionId: $optionId }
|
|
197
|
+
}) {
|
|
198
|
+
projectV2Item { id }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
`,
|
|
202
|
+
{
|
|
203
|
+
projectId: this.config.projectId,
|
|
204
|
+
itemId,
|
|
205
|
+
fieldId: sf.fieldId,
|
|
206
|
+
optionId,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async findProjectItemId(issueNodeId: string): Promise<string | null> {
|
|
212
|
+
const result = await this.client.graphql(
|
|
213
|
+
`
|
|
214
|
+
query FindProjectItem($projectId: ID!, $contentId: ID!) {
|
|
215
|
+
node(id: $projectId) {
|
|
216
|
+
... on ProjectV2 {
|
|
217
|
+
items(first: 100) {
|
|
218
|
+
nodes {
|
|
219
|
+
id
|
|
220
|
+
content { ... on Issue { id } ... on PullRequest { id } }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
`,
|
|
227
|
+
{ projectId: this.config.projectId, contentId: issueNodeId },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const items = (
|
|
231
|
+
result as {
|
|
232
|
+
node: {
|
|
233
|
+
items: {
|
|
234
|
+
nodes: Array<{
|
|
235
|
+
id: string;
|
|
236
|
+
content: { id: string } | null;
|
|
237
|
+
}>;
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
).node.items.nodes;
|
|
242
|
+
|
|
243
|
+
const match = items.find((item) => item.content?.id === issueNodeId);
|
|
244
|
+
return match?.id ?? null;
|
|
163
245
|
}
|
|
164
246
|
|
|
165
247
|
async createPrd(input: CreatePrdInput): Promise<Prd> {
|
|
@@ -204,7 +286,8 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
204
286
|
body: finalBody,
|
|
205
287
|
});
|
|
206
288
|
|
|
207
|
-
await this.addToProjectsBoard(createdIssue.node_id);
|
|
289
|
+
const projectItemId = await this.addToProjectsBoard(createdIssue.node_id);
|
|
290
|
+
await this.setProjectBoardStatus(projectItemId, "DRAFT");
|
|
208
291
|
await this.addToIndex(ref, issueNumber);
|
|
209
292
|
|
|
210
293
|
const getResponse = await this.client.rest.issues.get({
|
|
@@ -279,7 +362,7 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
279
362
|
updateParams.title = input.title;
|
|
280
363
|
}
|
|
281
364
|
|
|
282
|
-
if (newStatus === "ARCHIVED") {
|
|
365
|
+
if (newStatus === "COMPLETED" || newStatus === "ARCHIVED") {
|
|
283
366
|
updateParams.state = "closed";
|
|
284
367
|
}
|
|
285
368
|
|
|
@@ -287,6 +370,13 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
287
370
|
updateParams as Parameters<typeof this.client.rest.issues.update>[0],
|
|
288
371
|
);
|
|
289
372
|
|
|
373
|
+
if (input.status && this.config.statusField) {
|
|
374
|
+
const itemId = await this.findProjectItemId(existingIssue.node_id);
|
|
375
|
+
if (itemId) {
|
|
376
|
+
await this.setProjectBoardStatus(itemId, newStatus);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
290
380
|
return issueToPrd(updateResponse.data as GitHubIssue, this.config);
|
|
291
381
|
}
|
|
292
382
|
|
|
@@ -524,7 +614,8 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
524
614
|
// sub-issue linking is cosmetic for GitHub UI; hierarchy is stored in flux-meta
|
|
525
615
|
}
|
|
526
616
|
|
|
527
|
-
await this.addToProjectsBoard(createdIssue.node_id);
|
|
617
|
+
const epicItemId = await this.addToProjectsBoard(createdIssue.node_id);
|
|
618
|
+
await this.setProjectBoardStatus(epicItemId, "PENDING");
|
|
528
619
|
await this.addToIndex(ref, issueNumber);
|
|
529
620
|
|
|
530
621
|
const getResponse = await this.client.rest.issues.get({
|
|
@@ -598,6 +689,13 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
598
689
|
updateParams as Parameters<typeof this.client.rest.issues.update>[0],
|
|
599
690
|
);
|
|
600
691
|
|
|
692
|
+
if (input.status && this.config.statusField) {
|
|
693
|
+
const itemId = await this.findProjectItemId(existingIssue.node_id);
|
|
694
|
+
if (itemId) {
|
|
695
|
+
await this.setProjectBoardStatus(itemId, newStatus);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
601
699
|
return this.issueToEpic(updateResponse.data as GitHubIssue);
|
|
602
700
|
}
|
|
603
701
|
|
|
@@ -817,7 +915,8 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
817
915
|
// sub-issue linking is cosmetic for GitHub UI; hierarchy is in flux-meta
|
|
818
916
|
}
|
|
819
917
|
|
|
820
|
-
await this.addToProjectsBoard(createdIssue.node_id);
|
|
918
|
+
const taskItemId = await this.addToProjectsBoard(createdIssue.node_id);
|
|
919
|
+
await this.setProjectBoardStatus(taskItemId, "PENDING");
|
|
821
920
|
await this.addToIndex(ref, issueNumber);
|
|
822
921
|
|
|
823
922
|
const getResponse = await this.client.rest.issues.get({
|
|
@@ -902,6 +1001,13 @@ export class GitHubAdapter implements BackendAdapter {
|
|
|
902
1001
|
updateParams as Parameters<typeof this.client.rest.issues.update>[0],
|
|
903
1002
|
);
|
|
904
1003
|
|
|
1004
|
+
if (input.status && this.config.statusField) {
|
|
1005
|
+
const itemId = await this.findProjectItemId(existingIssue.node_id);
|
|
1006
|
+
if (itemId) {
|
|
1007
|
+
await this.setProjectBoardStatus(itemId, newStatus);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
905
1011
|
return this.issueToTask(updateResponse.data as GitHubIssue);
|
|
906
1012
|
}
|
|
907
1013
|
|
|
@@ -23,7 +23,10 @@ export function labelToPrdStatus(
|
|
|
23
23
|
labels: string[],
|
|
24
24
|
isClosed: boolean,
|
|
25
25
|
): PrdStatus {
|
|
26
|
-
if (isClosed)
|
|
26
|
+
if (isClosed) {
|
|
27
|
+
if (labels.includes(GITHUB_LABELS.STATUS_COMPLETED)) return "COMPLETED";
|
|
28
|
+
return "ARCHIVED";
|
|
29
|
+
}
|
|
27
30
|
|
|
28
31
|
if (labels.includes(GITHUB_LABELS.STATUS_COMPLETED)) return "COMPLETED";
|
|
29
32
|
if (labels.includes(GITHUB_LABELS.STATUS_BREAKDOWN_READY))
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
export interface ProjectStatusField {
|
|
2
|
+
fieldId: string;
|
|
3
|
+
optionIds: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
export interface GitHubConfig {
|
|
2
7
|
token: string;
|
|
3
8
|
owner: string;
|
|
4
9
|
repo: string;
|
|
5
10
|
projectId: string;
|
|
6
11
|
refPrefix: string;
|
|
12
|
+
statusField?: ProjectStatusField;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
export const GITHUB_LABELS = {
|
package/src/server/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
createTaskTool,
|
|
13
13
|
deleteEntityTool,
|
|
14
14
|
getEntityTool,
|
|
15
|
+
getProjectContextTool,
|
|
15
16
|
getStatsTool,
|
|
16
17
|
getVersionTool,
|
|
17
18
|
initProjectTool,
|
|
@@ -48,6 +49,7 @@ const tools: ToolDefinition[] = [
|
|
|
48
49
|
getEntityTool,
|
|
49
50
|
queryEntitiesTool,
|
|
50
51
|
initProjectTool,
|
|
52
|
+
getProjectContextTool,
|
|
51
53
|
getStatsTool,
|
|
52
54
|
getVersionTool,
|
|
53
55
|
// Configuration tools
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const TEST_DIR = `/tmp/flux-test-project-context-${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 { getProjectContextTool } from "../get-project-context.js";
|
|
11
|
+
|
|
12
|
+
describe("get_project_context", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
config.clearCache();
|
|
15
|
+
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
16
|
+
mkdirSync(FLUX_DIR, { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns local adapter when no adapter configured", async () => {
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(FLUX_DIR, "project.json"),
|
|
26
|
+
JSON.stringify({ name: "test-project", vision: "A test project" }),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const result = (await getProjectContextTool.handler({})) as any;
|
|
30
|
+
|
|
31
|
+
expect(result.name).toBe("test-project");
|
|
32
|
+
expect(result.vision).toBe("A test project");
|
|
33
|
+
expect(result.adapter.type).toBe("local");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns github adapter type when configured", async () => {
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(FLUX_DIR, "project.json"),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
name: "gh-project",
|
|
41
|
+
vision: "GitHub project",
|
|
42
|
+
refPrefix: "GH",
|
|
43
|
+
adapter: { type: "github" },
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const result = (await getProjectContextTool.handler({})) as any;
|
|
48
|
+
|
|
49
|
+
expect(result.name).toBe("gh-project");
|
|
50
|
+
expect(result.vision).toBe("GitHub project");
|
|
51
|
+
expect(result.ref_prefix).toBe("GH");
|
|
52
|
+
expect(result.adapter.type).toBe("github");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns linear adapter type when configured", async () => {
|
|
56
|
+
writeFileSync(
|
|
57
|
+
join(FLUX_DIR, "project.json"),
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
name: "linear-project",
|
|
60
|
+
adapter: { type: "linear" },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const result = (await getProjectContextTool.handler({})) as any;
|
|
65
|
+
|
|
66
|
+
expect(result.name).toBe("linear-project");
|
|
67
|
+
expect(result.adapter.type).toBe("linear");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("has correct tool definition", () => {
|
|
71
|
+
expect(getProjectContextTool.name).toBe("get_project_context");
|
|
72
|
+
expect(getProjectContextTool.description).toContain("adapter");
|
|
73
|
+
expect(getProjectContextTool.inputSchema).toBeDefined();
|
|
74
|
+
expect(getProjectContextTool.handler).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("throws when project.json does not exist", async () => {
|
|
78
|
+
// No project.json written
|
|
79
|
+
expect(getProjectContextTool.handler({})).rejects.toThrow();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -126,6 +126,45 @@ mock.module("@octokit/graphql", () => ({
|
|
|
126
126
|
linkProjectV2ToRepository: { repository: { id: "REPO_NODE_ID" } },
|
|
127
127
|
};
|
|
128
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
|
+
}
|
|
129
168
|
throw new Error(`Unhandled GraphQL query: ${query}`);
|
|
130
169
|
};
|
|
131
170
|
},
|
|
@@ -17,11 +17,17 @@ const inputSchema = z.object({
|
|
|
17
17
|
|
|
18
18
|
type ConfigureMode = "setup" | "join" | "update";
|
|
19
19
|
|
|
20
|
+
interface StatusFieldConfig {
|
|
21
|
+
fieldId: string;
|
|
22
|
+
optionIds: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
interface SharedConfig {
|
|
21
26
|
owner: string;
|
|
22
27
|
repo: string;
|
|
23
28
|
projectId: string;
|
|
24
29
|
refPrefix: string;
|
|
30
|
+
statusField?: StatusFieldConfig;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
interface ConfigureResult {
|
|
@@ -214,6 +220,98 @@ async function createProjectsBoard(
|
|
|
214
220
|
return project;
|
|
215
221
|
}
|
|
216
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
|
+
|
|
217
315
|
async function commitSharedConfig(
|
|
218
316
|
octokit: Octokit,
|
|
219
317
|
owner: string,
|
|
@@ -238,6 +336,7 @@ function writeProjectJson(params: {
|
|
|
238
336
|
repo: string;
|
|
239
337
|
projectId: string;
|
|
240
338
|
refPrefix: string;
|
|
339
|
+
statusField?: StatusFieldConfig;
|
|
241
340
|
}): void {
|
|
242
341
|
const projectJsonPath = config.projectJsonPath;
|
|
243
342
|
const existing = existsSync(projectJsonPath)
|
|
@@ -252,6 +351,7 @@ function writeProjectJson(params: {
|
|
|
252
351
|
repo: params.repo,
|
|
253
352
|
projectId: params.projectId,
|
|
254
353
|
refPrefix: params.refPrefix,
|
|
354
|
+
...(params.statusField ? { statusField: params.statusField } : {}),
|
|
255
355
|
},
|
|
256
356
|
};
|
|
257
357
|
|
|
@@ -290,11 +390,14 @@ async function runSetup(params: {
|
|
|
290
390
|
params.repo,
|
|
291
391
|
);
|
|
292
392
|
|
|
393
|
+
const statusField = await configureStatusField(params.gql, project.id);
|
|
394
|
+
|
|
293
395
|
const sharedConfig: SharedConfig = {
|
|
294
396
|
owner: params.owner,
|
|
295
397
|
repo: params.repo,
|
|
296
398
|
projectId: project.id,
|
|
297
399
|
refPrefix: params.refPrefix,
|
|
400
|
+
...(statusField ? { statusField } : {}),
|
|
298
401
|
};
|
|
299
402
|
|
|
300
403
|
await commitSharedConfig(
|
|
@@ -310,6 +413,7 @@ async function runSetup(params: {
|
|
|
310
413
|
repo: params.repo,
|
|
311
414
|
projectId: project.id,
|
|
312
415
|
refPrefix: params.refPrefix,
|
|
416
|
+
statusField,
|
|
313
417
|
});
|
|
314
418
|
|
|
315
419
|
return {
|
|
@@ -332,6 +436,7 @@ async function runJoin(params: {
|
|
|
332
436
|
repo: params.remoteConfig.repo,
|
|
333
437
|
projectId: params.remoteConfig.projectId,
|
|
334
438
|
refPrefix: params.remoteConfig.refPrefix,
|
|
439
|
+
statusField: params.remoteConfig.statusField,
|
|
335
440
|
});
|
|
336
441
|
|
|
337
442
|
const repoUrl = `https://github.com/${params.remoteConfig.owner}/${params.remoteConfig.repo}`;
|
|
@@ -348,10 +453,26 @@ async function runUpdate(params: {
|
|
|
348
453
|
token: string;
|
|
349
454
|
owner: string;
|
|
350
455
|
repo: string;
|
|
456
|
+
gql: (
|
|
457
|
+
query: string,
|
|
458
|
+
vars: Record<string, unknown>,
|
|
459
|
+
) => Promise<Record<string, Record<string, unknown>>>;
|
|
351
460
|
}): Promise<ConfigureResult> {
|
|
352
461
|
const projectJsonPath = config.projectJsonPath;
|
|
353
462
|
const existing = JSON.parse(readFileSync(projectJsonPath, "utf-8"));
|
|
354
463
|
existing.adapter.config.token = params.token;
|
|
464
|
+
|
|
465
|
+
const adapterConfig = existing.adapter?.config;
|
|
466
|
+
if (adapterConfig?.projectId && !adapterConfig.statusField) {
|
|
467
|
+
const statusField = await configureStatusField(
|
|
468
|
+
params.gql,
|
|
469
|
+
adapterConfig.projectId,
|
|
470
|
+
);
|
|
471
|
+
if (statusField) {
|
|
472
|
+
adapterConfig.statusField = statusField;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
355
476
|
writeFileSync(projectJsonPath, JSON.stringify(existing, null, 2));
|
|
356
477
|
|
|
357
478
|
const repoUrl = `https://github.com/${params.owner}/${params.repo}`;
|
|
@@ -399,6 +520,7 @@ async function handler(input: unknown) {
|
|
|
399
520
|
token,
|
|
400
521
|
owner: parsed.owner,
|
|
401
522
|
repo: parsed.repo,
|
|
523
|
+
gql: gqlWithAuth,
|
|
402
524
|
});
|
|
403
525
|
} else if (mode === "join" && remoteConfig) {
|
|
404
526
|
result = await runJoin({
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
import type { ToolDefinition } from "./index.js";
|
|
5
|
+
|
|
6
|
+
const inputSchema = z.object({});
|
|
7
|
+
|
|
8
|
+
async function handler(_input: unknown) {
|
|
9
|
+
const content = readFileSync(config.projectJsonPath, "utf-8");
|
|
10
|
+
const project = JSON.parse(content);
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
name: project.name ?? null,
|
|
14
|
+
vision: project.vision ?? null,
|
|
15
|
+
ref_prefix: project.refPrefix ?? null,
|
|
16
|
+
adapter: {
|
|
17
|
+
type: project.adapter?.type ?? "local",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const getProjectContextTool: ToolDefinition = {
|
|
23
|
+
name: "get_project_context",
|
|
24
|
+
description:
|
|
25
|
+
"Get project context including adapter type, project name, vision, and ref prefix. No parameters required. Returns {name, vision, ref_prefix, adapter: {type: 'local' | 'github' | 'linear'}}. Use this to detect which adapter is configured before performing adapter-specific operations.",
|
|
26
|
+
inputSchema,
|
|
27
|
+
handler,
|
|
28
|
+
};
|
|
@@ -150,6 +150,7 @@ export { deleteEntityTool } from "./delete-entity.js";
|
|
|
150
150
|
export { addDependencyTool, removeDependencyTool } from "./dependencies.js";
|
|
151
151
|
export { getEntityTool } from "./get-entity.js";
|
|
152
152
|
export { getLinearUrlTool } from "./get-linear-url.js";
|
|
153
|
+
export { getProjectContextTool } from "./get-project-context.js";
|
|
153
154
|
export { getStatsTool } from "./get-stats.js";
|
|
154
155
|
export { getVersionTool } from "./get-version.js";
|
|
155
156
|
export { initProjectTool } from "./init-project.js";
|