@cliangdev/flux-plugin 0.1.0 → 0.2.0-dev.dc5e2c4

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.
Files changed (100) hide show
  1. package/README.md +54 -21
  2. package/agents/coder.md +192 -0
  3. package/agents/critic.md +174 -0
  4. package/agents/researcher.md +146 -0
  5. package/agents/verifier.md +149 -0
  6. package/bin/install.cjs +369 -0
  7. package/commands/breakdown.md +1 -0
  8. package/commands/flux.md +128 -84
  9. package/commands/implement.md +1 -0
  10. package/commands/linear.md +171 -0
  11. package/commands/prd.md +1 -0
  12. package/manifest.json +15 -0
  13. package/package.json +15 -11
  14. package/skills/agent-creator/SKILL.md +2 -0
  15. package/skills/epic-template/SKILL.md +2 -0
  16. package/skills/flux-orchestrator/SKILL.md +60 -76
  17. package/skills/prd-template/SKILL.md +2 -0
  18. package/src/__tests__/version.test.ts +37 -0
  19. package/src/adapters/local/.gitkeep +0 -0
  20. package/src/server/__tests__/config.test.ts +163 -0
  21. package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
  22. package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
  23. package/src/server/adapters/__tests__/dependency-ops.test.ts +395 -0
  24. package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
  25. package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
  26. package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
  27. package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
  28. package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
  29. package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
  30. package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
  31. package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
  32. package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
  33. package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
  34. package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
  35. package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
  36. package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
  37. package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
  38. package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
  39. package/src/server/adapters/factory.ts +90 -0
  40. package/src/server/adapters/index.ts +9 -0
  41. package/src/server/adapters/linear/adapter.ts +1136 -0
  42. package/src/server/adapters/linear/client.ts +169 -0
  43. package/src/server/adapters/linear/config.ts +152 -0
  44. package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
  45. package/src/server/adapters/linear/helpers/index.ts +7 -0
  46. package/src/server/adapters/linear/index.ts +16 -0
  47. package/src/server/adapters/linear/mappers/description.ts +136 -0
  48. package/src/server/adapters/linear/mappers/epic.ts +81 -0
  49. package/src/server/adapters/linear/mappers/index.ts +27 -0
  50. package/src/server/adapters/linear/mappers/prd.ts +178 -0
  51. package/src/server/adapters/linear/mappers/task.ts +82 -0
  52. package/src/server/adapters/linear/types.ts +264 -0
  53. package/src/server/adapters/local-adapter.ts +968 -0
  54. package/src/server/adapters/types.ts +293 -0
  55. package/src/server/config.ts +73 -0
  56. package/src/server/db/__tests__/queries.test.ts +472 -0
  57. package/src/server/db/ids.ts +17 -0
  58. package/src/server/db/index.ts +69 -0
  59. package/src/server/db/queries.ts +142 -0
  60. package/src/server/db/refs.ts +60 -0
  61. package/src/server/db/schema.ts +88 -0
  62. package/src/server/db/sqlite.ts +10 -0
  63. package/src/server/index.ts +83 -0
  64. package/src/server/tools/__tests__/crud.test.ts +301 -0
  65. package/src/server/tools/__tests__/get-version.test.ts +27 -0
  66. package/src/server/tools/__tests__/mcp-interface.test.ts +388 -0
  67. package/src/server/tools/__tests__/query.test.ts +353 -0
  68. package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
  69. package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
  70. package/src/server/tools/configure-linear.ts +373 -0
  71. package/src/server/tools/create-epic.ts +35 -0
  72. package/src/server/tools/create-prd.ts +31 -0
  73. package/src/server/tools/create-task.ts +38 -0
  74. package/src/server/tools/criteria.ts +50 -0
  75. package/src/server/tools/delete-entity.ts +76 -0
  76. package/src/server/tools/dependencies.ts +55 -0
  77. package/src/server/tools/get-entity.ts +238 -0
  78. package/src/server/tools/get-linear-url.ts +28 -0
  79. package/src/server/tools/get-project-context.ts +33 -0
  80. package/src/server/tools/get-stats.ts +52 -0
  81. package/src/server/tools/get-version.ts +20 -0
  82. package/src/server/tools/index.ts +114 -0
  83. package/src/server/tools/init-project.ts +108 -0
  84. package/src/server/tools/query-entities.ts +167 -0
  85. package/src/server/tools/render-status.ts +201 -0
  86. package/src/server/tools/update-entity.ts +140 -0
  87. package/src/server/tools/update-status.ts +166 -0
  88. package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
  89. package/src/server/utils/logger.ts +9 -0
  90. package/src/server/utils/mcp-response.ts +254 -0
  91. package/src/server/utils/status-transitions.ts +160 -0
  92. package/src/status-line/__tests__/status-line.test.ts +215 -0
  93. package/src/status-line/index.ts +147 -0
  94. package/src/utils/__tests__/chalk-import.test.ts +32 -0
  95. package/src/utils/__tests__/display.test.ts +97 -0
  96. package/src/utils/__tests__/status-renderer.test.ts +310 -0
  97. package/src/utils/display.ts +62 -0
  98. package/src/utils/status-renderer.ts +188 -0
  99. package/src/version.ts +5 -0
  100. package/dist/server/index.js +0 -86929
