@calltelemetry/openclaw-linear 0.7.0 → 0.7.1
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/LICENSE +21 -0
- package/index.ts +39 -0
- package/openclaw.plugin.json +3 -3
- package/package.json +2 -1
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +14 -11
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +97 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.ts +115 -15
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.ts +6 -1
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +21 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 CallTelemetry, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/index.ts
CHANGED
|
@@ -7,11 +7,15 @@ import { handleLinearWebhook } from "./src/pipeline/webhook.js";
|
|
|
7
7
|
import { handleOAuthCallback } from "./src/api/oauth-callback.js";
|
|
8
8
|
import { LinearAgentApi, resolveLinearToken } from "./src/api/linear-api.js";
|
|
9
9
|
import { createDispatchService } from "./src/pipeline/dispatch-service.js";
|
|
10
|
+
import { registerDispatchMethods } from "./src/gateway/dispatch-methods.js";
|
|
10
11
|
import { readDispatchState, lookupSessionMapping, getActiveDispatch } from "./src/pipeline/dispatch-state.js";
|
|
11
12
|
import { triggerAudit, processVerdict, type HookContext } from "./src/pipeline/pipeline.js";
|
|
12
13
|
import { createNotifierFromConfig, type NotifyFn } from "./src/infra/notify.js";
|
|
13
14
|
import { readPlanningState, setPlanningCache } from "./src/pipeline/planning-state.js";
|
|
14
15
|
import { createPlannerTools } from "./src/tools/planner-tools.js";
|
|
16
|
+
import { registerDispatchCommands } from "./src/infra/commands.js";
|
|
17
|
+
import { createDispatchHistoryTool } from "./src/tools/dispatch-history-tool.js";
|
|
18
|
+
import { readDispatchState as readStateForHook, listActiveDispatches as listActiveForHook } from "./src/pipeline/dispatch-state.js";
|
|
15
19
|
|
|
16
20
|
export default function register(api: OpenClawPluginApi) {
|
|
17
21
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
@@ -41,6 +45,12 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
41
45
|
// Register planner tools (context injected at runtime via setActivePlannerContext)
|
|
42
46
|
api.registerTool(() => createPlannerTools());
|
|
43
47
|
|
|
48
|
+
// Register dispatch_history tool for agent context
|
|
49
|
+
api.registerTool(() => createDispatchHistoryTool(api, pluginConfig));
|
|
50
|
+
|
|
51
|
+
// Register zero-LLM slash commands for dispatch ops
|
|
52
|
+
registerDispatchCommands(api);
|
|
53
|
+
|
|
44
54
|
// Register Linear webhook handler on a dedicated route
|
|
45
55
|
api.registerHttpRoute({
|
|
46
56
|
path: "/linear/webhook",
|
|
@@ -68,6 +78,9 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
68
78
|
// Register dispatch monitor service (stale detection, session hydration, cleanup)
|
|
69
79
|
api.registerService(createDispatchService(api));
|
|
70
80
|
|
|
81
|
+
// Register dispatch gateway RPC methods (list, get, retry, escalate, cancel, stats)
|
|
82
|
+
registerDispatchMethods(api);
|
|
83
|
+
|
|
71
84
|
// Hydrate planning state on startup
|
|
72
85
|
readPlanningState(pluginConfig?.planningStatePath as string | undefined).then((state) => {
|
|
73
86
|
for (const session of Object.values(state.sessions)) {
|
|
@@ -159,6 +172,32 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
159
172
|
}
|
|
160
173
|
});
|
|
161
174
|
|
|
175
|
+
// Inject recent dispatch history as context for worker/audit agents
|
|
176
|
+
api.on("before_agent_start", async (event: any, ctx: any) => {
|
|
177
|
+
try {
|
|
178
|
+
const sessionKey = ctx?.sessionKey ?? "";
|
|
179
|
+
if (!sessionKey.startsWith("linear-worker-") && !sessionKey.startsWith("linear-audit-")) return;
|
|
180
|
+
|
|
181
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
182
|
+
const state = await readStateForHook(statePath);
|
|
183
|
+
const active = listActiveForHook(state);
|
|
184
|
+
|
|
185
|
+
// Include up to 3 recent active dispatches as context
|
|
186
|
+
const recent = active.slice(0, 3);
|
|
187
|
+
if (recent.length === 0) return;
|
|
188
|
+
|
|
189
|
+
const lines = recent.map(d =>
|
|
190
|
+
`- **${d.issueIdentifier}** (${d.tier}): ${d.status}, attempt ${d.attempt}`
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
prependContext: `<dispatch-history>\nActive dispatches:\n${lines.join("\n")}\n</dispatch-history>\n\n`,
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
// Never block agent start for telemetry
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
162
201
|
// Narration Guard: catch short "Let me explore..." responses that narrate intent
|
|
163
202
|
// without actually calling tools, and append a warning for the user.
|
|
164
203
|
const NARRATION_PATTERNS = [
|
package/openclaw.plugin.json
CHANGED
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
"defaultAgentId": { "type": "string", "description": "OpenClaw agent ID to use for pipeline stages" },
|
|
16
16
|
"enableAudit": { "type": "boolean", "description": "Run auditor stage after implementation", "default": true },
|
|
17
17
|
"codexBaseRepo": { "type": "string", "description": "Path to git repo for Codex worktrees", "default": "/home/claw/ai-workspace" },
|
|
18
|
-
"codexModel": { "type": "string", "description": "Default Codex model (optional — uses Codex default if omitted)" },
|
|
19
|
-
"codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
|
|
20
18
|
"enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true },
|
|
21
19
|
"worktreeBaseDir": { "type": "string", "description": "Base directory for persistent git worktrees (default: ~/.openclaw/worktrees)" },
|
|
20
|
+
"repos": { "type": "object", "description": "Multi-repo map (name → path, e.g. {\"api\": \"/home/claw/api\", \"frontend\": \"/home/claw/frontend\"})", "additionalProperties": { "type": "string" } },
|
|
22
21
|
"dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" },
|
|
23
22
|
"planningStatePath": { "type": "string", "description": "Path to planning state JSON file (default: ~/.openclaw/linear-planning-state.json)" },
|
|
24
23
|
"notifications": {
|
|
@@ -51,7 +50,8 @@
|
|
|
51
50
|
"stuck": { "type": "boolean" },
|
|
52
51
|
"watchdog_kill": { "type": "boolean" }
|
|
53
52
|
}
|
|
54
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"richFormat": { "type": "boolean", "description": "Send rich embeds (Discord) and HTML (Telegram) instead of plain text", "default": false }
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"promptsPath": { "type": "string", "description": "Override path for prompts.yaml (default: ships with plugin)" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calltelemetry/openclaw-linear",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
]
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
+
"cockatiel": "^3.2.1",
|
|
48
49
|
"yaml": "^2.8.2"
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
|
2
|
+
import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH } from "./linear-api.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mocks
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
vi.mock("node:fs", () => ({
|
|
9
|
+
readFileSync: vi.fn(),
|
|
10
|
+
writeFileSync: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./auth.js", () => ({
|
|
14
|
+
refreshLinearToken: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("../infra/resilience.js", () => ({
|
|
18
|
+
withResilience: vi.fn((fn: () => Promise<unknown>) => fn()),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { refreshLinearToken } from "./auth.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const mockReadFileSync = readFileSync as Mock;
|
|
29
|
+
const mockWriteFileSync = writeFileSync as Mock;
|
|
30
|
+
const mockRefreshLinearToken = refreshLinearToken as Mock;
|
|
31
|
+
|
|
32
|
+
/** Build a minimal successful fetch Response. */
|
|
33
|
+
function okResponse(data: unknown, status = 200): Response {
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
status,
|
|
37
|
+
json: () => Promise.resolve({ data }),
|
|
38
|
+
text: () => Promise.resolve(JSON.stringify({ data })),
|
|
39
|
+
headers: new Headers(),
|
|
40
|
+
} as unknown as Response;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build a failing fetch Response. */
|
|
44
|
+
function errorResponse(status: number, body = "error"): Response {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
status,
|
|
48
|
+
json: () => Promise.resolve({ errors: [{ message: body }] }),
|
|
49
|
+
text: () => Promise.resolve(body),
|
|
50
|
+
headers: new Headers(),
|
|
51
|
+
} as unknown as Response;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a response that carries GraphQL-level errors. */
|
|
55
|
+
function gqlErrorResponse(errors: Array<{ message: string }>): Response {
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
status: 200,
|
|
59
|
+
json: () => Promise.resolve({ errors }),
|
|
60
|
+
text: () => Promise.resolve(JSON.stringify({ errors })),
|
|
61
|
+
headers: new Headers(),
|
|
62
|
+
} as unknown as Response;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Setup
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
let fetchMock: Mock;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.restoreAllMocks();
|
|
73
|
+
fetchMock = vi.fn();
|
|
74
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
75
|
+
|
|
76
|
+
// Default: readFileSync throws (no profile file)
|
|
77
|
+
mockReadFileSync.mockImplementation(() => {
|
|
78
|
+
throw new Error("ENOENT");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Clear env vars that could leak between tests
|
|
82
|
+
delete process.env.LINEAR_ACCESS_TOKEN;
|
|
83
|
+
delete process.env.LINEAR_API_KEY;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ===========================================================================
|
|
87
|
+
// resolveLinearToken
|
|
88
|
+
// ===========================================================================
|
|
89
|
+
|
|
90
|
+
describe("resolveLinearToken", () => {
|
|
91
|
+
it("returns token from pluginConfig.accessToken (source: config)", () => {
|
|
92
|
+
const result = resolveLinearToken({ accessToken: "cfg-token-123" });
|
|
93
|
+
expect(result).toEqual({ accessToken: "cfg-token-123", source: "config" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns token from auth profile store when config is empty (source: profile)", () => {
|
|
97
|
+
const profileStore = {
|
|
98
|
+
profiles: {
|
|
99
|
+
"linear:default": {
|
|
100
|
+
accessToken: "oauth-tok",
|
|
101
|
+
refreshToken: "oauth-refresh",
|
|
102
|
+
expiresAt: 9999999999999,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
107
|
+
|
|
108
|
+
const result = resolveLinearToken();
|
|
109
|
+
expect(result).toEqual({
|
|
110
|
+
accessToken: "oauth-tok",
|
|
111
|
+
refreshToken: "oauth-refresh",
|
|
112
|
+
expiresAt: 9999999999999,
|
|
113
|
+
source: "profile",
|
|
114
|
+
});
|
|
115
|
+
expect(mockReadFileSync).toHaveBeenCalledWith(AUTH_PROFILES_PATH, "utf8");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns token from env var LINEAR_ACCESS_TOKEN when config and profile are empty (source: env)", () => {
|
|
119
|
+
process.env.LINEAR_ACCESS_TOKEN = "env-token-abc";
|
|
120
|
+
|
|
121
|
+
const result = resolveLinearToken();
|
|
122
|
+
expect(result).toEqual({ accessToken: "env-token-abc", source: "env" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns token from env var LINEAR_API_KEY as fallback", () => {
|
|
126
|
+
process.env.LINEAR_API_KEY = "api-key-xyz";
|
|
127
|
+
|
|
128
|
+
const result = resolveLinearToken();
|
|
129
|
+
expect(result).toEqual({ accessToken: "api-key-xyz", source: "env" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns null with source 'none' when nothing is configured", () => {
|
|
133
|
+
const result = resolveLinearToken();
|
|
134
|
+
expect(result).toEqual({ accessToken: null, source: "none" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("respects priority: config > profile > env", () => {
|
|
138
|
+
// Set up all three sources
|
|
139
|
+
const profileStore = {
|
|
140
|
+
profiles: {
|
|
141
|
+
"linear:default": {
|
|
142
|
+
accessToken: "profile-tok",
|
|
143
|
+
refreshToken: "profile-refresh",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
148
|
+
process.env.LINEAR_ACCESS_TOKEN = "env-tok";
|
|
149
|
+
|
|
150
|
+
// Config wins when present
|
|
151
|
+
const r1 = resolveLinearToken({ accessToken: "config-tok" });
|
|
152
|
+
expect(r1.source).toBe("config");
|
|
153
|
+
expect(r1.accessToken).toBe("config-tok");
|
|
154
|
+
|
|
155
|
+
// Profile wins over env when config is absent
|
|
156
|
+
const r2 = resolveLinearToken();
|
|
157
|
+
expect(r2.source).toBe("profile");
|
|
158
|
+
expect(r2.accessToken).toBe("profile-tok");
|
|
159
|
+
|
|
160
|
+
// Env is used when profile file is unreadable and no config
|
|
161
|
+
mockReadFileSync.mockImplementation(() => {
|
|
162
|
+
throw new Error("ENOENT");
|
|
163
|
+
});
|
|
164
|
+
const r3 = resolveLinearToken();
|
|
165
|
+
expect(r3.source).toBe("env");
|
|
166
|
+
expect(r3.accessToken).toBe("env-tok");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ===========================================================================
|
|
171
|
+
// LinearAgentApi
|
|
172
|
+
// ===========================================================================
|
|
173
|
+
|
|
174
|
+
describe("LinearAgentApi", () => {
|
|
175
|
+
const TOKEN = "test-access-token";
|
|
176
|
+
|
|
177
|
+
// -------------------------------------------------------------------------
|
|
178
|
+
// gql — tested indirectly via public methods
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe("gql (via public methods)", () => {
|
|
182
|
+
it("sends correct headers and body", async () => {
|
|
183
|
+
fetchMock.mockResolvedValueOnce(
|
|
184
|
+
okResponse({ commentCreate: { success: true, comment: { id: "c1" } } }),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const api = new LinearAgentApi(TOKEN);
|
|
188
|
+
await api.createComment("issue-1", "hello");
|
|
189
|
+
|
|
190
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
191
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
192
|
+
expect(url).toBe("https://api.linear.app/graphql");
|
|
193
|
+
expect(init.method).toBe("POST");
|
|
194
|
+
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
195
|
+
expect(init.headers["Authorization"]).toBe(TOKEN); // no Bearer — no refreshToken
|
|
196
|
+
|
|
197
|
+
const body = JSON.parse(init.body);
|
|
198
|
+
expect(body.query).toContain("CommentCreate");
|
|
199
|
+
expect(body.variables.input.issueId).toBe("issue-1");
|
|
200
|
+
expect(body.variables.input.body).toBe("hello");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns data on success", async () => {
|
|
204
|
+
fetchMock.mockResolvedValueOnce(
|
|
205
|
+
okResponse({
|
|
206
|
+
issueUpdate: { success: true },
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const api = new LinearAgentApi(TOKEN);
|
|
211
|
+
const result = await api.updateIssue("i1", { estimate: 3 });
|
|
212
|
+
expect(result).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("throws on non-ok response", async () => {
|
|
216
|
+
fetchMock.mockResolvedValueOnce(errorResponse(500, "Internal Server Error"));
|
|
217
|
+
|
|
218
|
+
const api = new LinearAgentApi(TOKEN);
|
|
219
|
+
await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
|
|
220
|
+
/Linear API 500/,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("throws on GraphQL errors", async () => {
|
|
225
|
+
fetchMock.mockResolvedValueOnce(
|
|
226
|
+
gqlErrorResponse([{ message: "Field 'foo' not found" }]),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const api = new LinearAgentApi(TOKEN);
|
|
230
|
+
await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
|
|
231
|
+
/Linear GraphQL/,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("retries on 401 when refresh token is available", async () => {
|
|
236
|
+
// First call (via withResilience): 401
|
|
237
|
+
fetchMock.mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
|
|
238
|
+
// Retry (direct fetch, not through withResilience): succeeds
|
|
239
|
+
fetchMock.mockResolvedValueOnce(
|
|
240
|
+
okResponse({ issueUpdate: { success: true } }),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
mockRefreshLinearToken.mockResolvedValueOnce({
|
|
244
|
+
access_token: "new-token",
|
|
245
|
+
refresh_token: "new-refresh",
|
|
246
|
+
expires_in: 3600,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// readFileSync/writeFileSync for persistToken
|
|
250
|
+
mockReadFileSync.mockReturnValue(
|
|
251
|
+
JSON.stringify({
|
|
252
|
+
profiles: { "linear:default": { accessToken: "old" } },
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Use expiresAt = 1 (truthy but in the past) so ensureValidToken triggers
|
|
257
|
+
// the refresh on the 401 path when expiresAt is set to 0... actually
|
|
258
|
+
// the code sets expiresAt=0 which is falsy, so ensureValidToken bails.
|
|
259
|
+
// But the retry still happens — let's verify the retry occurs.
|
|
260
|
+
const api = new LinearAgentApi(TOKEN, {
|
|
261
|
+
refreshToken: "refresh-tok",
|
|
262
|
+
expiresAt: Date.now() + 100_000,
|
|
263
|
+
clientId: "cid",
|
|
264
|
+
clientSecret: "csecret",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const result = await api.updateIssue("i1", { estimate: 2 });
|
|
268
|
+
expect(result).toBe(true);
|
|
269
|
+
|
|
270
|
+
// Two fetch calls: original (401) + retry after 401 handling
|
|
271
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
272
|
+
|
|
273
|
+
// The retry request uses Bearer prefix (refreshToken is still set)
|
|
274
|
+
const retryInit = fetchMock.mock.calls[1][1];
|
|
275
|
+
expect(retryInit.headers["Authorization"]).toContain("Bearer");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("throws after 401 refresh also fails", async () => {
|
|
279
|
+
// First call: 401
|
|
280
|
+
fetchMock.mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
|
|
281
|
+
// After refresh, retry still fails
|
|
282
|
+
fetchMock.mockResolvedValueOnce(errorResponse(403, "Forbidden"));
|
|
283
|
+
|
|
284
|
+
mockRefreshLinearToken.mockResolvedValueOnce({
|
|
285
|
+
access_token: "refreshed-tok",
|
|
286
|
+
expires_in: 3600,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
mockReadFileSync.mockReturnValue(
|
|
290
|
+
JSON.stringify({
|
|
291
|
+
profiles: { "linear:default": { accessToken: "old" } },
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const api = new LinearAgentApi(TOKEN, {
|
|
296
|
+
refreshToken: "r-tok",
|
|
297
|
+
expiresAt: Date.now() + 100_000,
|
|
298
|
+
clientId: "cid",
|
|
299
|
+
clientSecret: "csecret",
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
|
|
303
|
+
/Linear API 403 \(after refresh\)/,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// -------------------------------------------------------------------------
|
|
309
|
+
// authHeader
|
|
310
|
+
// -------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
describe("authHeader (via request headers)", () => {
|
|
313
|
+
it("uses 'Bearer' prefix when refreshToken is set", async () => {
|
|
314
|
+
fetchMock.mockResolvedValueOnce(
|
|
315
|
+
okResponse({ issueUpdate: { success: true } }),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const api = new LinearAgentApi(TOKEN, {
|
|
319
|
+
refreshToken: "r-tok",
|
|
320
|
+
expiresAt: Date.now() + 600_000, // far future — no refresh triggered
|
|
321
|
+
});
|
|
322
|
+
await api.updateIssue("i1", { estimate: 1 });
|
|
323
|
+
|
|
324
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
325
|
+
expect(init.headers["Authorization"]).toBe(`Bearer ${TOKEN}`);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("uses raw token when refreshToken is not set", async () => {
|
|
329
|
+
fetchMock.mockResolvedValueOnce(
|
|
330
|
+
okResponse({ issueUpdate: { success: true } }),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const api = new LinearAgentApi(TOKEN);
|
|
334
|
+
await api.updateIssue("i1", { estimate: 1 });
|
|
335
|
+
|
|
336
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
337
|
+
expect(init.headers["Authorization"]).toBe(TOKEN);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
// Public methods
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
describe("emitActivity", () => {
|
|
346
|
+
it("calls the correct mutation with content payload", async () => {
|
|
347
|
+
fetchMock.mockResolvedValueOnce(
|
|
348
|
+
okResponse({ agentActivityCreate: { success: true } }),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const api = new LinearAgentApi(TOKEN);
|
|
352
|
+
await api.emitActivity("session-1", { type: "thought", body: "thinking..." });
|
|
353
|
+
|
|
354
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
355
|
+
const body = JSON.parse(init.body);
|
|
356
|
+
expect(body.query).toContain("agentActivityCreate");
|
|
357
|
+
expect(body.variables.input).toEqual({
|
|
358
|
+
agentSessionId: "session-1",
|
|
359
|
+
content: { type: "thought", body: "thinking..." },
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("createComment", () => {
|
|
365
|
+
it("sends correct input and returns comment id", async () => {
|
|
366
|
+
fetchMock.mockResolvedValueOnce(
|
|
367
|
+
okResponse({
|
|
368
|
+
commentCreate: { success: true, comment: { id: "comment-abc" } },
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const api = new LinearAgentApi(TOKEN);
|
|
373
|
+
const id = await api.createComment("issue-99", "Test comment body", {
|
|
374
|
+
createAsUser: "user-1",
|
|
375
|
+
displayIconUrl: "https://example.com/icon.png",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(id).toBe("comment-abc");
|
|
379
|
+
|
|
380
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
381
|
+
expect(body.variables.input).toEqual({
|
|
382
|
+
issueId: "issue-99",
|
|
383
|
+
body: "Test comment body",
|
|
384
|
+
createAsUser: "user-1",
|
|
385
|
+
displayIconUrl: "https://example.com/icon.png",
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("getIssueDetails", () => {
|
|
391
|
+
it("returns expected shape", async () => {
|
|
392
|
+
const issueData = {
|
|
393
|
+
id: "iss-1",
|
|
394
|
+
identifier: "CT-123",
|
|
395
|
+
title: "Fix the bug",
|
|
396
|
+
description: "Something is broken",
|
|
397
|
+
estimate: 3,
|
|
398
|
+
state: { name: "In Progress" },
|
|
399
|
+
assignee: { name: "Alice" },
|
|
400
|
+
labels: { nodes: [{ id: "l1", name: "bug" }] },
|
|
401
|
+
team: { id: "t1", name: "Engineering", issueEstimationType: "fibonacci" },
|
|
402
|
+
comments: {
|
|
403
|
+
nodes: [
|
|
404
|
+
{ body: "Looking into it", user: { name: "Bob" }, createdAt: "2026-01-01T00:00:00Z" },
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
project: { id: "p1", name: "Q1 Sprint" },
|
|
408
|
+
parent: null,
|
|
409
|
+
relations: { nodes: [] },
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
fetchMock.mockResolvedValueOnce(okResponse({ issue: issueData }));
|
|
413
|
+
|
|
414
|
+
const api = new LinearAgentApi(TOKEN);
|
|
415
|
+
const result = await api.getIssueDetails("iss-1");
|
|
416
|
+
|
|
417
|
+
expect(result.id).toBe("iss-1");
|
|
418
|
+
expect(result.identifier).toBe("CT-123");
|
|
419
|
+
expect(result.title).toBe("Fix the bug");
|
|
420
|
+
expect(result.description).toBe("Something is broken");
|
|
421
|
+
expect(result.estimate).toBe(3);
|
|
422
|
+
expect(result.state.name).toBe("In Progress");
|
|
423
|
+
expect(result.assignee?.name).toBe("Alice");
|
|
424
|
+
expect(result.labels.nodes).toHaveLength(1);
|
|
425
|
+
expect(result.team.issueEstimationType).toBe("fibonacci");
|
|
426
|
+
expect(result.comments.nodes).toHaveLength(1);
|
|
427
|
+
expect(result.project?.name).toBe("Q1 Sprint");
|
|
428
|
+
expect(result.parent).toBeNull();
|
|
429
|
+
expect(result.relations.nodes).toHaveLength(0);
|
|
430
|
+
|
|
431
|
+
// Verify variables sent
|
|
432
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
433
|
+
expect(body.variables).toEqual({ id: "iss-1" });
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("updateIssue", () => {
|
|
438
|
+
it("calls mutation and returns success boolean", async () => {
|
|
439
|
+
fetchMock.mockResolvedValueOnce(
|
|
440
|
+
okResponse({ issueUpdate: { success: true } }),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const api = new LinearAgentApi(TOKEN);
|
|
444
|
+
const success = await api.updateIssue("iss-42", {
|
|
445
|
+
estimate: 5,
|
|
446
|
+
labelIds: ["l1", "l2"],
|
|
447
|
+
stateId: "s1",
|
|
448
|
+
priority: 2,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
expect(success).toBe(true);
|
|
452
|
+
|
|
453
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
454
|
+
expect(body.query).toContain("issueUpdate");
|
|
455
|
+
expect(body.variables).toEqual({
|
|
456
|
+
id: "iss-42",
|
|
457
|
+
input: {
|
|
458
|
+
estimate: 5,
|
|
459
|
+
labelIds: ["l1", "l2"],
|
|
460
|
+
stateId: "s1",
|
|
461
|
+
priority: 2,
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("createSessionOnIssue", () => {
|
|
468
|
+
it("returns sessionId on success", async () => {
|
|
469
|
+
fetchMock.mockResolvedValueOnce(
|
|
470
|
+
okResponse({
|
|
471
|
+
agentSessionCreateOnIssue: {
|
|
472
|
+
success: true,
|
|
473
|
+
agentSession: { id: "sess-new" },
|
|
474
|
+
},
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const api = new LinearAgentApi(TOKEN);
|
|
479
|
+
const result = await api.createSessionOnIssue("iss-1");
|
|
480
|
+
expect(result).toEqual({ sessionId: "sess-new" });
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("returns error on failure", async () => {
|
|
484
|
+
fetchMock.mockResolvedValueOnce(errorResponse(500, "Server Error"));
|
|
485
|
+
|
|
486
|
+
const api = new LinearAgentApi(TOKEN);
|
|
487
|
+
const result = await api.createSessionOnIssue("iss-bad");
|
|
488
|
+
|
|
489
|
+
expect(result.sessionId).toBeNull();
|
|
490
|
+
expect(result.error).toBeDefined();
|
|
491
|
+
expect(result.error).toContain("Linear API 500");
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
});
|
package/src/api/linear-api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { refreshLinearToken } from "./auth.js";
|
|
4
|
+
import { withResilience } from "../infra/resilience.js";
|
|
4
5
|
|
|
5
6
|
export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
6
7
|
export const AUTH_PROFILES_PATH = join(
|
|
@@ -161,13 +162,15 @@ export class LinearAgentApi {
|
|
|
161
162
|
...extraHeaders,
|
|
162
163
|
};
|
|
163
164
|
|
|
164
|
-
const res = await
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
const res = await withResilience(() =>
|
|
166
|
+
fetch(LINEAR_GRAPHQL_URL, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers,
|
|
169
|
+
body: JSON.stringify({ query, variables }),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
169
172
|
|
|
170
|
-
// If 401, try refreshing token once
|
|
173
|
+
// If 401, try refreshing token once (outside resilience — own retry semantics)
|
|
171
174
|
if (res.status === 401 && this.refreshToken && this.clientId && this.clientSecret) {
|
|
172
175
|
this.expiresAt = 0; // force refresh
|
|
173
176
|
await this.ensureValidToken();
|
|
@@ -178,18 +181,18 @@ export class LinearAgentApi {
|
|
|
178
181
|
...extraHeaders,
|
|
179
182
|
};
|
|
180
183
|
|
|
181
|
-
const
|
|
184
|
+
const retryRes = await fetch(LINEAR_GRAPHQL_URL, {
|
|
182
185
|
method: "POST",
|
|
183
186
|
headers: retryHeaders,
|
|
184
187
|
body: JSON.stringify({ query, variables }),
|
|
185
188
|
});
|
|
186
189
|
|
|
187
|
-
if (!
|
|
188
|
-
const text = await
|
|
189
|
-
throw new Error(`Linear API ${
|
|
190
|
+
if (!retryRes.ok) {
|
|
191
|
+
const text = await retryRes.text();
|
|
192
|
+
throw new Error(`Linear API ${retryRes.status} (after refresh): ${text}`);
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
const payload = await
|
|
195
|
+
const payload = await retryRes.json();
|
|
193
196
|
if (payload.errors?.length) {
|
|
194
197
|
throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
|
|
195
198
|
}
|