@calltelemetry/openclaw-linear 0.7.1 → 0.8.0

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.
@@ -189,7 +189,7 @@ export class LinearAgentApi {
189
189
 
190
190
  if (!retryRes.ok) {
191
191
  const text = await retryRes.text();
192
- throw new Error(`Linear API ${retryRes.status} (after refresh): ${text}`);
192
+ throw new Error(`Linear API authentication failed (${retryRes.status}). Your token may have expired. Run: openclaw openclaw-linear auth`);
193
193
  }
194
194
 
195
195
  const payload = await retryRes.json();
@@ -375,6 +375,42 @@ export class LinearAgentApi {
375
375
  return data.team.labels.nodes;
376
376
  }
377
377
 
378
+ async getTeams(): Promise<Array<{ id: string; name: string; key: string }>> {
379
+ const data = await this.gql<{
380
+ teams: { nodes: Array<{ id: string; name: string; key: string }> };
381
+ }>(
382
+ `query { teams { nodes { id name key } } }`,
383
+ );
384
+ return data.teams.nodes;
385
+ }
386
+
387
+ async createLabel(
388
+ teamId: string,
389
+ name: string,
390
+ opts?: { color?: string; description?: string },
391
+ ): Promise<{ id: string; name: string }> {
392
+ const input: Record<string, string> = { teamId, name };
393
+ if (opts?.color) input.color = opts.color;
394
+ if (opts?.description) input.description = opts.description;
395
+
396
+ const data = await this.gql<{
397
+ issueLabelCreate: { success: boolean; issueLabel: { id: string; name: string } };
398
+ }>(
399
+ `mutation CreateLabel($input: IssueLabelCreateInput!) {
400
+ issueLabelCreate(input: $input) {
401
+ success
402
+ issueLabel { id name }
403
+ }
404
+ }`,
405
+ { input },
406
+ );
407
+
408
+ if (!data.issueLabelCreate.success) {
409
+ throw new Error(`Failed to create label "${name}"`);
410
+ }
411
+ return data.issueLabelCreate.issueLabel;
412
+ }
413
+
378
414
  // ---------------------------------------------------------------------------
379
415
  // Planning methods
380
416
  // ---------------------------------------------------------------------------
@@ -0,0 +1,409 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock dispatch-state
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const mockReadDispatchState = vi.fn();
8
+ const mockGetActiveDispatch = vi.fn();
9
+ const mockListActiveDispatches = vi.fn();
10
+ const mockTransitionDispatch = vi.fn();
11
+ const mockRemoveActiveDispatch = vi.fn();
12
+ const mockRegisterDispatch = vi.fn();
13
+
14
+ vi.mock("../pipeline/dispatch-state.js", () => ({
15
+ readDispatchState: (...args: any[]) => mockReadDispatchState(...args),
16
+ getActiveDispatch: (...args: any[]) => mockGetActiveDispatch(...args),
17
+ listActiveDispatches: (...args: any[]) => mockListActiveDispatches(...args),
18
+ transitionDispatch: (...args: any[]) => mockTransitionDispatch(...args),
19
+ removeActiveDispatch: (...args: any[]) => mockRemoveActiveDispatch(...args),
20
+ registerDispatch: (...args: any[]) => mockRegisterDispatch(...args),
21
+ TransitionError: class TransitionError extends Error {},
22
+ }));
23
+
24
+ import { registerDispatchMethods } from "./dispatch-methods.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function createApi() {
31
+ const methods: Record<string, Function> = {};
32
+ return {
33
+ api: {
34
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
35
+ pluginConfig: {},
36
+ registerGatewayMethod: (name: string, handler: Function) => {
37
+ methods[name] = handler;
38
+ },
39
+ } as any,
40
+ methods,
41
+ };
42
+ }
43
+
44
+ function makeDispatch(overrides?: Record<string, any>) {
45
+ return {
46
+ issueIdentifier: "CT-100",
47
+ issueId: "issue-id",
48
+ status: "working",
49
+ tier: "senior",
50
+ attempt: 0,
51
+ worktreePath: "/wt/ct-100",
52
+ model: "opus",
53
+ startedAt: new Date().toISOString(),
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function makeState(active: Record<string, any> = {}, completed: Record<string, any> = {}) {
59
+ return {
60
+ dispatches: { active, completed },
61
+ sessionMap: {},
62
+ processedEvents: [],
63
+ };
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Tests
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe("registerDispatchMethods", () => {
71
+ beforeEach(() => {
72
+ vi.clearAllMocks();
73
+ });
74
+
75
+ it("registers all 6 methods", () => {
76
+ const { api, methods } = createApi();
77
+ registerDispatchMethods(api);
78
+ expect(Object.keys(methods)).toEqual(
79
+ expect.arrayContaining([
80
+ "dispatch.list",
81
+ "dispatch.get",
82
+ "dispatch.retry",
83
+ "dispatch.escalate",
84
+ "dispatch.cancel",
85
+ "dispatch.stats",
86
+ ]),
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("dispatch.list", () => {
92
+ beforeEach(() => vi.clearAllMocks());
93
+
94
+ it("returns active and completed dispatches", async () => {
95
+ const { api, methods } = createApi();
96
+ registerDispatchMethods(api);
97
+
98
+ const d = makeDispatch();
99
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }, { "CT-99": { status: "done" } }));
100
+ mockListActiveDispatches.mockReturnValue([d]);
101
+
102
+ const respond = vi.fn();
103
+ await methods["dispatch.list"]({ params: {}, respond });
104
+
105
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
106
+ ok: true,
107
+ active: [d],
108
+ }));
109
+ });
110
+
111
+ it("filters by status", async () => {
112
+ const { api, methods } = createApi();
113
+ registerDispatchMethods(api);
114
+
115
+ const d1 = makeDispatch({ issueIdentifier: "CT-1", status: "working" });
116
+ const d2 = makeDispatch({ issueIdentifier: "CT-2", status: "stuck" });
117
+ mockReadDispatchState.mockResolvedValue(makeState());
118
+ mockListActiveDispatches.mockReturnValue([d1, d2]);
119
+
120
+ const respond = vi.fn();
121
+ await methods["dispatch.list"]({ params: { status: "stuck" }, respond });
122
+
123
+ const result = respond.mock.calls[0][1];
124
+ expect(result.ok).toBe(true);
125
+ expect(result.active).toEqual([d2]);
126
+ });
127
+
128
+ it("filters by tier", async () => {
129
+ const { api, methods } = createApi();
130
+ registerDispatchMethods(api);
131
+
132
+ const d1 = makeDispatch({ issueIdentifier: "CT-1", tier: "junior" });
133
+ const d2 = makeDispatch({ issueIdentifier: "CT-2", tier: "senior" });
134
+ mockReadDispatchState.mockResolvedValue(makeState());
135
+ mockListActiveDispatches.mockReturnValue([d1, d2]);
136
+
137
+ const respond = vi.fn();
138
+ await methods["dispatch.list"]({ params: { tier: "senior" }, respond });
139
+
140
+ const result = respond.mock.calls[0][1];
141
+ expect(result.active).toEqual([d2]);
142
+ });
143
+ });
144
+
145
+ describe("dispatch.get", () => {
146
+ beforeEach(() => vi.clearAllMocks());
147
+
148
+ it("returns active dispatch", async () => {
149
+ const { api, methods } = createApi();
150
+ registerDispatchMethods(api);
151
+
152
+ const d = makeDispatch();
153
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
154
+ mockGetActiveDispatch.mockReturnValue(d);
155
+
156
+ const respond = vi.fn();
157
+ await methods["dispatch.get"]({ params: { identifier: "CT-100" }, respond });
158
+
159
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
160
+ ok: true,
161
+ dispatch: d,
162
+ source: "active",
163
+ }));
164
+ });
165
+
166
+ it("returns completed dispatch when not active", async () => {
167
+ const { api, methods } = createApi();
168
+ registerDispatchMethods(api);
169
+
170
+ const completed = { status: "done", tier: "junior" };
171
+ mockReadDispatchState.mockResolvedValue(makeState({}, { "CT-99": completed }));
172
+ mockGetActiveDispatch.mockReturnValue(undefined);
173
+
174
+ const respond = vi.fn();
175
+ await methods["dispatch.get"]({ params: { identifier: "CT-99" }, respond });
176
+
177
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
178
+ ok: true,
179
+ source: "completed",
180
+ }));
181
+ });
182
+
183
+ it("fails when identifier missing", async () => {
184
+ const { api, methods } = createApi();
185
+ registerDispatchMethods(api);
186
+
187
+ const respond = vi.fn();
188
+ await methods["dispatch.get"]({ params: {}, respond });
189
+
190
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
191
+ ok: false,
192
+ error: expect.stringContaining("identifier"),
193
+ }));
194
+ });
195
+
196
+ it("fails when dispatch not found", async () => {
197
+ const { api, methods } = createApi();
198
+ registerDispatchMethods(api);
199
+
200
+ mockReadDispatchState.mockResolvedValue(makeState());
201
+ mockGetActiveDispatch.mockReturnValue(undefined);
202
+
203
+ const respond = vi.fn();
204
+ await methods["dispatch.get"]({ params: { identifier: "NOPE-1" }, respond });
205
+
206
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
207
+ ok: false,
208
+ error: expect.stringContaining("NOPE-1"),
209
+ }));
210
+ });
211
+ });
212
+
213
+ describe("dispatch.retry", () => {
214
+ beforeEach(() => vi.clearAllMocks());
215
+
216
+ it("retries stuck dispatch", async () => {
217
+ const { api, methods } = createApi();
218
+ registerDispatchMethods(api);
219
+
220
+ const d = makeDispatch({ status: "stuck", attempt: 1 });
221
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
222
+ mockGetActiveDispatch.mockReturnValue(d);
223
+ mockRemoveActiveDispatch.mockResolvedValue(undefined);
224
+ mockRegisterDispatch.mockResolvedValue(undefined);
225
+
226
+ const respond = vi.fn();
227
+ await methods["dispatch.retry"]({ params: { identifier: "CT-100" }, respond });
228
+
229
+ const result = respond.mock.calls[0][1];
230
+ expect(result.ok).toBe(true);
231
+ expect(result.dispatch.status).toBe("dispatched");
232
+ expect(result.dispatch.attempt).toBe(2);
233
+ expect(mockRemoveActiveDispatch).toHaveBeenCalledWith("CT-100", undefined);
234
+ expect(mockRegisterDispatch).toHaveBeenCalled();
235
+ });
236
+
237
+ it("rejects retry for working dispatch", async () => {
238
+ const { api, methods } = createApi();
239
+ registerDispatchMethods(api);
240
+
241
+ const d = makeDispatch({ status: "working" });
242
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
243
+ mockGetActiveDispatch.mockReturnValue(d);
244
+
245
+ const respond = vi.fn();
246
+ await methods["dispatch.retry"]({ params: { identifier: "CT-100" }, respond });
247
+
248
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
249
+ ok: false,
250
+ error: expect.stringContaining("working"),
251
+ }));
252
+ });
253
+
254
+ it("rejects retry for missing dispatch", async () => {
255
+ const { api, methods } = createApi();
256
+ registerDispatchMethods(api);
257
+
258
+ mockReadDispatchState.mockResolvedValue(makeState());
259
+ mockGetActiveDispatch.mockReturnValue(undefined);
260
+
261
+ const respond = vi.fn();
262
+ await methods["dispatch.retry"]({ params: { identifier: "CT-100" }, respond });
263
+
264
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
265
+ ok: false,
266
+ }));
267
+ });
268
+ });
269
+
270
+ describe("dispatch.escalate", () => {
271
+ beforeEach(() => vi.clearAllMocks());
272
+
273
+ it("escalates working dispatch to stuck", async () => {
274
+ const { api, methods } = createApi();
275
+ registerDispatchMethods(api);
276
+
277
+ const d = makeDispatch({ status: "working" });
278
+ const updated = { ...d, status: "stuck", stuckReason: "Manual escalation" };
279
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
280
+ mockGetActiveDispatch.mockReturnValue(d);
281
+ mockTransitionDispatch.mockResolvedValue(updated);
282
+
283
+ const respond = vi.fn();
284
+ await methods["dispatch.escalate"]({ params: { identifier: "CT-100", reason: "Manual escalation" }, respond });
285
+
286
+ const result = respond.mock.calls[0][1];
287
+ expect(result.ok).toBe(true);
288
+ expect(mockTransitionDispatch).toHaveBeenCalledWith("CT-100", "working", "stuck", expect.objectContaining({ stuckReason: "Manual escalation" }), undefined);
289
+ });
290
+
291
+ it("uses default reason when none provided", async () => {
292
+ const { api, methods } = createApi();
293
+ registerDispatchMethods(api);
294
+
295
+ const d = makeDispatch({ status: "auditing" });
296
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
297
+ mockGetActiveDispatch.mockReturnValue(d);
298
+ mockTransitionDispatch.mockResolvedValue(d);
299
+
300
+ const respond = vi.fn();
301
+ await methods["dispatch.escalate"]({ params: { identifier: "CT-100" }, respond });
302
+
303
+ expect(mockTransitionDispatch).toHaveBeenCalledWith(
304
+ "CT-100", "auditing", "stuck",
305
+ expect.objectContaining({ stuckReason: "Manually escalated via gateway" }),
306
+ undefined,
307
+ );
308
+ });
309
+
310
+ it("rejects escalation for stuck dispatch", async () => {
311
+ const { api, methods } = createApi();
312
+ registerDispatchMethods(api);
313
+
314
+ const d = makeDispatch({ status: "stuck" });
315
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
316
+ mockGetActiveDispatch.mockReturnValue(d);
317
+
318
+ const respond = vi.fn();
319
+ await methods["dispatch.escalate"]({ params: { identifier: "CT-100" }, respond });
320
+
321
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
322
+ ok: false,
323
+ error: expect.stringContaining("stuck"),
324
+ }));
325
+ });
326
+ });
327
+
328
+ describe("dispatch.cancel", () => {
329
+ beforeEach(() => vi.clearAllMocks());
330
+
331
+ it("removes active dispatch", async () => {
332
+ const { api, methods } = createApi();
333
+ registerDispatchMethods(api);
334
+
335
+ const d = makeDispatch({ status: "working" });
336
+ mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
337
+ mockGetActiveDispatch.mockReturnValue(d);
338
+ mockRemoveActiveDispatch.mockResolvedValue(undefined);
339
+
340
+ const respond = vi.fn();
341
+ await methods["dispatch.cancel"]({ params: { identifier: "CT-100" }, respond });
342
+
343
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
344
+ ok: true,
345
+ cancelled: "CT-100",
346
+ previousStatus: "working",
347
+ }));
348
+ expect(mockRemoveActiveDispatch).toHaveBeenCalledWith("CT-100", undefined);
349
+ });
350
+
351
+ it("fails for missing dispatch", async () => {
352
+ const { api, methods } = createApi();
353
+ registerDispatchMethods(api);
354
+
355
+ mockReadDispatchState.mockResolvedValue(makeState());
356
+ mockGetActiveDispatch.mockReturnValue(undefined);
357
+
358
+ const respond = vi.fn();
359
+ await methods["dispatch.cancel"]({ params: { identifier: "CT-999" }, respond });
360
+
361
+ expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
362
+ ok: false,
363
+ }));
364
+ });
365
+ });
366
+
367
+ describe("dispatch.stats", () => {
368
+ beforeEach(() => vi.clearAllMocks());
369
+
370
+ it("returns counts by status and tier", async () => {
371
+ const { api, methods } = createApi();
372
+ registerDispatchMethods(api);
373
+
374
+ const active = [
375
+ makeDispatch({ status: "working", tier: "senior" }),
376
+ makeDispatch({ status: "working", tier: "junior" }),
377
+ makeDispatch({ status: "stuck", tier: "senior" }),
378
+ ];
379
+ mockReadDispatchState.mockResolvedValue(makeState({}, { "CT-99": {} }));
380
+ mockListActiveDispatches.mockReturnValue(active);
381
+
382
+ const respond = vi.fn();
383
+ await methods["dispatch.stats"]({ params: {}, respond });
384
+
385
+ const result = respond.mock.calls[0][1];
386
+ expect(result.ok).toBe(true);
387
+ expect(result.activeCount).toBe(3);
388
+ expect(result.completedCount).toBe(1);
389
+ expect(result.byStatus).toEqual({ working: 2, stuck: 1 });
390
+ expect(result.byTier).toEqual({ senior: 2, junior: 1 });
391
+ });
392
+
393
+ it("returns zeros when no dispatches", async () => {
394
+ const { api, methods } = createApi();
395
+ registerDispatchMethods(api);
396
+
397
+ mockReadDispatchState.mockResolvedValue(makeState());
398
+ mockListActiveDispatches.mockReturnValue([]);
399
+
400
+ const respond = vi.fn();
401
+ await methods["dispatch.stats"]({ params: {}, respond });
402
+
403
+ const result = respond.mock.calls[0][1];
404
+ expect(result.activeCount).toBe(0);
405
+ expect(result.completedCount).toBe(0);
406
+ expect(result.byStatus).toEqual({});
407
+ expect(result.byTier).toEqual({});
408
+ });
409
+ });
package/src/infra/cli.ts CHANGED
@@ -8,7 +8,8 @@ import { exec } from "node:child_process";
8
8
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
9
9
  import { join, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
- import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
11
+ import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
12
+ import { validateRepoPath } from "./multi-repo.js";
12
13
  import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "../api/auth.js";
13
14
  import { listWorktrees } from "./codex-worktree.js";
14
15
  import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
@@ -250,6 +251,25 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
250
251
  console.log(`\nTo remove one: openclaw openclaw-linear worktrees --prune <path>\n`);
251
252
  });
