@calltelemetry/openclaw-linear 0.9.15 → 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.
@@ -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
- let config = await api.runtime.config.loadConfig();
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 input = typeof data.input === "string" ? data.input : "";
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
- const detail = input || meta || toolName;
342
- emit({ type: "action", action: `Running ${toolName}`, parameter: detail.slice(0, 300) });
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
+ });
@@ -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) — preferred because OAuth tokens
45
- // carry app:assignable/app:mentionable scopes needed for Agent Sessions
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 (personal API key — works for comments but not Agent Sessions)
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();