@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/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
+ });