@calltelemetry/openclaw-linear 0.7.1 → 0.8.1
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 +834 -536
- package/index.ts +1 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +46 -6
- 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 +192 -0
- package/src/agent/agent.ts +26 -1
- 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 +30 -25
- 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 +478 -0
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +108 -60
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +424 -251
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
// Mock openclaw/plugin-sdk
|
|
8
|
+
vi.mock("openclaw/plugin-sdk", () => ({
|
|
9
|
+
jsonResult: (data: any) => ({ type: "json", data }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// Mock dispatch-state
|
|
13
|
+
vi.mock("../pipeline/dispatch-state.js", () => ({
|
|
14
|
+
readDispatchState: vi.fn(),
|
|
15
|
+
listActiveDispatches: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock artifacts — resolveOrchestratorWorkspace
|
|
19
|
+
vi.mock("../pipeline/artifacts.js", () => ({
|
|
20
|
+
resolveOrchestratorWorkspace: vi.fn(() => "/mock/workspace"),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock node:fs (readdirSync, readFileSync)
|
|
24
|
+
vi.mock("node:fs", () => ({
|
|
25
|
+
readdirSync: vi.fn(() => []),
|
|
26
|
+
readFileSync: vi.fn(() => ""),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
import { createDispatchHistoryTool } from "./dispatch-history-tool.js";
|
|
30
|
+
import { readDispatchState, listActiveDispatches } from "../pipeline/dispatch-state.js";
|
|
31
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
32
|
+
import type { DispatchState, ActiveDispatch, CompletedDispatch } from "../pipeline/dispatch-state.js";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const mockReadDispatchState = readDispatchState as ReturnType<typeof vi.fn>;
|
|
39
|
+
const mockListActiveDispatches = listActiveDispatches as ReturnType<typeof vi.fn>;
|
|
40
|
+
const mockReaddirSync = readdirSync as unknown as ReturnType<typeof vi.fn>;
|
|
41
|
+
const mockReadFileSync = readFileSync as unknown as ReturnType<typeof vi.fn>;
|
|
42
|
+
|
|
43
|
+
function emptyState(): DispatchState {
|
|
44
|
+
return {
|
|
45
|
+
dispatches: { active: {}, completed: {} },
|
|
46
|
+
sessionMap: {},
|
|
47
|
+
processedEvents: [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeActive(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
|
|
52
|
+
return {
|
|
53
|
+
issueId: "uuid-1",
|
|
54
|
+
issueIdentifier: "CT-100",
|
|
55
|
+
worktreePath: "/tmp/wt/CT-100",
|
|
56
|
+
branch: "codex/CT-100",
|
|
57
|
+
tier: "junior",
|
|
58
|
+
model: "test-model",
|
|
59
|
+
status: "dispatched",
|
|
60
|
+
dispatchedAt: new Date().toISOString(),
|
|
61
|
+
attempt: 0,
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeCompleted(overrides?: Partial<CompletedDispatch>): CompletedDispatch {
|
|
67
|
+
return {
|
|
68
|
+
issueIdentifier: "CT-200",
|
|
69
|
+
tier: "senior",
|
|
70
|
+
status: "done",
|
|
71
|
+
completedAt: new Date().toISOString(),
|
|
72
|
+
totalAttempts: 2,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fakeApi = {
|
|
78
|
+
runtime: { config: { loadConfig: () => ({}) } },
|
|
79
|
+
} as any;
|
|
80
|
+
|
|
81
|
+
function createTool(pluginConfig?: Record<string, unknown>) {
|
|
82
|
+
return createDispatchHistoryTool(fakeApi, pluginConfig) as any;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Tests
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe("dispatch_history tool", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.clearAllMocks();
|
|
92
|
+
// Default: no memory files
|
|
93
|
+
mockReaddirSync.mockReturnValue([]);
|
|
94
|
+
mockReadFileSync.mockReturnValue("");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns empty results when no dispatches exist", async () => {
|
|
98
|
+
mockReadDispatchState.mockResolvedValue(emptyState());
|
|
99
|
+
mockListActiveDispatches.mockReturnValue([]);
|
|
100
|
+
|
|
101
|
+
const tool = createTool();
|
|
102
|
+
const result = await tool.execute("call-1", {});
|
|
103
|
+
|
|
104
|
+
expect(result.data.results).toEqual([]);
|
|
105
|
+
expect(result.data.message).toContain("No dispatch history found");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("finds active dispatch by identifier query", async () => {
|
|
109
|
+
const active = makeActive({ issueIdentifier: "CT-100", tier: "junior", status: "working" });
|
|
110
|
+
const state = emptyState();
|
|
111
|
+
state.dispatches.active["CT-100"] = active;
|
|
112
|
+
|
|
113
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
114
|
+
mockListActiveDispatches.mockReturnValue([active]);
|
|
115
|
+
|
|
116
|
+
const tool = createTool();
|
|
117
|
+
const result = await tool.execute("call-2", { query: "CT-100" });
|
|
118
|
+
|
|
119
|
+
expect(result.data.results).toHaveLength(1);
|
|
120
|
+
expect(result.data.results[0].identifier).toBe("CT-100");
|
|
121
|
+
expect(result.data.results[0].active).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("finds completed dispatch by identifier query", async () => {
|
|
125
|
+
const completed = makeCompleted({ issueIdentifier: "CT-200", tier: "senior", status: "done" });
|
|
126
|
+
const state = emptyState();
|
|
127
|
+
state.dispatches.completed["CT-200"] = completed;
|
|
128
|
+
|
|
129
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
130
|
+
mockListActiveDispatches.mockReturnValue([]);
|
|
131
|
+
|
|
132
|
+
const tool = createTool();
|
|
133
|
+
const result = await tool.execute("call-3", { query: "CT-200" });
|
|
134
|
+
|
|
135
|
+
expect(result.data.results).toHaveLength(1);
|
|
136
|
+
expect(result.data.results[0].identifier).toBe("CT-200");
|
|
137
|
+
expect(result.data.results[0].active).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("filters by tier", async () => {
|
|
141
|
+
const junior = makeActive({ issueIdentifier: "CT-10", tier: "junior", status: "working" });
|
|
142
|
+
const senior = makeActive({ issueIdentifier: "CT-20", tier: "senior", status: "working" });
|
|
143
|
+
const state = emptyState();
|
|
144
|
+
state.dispatches.active["CT-10"] = junior;
|
|
145
|
+
state.dispatches.active["CT-20"] = senior;
|
|
146
|
+
|
|
147
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
148
|
+
mockListActiveDispatches.mockReturnValue([junior, senior]);
|
|
149
|
+
|
|
150
|
+
const tool = createTool();
|
|
151
|
+
const result = await tool.execute("call-4", { tier: "senior" });
|
|
152
|
+
|
|
153
|
+
expect(result.data.results).toHaveLength(1);
|
|
154
|
+
expect(result.data.results[0].identifier).toBe("CT-20");
|
|
155
|
+
expect(result.data.results[0].tier).toBe("senior");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("filters by status", async () => {
|
|
159
|
+
const working = makeActive({ issueIdentifier: "CT-10", status: "working" });
|
|
160
|
+
const auditing = makeActive({ issueIdentifier: "CT-20", status: "auditing" });
|
|
161
|
+
const state = emptyState();
|
|
162
|
+
state.dispatches.active["CT-10"] = working;
|
|
163
|
+
state.dispatches.active["CT-20"] = auditing;
|
|
164
|
+
|
|
165
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
166
|
+
mockListActiveDispatches.mockReturnValue([working, auditing]);
|
|
167
|
+
|
|
168
|
+
const tool = createTool();
|
|
169
|
+
const result = await tool.execute("call-5", { status: "working" });
|
|
170
|
+
|
|
171
|
+
expect(result.data.results).toHaveLength(1);
|
|
172
|
+
expect(result.data.results[0].identifier).toBe("CT-10");
|
|
173
|
+
expect(result.data.results[0].status).toBe("working");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("combines tier + status filters", async () => {
|
|
177
|
+
const a = makeActive({ issueIdentifier: "CT-1", tier: "senior", status: "working" });
|
|
178
|
+
const b = makeActive({ issueIdentifier: "CT-2", tier: "junior", status: "working" });
|
|
179
|
+
const c = makeActive({ issueIdentifier: "CT-3", tier: "senior", status: "dispatched" });
|
|
180
|
+
const state = emptyState();
|
|
181
|
+
state.dispatches.active["CT-1"] = a;
|
|
182
|
+
state.dispatches.active["CT-2"] = b;
|
|
183
|
+
state.dispatches.active["CT-3"] = c;
|
|
184
|
+
|
|
185
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
186
|
+
mockListActiveDispatches.mockReturnValue([a, b, c]);
|
|
187
|
+
|
|
188
|
+
const tool = createTool();
|
|
189
|
+
const result = await tool.execute("call-6", { tier: "senior", status: "working" });
|
|
190
|
+
|
|
191
|
+
expect(result.data.results).toHaveLength(1);
|
|
192
|
+
expect(result.data.results[0].identifier).toBe("CT-1");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("respects limit parameter", async () => {
|
|
196
|
+
const dispatches = Array.from({ length: 5 }, (_, i) =>
|
|
197
|
+
makeActive({ issueIdentifier: `CT-${i + 1}`, tier: "junior", status: "dispatched" }),
|
|
198
|
+
);
|
|
199
|
+
const state = emptyState();
|
|
200
|
+
for (const d of dispatches) {
|
|
201
|
+
state.dispatches.active[d.issueIdentifier] = d;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
205
|
+
mockListActiveDispatches.mockReturnValue(dispatches);
|
|
206
|
+
|
|
207
|
+
const tool = createTool();
|
|
208
|
+
const result = await tool.execute("call-7", { limit: 2 });
|
|
209
|
+
|
|
210
|
+
expect(result.data.results).toHaveLength(2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("matches query substring in memory file content", async () => {
|
|
214
|
+
// No active or completed dispatches — result comes only from memory file.
|
|
215
|
+
// The tool's matchesFilters requires the query to appear in the identifier,
|
|
216
|
+
// so we search for "CT-300" which matches the identifier AND will find
|
|
217
|
+
// the memory file whose content has extra context that gets extracted as summary.
|
|
218
|
+
const state = emptyState();
|
|
219
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
220
|
+
mockListActiveDispatches.mockReturnValue([]);
|
|
221
|
+
|
|
222
|
+
// Memory file for CT-300 contains extra context
|
|
223
|
+
mockReaddirSync.mockReturnValue(["dispatch-CT-300.md"]);
|
|
224
|
+
mockReadFileSync.mockReturnValue(
|
|
225
|
+
"---\ntier: medior\nstatus: done\nattempts: 1\n---\nApplied a workaround for the flaky test in CT-300.",
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const tool = createTool();
|
|
229
|
+
const result = await tool.execute("call-8", { query: "CT-300" });
|
|
230
|
+
|
|
231
|
+
expect(result.data.results).toHaveLength(1);
|
|
232
|
+
expect(result.data.results[0].identifier).toBe("CT-300");
|
|
233
|
+
expect(result.data.results[0].tier).toBe("medior");
|
|
234
|
+
expect(result.data.results[0].summary).toContain("workaround");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns active flag true for active dispatches", async () => {
|
|
238
|
+
const active = makeActive({ issueIdentifier: "CT-50", status: "working" });
|
|
239
|
+
const state = emptyState();
|
|
240
|
+
state.dispatches.active["CT-50"] = active;
|
|
241
|
+
|
|
242
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
243
|
+
mockListActiveDispatches.mockReturnValue([active]);
|
|
244
|
+
|
|
245
|
+
const tool = createTool();
|
|
246
|
+
const result = await tool.execute("call-9", {});
|
|
247
|
+
|
|
248
|
+
expect(result.data.results[0].active).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("returns active flag false for completed dispatches", async () => {
|
|
252
|
+
const completed = makeCompleted({ issueIdentifier: "CT-60" });
|
|
253
|
+
const state = emptyState();
|
|
254
|
+
state.dispatches.completed["CT-60"] = completed;
|
|
255
|
+
|
|
256
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
257
|
+
mockListActiveDispatches.mockReturnValue([]);
|
|
258
|
+
|
|
259
|
+
const tool = createTool();
|
|
260
|
+
const result = await tool.execute("call-10", { query: "CT-60" });
|
|
261
|
+
|
|
262
|
+
expect(result.data.results[0].active).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("handles memory file read errors gracefully (skips, does not crash)", async () => {
|
|
266
|
+
const active = makeActive({ issueIdentifier: "CT-70", status: "working" });
|
|
267
|
+
const state = emptyState();
|
|
268
|
+
state.dispatches.active["CT-70"] = active;
|
|
269
|
+
|
|
270
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
271
|
+
mockListActiveDispatches.mockReturnValue([active]);
|
|
272
|
+
|
|
273
|
+
// Memory dir listing succeeds but reading individual files throws
|
|
274
|
+
mockReaddirSync.mockReturnValue(["dispatch-CT-70.md"]);
|
|
275
|
+
mockReadFileSync.mockImplementation(() => {
|
|
276
|
+
throw new Error("EACCES: permission denied");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const tool = createTool();
|
|
280
|
+
const result = await tool.execute("call-11", {});
|
|
281
|
+
|
|
282
|
+
// Should still return the active dispatch, just without summary enrichment
|
|
283
|
+
expect(result.data.results).toHaveLength(1);
|
|
284
|
+
expect(result.data.results[0].identifier).toBe("CT-70");
|
|
285
|
+
expect(result.data.results[0].summary).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("returns structured result with identifier, tier, status, attempt fields", async () => {
|
|
289
|
+
const active = makeActive({
|
|
290
|
+
issueIdentifier: "CT-80",
|
|
291
|
+
tier: "medior",
|
|
292
|
+
status: "auditing",
|
|
293
|
+
attempt: 3,
|
|
294
|
+
});
|
|
295
|
+
const state = emptyState();
|
|
296
|
+
state.dispatches.active["CT-80"] = active;
|
|
297
|
+
|
|
298
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
299
|
+
mockListActiveDispatches.mockReturnValue([active]);
|
|
300
|
+
|
|
301
|
+
const tool = createTool();
|
|
302
|
+
const result = await tool.execute("call-12", {});
|
|
303
|
+
|
|
304
|
+
const entry = result.data.results[0];
|
|
305
|
+
expect(entry).toEqual(
|
|
306
|
+
expect.objectContaining({
|
|
307
|
+
identifier: "CT-80",
|
|
308
|
+
tier: "medior",
|
|
309
|
+
status: "auditing",
|
|
310
|
+
attempts: 3,
|
|
311
|
+
active: true,
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -30,7 +30,7 @@ function makeIssue(overrides: Partial<ProjectIssue> & { identifier: string; titl
|
|
|
30
30
|
id: overrides.id ?? overrides.identifier.toLowerCase().replace("-", "_"),
|
|
31
31
|
identifier: overrides.identifier,
|
|
32
32
|
title: overrides.title,
|
|
33
|
-
description: "description" in overrides ? overrides.description : "
|
|
33
|
+
description: "description" in overrides ? overrides.description : "As a user, I want this feature so that I can be productive. Given I am logged in, When I click the button, Then the action completes.",
|
|
34
34
|
estimate: "estimate" in overrides ? overrides.estimate : 3,
|
|
35
35
|
priority: "priority" in overrides ? overrides.priority : 2,
|
|
36
36
|
labels: overrides.labels ?? { nodes: [] },
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* before/after calling runAgent(). Tools read from this module-level context
|
|
9
9
|
* at execution time (same pattern as active-session.ts).
|
|
10
10
|
*/
|
|
11
|
-
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
11
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
12
12
|
import { jsonResult } from "openclaw/plugin-sdk";
|
|
13
13
|
import type { LinearAgentApi } from "../api/linear-api.js";
|
|
14
14
|
|
|
@@ -20,6 +20,8 @@ export interface PlannerToolContext {
|
|
|
20
20
|
linearApi: LinearAgentApi;
|
|
21
21
|
projectId: string;
|
|
22
22
|
teamId: string;
|
|
23
|
+
api: OpenClawPluginApi;
|
|
24
|
+
pluginConfig?: Record<string, unknown>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
@@ -151,6 +153,12 @@ export function auditPlan(issues: ProjectIssue[]): AuditResult {
|
|
|
151
153
|
if (!issue.priority || issue.priority === 0) {
|
|
152
154
|
problems.push(`${issue.identifier} "${issue.title}": missing priority`);
|
|
153
155
|
}
|
|
156
|
+
|
|
157
|
+
// Acceptance criteria check (warning, not failure)
|
|
158
|
+
const acMarkers = /\b(given|when|then|as a|i want|so that|acceptance criteria|uat|test scenario)\b/i;
|
|
159
|
+
if (issue.description && !acMarkers.test(issue.description)) {
|
|
160
|
+
warnings.push(`${issue.identifier} "${issue.title}": no acceptance criteria or test scenarios found in description`);
|
|
161
|
+
}
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
|
|
@@ -262,7 +270,7 @@ export function createPlannerTools(): AnyAgentTool[] {
|
|
|
262
270
|
type: "object",
|
|
263
271
|
properties: {
|
|
264
272
|
title: { type: "string", description: "Issue title" },
|
|
265
|
-
description: { type: "string", description: "Issue description
|
|
273
|
+
description: { type: "string", description: "Issue description including: user story (As a...), acceptance criteria (Given/When/Then), and at least one UAT test scenario (min 50 chars)" },
|
|
266
274
|
parentIdentifier: { type: "string", description: "Parent issue identifier (e.g. PROJ-2) to create as sub-issue" },
|
|
267
275
|
isEpic: { type: "boolean", description: "Mark as epic (high-level feature area)" },
|
|
268
276
|
priority: { type: "number", description: "Priority: 1=Urgent, 2=High, 3=Medium, 4=Low" },
|