@@ -1,21 +1,25 @@
1
1
  ---
2
+ name: flux:flux-orchestrator
2
3
  description: Orchestrates Flux workflows based on project context
4
+ user-invocable: false
3
5
  ---
4
6
 
5
7
  # Flux Orchestrator Skill
6
8
 
7
- This skill is automatically active when working in a Flux project. It provides context about available tools and workflow patterns.
9
+ This skill is automatically active when working in a Flux project. It provides context about available tools, workflow patterns, and integration options.
8
10
 
9
11
  ## Available MCP Tools
10
12
 
11
13
  ### Query Tools
12
- - `get_project_context` - Check if project initialized, get name/vision/prefix
14
+ - `get_project_context` - Check if project initialized, get name/vision/adapter type
13
15
  - `get_stats` - Get PRD/epic/task counts by status
14
16
  - `get_entity` - Fetch entity by ref with optional includes (criteria, tasks, dependencies)
15
17
  - `query_entities` - Search entities by type, status, parent ref
18
+ - `render_status` - Get formatted project status with progress bars
16
19
 
17
20
  ### Mutation Tools
18
21
  - `init_project` - Initialize new .flux/ directory with project.json and database
22
+ - `configure_linear` - Configure Linear integration (supports interactive mode)
19
23
  - `create_prd` - Create a new PRD
20
24
  - `create_epic` - Create an epic linked to a PRD
21
25
  - `create_task` - Create a task linked to an epic
@@ -29,102 +33,82 @@ This skill is automatically active when working in a Flux project. It provides c
29
33
  - `add_criteria` - Add acceptance criterion to epic or task
30
34
  - `mark_criteria_met` - Mark criterion as satisfied
31
35
 
32
- ## Workflow States
36
+ ## Available Commands
33
37
 
34
- The Flux project progresses through these states:
38
+ | Command | Purpose |
39
+ |---------|---------|
40
+ | `/flux` | Project init, status, and workflow routing |
41
+ | `/flux:linear` | Connect project to Linear (interactive setup) |
42
+ | `/flux:prd` | Create or refine PRDs |
43
+ | `/flux:breakdown` | Break approved PRD into epics and tasks |
44
+ | `/flux:implement` | Implement tasks with TDD workflow |
35
45
 
36
- 1. **Uninitialized** - No .flux/ directory
37
- - Action: Run `/flux` to initialize
46
+ ## Workflow States
38
47
 
39
- 2. **No PRDs** - Project initialized but empty
40
- - Action: Run `/flux:prd` to create first PRD
48
+ ```
49
+ Uninitialized Initialized PRD Draft Pending Review → Reviewed → Approved → Breakdown Ready → In Progress → Complete
50
+
51
+ (optional) Linear Connected
52
+ ```
41
53
 
42
- 3. **PRD Draft** - PRD created but needs review
43
- - Action: Review and submit for approval or refine
54
+ ### PRD Status Transitions
55
+ ```
56
+ DRAFT → PENDING_REVIEW → REVIEWED → APPROVED → BREAKDOWN_READY → COMPLETED
57
+ ↓ ↓
58
+ DRAFT (revise) DRAFT (revise)
59
+ ```
44
60
 
45
- 4. **PRD Pending Review** - PRD submitted for review
46
- - Action: Run critique agent, then approve or revise
61
+ ### Epic/Task Statuses
62
+ - `PENDING` `IN_PROGRESS` `COMPLETED`
63
+
64
+ ## Backend Adapters
47
65
 
48
- 5. **PRD Reviewed** - Critique complete
49
- - Action: Address feedback, then approve or revise to DRAFT
66
+ Flux supports multiple backends via the adapter pattern:
50
67
 
51
- 6. **PRD Approved** - Ready for epic breakdown
52
- - Action: Run `/flux:breakdown` to create epics
68
+ | Adapter | Storage | Use Case |
69
+ |---------|---------|----------|
70
+ | `local` | SQLite (.flux/flux.db) | Default, offline-first |
71
+ | `linear` | Linear API | Team collaboration, issue tracking |
53
72
 
54
- 7. **Breakdown Ready** - Epics and tasks created
55
- - Action: Run `/flux:implement` to start coding
73
+ ### Switching to Linear
56
74
 
57
- 8. **Implementation In Progress** - Tasks IN_PROGRESS
58
- - Action: Continue implementing current task
75
+ Use `configure_linear` with interactive mode:
76
+ 1. `{interactive: true}` returns teams list
77
+ 2. `{interactive: true, teamId: "..."}` → returns projects list
78
+ 3. `{teamId: "...", projectName/existingProjectId: "..."}` → configures
59
79
 
