@calltelemetry/openclaw-linear 0.8.3 → 0.8.4

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 CHANGED
@@ -12,6 +12,7 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
12
12
  - **New issue?** Agent estimates story points, adds labels, sets priority.
13
13
  - **Assign to agent?** A worker implements it, an independent auditor verifies it, done.
14
14
  - **Comment anything?** The bot understands natural language — no magic commands needed.
15
+ - **Say "close this" or "mark as done"?** Agent writes a closure report and transitions the issue to completed.
15
16
  - **Say "let's plan the features"?** A planner interviews you, writes user stories, and builds your full issue hierarchy.
16
17
  - **Plan looks good?** A different AI model automatically audits the plan before dispatch.
17
18
  - **Agent goes silent?** A watchdog kills it and retries automatically.
@@ -118,7 +119,7 @@ Every issue moves through a clear pipeline. Here's exactly what happens at each
118
119
 
119
120
  **Trigger:** You create a new issue.
120
121
 
121
- The agent reads your issue, estimates story points, adds labels, sets priority, and posts an assessment comment — all within seconds.
122
+ The agent reads your issue, estimates story points, adds labels, sets priority, and posts an assessment comment — all within seconds. Triage runs in **read-only mode** (no file writes, no code execution) to prevent side effects.
122
123
 
123
124
  **What you'll see in Linear:**
124
125
 
@@ -305,6 +306,7 @@ User comment → Intent Classifier (small model, ~2s) → Route to handler
305
306
  | "hey kaylee can you look at this?" | Routes to Kaylee (no `@` needed) |
306
307
  | "what can I do here?" | Default agent responds (not silently dropped) |
307
308
  | "fix the search bug" | Default agent dispatches work |
309
+ | "close this" / "mark as done" / "this is resolved" | Generates closure report, transitions issue to completed |
308
310
 
309
311
  `@mentions` still work as a fast path — if you write `@kaylee`, the classifier is skipped entirely for speed.
310
312
 
@@ -327,7 +329,11 @@ The webhook handler prevents double-processing through a two-tier guard system:
327
329
  | `Issue.create` | `issue-create:<issueId>` | wasRecentlyProcessed → activeRuns → planning mode → bot-created |
328
330
  | `AppUserNotification` | *(immediate discard)* | — |
329
331
 
330
- **Comment echo prevention:** All comments posted by the handler use `createCommentWithDedup()`, which pre-registers the comment's ID in `wasRecentlyProcessed` immediately after the API returns. When Linear echoes the `Comment.create` webhook back, it's caught before any processing.
332
+ `AppUserNotification` events are discarded because they duplicate events already received via the workspace webhook (e.g., `Comment.create` for mentions, `Issue.update` for assignments). Processing both would cause double agent runs.
333
+
334
+ **Response delivery:** When an agent session exists, responses are delivered via `emitActivity(type: "response")` — not `createComment`. This prevents duplicate visible messages on the issue. `createComment` is only used as a fallback when `emitActivity` fails or when no agent session exists.
335
+
336
+ **Comment echo prevention:** Comments posted outside of sessions use `createCommentWithDedup()`, which pre-registers the comment's ID in `wasRecentlyProcessed` immediately after the API returns. When Linear echoes the `Comment.create` webhook back, it's caught before any processing.
331
337
 
332
338
  ---
333
339
 
@@ -436,6 +442,7 @@ If an issue gets stuck (all retries failed), dependent issues are blocked and yo
436
442
  | Comment anything on an issue | Intent classifier routes to the right handler |
437
443
  | Mention an agent by name (with or without `@`) | That agent responds |
438
444
  | Ask a question or request work | Default agent handles it |
445
+ | Say "close this" / "mark as done" / "this is resolved" | Closure report posted, issue moved to completed |
439
446
  | Say "plan this project" (on a project issue) | Planning interview starts |
440
447
  | Reply during planning | Issues created/updated with user stories & AC |
441
448
  | Say "looks good" / "finalize plan" | Validates → cross-model review → approval |
@@ -899,7 +906,7 @@ Every warning and error includes a `→` line telling you what to do. Run `docto
899
906
 
900
907
  ### Unit tests
901
908
 
902
- 454 tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, cross-model review, notifications, and infrastructure:
909
+ 524 tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, cross-model review, notifications, and infrastructure:
903
910
 
904
911
  ```bash
