@calltelemetry/openclaw-linear 0.9.15 → 0.9.17
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 +7 -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 +225 -0
- package/src/infra/tmux.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/pipeline.ts +184 -17
- package/src/pipeline/webhook.test.ts +1 -1
- package/src/pipeline/webhook.ts +271 -30
- 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/planner-tools.ts +1 -0
- package/src/tools/steering-tools.ts +141 -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
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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { resolveRepos, isMultiRepo, validateRepoPath, type RepoResolution } from "./multi-repo.ts";
|
|
4
|
+
import { resolveRepos, isMultiRepo, validateRepoPath, getRepoEntries, buildCandidateRepositories, type RepoResolution } from "./multi-repo.ts";
|
|
5
5
|
|
|
6
6
|
vi.mock("node:fs", async (importOriginal) => {
|
|
7
7
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
@@ -131,6 +131,132 @@ describe("isMultiRepo", () => {
|
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
describe("getRepoEntries", () => {
|
|
135
|
+
it("normalizes string values to RepoEntry objects", () => {
|
|
136
|
+
const config = { repos: { api: "/tmp/api", frontend: "/tmp/frontend" } };
|
|
137
|
+
const entries = getRepoEntries(config);
|
|
138
|
+
expect(entries.api).toEqual({ path: "/tmp/api" });
|
|
139
|
+
expect(entries.frontend).toEqual({ path: "/tmp/frontend" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("passes through object values with github and hostname", () => {
|
|
143
|
+
const config = {
|
|
144
|
+
repos: {
|
|
145
|
+
api: { path: "/tmp/api", github: "org/api", hostname: "github.example.com" },
|
|
146
|
+
frontend: { path: "/tmp/frontend", github: "org/frontend" },
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const entries = getRepoEntries(config);
|
|
150
|
+
expect(entries.api).toEqual({ path: "/tmp/api", github: "org/api", hostname: "github.example.com" });
|
|
151
|
+
expect(entries.frontend).toEqual({ path: "/tmp/frontend", github: "org/frontend", hostname: undefined });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("handles mixed string and object repos", () => {
|
|
155
|
+
const config = {
|
|
156
|
+
repos: {
|
|
157
|
+
api: { path: "/tmp/api", github: "org/api" },
|
|
158
|
+
legacy: "/tmp/legacy",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const entries = getRepoEntries(config);
|
|
162
|
+
expect(entries.api.github).toBe("org/api");
|
|
163
|
+
expect(entries.legacy).toEqual({ path: "/tmp/legacy" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns empty object when no repos config", () => {
|
|
167
|
+
expect(getRepoEntries({})).toEqual({});
|
|
168
|
+
expect(getRepoEntries(undefined)).toEqual({});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("buildCandidateRepositories", () => {
|
|
173
|
+
it("builds candidates from repos with github field", () => {
|
|
174
|
+
const config = {
|
|
175
|
+
repos: {
|
|
176
|
+
api: { path: "/tmp/api", github: "calltelemetry/cisco-cdr" },
|
|
177
|
+
frontend: { path: "/tmp/frontend", github: "calltelemetry/ct-quasar" },
|
|
178
|
+
legacy: "/tmp/legacy",
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const candidates = buildCandidateRepositories(config);
|
|
182
|
+
expect(candidates).toHaveLength(2);
|
|
183
|
+
expect(candidates[0]).toEqual({ hostname: "github.com", repositoryFullName: "calltelemetry/cisco-cdr" });
|
|
184
|
+
expect(candidates[1]).toEqual({ hostname: "github.com", repositoryFullName: "calltelemetry/ct-quasar" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("uses custom hostname when provided", () => {
|
|
188
|
+
const config = {
|
|
189
|
+
repos: {
|
|
190
|
+
api: { path: "/tmp/api", github: "org/api", hostname: "git.corp.com" },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const candidates = buildCandidateRepositories(config);
|
|
194
|
+
expect(candidates[0].hostname).toBe("git.corp.com");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns empty array when no repos have github", () => {
|
|
198
|
+
const config = { repos: { api: "/tmp/api" } };
|
|
199
|
+
expect(buildCandidateRepositories(config)).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("resolveRepos with team mapping", () => {
|
|
204
|
+
const config = {
|
|
205
|
+
repos: {
|
|
206
|
+
api: { path: "/tmp/api", github: "org/api" },
|
|
207
|
+
frontend: { path: "/tmp/frontend", github: "org/frontend" },
|
|
208
|
+
},
|
|
209
|
+
teamMappings: {
|
|
210
|
+
API: { repos: ["api"], defaultAgent: "kaylee" },
|
|
211
|
+
UAT: { repos: ["api", "frontend"] },
|
|
212
|
+
MED: { context: "Media team" },
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
it("uses team mapping when no body markers or labels", () => {
|
|
217
|
+
const result = resolveRepos("Plain description", [], config, "API");
|
|
218
|
+
expect(result.source).toBe("team_mapping");
|
|
219
|
+
expect(result.repos).toHaveLength(1);
|
|
220
|
+
expect(result.repos[0].name).toBe("api");
|
|
221
|
+
expect(result.repos[0].path).toBe("/tmp/api");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("team mapping resolves multi-repo teams", () => {
|
|
225
|
+
const result = resolveRepos("Plain description", [], config, "UAT");
|
|
226
|
+
expect(result.source).toBe("team_mapping");
|
|
227
|
+
expect(result.repos).toHaveLength(2);
|
|
228
|
+
expect(result.repos[0].name).toBe("api");
|
|
229
|
+
expect(result.repos[1].name).toBe("frontend");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("body markers take priority over team mapping", () => {
|
|
233
|
+
const result = resolveRepos("<!-- repos: frontend -->", [], config, "API");
|
|
234
|
+
expect(result.source).toBe("issue_body");
|
|
235
|
+
expect(result.repos[0].name).toBe("frontend");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("labels take priority over team mapping", () => {
|
|
239
|
+
const result = resolveRepos("No markers", ["repo:frontend"], config, "API");
|
|
240
|
+
expect(result.source).toBe("labels");
|
|
241
|
+
expect(result.repos[0].name).toBe("frontend");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("falls back to config_default when team has no repos", () => {
|
|
245
|
+
const result = resolveRepos("Plain description", [], config, "MED");
|
|
246
|
+
expect(result.source).toBe("config_default");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("falls back to config_default when teamKey is unknown", () => {
|
|
250
|
+
const result = resolveRepos("Plain description", [], config, "UNKNOWN");
|
|
251
|
+
expect(result.source).toBe("config_default");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("falls back to config_default when no teamKey provided", () => {
|
|
255
|
+
const result = resolveRepos("Plain description", [], config);
|
|
256
|
+
expect(result.source).toBe("config_default");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
134
260
|
describe("validateRepoPath", () => {
|
|
135
261
|
beforeEach(() => {
|
|
136
262
|
vi.restoreAllMocks();
|