@cliangdev/flux-plugin 0.2.0 → 0.3.0
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/README.md +11 -7
- package/agents/coder.md +150 -25
- package/bin/install.cjs +171 -16
- package/commands/breakdown.md +47 -10
- package/commands/dashboard.md +29 -0
- package/commands/flux.md +92 -12
- package/commands/implement.md +166 -17
- package/commands/linear.md +6 -5
- package/commands/prd.md +996 -82
- package/manifest.json +2 -1
- package/package.json +9 -11
- package/skills/flux-orchestrator/SKILL.md +11 -3
- package/skills/prd-writer/SKILL.md +761 -0
- package/skills/ux-ui-design/SKILL.md +346 -0
- package/skills/ux-ui-design/references/design-tokens.md +359 -0
- package/src/__tests__/version.test.ts +37 -0
- package/src/adapters/local/.gitkeep +0 -0
- package/src/dashboard/__tests__/api.test.ts +211 -0
- package/src/dashboard/browser.ts +35 -0
- package/src/dashboard/public/app.js +869 -0
- package/src/dashboard/public/index.html +90 -0
- package/src/dashboard/public/styles.css +807 -0
- package/src/dashboard/public/vendor/highlight.css +10 -0
- package/src/dashboard/public/vendor/highlight.min.js +8422 -0
- package/src/dashboard/public/vendor/marked.min.js +2210 -0
- package/src/dashboard/server.ts +296 -0
- package/src/dashboard/watchers.ts +83 -0
- package/src/server/__tests__/config.test.ts +163 -0
- package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
- package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
- package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
- package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
- package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
- package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
- package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
- package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
- package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
- package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
- package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
- package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
- package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
- package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
- package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
- package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
- package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
- package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
- package/src/server/adapters/factory.ts +90 -0
- package/src/server/adapters/index.ts +9 -0
- package/src/server/adapters/linear/adapter.ts +1141 -0
- package/src/server/adapters/linear/client.ts +169 -0
- package/src/server/adapters/linear/config.ts +152 -0
- package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
- package/src/server/adapters/linear/helpers/index.ts +7 -0
- package/src/server/adapters/linear/index.ts +16 -0
- package/src/server/adapters/linear/mappers/description.ts +136 -0
- package/src/server/adapters/linear/mappers/epic.ts +81 -0
- package/src/server/adapters/linear/mappers/index.ts +27 -0
- package/src/server/adapters/linear/mappers/prd.ts +178 -0
- package/src/server/adapters/linear/mappers/task.ts +82 -0
- package/src/server/adapters/linear/types.ts +264 -0
- package/src/server/adapters/local-adapter.ts +1009 -0
- package/src/server/adapters/types.ts +293 -0
- package/src/server/config.ts +73 -0
- package/src/server/db/__tests__/queries.test.ts +473 -0
- package/src/server/db/ids.ts +17 -0
- package/src/server/db/index.ts +69 -0
- package/src/server/db/queries.ts +142 -0
- package/src/server/db/refs.ts +60 -0
- package/src/server/db/schema.ts +97 -0
- package/src/server/db/sqlite.ts +10 -0
- package/src/server/index.ts +81 -0
- package/src/server/tools/__tests__/crud.test.ts +411 -0
- package/src/server/tools/__tests__/get-version.test.ts +27 -0
- package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
- package/src/server/tools/__tests__/query.test.ts +405 -0
- package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
- package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
- package/src/server/tools/configure-linear.ts +373 -0
- package/src/server/tools/create-epic.ts +44 -0
- package/src/server/tools/create-prd.ts +40 -0
- package/src/server/tools/create-task.ts +47 -0
- package/src/server/tools/criteria.ts +50 -0
- package/src/server/tools/delete-entity.ts +76 -0
- package/src/server/tools/dependencies.ts +55 -0
- package/src/server/tools/get-entity.ts +240 -0
- package/src/server/tools/get-linear-url.ts +28 -0
- package/src/server/tools/get-stats.ts +52 -0
- package/src/server/tools/get-version.ts +20 -0
- package/src/server/tools/index.ts +158 -0
- package/src/server/tools/init-project.ts +108 -0
- package/src/server/tools/query-entities.ts +167 -0
- package/src/server/tools/render-status.ts +219 -0
- package/src/server/tools/update-entity.ts +140 -0
- package/src/server/tools/update-status.ts +166 -0
- package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
- package/src/server/utils/logger.ts +9 -0
- package/src/server/utils/mcp-response.ts +254 -0
- package/src/server/utils/status-transitions.ts +160 -0
- package/src/status-line/__tests__/status-line.test.ts +215 -0
- package/src/status-line/index.ts +147 -0
- package/src/utils/__tests__/chalk-import.test.ts +32 -0
- package/src/utils/__tests__/display.test.ts +97 -0
- package/src/utils/__tests__/status-renderer.test.ts +310 -0
- package/src/utils/display.ts +62 -0
- package/src/utils/status-renderer.ts +214 -0
- package/src/version.ts +5 -0
- package/dist/server/index.js +0 -87063
- package/skills/prd-template/SKILL.md +0 -242
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
describe("Linear Config", () => {
|
|
13
|
+
const originalEnv = process.env.FLUX_PROJECT_ROOT;
|
|
14
|
+
const TEST_DIR = `${realpathSync(tmpdir())}/flux-linear-config-test-${Date.now()}`;
|
|
15
|
+
const FLUX_DIR = `${TEST_DIR}/.flux`;
|
|
16
|
+
const CONFIG_PATH = `${FLUX_DIR}/linear-config.json`;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
// Clean up any previous test directory
|
|
20
|
+
if (existsSync(TEST_DIR)) {
|
|
21
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Create test directory structure
|
|
25
|
+
mkdirSync(FLUX_DIR, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// Set project root to test directory
|
|
28
|
+
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
29
|
+
|
|
30
|
+
// Clear the config cache
|
|
31
|
+
const { config } = await import("../../config.js");
|
|
32
|
+
config.clearCache();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
// Restore original env
|
|
37
|
+
if (originalEnv !== undefined) {
|
|
38
|
+
process.env.FLUX_PROJECT_ROOT = originalEnv;
|
|
39
|
+
} else {
|
|
40
|
+
delete process.env.FLUX_PROJECT_ROOT;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Clear the config cache
|
|
44
|
+
const { config } = await import("../../config.js");
|
|
45
|
+
config.clearCache();
|
|
46
|
+
|
|
47
|
+
// Clean up test directory
|
|
48
|
+
if (existsSync(TEST_DIR)) {
|
|
49
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("linearConfigExists", () => {
|
|
54
|
+
test("returns true when config file exists", async () => {
|
|
55
|
+
writeFileSync(
|
|
56
|
+
CONFIG_PATH,
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
apiKey: "lin_api_test",
|
|
59
|
+
teamId: "TEAM-123",
|
|
60
|
+
projectId: "proj_123",
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const { linearConfigExists } = await import("../linear/config.js");
|
|
65
|
+
expect(linearConfigExists()).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns false when config file does not exist", async () => {
|
|
69
|
+
const { linearConfigExists } = await import("../linear/config.js");
|
|
70
|
+
expect(linearConfigExists()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("validateLinearConfig", () => {
|
|
75
|
+
test("validates valid config with all fields", async () => {
|
|
76
|
+
const validConfig = {
|
|
77
|
+
apiKey: "lin_api_test123",
|
|
78
|
+
teamId: "TEAM-123",
|
|
79
|
+
projectId: "proj_abc123",
|
|
80
|
+
defaultLabels: {
|
|
81
|
+
prd: "prd",
|
|
82
|
+
epic: "epic",
|
|
83
|
+
task: "task",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
88
|
+
const result = validateLinearConfig(validConfig);
|
|
89
|
+
|
|
90
|
+
expect(result.apiKey).toBe("lin_api_test123");
|
|
91
|
+
expect(result.teamId).toBe("TEAM-123");
|
|
92
|
+
expect(result.projectId).toBe("proj_abc123");
|
|
93
|
+
expect(result.defaultLabels.prd).toBe("prd");
|
|
94
|
+
expect(result.defaultLabels.epic).toBe("epic");
|
|
95
|
+
expect(result.defaultLabels.task).toBe("task");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("validates valid config with optional defaultLabels omitted", async () => {
|
|
99
|
+
const validConfig = {
|
|
100
|
+
apiKey: "lin_api_test123",
|
|
101
|
+
teamId: "TEAM-123",
|
|
102
|
+
projectId: "proj_abc123",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
106
|
+
const result = validateLinearConfig(validConfig);
|
|
107
|
+
|
|
108
|
+
expect(result.apiKey).toBe("lin_api_test123");
|
|
109
|
+
expect(result.teamId).toBe("TEAM-123");
|
|
110
|
+
expect(result.projectId).toBe("proj_abc123");
|
|
111
|
+
expect(result.defaultLabels.prd).toBe("prd");
|
|
112
|
+
expect(result.defaultLabels.epic).toBe("epic");
|
|
113
|
+
expect(result.defaultLabels.task).toBe("task");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("throws error when apiKey is missing", async () => {
|
|
117
|
+
const invalidConfig = {
|
|
118
|
+
teamId: "TEAM-123",
|
|
119
|
+
projectId: "proj_abc123",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
123
|
+
|
|
124
|
+
expect(() => validateLinearConfig(invalidConfig)).toThrow(
|
|
125
|
+
"Invalid Linear config: apiKey is required",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("throws error when teamId is missing", async () => {
|
|
130
|
+
const invalidConfig = {
|
|
131
|
+
apiKey: "lin_api_test123",
|
|
132
|
+
projectId: "proj_abc123",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
136
|
+
|
|
137
|
+
expect(() => validateLinearConfig(invalidConfig)).toThrow(
|
|
138
|
+
"Invalid Linear config: teamId is required",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("throws error when projectId is missing", async () => {
|
|
143
|
+
const invalidConfig = {
|
|
144
|
+
apiKey: "lin_api_test123",
|
|
145
|
+
teamId: "TEAM-123",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
149
|
+
|
|
150
|
+
expect(() => validateLinearConfig(invalidConfig)).toThrow(
|
|
151
|
+
"Invalid Linear config: projectId is required",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("throws error when config is not an object", async () => {
|
|
156
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
157
|
+
|
|
158
|
+
expect(() => validateLinearConfig(null)).toThrow(
|
|
159
|
+
"Invalid Linear config: must be an object",
|
|
160
|
+
);
|
|
161
|
+
expect(() => validateLinearConfig("string")).toThrow(
|
|
162
|
+
"Invalid Linear config: must be an object",
|
|
163
|
+
);
|
|
164
|
+
expect(() => validateLinearConfig(123)).toThrow(
|
|
165
|
+
"Invalid Linear config: must be an object",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("throws error when apiKey is not a string", async () => {
|
|
170
|
+
const invalidConfig = {
|
|
171
|
+
apiKey: 123,
|
|
172
|
+
teamId: "TEAM-123",
|
|
173
|
+
projectId: "proj_abc123",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
177
|
+
|
|
178
|
+
expect(() => validateLinearConfig(invalidConfig)).toThrow(
|
|
179
|
+
"Invalid Linear config: apiKey must be a string",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("throws error when teamId is not a string", async () => {
|
|
184
|
+
const invalidConfig = {
|
|
185
|
+
apiKey: "lin_api_test",
|
|
186
|
+
teamId: 123,
|
|
187
|
+
projectId: "proj_abc123",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
191
|
+
|
|
192
|
+
expect(() => validateLinearConfig(invalidConfig)).toThrow(
|
|
193
|
+
"Invalid Linear config: teamId must be a string",
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("throws error when projectId is not a string", async () => {
|
|
198
|
+
const invalidConfig = {
|
|
199
|
+
apiKey: "lin_api_test",
|
|
200
|
+
teamId: "TEAM-123",
|
|
201
|
+
projectId: 123,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
205
|
+
|
|
206
|
+
expect(() => validateLinearConfig(invalidConfig)).toThrow(
|
|
207
|
+
"Invalid Linear config: projectId must be a string",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("uses default labels when defaultLabels is partially provided", async () => {
|
|
212
|
+
const configWithPartialLabels = {
|
|
213
|
+
apiKey: "lin_api_test",
|
|
214
|
+
teamId: "TEAM-123",
|
|
215
|
+
projectId: "proj_abc123",
|
|
216
|
+
defaultLabels: {
|
|
217
|
+
epic: "custom-epic",
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
222
|
+
const result = validateLinearConfig(configWithPartialLabels);
|
|
223
|
+
|
|
224
|
+
expect(result.defaultLabels.prd).toBe("prd");
|
|
225
|
+
expect(result.defaultLabels.epic).toBe("custom-epic");
|
|
226
|
+
expect(result.defaultLabels.task).toBe("task");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("allows custom prd label", async () => {
|
|
230
|
+
const configWithCustomPrdLabel = {
|
|
231
|
+
apiKey: "lin_api_test",
|
|
232
|
+
teamId: "TEAM-123",
|
|
233
|
+
projectId: "proj_abc123",
|
|
234
|
+
defaultLabels: {
|
|
235
|
+
prd: "custom-prd",
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const { validateLinearConfig } = await import("../linear/config.js");
|
|
240
|
+
const result = validateLinearConfig(configWithCustomPrdLabel);
|
|
241
|
+
|
|
242
|
+
expect(result.defaultLabels.prd).toBe("custom-prd");
|
|
243
|
+
expect(result.defaultLabels.epic).toBe("epic");
|
|
244
|
+
expect(result.defaultLabels.task).toBe("task");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("loadLinearConfig", () => {
|
|
249
|
+
test("loads valid config from file", async () => {
|
|
250
|
+
writeFileSync(
|
|
251
|
+
CONFIG_PATH,
|
|
252
|
+
JSON.stringify({
|
|
253
|
+
apiKey: "lin_api_test123",
|
|
254
|
+
teamId: "TEAM-123",
|
|
255
|
+
projectId: "proj_abc123",
|
|
256
|
+
defaultLabels: {
|
|
257
|
+
prd: "prd",
|
|
258
|
+
epic: "epic",
|
|
259
|
+
task: "task",
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const { loadLinearConfig } = await import("../linear/config.js");
|
|
265
|
+
const config = loadLinearConfig();
|
|
266
|
+
|
|
267
|
+
expect(config.apiKey).toBe("lin_api_test123");
|
|
268
|
+
expect(config.teamId).toBe("TEAM-123");
|
|
269
|
+
expect(config.projectId).toBe("proj_abc123");
|
|
270
|
+
expect(config.defaultLabels.prd).toBe("prd");
|
|
271
|
+
expect(config.defaultLabels.epic).toBe("epic");
|
|
272
|
+
expect(config.defaultLabels.task).toBe("task");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("loads config with default labels when not provided", async () => {
|
|
276
|
+
writeFileSync(
|
|
277
|
+
CONFIG_PATH,
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
apiKey: "lin_api_test123",
|
|
280
|
+
teamId: "TEAM-123",
|
|
281
|
+
projectId: "proj_abc123",
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const { loadLinearConfig } = await import("../linear/config.js");
|
|
286
|
+
const config = loadLinearConfig();
|
|
287
|
+
|
|
288
|
+
expect(config.apiKey).toBe("lin_api_test123");
|
|
289
|
+
expect(config.teamId).toBe("TEAM-123");
|
|
290
|
+
expect(config.projectId).toBe("proj_abc123");
|
|
291
|
+
expect(config.defaultLabels.prd).toBe("prd");
|
|
292
|
+
expect(config.defaultLabels.epic).toBe("epic");
|
|
293
|
+
expect(config.defaultLabels.task).toBe("task");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("throws error when config file does not exist", async () => {
|
|
297
|
+
const { loadLinearConfig } = await import("../linear/config.js");
|
|
298
|
+
|
|
299
|
+
expect(() => loadLinearConfig()).toThrow(
|
|
300
|
+
"Linear config not found. Run configure_linear first.",
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("throws error when config file contains invalid JSON", async () => {
|
|
305
|
+
writeFileSync(CONFIG_PATH, "invalid json {");
|
|
306
|
+
|
|
307
|
+
const { loadLinearConfig } = await import("../linear/config.js");
|
|
308
|
+
|
|
309
|
+
expect(() => loadLinearConfig()).toThrow();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("throws error when config is missing required fields", async () => {
|
|
313
|
+
writeFileSync(
|
|
314
|
+
CONFIG_PATH,
|
|
315
|
+
JSON.stringify({
|
|
316
|
+
apiKey: "lin_api_test123",
|
|
317
|
+
// missing teamId and projectId
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const { loadLinearConfig } = await import("../linear/config.js");
|
|
322
|
+
|
|
323
|
+
expect(() => loadLinearConfig()).toThrow(
|
|
324
|
+
"Invalid Linear config: teamId is required",
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("saveLinearConfig", () => {
|
|
330
|
+
test("saves valid config to file", async () => {
|
|
331
|
+
const config = {
|
|
332
|
+
apiKey: "lin_api_test123",
|
|
333
|
+
teamId: "TEAM-123",
|
|
334
|
+
projectId: "proj_abc123",
|
|
335
|
+
defaultLabels: {
|
|
336
|
+
prd: "prd",
|
|
337
|
+
epic: "epic",
|
|
338
|
+
task: "task",
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const { saveLinearConfig } = await import("../linear/config.js");
|
|
343
|
+
saveLinearConfig(config);
|
|
344
|
+
|
|
345
|
+
expect(existsSync(CONFIG_PATH)).toBe(true);
|
|
346
|
+
|
|
347
|
+
const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
348
|
+
expect(saved.apiKey).toBe("lin_api_test123");
|
|
349
|
+
expect(saved.teamId).toBe("TEAM-123");
|
|
350
|
+
expect(saved.projectId).toBe("proj_abc123");
|
|
351
|
+
expect(saved.defaultLabels.prd).toBe("prd");
|
|
352
|
+
expect(saved.defaultLabels.epic).toBe("epic");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("creates .flux directory if it does not exist", async () => {
|
|
356
|
+
// Remove .flux directory
|
|
357
|
+
rmSync(FLUX_DIR, { recursive: true });
|
|
358
|
+
|
|
359
|
+
const config = {
|
|
360
|
+
apiKey: "lin_api_test123",
|
|
361
|
+
teamId: "TEAM-123",
|
|
362
|
+
projectId: "proj_abc123",
|
|
363
|
+
defaultLabels: {
|
|
364
|
+
prd: "prd",
|
|
365
|
+
epic: "epic",
|
|
366
|
+
task: "task",
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const { saveLinearConfig } = await import("../linear/config.js");
|
|
371
|
+
saveLinearConfig(config);
|
|
372
|
+
|
|
373
|
+
expect(existsSync(FLUX_DIR)).toBe(true);
|
|
374
|
+
expect(existsSync(CONFIG_PATH)).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("overwrites existing config file", async () => {
|
|
378
|
+
writeFileSync(
|
|
379
|
+
CONFIG_PATH,
|
|
380
|
+
JSON.stringify({
|
|
381
|
+
apiKey: "old_api_key",
|
|
382
|
+
teamId: "OLD-TEAM",
|
|
383
|
+
projectId: "old_proj",
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const newConfig = {
|
|
388
|
+
apiKey: "new_api_key",
|
|
389
|
+
teamId: "NEW-TEAM",
|
|
390
|
+
projectId: "new_proj",
|
|
391
|
+
defaultLabels: {
|
|
392
|
+
prd: "custom-prd",
|
|
393
|
+
epic: "custom-epic",
|
|
394
|
+
task: "custom-task",
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const { saveLinearConfig } = await import("../linear/config.js");
|
|
399
|
+
saveLinearConfig(newConfig);
|
|
400
|
+
|
|
401
|
+
const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
402
|
+
expect(saved.apiKey).toBe("new_api_key");
|
|
403
|
+
expect(saved.teamId).toBe("NEW-TEAM");
|
|
404
|
+
expect(saved.projectId).toBe("new_proj");
|
|
405
|
+
expect(saved.defaultLabels.prd).toBe("custom-prd");
|
|
406
|
+
expect(saved.defaultLabels.epic).toBe("custom-epic");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("validates config before saving", async () => {
|
|
410
|
+
const invalidConfig = {
|
|
411
|
+
apiKey: "lin_api_test",
|
|
412
|
+
// missing teamId and projectId
|
|
413
|
+
} as any;
|
|
414
|
+
|
|
415
|
+
const { saveLinearConfig } = await import("../linear/config.js");
|
|
416
|
+
|
|
417
|
+
expect(() => saveLinearConfig(invalidConfig)).toThrow(
|
|
418
|
+
"Invalid Linear config: teamId is required",
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// Ensure file was not created
|
|
422
|
+
expect(existsSync(CONFIG_PATH)).toBe(false);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
addCriterionToDescription,
|
|
4
|
+
generateCriteriaId,
|
|
5
|
+
parseCriteriaFromDescription,
|
|
6
|
+
updateCriterionInDescription,
|
|
7
|
+
} from "../linear/helpers/criteria-parser.js";
|
|
8
|
+
|
|
9
|
+
describe("criteria-parser", () => {
|
|
10
|
+
describe("generateCriteriaId", () => {
|
|
11
|
+
test("generates stable hash-based IDs", () => {
|
|
12
|
+
const id1 = generateCriteriaId("User can sign in");
|
|
13
|
+
const id2 = generateCriteriaId("User can sign in");
|
|
14
|
+
expect(id1).toBe(id2);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("generates different IDs for different text", () => {
|
|
18
|
+
const id1 = generateCriteriaId("User can sign in");
|
|
19
|
+
const id2 = generateCriteriaId("User can sign out");
|
|
20
|
+
expect(id1).not.toBe(id2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("ID format is ac_ prefix with 8 hex chars", () => {
|
|
24
|
+
const id = generateCriteriaId("Test criterion");
|
|
25
|
+
expect(id).toMatch(/^ac_[a-f0-9]{8}$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("trims whitespace for consistent IDs", () => {
|
|
29
|
+
const id1 = generateCriteriaId(" Test ");
|
|
30
|
+
const id2 = generateCriteriaId("Test");
|
|
31
|
+
expect(id1).toBe(id2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("parseCriteriaFromDescription", () => {
|
|
36
|
+
test("returns empty array for undefined description", () => {
|
|
37
|
+
const result = parseCriteriaFromDescription(undefined);
|
|
38
|
+
expect(result).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns empty array for empty description", () => {
|
|
42
|
+
const result = parseCriteriaFromDescription("");
|
|
43
|
+
expect(result).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parses unchecked checkboxes with dash", () => {
|
|
47
|
+
const description = "- [ ] First item\n- [ ] Second item";
|
|
48
|
+
const result = parseCriteriaFromDescription(description);
|
|
49
|
+
expect(result).toHaveLength(2);
|
|
50
|
+
expect(result[0].text).toBe("First item");
|
|
51
|
+
expect(result[0].isMet).toBe(false);
|
|
52
|
+
expect(result[1].text).toBe("Second item");
|
|
53
|
+
expect(result[1].isMet).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("parses checked checkboxes with lowercase x", () => {
|
|
57
|
+
const description = "- [x] Completed item";
|
|
58
|
+
const result = parseCriteriaFromDescription(description);
|
|
59
|
+
expect(result).toHaveLength(1);
|
|
60
|
+
expect(result[0].text).toBe("Completed item");
|
|
61
|
+
expect(result[0].isMet).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("parses checked checkboxes with uppercase X", () => {
|
|
65
|
+
const description = "- [X] Completed item";
|
|
66
|
+
const result = parseCriteriaFromDescription(description);
|
|
67
|
+
expect(result).toHaveLength(1);
|
|
68
|
+
expect(result[0].isMet).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("parses checkboxes with asterisk", () => {
|
|
72
|
+
const description = "* [ ] Item with asterisk";
|
|
73
|
+
const result = parseCriteriaFromDescription(description);
|
|
74
|
+
expect(result).toHaveLength(1);
|
|
75
|
+
expect(result[0].text).toBe("Item with asterisk");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("handles indented checkboxes", () => {
|
|
79
|
+
const description = " - [ ] Indented item";
|
|
80
|
+
const result = parseCriteriaFromDescription(description);
|
|
81
|
+
expect(result).toHaveLength(1);
|
|
82
|
+
expect(result[0].text).toBe("Indented item");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("parses only AC section when header is present", () => {
|
|
86
|
+
const description = `# Overview
|
|
87
|
+
Some overview text
|
|
88
|
+
|
|
89
|
+
- [ ] Not a criterion
|
|
90
|
+
|
|
91
|
+
## Acceptance Criteria
|
|
92
|
+
|
|
93
|
+
- [ ] Real criterion 1
|
|
94
|
+
- [x] Real criterion 2
|
|
95
|
+
|
|
96
|
+
## Notes
|
|
97
|
+
- [ ] Not a criterion either`;
|
|
98
|
+
|
|
99
|
+
const result = parseCriteriaFromDescription(description);
|
|
100
|
+
expect(result).toHaveLength(2);
|
|
101
|
+
expect(result[0].text).toBe("Real criterion 1");
|
|
102
|
+
expect(result[1].text).toBe("Real criterion 2");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("parses AC section with bold header", () => {
|
|
106
|
+
const description = `**Acceptance Criteria**
|
|
107
|
+
|
|
108
|
+
- [ ] Criterion 1
|
|
109
|
+
- [ ] Criterion 2
|
|
110
|
+
|
|
111
|
+
**Notes**
|
|
112
|
+
- [ ] Not a criterion`;
|
|
113
|
+
|
|
114
|
+
const result = parseCriteriaFromDescription(description);
|
|
115
|
+
expect(result).toHaveLength(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("includes line index for updates", () => {
|
|
119
|
+
const description = "Header\n\n- [ ] Item 1\n- [ ] Item 2";
|
|
120
|
+
const result = parseCriteriaFromDescription(description);
|
|
121
|
+
expect(result[0].lineIndex).toBe(2);
|
|
122
|
+
expect(result[1].lineIndex).toBe(3);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("generates correct IDs for criteria", () => {
|
|
126
|
+
const description = "- [ ] Test criterion";
|
|
127
|
+
const result = parseCriteriaFromDescription(description);
|
|
128
|
+
const expectedId = generateCriteriaId("Test criterion");
|
|
129
|
+
expect(result[0].id).toBe(expectedId);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("updateCriterionInDescription", () => {
|
|
134
|
+
test("marks criterion as met", () => {
|
|
135
|
+
const description = "- [ ] First item\n- [ ] Second item";
|
|
136
|
+
const criteriaId = generateCriteriaId("First item");
|
|
137
|
+
const result = updateCriterionInDescription(
|
|
138
|
+
description,
|
|
139
|
+
criteriaId,
|
|
140
|
+
true,
|
|
141
|
+
);
|
|
142
|
+
expect(result).toBe("- [x] First item\n- [ ] Second item");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("marks criterion as unmet", () => {
|
|
146
|
+
const description = "- [x] First item\n- [ ] Second item";
|
|
147
|
+
const criteriaId = generateCriteriaId("First item");
|
|
148
|
+
const result = updateCriterionInDescription(
|
|
149
|
+
description,
|
|
150
|
+
criteriaId,
|
|
151
|
+
false,
|
|
152
|
+
);
|
|
153
|
+
expect(result).toBe("- [ ] First item\n- [ ] Second item");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("preserves indentation", () => {
|
|
157
|
+
const description = " - [ ] Indented item";
|
|
158
|
+
const criteriaId = generateCriteriaId("Indented item");
|
|
159
|
+
const result = updateCriterionInDescription(
|
|
160
|
+
description,
|
|
161
|
+
criteriaId,
|
|
162
|
+
true,
|
|
163
|
+
);
|
|
164
|
+
expect(result).toBe(" - [x] Indented item");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("throws for non-existent criterion", () => {
|
|
168
|
+
const description = "- [ ] Item";
|
|
169
|
+
expect(() =>
|
|
170
|
+
updateCriterionInDescription(description, "ac_nonexist", true),
|
|
171
|
+
).toThrow("Criterion not found");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("updates correct criterion when multiple exist", () => {
|
|
175
|
+
const description = "- [ ] First\n- [ ] Second\n- [ ] Third";
|
|
176
|
+
const criteriaId = generateCriteriaId("Second");
|
|
177
|
+
const result = updateCriterionInDescription(
|
|
178
|
+
description,
|
|
179
|
+
criteriaId,
|
|
180
|
+
true,
|
|
181
|
+
);
|
|
182
|
+
expect(result).toBe("- [ ] First\n- [x] Second\n- [ ] Third");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("addCriterionToDescription", () => {
|
|
187
|
+
test("creates AC section for undefined description", () => {
|
|
188
|
+
const result = addCriterionToDescription(undefined, "New criterion");
|
|
189
|
+
expect(result).toBe("## Acceptance Criteria\n\n- [ ] New criterion");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("creates AC section for empty description", () => {
|
|
193
|
+
const result = addCriterionToDescription("", "New criterion");
|
|
194
|
+
expect(result).toContain("## Acceptance Criteria");
|
|
195
|
+
expect(result).toContain("- [ ] New criterion");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("adds to existing AC section", () => {
|
|
199
|
+
const description = `## Acceptance Criteria
|
|
200
|
+
|
|
201
|
+
- [ ] Existing criterion`;
|
|
202
|
+
|
|
203
|
+
const result = addCriterionToDescription(description, "New criterion");
|
|
204
|
+
expect(result).toContain("- [ ] Existing criterion");
|
|
205
|
+
expect(result).toContain("- [ ] New criterion");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("inserts after last checkbox in AC section", () => {
|
|
209
|
+
const description = `## Acceptance Criteria
|
|
210
|
+
|
|
211
|
+
- [ ] First
|
|
212
|
+
- [ ] Second
|
|
213
|
+
|
|
214
|
+
## Notes
|
|
215
|
+
Some notes`;
|
|
216
|
+
|
|
217
|
+
const result = addCriterionToDescription(description, "Third");
|
|
218
|
+
const lines = result.split("\n");
|
|
219
|
+
const thirdIndex = lines.findIndex((l) => l.includes("Third"));
|
|
220
|
+
const notesIndex = lines.findIndex((l) => l.includes("## Notes"));
|
|
221
|
+
expect(thirdIndex).toBeLessThan(notesIndex);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("preserves description content before AC section", () => {
|
|
225
|
+
const description = `# Overview
|
|
226
|
+
|
|
227
|
+
This is an overview.
|
|
228
|
+
|
|
229
|
+
## Acceptance Criteria
|
|
230
|
+
|
|
231
|
+
- [ ] Existing`;
|
|
232
|
+
|
|
233
|
+
const result = addCriterionToDescription(description, "New");
|
|
234
|
+
expect(result).toContain("# Overview");
|
|
235
|
+
expect(result).toContain("This is an overview.");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("creates AC section at end if none exists", () => {
|
|
239
|
+
const description = `# Overview
|
|
240
|
+
|
|
241
|
+
Some content here.`;
|
|
242
|
+
|
|
243
|
+
const result = addCriterionToDescription(description, "New criterion");
|
|
244
|
+
expect(result).toContain("# Overview");
|
|
245
|
+
expect(result).toContain("Some content here.");
|
|
246
|
+
expect(result).toContain("## Acceptance Criteria");
|
|
247
|
+
expect(result).toContain("- [ ] New criterion");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("integration: parse, update, parse cycle", () => {
|
|
252
|
+
test("round-trip preserves structure", () => {
|
|
253
|
+
const original = `## Acceptance Criteria
|
|
254
|
+
|
|
255
|
+
- [ ] User can login
|
|
256
|
+
- [ ] User can logout
|
|
257
|
+
- [ ] Session persists`;
|
|
258
|
+
|
|
259
|
+
const parsed = parseCriteriaFromDescription(original);
|
|
260
|
+
expect(parsed).toHaveLength(3);
|
|
261
|
+
|
|
262
|
+
const updated = updateCriterionInDescription(
|
|
263
|
+
original,
|
|
264
|
+
parsed[1].id,
|
|
265
|
+
true,
|
|
266
|
+
);
|
|
267
|
+
const reParsed = parseCriteriaFromDescription(updated);
|
|
268
|
+
|
|
269
|
+
expect(reParsed).toHaveLength(3);
|
|
270
|
+
expect(reParsed[0].isMet).toBe(false);
|
|
271
|
+
expect(reParsed[1].isMet).toBe(true);
|
|
272
|
+
expect(reParsed[2].isMet).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("add and parse cycle", () => {
|
|
276
|
+
let description = "## Overview\n\nSome text";
|
|
277
|
+
|
|
278
|
+
description = addCriterionToDescription(description, "First");
|
|
279
|
+
description = addCriterionToDescription(description, "Second");
|
|
280
|
+
|
|
281
|
+
const parsed = parseCriteriaFromDescription(description);
|
|
282
|
+
expect(parsed).toHaveLength(2);
|
|
283
|
+
expect(parsed.map((c) => c.text)).toContain("First");
|
|
284
|
+
expect(parsed.map((c) => c.text)).toContain("Second");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|