@calltelemetry/openclaw-linear 0.8.2 → 0.8.3

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.
@@ -232,6 +232,43 @@ describe("LinearAgentApi", () => {
232
232
  );
233
233
  });
234
234
 
235
+ it("returns data when GraphQL errors and data coexist (partial success)", async () => {
236
+ // Simulates createAsUser returning warnings alongside valid comment data.
237
+ // This is the root cause of Bug 2 in API-477: gql() used to throw on
238
+ // any errors, even when the mutation succeeded and data was present.
239
+ fetchMock.mockResolvedValueOnce({
240
+ ok: true,
241
+ status: 200,
242
+ json: () =>
243
+ Promise.resolve({
244
+ data: {
245
+ commentCreate: { success: true, comment: { id: "c-partial" } },
246
+ },
247
+ errors: [{ message: "createAsUser: user not found, using default" }],
248
+ }),
249
+ text: () => Promise.resolve(""),
250
+ headers: new Headers(),
251
+ } as unknown as Response);
252
+
253
+ const api = new LinearAgentApi(TOKEN);
254
+ const id = await api.createComment("issue-1", "test body", {
255
+ createAsUser: "NonexistentUser",
256
+ });
257
+
258
+ expect(id).toBe("c-partial");
259
+ });
260
+
261
+ it("still throws on GraphQL errors when no data is present", async () => {
262
+ fetchMock.mockResolvedValueOnce(
263
+ gqlErrorResponse([{ message: "Totally broken" }]),
264
+ );
265
+
266
+ const api = new LinearAgentApi(TOKEN);
267
+ await expect(
268
+ api.createComment("issue-1", "test body"),
269
+ ).rejects.toThrow(/Linear GraphQL/);
270
+ });
271
+
235
272
  it("retries on 401 when refresh token is available", async () => {
236
273
  // First call (via withResilience): 401
237
274
  fetchMock.mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
@@ -193,7 +193,7 @@ export class LinearAgentApi {
193
193
  }
194
194
 
195
195
  const payload = await retryRes.json();
196
- if (payload.errors?.length) {
196
+ if (payload.errors?.length && !payload.data) {
197
197
  throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
198
198
  }
199
199
  return payload.data as T;
@@ -205,7 +205,7 @@ export class LinearAgentApi {
205
205
  }
206
206
 
207
207
  const payload = await res.json();
208
- if (payload.errors?.length) {
208
+ if (payload.errors?.length && !payload.data) {
209
209
  throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
210
210
  }
211
211
 
@@ -306,7 +306,7 @@ export class LinearAgentApi {
306
306
  title: string;
307
307
  description: string | null;
308
308
  estimate: number | null;
309
- state: { name: string };
309
+ state: { name: string; type: string };
310
310
  assignee: { name: string } | null;
311
311
  labels: { nodes: Array<{ id: string; name: string }> };
312
312
  team: { id: string; name: string; issueEstimationType: string };
@@ -323,7 +323,7 @@ export class LinearAgentApi {
323
323
  title
324
324
  description
325
325
  estimate
326
- state { name }
326
+ state { name type }
327
327
  assignee { name }
328
328
  labels { nodes { id name } }
329
329
  team { id name issueEstimationType }
@@ -533,7 +533,7 @@ export class LinearAgentApi {
533
533
  }>(
534
534
  `query TeamStates($id: String!) {
535
535
  team(id: $id) {
536
- states: workflowStates { nodes { id name type } }
536
+ states { nodes { id name type } }
537
537
  }
538
538
  }`,
539
539
  { id: teamId },
@@ -591,4 +591,95 @@ export class LinearAgentApi {
591
591
  );
592
592
  return data.notifications.nodes as any;
593
593
  }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Webhook management
597
+ // ---------------------------------------------------------------------------
598
+
599
+ async listWebhooks(): Promise<Array<{
600
+ id: string;
601
+ label: string | null;
602
+ url: string;
603
+ enabled: boolean;
604
+ resourceTypes: string[];
605
+ allPublicTeams: boolean;
606
+ team: { id: string; name: string } | null;
607
+ createdAt: string;
608
+ }>> {
609
+ const data = await this.gql<{
610
+ webhooks: { nodes: unknown[] };
611
+ }>(
612
+ `query Webhooks {
613
+ webhooks {
614
+ nodes {
615
+ id
616
+ label
617
+ url
618
+ enabled
619
+ resourceTypes
620
+ allPublicTeams
621
+ team { id name }
622
+ createdAt
623
+ }
624
+ }
625
+ }`,
626
+ );
627
+ return data.webhooks.nodes as any;
628
+ }
629
+
630
+ async createWebhook(input: {
631
+ url: string;
632
+ resourceTypes: string[];
633
+ label?: string;
634
+ teamId?: string;
635
+ allPublicTeams?: boolean;
636
+ enabled?: boolean;
637
+ secret?: string;
638
+ }): Promise<{ id: string; enabled: boolean }> {
639
+ const data = await this.gql<{
640
+ webhookCreate: { success: boolean; webhook: { id: string; enabled: boolean } };
641
+ }>(
642
+ `mutation WebhookCreate($input: WebhookCreateInput!) {
643
+ webhookCreate(input: $input) {
644
+ success
645
+ webhook { id enabled }
646
+ }
647
+ }`,
648
+ { input },
649
+ );
650
+ return data.webhookCreate.webhook;
651
+ }
652
+
653
+ async updateWebhook(webhookId: string, input: {
654
+ url?: string;
655
+ resourceTypes?: string[];
656
+ label?: string;
657
+ enabled?: boolean;
658
+ }): Promise<boolean> {
659
+ const data = await this.gql<{
660
+ webhookUpdate: { success: boolean };
661
+ }>(
662
+ `mutation WebhookUpdate($id: String!, $input: WebhookUpdateInput!) {
663
+ webhookUpdate(id: $id, input: $input) {
664
+ success
665
+ }
666
+ }`,
667
+ { id: webhookId, input },
668
+ );
669
+ return data.webhookUpdate.success;
670
+ }
671
+
672
+ async deleteWebhook(webhookId: string): Promise<boolean> {
673
+ const data = await this.gql<{
674
+ webhookDelete: { success: boolean };
675
+ }>(
676
+ `mutation WebhookDelete($id: String!) {
677
+ webhookDelete(id: $id) {
678
+ success
679
+ }
680
+ }`,
681
+ { id: webhookId },
682
+ );
683
+ return data.webhookDelete.success;
684
+ }
594
685
  }
package/src/infra/cli.ts CHANGED
@@ -644,6 +644,156 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
644
644
  process.exitCode = 1;
645
645
  }
646
646
  });
647
+
648
+ // --- openclaw openclaw-linear webhooks ---
649
+ const webhooksCmd = linear
650
+ .command("webhooks")
651
+ .description("Manage Linear webhook subscriptions");
652
+
653
+ webhooksCmd
654
+ .command("status")
655
+ .description("Show current webhook configuration in Linear")
656
+ .action(async () => {
657
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
658
+ const tokenInfo = resolveLinearToken(pluginConfig);
659
+ if (!tokenInfo.accessToken) {
660
+ console.error("\n No Linear token found. Run \"openclaw openclaw-linear auth\" first.\n");
661
+ process.exitCode = 1;
662
+ return;
663
+ }
664
+
665
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
666
+ refreshToken: tokenInfo.refreshToken,
667
+ expiresAt: tokenInfo.expiresAt,
668
+ });
669
+
670
+ console.log("\nLinear Webhooks");
671
+ console.log("─".repeat(50));
672
+
673
+ const webhooks = await linearApi.listWebhooks();
674
+ if (webhooks.length === 0) {
675
+ console.log("\n No webhooks found.");
676
+ console.log(" Run \"openclaw openclaw-linear webhooks setup\" to create one.\n");
677
+ return;
678
+ }
679
+
680
+ for (const wh of webhooks) {
681
+ const status = wh.enabled ? "enabled" : "DISABLED";
682
+ const label = wh.label ?? "(no label)";
683
+ const team = wh.team ? ` (team: ${wh.team.name})` : " (all teams)";
684
+ console.log(`\n ${label}${team}`);
685
+ console.log(` ID: ${wh.id}`);
686
+ console.log(` URL: ${wh.url}`);
687
+ console.log(` Status: ${status}`);
688
+ console.log(` Events: ${wh.resourceTypes.join(", ")}`);
689
+ console.log(` Created: ${wh.createdAt}`);
690
+ }
691
+
692
+ console.log();
693
+ });
694
+
695
+ webhooksCmd
696
+ .command("setup")
697
+ .description("Auto-provision or fix the workspace webhook (create/update as needed)")
698
+ .option("--url <url>", "Webhook URL (default: from Cloudflare tunnel config)")
699
+ .option("--team <id>", "Restrict to a specific team ID (default: all public teams)")
700
+ .option("--dry-run", "Show what would change without making changes")
701
+ .action(async (opts: { url?: string; team?: string; dryRun?: boolean }) => {
702
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
703
+ const { provisionWebhook, getWebhookStatus, REQUIRED_RESOURCE_TYPES } = await import("./webhook-provision.js");
704
+
705
+ const tokenInfo = resolveLinearToken(pluginConfig);
706
+ if (!tokenInfo.accessToken) {
707
+ console.error("\n No Linear token found. Run \"openclaw openclaw-linear auth\" first.\n");
708
+ process.exitCode = 1;
709
+ return;
710
+ }
711
+
712
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
713
+ refreshToken: tokenInfo.refreshToken,
714
+ expiresAt: tokenInfo.expiresAt,
715
+ });
716
+
717
+ const webhookUrl = opts.url
718
+ ?? (pluginConfig?.webhookUrl as string)
719
+ ?? "https://linear.calltelemetry.com/linear/webhook";
720
+
721
+ console.log("\nWebhook Provisioning");
722
+ console.log("─".repeat(50));
723
+ console.log(` URL: ${webhookUrl}`);
724
+ console.log(` Events: ${[...REQUIRED_RESOURCE_TYPES].join(", ")}`);
725
+
726
+ if (opts.dryRun) {
727
+ const status = await getWebhookStatus(linearApi, webhookUrl);
728
+ if (!status) {
729
+ console.log("\n Would CREATE a new webhook with the above config.");
730
+ } else if (status.issues.length === 0) {
731
+ console.log("\n Webhook already configured correctly. No changes needed.");
732
+ } else {
733
+ console.log("\n Would UPDATE existing webhook:");
734
+ for (const issue of status.issues) {
735
+ console.log(` - Fix: ${issue}`);
736
+ }
737
+ }
738
+ console.log();
739
+ return;
740
+ }
741
+
742
+ const result = await provisionWebhook(linearApi, webhookUrl, {
743
+ teamId: opts.team,
744
+ allPublicTeams: !opts.team,
745
+ });
746
+
747
+ switch (result.action) {
748
+ case "created":
749
+ console.log(`\n Created webhook: ${result.webhookId}`);
750
+ break;
751
+ case "updated":
752
+ console.log(`\n Updated webhook: ${result.webhookId}`);
753
+ for (const change of result.changes ?? []) {
754
+ console.log(` - ${change}`);
755
+ }
756
+ break;
757
+ case "already_ok":
758
+ console.log(`\n Webhook already configured correctly (${result.webhookId}). No changes needed.`);
759
+ break;
760
+ }
761
+
762
+ console.log();
763
+ });
764
+
765
+ webhooksCmd
766
+ .command("delete")
767
+ .description("Delete a webhook by ID")
768
+ .argument("<webhook-id>", "ID of the webhook to delete")
769
+ .action(async (webhookId: string) => {
770
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
771
+ const tokenInfo = resolveLinearToken(pluginConfig);
772
+ if (!tokenInfo.accessToken) {
773
+ console.error("\n No Linear token found.\n");
774
+ process.exitCode = 1;
775
+ return;
776
+ }
777
+
778
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
779
+ refreshToken: tokenInfo.refreshToken,
780
+ expiresAt: tokenInfo.expiresAt,
781
+ });
782
+
783
+ const confirmAnswer = await prompt(`Delete webhook ${webhookId}? [y/N]: `);
784
+ if (confirmAnswer.toLowerCase() !== "y") {
785
+ console.log(" Aborted.\n");
786
+ return;
787
+ }
788
+
789
+ const success = await linearApi.deleteWebhook(webhookId);
790
+ if (success) {
791
+ console.log(`\n Deleted webhook ${webhookId}\n`);
792
+ } else {
793
+ console.error(`\n Failed to delete webhook ${webhookId}\n`);
794
+ process.exitCode = 1;
795
+ }
796
+ });
647
797
  }
648
798
 
649
799
  // ---------------------------------------------------------------------------
@@ -11,6 +11,9 @@ vi.mock("../api/linear-api.js", () => ({
11
11
  expiresAt: Date.now() + 24 * 3_600_000,
12
12
  source: "profile" as const,
13
13
  })),
14
+ LinearAgentApi: class MockLinearAgentApi {
15
+ constructor() {}
16
+ },
14
17
  AUTH_PROFILES_PATH: "/tmp/test-auth-profiles.json",
15
18
  LINEAR_GRAPHQL_URL: "https://api.linear.app/graphql",
16
19
  }));
@@ -57,6 +60,16 @@ vi.mock("../tools/code-tool.js", () => ({
57
60
  })),
58
61
  }));
59
62
 
63
+ vi.mock("./webhook-provision.js", () => ({
64
+ getWebhookStatus: vi.fn(async () => null),
65
+ provisionWebhook: vi.fn(async () => ({
66
+ action: "created",
67
+ webhookId: "wh-test-1",
68
+ changes: ["created new webhook"],
69
+ })),
70
+ REQUIRED_RESOURCE_TYPES: ["Comment", "Issue"],
71
+ }));
72
+
60
73
  import {
61
74
  checkAuth,
62
75
  checkAgentConfig,
@@ -64,6 +77,7 @@ import {
64
77
  checkFilesAndDirs,
65
78
  checkConnectivity,
66
79
  checkDispatchHealth,
80
+ checkWebhooks,
67
81
  runDoctor,
68
82
  formatReport,
69
83
  formatReportJson,
@@ -330,7 +344,7 @@ describe("checkDispatchHealth", () => {
330
344
  // ---------------------------------------------------------------------------
331
345
 
332
346
  describe("runDoctor", () => {
333
- it("returns all 6 sections", async () => {
347
+ it("returns all 7 sections", async () => {
334
348
  vi.stubGlobal("fetch", vi.fn(async (url: string) => {
335
349
  if (url.includes("linear.app")) {
336
350
  return {
@@ -342,7 +356,7 @@ describe("runDoctor", () => {
342
356
  }));
343
357
 
344
358
  const report = await runDoctor({ fix: false, json: false });
345
- expect(report.sections).toHaveLength(6);
359
+ expect(report.sections).toHaveLength(7);
346
360
  expect(report.sections.map((s) => s.name)).toEqual([
347
361
  "Authentication & Tokens",
348
362
  "Agent Configuration",
@@ -350,6 +364,7 @@ describe("runDoctor", () => {
350
364
  "Files & Directories",
351
365
  "Connectivity",
352
366
  "Dispatch Health",
367
+ "Webhook Configuration",
353
368
  ]);
354
369
  expect(report.summary.passed + report.summary.warnings + report.summary.errors).toBeGreaterThan(0);
355
370
  });
@@ -8,11 +8,12 @@ import { join } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { execFileSync } from "node:child_process";
10
10
 
11
- import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
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
15
  import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
16
+ import { getWebhookStatus, provisionWebhook, REQUIRED_RESOURCE_TYPES } from "./webhook-provision.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Types
@@ -650,6 +651,68 @@ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>
650
651
  return checks;
651
652
  }
652
653
 
654
+ // ---------------------------------------------------------------------------
655
+ // Section 7: Webhook Configuration
656
+ // ---------------------------------------------------------------------------
657
+
658
+ export async function checkWebhooks(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
659
+ const checks: CheckResult[] = [];
660
+
661
+ const tokenInfo = resolveLinearToken(pluginConfig);
662
+ if (!tokenInfo.accessToken) {
663
+ checks.push(warn("Webhook check skipped (no Linear token)"));
664
+ return checks;
665
+ }
666
+
667
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
668
+ refreshToken: tokenInfo.refreshToken,
669
+ expiresAt: tokenInfo.expiresAt,
670
+ });
671
+
672
+ const webhookUrl = (pluginConfig?.webhookUrl as string)
673
+ ?? "https://linear.calltelemetry.com/linear/webhook";
674
+
675
+ try {
676
+ const status = await getWebhookStatus(linearApi, webhookUrl);
677
+
678
+ if (!status) {
679
+ if (fix) {
680
+ const result = await provisionWebhook(linearApi, webhookUrl, { allPublicTeams: true });
681
+ checks.push(pass(`Workspace webhook created (${result.webhookId}) (--fix)`));
682
+ } else {
683
+ checks.push(fail(
684
+ `No workspace webhook found for ${webhookUrl}`,
685
+ undefined,
686
+ 'Run: openclaw openclaw-linear webhooks setup',
687
+ ));
688
+ }
689
+ return checks;
690
+ }
691
+
692
+ if (status.issues.length === 0) {
693
+ checks.push(pass(`Workspace webhook OK (${[...REQUIRED_RESOURCE_TYPES].join(", ")})`));
694
+ } else {
695
+ if (fix) {
696
+ const result = await provisionWebhook(linearApi, webhookUrl);
697
+ const changes = result.changes?.join(", ") ?? "fixed";
698
+ checks.push(pass(`Workspace webhook fixed: ${changes} (--fix)`));
699
+ } else {
700
+ for (const issue of status.issues) {
701
+ checks.push(warn(
702
+ `Webhook issue: ${issue}`,
703
+ undefined,
704
+ { fixable: true, fix: 'Run: openclaw openclaw-linear webhooks setup' },
705
+ ));
706
+ }
707
+ }
708
+ }
709
+ } catch (err) {
710
+ checks.push(warn(`Webhook check failed: ${err instanceof Error ? err.message : String(err)}`));
711
+ }
712
+
713
+ return checks;
714
+ }
715
+
653
716
  // ---------------------------------------------------------------------------
654
717
  // Main entry point
655
718
  // ---------------------------------------------------------------------------
@@ -685,6 +748,12 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
685
748
  checks: await checkDispatchHealth(opts.pluginConfig, opts.fix),
686
749
  });
687
750
 
751
+ // 7. Webhook configuration (auto-fix if --fix)
752
+ sections.push({
753
+ name: "Webhook Configuration",
754
+ checks: await checkWebhooks(opts.pluginConfig, opts.fix),
755
+ });
756
+
688
757
  // Fix: chmod auth-profiles.json if needed
689
758
  if (opts.fix) {
690
759
  const permCheck = auth.checks.find((c) => c.fixable && c.label.includes("permissions"));
@@ -0,0 +1,162 @@
1
+ /**
2
+ * webhook-provision.test.ts — Unit tests for webhook auto-provisioning.
3
+ *
4
+ * Tests getWebhookStatus() and provisionWebhook() with inline mock objects.
5
+ * No vi.mock needed — both functions accept linearApi as a parameter.
6
+ */
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import {
9
+ getWebhookStatus,
10
+ provisionWebhook,
11
+ REQUIRED_RESOURCE_TYPES,
12
+ WEBHOOK_LABEL,
13
+ } from "./webhook-provision.js";
14
+
15
+ // ── Helpers ────────────────────────────────────────────────────────
16
+
17
+ const TEST_URL = "https://example.com/linear/webhook";
18
+
19
+ function makeWebhook(overrides?: Record<string, unknown>) {
20
+ return {
21
+ id: "wh-1",
22
+ label: WEBHOOK_LABEL,
23
+ url: TEST_URL,
24
+ enabled: true,
25
+ resourceTypes: ["Comment", "Issue"],
26
+ allPublicTeams: true,
27
+ team: null,
28
+ createdAt: "2026-01-01T00:00:00Z",
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function makeMockApi(overrides?: Record<string, unknown>) {
34
+ return {
35
+ listWebhooks: vi.fn().mockResolvedValue([]),
36
+ createWebhook: vi.fn().mockResolvedValue({ id: "new-wh", enabled: true }),
37
+ updateWebhook: vi.fn().mockResolvedValue(true),
38
+ deleteWebhook: vi.fn().mockResolvedValue(true),
39
+ ...overrides,
40
+ } as any;
41
+ }
42
+
43
+ // ── Tests ──────────────────────────────────────────────────────────
44
+
45
+ describe("REQUIRED_RESOURCE_TYPES", () => {
46
+ it("contains exactly Comment and Issue", () => {
47
+ expect([...REQUIRED_RESOURCE_TYPES]).toEqual(["Comment", "Issue"]);
48
+ });
49
+ });
50
+
51
+ describe("getWebhookStatus", () => {
52
+ it("returns null when no webhook matches URL", async () => {
53
+ const api = makeMockApi({
54
+ listWebhooks: vi.fn().mockResolvedValue([
55
+ makeWebhook({ url: "https://other.com/webhook" }),
56
+ ]),
57
+ });
58
+ const status = await getWebhookStatus(api, TEST_URL);
59
+ expect(status).toBeNull();
60
+ });
61
+
62
+ it("returns status with no issues when webhook is correctly configured", async () => {
63
+ const api = makeMockApi({
64
+ listWebhooks: vi.fn().mockResolvedValue([makeWebhook()]),
65
+ });
66
+ const status = await getWebhookStatus(api, TEST_URL);
67
+ expect(status).not.toBeNull();
68
+ expect(status!.id).toBe("wh-1");
69
+ expect(status!.issues).toEqual([]);
70
+ });
71
+
72
+ it("reports disabled webhook", async () => {
73
+ const api = makeMockApi({
74
+ listWebhooks: vi.fn().mockResolvedValue([
75
+ makeWebhook({ enabled: false }),
76
+ ]),
77
+ });
78
+ const status = await getWebhookStatus(api, TEST_URL);
79
+ expect(status!.issues).toContain("disabled");
80
+ });
81
+
82
+ it("reports missing event types", async () => {
83
+ const api = makeMockApi({
84
+ listWebhooks: vi.fn().mockResolvedValue([
85
+ makeWebhook({ resourceTypes: ["Comment"] }),
86
+ ]),
87
+ });
88
+ const status = await getWebhookStatus(api, TEST_URL);
89
+ expect(status!.issues.some((i) => i.includes("missing event type: Issue"))).toBe(true);
90
+ });
91
+
92
+ it("reports unnecessary event types", async () => {
93
+ const api = makeMockApi({
94
+ listWebhooks: vi.fn().mockResolvedValue([
95
+ makeWebhook({ resourceTypes: ["Comment", "Issue", "User"] }),
96
+ ]),
97
+ });
98
+ const status = await getWebhookStatus(api, TEST_URL);
99
+ expect(status!.issues.some((i) => i.includes("unnecessary event types: User"))).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe("provisionWebhook", () => {
104
+ it("creates new webhook when none exists", async () => {
105
+ const api = makeMockApi();
106
+ const result = await provisionWebhook(api, TEST_URL);
107
+
108
+ expect(result.action).toBe("created");
109
+ expect(result.webhookId).toBe("new-wh");
110
+ expect(result.changes).toContain("created new webhook");
111
+ expect(api.createWebhook).toHaveBeenCalledWith(
112
+ expect.objectContaining({
113
+ url: TEST_URL,
114
+ resourceTypes: [...REQUIRED_RESOURCE_TYPES],
115
+ enabled: true,
116
+ }),
117
+ );
118
+ });
119
+
120
+ it("returns already_ok when webhook is correct", async () => {
121
+ const api = makeMockApi({
122
+ listWebhooks: vi.fn().mockResolvedValue([makeWebhook()]),
123
+ });
124
+ const result = await provisionWebhook(api, TEST_URL);
125
+
126
+ expect(result.action).toBe("already_ok");
127
+ expect(result.webhookId).toBe("wh-1");
128
+ expect(api.createWebhook).not.toHaveBeenCalled();
129
+ expect(api.updateWebhook).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("updates webhook to fix issues", async () => {
133
+ const api = makeMockApi({
134
+ listWebhooks: vi.fn().mockResolvedValue([
135
+ makeWebhook({
136
+ enabled: false,
137
+ resourceTypes: ["Comment", "Issue", "User", "Customer"],
138
+ }),
139
+ ]),
140
+ });
141
+ const result = await provisionWebhook(api, TEST_URL);
142
+
143
+ expect(result.action).toBe("updated");
144
+ expect(result.webhookId).toBe("wh-1");
145
+ expect(result.changes).toBeDefined();
146
+ expect(result.changes!.some((c) => c.includes("enabled"))).toBe(true);
147
+ expect(result.changes!.some((c) => c.includes("removed event types"))).toBe(true);
148
+ expect(api.updateWebhook).toHaveBeenCalledWith("wh-1", expect.objectContaining({
149
+ enabled: true,
150
+ resourceTypes: [...REQUIRED_RESOURCE_TYPES],
151
+ }));
152
+ });
153
+
154
+ it("passes teamId option to createWebhook", async () => {
155
+ const api = makeMockApi();
156
+ await provisionWebhook(api, TEST_URL, { teamId: "team-1" });
157
+
158
+ expect(api.createWebhook).toHaveBeenCalledWith(
159
+ expect.objectContaining({ teamId: "team-1" }),
160
+ );
161
+ });
162
+ });