@calltelemetry/openclaw-linear 0.9.5 → 0.9.7

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.
@@ -5,7 +5,7 @@
5
5
  * resolveAgentFromAlias() implementations that were previously in
6
6
  * webhook.ts, intent-classify.ts, and tier-assess.ts.
7
7
  */
8
- import { readFileSync } from "node:fs";
8
+ import { readFileSync, existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
 
@@ -107,6 +107,64 @@ export function resolveDefaultAgent(api: { pluginConfig?: Record<string, unknown
107
107
  return "default";
108
108
  }
109
109
 
110
+ // ---------------------------------------------------------------------------
111
+ // Profile validation — returns a user-facing error string or null if OK.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Validate that agent-profiles.json exists, is parseable, and has at least
116
+ * one agent. Returns a human-readable error string suitable for posting
117
+ * back to Linear, or null when everything looks good.
118
+ */
119
+ export function validateProfiles(): string | null {
120
+ if (!existsSync(PROFILES_PATH)) {
121
+ return (
122
+ `**Critical setup error:** \`agent-profiles.json\` not found.\n\n` +
123
+ `The Linear plugin requires this file to route messages to your agent.\n\n` +
124
+ `**Create it now:**\n` +
125
+ "```\n" +
126
+ `cat > ${PROFILES_PATH} << 'EOF'\n` +
127
+ `{\n` +
128
+ ` "agents": {\n` +
129
+ ` "my-agent": {\n` +
130
+ ` "label": "My Agent",\n` +
131
+ ` "mission": "AI assistant",\n` +
132
+ ` "isDefault": true,\n` +
133
+ ` "mentionAliases": ["my-agent"]\n` +
134
+ ` }\n` +
135
+ ` }\n` +
136
+ `}\n` +
137
+ `EOF\n` +
138
+ "```\n\n" +
139
+ `Then restart the gateway: \`systemctl --user restart openclaw-gateway\`\n\n` +
140
+ `Run \`openclaw openclaw-linear doctor\` to verify your setup.`
141
+ );
142
+ }
143
+
144
+ let profiles: Record<string, unknown>;
145
+ try {
146
+ const raw = readFileSync(PROFILES_PATH, "utf8");
147
+ profiles = JSON.parse(raw).agents ?? {};
148
+ } catch (err) {
149
+ return (
150
+ `**Critical setup error:** \`agent-profiles.json\` exists but could not be parsed.\n\n` +
151
+ `Error: ${err instanceof Error ? err.message : String(err)}\n\n` +
152
+ `Fix the JSON syntax in \`${PROFILES_PATH}\` and restart the gateway.\n` +
153
+ `Run \`openclaw openclaw-linear doctor\` to verify.`
154
+ );
155
+ }
156
+
157
+ if (Object.keys(profiles).length === 0) {
158
+ return (
159
+ `**Critical setup error:** \`agent-profiles.json\` has no agents configured.\n\n` +
160
+ `Add at least one agent entry to the \`"agents"\` object in \`${PROFILES_PATH}\`.\n` +
161
+ `Run \`openclaw openclaw-linear doctor\` for a guided setup check.`
162
+ );
163
+ }
164
+
165
+ return null;
166
+ }
167
+
110
168
  // ---------------------------------------------------------------------------
111
169
  // Test-only: reset cache
112
170
  // ---------------------------------------------------------------------------
@@ -0,0 +1,388 @@
1
+ /**
2
+ * sub-issue-decomposition.test.ts — Mock replay of sub-issue creation flow.
3
+ *
4
+ * Uses recorded API responses from the smoke test to verify parent-child
5
+ * hierarchy creation, parentId resolution, and issue relation handling.
6
+ *
7
+ * Run with: npx vitest run src/pipeline/sub-issue-decomposition.test.ts
8
+ * No credentials required — all API calls use recorded fixtures.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
+
12
+ // Mock external dependencies before imports
13
+ vi.mock("openclaw/plugin-sdk", () => ({
14
+ jsonResult: (data: any) => ({ type: "json", data }),
15
+ }));
16
+
17
+ vi.mock("../api/linear-api.js", () => ({
18
+ LinearAgentApi: vi.fn(),
19
+ }));
20
+
21
+ import { RECORDED } from "../__test__/fixtures/recorded-sub-issue-flow.js";
22
+ import {
23
+ createPlannerTools,
24
+ setActivePlannerContext,
25
+ clearActivePlannerContext,
26
+ detectCycles,
27
+ auditPlan,
28
+ buildPlanSnapshot,
29
+ } from "../tools/planner-tools.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ type ProjectIssue = Parameters<typeof detectCycles>[0][number];
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function createReplayApi() {
42
+ const api = {
43
+ getTeamStates: vi.fn().mockResolvedValue(RECORDED.teamStates),
44
+ createIssue: vi.fn(),
45
+ getIssueDetails: vi.fn(),
46
+ createIssueRelation: vi.fn().mockResolvedValue(RECORDED.createRelation),
47
+ getProjectIssues: vi.fn(),
48
+ getTeamLabels: vi.fn().mockResolvedValue([]),
49
+ updateIssue: vi.fn().mockResolvedValue(true),
50
+ updateIssueExtended: vi.fn().mockResolvedValue(true),
51
+ getViewerId: vi.fn().mockResolvedValue("viewer-1"),
52
+ createComment: vi.fn().mockResolvedValue("comment-id"),
53
+ emitActivity: vi.fn().mockResolvedValue(undefined),
54
+ updateSession: vi.fn().mockResolvedValue(undefined),
55
+ getProject: vi.fn().mockResolvedValue({
56
+ id: "proj-1",
57
+ name: "Test",
58
+ description: "",
59
+ state: "started",
60
+ teams: {
61
+ nodes: [
62
+ {
63
+ id: RECORDED.parentDetails.team.id,
64
+ name: RECORDED.parentDetails.team.name,
65
+ },
66
+ ],
67
+ },
68
+ }),
69
+ };
70
+
71
+ // Wire up getIssueDetails to return recorded response by ID
72
+ api.getIssueDetails.mockImplementation((id: string) => {
73
+ if (id === RECORDED.createParent.id)
74
+ return Promise.resolve(RECORDED.parentDetails);
75
+ if (id === RECORDED.createSubIssue1.id)
76
+ return Promise.resolve(RECORDED.subIssue1WithRelation);
77
+ if (id === RECORDED.createSubIssue2.id)
78
+ return Promise.resolve(RECORDED.subIssue2WithRelation);
79
+ throw new Error(`Unexpected issue ID in replay: ${id}`);
80
+ });
81
+
82
+ return api;
83
+ }
84
+
85
+ /** Build a ProjectIssue from recorded detail shapes. */
86
+ function recordedToProjectIssue(
87
+ detail: typeof RECORDED.parentDetails,
88
+ overrides?: Partial<ProjectIssue>,
89
+ ): ProjectIssue {
90
+ return {
91
+ id: detail.id,
92
+ identifier: detail.identifier,
93
+ title: detail.title,
94
+ description: detail.description,
95
+ estimate: detail.estimate,
96
+ priority: 0,
97
+ state: detail.state,
98
+ parent: detail.parent,
99
+ labels: detail.labels,
100
+ relations: detail.relations,
101
+ ...overrides,
102
+ } as ProjectIssue;
103
+ }
104
+
105
+ // ===========================================================================
106
+ // Group A: Direct API hierarchy (mock createIssue / getIssueDetails)
107
+ // ===========================================================================
108
+
109
+ describe("sub-issue decomposition (recorded replay)", () => {
110
+ describe("parent-child hierarchy via direct API", () => {
111
+ it("createIssue with parentId creates a sub-issue", async () => {
112
+ const api = createReplayApi();
113
+ api.createIssue.mockResolvedValueOnce(RECORDED.createSubIssue1);
114
+
115
+ const result = await api.createIssue({
116
+ teamId: RECORDED.parentDetails.team.id,
117
+ title: RECORDED.subIssue1Details.title,
118
+ parentId: RECORDED.createParent.id,
119
+ estimate: 2,
120
+ priority: 3,
121
+ });
122
+
123
+ expect(result.id).toBe(RECORDED.createSubIssue1.id);
124
+ expect(result.identifier).toBe(RECORDED.createSubIssue1.identifier);
125
+ expect(api.createIssue).toHaveBeenCalledWith(
126
+ expect.objectContaining({
127
+ parentId: RECORDED.createParent.id,
128
+ }),
129
+ );
130
+ });
131
+
132
+ it("getIssueDetails of sub-issue returns parent reference", async () => {
133
+ const api = createReplayApi();
134
+ const details = await api.getIssueDetails(
135
+ RECORDED.createSubIssue1.id,
136
+ );
137
+
138
+ expect(details.parent).not.toBeNull();
139
+ expect(details.parent!.id).toBe(RECORDED.createParent.id);
140
+ expect(details.parent!.identifier).toBe(
141
+ RECORDED.createParent.identifier,
142
+ );
143
+ });
144
+
145
+ it("getIssueDetails of parent returns null parent (root)", async () => {
146
+ const api = createReplayApi();
147
+ const details = await api.getIssueDetails(RECORDED.createParent.id);
148
+
149
+ expect(details.parent).toBeNull();
150
+ });
151
+
152
+ it("createIssueRelation creates blocks dependency", async () => {
153
+ const api = createReplayApi();
154
+ const result = await api.createIssueRelation({
155
+ issueId: RECORDED.createSubIssue1.id,
156
+ relatedIssueId: RECORDED.createSubIssue2.id,
157
+ type: "blocks",
158
+ });
159
+
160
+ expect(result.id).toBe(RECORDED.createRelation.id);
161
+ expect(api.createIssueRelation).toHaveBeenCalledWith({
162
+ issueId: RECORDED.createSubIssue1.id,
163
+ relatedIssueId: RECORDED.createSubIssue2.id,
164
+ type: "blocks",
165
+ });
166
+ });
167
+
168
+ it("sub-issue details include blocks relation after linking", async () => {
169
+ const api = createReplayApi();
170
+ const details = await api.getIssueDetails(
171
+ RECORDED.createSubIssue1.id,
172
+ );
173
+
174
+ const blocksRels = details.relations.nodes.filter(
175
+ (r: any) => r.type === "blocks",
176
+ );
177
+ expect(blocksRels.length).toBeGreaterThan(0);
178
+ expect(
179
+ blocksRels.some(
180
+ (r: any) => r.relatedIssue.id === RECORDED.createSubIssue2.id,
181
+ ),
182
+ ).toBe(true);
183
+ });
184
+ });
185
+
186
+ // =========================================================================
187
+ // Group B: Planner tools (real tool code, mocked API)
188
+ // =========================================================================
189
+
190
+ describe("planner tools: parentIdentifier resolution", () => {
191
+ let tools: any[];
192
+ let mockApi: ReturnType<typeof createReplayApi>;
193
+
194
+ beforeEach(() => {
195
+ vi.clearAllMocks();
196
+ mockApi = createReplayApi();
197
+ setActivePlannerContext({
198
+ linearApi: mockApi as any,
199
+ projectId: "proj-1",
200
+ teamId: RECORDED.parentDetails.team.id,
201
+ api: { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } } as any,
202
+ });
203
+ tools = createPlannerTools();
204
+ });
205
+
206
+ afterEach(() => {
207
+ clearActivePlannerContext();
208
+ });
209
+
210
+ function findTool(name: string) {
211
+ const tool = tools.find((t: any) => t.name === name) as any;
212
+ if (!tool) throw new Error(`Tool '${name}' not found`);
213
+ return tool;
214
+ }
215
+
216
+ it("plan_create_issue resolves parentIdentifier to parentId", async () => {
217
+ // Mock getProjectIssues to return the parent issue
218
+ mockApi.getProjectIssues.mockResolvedValueOnce([
219
+ recordedToProjectIssue(RECORDED.parentDetails),
220
+ ]);
221
+ mockApi.createIssue.mockResolvedValueOnce(RECORDED.createSubIssue1);
222
+
223
+ const tool = findTool("plan_create_issue");
224
+ const result = await tool.execute("call-1", {
225
+ title: RECORDED.subIssue1Details.title,
226
+ description: RECORDED.subIssue1Details.description,
227
+ parentIdentifier: RECORDED.createParent.identifier,
228
+ estimate: 2,
229
+ priority: 3,
230
+ });
231
+
232
+ // Verify createIssue was called with resolved parentId (not identifier)
233
+ expect(mockApi.createIssue).toHaveBeenCalledWith(
234
+ expect.objectContaining({
235
+ parentId: RECORDED.createParent.id,
236
+ }),
237
+ );
238
+ expect(result.data.identifier).toBe(
239
+ RECORDED.createSubIssue1.identifier,
240
+ );
241
+ });
242
+
243
+ it("plan_link_issues creates blocks relation between resolved IDs", async () => {
244
+ // Mock getProjectIssues to return both sub-issues
245
+ mockApi.getProjectIssues.mockResolvedValueOnce([
246
+ recordedToProjectIssue(RECORDED.subIssue1WithRelation),
247
+ recordedToProjectIssue(RECORDED.subIssue2WithRelation),
248
+ ]);
249
+
250
+ const tool = findTool("plan_link_issues");
251
+ const result = await tool.execute("call-2", {
252
+ fromIdentifier: RECORDED.subIssue1WithRelation.identifier,
253
+ toIdentifier: RECORDED.subIssue2WithRelation.identifier,
254
+ type: "blocks",
255
+ });
256
+
257
+ expect(mockApi.createIssueRelation).toHaveBeenCalledWith({
258
+ issueId: RECORDED.subIssue1WithRelation.id,
259
+ relatedIssueId: RECORDED.subIssue2WithRelation.id,
260
+ type: "blocks",
261
+ });
262
+ expect(result.data.id).toBe(RECORDED.createRelation.id);
263
+ expect(result.data.type).toBe("blocks");
264
+ });
265
+
266
+ it("plan_get_project shows hierarchy with parent-child nesting", async () => {
267
+ // Return all 3 issues (parent + 2 subs)
268
+ mockApi.getProjectIssues.mockResolvedValueOnce([
269
+ recordedToProjectIssue(RECORDED.parentDetails),
270
+ recordedToProjectIssue(RECORDED.subIssue1WithRelation),
271
+ recordedToProjectIssue(RECORDED.subIssue2WithRelation),
272
+ ]);
273
+
274
+ const tool = findTool("plan_get_project");
275
+ const result = await tool.execute("call-3", {});
276
+ const snapshot = result.data?.snapshot ?? result.data?.plan ?? "";
277
+
278
+ // All three identifiers should appear
279
+ expect(snapshot).toContain(RECORDED.createParent.identifier);
280
+ expect(snapshot).toContain(RECORDED.createSubIssue1.identifier);
281
+ expect(snapshot).toContain(RECORDED.createSubIssue2.identifier);
282
+ });
283
+
284
+ it("plan_audit passes valid sub-issue hierarchy", async () => {
285
+ // Build issues that pass audit: descriptions >= 50 chars, estimate, priority set
286
+ const parent = recordedToProjectIssue(RECORDED.parentDetails, {
287
+ priority: 2,
288
+ estimate: 5,
289
+ });
290
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
291
+ priority: 3,
292
+ estimate: 2,
293
+ });
294
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
295
+ priority: 3,
296
+ estimate: 3,
297
+ });
298
+
299
+ mockApi.getProjectIssues.mockResolvedValueOnce([parent, sub1, sub2]);
300
+
301
+ const tool = findTool("plan_audit");
302
+ const result = await tool.execute("call-4", {});
303
+
304
+ expect(result.data.pass).toBe(true);
305
+ expect(result.data.problems).toHaveLength(0);
306
+ });
307
+ });
308
+
309
+ // =========================================================================
310
+ // Group C: auditPlan pure function with recorded data shapes
311
+ // =========================================================================
312
+
313
+ describe("auditPlan with parent-child relationships", () => {
314
+ it("issues with parent are not flagged as orphans", () => {
315
+ const parent = recordedToProjectIssue(RECORDED.parentDetails, {
316
+ priority: 2,
317
+ estimate: 5,
318
+ });
319
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1Details, {
320
+ priority: 3,
321
+ estimate: 2,
322
+ });
323
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2Details, {
324
+ priority: 3,
325
+ estimate: 3,
326
+ });
327
+
328
+ const result = auditPlan([parent, sub1, sub2]);
329
+
330
+ // Sub-issues have parent set, so they're not orphans.
331
+ // Parent may be flagged as orphan (no parent, no relations linking to it)
332
+ // but sub-issues definitely should NOT be orphans.
333
+ const orphanWarnings = result.warnings.filter((w) =>
334
+ w.includes("orphan"),
335
+ );
336
+ const subOrphans = orphanWarnings.filter(
337
+ (w) =>
338
+ w.includes(RECORDED.subIssue1Details.identifier) ||
339
+ w.includes(RECORDED.subIssue2Details.identifier),
340
+ );
341
+ expect(subOrphans).toHaveLength(0);
342
+ });
343
+
344
+ it("blocks relation between sub-issues produces valid DAG", () => {
345
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
346
+ priority: 3,
347
+ estimate: 2,
348
+ });
349
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
350
+ priority: 3,
351
+ estimate: 3,
352
+ });
353
+
354
+ const cycles = detectCycles([sub1, sub2]);
355
+ expect(cycles).toHaveLength(0);
356
+ });
357
+
358
+ it("buildPlanSnapshot nests sub-issues under parent", () => {
359
+ const parent = recordedToProjectIssue(RECORDED.parentDetails, {
360
+ priority: 2,
361
+ estimate: 5,
362
+ });
363
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
364
+ priority: 3,
365
+ estimate: 2,
366
+ });
367
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
368
+ priority: 3,
369
+ estimate: 3,
370
+ });
371
+
372
+ const snapshot = buildPlanSnapshot([parent, sub1, sub2]);
373
+
374
+ // Parent should appear
375
+ expect(snapshot).toContain(RECORDED.createParent.identifier);
376
+ // Sub-issues should appear
377
+ expect(snapshot).toContain(RECORDED.createSubIssue1.identifier);
378
+ expect(snapshot).toContain(RECORDED.createSubIssue2.identifier);
379
+ // Sub-issues should be indented (nested under parent)
380
+ const lines = snapshot.split("\n");
381
+ const sub1Line = lines.find((l) =>
382
+ l.includes(RECORDED.createSubIssue1.identifier),
383
+ );
384
+ expect(sub1Line).toBeTruthy();
385
+ expect(sub1Line!.startsWith(" ")).toBe(true);
386
+ });
387
+ });
388
+ });
@@ -14,6 +14,7 @@ const {
14
14
  loadAgentProfilesMock,
15
15
  buildMentionPatternMock,
16
16
  resolveAgentFromAliasMock,
17
+ validateProfilesMock,
17
18
  resetProfilesCacheMock,
18
19
  classifyIntentMock,
19
20
  extractGuidanceMock,
@@ -24,6 +25,9 @@ const {
24
25
  resetGuidanceCacheMock,
25
26
  setActiveSessionMock,
26
27
  clearActiveSessionMock,
28
+ getIssueAffinityMock,
29
+ configureAffinityTtlMock,
30
+ resetAffinityForTestingMock,
27
31
  readDispatchStateMock,
28
32
  getActiveDispatchMock,
29
33
  registerDispatchMock,
@@ -83,6 +87,7 @@ const {
83
87
  }),
84
88
  buildMentionPatternMock: vi.fn().mockReturnValue(/@(mal|mason|kaylee|eureka)/i),
85
89
  resolveAgentFromAliasMock: vi.fn().mockReturnValue(null),
90
+ validateProfilesMock: vi.fn().mockReturnValue(null),
86
91
  resetProfilesCacheMock: vi.fn(),
87
92
  classifyIntentMock: vi.fn().mockResolvedValue({
88
93
  intent: "general",
@@ -97,6 +102,9 @@ const {
97
102
  resetGuidanceCacheMock: vi.fn(),
98
103
  setActiveSessionMock: vi.fn(),
99
104
  clearActiveSessionMock: vi.fn(),
105
+ getIssueAffinityMock: vi.fn().mockReturnValue(null),
106
+ configureAffinityTtlMock: vi.fn(),
107
+ resetAffinityForTestingMock: vi.fn(),
100
108
  readDispatchStateMock: vi.fn().mockResolvedValue({ activeDispatches: {} }),
101
109
  getActiveDispatchMock: vi.fn().mockReturnValue(null),
102
110
  registerDispatchMock: vi.fn().mockResolvedValue(undefined),
@@ -148,6 +156,7 @@ vi.mock("../infra/shared-profiles.js", () => ({
148
156
  loadAgentProfiles: loadAgentProfilesMock,
149
157
  buildMentionPattern: buildMentionPatternMock,
150
158
  resolveAgentFromAlias: resolveAgentFromAliasMock,
159
+ validateProfiles: validateProfilesMock,
151
160
  _resetProfilesCacheForTesting: resetProfilesCacheMock,
152
161
  }));
153
162
 
@@ -167,9 +176,9 @@ vi.mock("./guidance.js", () => ({
167
176
  vi.mock("./active-session.js", () => ({
168
177
  setActiveSession: setActiveSessionMock,
169
178
  clearActiveSession: clearActiveSessionMock,
170
- getIssueAffinity: vi.fn().mockReturnValue(null),
171
- _configureAffinityTtl: vi.fn(),
172
- _resetAffinityForTesting: vi.fn(),
179
+ getIssueAffinity: getIssueAffinityMock,
180
+ _configureAffinityTtl: configureAffinityTtlMock,
181
+ _resetAffinityForTesting: resetAffinityForTestingMock,
173
182
  }));
174
183
 
175
184
  vi.mock("./dispatch-state.js", () => ({
@@ -346,6 +355,7 @@ afterEach(() => {
346
355
  });
347
356
  buildMentionPatternMock.mockReset().mockReturnValue(/@(mal|mason|kaylee|eureka)/i);
348
357
  resolveAgentFromAliasMock.mockReset().mockReturnValue(null);
358
+ validateProfilesMock.mockReset().mockReturnValue(null);
349
359
  classifyIntentMock.mockReset().mockResolvedValue({
350
360
  intent: "general",
351
361
  reasoning: "Not actionable",
@@ -358,6 +368,9 @@ afterEach(() => {
358
368
  isGuidanceEnabledMock.mockReset().mockReturnValue(false);
359
369
  setActiveSessionMock.mockReset();
360
370
  clearActiveSessionMock.mockReset();
371
+ getIssueAffinityMock.mockReset().mockReturnValue(null);
372
+ configureAffinityTtlMock.mockReset();
373
+ resetAffinityForTestingMock.mockReset();
361
374
  readDispatchStateMock.mockReset().mockResolvedValue({ activeDispatches: {} });
362
375
  getActiveDispatchMock.mockReset().mockReturnValue(null);
363
376
  registerDispatchMock.mockReset().mockResolvedValue(undefined);