905
912
  cd ~/claw-extensions/linear
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@ const {
28
28
  mockUpdateSession,
29
29
  mockUpdateIssue,
30
30
  mockGetTeamLabels,
31
+ mockGetTeamStates,
31
32
  mockCreateSessionOnIssue,
32
33
  mockClassifyIntent,
33
34
  mockSpawnWorker,
@@ -43,6 +44,7 @@ const {
43
44
  mockUpdateSession: vi.fn(),
44
45
  mockUpdateIssue: vi.fn(),
45
46
  mockGetTeamLabels: vi.fn(),
47
+ mockGetTeamStates: vi.fn(),
46
48
  mockCreateSessionOnIssue: vi.fn(),
47
49
  mockClassifyIntent: vi.fn(),
48
50
  mockSpawnWorker: vi.fn(),
@@ -66,6 +68,7 @@ vi.mock("../api/linear-api.js", () => ({
66
68
  getViewerId = mockGetViewerId;
67
69
  updateIssue = mockUpdateIssue;
68
70
  getTeamLabels = mockGetTeamLabels;
71
+ getTeamStates = mockGetTeamStates;
69
72
  createSessionOnIssue = mockCreateSessionOnIssue;
70
73
  },
71
74
  resolveLinearToken: vi.fn().mockReturnValue({
@@ -265,6 +268,12 @@ beforeEach(() => {
265
268
  { id: "label-bug", name: "Bug" },
266
269
  { id: "label-feature", name: "Feature" },
267
270
  ]);
271
+ mockGetTeamStates.mockResolvedValue([
272
+ { id: "st-backlog", name: "Backlog", type: "backlog" },
273
+ { id: "st-started", name: "In Progress", type: "started" },
274
+ { id: "st-done", name: "Done", type: "completed" },
275
+ { id: "st-canceled", name: "Canceled", type: "canceled" },
276
+ ]);
268
277
  mockRunAgent.mockResolvedValue({ success: true, output: "Agent response text" });
269
278
  mockSpawnWorker.mockResolvedValue(undefined);
270
279
  mockClassifyIntent.mockResolvedValue({
@@ -487,6 +496,58 @@ describe("webhook scenario tests — full handler flows", () => {
487
496
  expect(logs.some((l) => l.includes("no action taken"))).toBe(true);
488
497
  expect(mockRunAgent).not.toHaveBeenCalled();
489
498
  });
499
+
500
+ it("close_issue intent: generates closure report, transitions state, posts comment", async () => {
501
+ mockClassifyIntent.mockResolvedValue({
502
+ intent: "close_issue",
503
+ reasoning: "user wants to close the issue",
504
+ fromFallback: false,
505
+ });
506
+
507
+ mockRunAgent.mockResolvedValueOnce({
508
+ success: true,
509
+ output: "**Summary**: Fixed the authentication bug.\n**Resolution**: Updated token refresh logic.",
510
+ });
511
+
512
+ const api = createApi();
513
+ const payload = makeCommentCreate({
514
+ data: {
515
+ id: "comment-close-1",
516
+ body: "close this issue",
517
+ user: { id: "user-human", name: "Human" },
518
+ issue: {
519
+ id: "issue-close-1",
520
+ identifier: "ENG-400",
521
+ title: "Auth bug fix",
522
+ team: { id: "team-1" },
523
+ project: null,
524
+ },
525
+ createdAt: new Date().toISOString(),
526
+ },
527
+ });
528
+ await postWebhook(api, payload);
529
+
530
+ await waitForMock(mockClearActiveSession);
531
+
532
+ // Agent ran with readOnly for closure report
533
+ expect(mockRunAgent).toHaveBeenCalledOnce();
534
+ const runArgs = mockRunAgent.mock.calls[0][0];
535
+ expect(runArgs.readOnly).toBe(true);
536
+ expect(runArgs.message).toContain("closure report");
537
+
538
+ // Issue state transitioned to completed
539
+ expect(mockUpdateIssue).toHaveBeenCalledWith("issue-close-1", { stateId: "st-done" });
540
+
541
+ // Team states fetched to find completed state
542
+ expect(mockGetTeamStates).toHaveBeenCalledWith("team-1");
543
+
544
+ // Closure report posted via emitActivity
545
+ const responseCalls = activityCallsOfType("response");
546
+ expect(responseCalls.length).toBeGreaterThan(0);
547
+ const reportBody = (responseCalls[0][1] as any).body;
548
+ expect(reportBody).toContain("Closure Report");
549
+ expect(reportBody).toContain("authentication bug");
550
+ });
490
551
  });
491
552
 
492
553
  describe("Issue.update", () => {
@@ -195,6 +195,19 @@ describe("classifyIntent", () => {
195
195
  const call = runAgentMock.mock.calls[0][0];
196
196
  expect(call.message).not.toContain("x".repeat(501));
197
197
  });
198
+
199
+ it("parses close_issue intent from LLM response", async () => {
200
+ runAgentMock.mockResolvedValueOnce({
201
+ success: true,
202
+ output: '{"intent":"close_issue","reasoning":"user wants to close the issue"}',
203
+ });
204
+
205
+ const result = await classifyIntent(createApi(), createCtx({ commentBody: "close this" }));
206
+
207
+ expect(result.intent).toBe("close_issue");
208
+ expect(result.reasoning).toBe("user wants to close the issue");
209
+ expect(result.fromFallback).toBe(false);
210
+ });
198
211
  });
199
212
 
200
213
  // ---------------------------------------------------------------------------
@@ -281,5 +294,35 @@ describe("regexFallback", () => {
281
294
  expect(result.intent).toBe("general");
282
295
  expect(result.fromFallback).toBe(true);
283
296
  });
297
+
298
+ it("detects close_issue for 'close this' pattern", () => {
299
+ const result = regexFallback(createCtx({
300
+ commentBody: "close this issue",
301
+ }));
302
+ expect(result.intent).toBe("close_issue");
303
+ expect(result.fromFallback).toBe(true);
304
+ });
305
+
306
+ it("detects close_issue for 'mark as done' pattern", () => {
307
+ const result = regexFallback(createCtx({
308
+ commentBody: "mark as done",
309
+ }));
310
+ expect(result.intent).toBe("close_issue");
311
+ });
312
+
313
+ it("detects close_issue for 'this is resolved' pattern", () => {
314
+ const result = regexFallback(createCtx({
315
+ commentBody: "this is resolved",
316
+ }));
317
+ expect(result.intent).toBe("close_issue");
318
+ });
319
+
320
+ it("does NOT detect close_issue for ambiguous text", () => {
321
+ const result = regexFallback(createCtx({
322
+ commentBody: "I think this might be resolved soon",
323
+ agentNames: [],
324
+ }));
325
+ expect(result.intent).toBe("general");
326
+ });
284
327
  });
285
328
  });
@@ -23,6 +23,7 @@ export type Intent =
23
23
  | "ask_agent"
24
24
  | "request_work"
25
25
  | "question"
26
+ | "close_issue"
26
27
  | "general";
27
28
 
28
29
  export interface IntentResult {
@@ -55,6 +56,7 @@ const VALID_INTENTS: Set<string> = new Set([
55
56
  "ask_agent",
56
57
  "request_work",
57
58
  "question",
59
+ "close_issue",
58
60
  "general",
59
61
  ]);
60
62
 
@@ -72,12 +74,14 @@ Intents:
72
74
  - ask_agent: user is addressing a specific agent by name
73
75
  - request_work: user wants something built, fixed, or implemented
74
76
  - question: user asking for information or help
77
+ - close_issue: user wants to close/complete/resolve the issue (e.g. "close this", "mark as done", "resolved")
75
78
  - general: none of the above, automated messages, or noise
76
79
 
77
80
  Rules:
78
81
  - plan_start ONLY if the issue belongs to a project (hasProject=true)
79
82
  - If planning mode is active and no clear finalize/abandon intent, default to plan_continue
80
83
  - For ask_agent, set agentId to the matching name from Available agents
84
+ - close_issue only for explicit closure requests, NOT ambiguous comments about resolution
81
85
  - One sentence reasoning`;
82
86
 
83
87
  // ---------------------------------------------------------------------------
@@ -188,6 +192,7 @@ function parseIntentResponse(raw: string, ctx: IntentContext): IntentResult | nu
188
192
  const PLAN_START_PATTERN = /\b(plan|planning)\s+(this\s+)(project|out)\b|\bplan\s+this\s+out\b/i;
189
193
  const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
190
194
  const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
195
+ const CLOSE_ISSUE_PATTERN = /\b(close\s+(this|the\s+issue)|mark\s+(as\s+)?(done|completed?|resolved)|this\s+is\s+(done|resolved|completed?)|resolve\s+(this|the\s+issue))\b/i;
191
196
 
192
197
  export function regexFallback(ctx: IntentContext): IntentResult {
193
198
  const text = ctx.commentBody;
@@ -209,6 +214,11 @@ export function regexFallback(ctx: IntentContext): IntentResult {
209
214
  return { intent: "plan_start", reasoning: "regex: plan start pattern matched", fromFallback: true };
210
215
  }
211
216
 
217
+ // Close issue detection
218
+ if (CLOSE_ISSUE_PATTERN.test(text)) {
219
+ return { intent: "close_issue", reasoning: "regex: close issue pattern matched", fromFallback: true };
220
+ }
221
+
212
222
  // Agent name detection
213
223
  if (ctx.agentNames.length > 0) {
214
224
  const lower = text.toLowerCase();
@@ -805,6 +805,14 @@ export async function handleLinearWebhook(
805
805
  break;
806
806
  }
807
807
 
808
+ case "close_issue": {
809
+ const closeAgent = resolveAgentId(api);
810
+ api.logger.info(`Comment intent close_issue: closing ${issue.identifier ?? issue.id} via ${closeAgent}`);
811
+ void handleCloseIssue(api, linearApi, profiles, closeAgent, issue, comment, commentBody, commentor, pluginConfig)
812
+ .catch((err) => api.logger.error(`Close issue error: ${err}`));
813
+ break;
814
+ }
815
+
808
816
  case "general":
809
817
  default:
810
818
  api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
@@ -1284,6 +1292,172 @@ async function dispatchCommentToAgent(
1284
1292
  }
1285
1293
  }
1286
1294
 
1295
+ // ── Close issue handler ──────────────────────────────────────────
1296
+ //
1297
+ // Triggered by close_issue intent. Generates a closure report via agent,
1298
+ // transitions issue to completed state, and posts the report.
1299
+
1300
+ async function handleCloseIssue(
1301
+ api: OpenClawPluginApi,
1302
+ linearApi: LinearAgentApi,
1303
+ profiles: Record<string, AgentProfile>,
1304
+ agentId: string,
1305
+ issue: any,
1306
+ comment: any,
1307
+ commentBody: string,
1308
+ commentor: string,
1309
+ pluginConfig?: Record<string, unknown>,
1310
+ ): Promise<void> {
1311
+ const profile = profiles[agentId];
1312
+ const label = profile?.label ?? agentId;
1313
+ const avatarUrl = profile?.avatarUrl;
1314
+
1315
+ if (activeRuns.has(issue.id)) {
1316
+ api.logger.info(`handleCloseIssue: ${issue.identifier ?? issue.id} has active run — skipping`);
1317
+ return;
1318
+ }
1319
+
1320
+ // Fetch full issue details
1321
+ let enrichedIssue: any = issue;
1322
+ try {
1323
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1324
+ } catch (err) {
1325
+ api.logger.warn(`Could not fetch issue details for close: ${err}`);
1326
+ }
1327
+
1328
+ const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1329
+ const teamId = enrichedIssue?.team?.id ?? issue.team?.id;
1330
+
1331
+ // Find completed state
1332
+ let completedStateId: string | null = null;
1333
+ if (teamId) {
1334
+ try {
1335
+ const states = await linearApi.getTeamStates(teamId);
1336
+ const completedState = states.find((s: any) => s.type === "completed");
1337
+ if (completedState) completedStateId = completedState.id;
1338
+ } catch (err) {
1339
+ api.logger.warn(`Could not fetch team states for close: ${err}`);
1340
+ }
1341
+ }
1342
+
1343
+ // Build closure report prompt
1344
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1345
+ const comments = enrichedIssue?.comments?.nodes ?? [];
1346
+ const commentSummary = comments
1347
+ .slice(-10)
1348
+ .map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 300)}`)
1349
+ .join("\n");
1350
+
1351
+ const message = [
1352
+ `You are writing a closure report for a Linear issue that is being marked as done.`,
1353
+ `Your text output will be posted as the closing comment on the issue.`,
1354
+ ``,
1355
+ `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1356
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
1357
+ ``,
1358
+ `**Description:**`,
1359
+ description,
1360
+ commentSummary ? `\n**Comment history:**\n${commentSummary}` : "",
1361
+ `\n**${commentor} says (closure request):**\n> ${commentBody}`,
1362
+ ``,
1363
+ `Write a concise closure report with:`,
1364
+ `- **Summary**: What was done (1-2 sentences)`,
1365
+ `- **Resolution**: How it was resolved`,
1366
+ `- **Notes**: Any follow-up items or caveats (if applicable)`,
1367
+ ``,
1368
+ `Keep it brief and factual. Use markdown formatting.`,
1369
+ ].filter(Boolean).join("\n");
1370
+
1371
+ // Execute with session lifecycle
1372
+ activeRuns.add(issue.id);
1373
+ let agentSessionId: string | null = null;
1374
+
1375
+ try {
1376
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1377
+ agentSessionId = sessionResult.sessionId;
1378
+ if (agentSessionId) {
1379
+ wasRecentlyProcessed(`session:${agentSessionId}`);
1380
+ setActiveSession({
1381
+ agentSessionId,
1382
+ issueIdentifier: issueRef,
1383
+ issueId: issue.id,
1384
+ agentId,
1385
+ startedAt: Date.now(),
1386
+ });
1387
+ }
1388
+
1389
+ if (agentSessionId) {
1390
+ await linearApi.emitActivity(agentSessionId, {
1391
+ type: "thought",
1392
+ body: `${label} is preparing closure report for ${issueRef}...`,
1393
+ }).catch(() => {});
1394
+ }
1395
+
1396
+ // Run agent for closure report
1397
+ const { runAgent } = await import("../agent/agent.js");
1398
+ const result = await runAgent({
1399
+ api,
1400
+ agentId,
1401
+ sessionId: `linear-close-${agentId}-${Date.now()}`,
1402
+ message,
1403
+ timeoutMs: 2 * 60_000,
1404
+ readOnly: true,
1405
+ });
1406
+
1407
+ const closureReport = result.success
1408
+ ? result.output
1409
+ : "Issue closed. (Closure report generation failed.)";
1410
+
1411
+ const fullReport = `## Closure Report\n\n${closureReport}`;
1412
+
1413
+ // Transition issue to completed state
1414
+ if (completedStateId) {
1415
+ try {
1416
+ await linearApi.updateIssue(issue.id, { stateId: completedStateId });
1417
+ api.logger.info(`Closed issue ${issueRef} (state → completed)`);
1418
+ } catch (err) {
1419
+ api.logger.error(`Failed to transition issue ${issueRef} to completed: ${err}`);
1420
+ }
1421
+ } else {
1422
+ api.logger.warn(`No completed state found for ${issueRef} — posting report without state change`);
1423
+ }
1424
+
1425
+ // Post closure report via emitActivity-first pattern
1426
+ if (agentSessionId) {
1427
+ const labeledReport = `**[${label}]** ${fullReport}`;
1428
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1429
+ type: "response",
1430
+ body: labeledReport,
1431
+ }).then(() => true).catch(() => false);
1432
+
1433
+ if (!emitted) {
1434
+ const agentOpts = avatarUrl
1435
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1436
+ : undefined;
1437
+ await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
1438
+ }
1439
+ } else {
1440
+ const agentOpts = avatarUrl
1441
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1442
+ : undefined;
1443
+ await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
1444
+ }
1445
+
1446
+ api.logger.info(`Posted closure report for ${issueRef}`);
1447
+ } catch (err) {
1448
+ api.logger.error(`handleCloseIssue error: ${err}`);
1449
+ if (agentSessionId) {
1450
+ await linearApi.emitActivity(agentSessionId, {
1451
+ type: "error",
1452
+ body: `Failed to close issue: ${String(err).slice(0, 500)}`,
1453
+ }).catch(() => {});
1454
+ }
1455
+ } finally {
1456
+ clearActiveSession(issue.id);
1457
+ activeRuns.delete(issue.id);
1458
+ }
1459
+ }
1460
+
1287
1461
  // ── @dispatch handler ─────────────────────────────────────────────
1288
1462
  //
1289
1463
  // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,