@cliangdev/flux-plugin 0.1.0 → 0.2.0-dev.2b9c207
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 +55 -22
- package/agents/coder.md +317 -0
- package/agents/critic.md +174 -0
- package/agents/researcher.md +146 -0
- package/agents/verifier.md +149 -0
- package/bin/install.cjs +369 -0
- package/commands/breakdown.md +48 -10
- package/commands/flux.md +218 -94
- package/commands/implement.md +167 -17
- package/commands/linear.md +172 -0
- package/commands/prd.md +997 -82
- package/manifest.json +15 -0
- package/package.json +15 -11
- package/skills/agent-creator/SKILL.md +2 -0
- package/skills/epic-template/SKILL.md +2 -0
- package/skills/flux-orchestrator/SKILL.md +68 -76
- package/skills/prd-writer/SKILL.md +761 -0
- package/src/__tests__/version.test.ts +37 -0
- package/src/adapters/local/.gitkeep +0 -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 +1003 -0
- package/src/server/adapters/types.ts +293 -0
- package/src/server/config.ts +73 -0
- package/src/server/db/__tests__/queries.test.ts +472 -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 +410 -0
- package/src/server/tools/__tests__/get-version.test.ts +27 -0
- package/src/server/tools/__tests__/mcp-interface.test.ts +478 -0
- package/src/server/tools/__tests__/query.test.ts +403 -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 -86929
- package/skills/prd-template/SKILL.md +0 -240
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
describe("version module", () => {
|
|
6
|
+
test("VERSION is exported from src/version.ts", async () => {
|
|
7
|
+
// Dynamic import to get the VERSION constant
|
|
8
|
+
const versionModule = await import("../version.js");
|
|
9
|
+
|
|
10
|
+
expect(versionModule.VERSION).toBeDefined();
|
|
11
|
+
expect(typeof versionModule.VERSION).toBe("string");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("VERSION equals package.json version", async () => {
|
|
15
|
+
// Read package.json version
|
|
16
|
+
const packageJsonPath = join(process.cwd(), "package.json");
|
|
17
|
+
expect(existsSync(packageJsonPath)).toBe(true);
|
|
18
|
+
|
|
19
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
20
|
+
const packageVersion = packageJson.version;
|
|
21
|
+
|
|
22
|
+
expect(packageVersion).toBeDefined();
|
|
23
|
+
expect(typeof packageVersion).toBe("string");
|
|
24
|
+
|
|
25
|
+
// Import VERSION and compare
|
|
26
|
+
const versionModule = await import("../version.js");
|
|
27
|
+
expect(versionModule.VERSION).toBe(packageVersion);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("VERSION is a valid semver format", async () => {
|
|
31
|
+
const versionModule = await import("../version.js");
|
|
32
|
+
|
|
33
|
+
// Basic semver format check (e.g., "0.1.0", "1.2.3-beta.1")
|
|
34
|
+
const semverPattern = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
|
|
35
|
+
expect(semverPattern.test(versionModule.VERSION)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
describe("config", () => {
|
|
12
|
+
const originalEnv = process.env.FLUX_PROJECT_ROOT;
|
|
13
|
+
const originalCwd = process.cwd();
|
|
14
|
+
// Use os.tmpdir() to get the real temp path (handles /tmp -> /private/tmp on macOS)
|
|
15
|
+
const TEST_DIR = `${realpathSync(tmpdir())}/flux-config-test-${Date.now()}`;
|
|
16
|
+
const NESTED_DIR = `${TEST_DIR}/subdir/nested`;
|
|
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(NESTED_DIR, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// Clear the config cache before each test
|
|
28
|
+
const { config } = await import("../config.js");
|
|
29
|
+
config.clearCache();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
// Restore original env
|
|
34
|
+
if (originalEnv !== undefined) {
|
|
35
|
+
process.env.FLUX_PROJECT_ROOT = originalEnv;
|
|
36
|
+
} else {
|
|
37
|
+
delete process.env.FLUX_PROJECT_ROOT;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Restore original cwd
|
|
41
|
+
process.chdir(originalCwd);
|
|
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
|
+
test("uses env var when properly set", async () => {
|
|
54
|
+
process.env.FLUX_PROJECT_ROOT = "/some/valid/path";
|
|
55
|
+
|
|
56
|
+
const { config } = await import("../config.js");
|
|
57
|
+
config.clearCache();
|
|
58
|
+
|
|
59
|
+
expect(config.projectRoot).toBe("/some/valid/path");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("ignores unresolved template variable and walks up directories", async () => {
|
|
63
|
+
// Create a .flux folder at TEST_DIR
|
|
64
|
+
mkdirSync(`${TEST_DIR}/.flux`, { recursive: true });
|
|
65
|
+
writeFileSync(
|
|
66
|
+
`${TEST_DIR}/.flux/project.json`,
|
|
67
|
+
JSON.stringify({ name: "test" }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Simulate Claude Code passing unresolved template variable
|
|
71
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentionally testing literal template string
|
|
72
|
+
process.env.FLUX_PROJECT_ROOT = "${CLAUDE_PROJECT_DIR}";
|
|
73
|
+
|
|
74
|
+
// Change to nested directory
|
|
75
|
+
process.chdir(NESTED_DIR);
|
|
76
|
+
|
|
77
|
+
const { config } = await import("../config.js");
|
|
78
|
+
config.clearCache();
|
|
79
|
+
|
|
80
|
+
// Should walk up and find TEST_DIR, not use the literal "${CLAUDE_PROJECT_DIR}"
|
|
81
|
+
expect(config.projectRoot).not.toContain("${");
|
|
82
|
+
expect(config.projectRoot).toBe(TEST_DIR);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("walks up directories to find .flux folder", async () => {
|
|
86
|
+
// Create a .flux folder at TEST_DIR (parent of NESTED_DIR)
|
|
87
|
+
mkdirSync(`${TEST_DIR}/.flux`, { recursive: true });
|
|
88
|
+
writeFileSync(
|
|
89
|
+
`${TEST_DIR}/.flux/project.json`,
|
|
90
|
+
JSON.stringify({ name: "test" }),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// No env var set
|
|
94
|
+
delete process.env.FLUX_PROJECT_ROOT;
|
|
95
|
+
|
|
96
|
+
// Change to nested directory
|
|
97
|
+
process.chdir(NESTED_DIR);
|
|
98
|
+
|
|
99
|
+
const { config } = await import("../config.js");
|
|
100
|
+
config.clearCache();
|
|
101
|
+
|
|
102
|
+
// Should walk up and find TEST_DIR
|
|
103
|
+
expect(config.projectRoot).toBe(TEST_DIR);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("falls back to cwd when no .flux folder found", async () => {
|
|
107
|
+
// No .flux folder anywhere in TEST_DIR hierarchy
|
|
108
|
+
// No env var set
|
|
109
|
+
delete process.env.FLUX_PROJECT_ROOT;
|
|
110
|
+
|
|
111
|
+
// Change to test directory (which has no .flux)
|
|
112
|
+
process.chdir(TEST_DIR);
|
|
113
|
+
|
|
114
|
+
const { config } = await import("../config.js");
|
|
115
|
+
config.clearCache();
|
|
116
|
+
|
|
117
|
+
// Should fall back to cwd
|
|
118
|
+
expect(config.projectRoot).toBe(TEST_DIR);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("projectExists returns true when project.json exists", async () => {
|
|
122
|
+
mkdirSync(`${TEST_DIR}/.flux`, { recursive: true });
|
|
123
|
+
writeFileSync(
|
|
124
|
+
`${TEST_DIR}/.flux/project.json`,
|
|
125
|
+
JSON.stringify({ name: "test" }),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
129
|
+
|
|
130
|
+
const { config } = await import("../config.js");
|
|
131
|
+
config.clearCache();
|
|
132
|
+
|
|
133
|
+
expect(config.projectExists).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("projectExists returns false when project.json does not exist", async () => {
|
|
137
|
+
// No .flux folder
|
|
138
|
+
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
139
|
+
|
|
140
|
+
const { config } = await import("../config.js");
|
|
141
|
+
config.clearCache();
|
|
142
|
+
|
|
143
|
+
expect(config.projectExists).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("caches project root for performance", async () => {
|
|
147
|
+
process.env.FLUX_PROJECT_ROOT = "/first/path";
|
|
148
|
+
|
|
149
|
+
const { config } = await import("../config.js");
|
|
150
|
+
config.clearCache();
|
|
151
|
+
|
|
152
|
+
// First call caches the value
|
|
153
|
+
expect(config.projectRoot).toBe("/first/path");
|
|
154
|
+
|
|
155
|
+
// Changing env var should not affect cached value
|
|
156
|
+
process.env.FLUX_PROJECT_ROOT = "/second/path";
|
|
157
|
+
expect(config.projectRoot).toBe("/first/path");
|
|
158
|
+
|
|
159
|
+
// After clearing cache, should use new env var
|
|
160
|
+
config.clearCache();
|
|
161
|
+
expect(config.projectRoot).toBe("/second/path");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { LinearApiError, LinearClient } from "../linear/client.js";
|
|
3
|
+
|
|
4
|
+
const config = {
|
|
5
|
+
apiKey: "lin_api_test123",
|
|
6
|
+
teamId: "TEAM-123",
|
|
7
|
+
projectId: "proj_container",
|
|
8
|
+
defaultLabels: { prd: "prd", epic: "epic", task: "task" },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("LinearClient", () => {
|
|
12
|
+
describe("constructor", () => {
|
|
13
|
+
test("creates client with valid config", () => {
|
|
14
|
+
const client = new LinearClient(config);
|
|
15
|
+
|
|
16
|
+
expect(client).toBeDefined();
|
|
17
|
+
expect(client.client).toBeDefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("accepts custom retry options", () => {
|
|
21
|
+
const client = new LinearClient(config, {
|
|
22
|
+
maxRetries: 5,
|
|
23
|
+
baseDelay: 500,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(client).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("execute - retry logic", () => {
|
|
31
|
+
test("returns result on successful operation", async () => {
|
|
32
|
+
const client = new LinearClient(config);
|
|
33
|
+
let callCount = 0;
|
|
34
|
+
const operation = () => {
|
|
35
|
+
callCount++;
|
|
36
|
+
return Promise.resolve({ data: "success" });
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const result = await client.execute(operation);
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual({ data: "success" });
|
|
42
|
+
expect(callCount).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("retries on network error and succeeds", async () => {
|
|
46
|
+
const client = new LinearClient(config, { maxRetries: 3, baseDelay: 10 });
|
|
47
|
+
let attempts = 0;
|
|
48
|
+
const operation = () => {
|
|
49
|
+
attempts++;
|
|
50
|
+
if (attempts < 3) {
|
|
51
|
+
const error: any = new Error("Network error");
|
|
52
|
+
error.code = "ECONNRESET";
|
|
53
|
+
return Promise.reject(error);
|
|
54
|
+
}
|
|
55
|
+
return Promise.resolve({ data: "success" });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const result = await client.execute(operation);
|
|
59
|
+
|
|
60
|
+
expect(result).toEqual({ data: "success" });
|
|
61
|
+
expect(attempts).toBe(3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("retries on rate limit error (429) and succeeds", async () => {
|
|
65
|
+
const client = new LinearClient(config, { maxRetries: 3, baseDelay: 10 });
|
|
66
|
+
let attempts = 0;
|
|
67
|
+
const operation = () => {
|
|
68
|
+
attempts++;
|
|
69
|
+
if (attempts < 2) {
|
|
70
|
+
const error: any = new Error("Rate limited");
|
|
71
|
+
error.status = 429;
|
|
72
|
+
return Promise.reject(error);
|
|
73
|
+
}
|
|
74
|
+
return Promise.resolve({ data: "success" });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result = await client.execute(operation);
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual({ data: "success" });
|
|
80
|
+
expect(attempts).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("throws LinearApiError after max retries exhausted (rate limit)", async () => {
|
|
84
|
+
const client = new LinearClient(config, { maxRetries: 2, baseDelay: 10 });
|
|
85
|
+
const operation = () => {
|
|
86
|
+
const error: any = new Error("Rate limited");
|
|
87
|
+
error.status = 429;
|
|
88
|
+
return Promise.reject(error);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
await expect(client.execute(operation)).rejects.toThrow(LinearApiError);
|
|
92
|
+
await expect(client.execute(operation)).rejects.toThrow(
|
|
93
|
+
"Rate limited after 2 retries",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("throws LinearApiError after max retries exhausted (network error)", async () => {
|
|
98
|
+
const client = new LinearClient(config, { maxRetries: 2, baseDelay: 10 });
|
|
99
|
+
const operation = () => {
|
|
100
|
+
const error: any = new Error("Connection failed");
|
|
101
|
+
error.code = "ECONNREFUSED";
|
|
102
|
+
return Promise.reject(error);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await expect(client.execute(operation)).rejects.toThrow(LinearApiError);
|
|
106
|
+
await expect(client.execute(operation)).rejects.toThrow(
|
|
107
|
+
"Network error after 2 retries",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("throws immediately on unauthorized error (401)", async () => {
|
|
112
|
+
const client = new LinearClient(config);
|
|
113
|
+
const operation = () => {
|
|
114
|
+
const error: any = new Error("Unauthorized");
|
|
115
|
+
error.status = 401;
|
|
116
|
+
return Promise.reject(error);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
await expect(client.execute(operation)).rejects.toThrow(LinearApiError);
|
|
120
|
+
await expect(client.execute(operation)).rejects.toThrow(
|
|
121
|
+
"Unauthorized: Invalid API key",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("throws immediately on other API errors", async () => {
|
|
126
|
+
const client = new LinearClient(config);
|
|
127
|
+
let callCount = 0;
|
|
128
|
+
const operation = () => {
|
|
129
|
+
callCount++;
|
|
130
|
+
const error: any = new Error("Bad request");
|
|
131
|
+
error.status = 400;
|
|
132
|
+
return Promise.reject(error);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
await expect(client.execute(operation)).rejects.toThrow(LinearApiError);
|
|
136
|
+
expect(callCount).toBe(1); // No retries for non-retryable errors
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("LinearApiError", () => {
|
|
141
|
+
test("creates error with all properties", () => {
|
|
142
|
+
const originalError = new Error("Original error");
|
|
143
|
+
const error = new LinearApiError(
|
|
144
|
+
"Test error message",
|
|
145
|
+
"TEST_CODE",
|
|
146
|
+
500,
|
|
147
|
+
originalError,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(error).toBeInstanceOf(Error);
|
|
151
|
+
expect(error.name).toBe("LinearApiError");
|
|
152
|
+
expect(error.message).toBe("Test error message");
|
|
153
|
+
expect(error.code).toBe("TEST_CODE");
|
|
154
|
+
expect(error.statusCode).toBe(500);
|
|
155
|
+
expect(error.originalError).toBe(originalError);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("creates error without optional fields", () => {
|
|
159
|
+
const error = new LinearApiError("Test error", "TEST_CODE");
|
|
160
|
+
|
|
161
|
+
expect(error.message).toBe("Test error");
|
|
162
|
+
expect(error.code).toBe("TEST_CODE");
|
|
163
|
+
expect(error.statusCode).toBeUndefined();
|
|
164
|
+
expect(error.originalError).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("exponential backoff", () => {
|
|
169
|
+
test("applies exponential backoff with jitter", async () => {
|
|
170
|
+
const client = new LinearClient(config, {
|
|
171
|
+
maxRetries: 3,
|
|
172
|
+
baseDelay: 100,
|
|
173
|
+
});
|
|
174
|
+
const startTime = Date.now();
|
|
175
|
+
let attempts = 0;
|
|
176
|
+
|
|
177
|
+
const operation = () => {
|
|
178
|
+
attempts++;
|
|
179
|
+
if (attempts <= 3) {
|
|
180
|
+
const error: any = new Error("Rate limited");
|
|
181
|
+
error.status = 429;
|
|
182
|
+
return Promise.reject(error);
|
|
183
|
+
}
|
|
184
|
+
return Promise.resolve({ data: "success" });
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const result = await client.execute(operation);
|
|
188
|
+
|
|
189
|
+
const elapsed = Date.now() - startTime;
|
|
190
|
+
|
|
191
|
+
// Expected delays: ~100ms, ~200ms, ~400ms (with jitter ±10%)
|
|
192
|
+
// Total: ~700ms minimum
|
|
193
|
+
expect(elapsed).toBeGreaterThanOrEqual(630); // 700ms - 10% jitter
|
|
194
|
+
expect(result).toEqual({ data: "success" });
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
describe("Adapter Factory", () => {
|
|
12
|
+
const originalEnv = process.env.FLUX_PROJECT_ROOT;
|
|
13
|
+
const TEST_DIR = `${realpathSync(tmpdir())}/flux-factory-test-${Date.now()}`;
|
|
14
|
+
const FLUX_DIR = `${TEST_DIR}/.flux`;
|
|
15
|
+
const PROJECT_JSON_PATH = `${FLUX_DIR}/project.json`;
|
|
16
|
+
const LINEAR_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 caches
|
|
31
|
+
const { config } = await import("../../config.js");
|
|
32
|
+
config.clearCache();
|
|
33
|
+
|
|
34
|
+
const { clearAdapterCache } = await import("../factory.js");
|
|
35
|
+
clearAdapterCache();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
// Restore original env
|
|
40
|
+
if (originalEnv !== undefined) {
|
|
41
|
+
process.env.FLUX_PROJECT_ROOT = originalEnv;
|
|
42
|
+
} else {
|
|
43
|
+
delete process.env.FLUX_PROJECT_ROOT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Clear caches
|
|
47
|
+
const { config } = await import("../../config.js");
|
|
48
|
+
config.clearCache();
|
|
49
|
+
|
|
50
|
+
const { clearAdapterCache } = await import("../factory.js");
|
|
51
|
+
clearAdapterCache();
|
|
52
|
+
|
|
53
|
+
// Clean up test directory
|
|
54
|
+
if (existsSync(TEST_DIR)) {
|
|
55
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("getAdapter with default configuration", () => {
|
|
60
|
+
test("returns LocalAdapter when no project.json exists", async () => {
|
|
61
|
+
const { getAdapter } = await import("../factory.js");
|
|
62
|
+
const { LocalAdapter } = await import("../local-adapter.js");
|
|
63
|
+
|
|
64
|
+
const adapter = getAdapter();
|
|
65
|
+
|
|
66
|
+
expect(adapter).toBeInstanceOf(LocalAdapter);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns LocalAdapter when project.json has no adapter config", async () => {
|
|
70
|
+
writeFileSync(
|
|
71
|
+
PROJECT_JSON_PATH,
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
name: "test-project",
|
|
74
|
+
// No adapter config - should default to local
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const { getAdapter } = await import("../factory.js");
|
|
79
|
+
const { LocalAdapter } = await import("../local-adapter.js");
|
|
80
|
+
|
|
81
|
+
const adapter = getAdapter(true); // Force new adapter to pick up config
|
|
82
|
+
|
|
83
|
+
expect(adapter).toBeInstanceOf(LocalAdapter);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("returns LocalAdapter when project.json explicitly sets adapter.type='local'", async () => {
|
|
87
|
+
writeFileSync(
|
|
88
|
+
PROJECT_JSON_PATH,
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
name: "test-project",
|
|
91
|
+
adapter: { type: "local" },
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const { getAdapter } = await import("../factory.js");
|
|
96
|
+
const { LocalAdapter } = await import("../local-adapter.js");
|
|
97
|
+
|
|
98
|
+
const adapter = getAdapter(true);
|
|
99
|
+
|
|
100
|
+
expect(adapter).toBeInstanceOf(LocalAdapter);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("getAdapter with Linear configuration", () => {
|
|
105
|
+
test("returns LinearAdapter when project.json has adapter.type='linear' and config exists", async () => {
|
|
106
|
+
// Create project.json with Linear adapter
|
|
107
|
+
writeFileSync(
|
|
108
|
+
PROJECT_JSON_PATH,
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
name: "test-project",
|
|
111
|
+
adapter: { type: "linear" },
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Create Linear config file
|
|
116
|
+
writeFileSync(
|
|
117
|
+
LINEAR_CONFIG_PATH,
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
apiKey: "lin_api_test_key",
|
|
120
|
+
teamId: "TEAM-123",
|
|
121
|
+
projectId: "proj_container",
|
|
122
|
+
defaultLabels: {
|
|
123
|
+
prd: "prd",
|
|
124
|
+
epic: "epic",
|
|
125
|
+
task: "task",
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const { getAdapter } = await import("../factory.js");
|
|
131
|
+
const { LinearAdapter } = await import("../linear/adapter.js");
|
|
132
|
+
|
|
133
|
+
const adapter = getAdapter(true);
|
|
134
|
+
|
|
135
|
+
expect(adapter).toBeInstanceOf(LinearAdapter);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("throws error when adapter.type='linear' but no config file exists", async () => {
|
|
139
|
+
// Create project.json with Linear adapter
|
|
140
|
+
writeFileSync(
|
|
141
|
+
PROJECT_JSON_PATH,
|
|
142
|
+
JSON.stringify({
|
|
143
|
+
name: "test-project",
|
|
144
|
+
adapter: { type: "linear" },
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Don't create Linear config file
|
|
149
|
+
|
|
150
|
+
const { getAdapter } = await import("../factory.js");
|
|
151
|
+
|
|
152
|
+
expect(() => getAdapter(true)).toThrow(
|
|
153
|
+
"Linear adapter configured but no configuration found. Run configure_linear tool first.",
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("adapter caching", () => {
|
|
159
|
+
test("returns same instance when called multiple times without forceNew", async () => {
|
|
160
|
+
const { getAdapter } = await import("../factory.js");
|
|
161
|
+
|
|
162
|
+
const adapter1 = getAdapter();
|
|
163
|
+
const adapter2 = getAdapter();
|
|
164
|
+
|
|
165
|
+
expect(adapter1).toBe(adapter2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("returns new instance when forceNew=true", async () => {
|
|
169
|
+
const { getAdapter } = await import("../factory.js");
|
|
170
|
+
|
|
171
|
+
const adapter1 = getAdapter();
|
|
172
|
+
const adapter2 = getAdapter(true);
|
|
173
|
+
|
|
174
|
+
expect(adapter1).not.toBe(adapter2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("clearAdapterCache forces new instance on next getAdapter call", async () => {
|
|
178
|
+
const { getAdapter, clearAdapterCache } = await import("../factory.js");
|
|
179
|
+
|
|
180
|
+
const adapter1 = getAdapter();
|
|
181
|
+
clearAdapterCache();
|
|
182
|
+
const adapter2 = getAdapter();
|
|
183
|
+
|
|
184
|
+
expect(adapter1).not.toBe(adapter2);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("createAdapter with explicit config", () => {
|
|
189
|
+
test("creates LocalAdapter when type='local'", async () => {
|
|
190
|
+
const { createAdapter } = await import("../factory.js");
|
|
191
|
+
const { LocalAdapter } = await import("../local-adapter.js");
|
|
192
|
+
|
|
193
|
+
const adapter = createAdapter({ type: "local" });
|
|
194
|
+
|
|
195
|
+
expect(adapter).toBeInstanceOf(LocalAdapter);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("creates LinearAdapter when type='linear' and config exists", async () => {
|
|
199
|
+
// Create Linear config file
|
|
200
|
+
writeFileSync(
|
|
201
|
+
LINEAR_CONFIG_PATH,
|
|
202
|
+
JSON.stringify({
|
|
203
|
+
apiKey: "lin_api_test_key",
|
|
204
|
+
teamId: "TEAM-123",
|
|
205
|
+
projectId: "proj_container",
|
|
206
|
+
defaultLabels: {
|
|
207
|
+
prd: "prd",
|
|
208
|
+
epic: "epic",
|
|
209
|
+
task: "task",
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const { createAdapter } = await import("../factory.js");
|
|
215
|
+
const { LinearAdapter } = await import("../linear/adapter.js");
|
|
216
|
+
|
|
217
|
+
const adapter = createAdapter({ type: "linear" });
|
|
218
|
+
|
|
219
|
+
expect(adapter).toBeInstanceOf(LinearAdapter);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("throws error when type='linear' but no config exists", async () => {
|
|
223
|
+
const { createAdapter } = await import("../factory.js");
|
|
224
|
+
|
|
225
|
+
expect(() => createAdapter({ type: "linear" })).toThrow(
|
|
226
|
+
"Linear adapter configured but no configuration found. Run configure_linear tool first.",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|