@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.
- package/README.md +719 -539
- package/index.ts +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/prompts.yaml +19 -5
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +143 -0
- package/src/api/linear-api.test.ts +93 -1
- package/src/api/linear-api.ts +37 -1
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/infra/cli.ts +176 -1
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +29 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +26 -15
- package/src/infra/observability.test.ts +85 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/dispatch-state.ts +1 -0
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +12 -30
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +114 -37
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -0
package/src/api/linear-api.ts
CHANGED
|
@@ -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}
|
|
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
|
+
}
|