@cliangdev/flux-plugin 0.4.0-dev.0892a21 → 0.4.0-dev.17634ed

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliangdev/flux-plugin",
3
- "version": "0.4.0-dev.0892a21",
3
+ "version": "0.4.0-dev.17634ed",
4
4
  "description": "Claude Code plugin for AI-first workflow orchestration with MCP server",
5
5
  "type": "module",
6
6
  "main": "./dist/server/index.js",
@@ -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 correct folderPath", async () => {
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).toBe("prds/fp-p42/");
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 with correct folderPath derived from ref", async () => {
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).toBe("prds/fp-p9/");
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<void> {
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}`;
@@ -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({