@cliangdev/flux-plugin 0.4.0-dev.0892a21 → 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/package.json +1 -1
- package/src/server/adapters/github/__tests__/prd-crud.test.ts +4 -4
- package/src/server/adapters/github/adapter.ts +113 -7
- package/src/server/adapters/github/types.ts +6 -0
- package/src/server/tools/__tests__/z-configure-github.test.ts +39 -0
- package/src/server/tools/configure-github.ts +105 -0
package/package.json
CHANGED
|
@@ -292,7 +292,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
292
292
|
afterEach(() => {});
|
|
293
293
|
|
|
294
294
|
describe("createPrd", () => {
|
|
295
|
-
test("returns Prd with DRAFT status and
|
|
295
|
+
test("returns Prd with DRAFT status and no folderPath", async () => {
|
|
296
296
|
const issue = makeIssue({
|
|
297
297
|
number: 42,
|
|
298
298
|
title: "My New PRD",
|
|
@@ -315,7 +315,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
315
315
|
|
|
316
316
|
expect(prd.status).toBe("DRAFT");
|
|
317
317
|
expect(prd.ref).toBe("FP-P42");
|
|
318
|
-
expect(prd.folderPath).
|
|
318
|
+
expect(prd.folderPath).toBeUndefined();
|
|
319
319
|
expect(prd.title).toBe("My New PRD");
|
|
320
320
|
});
|
|
321
321
|
|
|
@@ -529,7 +529,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
529
529
|
expect(prd?.title).toBe("My PRD");
|
|
530
530
|
});
|
|
531
531
|
|
|
532
|
-
test("returns Prd
|
|
532
|
+
test("returns Prd without folderPath (GitHub adapter stores content in issue body)", async () => {
|
|
533
533
|
const issue = makeIssue({
|
|
534
534
|
number: 9,
|
|
535
535
|
title: "Folder PRD",
|
|
@@ -551,7 +551,7 @@ describe("GitHubAdapter PRD CRUD", () => {
|
|
|
551
551
|
const adapter = makeAdapter();
|
|
552
552
|
const prd = await adapter.getPrd("FP-P9");
|
|
553
553
|
|
|
554
|
-
expect(prd?.folderPath).
|
|
554
|
+
expect(prd?.folderPath).toBeUndefined();
|
|
555
555
|
});
|
|
556
556
|
});
|
|
557
557
|
|
|
@@ -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({
|
|
@@ -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
|
|
|
@@ -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 = {
|
|
@@ -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}`;
|