@cliangdev/flux-plugin 0.3.0 → 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.
- package/bin/install.cjs +2 -2
- 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 +1552 -0
- package/src/server/adapters/github/client.ts +33 -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 +75 -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 +27 -0
- 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 +509 -0
- package/src/server/tools/__tests__/z-init-project.test.ts +168 -0
- package/src/server/tools/configure-github.ts +411 -0
- package/src/server/tools/index.ts +2 -1
- package/src/server/tools/init-project.ts +26 -12
package/bin/install.cjs
CHANGED
|
@@ -327,10 +327,10 @@ ${cyan} ███████╗██╗ ██╗ ██╗██╗
|
|
|
327
327
|
mcpConfig.mcpServers = {};
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
-
const
|
|
330
|
+
const tag = pkg.version.includes("-dev.") ? "next" : "latest";
|
|
331
331
|
mcpConfig.mcpServers.flux = {
|
|
332
332
|
command: "bunx",
|
|
333
|
-
args: [`@cliangdev/flux-plugin@${
|
|
333
|
+
args: [`@cliangdev/flux-plugin@${tag}`, "serve"],
|
|
334
334
|
};
|
|
335
335
|
|
|
336
336
|
writeJson(mcpConfigPath, mcpConfig);
|
package/commands/dashboard.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cliangdev/flux-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1-dev.8c219d5",
|
|
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",
|
|
@@ -59,6 +59,8 @@
|
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@linear/sdk": "^70.0.0",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
62
|
+
"@octokit/graphql": "^9.0.3",
|
|
63
|
+
"@octokit/rest": "^22.0.1",
|
|
62
64
|
"chalk": "^5.4.1",
|
|
63
65
|
"zod": "^4.3.5"
|
|
64
66
|
},
|
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Adapter Factory
|
|
3
|
-
*
|
|
4
|
-
* Creates the appropriate adapter based on project configuration.
|
|
5
|
-
* Supports LocalAdapter and LinearAdapter; future adapters will be added here.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
2
|
import { config } from "../config.js";
|
|
3
|
+
import { GitHubAdapter } from "./github/adapter.js";
|
|
4
|
+
import { loadGithubConfig } from "./github/config.js";
|
|
10
5
|
import { LinearAdapter } from "./linear/adapter.js";
|
|
11
6
|
import { linearConfigExists, loadLinearConfig } from "./linear/config.js";
|
|
12
7
|
import { LocalAdapter } from "./local-adapter.js";
|
|
@@ -14,12 +9,8 @@ import type { AdapterConfig, BackendAdapter } from "./types.js";
|
|
|
14
9
|
|
|
15
10
|
let cachedAdapter: BackendAdapter | null = null;
|
|
16
11
|
|
|
17
|
-
/**
|
|
18
|
-
* Get the adapter configuration from project.json
|
|
19
|
-
*/
|
|
20
12
|
function getAdapterConfig(): AdapterConfig {
|
|
21
13
|
if (!existsSync(config.projectJsonPath)) {
|
|
22
|
-
// Default to local adapter if project not initialized
|
|
23
14
|
return { type: "local" };
|
|
24
15
|
}
|
|
25
16
|
|
|
@@ -32,12 +23,6 @@ function getAdapterConfig(): AdapterConfig {
|
|
|
32
23
|
}
|
|
33
24
|
}
|
|
34
25
|
|
|
35
|
-
/**
|
|
36
|
-
* Create an adapter instance based on configuration.
|
|
37
|
-
*
|
|
38
|
-
* @param adapterConfig - Optional override for adapter configuration
|
|
39
|
-
* @returns The appropriate BackendAdapter implementation
|
|
40
|
-
*/
|
|
41
26
|
export function createAdapter(adapterConfig?: AdapterConfig): BackendAdapter {
|
|
42
27
|
const cfg = adapterConfig ?? getAdapterConfig();
|
|
43
28
|
|
|
@@ -60,6 +45,10 @@ export function createAdapter(adapterConfig?: AdapterConfig): BackendAdapter {
|
|
|
60
45
|
case "notion":
|
|
61
46
|
// TODO: Implement NotionAdapter
|
|
62
47
|
throw new Error("Notion adapter not yet implemented");
|
|
48
|
+
case "github": {
|
|
49
|
+
const githubConfig = loadGithubConfig();
|
|
50
|
+
return new GitHubAdapter(githubConfig);
|
|
51
|
+
}
|
|
63
52
|
default: {
|
|
64
53
|
const _exhaustive: never = cfg.type;
|
|
65
54
|
throw new Error(`Unknown adapter type: ${cfg.type}`);
|
|
@@ -67,13 +56,6 @@ export function createAdapter(adapterConfig?: AdapterConfig): BackendAdapter {
|
|
|
67
56
|
}
|
|
68
57
|
}
|
|
69
58
|
|
|
70
|
-
/**
|
|
71
|
-
* Get or create a cached adapter instance.
|
|
72
|
-
* Uses singleton pattern to avoid creating multiple adapter instances.
|
|
73
|
-
*
|
|
74
|
-
* @param forceNew - If true, creates a new adapter even if one is cached
|
|
75
|
-
* @returns The cached or newly created adapter
|
|
76
|
-
*/
|
|
77
59
|
export function getAdapter(forceNew = false): BackendAdapter {
|
|
78
60
|
if (forceNew || !cachedAdapter) {
|
|
79
61
|
cachedAdapter = createAdapter();
|
|
@@ -81,10 +63,6 @@ export function getAdapter(forceNew = false): BackendAdapter {
|
|
|
81
63
|
return cachedAdapter;
|
|
82
64
|
}
|
|
83
65
|
|
|
84
|
-
/**
|
|
85
|
-
* Clear the cached adapter instance.
|
|
86
|
-
* Useful for testing or when project configuration changes.
|
|
87
|
-
*/
|
|
88
66
|
export function clearAdapterCache(): void {
|
|
89
67
|
cachedAdapter = null;
|
|
90
68
|
}
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
addCriterionToDescription,
|
|
5
|
+
generateCriteriaId,
|
|
6
|
+
parseCriteriaFromDescription,
|
|
7
|
+
updateCriterionInDescription,
|
|
8
|
+
} from "../helpers/criteria.js";
|
|
9
|
+
|
|
10
|
+
// ============================================================
|
|
11
|
+
// Unit tests: criteria helper functions
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
describe("parseCriteriaFromDescription", () => {
|
|
15
|
+
test("extracts criteria with correct isMet state", () => {
|
|
16
|
+
const body = `## Acceptance Criteria
|
|
17
|
+
|
|
18
|
+
- [ ] First criterion
|
|
19
|
+
- [x] Second criterion
|
|
20
|
+
- [ ] Third criterion`;
|
|
21
|
+
|
|
22
|
+
const criteria = parseCriteriaFromDescription(body);
|
|
23
|
+
expect(criteria).toHaveLength(3);
|
|
24
|
+
expect(criteria[0].text).toBe("First criterion");
|
|
25
|
+
expect(criteria[0].isMet).toBe(false);
|
|
26
|
+
expect(criteria[1].text).toBe("Second criterion");
|
|
27
|
+
expect(criteria[1].isMet).toBe(true);
|
|
28
|
+
expect(criteria[2].text).toBe("Third criterion");
|
|
29
|
+
expect(criteria[2].isMet).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns empty array when no Acceptance Criteria section present", () => {
|
|
33
|
+
const body = `Some description without criteria`;
|
|
34
|
+
const criteria = parseCriteriaFromDescription(body);
|
|
35
|
+
expect(criteria).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns empty array for undefined input", () => {
|
|
39
|
+
const criteria = parseCriteriaFromDescription(undefined);
|
|
40
|
+
expect(criteria).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("stops parsing at next section header", () => {
|
|
44
|
+
const body = `## Acceptance Criteria
|
|
45
|
+
|
|
46
|
+
- [ ] First criterion
|
|
47
|
+
|
|
48
|
+
## Other Section
|
|
49
|
+
|
|
50
|
+
- [ ] Should not be parsed`;
|
|
51
|
+
|
|
52
|
+
const criteria = parseCriteriaFromDescription(body);
|
|
53
|
+
expect(criteria).toHaveLength(1);
|
|
54
|
+
expect(criteria[0].text).toBe("First criterion");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("updateCriterionInDescription", () => {
|
|
59
|
+
test("flips [ ] to [x] for matching criterion ID", () => {
|
|
60
|
+
const body = `## Acceptance Criteria
|
|
61
|
+
|
|
62
|
+
- [ ] First criterion
|
|
63
|
+
- [ ] Second criterion`;
|
|
64
|
+
|
|
65
|
+
const id = generateCriteriaId("First criterion");
|
|
66
|
+
const updated = updateCriterionInDescription(body, id, true);
|
|
67
|
+
expect(updated).toContain("- [x] First criterion");
|
|
68
|
+
expect(updated).toContain("- [ ] Second criterion");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("flips [x] to [ ] for matching criterion ID", () => {
|
|
72
|
+
const body = `## Acceptance Criteria
|
|
73
|
+
|
|
74
|
+
- [x] Already met
|
|
75
|
+
- [ ] Not met`;
|
|
76
|
+
|
|
77
|
+
const id = generateCriteriaId("Already met");
|
|
78
|
+
const updated = updateCriterionInDescription(body, id, false);
|
|
79
|
+
expect(updated).toContain("- [ ] Already met");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("throws when criterion ID not found", () => {
|
|
83
|
+
const body = `## Acceptance Criteria
|
|
84
|
+
|
|
85
|
+
- [ ] Some criterion`;
|
|
86
|
+
|
|
87
|
+
expect(() =>
|
|
88
|
+
updateCriterionInDescription(body, "ac_nonexist", true),
|
|
89
|
+
).toThrow("Criterion not found: ac_nonexist");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("addCriterionToDescription", () => {
|
|
94
|
+
test("appends to existing Acceptance Criteria section", () => {
|
|
95
|
+
const body = `## Acceptance Criteria
|
|
96
|
+
|
|
97
|
+
- [ ] Existing criterion`;
|
|
98
|
+
|
|
99
|
+
const updated = addCriterionToDescription(body, "New criterion");
|
|
100
|
+
expect(updated).toContain("- [ ] Existing criterion");
|
|
101
|
+
expect(updated).toContain("- [ ] New criterion");
|
|
102
|
+
const lines = updated.split("\n");
|
|
103
|
+
const existingIdx = lines.findIndex((l) =>
|
|
104
|
+
l.includes("Existing criterion"),
|
|
105
|
+
);
|
|
106
|
+
const newIdx = lines.findIndex((l) => l.includes("New criterion"));
|
|
107
|
+
expect(newIdx).toBeGreaterThan(existingIdx);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("creates Acceptance Criteria section if absent", () => {
|
|
111
|
+
const body = `Some description without criteria`;
|
|
112
|
+
const updated = addCriterionToDescription(body, "New criterion");
|
|
113
|
+
expect(updated).toContain("## Acceptance Criteria");
|
|
114
|
+
expect(updated).toContain("- [ ] New criterion");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("creates section from empty/undefined description", () => {
|
|
118
|
+
const updated = addCriterionToDescription(undefined, "First criterion");
|
|
119
|
+
expect(updated).toBe("## Acceptance Criteria\n\n- [ ] First criterion");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("generateCriteriaId", () => {
|
|
124
|
+
test("produces stable ID for same text", () => {
|
|
125
|
+
const id1 = generateCriteriaId("some criterion text");
|
|
126
|
+
const id2 = generateCriteriaId("some criterion text");
|
|
127
|
+
expect(id1).toBe(id2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("produces different IDs for different text", () => {
|
|
131
|
+
const id1 = generateCriteriaId("criterion A");
|
|
132
|
+
const id2 = generateCriteriaId("criterion B");
|
|
133
|
+
expect(id1).not.toBe(id2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("has ac_ prefix and 8-char hex suffix", () => {
|
|
137
|
+
const id = generateCriteriaId("test text");
|
|
138
|
+
expect(id).toMatch(/^ac_[0-9a-f]{8}$/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("trims whitespace before hashing", () => {
|
|
142
|
+
const id1 = generateCriteriaId(" test ");
|
|
143
|
+
const id2 = generateCriteriaId("test");
|
|
144
|
+
expect(id1).toBe(id2);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ============================================================
|
|
149
|
+
// Adapter method tests (mock Octokit)
|
|
150
|
+
// ============================================================
|
|
151
|
+
|
|
152
|
+
let mockIssuesUpdate: any = null;
|
|
153
|
+
let mockIssuesGet: Record<number, any> = {};
|
|
154
|
+
let mockIssuesList: any[] = [];
|
|
155
|
+
let mockGetContent: any = null;
|
|
156
|
+
let lastUpdateParams: any = null;
|
|
157
|
+
|
|
158
|
+
mock.module("@octokit/rest", () => ({
|
|
159
|
+
Octokit: class MockOctokit {
|
|
160
|
+
repos = {
|
|
161
|
+
getContent: async (_params: any) => {
|
|
162
|
+
if (mockGetContent === null) {
|
|
163
|
+
const err: any = new Error("Not Found");
|
|
164
|
+
err.status = 404;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
return mockGetContent;
|
|
168
|
+
},
|
|
169
|
+
createOrUpdateFileContents: async () => {
|
|
170
|
+
return { data: { content: { sha: "new-sha" } } };
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
issues = {
|
|
175
|
+
update: async (params: any) => {
|
|
176
|
+
lastUpdateParams = params;
|
|
177
|
+
const num = params.issue_number;
|
|
178
|
+
if (mockIssuesGet[num]) {
|
|
179
|
+
const updated = {
|
|
180
|
+
...mockIssuesGet[num],
|
|
181
|
+
body: params.body ?? mockIssuesGet[num].body,
|
|
182
|
+
};
|
|
183
|
+
return { data: updated };
|
|
184
|
+
}
|
|
185
|
+
return { data: mockIssuesUpdate };
|
|
186
|
+
},
|
|
187
|
+
get: async (params: any) => {
|
|
188
|
+
const num = params.issue_number;
|
|
189
|
+
if (mockIssuesGet[num]) return { data: mockIssuesGet[num] };
|
|
190
|
+
const err: any = new Error("Not Found");
|
|
191
|
+
err.status = 404;
|
|
192
|
+
throw err;
|
|
193
|
+
},
|
|
194
|
+
listForRepo: async (_params: any) => {
|
|
195
|
+
return { data: mockIssuesList };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
mock.module("@octokit/graphql", () => ({
|
|
202
|
+
graphql: {
|
|
203
|
+
defaults: (_opts: any) => {
|
|
204
|
+
return async () => ({});
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
}));
|
|
208
|
+
|
|
209
|
+
function makeIssue(
|
|
210
|
+
overrides: Partial<{
|
|
211
|
+
number: number;
|
|
212
|
+
title: string;
|
|
213
|
+
body: string;
|
|
214
|
+
labels: string[];
|
|
215
|
+
state: "open" | "closed";
|
|
216
|
+
created_at: string;
|
|
217
|
+
updated_at: string;
|
|
218
|
+
node_id: string;
|
|
219
|
+
}> = {},
|
|
220
|
+
) {
|
|
221
|
+
const number = overrides.number ?? 1;
|
|
222
|
+
return {
|
|
223
|
+
number,
|
|
224
|
+
title: overrides.title ?? "Test Issue",
|
|
225
|
+
body: overrides.body ?? "",
|
|
226
|
+
labels: (overrides.labels ?? []).map((name) => ({ name })),
|
|
227
|
+
state: overrides.state ?? "open",
|
|
228
|
+
created_at: overrides.created_at ?? "2026-01-01T00:00:00Z",
|
|
229
|
+
updated_at: overrides.updated_at ?? "2026-01-01T00:00:00Z",
|
|
230
|
+
node_id: overrides.node_id ?? `MDU6SXNzdWU${number}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function encodeIndex(index: Record<string, number>): string {
|
|
235
|
+
return Buffer.from(JSON.stringify(index)).toString("base64");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function makeAdapter() {
|
|
239
|
+
const { GitHubAdapter } = require("../adapter.js");
|
|
240
|
+
const adapter = new GitHubAdapter({
|
|
241
|
+
token: "ghp_test",
|
|
242
|
+
owner: "test-owner",
|
|
243
|
+
repo: "test-repo",
|
|
244
|
+
projectId: "PVT_kwDO123",
|
|
245
|
+
refPrefix: "FP",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
(adapter as any).client.rest.issues = {
|
|
249
|
+
update: async (params: any) => {
|
|
250
|
+
lastUpdateParams = params;
|
|
251
|
+
const num = params.issue_number;
|
|
252
|
+
if (mockIssuesGet[num]) {
|
|
253
|
+
const updated = {
|
|
254
|
+
...mockIssuesGet[num],
|
|
255
|
+
body: params.body ?? mockIssuesGet[num].body,
|
|
256
|
+
};
|
|
257
|
+
return { data: updated };
|
|
258
|
+
}
|
|
259
|
+
return { data: mockIssuesUpdate };
|
|
260
|
+
},
|
|
261
|
+
get: async (params: any) => {
|
|
262
|
+
const num = params.issue_number;
|
|
263
|
+
if (mockIssuesGet[num]) return { data: mockIssuesGet[num] };
|
|
264
|
+
const err: any = new Error("Not Found");
|
|
265
|
+
err.status = 404;
|
|
266
|
+
throw err;
|
|
267
|
+
},
|
|
268
|
+
listForRepo: async (_params: any) => {
|
|
269
|
+
return { data: mockIssuesList };
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
(adapter as any).client.rest.repos = {
|
|
274
|
+
getContent: async (_params: any) => {
|
|
275
|
+
if (mockGetContent === null) {
|
|
276
|
+
const err: any = new Error("Not Found");
|
|
277
|
+
err.status = 404;
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
return mockGetContent;
|
|
281
|
+
},
|
|
282
|
+
createOrUpdateFileContents: async () => {
|
|
283
|
+
return { data: { content: { sha: "new-sha" } } };
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
(adapter as any).client.rest.request = async (url: string, params: any) => {
|
|
288
|
+
if (url.includes("sub_issues") && url.startsWith("GET")) {
|
|
289
|
+
return { data: [] };
|
|
290
|
+
}
|
|
291
|
+
if (url.includes("sub_issues") && url.startsWith("POST")) {
|
|
292
|
+
return { data: {} };
|
|
293
|
+
}
|
|
294
|
+
throw new Error(`Unmocked request: ${url}`);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return adapter;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
mockIssuesUpdate = null;
|
|
302
|
+
mockIssuesGet = {};
|
|
303
|
+
mockIssuesList = [];
|
|
304
|
+
mockGetContent = null;
|
|
305
|
+
lastUpdateParams = null;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
afterEach(() => {
|
|
309
|
+
mockIssuesUpdate = null;
|
|
310
|
+
mockIssuesGet = {};
|
|
311
|
+
mockIssuesList = [];
|
|
312
|
+
mockGetContent = null;
|
|
313
|
+
lastUpdateParams = null;
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Helper: make index content returning given mapping
|
|
317
|
+
function makeIndexContent(index: Record<string, number>) {
|
|
318
|
+
return {
|
|
319
|
+
data: {
|
|
320
|
+
sha: "abc123",
|
|
321
|
+
content: encodeIndex(index),
|
|
322
|
+
encoding: "base64",
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
describe("adapter.addCriterion", () => {
|
|
328
|
+
test("returns AcceptanceCriterion with isMet: false", async () => {
|
|
329
|
+
const epicBody = `Some description
|
|
330
|
+
|
|
331
|
+
<!-- flux-meta
|
|
332
|
+
{"ref":"FP-E5","prd_ref":"FP-P1","dependencies":[]}
|
|
333
|
+
-->`;
|
|
334
|
+
mockIssuesGet[5] = makeIssue({
|
|
335
|
+
number: 5,
|
|
336
|
+
body: epicBody,
|
|
337
|
+
labels: ["flux:epic"],
|
|
338
|
+
});
|
|
339
|
+
mockGetContent = makeIndexContent({ "FP-E5": 5 });
|
|
340
|
+
|
|
341
|
+
const adapter = makeAdapter();
|
|
342
|
+
const result = await adapter.addCriterion({
|
|
343
|
+
parentRef: "FP-E5",
|
|
344
|
+
criteria: "New acceptance criterion",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(result.isMet).toBe(false);
|
|
348
|
+
expect(result.criteria).toBe("New acceptance criterion");
|
|
349
|
+
expect(result.parentType).toBe("epic");
|
|
350
|
+
expect(result.id).toBe(generateCriteriaId("New acceptance criterion"));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("body is updated with new checkbox", async () => {
|
|
354
|
+
const epicBody = `Some description
|
|
355
|
+
|
|
356
|
+
<!-- flux-meta
|
|
357
|
+
{"ref":"FP-E5","prd_ref":"FP-P1","dependencies":[]}
|
|
358
|
+
-->`;
|
|
359
|
+
mockIssuesGet[5] = makeIssue({
|
|
360
|
+
number: 5,
|
|
361
|
+
body: epicBody,
|
|
362
|
+
labels: ["flux:epic"],
|
|
363
|
+
});
|
|
364
|
+
mockGetContent = makeIndexContent({ "FP-E5": 5 });
|
|
365
|
+
|
|
366
|
+
const adapter = makeAdapter();
|
|
367
|
+
await adapter.addCriterion({
|
|
368
|
+
parentRef: "FP-E5",
|
|
369
|
+
criteria: "New acceptance criterion",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(lastUpdateParams).not.toBeNull();
|
|
373
|
+
expect(lastUpdateParams.body).toContain("- [ ] New acceptance criterion");
|
|
374
|
+
expect(lastUpdateParams.body).toContain("## Acceptance Criteria");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("adapter.markCriterionMet", () => {
|
|
379
|
+
test("returns AcceptanceCriterion with isMet: true", async () => {
|
|
380
|
+
const criterionText = "Must handle errors";
|
|
381
|
+
const criterionId = generateCriteriaId(criterionText);
|
|
382
|
+
|
|
383
|
+
const taskBody = `Task description
|
|
384
|
+
|
|
385
|
+
## Acceptance Criteria
|
|
386
|
+
|
|
387
|
+
- [ ] ${criterionText}
|
|
388
|
+
|
|
389
|
+
<!-- flux-meta
|
|
390
|
+
{"ref":"FP-T10","epic_ref":"FP-E5","prd_ref":"FP-P1","dependencies":[]}
|
|
391
|
+
-->`;
|
|
392
|
+
mockIssuesGet[10] = makeIssue({
|
|
393
|
+
number: 10,
|
|
394
|
+
body: taskBody,
|
|
395
|
+
labels: ["flux:task"],
|
|
396
|
+
});
|
|
397
|
+
mockIssuesList = [
|
|
398
|
+
makeIssue({ number: 10, body: taskBody, labels: ["flux:task"] }),
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
const adapter = makeAdapter();
|
|
402
|
+
const result = await adapter.markCriterionMet(criterionId);
|
|
403
|
+
|
|
404
|
+
expect(result.isMet).toBe(true);
|
|
405
|
+
expect(result.criteria).toBe(criterionText);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("throws when criterionId not found in any issue", async () => {
|
|
409
|
+
const taskBody = `## Acceptance Criteria
|
|
410
|
+
|
|
411
|
+
- [ ] Some other criterion
|
|
412
|
+
|
|
413
|
+
<!-- flux-meta
|
|
414
|
+
{"ref":"FP-T10","epic_ref":"FP-E5","prd_ref":"FP-P1","dependencies":[]}
|
|
415
|
+
-->`;
|
|
416
|
+
mockIssuesList = [
|
|
417
|
+
makeIssue({ number: 10, body: taskBody, labels: ["flux:task"] }),
|
|
418
|
+
makeIssue({
|
|
419
|
+
number: 11,
|
|
420
|
+
body: `<!-- flux-meta\n{"ref":"FP-E2","prd_ref":"FP-P1","dependencies":[]}\n-->`,
|
|
421
|
+
labels: ["flux:epic"],
|
|
422
|
+
}),
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
const adapter = makeAdapter();
|
|
426
|
+
await expect(adapter.markCriterionMet("ac_notfound")).rejects.toThrow(
|
|
427
|
+
"Criterion not found",
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("adapter.getCriteria", () => {
|
|
433
|
+
test("returns all criteria in order with correct isMet state", async () => {
|
|
434
|
+
const epicBody = `Epic description
|
|
435
|
+
|
|
436
|
+
## Acceptance Criteria
|
|
437
|
+
|
|
438
|
+
- [ ] First criterion
|
|
439
|
+
- [x] Second criterion
|
|
440
|
+
- [ ] Third criterion
|
|
441
|
+
|
|
442
|
+
<!-- flux-meta
|
|
443
|
+
{"ref":"FP-E5","prd_ref":"FP-P1","dependencies":[]}
|
|
444
|
+
-->`;
|
|
445
|
+
mockIssuesGet[5] = makeIssue({
|
|
446
|
+
number: 5,
|
|
447
|
+
body: epicBody,
|
|
448
|
+
labels: ["flux:epic"],
|
|
449
|
+
});
|
|
450
|
+
mockGetContent = makeIndexContent({ "FP-E5": 5 });
|
|
451
|
+
|
|
452
|
+
const adapter = makeAdapter();
|
|
453
|
+
const criteria = await adapter.getCriteria("FP-E5");
|
|
454
|
+
|
|
455
|
+
expect(criteria).toHaveLength(3);
|
|
456
|
+
expect(criteria[0].criteria).toBe("First criterion");
|
|
457
|
+
expect(criteria[0].isMet).toBe(false);
|
|
458
|
+
expect(criteria[1].criteria).toBe("Second criterion");
|
|
459
|
+
expect(criteria[1].isMet).toBe(true);
|
|
460
|
+
expect(criteria[2].criteria).toBe("Third criterion");
|
|
461
|
+
expect(criteria[2].isMet).toBe(false);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("returns empty array when no criteria in body", async () => {
|
|
465
|
+
const epicBody = `Epic description without criteria
|
|
466
|
+
|
|
467
|
+
<!-- flux-meta
|
|
468
|
+
{"ref":"FP-E5","prd_ref":"FP-P1","dependencies":[]}
|
|
469
|
+
-->`;
|
|
470
|
+
mockIssuesGet[5] = makeIssue({
|
|
471
|
+
number: 5,
|
|
472
|
+
body: epicBody,
|
|
473
|
+
labels: ["flux:epic"],
|
|
474
|
+
});
|
|
475
|
+
mockGetContent = makeIndexContent({ "FP-E5": 5 });
|
|
476
|
+
|
|
477
|
+
const adapter = makeAdapter();
|
|
478
|
+
const criteria = await adapter.getCriteria("FP-E5");
|
|
479
|
+
expect(criteria).toHaveLength(0);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe("adapter.addDependency", () => {
|
|
484
|
+
test("adds dependsOnRef to flux-meta dependencies array", async () => {
|
|
485
|
+
const taskBody = `Task description
|
|
486
|
+
|
|
487
|
+
<!-- flux-meta
|
|
488
|
+
{"ref":"FP-T10","epic_ref":"FP-E5","prd_ref":"FP-P1","priority":"HIGH","dependencies":[]}
|
|
489
|
+
-->`;
|
|
490
|
+
mockIssuesGet[10] = makeIssue({
|
|
491
|
+
number: 10,
|
|
492
|
+
body: taskBody,
|
|
493
|
+
labels: ["flux:task"],
|
|
494
|
+
});
|
|
495
|
+
mockGetContent = makeIndexContent({ "FP-T10": 10 });
|
|
496
|
+
|
|
497
|
+
const adapter = makeAdapter();
|
|
498
|
+
await adapter.addDependency("FP-T10", "FP-T9");
|
|
499
|
+
|
|
500
|
+
expect(lastUpdateParams).not.toBeNull();
|
|
501
|
+
expect(lastUpdateParams.body).toContain('"FP-T9"');
|
|
502
|
+
expect(lastUpdateParams.body).toContain("dependencies");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("throws when ref and dependsOnRef are different entity types", async () => {
|
|
506
|
+
const adapter = makeAdapter();
|
|
507
|
+
await expect(adapter.addDependency("FP-T10", "FP-E5")).rejects.toThrow(
|
|
508
|
+
"Dependencies must be between entities of the same type",
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("throws on self-dependency", async () => {
|
|
513
|
+
const adapter = makeAdapter();
|
|
514
|
+
await expect(adapter.addDependency("FP-T10", "FP-T10")).rejects.toThrow(
|
|
515
|
+
"Cannot add self-dependency",
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe("adapter.removeDependency", () => {
|
|
521
|
+
test("removes the ref from the dependencies array", async () => {
|
|
522
|
+
const taskBody = `Task description
|
|
523
|
+
|
|
524
|
+
<!-- flux-meta
|
|
525
|
+
{"ref":"FP-T10","epic_ref":"FP-E5","prd_ref":"FP-P1","priority":"HIGH","dependencies":["FP-T9","FP-T8"]}
|
|
526
|
+
-->`;
|
|
527
|
+
mockIssuesGet[10] = makeIssue({
|
|
528
|
+
number: 10,
|
|
529
|
+
body: taskBody,
|
|
530
|
+
labels: ["flux:task"],
|
|
531
|
+
});
|
|
532
|
+
mockGetContent = makeIndexContent({ "FP-T10": 10 });
|
|
533
|
+
|
|
534
|
+
const adapter = makeAdapter();
|
|
535
|
+
await adapter.removeDependency("FP-T10", "FP-T9");
|
|
536
|
+
|
|
537
|
+
expect(lastUpdateParams).not.toBeNull();
|
|
538
|
+
expect(lastUpdateParams.body).not.toContain('"FP-T9"');
|
|
539
|
+
expect(lastUpdateParams.body).toContain('"FP-T8"');
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe("adapter.getDependencies", () => {
|
|
544
|
+
test("returns empty array when no dependencies in meta", async () => {
|
|
545
|
+
const taskBody = `Task description
|
|
546
|
+
|
|
547
|
+
<!-- flux-meta
|
|
548
|
+
{"ref":"FP-T10","epic_ref":"FP-E5","prd_ref":"FP-P1","priority":"MEDIUM","dependencies":[]}
|
|
549
|
+
-->`;
|
|
550
|
+
mockIssuesGet[10] = makeIssue({
|
|
551
|
+
number: 10,
|
|
552
|
+
body: taskBody,
|
|
553
|
+
labels: ["flux:task"],
|
|
554
|
+
});
|
|
555
|
+
mockGetContent = makeIndexContent({ "FP-T10": 10 });
|
|
556
|
+
|
|
557
|
+
const adapter = makeAdapter();
|
|
558
|
+
const deps = await adapter.getDependencies("FP-T10");
|
|
559
|
+
expect(deps).toEqual([]);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("returns existing dependencies", async () => {
|
|
563
|
+
const taskBody = `Task description
|
|
564
|
+
|
|
565
|
+
<!-- flux-meta
|
|
566
|
+
{"ref":"FP-T10","epic_ref":"FP-E5","prd_ref":"FP-P1","priority":"MEDIUM","dependencies":["FP-T9","FP-T8"]}
|
|
567
|
+
-->`;
|
|
568
|
+
mockIssuesGet[10] = makeIssue({
|
|
569
|
+
number: 10,
|
|
570
|
+
body: taskBody,
|
|
571
|
+
labels: ["flux:task"],
|
|
572
|
+
});
|
|
573
|
+
mockGetContent = makeIndexContent({ "FP-T10": 10 });
|
|
574
|
+
|
|
575
|
+
const adapter = makeAdapter();
|
|
576
|
+
const deps = await adapter.getDependencies("FP-T10");
|
|
577
|
+
expect(deps).toEqual(["FP-T9", "FP-T8"]);
|
|
578
|
+
});
|
|
579
|
+
});
|