@calltelemetry/openclaw-linear 0.8.3 → 0.8.5
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 +52 -4
- package/package.json +1 -1
- package/src/__test__/fixtures/linear-responses.ts +2 -1
- package/src/__test__/webhook-scenarios.test.ts +61 -0
- package/src/api/linear-api.ts +2 -0
- package/src/infra/cli.ts +27 -0
- package/src/infra/doctor.test.ts +130 -3
- package/src/infra/doctor.ts +216 -11
- package/src/pipeline/intent-classify.test.ts +43 -0
- package/src/pipeline/intent-classify.ts +10 -0
- package/src/pipeline/webhook.ts +268 -19
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 |
|
|
@@ -897,9 +904,45 @@ Example output:
|
|
|
897
904
|
|
|
898
905
|
Every warning and error includes a `→` line telling you what to do. Run `doctor --fix` to auto-repair what it can.
|
|
899
906
|
|
|
907
|
+
### Code-run health check
|
|
908
|
+
|
|
909
|
+
For deeper diagnostics on coding tool backends (Claude Code, Codex, Gemini CLI), run the dedicated code-run doctor. It checks binary installation, API key configuration, and actually invokes each backend to verify it can authenticate and respond:
|
|
910
|
+
|
|
911
|
+
```bash
|
|
912
|
+
openclaw openclaw-linear code-run doctor
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
Example output:
|
|
916
|
+
|
|
917
|
+
```
|
|
918
|
+
Code Run: Claude Code (Anthropic)
|
|
919
|
+
✓ Binary: 2.1.50 (/home/claw/.npm-global/bin/claude)
|
|
920
|
+
✓ API key: configured (ANTHROPIC_API_KEY)
|
|
921
|
+
✓ Live test: responded in 3.2s
|
|
922
|
+
|
|
923
|
+
Code Run: Codex (OpenAI)
|
|
924
|
+
✓ Binary: 0.101.0 (/home/claw/.npm-global/bin/codex)
|
|
925
|
+
✓ API key: configured (OPENAI_API_KEY)
|
|
926
|
+
✓ Live test: responded in 2.8s
|
|
927
|
+
|
|
928
|
+
Code Run: Gemini CLI (Google)
|
|
929
|
+
✓ Binary: 0.28.2 (/home/claw/.npm-global/bin/gemini)
|
|
930
|
+
✓ API key: configured (GEMINI_API_KEY)
|
|
931
|
+
✓ Live test: responded in 4.1s
|
|
932
|
+
|
|
933
|
+
Code Run: Routing
|
|
934
|
+
✓ Default backend: codex
|
|
935
|
+
✓ Mal → codex (default)
|
|
936
|
+
✓ Kaylee → codex (default)
|
|
937
|
+
✓ Inara → claude (override)
|
|
938
|
+
✓ Callable backends: 3/3
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
This is separate from the main `doctor` because each live test spawns a real CLI subprocess (~5-10s per backend). Use `--json` for machine-readable output.
|
|
942
|
+
|
|
900
943
|
### Unit tests
|
|
901
944
|
|
|
902
|
-
|
|
945
|
+
532 tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, cross-model review, notifications, and infrastructure:
|
|
903
946
|
|
|
904
947
|
```bash
|
|
905
948
|
cd ~/claw-extensions/linear
|
|
@@ -1062,6 +1105,10 @@ openclaw openclaw-linear webhooks delete <id> # Delete a webhook by ID
|
|
|
1062
1105
|
openclaw openclaw-linear doctor # Run health checks
|
|
1063
1106
|
openclaw openclaw-linear doctor --fix # Auto-fix issues
|
|
1064
1107
|
openclaw openclaw-linear doctor --json # JSON output
|
|
1108
|
+
|
|
1109
|
+
# Code-run backends
|
|
1110
|
+
openclaw openclaw-linear code-run doctor # Deep check all backends (binary, API key, live test)
|
|
1111
|
+
openclaw openclaw-linear code-run doctor --json # JSON output
|
|
1065
1112
|
```
|
|
1066
1113
|
|
|
1067
1114
|
---
|
|
@@ -1082,7 +1129,8 @@ journalctl --user -u openclaw-gateway -f # Watch live logs
|
|
|
1082
1129
|
|---|---|
|
|
1083
1130
|
| Agent goes silent | Watchdog auto-kills after `inactivitySec` and retries. Check logs for `Watchdog KILL`. |
|
|
1084
1131
|
| Dispatch stuck after watchdog | Both retries failed. Check `.claw/log.jsonl`. Re-assign issue to restart. |
|
|
1085
|
-
| `code_run` uses wrong backend | Check `coding-tools.json` — explicit backend > per-agent > global default. |
|
|
1132
|
+
| `code_run` uses wrong backend | Check `coding-tools.json` — explicit backend > per-agent > global default. Run `code-run doctor` to see routing. |
|
|
1133
|
+
| `code_run` fails at runtime | Run `openclaw openclaw-linear code-run doctor` — checks binary, API key, and live callability for each backend. |
|
|
1086
1134
|
| Webhook events not arriving | Run `openclaw openclaw-linear webhooks setup` to auto-provision. Both webhooks must point to `/linear/webhook`. Check tunnel is running. |
|
|
1087
1135
|
| OAuth token expired | Auto-refreshes. If stuck, re-run `openclaw openclaw-linear auth` and restart. |
|
|
1088
1136
|
| Audit always fails | Run `openclaw openclaw-linear prompts validate` to check prompt syntax. |
|
package/package.json
CHANGED
|
@@ -11,7 +11,8 @@ export function makeIssueDetails(overrides?: Record<string, unknown>) {
|
|
|
11
11
|
title: "Fix webhook routing",
|
|
12
12
|
description: "The webhook handler needs fixing.",
|
|
13
13
|
estimate: 3,
|
|
14
|
-
state: { name: "In Progress" },
|
|
14
|
+
state: { name: "In Progress", type: "started" },
|
|
15
|
+
creator: { name: "Test User", email: "test@example.com" },
|
|
15
16
|
assignee: { name: "Agent" },
|
|
16
17
|
labels: { nodes: [] as Array<{ id: string; name: string }> },
|
|
17
18
|
team: { id: "team-1", name: "Engineering", issueEstimationType: "notUsed" },
|
|
@@ -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", () => {
|
package/src/api/linear-api.ts
CHANGED
|
@@ -307,6 +307,7 @@ export class LinearAgentApi {
|
|
|
307
307
|
description: string | null;
|
|
308
308
|
estimate: number | null;
|
|
309
309
|
state: { name: string; type: string };
|
|
310
|
+
creator: { name: string; email: string | null } | null;
|
|
310
311
|
assignee: { name: string } | null;
|
|
311
312
|
labels: { nodes: Array<{ id: string; name: string }> };
|
|
312
313
|
team: { id: string; name: string; issueEstimationType: string };
|
|
@@ -324,6 +325,7 @@ export class LinearAgentApi {
|
|
|
324
325
|
description
|
|
325
326
|
estimate
|
|
326
327
|
state { name type }
|
|
328
|
+
creator { name email }
|
|
327
329
|
assignee { name }
|
|
328
330
|
labels { nodes { id name } }
|
|
329
331
|
team { id name issueEstimationType }
|
package/src/infra/cli.ts
CHANGED
|
@@ -645,6 +645,33 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
645
645
|
}
|
|
646
646
|
});
|
|
647
647
|
|
|
648
|
+
// --- openclaw openclaw-linear code-run ---
|
|
649
|
+
const codeRunCmd = linear
|
|
650
|
+
.command("code-run")
|
|
651
|
+
.description("Manage and diagnose coding tool backends");
|
|
652
|
+
|
|
653
|
+
codeRunCmd
|
|
654
|
+
.command("doctor")
|
|
655
|
+
.description("Deep health check: verify each coding backend (Claude, Codex, Gemini) is callable")
|
|
656
|
+
.option("--json", "Output results as JSON")
|
|
657
|
+
.action(async (opts: { json?: boolean }) => {
|
|
658
|
+
const { checkCodeRunDeep, buildSummary, formatReport, formatReportJson } = await import("./doctor.js");
|
|
659
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
660
|
+
|
|
661
|
+
const sections = await checkCodeRunDeep(pluginConfig);
|
|
662
|
+
const report = { sections, summary: buildSummary(sections) };
|
|
663
|
+
|
|
664
|
+
if (opts.json) {
|
|
665
|
+
console.log(formatReportJson(report));
|
|
666
|
+
} else {
|
|
667
|
+
console.log(formatReport(report));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (report.summary.errors > 0) {
|
|
671
|
+
process.exitCode = 1;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
648
675
|
// --- openclaw openclaw-linear webhooks ---
|
|
649
676
|
const webhooksCmd = linear
|
|
650
677
|
.command("webhooks")
|
package/src/infra/doctor.test.ts
CHANGED
|
@@ -50,14 +50,18 @@ vi.mock("./codex-worktree.js", () => ({
|
|
|
50
50
|
|
|
51
51
|
vi.mock("../tools/code-tool.js", () => ({
|
|
52
52
|
loadCodingConfig: vi.fn(() => ({
|
|
53
|
-
codingTool: "
|
|
54
|
-
agentCodingTools: {},
|
|
53
|
+
codingTool: "codex",
|
|
54
|
+
agentCodingTools: { inara: "claude" },
|
|
55
55
|
backends: {
|
|
56
56
|
claude: { aliases: ["claude", "anthropic"] },
|
|
57
57
|
codex: { aliases: ["codex", "openai"] },
|
|
58
58
|
gemini: { aliases: ["gemini", "google"] },
|
|
59
59
|
},
|
|
60
60
|
})),
|
|
61
|
+
resolveCodingBackend: vi.fn((config: any, agentId?: string) => {
|
|
62
|
+
if (agentId && config?.agentCodingTools?.[agentId]) return config.agentCodingTools[agentId];
|
|
63
|
+
return config?.codingTool ?? "codex";
|
|
64
|
+
}),
|
|
61
65
|
}));
|
|
62
66
|
|
|
63
67
|
vi.mock("./webhook-provision.js", () => ({
|
|
@@ -78,6 +82,8 @@ import {
|
|
|
78
82
|
checkConnectivity,
|
|
79
83
|
checkDispatchHealth,
|
|
80
84
|
checkWebhooks,
|
|
85
|
+
checkCodeRunDeep,
|
|
86
|
+
buildSummary,
|
|
81
87
|
runDoctor,
|
|
82
88
|
formatReport,
|
|
83
89
|
formatReportJson,
|
|
@@ -199,7 +205,7 @@ describe("checkCodingTools", () => {
|
|
|
199
205
|
const checks = checkCodingTools();
|
|
200
206
|
const configCheck = checks.find((c) => c.label.includes("coding-tools.json"));
|
|
201
207
|
expect(configCheck?.severity).toBe("pass");
|
|
202
|
-
expect(configCheck?.label).toContain("
|
|
208
|
+
expect(configCheck?.label).toContain("codex");
|
|
203
209
|
});
|
|
204
210
|
|
|
205
211
|
it("reports warn for missing CLIs", () => {
|
|
@@ -431,3 +437,124 @@ describe("formatReportJson", () => {
|
|
|
431
437
|
expect(parsed.summary.passed).toBe(1);
|
|
432
438
|
});
|
|
433
439
|
});
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// buildSummary
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
describe("buildSummary", () => {
|
|
446
|
+
it("counts pass/warn/fail across sections", () => {
|
|
447
|
+
const sections = [
|
|
448
|
+
{ name: "A", checks: [{ label: "ok", severity: "pass" as const }, { label: "meh", severity: "warn" as const }] },
|
|
449
|
+
{ name: "B", checks: [{ label: "bad", severity: "fail" as const }] },
|
|
450
|
+
];
|
|
451
|
+
const summary = buildSummary(sections);
|
|
452
|
+
expect(summary).toEqual({ passed: 1, warnings: 1, errors: 1 });
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// checkCodeRunDeep
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
describe("checkCodeRunDeep", () => {
|
|
461
|
+
// Run a single invocation and share results across assertions to avoid
|
|
462
|
+
// repeated 30s live CLI calls (the live test spawns all 3 backends).
|
|
463
|
+
let sections: Awaited<ReturnType<typeof checkCodeRunDeep>>;
|
|
464
|
+
|
|
465
|
+
beforeEach(async () => {
|
|
466
|
+
if (!sections) {
|
|
467
|
+
sections = await checkCodeRunDeep();
|
|
468
|
+
}
|
|
469
|
+
}, 120_000);
|
|
470
|
+
|
|
471
|
+
it("returns 4 sections (3 backends + routing)", () => {
|
|
472
|
+
expect(sections).toHaveLength(4);
|
|
473
|
+
expect(sections.map((s) => s.name)).toEqual([
|
|
474
|
+
"Code Run: Claude Code (Anthropic)",
|
|
475
|
+
"Code Run: Codex (OpenAI)",
|
|
476
|
+
"Code Run: Gemini CLI (Google)",
|
|
477
|
+
"Code Run: Routing",
|
|
478
|
+
]);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("each backend section has binary, API key, and live test checks", () => {
|
|
482
|
+
for (const section of sections.slice(0, 3)) {
|
|
483
|
+
const labels = section.checks.map((c) => c.label);
|
|
484
|
+
expect(labels.some((l) => l.includes("Binary:"))).toBe(true);
|
|
485
|
+
expect(labels.some((l) => l.includes("API key:"))).toBe(true);
|
|
486
|
+
expect(labels.some((l) => l.includes("Live test:"))).toBe(true);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("all check results have valid severity", () => {
|
|
491
|
+
for (const section of sections) {
|
|
492
|
+
for (const check of section.checks) {
|
|
493
|
+
expect(check).toHaveProperty("label");
|
|
494
|
+
expect(check).toHaveProperty("severity");
|
|
495
|
+
expect(["pass", "warn", "fail"]).toContain(check.severity);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("shows routing with default backend and callable count", () => {
|
|
501
|
+
const routing = sections.find((s) => s.name === "Code Run: Routing")!;
|
|
502
|
+
expect(routing).toBeDefined();
|
|
503
|
+
|
|
504
|
+
const defaultCheck = routing.checks.find((c) => c.label.includes("Default backend:"));
|
|
505
|
+
expect(defaultCheck?.severity).toBe("pass");
|
|
506
|
+
expect(defaultCheck?.label).toContain("codex");
|
|
507
|
+
|
|
508
|
+
const callableCheck = routing.checks.find((c) => c.label.includes("Callable backends:"));
|
|
509
|
+
expect(callableCheck?.severity).toBe("pass");
|
|
510
|
+
expect(callableCheck?.label).toMatch(/Callable backends: \d+\/3/);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("shows agent override routing for inara", () => {
|
|
514
|
+
const routing = sections.find((s) => s.name === "Code Run: Routing")!;
|
|
515
|
+
const inaraCheck = routing.checks.find((c) => c.label.toLowerCase().includes("inara"));
|
|
516
|
+
if (inaraCheck) {
|
|
517
|
+
expect(inaraCheck.label).toContain("claude");
|
|
518
|
+
expect(inaraCheck.label).toContain("override");
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// API key tests — these are fast (no live invocation), use separate calls
|
|
523
|
+
it("detects API key from plugin config", async () => {
|
|
524
|
+
const origKey = process.env.ANTHROPIC_API_KEY;
|
|
525
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
const result = await checkCodeRunDeep({ claudeApiKey: "sk-from-config" });
|
|
529
|
+
const claudeKey = result[0].checks.find((c) => c.label.includes("API key:"));
|
|
530
|
+
expect(claudeKey?.severity).toBe("pass");
|
|
531
|
+
expect(claudeKey?.label).toContain("claudeApiKey");
|
|
532
|
+
} finally {
|
|
533
|
+
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
|
|
534
|
+
else delete process.env.ANTHROPIC_API_KEY;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("warns when API key missing", async () => {
|
|
539
|
+
const origKeys = {
|
|
540
|
+
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
|
541
|
+
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
|
542
|
+
GOOGLE_GENAI_API_KEY: process.env.GOOGLE_GENAI_API_KEY,
|
|
543
|
+
};
|
|
544
|
+
delete process.env.GEMINI_API_KEY;
|
|
545
|
+
delete process.env.GOOGLE_API_KEY;
|
|
546
|
+
delete process.env.GOOGLE_GENAI_API_KEY;
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const result = await checkCodeRunDeep();
|
|
550
|
+
const geminiKey = result[2].checks.find((c) => c.label.includes("API key:"));
|
|
551
|
+
expect(geminiKey?.severity).toBe("warn");
|
|
552
|
+
expect(geminiKey?.label).toContain("not found");
|
|
553
|
+
} finally {
|
|
554
|
+
for (const [k, v] of Object.entries(origKeys)) {
|
|
555
|
+
if (v) process.env[k] = v;
|
|
556
|
+
else delete process.env[k];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
});
|
package/src/infra/doctor.ts
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
import { existsSync, readFileSync, statSync, accessSync, unlinkSync, chmodSync, constants } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
10
|
|
|
11
11
|
import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
|
|
12
12
|
import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
|
|
13
13
|
import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
|
|
14
14
|
import { listWorktrees } from "./codex-worktree.js";
|
|
15
|
-
import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
|
|
15
|
+
import { loadCodingConfig, resolveCodingBackend, type CodingBackend } from "../tools/code-tool.js";
|
|
16
16
|
import { getWebhookStatus, provisionWebhook, REQUIRED_RESOURCE_TYPES } from "./webhook-provision.js";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
@@ -767,19 +767,209 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
|
|
|
767
767
|
}
|
|
768
768
|
}
|
|
769
769
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
770
|
+
return { sections, summary: buildSummary(sections) };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// Code Run Deep Checks
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
interface BackendSpec {
|
|
778
|
+
id: CodingBackend;
|
|
779
|
+
label: string;
|
|
780
|
+
bin: string;
|
|
781
|
+
/** CLI args for a minimal live invocation test */
|
|
782
|
+
testArgs: string[];
|
|
783
|
+
/** Environment variable names that provide an API key */
|
|
784
|
+
envKeys: string[];
|
|
785
|
+
/** Plugin config key for API key (if any) */
|
|
786
|
+
configKey?: string;
|
|
787
|
+
/** Env vars to unset before spawning (e.g. CLAUDECODE) */
|
|
788
|
+
unsetEnv?: string[];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const BACKEND_SPECS: BackendSpec[] = [
|
|
792
|
+
{
|
|
793
|
+
id: "claude",
|
|
794
|
+
label: "Claude Code (Anthropic)",
|
|
795
|
+
bin: "/home/claw/.npm-global/bin/claude",
|
|
796
|
+
testArgs: ["--print", "-p", "Respond with the single word hello", "--output-format", "stream-json", "--max-turns", "1", "--dangerously-skip-permissions"],
|
|
797
|
+
envKeys: ["ANTHROPIC_API_KEY"],
|
|
798
|
+
configKey: "claudeApiKey",
|
|
799
|
+
unsetEnv: ["CLAUDECODE"],
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
id: "codex",
|
|
803
|
+
label: "Codex (OpenAI)",
|
|
804
|
+
bin: "/home/claw/.npm-global/bin/codex",
|
|
805
|
+
testArgs: ["exec", "--json", "--ephemeral", "--full-auto", "echo hello"],
|
|
806
|
+
envKeys: ["OPENAI_API_KEY"],
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
id: "gemini",
|
|
810
|
+
label: "Gemini CLI (Google)",
|
|
811
|
+
bin: "/home/claw/.npm-global/bin/gemini",
|
|
812
|
+
testArgs: ["-p", "Respond with the single word hello", "-o", "stream-json", "--yolo"],
|
|
813
|
+
envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY"],
|
|
814
|
+
},
|
|
815
|
+
];
|
|
816
|
+
|
|
817
|
+
function checkBackendBinary(spec: BackendSpec): { installed: boolean; checks: CheckResult[] } {
|
|
818
|
+
const checks: CheckResult[] = [];
|
|
819
|
+
|
|
820
|
+
// Binary existence
|
|
821
|
+
try {
|
|
822
|
+
accessSync(spec.bin, constants.X_OK);
|
|
823
|
+
} catch {
|
|
824
|
+
checks.push(fail(
|
|
825
|
+
`Binary: not found at ${spec.bin}`,
|
|
826
|
+
undefined,
|
|
827
|
+
`Install ${spec.id}: npm install -g <package>`,
|
|
828
|
+
));
|
|
829
|
+
return { installed: false, checks };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Version check
|
|
833
|
+
try {
|
|
834
|
+
const env = { ...process.env } as Record<string, string | undefined>;
|
|
835
|
+
for (const key of spec.unsetEnv ?? []) delete env[key];
|
|
836
|
+
const raw = execFileSync(spec.bin, ["--version"], {
|
|
837
|
+
encoding: "utf8",
|
|
838
|
+
timeout: 15_000,
|
|
839
|
+
env: env as NodeJS.ProcessEnv,
|
|
840
|
+
}).trim();
|
|
841
|
+
checks.push(pass(`Binary: ${raw || "installed"} (${spec.bin})`));
|
|
842
|
+
} catch {
|
|
843
|
+
checks.push(pass(`Binary: installed (${spec.bin})`));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return { installed: true, checks };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function checkBackendApiKey(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
|
|
850
|
+
// Check plugin config first
|
|
851
|
+
if (spec.configKey) {
|
|
852
|
+
const configVal = pluginConfig?.[spec.configKey];
|
|
853
|
+
if (typeof configVal === "string" && configVal) {
|
|
854
|
+
return pass(`API key: configured (${spec.configKey} in plugin config)`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Check env vars
|
|
859
|
+
for (const envKey of spec.envKeys) {
|
|
860
|
+
if (process.env[envKey]) {
|
|
861
|
+
return pass(`API key: configured (${envKey})`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return warn(
|
|
866
|
+
`API key: not found`,
|
|
867
|
+
`Checked: ${spec.envKeys.join(", ")}${spec.configKey ? `, pluginConfig.${spec.configKey}` : ""}`,
|
|
868
|
+
{ fix: `Set ${spec.envKeys[0]} environment variable or configure in plugin config` },
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function checkBackendLive(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
|
|
873
|
+
const env = { ...process.env } as Record<string, string | undefined>;
|
|
874
|
+
for (const key of spec.unsetEnv ?? []) delete env[key];
|
|
875
|
+
|
|
876
|
+
// Pass API key from plugin config if available (Claude-specific)
|
|
877
|
+
if (spec.configKey) {
|
|
878
|
+
const configVal = pluginConfig?.[spec.configKey] as string | undefined;
|
|
879
|
+
if (configVal && spec.envKeys[0]) {
|
|
880
|
+
env[spec.envKeys[0]] = configVal;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const start = Date.now();
|
|
885
|
+
try {
|
|
886
|
+
const result = spawnSync(spec.bin, spec.testArgs, {
|
|
887
|
+
encoding: "utf8",
|
|
888
|
+
timeout: 30_000,
|
|
889
|
+
env: env as NodeJS.ProcessEnv,
|
|
890
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
894
|
+
|
|
895
|
+
if (result.error) {
|
|
896
|
+
const msg = result.error.message ?? String(result.error);
|
|
897
|
+
if (msg.includes("ETIMEDOUT") || msg.includes("timed out")) {
|
|
898
|
+
return warn(`Live test: timed out after 30s`);
|
|
778
899
|
}
|
|
900
|
+
return warn(`Live test: spawn error — ${msg.slice(0, 200)}`);
|
|
779
901
|
}
|
|
902
|
+
|
|
903
|
+
if (result.status === 0) {
|
|
904
|
+
return pass(`Live test: responded in ${elapsed}s`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Non-zero exit
|
|
908
|
+
const stderr = (result.stderr ?? "").trim().slice(0, 200);
|
|
909
|
+
const stdout = (result.stdout ?? "").trim().slice(0, 200);
|
|
910
|
+
const detail = stderr || stdout || "(no output)";
|
|
911
|
+
return warn(`Live test: exit code ${result.status} (${elapsed}s) — ${detail}`);
|
|
912
|
+
} catch (err) {
|
|
913
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
914
|
+
return warn(`Live test: error (${elapsed}s) — ${err instanceof Error ? err.message.slice(0, 200) : String(err)}`);
|
|
780
915
|
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Deep health checks for coding tool backends.
|
|
920
|
+
*
|
|
921
|
+
* Verifies binary installation, API key configuration, and live callability
|
|
922
|
+
* for each backend (Claude, Codex, Gemini). Also shows agent routing.
|
|
923
|
+
*
|
|
924
|
+
* Usage: openclaw openclaw-linear code-run doctor [--json]
|
|
925
|
+
*/
|
|
926
|
+
export async function checkCodeRunDeep(
|
|
927
|
+
pluginConfig?: Record<string, unknown>,
|
|
928
|
+
): Promise<CheckSection[]> {
|
|
929
|
+
const sections: CheckSection[] = [];
|
|
930
|
+
const config = loadCodingConfig();
|
|
931
|
+
let callableCount = 0;
|
|
932
|
+
|
|
933
|
+
for (const spec of BACKEND_SPECS) {
|
|
934
|
+
const checks: CheckResult[] = [];
|
|
935
|
+
|
|
936
|
+
// 1. Binary check
|
|
937
|
+
const { installed, checks: binChecks } = checkBackendBinary(spec);
|
|
938
|
+
checks.push(...binChecks);
|
|
939
|
+
|
|
940
|
+
if (installed) {
|
|
941
|
+
// 2. API key check
|
|
942
|
+
checks.push(checkBackendApiKey(spec, pluginConfig));
|
|
943
|
+
|
|
944
|
+
// 3. Live invocation test
|
|
945
|
+
const liveResult = checkBackendLive(spec, pluginConfig);
|
|
946
|
+
checks.push(liveResult);
|
|
781
947
|
|
|
782
|
-
|
|
948
|
+
if (liveResult.severity === "pass") callableCount++;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
sections.push({ name: `Code Run: ${spec.label}`, checks });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Routing summary section
|
|
955
|
+
const routingChecks: CheckResult[] = [];
|
|
956
|
+
const defaultBackend = resolveCodingBackend(config);
|
|
957
|
+
routingChecks.push(pass(`Default backend: ${defaultBackend}`));
|
|
958
|
+
|
|
959
|
+
const profiles = loadAgentProfiles();
|
|
960
|
+
for (const [agentId, profile] of Object.entries(profiles)) {
|
|
961
|
+
const resolved = resolveCodingBackend(config, agentId);
|
|
962
|
+
const isOverride = config.agentCodingTools?.[agentId] != null;
|
|
963
|
+
const label = profile.label ?? agentId;
|
|
964
|
+
routingChecks.push(pass(
|
|
965
|
+
`${label} → ${resolved}${isOverride ? " (override)" : " (default)"}`,
|
|
966
|
+
));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
routingChecks.push(pass(`Callable backends: ${callableCount}/${BACKEND_SPECS.length}`));
|
|
970
|
+
sections.push({ name: "Code Run: Routing", checks: routingChecks });
|
|
971
|
+
|
|
972
|
+
return sections;
|
|
783
973
|
}
|
|
784
974
|
|
|
785
975
|
// ---------------------------------------------------------------------------
|
|
@@ -831,3 +1021,18 @@ export function formatReport(report: DoctorReport): string {
|
|
|
831
1021
|
export function formatReportJson(report: DoctorReport): string {
|
|
832
1022
|
return JSON.stringify(report, null, 2);
|
|
833
1023
|
}
|
|
1024
|
+
|
|
1025
|
+
/** Build a summary by counting pass/warn/fail across sections. */
|
|
1026
|
+
export function buildSummary(sections: CheckSection[]): { passed: number; warnings: number; errors: number } {
|
|
1027
|
+
let passed = 0, warnings = 0, errors = 0;
|
|
1028
|
+
for (const section of sections) {
|
|
1029
|
+
for (const check of section.checks) {
|
|
1030
|
+
switch (check.severity) {
|
|
1031
|
+
case "pass": passed++; break;
|
|
1032
|
+
case "warn": warnings++; break;
|
|
1033
|
+
case "fail": errors++; break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return { passed, warnings, errors };
|
|
1038
|
+
}
|
|
@@ -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
|
@@ -325,16 +325,35 @@ export async function handleLinearWebhook(
|
|
|
325
325
|
.join("\n\n");
|
|
326
326
|
|
|
327
327
|
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
328
|
+
const stateType = enrichedIssue?.state?.type ?? "";
|
|
329
|
+
const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
|
|
330
|
+
|
|
331
|
+
const toolAccessLines = isTriaged
|
|
332
|
+
? [
|
|
333
|
+
`**Tool access:**`,
|
|
334
|
+
`- \`linearis\` CLI: Full access. You can read, update, close, and comment on issues. Use \`linearis issues update ${issueRef} --state <state>\` to change status, \`linearis issues update ${issueRef} --priority <1-4>\` to set priority, etc.`,
|
|
335
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
336
|
+
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
337
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
338
|
+
]
|
|
339
|
+
: [
|
|
340
|
+
`**Tool access:**`,
|
|
341
|
+
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
342
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
343
|
+
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
344
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const roleLines = isTriaged
|
|
348
|
+
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
|
|
349
|
+
: [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`];
|
|
350
|
+
|
|
328
351
|
const message = [
|
|
329
352
|
`You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
|
|
330
353
|
``,
|
|
331
|
-
|
|
332
|
-
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
333
|
-
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
334
|
-
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
335
|
-
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
354
|
+
...toolAccessLines,
|
|
336
355
|
``,
|
|
337
|
-
|
|
356
|
+
...roleLines,
|
|
338
357
|
``,
|
|
339
358
|
`## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
340
359
|
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
@@ -367,7 +386,7 @@ export async function handleLinearWebhook(
|
|
|
367
386
|
// Emit initial thought
|
|
368
387
|
await linearApi.emitActivity(session.id, {
|
|
369
388
|
type: "thought",
|
|
370
|
-
body:
|
|
389
|
+
body: `${label} is processing request for ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
371
390
|
}).catch(() => {});
|
|
372
391
|
|
|
373
392
|
// Run agent with streaming to Linear
|
|
@@ -503,16 +522,35 @@ export async function handleLinearWebhook(
|
|
|
503
522
|
.join("\n\n");
|
|
504
523
|
|
|
505
524
|
const followUpIssueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
525
|
+
const followUpStateType = enrichedIssue?.state?.type ?? "";
|
|
526
|
+
const followUpIsTriaged = followUpStateType === "started" || followUpStateType === "completed" || followUpStateType === "canceled";
|
|
527
|
+
|
|
528
|
+
const followUpToolAccessLines = followUpIsTriaged
|
|
529
|
+
? [
|
|
530
|
+
`**Tool access:**`,
|
|
531
|
+
`- \`linearis\` CLI: Full access. You can read, update, close, and comment on issues. Use \`linearis issues update ${followUpIssueRef} --state <state>\` to change status, \`linearis issues update ${followUpIssueRef} --priority <1-4>\` to set priority, etc.`,
|
|
532
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
533
|
+
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
534
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
535
|
+
]
|
|
536
|
+
: [
|
|
537
|
+
`**Tool access:**`,
|
|
538
|
+
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${followUpIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
539
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
540
|
+
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
541
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
const followUpRoleLines = followUpIsTriaged
|
|
545
|
+
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
|
|
546
|
+
: [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
|
|
547
|
+
|
|
506
548
|
const message = [
|
|
507
549
|
`You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
|
|
508
550
|
``,
|
|
509
|
-
|
|
510
|
-
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${followUpIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
511
|
-
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
512
|
-
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
513
|
-
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
551
|
+
...followUpToolAccessLines,
|
|
514
552
|
``,
|
|
515
|
-
|
|
553
|
+
...followUpRoleLines,
|
|
516
554
|
``,
|
|
517
555
|
`## Issue: ${followUpIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
518
556
|
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
@@ -536,7 +574,7 @@ export async function handleLinearWebhook(
|
|
|
536
574
|
try {
|
|
537
575
|
await linearApi.emitActivity(session.id, {
|
|
538
576
|
type: "thought",
|
|
539
|
-
body:
|
|
577
|
+
body: `${label} is processing follow-up for ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
540
578
|
}).catch(() => {});
|
|
541
579
|
|
|
542
580
|
const sessionId = `linear-session-${session.id}`;
|
|
@@ -805,6 +843,14 @@ export async function handleLinearWebhook(
|
|
|
805
843
|
break;
|
|
806
844
|
}
|
|
807
845
|
|
|
846
|
+
case "close_issue": {
|
|
847
|
+
const closeAgent = resolveAgentId(api);
|
|
848
|
+
api.logger.info(`Comment intent close_issue: closing ${issue.identifier ?? issue.id} via ${closeAgent}`);
|
|
849
|
+
void handleCloseIssue(api, linearApi, profiles, closeAgent, issue, comment, commentBody, commentor, pluginConfig)
|
|
850
|
+
.catch((err) => api.logger.error(`Close issue error: ${err}`));
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
|
|
808
854
|
case "general":
|
|
809
855
|
default:
|
|
810
856
|
api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
|
|
@@ -998,11 +1044,18 @@ export async function handleLinearWebhook(
|
|
|
998
1044
|
}).catch(() => {});
|
|
999
1045
|
}
|
|
1000
1046
|
|
|
1047
|
+
const creatorName = enrichedIssue?.creator?.name ?? "Unknown";
|
|
1048
|
+
const creatorEmail = enrichedIssue?.creator?.email ?? null;
|
|
1049
|
+
const creatorLine = creatorEmail
|
|
1050
|
+
? `**Created by:** ${creatorName} (${creatorEmail})`
|
|
1051
|
+
: `**Created by:** ${creatorName}`;
|
|
1052
|
+
|
|
1001
1053
|
const message = [
|
|
1002
1054
|
`IMPORTANT: You are triaging a new Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
|
|
1003
1055
|
``,
|
|
1004
1056
|
`## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
1005
1057
|
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
|
|
1058
|
+
creatorLine,
|
|
1006
1059
|
``,
|
|
1007
1060
|
`**Description:**`,
|
|
1008
1061
|
description,
|
|
@@ -1030,6 +1083,8 @@ export async function handleLinearWebhook(
|
|
|
1030
1083
|
`}`,
|
|
1031
1084
|
'```',
|
|
1032
1085
|
``,
|
|
1086
|
+
`IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities. The issue creator is shown in the "Created by" field.`,
|
|
1087
|
+
``,
|
|
1033
1088
|
`Then write your full assessment as markdown below the JSON block.`,
|
|
1034
1089
|
].filter(Boolean).join("\n");
|
|
1035
1090
|
|
|
@@ -1183,24 +1238,45 @@ async function dispatchCommentToAgent(
|
|
|
1183
1238
|
.join("\n");
|
|
1184
1239
|
|
|
1185
1240
|
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
1241
|
+
const stateType = enrichedIssue?.state?.type ?? "";
|
|
1242
|
+
const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
|
|
1243
|
+
|
|
1244
|
+
const toolAccessLines = isTriaged
|
|
1245
|
+
? [
|
|
1246
|
+
`**Tool access:**`,
|
|
1247
|
+
`- \`linearis\` CLI: Full access. You can read, update, close, and comment on issues. Use \`linearis issues update ${issueRef} --state <state>\` to change status, \`linearis issues update ${issueRef} --priority <1-4>\` to set priority, etc.`,
|
|
1248
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
1249
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
1250
|
+
]
|
|
1251
|
+
: [
|
|
1252
|
+
`**Tool access:**`,
|
|
1253
|
+
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
1254
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
1255
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
1256
|
+
];
|
|
1257
|
+
|
|
1258
|
+
const roleLines = isTriaged
|
|
1259
|
+
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
|
|
1260
|
+
: [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
|
|
1261
|
+
|
|
1186
1262
|
const message = [
|
|
1187
1263
|
`You are an orchestrator responding to a Linear comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
|
|
1188
1264
|
``,
|
|
1189
|
-
|
|
1190
|
-
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
1191
|
-
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
1192
|
-
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
1265
|
+
...toolAccessLines,
|
|
1193
1266
|
``,
|
|
1194
|
-
|
|
1267
|
+
...roleLines,
|
|
1195
1268
|
``,
|
|
1196
1269
|
`## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
1197
1270
|
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
1271
|
+
enrichedIssue?.creator ? `**Created by:** ${enrichedIssue.creator.name}${enrichedIssue.creator.email ? ` (${enrichedIssue.creator.email})` : ""}` : "",
|
|
1198
1272
|
``,
|
|
1199
1273
|
`**Description:**`,
|
|
1200
1274
|
description,
|
|
1201
1275
|
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
1202
1276
|
`\n**${commentor} says:**\n> ${commentBody}`,
|
|
1203
1277
|
``,
|
|
1278
|
+
`IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
|
|
1279
|
+
``,
|
|
1204
1280
|
`Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
|
|
1205
1281
|
].filter(Boolean).join("\n");
|
|
1206
1282
|
|
|
@@ -1284,6 +1360,179 @@ async function dispatchCommentToAgent(
|
|
|
1284
1360
|
}
|
|
1285
1361
|
}
|
|
1286
1362
|
|
|
1363
|
+
// ── Close issue handler ──────────────────────────────────────────
|
|
1364
|
+
//
|
|
1365
|
+
// Triggered by close_issue intent. Generates a closure report via agent,
|
|
1366
|
+
// transitions issue to completed state, and posts the report.
|
|
1367
|
+
|
|
1368
|
+
async function handleCloseIssue(
|
|
1369
|
+
api: OpenClawPluginApi,
|
|
1370
|
+
linearApi: LinearAgentApi,
|
|
1371
|
+
profiles: Record<string, AgentProfile>,
|
|
1372
|
+
agentId: string,
|
|
1373
|
+
issue: any,
|
|
1374
|
+
comment: any,
|
|
1375
|
+
commentBody: string,
|
|
1376
|
+
commentor: string,
|
|
1377
|
+
pluginConfig?: Record<string, unknown>,
|
|
1378
|
+
): Promise<void> {
|
|
1379
|
+
const profile = profiles[agentId];
|
|
1380
|
+
const label = profile?.label ?? agentId;
|
|
1381
|
+
const avatarUrl = profile?.avatarUrl;
|
|
1382
|
+
|
|
1383
|
+
if (activeRuns.has(issue.id)) {
|
|
1384
|
+
api.logger.info(`handleCloseIssue: ${issue.identifier ?? issue.id} has active run — skipping`);
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Fetch full issue details
|
|
1389
|
+
let enrichedIssue: any = issue;
|
|
1390
|
+
try {
|
|
1391
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
1392
|
+
} catch (err) {
|
|
1393
|
+
api.logger.warn(`Could not fetch issue details for close: ${err}`);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
1397
|
+
const teamId = enrichedIssue?.team?.id ?? issue.team?.id;
|
|
1398
|
+
|
|
1399
|
+
// Find completed state
|
|
1400
|
+
let completedStateId: string | null = null;
|
|
1401
|
+
if (teamId) {
|
|
1402
|
+
try {
|
|
1403
|
+
const states = await linearApi.getTeamStates(teamId);
|
|
1404
|
+
const completedState = states.find((s: any) => s.type === "completed");
|
|
1405
|
+
if (completedState) completedStateId = completedState.id;
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
api.logger.warn(`Could not fetch team states for close: ${err}`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Build closure report prompt
|
|
1412
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
1413
|
+
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
1414
|
+
const commentSummary = comments
|
|
1415
|
+
.slice(-10)
|
|
1416
|
+
.map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 300)}`)
|
|
1417
|
+
.join("\n");
|
|
1418
|
+
|
|
1419
|
+
const message = [
|
|
1420
|
+
`You are writing a closure report for a Linear issue that is being marked as done.`,
|
|
1421
|
+
`Your text output will be posted as the closing comment on the issue.`,
|
|
1422
|
+
``,
|
|
1423
|
+
`## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
1424
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
1425
|
+
enrichedIssue?.creator ? `**Created by:** ${enrichedIssue.creator.name}${enrichedIssue.creator.email ? ` (${enrichedIssue.creator.email})` : ""}` : "",
|
|
1426
|
+
``,
|
|
1427
|
+
`**Description:**`,
|
|
1428
|
+
description,
|
|
1429
|
+
commentSummary ? `\n**Comment history:**\n${commentSummary}` : "",
|
|
1430
|
+
`\n**${commentor} says (closure request):**\n> ${commentBody}`,
|
|
1431
|
+
``,
|
|
1432
|
+
`IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
|
|
1433
|
+
``,
|
|
1434
|
+
`Write a concise closure report with:`,
|
|
1435
|
+
`- **Summary**: What was done (1-2 sentences)`,
|
|
1436
|
+
`- **Resolution**: How it was resolved`,
|
|
1437
|
+
`- **Notes**: Any follow-up items or caveats (if applicable)`,
|
|
1438
|
+
``,
|
|
1439
|
+
`Keep it brief and factual. Use markdown formatting.`,
|
|
1440
|
+
].filter(Boolean).join("\n");
|
|
1441
|
+
|
|
1442
|
+
// Execute with session lifecycle
|
|
1443
|
+
activeRuns.add(issue.id);
|
|
1444
|
+
let agentSessionId: string | null = null;
|
|
1445
|
+
|
|
1446
|
+
try {
|
|
1447
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
1448
|
+
agentSessionId = sessionResult.sessionId;
|
|
1449
|
+
if (agentSessionId) {
|
|
1450
|
+
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
1451
|
+
setActiveSession({
|
|
1452
|
+
agentSessionId,
|
|
1453
|
+
issueIdentifier: issueRef,
|
|
1454
|
+
issueId: issue.id,
|
|
1455
|
+
agentId,
|
|
1456
|
+
startedAt: Date.now(),
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (agentSessionId) {
|
|
1461
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1462
|
+
type: "thought",
|
|
1463
|
+
body: `${label} is preparing closure report for ${issueRef}...`,
|
|
1464
|
+
}).catch(() => {});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// Run agent for closure report
|
|
1468
|
+
const { runAgent } = await import("../agent/agent.js");
|
|
1469
|
+
const result = await runAgent({
|
|
1470
|
+
api,
|
|
1471
|
+
agentId,
|
|
1472
|
+
sessionId: `linear-close-${agentId}-${Date.now()}`,
|
|
1473
|
+
message,
|
|
1474
|
+
timeoutMs: 2 * 60_000,
|
|
1475
|
+
readOnly: true,
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
if (!result.success) {
|
|
1479
|
+
api.logger.error(`Closure report agent failed for ${issueRef}: ${(result.output ?? "no output").slice(0, 500)}`);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const closureReport = result.success
|
|
1483
|
+
? result.output
|
|
1484
|
+
: `Issue closed by ${commentor}.\n\n> ${commentBody}\n\n*Closure report generation failed — agent returned: ${(result.output ?? "no output").slice(0, 200)}*`;
|
|
1485
|
+
|
|
1486
|
+
const fullReport = `## Closure Report\n\n${closureReport}`;
|
|
1487
|
+
|
|
1488
|
+
// Transition issue to completed state
|
|
1489
|
+
if (completedStateId) {
|
|
1490
|
+
try {
|
|
1491
|
+
await linearApi.updateIssue(issue.id, { stateId: completedStateId });
|
|
1492
|
+
api.logger.info(`Closed issue ${issueRef} (state → completed)`);
|
|
1493
|
+
} catch (err) {
|
|
1494
|
+
api.logger.error(`Failed to transition issue ${issueRef} to completed: ${err}`);
|
|
1495
|
+
}
|
|
1496
|
+
} else {
|
|
1497
|
+
api.logger.warn(`No completed state found for ${issueRef} — posting report without state change`);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Post closure report via emitActivity-first pattern
|
|
1501
|
+
if (agentSessionId) {
|
|
1502
|
+
const labeledReport = `**[${label}]** ${fullReport}`;
|
|
1503
|
+
const emitted = await linearApi.emitActivity(agentSessionId, {
|
|
1504
|
+
type: "response",
|
|
1505
|
+
body: labeledReport,
|
|
1506
|
+
}).then(() => true).catch(() => false);
|
|
1507
|
+
|
|
1508
|
+
if (!emitted) {
|
|
1509
|
+
const agentOpts = avatarUrl
|
|
1510
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1511
|
+
: undefined;
|
|
1512
|
+
await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
|
|
1513
|
+
}
|
|
1514
|
+
} else {
|
|
1515
|
+
const agentOpts = avatarUrl
|
|
1516
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1517
|
+
: undefined;
|
|
1518
|
+
await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
api.logger.info(`Posted closure report for ${issueRef}`);
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
api.logger.error(`handleCloseIssue error: ${err}`);
|
|
1524
|
+
if (agentSessionId) {
|
|
1525
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1526
|
+
type: "error",
|
|
1527
|
+
body: `Failed to close issue: ${String(err).slice(0, 500)}`,
|
|
1528
|
+
}).catch(() => {});
|
|
1529
|
+
}
|
|
1530
|
+
} finally {
|
|
1531
|
+
clearActiveSession(issue.id);
|
|
1532
|
+
activeRuns.delete(issue.id);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1287
1536
|
// ── @dispatch handler ─────────────────────────────────────────────
|
|
1288
1537
|
//
|
|
1289
1538
|
// Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
|