@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
|
-
|
|
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
|
-
|
|
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
|
@@ -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();
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -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,
|