@calltelemetry/openclaw-linear 0.6.0 → 0.7.0
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 +115 -17
- package/index.ts +18 -22
- package/openclaw.plugin.json +35 -2
- package/package.json +1 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.ts +180 -9
- package/src/infra/cli.ts +214 -0
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +759 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +114 -35
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +282 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +216 -0
- package/src/pipeline/webhook.ts +69 -17
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
package/src/infra/cli.ts
CHANGED
|
@@ -13,6 +13,13 @@ import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../a
|
|
|
13
13
|
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "../api/auth.js";
|
|
14
14
|
import { listWorktrees } from "./codex-worktree.js";
|
|
15
15
|
import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
|
|
16
|
+
import {
|
|
17
|
+
formatMessage,
|
|
18
|
+
parseNotificationsConfig,
|
|
19
|
+
sendToTarget,
|
|
20
|
+
type NotifyKind,
|
|
21
|
+
type NotifyPayload,
|
|
22
|
+
} from "./notify.js";
|
|
16
23
|
|
|
17
24
|
function prompt(question: string): Promise<string> {
|
|
18
25
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -342,4 +349,211 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
342
349
|
process.exitCode = 1;
|
|
343
350
|
}
|
|
344
351
|
});
|
|
352
|
+
|
|
353
|
+
// --- openclaw openclaw-linear notify ---
|
|
354
|
+
const notifyCmd = linear
|
|
355
|
+
.command("notify")
|
|
356
|
+
.description("Manage dispatch lifecycle notifications");
|
|
357
|
+
|
|
358
|
+
notifyCmd
|
|
359
|
+
.command("status")
|
|
360
|
+
.description("Show current notification target configuration")
|
|
361
|
+
.action(async () => {
|
|
362
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
363
|
+
const config = parseNotificationsConfig(pluginConfig);
|
|
364
|
+
|
|
365
|
+
console.log("\nNotification Targets");
|
|
366
|
+
console.log("─".repeat(50));
|
|
367
|
+
|
|
368
|
+
if (!config.targets?.length) {
|
|
369
|
+
console.log("\n No notification targets configured.");
|
|
370
|
+
console.log(" Run 'openclaw openclaw-linear notify setup' to configure.\n");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const t of config.targets) {
|
|
375
|
+
const acct = t.accountId ? ` (account: ${t.accountId})` : "";
|
|
376
|
+
console.log(` ${t.channel}: ${t.target}${acct}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Show event toggles if any are suppressed
|
|
380
|
+
const suppressed = Object.entries(config.events ?? {})
|
|
381
|
+
.filter(([, v]) => v === false)
|
|
382
|
+
.map(([k]) => k);
|
|
383
|
+
if (suppressed.length > 0) {
|
|
384
|
+
console.log(`\n Suppressed events: ${suppressed.join(", ")}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
console.log();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
notifyCmd
|
|
391
|
+
.command("test")
|
|
392
|
+
.description("Send a test notification to all configured targets")
|
|
393
|
+
.option("--channel <name>", "Test only targets for a specific channel (discord, slack, telegram, etc.)")
|
|
394
|
+
.action(async (opts: { channel?: string }) => {
|
|
395
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
396
|
+
const config = parseNotificationsConfig(pluginConfig);
|
|
397
|
+
|
|
398
|
+
const testPayload: NotifyPayload = {
|
|
399
|
+
identifier: "TEST-0",
|
|
400
|
+
title: "Test notification from Linear plugin",
|
|
401
|
+
status: "test",
|
|
402
|
+
};
|
|
403
|
+
const testKind: NotifyKind = "dispatch";
|
|
404
|
+
const message = formatMessage(testKind, testPayload);
|
|
405
|
+
|
|
406
|
+
console.log("\nSending test notification...\n");
|
|
407
|
+
|
|
408
|
+
if (!config.targets?.length) {
|
|
409
|
+
console.error(" No notification targets configured. Run 'openclaw openclaw-linear notify setup' first.\n");
|
|
410
|
+
process.exitCode = 1;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const targets = opts.channel
|
|
415
|
+
? config.targets.filter((t) => t.channel === opts.channel)
|
|
416
|
+
: config.targets;
|
|
417
|
+
|
|
418
|
+
if (targets.length === 0) {
|
|
419
|
+
console.error(` No targets found for channel "${opts.channel}".\n`);
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
for (const target of targets) {
|
|
425
|
+
try {
|
|
426
|
+
await sendToTarget(target, message, api.runtime);
|
|
427
|
+
console.log(` ${target.channel}: SENT to ${target.target}`);
|
|
428
|
+
console.log(` "${message}"`);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.error(` ${target.channel}: FAILED — ${err instanceof Error ? err.message : String(err)}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
console.log();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
notifyCmd
|
|
438
|
+
.command("setup")
|
|
439
|
+
.description("Interactive setup for notification targets")
|
|
440
|
+
.action(async () => {
|
|
441
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
442
|
+
const config = parseNotificationsConfig(pluginConfig);
|
|
443
|
+
|
|
444
|
+
console.log("\nNotification Target Setup");
|
|
445
|
+
console.log("─".repeat(50));
|
|
446
|
+
console.log(" Dispatch lifecycle notifications can be sent to any OpenClaw channel.");
|
|
447
|
+
console.log(" Add multiple targets for fan-out delivery.\n");
|
|
448
|
+
|
|
449
|
+
// Show current targets
|
|
450
|
+
if (config.targets?.length) {
|
|
451
|
+
console.log(" Current targets:");
|
|
452
|
+
for (const t of config.targets) {
|
|
453
|
+
const acct = t.accountId ? ` (account: ${t.accountId})` : "";
|
|
454
|
+
console.log(` ${t.channel}: ${t.target}${acct}`);
|
|
455
|
+
}
|
|
456
|
+
console.log();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const newTargets = [...(config.targets ?? [])];
|
|
460
|
+
const supportedChannels = ["discord", "slack", "telegram", "signal"];
|
|
461
|
+
|
|
462
|
+
// Add targets loop
|
|
463
|
+
let addMore = true;
|
|
464
|
+
while (addMore) {
|
|
465
|
+
const channelAnswer = await prompt(
|
|
466
|
+
`Add notification target? (${supportedChannels.join("/")}) or blank to finish: `,
|
|
467
|
+
);
|
|
468
|
+
if (!channelAnswer) {
|
|
469
|
+
addMore = false;
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const channel = channelAnswer.toLowerCase().trim();
|
|
474
|
+
const targetId = await prompt(` ${channel} target ID (channel/group/user): `);
|
|
475
|
+
if (!targetId) continue;
|
|
476
|
+
|
|
477
|
+
let accountId: string | undefined;
|
|
478
|
+
if (channel === "slack") {
|
|
479
|
+
const acct = await prompt(" Slack account ID (leave blank for default): ");
|
|
480
|
+
accountId = acct || undefined;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
newTargets.push({ channel, target: targetId, ...(accountId ? { accountId } : {}) });
|
|
484
|
+
console.log(` Added: ${channel} → ${targetId}\n`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Summary
|
|
488
|
+
console.log("\nConfiguration Summary");
|
|
489
|
+
console.log("─".repeat(50));
|
|
490
|
+
if (newTargets.length === 0) {
|
|
491
|
+
console.log(" No targets configured (notifications disabled).");
|
|
492
|
+
} else {
|
|
493
|
+
for (const t of newTargets) {
|
|
494
|
+
const acct = t.accountId ? ` (account: ${t.accountId})` : "";
|
|
495
|
+
console.log(` ${t.channel}: ${t.target}${acct}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (JSON.stringify(newTargets) === JSON.stringify(config.targets ?? [])) {
|
|
500
|
+
console.log("\n No changes made.\n");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Write config
|
|
505
|
+
const confirmAnswer = await prompt("\nApply these changes? [Y/n]: ");
|
|
506
|
+
if (confirmAnswer.toLowerCase() === "n") {
|
|
507
|
+
console.log(" Aborted.\n");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const runtimeConfig = api.runtime.config.loadConfig() as Record<string, any>;
|
|
513
|
+
const pluginEntries = runtimeConfig.plugins?.entries ?? {};
|
|
514
|
+
const linearConfig = pluginEntries["openclaw-linear"]?.config ?? {};
|
|
515
|
+
linearConfig.notifications = {
|
|
516
|
+
...linearConfig.notifications,
|
|
517
|
+
targets: newTargets,
|
|
518
|
+
};
|
|
519
|
+
pluginEntries["openclaw-linear"] = {
|
|
520
|
+
...pluginEntries["openclaw-linear"],
|
|
521
|
+
config: linearConfig,
|
|
522
|
+
};
|
|
523
|
+
runtimeConfig.plugins = { ...runtimeConfig.plugins, entries: pluginEntries };
|
|
524
|
+
api.runtime.config.writeConfigFile(runtimeConfig);
|
|
525
|
+
console.log("\n Configuration saved. Restart gateway to apply: systemctl --user restart openclaw-gateway\n");
|
|
526
|
+
} catch (err) {
|
|
527
|
+
console.error(`\n Failed to save config: ${err instanceof Error ? err.message : String(err)}`);
|
|
528
|
+
console.error(" You can manually add these values to openclaw.json → plugins.entries.openclaw-linear.config\n");
|
|
529
|
+
process.exitCode = 1;
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// --- openclaw openclaw-linear doctor ---
|
|
534
|
+
linear
|
|
535
|
+
.command("doctor")
|
|
536
|
+
.description("Run comprehensive health checks on the Linear plugin")
|
|
537
|
+
.option("--fix", "Auto-fix safe issues (chmod, stale locks, prune old dispatches)")
|
|
538
|
+
.option("--json", "Output results as JSON")
|
|
539
|
+
.action(async (opts: { fix?: boolean; json?: boolean }) => {
|
|
540
|
+
const { runDoctor, formatReport, formatReportJson } = await import("./doctor.js");
|
|
541
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
542
|
+
|
|
543
|
+
const report = await runDoctor({
|
|
544
|
+
fix: opts.fix ?? false,
|
|
545
|
+
json: opts.json ?? false,
|
|
546
|
+
pluginConfig,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (opts.json) {
|
|
550
|
+
console.log(formatReportJson(report));
|
|
551
|
+
} else {
|
|
552
|
+
console.log(formatReport(report));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (report.summary.errors > 0) {
|
|
556
|
+
process.exitCode = 1;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
345
559
|
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// Mock heavy cross-module imports to isolate doctor checks
|
|
7
|
+
vi.mock("../api/linear-api.js", () => ({
|
|
8
|
+
resolveLinearToken: vi.fn(() => ({
|
|
9
|
+
accessToken: "lin_test_token_123",
|
|
10
|
+
refreshToken: "refresh_token",
|
|
11
|
+
expiresAt: Date.now() + 24 * 3_600_000,
|
|
12
|
+
source: "profile" as const,
|
|
13
|
+
})),
|
|
14
|
+
AUTH_PROFILES_PATH: "/tmp/test-auth-profiles.json",
|
|
15
|
+
LINEAR_GRAPHQL_URL: "https://api.linear.app/graphql",
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("../pipeline/dispatch-state.js", () => ({
|
|
19
|
+
readDispatchState: vi.fn(async () => ({
|
|
20
|
+
dispatches: { active: {}, completed: {} },
|
|
21
|
+
sessionMap: {},
|
|
22
|
+
processedEvents: [],
|
|
23
|
+
})),
|
|
24
|
+
listActiveDispatches: vi.fn(() => []),
|
|
25
|
+
listStaleDispatches: vi.fn(() => []),
|
|
26
|
+
pruneCompleted: vi.fn(async () => 0),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../pipeline/pipeline.js", () => ({
|
|
30
|
+
loadPrompts: vi.fn(() => ({
|
|
31
|
+
worker: {
|
|
32
|
+
system: "You are a worker",
|
|
33
|
+
task: "Fix {{identifier}} {{title}} {{description}} in {{worktreePath}}",
|
|
34
|
+
},
|
|
35
|
+
audit: {
|
|
36
|
+
system: "You are an auditor",
|
|
37
|
+
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}",
|
|
38
|
+
},
|
|
39
|
+
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
40
|
+
})),
|
|
41
|
+
clearPromptCache: vi.fn(),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("./codex-worktree.js", () => ({
|
|
45
|
+
listWorktrees: vi.fn(() => []),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock("../tools/code-tool.js", () => ({
|
|
49
|
+
loadCodingConfig: vi.fn(() => ({
|
|
50
|
+
codingTool: "claude",
|
|
51
|
+
agentCodingTools: {},
|
|
52
|
+
backends: {
|
|
53
|
+
claude: { aliases: ["claude", "anthropic"] },
|
|
54
|
+
codex: { aliases: ["codex", "openai"] },
|
|
55
|
+
gemini: { aliases: ["gemini", "google"] },
|
|
56
|
+
},
|
|
57
|
+
})),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
checkAuth,
|
|
62
|
+
checkAgentConfig,
|
|
63
|
+
checkCodingTools,
|
|
64
|
+
checkFilesAndDirs,
|
|
65
|
+
checkConnectivity,
|
|
66
|
+
checkDispatchHealth,
|
|
67
|
+
runDoctor,
|
|
68
|
+
formatReport,
|
|
69
|
+
formatReportJson,
|
|
70
|
+
} from "./doctor.js";
|
|
71
|
+
|
|
72
|
+
import { resolveLinearToken } from "../api/linear-api.js";
|
|
73
|
+
import { readDispatchState, listStaleDispatches, pruneCompleted } from "../pipeline/dispatch-state.js";
|
|
74
|
+
import { loadPrompts } from "../pipeline/pipeline.js";
|
|
75
|
+
import { listWorktrees } from "./codex-worktree.js";
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// checkAuth
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe("checkAuth", () => {
|
|
86
|
+
it("reports pass when token is found", async () => {
|
|
87
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
88
|
+
ok: true,
|
|
89
|
+
json: async () => ({ data: { viewer: { id: "1", name: "Test" }, organization: { name: "TestOrg", urlKey: "test" } } }),
|
|
90
|
+
})));
|
|
91
|
+
|
|
92
|
+
const { checks, ctx } = await checkAuth();
|
|
93
|
+
const tokenCheck = checks.find((c) => c.label.includes("Access token"));
|
|
94
|
+
expect(tokenCheck?.severity).toBe("pass");
|
|
95
|
+
expect(tokenCheck?.label).toContain("profile");
|
|
96
|
+
expect(ctx.viewer?.name).toBe("Test");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports fail when no token found", async () => {
|
|
100
|
+
vi.mocked(resolveLinearToken).mockReturnValueOnce({
|
|
101
|
+
accessToken: null,
|
|
102
|
+
source: "none",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const { checks } = await checkAuth();
|
|
106
|
+
const tokenCheck = checks.find((c) => c.label.includes("access token"));
|
|
107
|
+
expect(tokenCheck?.severity).toBe("fail");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("reports warn when token is expired", async () => {
|
|
111
|
+
vi.mocked(resolveLinearToken).mockReturnValueOnce({
|
|
112
|
+
accessToken: "tok",
|
|
113
|
+
expiresAt: Date.now() - 1000,
|
|
114
|
+
source: "profile",
|
|
115
|
+
});
|
|
116
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
117
|
+
ok: true,
|
|
118
|
+
json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
|
|
119
|
+
})));
|
|
120
|
+
|
|
121
|
+
const { checks } = await checkAuth();
|
|
122
|
+
const expiryCheck = checks.find((c) => c.label.includes("expired") || c.label.includes("Token"));
|
|
123
|
+
expect(expiryCheck?.severity).toBe("warn");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reports pass with time remaining", async () => {
|
|
127
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
128
|
+
ok: true,
|
|
129
|
+
json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
|
|
130
|
+
})));
|
|
131
|
+
|
|
132
|
+
const { checks } = await checkAuth();
|
|
133
|
+
const expiryCheck = checks.find((c) => c.label.includes("not expired"));
|
|
134
|
+
expect(expiryCheck?.severity).toBe("pass");
|
|
135
|
+
expect(expiryCheck?.label).toContain("h");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("reports fail on API error", async () => {
|
|
139
|
+
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network down"); }));
|
|
140
|
+
|
|
141
|
+
const { checks } = await checkAuth();
|
|
142
|
+
const apiCheck = checks.find((c) => c.label.includes("unreachable") || c.label.includes("API"));
|
|
143
|
+
expect(apiCheck?.severity).toBe("fail");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// checkAgentConfig
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe("checkAgentConfig", () => {
|
|
152
|
+
let tmpDir: string;
|
|
153
|
+
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-agent-"));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("reports pass for valid agent profiles", () => {
|
|
159
|
+
// Mock the AGENT_PROFILES_PATH by writing to the expected location
|
|
160
|
+
// Since the path is hardcoded, we test the function's logic indirectly
|
|
161
|
+
const checks = checkAgentConfig();
|
|
162
|
+
// This tests against the real ~/.openclaw/agent-profiles.json on the system
|
|
163
|
+
// The checks should either pass (if file exists) or fail (if not)
|
|
164
|
+
expect(checks.length).toBeGreaterThan(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("detects duplicate mention aliases", () => {
|
|
168
|
+
// Since we can't easily mock the file path, we test the overall behavior
|
|
169
|
+
const checks = checkAgentConfig();
|
|
170
|
+
// Verify the function returns structured results
|
|
171
|
+
for (const check of checks) {
|
|
172
|
+
expect(check).toHaveProperty("label");
|
|
173
|
+
expect(check).toHaveProperty("severity");
|
|
174
|
+
expect(["pass", "warn", "fail"]).toContain(check.severity);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// checkCodingTools
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe("checkCodingTools", () => {
|
|
184
|
+
it("reports loaded config with default backend", () => {
|
|
185
|
+
const checks = checkCodingTools();
|
|
186
|
+
const configCheck = checks.find((c) => c.label.includes("coding-tools.json"));
|
|
187
|
+
expect(configCheck?.severity).toBe("pass");
|
|
188
|
+
expect(configCheck?.label).toContain("claude");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reports warn for missing CLIs", () => {
|
|
192
|
+
const checks = checkCodingTools();
|
|
193
|
+
// Each CLI check should be present
|
|
194
|
+
const cliChecks = checks.filter((c) =>
|
|
195
|
+
c.label.startsWith("codex:") ||
|
|
196
|
+
c.label.startsWith("claude:") ||
|
|
197
|
+
c.label.startsWith("gemini:") ||
|
|
198
|
+
c.label.includes("not found"),
|
|
199
|
+
);
|
|
200
|
+
expect(cliChecks.length).toBeGreaterThan(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// checkFilesAndDirs
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe("checkFilesAndDirs", () => {
|
|
209
|
+
it("reports dispatch state counts", async () => {
|
|
210
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
211
|
+
dispatches: {
|
|
212
|
+
active: { "API-1": { status: "working" } as any },
|
|
213
|
+
completed: { "API-2": { status: "done" } as any, "API-3": { status: "done" } as any },
|
|
214
|
+
},
|
|
215
|
+
sessionMap: {},
|
|
216
|
+
processedEvents: [],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const checks = await checkFilesAndDirs();
|
|
220
|
+
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
221
|
+
expect(stateCheck?.severity).toBe("pass");
|
|
222
|
+
expect(stateCheck?.label).toContain("1 active");
|
|
223
|
+
expect(stateCheck?.label).toContain("2 completed");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("reports valid prompts", async () => {
|
|
227
|
+
const checks = await checkFilesAndDirs();
|
|
228
|
+
const promptCheck = checks.find((c) => c.label.includes("Prompts"));
|
|
229
|
+
expect(promptCheck?.severity).toBe("pass");
|
|
230
|
+
expect(promptCheck?.label).toContain("5/5");
|
|
231
|
+
expect(promptCheck?.label).toContain("4/4");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("reports prompt failures when sections missing", async () => {
|
|
235
|
+
vi.mocked(loadPrompts).mockReturnValueOnce({
|
|
236
|
+
worker: { system: "ok", task: "no vars here" },
|
|
237
|
+
audit: { system: "ok", task: "no vars here" },
|
|
238
|
+
rework: { addendum: "" },
|
|
239
|
+
} as any);
|
|
240
|
+
|
|
241
|
+
const checks = await checkFilesAndDirs();
|
|
242
|
+
const promptCheck = checks.find((c) => c.label.includes("Prompt") || c.label.includes("prompt"));
|
|
243
|
+
expect(promptCheck?.severity).toBe("fail");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// checkConnectivity
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
describe("checkConnectivity", () => {
|
|
252
|
+
it("skips Linear API re-check when authCtx provided", async () => {
|
|
253
|
+
const checks = await checkConnectivity(undefined, {
|
|
254
|
+
viewer: { name: "Test" },
|
|
255
|
+
organization: { name: "Org", urlKey: "org" },
|
|
256
|
+
});
|
|
257
|
+
const apiCheck = checks.find((c) => c.label.includes("Linear API"));
|
|
258
|
+
expect(apiCheck?.severity).toBe("pass");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("reports notifications not configured as pass", async () => {
|
|
262
|
+
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("should not be called"); }));
|
|
263
|
+
const checks = await checkConnectivity({});
|
|
264
|
+
const notifCheck = checks.find((c) => c.label.includes("Notifications"));
|
|
265
|
+
expect(notifCheck?.severity).toBe("pass");
|
|
266
|
+
expect(notifCheck?.label).toContain("not configured");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("reports webhook skip when gateway not running", async () => {
|
|
270
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
271
|
+
if (url.includes("localhost")) throw new Error("ECONNREFUSED");
|
|
272
|
+
throw new Error("unexpected");
|
|
273
|
+
}));
|
|
274
|
+
|
|
275
|
+
const checks = await checkConnectivity({}, {
|
|
276
|
+
viewer: { name: "T" },
|
|
277
|
+
organization: { name: "O", urlKey: "o" },
|
|
278
|
+
});
|
|
279
|
+
const webhookCheck = checks.find((c) => c.label.includes("Webhook"));
|
|
280
|
+
expect(webhookCheck?.severity).toBe("warn");
|
|
281
|
+
expect(webhookCheck?.label).toContain("gateway not detected");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// checkDispatchHealth
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
describe("checkDispatchHealth", () => {
|
|
290
|
+
it("reports no active dispatches", async () => {
|
|
291
|
+
const checks = await checkDispatchHealth();
|
|
292
|
+
const activeCheck = checks.find((c) => c.label.includes("active"));
|
|
293
|
+
expect(activeCheck?.severity).toBe("pass");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("reports stale dispatches", async () => {
|
|
297
|
+
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
298
|
+
{ issueIdentifier: "API-1", status: "working", dispatchedAt: new Date(Date.now() - 3 * 3_600_000).toISOString() } as any,
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
const checks = await checkDispatchHealth();
|
|
302
|
+
const staleCheck = checks.find((c) => c.label.includes("stale"));
|
|
303
|
+
expect(staleCheck?.severity).toBe("warn");
|
|
304
|
+
expect(staleCheck?.label).toContain("API-1");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("prunes old completed with --fix", async () => {
|
|
308
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
309
|
+
dispatches: {
|
|
310
|
+
active: {},
|
|
311
|
+
completed: {
|
|
312
|
+
"API-OLD": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString(), status: "done" } as any,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
sessionMap: {},
|
|
316
|
+
processedEvents: [],
|
|
317
|
+
});
|
|
318
|
+
vi.mocked(pruneCompleted).mockResolvedValueOnce(1);
|
|
319
|
+
|
|
320
|
+
const checks = await checkDispatchHealth(undefined, true);
|
|
321
|
+
const pruneCheck = checks.find((c) => c.label.includes("Pruned") || c.label.includes("prune"));
|
|
322
|
+
expect(pruneCheck).toBeDefined();
|
|
323
|
+
// With fix=true, it should have called pruneCompleted
|
|
324
|
+
expect(pruneCompleted).toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// runDoctor (integration)
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
describe("runDoctor", () => {
|
|
333
|
+
it("returns all 6 sections", async () => {
|
|
334
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
335
|
+
if (url.includes("linear.app")) {
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
throw new Error("not mocked");
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
const report = await runDoctor({ fix: false, json: false });
|
|
345
|
+
expect(report.sections).toHaveLength(6);
|
|
346
|
+
expect(report.sections.map((s) => s.name)).toEqual([
|
|
347
|
+
"Authentication & Tokens",
|
|
348
|
+
"Agent Configuration",
|
|
349
|
+
"Coding Tools",
|
|
350
|
+
"Files & Directories",
|
|
351
|
+
"Connectivity",
|
|
352
|
+
"Dispatch Health",
|
|
353
|
+
]);
|
|
354
|
+
expect(report.summary.passed + report.summary.warnings + report.summary.errors).toBeGreaterThan(0);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Formatters
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
describe("formatReport", () => {
|
|
363
|
+
it("produces readable output with section headers", () => {
|
|
364
|
+
const report = {
|
|
365
|
+
sections: [
|
|
366
|
+
{
|
|
367
|
+
name: "Test Section",
|
|
368
|
+
checks: [
|
|
369
|
+
{ label: "Check passed", severity: "pass" as const },
|
|
370
|
+
{ label: "Check warned", severity: "warn" as const },
|
|
371
|
+
],
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
summary: { passed: 1, warnings: 1, errors: 0 },
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const output = formatReport(report);
|
|
378
|
+
expect(output).toContain("Linear Plugin Doctor");
|
|
379
|
+
expect(output).toContain("Test Section");
|
|
380
|
+
expect(output).toContain("Check passed");
|
|
381
|
+
expect(output).toContain("Check warned");
|
|
382
|
+
expect(output).toContain("1 passed");
|
|
383
|
+
expect(output).toContain("1 warning");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("formatReportJson", () => {
|
|
388
|
+
it("produces valid JSON", () => {
|
|
389
|
+
const report = {
|
|
390
|
+
sections: [{ name: "Test", checks: [{ label: "ok", severity: "pass" as const }] }],
|
|
391
|
+
summary: { passed: 1, warnings: 0, errors: 0 },
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const json = formatReportJson(report);
|
|
395
|
+
const parsed = JSON.parse(json);
|
|
396
|
+
expect(parsed.sections).toHaveLength(1);
|
|
397
|
+
expect(parsed.summary.passed).toBe(1);
|
|
398
|
+
});
|
|
399
|
+
});
|