60
- 9. **Complete** - All tasks COMPLETED
61
- - Action: Review and create PR
80
+ Or run `/flux:linear` for guided setup.
62
81
 
63
82
  ## Entity References
64
83
 
65
- All entities have a reference format: `{PREFIX}-{TYPE}{NUMBER}`
84
+ Format: `{PREFIX}-{TYPE}{NUMBER}`
66
85
 
67
86
  - PRD: `MSA-P1`, `MSA-P2`
68
87
  - Epic: `MSA-E1`, `MSA-E2`
69
88
  - Task: `MSA-T1`, `MSA-T2`
70
89
 
71
- The prefix is generated from the project name during initialization.
72
-
73
- ## Status Values
74
-
75
- ### PRD Statuses (6-stage workflow)
76
- - `DRAFT` - Initial state, being created/refined
77
- - `PENDING_REVIEW` - Submitted for critique
78
- - `REVIEWED` - Critique complete, awaiting approval
79
- - `APPROVED` - Ready for epic breakdown
80
- - `BREAKDOWN_READY` - Epics and tasks created
81
- - `COMPLETED` - All epics done
82
-
83
- ### Valid PRD Transitions
84
- ```
85
- DRAFT → PENDING_REVIEW
86
- PENDING_REVIEW → REVIEWED | DRAFT (revise)
87
- REVIEWED → APPROVED | DRAFT (revise)
88
- APPROVED → BREAKDOWN_READY
89
- BREAKDOWN_READY → COMPLETED
90
- ```
91
-
92
- ### Epic/Task Statuses
93
- - `PENDING` - Not started
94
- - `IN_PROGRESS` - Currently being worked on
95
- - `COMPLETED` - Done
96
-
97
- ## Confidence-Based Autonomy
98
-
99
- The orchestrator uses confidence levels to determine autonomy:
100
-
101
- | Confidence | Behavior | Example |
102
- |------------|----------|---------|
103
- | > 80% | Auto-execute, inform user | "I'm creating the epic structure..." |
104
- | 50-80% | Suggest action, wait for confirmation | "Ready to break down into tasks. Proceed?" |
105
- | < 50% | Ask clarifying question | "Should we research this technology first?" |
106
-
107
- ### Confidence Indicators
108
- - **High confidence (>80%)**: Clear next step, no ambiguity, user has been responsive
109
- - **Medium confidence (50-80%)**: Reasonable next step, some uncertainty
110
- - **Low confidence (<50%)**: Multiple valid paths, unclear requirements, unfamiliar tech
111
-
112
90
  ## Available Subagents
113
91
 
114
- ### Research Agent
115
- - **Trigger**: Unfamiliar technology mentioned, confidence < 70%
116
- - **Purpose**: Gather information about libraries, frameworks, APIs
92
+ ### Research Agent (`flux:flux-researcher`)
93
+ - **Trigger**: Unfamiliar technology, confidence < 70%
117
94
  - **Tools**: Context7, WebSearch, WebFetch
118
95
 
119
- ### Critique Agent
120
- - **Trigger**: PRD status becomes PENDING_REVIEW
121
- - **Purpose**: Analyze feasibility, scope, risks
122
- - **Output**: Structured critique with recommendations
96
+ ### Critique Agent (`flux:flux-critic`)
97
+ - **Trigger**: PRD submitted for review
98
+ - **Output**: Feasibility analysis, risks, recommendations
99
+
100
+ ### Coder Agent (`flux:flux-coder`)
101
+ - **Trigger**: Task implementation
102
+ - **Workflow**: TDD - write tests, implement, verify
103
+
104
+ ### Verifier Agent (`flux:flux-verifier`)
105
+ - **Trigger**: After implementation
106
+ - **Purpose**: Verify acceptance criteria coverage
123
107
 
124
108
  ## Best Practices
125
109
 
126
- 1. **Check context first** - Always call `get_project_context` before taking actions
127
- 2. **Use refs, not IDs** - Tools accept human-readable refs like `MSA-E1`
128
- 3. **Validate status transitions** - Use `update_status` which enforces valid transitions
129
- 4. **Include related data** - Use `include` parameter to fetch nested entities in one call
130
- 5. **Handle errors gracefully** - Tools return errors with codes, display user-friendly messages
110
+ 1. **Check context first** - Call `get_project_context` before actions
111
+ 2. **Use refs, not IDs** - Tools accept `MSA-E1` format
112
+ 3. **Use render_status** - For visual project overview
113
+ 4. **Validate transitions** - `update_status` enforces valid transitions
114
+ 5. **Include related data** - Use `include` param to fetch nested entities
@@ -1,5 +1,7 @@
1
1
  ---
2
+ name: flux:prd-template
2
3
  description: PRD structure and patterns for Flux. Use when creating or refining product requirement documents. PRDs should be concise for humans but detailed enough for AI agents to implement.
4
+ user-invocable: false
3
5
  ---
4
6
 
5
7
  # PRD Template Skill
@@ -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
+ });