@calltelemetry/openclaw-linear 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * webhook-provision.ts — Auto-provision and validate Linear webhooks.
3
+ *
4
+ * Ensures the workspace webhook exists with the correct URL, event types,
5
+ * and enabled state. Can be run during onboarding, from the CLI, or as
6
+ * part of the doctor checks.
7
+ *
8
+ * Required event types:
9
+ * - "Comment" — user @mentions, follow-ups, feedback
10
+ * - "Issue" — assignment, state changes, triage
11
+ *
12
+ * Excluded (noise):
13
+ * - "User", "Customer", "CustomerNeed" — never handled, generate log noise
14
+ */
15
+ import { LinearAgentApi } from "../api/linear-api.js";
16
+
17
+ // The exact set of resource types our webhook handler processes.
18
+ export const REQUIRED_RESOURCE_TYPES = ["Comment", "Issue"] as const;
19
+
20
+ export const WEBHOOK_LABEL = "OpenClaw Integration";
21
+
22
+ export interface WebhookStatus {
23
+ id: string;
24
+ url: string;
25
+ enabled: boolean;
26
+ resourceTypes: string[];
27
+ label: string | null;
28
+ issues: string[];
29
+ }
30
+
31
+ export interface ProvisionResult {
32
+ action: "created" | "updated" | "already_ok";
33
+ webhookId: string;
34
+ changes?: string[];
35
+ }
36
+
37
+ /**
38
+ * Inspect all webhooks and find the one(s) matching our URL pattern.
39
+ */
40
+ export async function getWebhookStatus(
41
+ linearApi: LinearAgentApi,
42
+ webhookUrl: string,
43
+ ): Promise<WebhookStatus | null> {
44
+ const webhooks = await linearApi.listWebhooks();
45
+ const ours = webhooks.find((w) => w.url === webhookUrl);
46
+ if (!ours) return null;
47
+
48
+ const issues: string[] = [];
49
+ if (!ours.enabled) issues.push("disabled");
50
+
51
+ const currentTypes = new Set(ours.resourceTypes);
52
+ const requiredTypes = new Set<string>(REQUIRED_RESOURCE_TYPES);
53
+
54
+ for (const t of requiredTypes) {
55
+ if (!currentTypes.has(t)) issues.push(`missing event type: ${t}`);
56
+ }
57
+
58
+ const noiseTypes = [...currentTypes].filter((t) => !requiredTypes.has(t));
59
+ if (noiseTypes.length > 0) {
60
+ issues.push(`unnecessary event types: ${noiseTypes.join(", ")}`);
61
+ }
62
+
63
+ return {
64
+ id: ours.id,
65
+ url: ours.url,
66
+ enabled: ours.enabled,
67
+ resourceTypes: ours.resourceTypes,
68
+ label: ours.label,
69
+ issues,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Provision (create or fix) the workspace webhook.
75
+ *
76
+ * - If no webhook with our URL exists → create one
77
+ * - If one exists but has wrong config → update it
78
+ * - If it's already correct → no-op
79
+ */
80
+ export async function provisionWebhook(
81
+ linearApi: LinearAgentApi,
82
+ webhookUrl: string,
83
+ opts?: { teamId?: string; allPublicTeams?: boolean },
84
+ ): Promise<ProvisionResult> {
85
+ const status = await getWebhookStatus(linearApi, webhookUrl);
86
+
87
+ if (!status) {
88
+ // No webhook found — create one
89
+ const result = await linearApi.createWebhook({
90
+ url: webhookUrl,
91
+ resourceTypes: [...REQUIRED_RESOURCE_TYPES],
92
+ label: WEBHOOK_LABEL,
93
+ enabled: true,
94
+ teamId: opts?.teamId,
95
+ allPublicTeams: opts?.allPublicTeams ?? true,
96
+ });
97
+
98
+ return {
99
+ action: "created",
100
+ webhookId: result.id,
101
+ changes: ["created new webhook"],
102
+ };
103
+ }
104
+
105
+ // Webhook exists — check if it needs updates
106
+ if (status.issues.length === 0) {
107
+ return { action: "already_ok", webhookId: status.id };
108
+ }
109
+
110
+ // Build update payload
111
+ const update: {
112
+ resourceTypes?: string[];
113
+ enabled?: boolean;
114
+ label?: string;
115
+ } = {};
116
+ const changes: string[] = [];
117
+
118
+ // Fix resource types
119
+ const currentTypes = new Set(status.resourceTypes);
120
+ const requiredTypes = new Set<string>(REQUIRED_RESOURCE_TYPES);
121
+ const typesNeedUpdate =
122
+ [...requiredTypes].some((t) => !currentTypes.has(t)) ||
123
+ [...currentTypes].some((t) => !requiredTypes.has(t));
124
+
125
+ if (typesNeedUpdate) {
126
+ update.resourceTypes = [...REQUIRED_RESOURCE_TYPES];
127
+ const removed = [...currentTypes].filter((t) => !requiredTypes.has(t));
128
+ const added = [...requiredTypes].filter((t) => !currentTypes.has(t));
129
+ if (removed.length) changes.push(`removed event types: ${removed.join(", ")}`);
130
+ if (added.length) changes.push(`added event types: ${added.join(", ")}`);
131
+ }
132
+
133
+ // Fix enabled state
134
+ if (!status.enabled) {
135
+ update.enabled = true;
136
+ changes.push("enabled webhook");
137
+ }
138
+
139
+ // Fix label if missing
140
+ if (!status.label) {
141
+ update.label = WEBHOOK_LABEL;
142
+ changes.push("set label");
143
+ }
144
+
145
+ await linearApi.updateWebhook(status.id, update);
146
+
147
+ return {
148
+ action: "updated",
149
+ webhookId: status.id,
150
+ changes,
151
+ };
152
+ }
@@ -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();