252
253
 
254
+ // --- openclaw openclaw-linear repos ---
255
+ const repos = linear
256
+ .command("repos")
257
+ .description("Validate multi-repo config and sync labels to Linear");
258
+
259
+ repos
260
+ .command("check")
261
+ .description("Validate repo paths and show what labels would be created (dry run)")
262
+ .action(async () => {
263
+ await reposAction(api, { dryRun: true });
264
+ });
265
+
266
+ repos
267
+ .command("sync")
268
+ .description("Create missing repo: labels in Linear from your repos config")
269
+ .action(async () => {
270
+ await reposAction(api, { dryRun: false });
271
+ });
272
+
253
273
  // --- openclaw openclaw-linear prompts ---
254
274
  const prompts = linear
255
275
  .command("prompts")
@@ -625,3 +645,158 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
625
645
  }
626
646
  });
627
647
  }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // repos sync / check helper
651
+ // ---------------------------------------------------------------------------
652
+
653
+ const REPO_LABEL_COLOR = "#5e6ad2"; // Linear indigo
654
+
655
+ async function reposAction(
656
+ api: OpenClawPluginApi,
657
+ opts: { dryRun: boolean },
658
+ ): Promise<void> {
659
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
660
+ const reposMap = (pluginConfig?.repos as Record<string, string> | undefined) ?? {};
661
+ const repoNames = Object.keys(reposMap);
662
+
663
+ const mode = opts.dryRun ? "Repos Check" : "Repos Sync";
664
+ console.log(`\n${mode}`);
665
+ console.log("─".repeat(40));
666
+
667
+ // 1. Validate config
668
+ if (repoNames.length === 0) {
669
+ console.log(`\n No "repos" configured in plugin config.`);
670
+ console.log(` Add a repos map to openclaw.json → plugins.entries.openclaw-linear.config:`);
671
+ console.log(`\n "repos": {`);
672
+ console.log(` "api": "/home/claw/repos/api",`);
673
+ console.log(` "frontend": "/home/claw/repos/frontend"`);
674
+ console.log(` }\n`);
675
+ return;
676
+ }
677
+
678
+ // 2. Validate each repo path
679
+ console.log("\n Repos from config:");
680
+ const warnings: string[] = [];
681
+
682
+ for (const name of repoNames) {
683
+ const repoPath = reposMap[name];
684
+ const status = validateRepoPath(repoPath);
685
+ const pad = name.padEnd(16);
686
+
687
+ if (!status.exists) {
688
+ console.log(` \u2717 ${pad} ${repoPath} (path not found)`);
689
+ warnings.push(`"${name}" at ${repoPath} does not exist`);
690
+ } else if (!status.isGitRepo) {
691
+ console.log(` \u2717 ${pad} ${repoPath} (not a git repo)`);
692
+ warnings.push(`"${name}" at ${repoPath} is not a git repository`);
693
+ } else if (status.isSubmodule) {
694
+ console.log(` \u26a0 ${pad} ${repoPath} (submodule)`);
695
+ warnings.push(`"${name}" at ${repoPath} is a git submodule`);
696
+ } else {
697
+ console.log(` \u2714 ${pad} ${repoPath} (git repo)`);
698
+ }
699
+ }
700
+
701
+ // 3. Connect to Linear
702
+ const tokenInfo = resolveLinearToken(pluginConfig);
703
+ if (!tokenInfo.accessToken) {
704
+ console.log(`\n No Linear token found. Run "openclaw openclaw-linear auth" first.\n`);
705
+ process.exitCode = 1;
706
+ return;
707
+ }
708
+
709
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
710
+ refreshToken: tokenInfo.refreshToken,
711
+ expiresAt: tokenInfo.expiresAt,
712
+ clientId: pluginConfig?.clientId as string | undefined,
713
+ clientSecret: pluginConfig?.clientSecret as string | undefined,
714
+ });
715
+
716
+ // 4. Get teams
717
+ let teams: Array<{ id: string; name: string; key: string }>;
718
+ try {
719
+ teams = await linearApi.getTeams();
720
+ } catch (err) {
721
+ console.log(`\n Failed to fetch teams: ${err instanceof Error ? err.message : String(err)}\n`);
722
+ process.exitCode = 1;
723
+ return;
724
+ }
725
+
726
+ if (teams.length === 0) {
727
+ console.log(`\n No teams found in your Linear workspace.\n`);
728
+ return;
729
+ }
730
+
731
+ // 5. Sync labels per team
732
+ let totalCreated = 0;
733
+ let totalExisted = 0;
734
+
735
+ for (const team of teams) {
736
+ console.log(`\n Team: ${team.name} (${team.key})`);
737
+
738
+ let existingLabels: Array<{ id: string; name: string }>;
739
+ try {
740
+ existingLabels = await linearApi.getTeamLabels(team.id);
741
+ } catch (err) {
742
+ console.log(` Failed to fetch labels: ${err instanceof Error ? err.message : String(err)}`);
743
+ continue;
744
+ }
745
+
746
+ const existingNames = new Set(existingLabels.map(l => l.name.toLowerCase()));
747
+
748
+ for (const name of repoNames) {
749
+ const labelName = `repo:${name}`;
750
+
751
+ if (existingNames.has(labelName.toLowerCase())) {
752
+ console.log(` \u2714 ${labelName.padEnd(24)} already exists`);
753
+ totalExisted++;
754
+ } else if (opts.dryRun) {
755
+ console.log(` + ${labelName.padEnd(24)} would be created`);
756
+ } else {
757
+ try {
758
+ await linearApi.createLabel(team.id, labelName, {
759
+ color: REPO_LABEL_COLOR,
760
+ description: `Multi-repo dispatch: ${name}`,
761
+ });
762
+ console.log(` + ${labelName.padEnd(24)} created`);
763
+ totalCreated++;
764
+ } catch (err) {
765
+ console.log(` \u2717 ${labelName.padEnd(24)} failed: ${err instanceof Error ? err.message : String(err)}`);
766
+ }
767
+ }
768
+ }
769
+ }
770
+
771
+ // 6. Summary
772
+ if (opts.dryRun) {
773
+ const wouldCreate = repoNames.length * teams.length - totalExisted;
774
+ console.log(`\n Dry run: ${wouldCreate} label(s) would be created, ${totalExisted} already exist`);
775
+ } else {
776
+ console.log(`\n Summary: ${totalCreated} created, ${totalExisted} already existed`);
777
+ }
778
+
779
+ // 7. Submodule warnings
780
+ const submoduleWarnings = warnings.filter(w => w.includes("submodule"));
781
+ if (submoduleWarnings.length > 0) {
782
+ console.log(`\n \u26a0 Submodule warning:`);
783
+ for (const w of submoduleWarnings) {
784
+ console.log(` ${w}`);
785
+ }
786
+ console.log(` Multi-repo dispatch uses "git worktree add" which doesn't work on submodules.`);
787
+ console.log(` Options:`);
788
+ console.log(` 1. Clone the repo as a standalone repo instead`);
789
+ console.log(` 2. Remove it from "repos" config and use the parent repo as codexBaseRepo`);
790
+ }
791
+
792
+ // Other warnings
793
+ const otherWarnings = warnings.filter(w => !w.includes("submodule"));
794
+ if (otherWarnings.length > 0) {
795
+ console.log(`\n Warnings:`);
796
+ for (const w of otherWarnings) {
797
+ console.log(` \u26a0 ${w}`);
798
+ }
799
+ }
800
+
801
+ console.log();
802
+ }