@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.
@@ -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();
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
3
3
  *
4
- * Three-tier resolution:
4
+ * Four-tier resolution:
5
5
  * 1. Issue body markers: <!-- repos: api, frontend --> or [repos: api, frontend]
6
6
  * 2. Linear labels: repo:api, repo:frontend
7
- * 3. Config default: Falls back to single codexBaseRepo
7
+ * 3. Team mapping: teamMappings[teamKey].repos from plugin config
8
+ * 4. Config default: Falls back to single codexBaseRepo
8
9
  */
9
10
 
10
11
  import { existsSync, statSync } from "node:fs";
@@ -18,7 +19,55 @@ export interface RepoConfig {
18
19
 
19
20
  export interface RepoResolution {
20
21
  repos: RepoConfig[];
21
- source: "issue_body" | "labels" | "config_default";
22
+ source: "issue_body" | "labels" | "team_mapping" | "config_default";
23
+ }
24
+
25
+ /**
26
+ * Enriched repo entry — filesystem path + optional GitHub identity.
27
+ * Supports both plain string paths (backward compat) and objects.
28
+ */
29
+ export interface RepoEntry {
30
+ path: string;
31
+ github?: string; // "owner/repo" format
32
+ hostname?: string; // defaults to "github.com"
33
+ }
34
+
35
+ /**
36
+ * Parse the repos config, normalizing both string and object formats.
37
+ * String values become { path: value }, objects pass through.
38
+ */
39
+ export function getRepoEntries(pluginConfig?: Record<string, unknown>): Record<string, RepoEntry> {
40
+ const repos = pluginConfig?.repos as Record<string, string | Record<string, unknown>> | undefined;
41
+ if (!repos) return {};
42
+ const result: Record<string, RepoEntry> = {};
43
+ for (const [name, value] of Object.entries(repos)) {
44
+ if (typeof value === "string") {
45
+ result[name] = { path: value };
46
+ } else if (value && typeof value === "object") {
47
+ result[name] = {
48
+ path: (value as any).path as string,
49
+ github: (value as any).github as string | undefined,
50
+ hostname: (value as any).hostname as string | undefined,
51
+ };
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Build candidate repositories for Linear's issueRepositorySuggestions API.
59
+ * Extracts GitHub identity from enriched repo entries.
60
+ */
61
+ export function buildCandidateRepositories(
62
+ pluginConfig?: Record<string, unknown>,
63
+ ): Array<{ hostname: string; repositoryFullName: string }> {
64
+ const entries = getRepoEntries(pluginConfig);
65
+ return Object.values(entries)
66
+ .filter(e => e.github)
67
+ .map(e => ({
68
+ hostname: e.hostname ?? "github.com",
69
+ repositoryFullName: e.github!,
70
+ }));
22
71
  }
23
72
 
24
73
  /**
@@ -28,6 +77,7 @@ export function resolveRepos(
28
77
  description: string | null | undefined,
29
78
  labels: string[],
30
79
  pluginConfig?: Record<string, unknown>,
80
+ teamKey?: string,
31
81
  ): RepoResolution {
32
82
  // 1. Check issue body for repo markers
33
83
  // Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
@@ -62,7 +112,21 @@ export function resolveRepos(
62
112
  return { repos, source: "labels" };
63
113
  }
64
114
 
65
- // 3. Config default: single repo
115
+ // 3. Team mapping: teamMappings[teamKey].repos
116
+ if (teamKey) {
117
+ const teamMappings = pluginConfig?.teamMappings as Record<string, Record<string, unknown>> | undefined;
118
+ const teamRepoNames = teamMappings?.[teamKey]?.repos as string[] | undefined;
119
+ if (teamRepoNames && teamRepoNames.length > 0) {
120
+ const repoMap = getRepoMap(pluginConfig);
121
+ const repos = teamRepoNames.map(name => ({
122
+ name,
123
+ path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
124
+ }));
125
+ return { repos, source: "team_mapping" };
126
+ }
127
+ }
128
+
129
+ // 4. Config default: single repo
66
130
  const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
67
131
  return {
68
132
  repos: [{ name: "default", path: baseRepo }],
@@ -71,8 +135,12 @@ export function resolveRepos(
71
135
  }
72
136
 
73
137
  function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
74
- const repos = pluginConfig?.repos as Record<string, string> | undefined;
75
- return repos ?? {};
138
+ const entries = getRepoEntries(pluginConfig);
139
+ const result: Record<string, string> = {};
140
+ for (const [name, entry] of Object.entries(entries)) {
141
+ result[name] = entry.path;
142
+ }
143
+ return result;
76
144
  }
77
145
 
78
146
  function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {