@calltelemetry/openclaw-linear 0.9.14 → 0.9.16
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 +104 -48
- package/index.ts +57 -0
- package/openclaw.plugin.json +44 -2
- package/package.json +1 -1
- package/prompts.yaml +3 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +37 -50
- package/src/agent/agent.test.ts +1 -1
- package/src/agent/agent.ts +39 -6
- package/src/api/linear-api.test.ts +188 -1
- package/src/api/linear-api.ts +114 -5
- package/src/infra/multi-repo.test.ts +127 -1
- package/src/infra/multi-repo.ts +74 -6
- package/src/infra/tmux-runner.ts +599 -0
- package/src/infra/tmux.ts +158 -0
- package/src/infra/token-refresh-timer.ts +44 -0
- package/src/pipeline/active-session.ts +19 -1
- package/src/pipeline/artifacts.ts +42 -0
- package/src/pipeline/dispatch-state.ts +3 -0
- package/src/pipeline/guidance.test.ts +53 -0
- package/src/pipeline/guidance.ts +38 -0
- package/src/pipeline/memory-search.ts +40 -0
- package/src/pipeline/pipeline.ts +184 -17
- package/src/pipeline/retro.ts +231 -0
- package/src/pipeline/webhook.test.ts +8 -8
- package/src/pipeline/webhook.ts +408 -29
- package/src/tools/claude-tool.ts +68 -10
- package/src/tools/cli-shared.ts +50 -2
- package/src/tools/code-tool.ts +230 -150
- package/src/tools/codex-tool.ts +61 -9
- package/src/tools/gemini-tool.ts +61 -10
- package/src/tools/steering-tools.ts +176 -0
- package/src/tools/tools.test.ts +47 -15
- package/src/tools/tools.ts +17 -4
- package/src/__test__/smoke-linear-api.test.ts +0 -847
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Recorded API responses from sub-issue decomposition smoke test.
|
|
3
3
|
* Auto-generated — do not edit manually.
|
|
4
4
|
* Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts
|
|
5
|
-
* Last recorded: 2026-02-
|
|
5
|
+
* Last recorded: 2026-02-24T05:15:32.100Z
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export const RECORDED = {
|
|
@@ -44,20 +44,20 @@ export const RECORDED = {
|
|
|
44
44
|
}
|
|
45
45
|
],
|
|
46
46
|
"createParent": {
|
|
47
|
-
"id": "
|
|
48
|
-
"identifier": "UAT-
|
|
47
|
+
"id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
|
|
48
|
+
"identifier": "UAT-638"
|
|
49
49
|
},
|
|
50
50
|
"createSubIssue1": {
|
|
51
|
-
"id": "
|
|
52
|
-
"identifier": "UAT-
|
|
51
|
+
"id": "a1702cad-3206-4a2b-bb0c-c6ad47df8983",
|
|
52
|
+
"identifier": "UAT-639"
|
|
53
53
|
},
|
|
54
54
|
"createSubIssue2": {
|
|
55
|
-
"id": "
|
|
56
|
-
"identifier": "UAT-
|
|
55
|
+
"id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
|
|
56
|
+
"identifier": "UAT-640"
|
|
57
57
|
},
|
|
58
58
|
"subIssue1Details": {
|
|
59
|
-
"id": "
|
|
60
|
-
"identifier": "UAT-
|
|
59
|
+
"id": "a1702cad-3206-4a2b-bb0c-c6ad47df8983",
|
|
60
|
+
"identifier": "UAT-639",
|
|
61
61
|
"title": "[SMOKE TEST] Sub-Issue 1: Backend API",
|
|
62
62
|
"description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
|
|
63
63
|
"estimate": 2,
|
|
@@ -75,6 +75,7 @@ export const RECORDED = {
|
|
|
75
75
|
},
|
|
76
76
|
"team": {
|
|
77
77
|
"id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
|
|
78
|
+
"key": "UAT",
|
|
78
79
|
"name": "UAT",
|
|
79
80
|
"issueEstimationType": "tShirt"
|
|
80
81
|
},
|
|
@@ -83,16 +84,16 @@ export const RECORDED = {
|
|
|
83
84
|
},
|
|
84
85
|
"project": null,
|
|
85
86
|
"parent": {
|
|
86
|
-
"id": "
|
|
87
|
-
"identifier": "UAT-
|
|
87
|
+
"id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
|
|
88
|
+
"identifier": "UAT-638"
|
|
88
89
|
},
|
|
89
90
|
"relations": {
|
|
90
91
|
"nodes": []
|
|
91
92
|
}
|
|
92
93
|
},
|
|
93
94
|
"subIssue2Details": {
|
|
94
|
-
"id": "
|
|
95
|
-
"identifier": "UAT-
|
|
95
|
+
"id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
|
|
96
|
+
"identifier": "UAT-640",
|
|
96
97
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
97
98
|
"description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
|
|
98
99
|
"estimate": 3,
|
|
@@ -110,6 +111,7 @@ export const RECORDED = {
|
|
|
110
111
|
},
|
|
111
112
|
"team": {
|
|
112
113
|
"id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
|
|
114
|
+
"key": "UAT",
|
|
113
115
|
"name": "UAT",
|
|
114
116
|
"issueEstimationType": "tShirt"
|
|
115
117
|
},
|
|
@@ -118,18 +120,18 @@ export const RECORDED = {
|
|
|
118
120
|
},
|
|
119
121
|
"project": null,
|
|
120
122
|
"parent": {
|
|
121
|
-
"id": "
|
|
122
|
-
"identifier": "UAT-
|
|
123
|
+
"id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
|
|
124
|
+
"identifier": "UAT-638"
|
|
123
125
|
},
|
|
124
126
|
"relations": {
|
|
125
127
|
"nodes": []
|
|
126
128
|
}
|
|
127
129
|
},
|
|
128
130
|
"parentDetails": {
|
|
129
|
-
"id": "
|
|
130
|
-
"identifier": "UAT-
|
|
131
|
+
"id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
|
|
132
|
+
"identifier": "UAT-638",
|
|
131
133
|
"title": "[SMOKE TEST] Sub-Issue Parent: Search Feature",
|
|
132
|
-
"description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-
|
|
134
|
+
"description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-24T05:15:30.285Z",
|
|
133
135
|
"estimate": null,
|
|
134
136
|
"state": {
|
|
135
137
|
"name": "Backlog",
|
|
@@ -145,17 +147,12 @@ export const RECORDED = {
|
|
|
145
147
|
},
|
|
146
148
|
"team": {
|
|
147
149
|
"id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
|
|
150
|
+
"key": "UAT",
|
|
148
151
|
"name": "UAT",
|
|
149
152
|
"issueEstimationType": "tShirt"
|
|
150
153
|
},
|
|
151
154
|
"comments": {
|
|
152
|
-
"nodes": [
|
|
153
|
-
{
|
|
154
|
-
"body": "This thread is for an agent session with ctclaw.",
|
|
155
|
-
"user": null,
|
|
156
|
-
"createdAt": "2026-02-22T03:40:53.165Z"
|
|
157
|
-
}
|
|
158
|
-
]
|
|
155
|
+
"nodes": []
|
|
159
156
|
},
|
|
160
157
|
"project": null,
|
|
161
158
|
"parent": null,
|
|
@@ -164,11 +161,11 @@ export const RECORDED = {
|
|
|
164
161
|
}
|
|
165
162
|
},
|
|
166
163
|
"createRelation": {
|
|
167
|
-
"id": "
|
|
164
|
+
"id": "139541b2-b088-4290-9ded-5b0167a42741"
|
|
168
165
|
},
|
|
169
166
|
"subIssue1WithRelation": {
|
|
170
|
-
"id": "
|
|
171
|
-
"identifier": "UAT-
|
|
167
|
+
"id": "a1702cad-3206-4a2b-bb0c-c6ad47df8983",
|
|
168
|
+
"identifier": "UAT-639",
|
|
172
169
|
"title": "[SMOKE TEST] Sub-Issue 1: Backend API",
|
|
173
170
|
"description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
|
|
174
171
|
"estimate": 2,
|
|
@@ -186,30 +183,25 @@ export const RECORDED = {
|
|
|
186
183
|
},
|
|
187
184
|
"team": {
|
|
188
185
|
"id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
|
|
186
|
+
"key": "UAT",
|
|
189
187
|
"name": "UAT",
|
|
190
188
|
"issueEstimationType": "tShirt"
|
|
191
189
|
},
|
|
192
190
|
"comments": {
|
|
193
|
-
"nodes": [
|
|
194
|
-
{
|
|
195
|
-
"body": "This thread is for an agent session with ctclaw.",
|
|
196
|
-
"user": null,
|
|
197
|
-
"createdAt": "2026-02-22T03:40:53.603Z"
|
|
198
|
-
}
|
|
199
|
-
]
|
|
191
|
+
"nodes": []
|
|
200
192
|
},
|
|
201
193
|
"project": null,
|
|
202
194
|
"parent": {
|
|
203
|
-
"id": "
|
|
204
|
-
"identifier": "UAT-
|
|
195
|
+
"id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
|
|
196
|
+
"identifier": "UAT-638"
|
|
205
197
|
},
|
|
206
198
|
"relations": {
|
|
207
199
|
"nodes": [
|
|
208
200
|
{
|
|
209
201
|
"type": "blocks",
|
|
210
202
|
"relatedIssue": {
|
|
211
|
-
"id": "
|
|
212
|
-
"identifier": "UAT-
|
|
203
|
+
"id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
|
|
204
|
+
"identifier": "UAT-640",
|
|
213
205
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
|
|
214
206
|
}
|
|
215
207
|
}
|
|
@@ -217,8 +209,8 @@ export const RECORDED = {
|
|
|
217
209
|
}
|
|
218
210
|
},
|
|
219
211
|
"subIssue2WithRelation": {
|
|
220
|
-
"id": "
|
|
221
|
-
"identifier": "UAT-
|
|
212
|
+
"id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
|
|
213
|
+
"identifier": "UAT-640",
|
|
222
214
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
223
215
|
"description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
|
|
224
216
|
"estimate": 3,
|
|
@@ -236,22 +228,17 @@ export const RECORDED = {
|
|
|
236
228
|
},
|
|
237
229
|
"team": {
|
|
238
230
|
"id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
|
|
231
|
+
"key": "UAT",
|
|
239
232
|
"name": "UAT",
|
|
240
233
|
"issueEstimationType": "tShirt"
|
|
241
234
|
},
|
|
242
235
|
"comments": {
|
|
243
|
-
"nodes": [
|
|
244
|
-
{
|
|
245
|
-
"body": "This thread is for an agent session with ctclaw.",
|
|
246
|
-
"user": null,
|
|
247
|
-
"createdAt": "2026-02-22T03:40:53.840Z"
|
|
248
|
-
}
|
|
249
|
-
]
|
|
236
|
+
"nodes": []
|
|
250
237
|
},
|
|
251
238
|
"project": null,
|
|
252
239
|
"parent": {
|
|
253
|
-
"id": "
|
|
254
|
-
"identifier": "UAT-
|
|
240
|
+
"id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
|
|
241
|
+
"identifier": "UAT-638"
|
|
255
242
|
},
|
|
256
243
|
"relations": {
|
|
257
244
|
"nodes": []
|
package/src/agent/agent.test.ts
CHANGED
|
@@ -228,7 +228,7 @@ describe("runAgent subprocess", () => {
|
|
|
228
228
|
const noisyOutput = [
|
|
229
229
|
"[plugins] Dispatch gateway methods registered",
|
|
230
230
|
"[plugins] Linear agent extension registered (agent: zoe)",
|
|
231
|
-
'[plugins]
|
|
231
|
+
'[plugins] cli tools registered: cli_codex, cli_claude, cli_gemini (agent default: cli_codex)',
|
|
232
232
|
JSON.stringify({ payloads: [{ text: "clean response" }], meta: {} }),
|
|
233
233
|
].join("\n");
|
|
234
234
|
(api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
|
package/src/agent/agent.ts
CHANGED
|
@@ -79,6 +79,8 @@ export async function runAgent(params: {
|
|
|
79
79
|
* Subprocess fallback is blocked — only the embedded runner is safe.
|
|
80
80
|
*/
|
|
81
81
|
readOnly?: boolean;
|
|
82
|
+
/** Additional tools to deny (merged with config + readOnly denies) */
|
|
83
|
+
toolsDeny?: string[];
|
|
82
84
|
}): Promise<AgentRunResult> {
|
|
83
85
|
const maxAttempts = 2;
|
|
84
86
|
|
|
@@ -138,8 +140,9 @@ async function runAgentOnce(params: {
|
|
|
138
140
|
timeoutMs?: number;
|
|
139
141
|
streaming?: AgentStreamCallbacks;
|
|
140
142
|
readOnly?: boolean;
|
|
143
|
+
toolsDeny?: string[];
|
|
141
144
|
}): Promise<AgentRunResult> {
|
|
142
|
-
const { api, agentId, sessionId, streaming, readOnly } = params;
|
|
145
|
+
const { api, agentId, sessionId, streaming, readOnly, toolsDeny } = params;
|
|
143
146
|
|
|
144
147
|
// Inject current timestamp into every LLM request
|
|
145
148
|
const message = `${buildDateContext()}\n\n${params.message}`;
|
|
@@ -153,7 +156,7 @@ async function runAgentOnce(params: {
|
|
|
153
156
|
// Try embedded runner first (has streaming callbacks)
|
|
154
157
|
if (streaming) {
|
|
155
158
|
try {
|
|
156
|
-
return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs, readOnly);
|
|
159
|
+
return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs, readOnly, toolsDeny);
|
|
157
160
|
} catch (err) {
|
|
158
161
|
// Read-only mode MUST NOT fall back to subprocess — subprocess runs a
|
|
159
162
|
// full agent with no way to enforce the tool deny policy.
|
|
@@ -211,11 +214,13 @@ async function runEmbedded(
|
|
|
211
214
|
streaming: AgentStreamCallbacks,
|
|
212
215
|
inactivityMs: number,
|
|
213
216
|
readOnly?: boolean,
|
|
217
|
+
toolsDeny?: string[],
|
|
214
218
|
): Promise<AgentRunResult> {
|
|
215
219
|
const ext = await getExtensionAPI();
|
|
216
220
|
|
|
217
221
|
// Load config so we can resolve agent dirs and providers correctly.
|
|
218
|
-
|
|
222
|
+
const origConfig = await api.runtime.config.loadConfig();
|
|
223
|
+
let config = origConfig;
|
|
219
224
|
let configAny = config as Record<string, any>;
|
|
220
225
|
|
|
221
226
|
// ── Read-only enforcement ──────────────────────────────────────────
|
|
@@ -231,6 +236,17 @@ async function runEmbedded(
|
|
|
231
236
|
api.logger.info(`Read-only mode: tools.deny = [${configAny.tools.deny.join(", ")}]`);
|
|
232
237
|
}
|
|
233
238
|
|
|
239
|
+
// ── Additional toolsDeny entries ─────────────────────────────────────
|
|
240
|
+
if (toolsDeny?.length) {
|
|
241
|
+
if (config === origConfig) {
|
|
242
|
+
configAny = JSON.parse(JSON.stringify(origConfig));
|
|
243
|
+
config = configAny as typeof config;
|
|
244
|
+
}
|
|
245
|
+
if (!configAny.tools) configAny.tools = {};
|
|
246
|
+
const existing: string[] = Array.isArray(configAny.tools.deny) ? configAny.tools.deny : [];
|
|
247
|
+
configAny.tools.deny = [...new Set([...existing, ...toolsDeny])];
|
|
248
|
+
}
|
|
249
|
+
|
|
234
250
|
// Resolve workspace and agent dirs from config (ext API ignores agentId).
|
|
235
251
|
const dirs = resolveAgentDirs(agentId, configAny);
|
|
236
252
|
const { workspaceDir, agentDir } = dirs;
|
|
@@ -333,13 +349,30 @@ async function runEmbedded(
|
|
|
333
349
|
const phase = String(data.phase ?? "");
|
|
334
350
|
const toolName = String(data.name ?? "tool");
|
|
335
351
|
const meta = typeof data.meta === "string" ? data.meta : "";
|
|
336
|
-
const
|
|
352
|
+
const rawInput = data.input;
|
|
353
|
+
const input = typeof rawInput === "string" ? rawInput : "";
|
|
354
|
+
|
|
355
|
+
// Parse structured input for richer detail on cli_* tools
|
|
356
|
+
let inputObj: Record<string, any> | null = null;
|
|
357
|
+
if (rawInput && typeof rawInput === "object") {
|
|
358
|
+
inputObj = rawInput as Record<string, any>;
|
|
359
|
+
} else if (input.startsWith("{")) {
|
|
360
|
+
try { inputObj = JSON.parse(input); } catch {}
|
|
361
|
+
}
|
|
337
362
|
|
|
338
363
|
// Tool execution start — emit action with tool name + available context
|
|
339
364
|
if (phase === "start") {
|
|
340
365
|
lastToolAction = toolName;
|
|
341
|
-
|
|
342
|
-
|
|
366
|
+
|
|
367
|
+
// cli_codex / cli_claude / cli_gemini: show working dir and prompt excerpt
|
|
368
|
+
if (toolName.startsWith("cli_") && inputObj) {
|
|
369
|
+
const prompt = String(inputObj.prompt ?? "").slice(0, 250);
|
|
370
|
+
const workDir = inputObj.workingDir ? ` in ${inputObj.workingDir}` : "";
|
|
371
|
+
emit({ type: "action", action: `Running ${toolName}${workDir}`, parameter: prompt });
|
|
372
|
+
} else {
|
|
373
|
+
const detail = input || meta || toolName;
|
|
374
|
+
emit({ type: "action", action: `Running ${toolName}`, parameter: detail.slice(0, 300) });
|
|
375
|
+
}
|
|
343
376
|
}
|
|
344
377
|
|
|
345
378
|
// Tool execution update — partial progress (keeps Linear UI alive for long tools)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
|
2
|
-
import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH } from "./linear-api.js";
|
|
2
|
+
import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, refreshTokenProactively } from "./linear-api.js";
|
|
3
3
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Mocks
|
|
@@ -621,3 +621,190 @@ describe("LinearAgentApi", () => {
|
|
|
621
621
|
});
|
|
622
622
|
});
|
|
623
623
|
});
|
|
624
|
+
|
|
625
|
+
// ===========================================================================
|
|
626
|
+
// refreshTokenProactively
|
|
627
|
+
// ===========================================================================
|
|
628
|
+
|
|
629
|
+
describe("refreshTokenProactively", () => {
|
|
630
|
+
beforeEach(() => {
|
|
631
|
+
delete process.env.LINEAR_CLIENT_ID;
|
|
632
|
+
delete process.env.LINEAR_CLIENT_SECRET;
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("skips refresh when token is still valid (not near expiry)", async () => {
|
|
636
|
+
const profileStore = {
|
|
637
|
+
profiles: {
|
|
638
|
+
"linear:default": {
|
|
639
|
+
accessToken: "still-good",
|
|
640
|
+
refreshToken: "r-tok",
|
|
641
|
+
expiresAt: Date.now() + 10 * 3_600_000, // 10 hours from now
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
646
|
+
|
|
647
|
+
const result = await refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" });
|
|
648
|
+
|
|
649
|
+
expect(result.refreshed).toBe(false);
|
|
650
|
+
expect(result.reason).toBe("token still valid");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("skips refresh when credentials are missing", async () => {
|
|
654
|
+
const profileStore = {
|
|
655
|
+
profiles: {
|
|
656
|
+
"linear:default": {
|
|
657
|
+
accessToken: "expired-tok",
|
|
658
|
+
refreshToken: "r-tok",
|
|
659
|
+
expiresAt: Date.now() - 1000, // expired
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
664
|
+
|
|
665
|
+
// No clientId or clientSecret provided
|
|
666
|
+
const result = await refreshTokenProactively();
|
|
667
|
+
|
|
668
|
+
expect(result.refreshed).toBe(false);
|
|
669
|
+
expect(result.reason).toContain("missing credentials");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("skips refresh when auth-profiles.json is not readable", async () => {
|
|
673
|
+
// Default mockReadFileSync throws ENOENT (from outer beforeEach)
|
|
674
|
+
|
|
675
|
+
const result = await refreshTokenProactively();
|
|
676
|
+
|
|
677
|
+
expect(result.refreshed).toBe(false);
|
|
678
|
+
expect(result.reason).toBe("auth-profiles.json not readable");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("skips refresh when no linear:default profile exists", async () => {
|
|
682
|
+
mockReadFileSync.mockReturnValue(JSON.stringify({ profiles: {} }));
|
|
683
|
+
|
|
684
|
+
const result = await refreshTokenProactively();
|
|
685
|
+
|
|
686
|
+
expect(result.refreshed).toBe(false);
|
|
687
|
+
expect(result.reason).toBe("no linear:default profile found");
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("refreshes expired token and persists to file", async () => {
|
|
691
|
+
const profileStore = {
|
|
692
|
+
profiles: {
|
|
693
|
+
"linear:default": {
|
|
694
|
+
accessToken: "old-tok",
|
|
695
|
+
access: "old-tok",
|
|
696
|
+
refreshToken: "old-refresh",
|
|
697
|
+
refresh: "old-refresh",
|
|
698
|
+
expiresAt: Date.now() - 1000, // expired
|
|
699
|
+
expires: Date.now() - 1000,
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
704
|
+
|
|
705
|
+
mockRefreshLinearToken.mockResolvedValue({
|
|
706
|
+
access_token: "proactive-new-tok",
|
|
707
|
+
refresh_token: "proactive-new-refresh",
|
|
708
|
+
expires_in: 3600,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const result = await refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" });
|
|
712
|
+
|
|
713
|
+
expect(result.refreshed).toBe(true);
|
|
714
|
+
expect(result.reason).toBe("token refreshed successfully");
|
|
715
|
+
|
|
716
|
+
// Verify refreshLinearToken was called with correct args
|
|
717
|
+
// (may have stale calls from outer tests, so check the latest call)
|
|
718
|
+
const calls = mockRefreshLinearToken.mock.calls;
|
|
719
|
+
const lastCall = calls[calls.length - 1];
|
|
720
|
+
expect(lastCall).toEqual(["cid", "csecret", "old-refresh"]);
|
|
721
|
+
|
|
722
|
+
// Verify it wrote back to the file
|
|
723
|
+
const writeCalls = mockWriteFileSync.mock.calls;
|
|
724
|
+
expect(writeCalls.length).toBeGreaterThanOrEqual(1);
|
|
725
|
+
// Get the LAST write call (which is ours)
|
|
726
|
+
const lastWrite = writeCalls[writeCalls.length - 1];
|
|
727
|
+
expect(lastWrite[0]).toBe(AUTH_PROFILES_PATH);
|
|
728
|
+
const writtenData = JSON.parse(lastWrite[1]);
|
|
729
|
+
const profile = writtenData.profiles["linear:default"];
|
|
730
|
+
// Tokens should NOT be the old values
|
|
731
|
+
expect(profile.accessToken).not.toBe("old-tok");
|
|
732
|
+
expect(profile.refreshToken).not.toBe("old-refresh");
|
|
733
|
+
// accessToken and access should match each other
|
|
734
|
+
expect(profile.accessToken).toBe(profile.access);
|
|
735
|
+
expect(profile.refreshToken).toBe(profile.refresh);
|
|
736
|
+
expect(profile.expiresAt).toBeGreaterThan(Date.now());
|
|
737
|
+
expect(profile.expiresAt).toBe(profile.expires);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("refreshes token that is within the 1-hour buffer", async () => {
|
|
741
|
+
const profileStore = {
|
|
742
|
+
profiles: {
|
|
743
|
+
"linear:default": {
|
|
744
|
+
accessToken: "almost-expired-tok",
|
|
745
|
+
refreshToken: "r-tok",
|
|
746
|
+
expiresAt: Date.now() + 30 * 60 * 1000, // 30 min from now (within 1h buffer)
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
751
|
+
|
|
752
|
+
mockRefreshLinearToken.mockResolvedValue({
|
|
753
|
+
access_token: "buffer-refreshed-tok",
|
|
754
|
+
expires_in: 3600,
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
const result = await refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" });
|
|
758
|
+
|
|
759
|
+
expect(result.refreshed).toBe(true);
|
|
760
|
+
expect(result.reason).toBe("token refreshed successfully");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("propagates refresh error to caller", async () => {
|
|
764
|
+
const profileStore = {
|
|
765
|
+
profiles: {
|
|
766
|
+
"linear:default": {
|
|
767
|
+
accessToken: "expired-tok",
|
|
768
|
+
refreshToken: "bad-refresh",
|
|
769
|
+
expiresAt: Date.now() - 1000,
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
774
|
+
|
|
775
|
+
mockRefreshLinearToken.mockRejectedValue(new Error("Linear token refresh failed (400): invalid_grant"));
|
|
776
|
+
|
|
777
|
+
await expect(
|
|
778
|
+
refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" }),
|
|
779
|
+
).rejects.toThrow(/Linear token refresh failed/);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("uses env vars when pluginConfig credentials are missing", async () => {
|
|
783
|
+
const profileStore = {
|
|
784
|
+
profiles: {
|
|
785
|
+
"linear:default": {
|
|
786
|
+
accessToken: "expired-tok",
|
|
787
|
+
refreshToken: "r-tok",
|
|
788
|
+
expiresAt: Date.now() - 1000,
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
|
|
793
|
+
|
|
794
|
+
process.env.LINEAR_CLIENT_ID = "env-cid";
|
|
795
|
+
process.env.LINEAR_CLIENT_SECRET = "env-csecret";
|
|
796
|
+
|
|
797
|
+
mockRefreshLinearToken.mockResolvedValue({
|
|
798
|
+
access_token: "env-refreshed",
|
|
799
|
+
expires_in: 3600,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const result = await refreshTokenProactively(); // no pluginConfig
|
|
803
|
+
|
|
804
|
+
expect(result.refreshed).toBe(true);
|
|
805
|
+
// Verify env vars were used
|
|
806
|
+
const calls = mockRefreshLinearToken.mock.calls;
|
|
807
|
+
const lastCall = calls[calls.length - 1];
|
|
808
|
+
expect(lastCall).toEqual(["env-cid", "env-csecret", "r-tok"]);
|
|
809
|
+
});
|
|
810
|
+
});
|
package/src/api/linear-api.ts
CHANGED
|
@@ -41,8 +41,10 @@ export function resolveLinearToken(pluginConfig?: Record<string, unknown>): {
|
|
|
41
41
|
return { accessToken: fromConfig, source: "config" };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// 2. Auth profile store (from OAuth flow) —
|
|
45
|
-
//
|
|
44
|
+
// 2. Auth profile store (from OAuth flow) — OAuth tokens carry
|
|
45
|
+
// app:assignable/app:mentionable scopes needed for Agent Sessions.
|
|
46
|
+
// Token refresh is handled by the 6-hour proactive timer; if it's
|
|
47
|
+
// expired here, fail loudly so we know the refresh is broken.
|
|
46
48
|
try {
|
|
47
49
|
const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
|
|
48
50
|
const store = JSON.parse(raw);
|
|
@@ -59,7 +61,7 @@ export function resolveLinearToken(pluginConfig?: Record<string, unknown>): {
|
|
|
59
61
|
// Profile store doesn't exist or is unreadable
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
// 3. Env var fallback
|
|
64
|
+
// 3. Env var fallback
|
|
63
65
|
const fromEnv = process.env.LINEAR_ACCESS_TOKEN ?? process.env.LINEAR_API_KEY;
|
|
64
66
|
if (fromEnv) {
|
|
65
67
|
return { accessToken: fromEnv, source: "env" };
|
|
@@ -311,7 +313,7 @@ export class LinearAgentApi {
|
|
|
311
313
|
creator: { name: string; email: string | null } | null;
|
|
312
314
|
assignee: { name: string } | null;
|
|
313
315
|
labels: { nodes: Array<{ id: string; name: string }> };
|
|
314
|
-
team: { id: string; name: string; issueEstimationType: string };
|
|
316
|
+
team: { id: string; key: string; name: string; issueEstimationType: string };
|
|
315
317
|
comments: { nodes: Array<{ body: string; user: { name: string } | null; createdAt: string }> };
|
|
316
318
|
project: { id: string; name: string } | null;
|
|
317
319
|
parent: { id: string; identifier: string } | null;
|
|
@@ -329,7 +331,7 @@ export class LinearAgentApi {
|
|
|
329
331
|
creator { name email }
|
|
330
332
|
assignee { name }
|
|
331
333
|
labels { nodes { id name } }
|
|
332
|
-
team { id name issueEstimationType }
|
|
334
|
+
team { id key name issueEstimationType }
|
|
333
335
|
comments(last: 10) {
|
|
334
336
|
nodes {
|
|
335
337
|
body
|
|
@@ -685,4 +687,111 @@ export class LinearAgentApi {
|
|
|
685
687
|
);
|
|
686
688
|
return data.webhookDelete.success;
|
|
687
689
|
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Get repository suggestions from Linear for an issue.
|
|
693
|
+
* Uses Linear's ML to rank candidate repos by relevance to the issue.
|
|
694
|
+
*/
|
|
695
|
+
async getRepositorySuggestions(
|
|
696
|
+
issueId: string,
|
|
697
|
+
agentSessionId: string,
|
|
698
|
+
candidates: Array<{ hostname: string; repositoryFullName: string }>,
|
|
699
|
+
): Promise<Array<{ repositoryFullName: string; hostname: string; confidence: number }>> {
|
|
700
|
+
if (candidates.length === 0) return [];
|
|
701
|
+
try {
|
|
702
|
+
const data = await this.gql<{
|
|
703
|
+
issueRepositorySuggestions: {
|
|
704
|
+
suggestions: Array<{ repositoryFullName: string; hostname: string; confidence: number }>;
|
|
705
|
+
};
|
|
706
|
+
}>(
|
|
707
|
+
`query RepoSuggestions($issueId: String!, $agentSessionId: String!, $candidateRepositories: [CandidateRepository!]!) {
|
|
708
|
+
issueRepositorySuggestions(issueId: $issueId, agentSessionId: $agentSessionId, candidateRepositories: $candidateRepositories) {
|
|
709
|
+
suggestions {
|
|
710
|
+
repositoryFullName
|
|
711
|
+
hostname
|
|
712
|
+
confidence
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}`,
|
|
716
|
+
{ issueId, agentSessionId, candidateRepositories: candidates },
|
|
717
|
+
);
|
|
718
|
+
return data.issueRepositorySuggestions?.suggestions ?? [];
|
|
719
|
+
} catch {
|
|
720
|
+
// Best-effort — if the API doesn't support this or fails, return empty
|
|
721
|
+
return [];
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
// Proactive token refresh (standalone, no API call required)
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
const PROACTIVE_BUFFER_MS = 3_600_000; // 1 hour before expiry
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Proactively refresh the Linear OAuth token if it's expired or about to expire.
|
|
734
|
+
* Returns true if refreshed, false if skipped (not needed or can't refresh).
|
|
735
|
+
*
|
|
736
|
+
* This is a standalone function that can be called from a timer without making
|
|
737
|
+
* a Linear API request. It reads/writes auth-profiles.json directly.
|
|
738
|
+
*/
|
|
739
|
+
export async function refreshTokenProactively(
|
|
740
|
+
pluginConfig?: Record<string, unknown>,
|
|
741
|
+
): Promise<{ refreshed: boolean; reason: string }> {
|
|
742
|
+
// 1. Read auth-profiles.json, get linear:default profile
|
|
743
|
+
let store: any;
|
|
744
|
+
try {
|
|
745
|
+
const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
|
|
746
|
+
store = JSON.parse(raw);
|
|
747
|
+
} catch {
|
|
748
|
+
return { refreshed: false, reason: "auth-profiles.json not readable" };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const profile = store?.profiles?.["linear:default"];
|
|
752
|
+
if (!profile) {
|
|
753
|
+
return { refreshed: false, reason: "no linear:default profile found" };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// 2. Check if token is expired or will expire within 1 hour
|
|
757
|
+
const expiresAt = profile.expiresAt ?? profile.expires;
|
|
758
|
+
if (typeof expiresAt === "number" && Date.now() < expiresAt - PROACTIVE_BUFFER_MS) {
|
|
759
|
+
return { refreshed: false, reason: "token still valid" };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// 3. Resolve credentials
|
|
763
|
+
const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
|
|
764
|
+
const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
|
|
765
|
+
const refreshToken = profile.refreshToken ?? profile.refresh;
|
|
766
|
+
|
|
767
|
+
if (!clientId || !clientSecret || !refreshToken) {
|
|
768
|
+
return { refreshed: false, reason: "missing credentials (clientId, clientSecret, or refreshToken)" };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// 4. Refresh the token
|
|
772
|
+
const result = await refreshLinearToken(clientId, clientSecret, refreshToken);
|
|
773
|
+
|
|
774
|
+
// 5. Persist updated tokens back to auth-profiles.json (same pattern as persistToken())
|
|
775
|
+
const newAccessToken = result.access_token;
|
|
776
|
+
const newRefreshToken = result.refresh_token ?? refreshToken;
|
|
777
|
+
const newExpiresAt = Date.now() + result.expires_in * 1000;
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
// Re-read to avoid clobbering concurrent writes
|
|
781
|
+
const freshRaw = readFileSync(AUTH_PROFILES_PATH, "utf8");
|
|
782
|
+
const freshStore = JSON.parse(freshRaw);
|
|
783
|
+
if (freshStore.profiles?.["linear:default"]) {
|
|
784
|
+
freshStore.profiles["linear:default"].accessToken = newAccessToken;
|
|
785
|
+
freshStore.profiles["linear:default"].access = newAccessToken;
|
|
786
|
+
freshStore.profiles["linear:default"].refreshToken = newRefreshToken;
|
|
787
|
+
freshStore.profiles["linear:default"].refresh = newRefreshToken;
|
|
788
|
+
freshStore.profiles["linear:default"].expiresAt = newExpiresAt;
|
|
789
|
+
freshStore.profiles["linear:default"].expires = newExpiresAt;
|
|
790
|
+
writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(freshStore, null, 2), "utf8");
|
|
791
|
+
}
|
|
792
|
+
} catch {
|
|
793
|
+
// Best-effort persistence — token was refreshed even if write fails
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return { refreshed: true, reason: "token refreshed successfully" };
|
|
688
797
|
}
|