@calltelemetry/openclaw-linear 0.8.1 → 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.
@@ -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
+ }
@@ -0,0 +1,466 @@
1
+ /**
2
+ * webhook-dedup.test.ts — Deduplication and feedback-loop prevention tests.
3
+ *
4
+ * Tests that duplicate webhooks, own-comment feedback, and concurrent runs
5
+ * are correctly handled without double-processing.
6
+ */
7
+ import type { AddressInfo } from "node:net";
8
+ import { createServer } from "node:http";
9
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ // ── Mocks ──────────────────────────────────────────────────────────
13
+
14
+ vi.mock("./pipeline.js", () => ({
15
+ runPlannerStage: vi.fn().mockResolvedValue("mock plan"),
16
+ runFullPipeline: vi.fn().mockResolvedValue(undefined),
17
+ resumePipeline: vi.fn().mockResolvedValue(undefined),
18
+ spawnWorker: vi.fn().mockResolvedValue(undefined),
19
+ }));
20
+
21
+ const mockGetViewerId = vi.fn().mockResolvedValue("viewer-bot-1");
22
+
23
+ vi.mock("../api/linear-api.js", () => ({
24
+ LinearAgentApi: class MockLinearAgentApi {
25
+ emitActivity = vi.fn().mockResolvedValue(undefined);
26
+ createComment = vi.fn().mockResolvedValue("comment-new-id");
27
+ getIssueDetails = vi.fn().mockResolvedValue(null);
28
+ updateSession = vi.fn().mockResolvedValue(undefined);
29
+ getViewerId = mockGetViewerId;
30
+ createSessionOnIssue = vi.fn().mockResolvedValue({ sessionId: null });
31
+ getTeamLabels = vi.fn().mockResolvedValue([]);
32
+ },
33
+ resolveLinearToken: vi.fn().mockReturnValue({
34
+ accessToken: "test-token",
35
+ source: "env",
36
+ }),
37
+ }));
38
+
39
+ vi.mock("./active-session.js", () => ({
40
+ setActiveSession: vi.fn(),
41
+ clearActiveSession: vi.fn(),
42
+ }));
43
+
44
+ vi.mock("../infra/observability.js", () => ({
45
+ emitDiagnostic: vi.fn(),
46
+ }));
47
+
48
+ vi.mock("./intent-classify.js", () => ({
49
+ classifyIntent: vi.fn().mockResolvedValue({
50
+ intent: "general",
51
+ reasoning: "test",
52
+ fromFallback: true,
53
+ }),
54
+ }));
55
+
56
+ import { handleLinearWebhook, _resetForTesting, _addActiveRunForTesting, _markAsProcessedForTesting } from "./webhook.js";
57
+ import { classifyIntent } from "./intent-classify.js";
58
+
59
+ // ── Helpers ────────────────────────────────────────────────────────
60
+
61
+ function createApi(): OpenClawPluginApi {
62
+ return {
63
+ logger: {
64
+ info: vi.fn(),
65
+ warn: vi.fn(),
66
+ error: vi.fn(),
67
+ debug: vi.fn(),
68
+ },
69
+ runtime: {},
70
+ pluginConfig: {},
71
+ } as unknown as OpenClawPluginApi;
72
+ }
73
+
74
+ async function withServer(
75
+ handler: Parameters<typeof createServer>[0],
76
+ fn: (baseUrl: string) => Promise<void>,
77
+ ) {
78
+ const server = createServer(handler);
79
+ await new Promise<void>((resolve) => {
80
+ server.listen(0, "127.0.0.1", () => resolve());
81
+ });
82
+ const address = server.address() as AddressInfo | null;
83
+ if (!address) throw new Error("missing server address");
84
+ try {
85
+ await fn(`http://127.0.0.1:${address.port}`);
86
+ } finally {
87
+ await new Promise<void>((resolve) => server.close(() => resolve()));
88
+ }
89
+ }
90
+
91
+ /** Post a webhook payload and capture response + logger calls. */
92
+ async function postWebhook(api: OpenClawPluginApi, payload: unknown) {
93
+ let status = 0;
94
+ let body = "";
95
+
96
+ await withServer(
97
+ async (req, res) => {
98
+ await handleLinearWebhook(api, req, res);
99
+ },
100
+ async (baseUrl) => {
101
+ const response = await fetch(`${baseUrl}/linear/webhook`, {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json" },
104
+ body: JSON.stringify(payload),
105
+ });
106
+ status = response.status;
107
+ body = await response.text();
108
+ },
109
+ );
110
+
111
+ return { status, body };
112
+ }
113
+
114
+ function infoLogs(api: OpenClawPluginApi): string[] {
115
+ return (api.logger.info as ReturnType<typeof vi.fn>).mock.calls.map(
116
+ (c: unknown[]) => String(c[0]),
117
+ );
118
+ }
119
+
120
+ // ── Tests ──────────────────────────────────────────────────────────
121
+
122
+ beforeEach(() => {
123
+ vi.clearAllMocks();
124
+ _resetForTesting();
125
+ mockGetViewerId.mockResolvedValue("viewer-bot-1");
126
+ vi.mocked(classifyIntent).mockResolvedValue({
127
+ intent: "general",
128
+ reasoning: "test",
129
+ fromFallback: true,
130
+ });
131
+ });
132
+
133
+ afterEach(() => {
134
+ _resetForTesting();
135
+ });
136
+
137
+ describe("webhook deduplication", () => {
138
+ it("skips duplicate AgentSessionEvent.created with same session ID", async () => {
139
+ const payload = {
140
+ type: "AgentSessionEvent",
141
+ action: "created",
142
+ agentSession: {
143
+ id: "sess-dedup-1",
144
+ issue: { id: "issue-dedup-1", identifier: "ENG-500", title: "Test" },
145
+ },
146
+ previousComments: [],
147
+ };
148
+
149
+ const api = createApi();
150
+
151
+ // First call — should be processed
152
+ await postWebhook(api, payload);
153
+ const firstLogs = infoLogs(api);
154
+ expect(firstLogs.some((l) => l.includes("AgentSession created:"))).toBe(true);
155
+
156
+ // Second call with same session ID — should be skipped
157
+ const api2 = createApi();
158
+ await postWebhook(api2, payload);
159
+ const secondLogs = infoLogs(api2);
160
+ // activeRuns guard fires first (issue already active from first call's async handler)
161
+ const skippedByActive = secondLogs.some((l) => l.includes("already running") || l.includes("already handled"));
162
+ expect(skippedByActive).toBe(true);
163
+ });
164
+
165
+ it("skips duplicate Comment.create with same comment ID", async () => {
166
+ const payload = {
167
+ type: "Comment",
168
+ action: "create",
169
+ data: {
170
+ id: "comment-dedup-1",
171
+ body: "Test comment",
172
+ user: { id: "user-other", name: "Human User" },
173
+ issue: {
174
+ id: "issue-dedup-2",
175
+ identifier: "ENG-501",
176
+ title: "Test Issue",
177
+ team: { id: "team-1" },
178
+ project: null,
179
+ },
180
+ },
181
+ };
182
+
183
+ const api = createApi();
184
+
185
+ // First call
186
+ await postWebhook(api, payload);
187
+ const firstLogs = infoLogs(api);
188
+ // Should not contain "already processed"
189
+ expect(firstLogs.some((l) => l.includes("already processed"))).toBe(false);
190
+
191
+ // Second call with same comment ID
192
+ const api2 = createApi();
193
+ await postWebhook(api2, payload);
194
+ const secondLogs = infoLogs(api2);
195
+ expect(secondLogs.some((l) => l.includes("already processed"))).toBe(true);
196
+ });
197
+
198
+ it("skips Comment.create when activeRuns has the issue — before LLM classification", async () => {
199
+ // Pre-set activeRuns for this issue
200
+ _addActiveRunForTesting("issue-active-1");
201
+
202
+ const payload = {
203
+ type: "Comment",
204
+ action: "create",
205
+ data: {
206
+ id: "comment-while-active",
207
+ body: "@mal please fix this",
208
+ user: { id: "user-other", name: "Human User" },
209
+ issue: {
210
+ id: "issue-active-1",
211
+ identifier: "ENG-502",
212
+ title: "Active Issue",
213
+ team: { id: "team-1" },
214
+ project: null,
215
+ },
216
+ },
217
+ };
218
+
219
+ const api = createApi();
220
+ await postWebhook(api, payload);
221
+
222
+ const logs = infoLogs(api);
223
+ expect(logs.some((l) => l.includes("active run — skipping"))).toBe(true);
224
+
225
+ // Intent classifier should NOT have been called (saved LLM cost)
226
+ expect(classifyIntent).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it("skips bot's own comments via viewerId check", async () => {
230
+ mockGetViewerId.mockResolvedValue("viewer-bot-1");
231
+
232
+ const payload = {
233
+ type: "Comment",
234
+ action: "create",
235
+ data: {
236
+ id: "comment-own-1",
237
+ body: "**[Mal]** Here is my response",
238
+ user: { id: "viewer-bot-1", name: "CT Claw" },
239
+ issue: {
240
+ id: "issue-own-1",
241
+ identifier: "ENG-503",
242
+ title: "Own Comment Issue",
243
+ team: { id: "team-1" },
244
+ project: null,
245
+ },
246
+ },
247
+ };
248
+
249
+ const api = createApi();
250
+ await postWebhook(api, payload);
251
+
252
+ const logs = infoLogs(api);
253
+ expect(logs.some((l) => l.includes("skipping our own comment"))).toBe(true);
254
+ });
255
+
256
+ it("skips duplicate Issue.update with same assignment", async () => {
257
+ const payload = {
258
+ type: "Issue",
259
+ action: "update",
260
+ data: {
261
+ id: "issue-assign-1",
262
+ identifier: "ENG-504",
263
+ title: "Assigned Issue",
264
+ assigneeId: "viewer-bot-1",
265
+ delegateId: null,
266
+ },
267
+ updatedFrom: {
268
+ assigneeId: null,
269
+ },
270
+ };
271
+
272
+ const api = createApi();
273
+
274
+ // First call
275
+ await postWebhook(api, payload);
276
+ // Second call with same payload
277
+ const api2 = createApi();
278
+ await postWebhook(api2, payload);
279
+
280
+ const secondLogs = infoLogs(api2);
281
+ // Should be skipped — either "already processed" or "no assignment change" on repeat
282
+ const skipped = secondLogs.some(
283
+ (l) => l.includes("already processed") || l.includes("no assignment") || l.includes("not us"),
284
+ );
285
+ expect(skipped).toBe(true);
286
+ });
287
+
288
+ it("skips AgentSessionEvent.created when activeRuns already has the issue", async () => {
289
+ // Simulates: our handler called createSessionOnIssue() which fires
290
+ // AgentSessionEvent.created webhook back to us. activeRuns was set
291
+ // BEFORE the API call, so the webhook is caught.
292
+ _addActiveRunForTesting("issue-race-1");
293
+
294
+ const payload = {
295
+ type: "AgentSessionEvent",
296
+ action: "created",
297
+ agentSession: {
298
+ id: "sess-race-1",
299
+ issue: { id: "issue-race-1", identifier: "ENG-505", title: "Race Issue" },
300
+ },
301
+ previousComments: [],
302
+ };
303
+
304
+ const api = createApi();
305
+ await postWebhook(api, payload);
306
+
307
+ const logs = infoLogs(api);
308
+ // Should hit the activeRuns guard FIRST (before wasRecentlyProcessed)
309
+ expect(logs.some((l) => l.includes("already running") && l.includes("ENG-505"))).toBe(true);
310
+ });
311
+
312
+ it("ignores AppUserNotification events", async () => {
313
+ const payload = {
314
+ type: "AppUserNotification",
315
+ action: "create",
316
+ notification: { type: "issueAssigned" },
317
+ appUserId: "app-user-1",
318
+ };
319
+
320
+ const api = createApi();
321
+ const result = await postWebhook(api, payload);
322
+
323
+ expect(result.status).toBe(200);
324
+ const logs = infoLogs(api);
325
+ expect(logs.some((l) => l.includes("AppUserNotification ignored"))).toBe(true);
326
+ });
327
+
328
+ it("skips duplicate Issue.create with same issue ID", async () => {
329
+ const payload = {
330
+ type: "Issue",
331
+ action: "create",
332
+ data: {
333
+ id: "issue-create-dedup-1",
334
+ identifier: "ENG-600",
335
+ title: "New Issue Dedup Test",
336
+ state: { name: "Backlog", type: "backlog" },
337
+ assignee: null,
338
+ team: { id: "team-1" },
339
+ project: null,
340
+ },
341
+ };
342
+
343
+ const api = createApi();
344
+
345
+ // First call — should be processed
346
+ await postWebhook(api, payload);
347
+ const firstLogs = infoLogs(api);
348
+ expect(firstLogs.some((l) => l.includes("already processed"))).toBe(false);
349
+
350
+ // Second call with same issue ID — should be skipped
351
+ const api2 = createApi();
352
+ await postWebhook(api2, payload);
353
+ const secondLogs = infoLogs(api2);
354
+ expect(secondLogs.some((l) => l.includes("already processed"))).toBe(true);
355
+ });
356
+
357
+ it("skips AgentSessionEvent.prompted when activeRuns has the issue (feedback loop)", async () => {
358
+ _addActiveRunForTesting("issue-prompted-feedback-1");
359
+
360
+ const payload = {
361
+ type: "AgentSessionEvent",
362
+ action: "prompted",
363
+ agentSession: {
364
+ id: "sess-prompted-fb-1",
365
+ issue: { id: "issue-prompted-feedback-1", identifier: "ENG-601", title: "Prompted Feedback" },
366
+ },
367
+ agentActivity: { content: { body: "Follow-up question" } },
368
+ webhookId: "wh-prompted-fb-1",
369
+ };
370
+
371
+ const api = createApi();
372
+ await postWebhook(api, payload);
373
+
374
+ const logs = infoLogs(api);
375
+ expect(logs.some((l) => l.includes("active") || l.includes("ignoring"))).toBe(true);
376
+
377
+ // Intent classifier should NOT have been called
378
+ expect(classifyIntent).not.toHaveBeenCalled();
379
+ });
380
+
381
+ it("skips duplicate AgentSessionEvent.prompted by webhookId", async () => {
382
+ const payload = {
383
+ type: "AgentSessionEvent",
384
+ action: "prompted",
385
+ agentSession: {
386
+ id: "sess-prompted-dedup-1",
387
+ issue: { id: "issue-prompted-dedup-1", identifier: "ENG-602", title: "Prompted Dedup" },
388
+ },
389
+ agentActivity: { content: { body: "First message" } },
390
+ webhookId: "wh-dedup-prompted-1",
391
+ };
392
+
393
+ const api = createApi();
394
+
395
+ // First call
396
+ await postWebhook(api, payload);
397
+
398
+ // Second call with same webhookId
399
+ const api2 = createApi();
400
+ await postWebhook(api2, payload);
401
+ const secondLogs = infoLogs(api2);
402
+ // Should be caught by either activeRuns (from first call's async handler) or wasRecentlyProcessed
403
+ const skipped = secondLogs.some(
404
+ (l) => l.includes("already") || l.includes("running") || l.includes("processed"),
405
+ );
406
+ expect(skipped).toBe(true);
407
+ });
408
+
409
+ it("skips Issue.update when activeRuns has the issue (triage still running)", async () => {
410
+ // Simulates: triage from Issue.create is still running (activeRuns set),
411
+ // then updateIssue() triggers an Issue.update webhook. The sync guard
412
+ // should catch it before any async getViewerId() call.
413
+ _addActiveRunForTesting("issue-triage-active-1");
414
+
415
+ const payload = {
416
+ type: "Issue",
417
+ action: "update",
418
+ data: {
419
+ id: "issue-triage-active-1",
420
+ identifier: "ENG-604",
421
+ title: "Triage Active Issue",
422
+ assigneeId: "viewer-bot-1",
423
+ delegateId: null,
424
+ },
425
+ updatedFrom: {
426
+ assigneeId: null,
427
+ },
428
+ };
429
+
430
+ const api = createApi();
431
+ await postWebhook(api, payload);
432
+
433
+ const logs = infoLogs(api);
434
+ expect(logs.some((l) => l.includes("active run — skipping"))).toBe(true);
435
+ });
436
+
437
+ it("skips Comment.create when comment ID was pre-registered by createCommentWithDedup", async () => {
438
+ // Simulate: our handler created a comment via createCommentWithDedup,
439
+ // which pre-registered the comment ID in wasRecentlyProcessed.
440
+ // When Linear echoes the Comment.create webhook back, it should be caught.
441
+ _markAsProcessedForTesting("comment:pre-registered-comment-1");
442
+
443
+ const payload = {
444
+ type: "Comment",
445
+ action: "create",
446
+ data: {
447
+ id: "pre-registered-comment-1",
448
+ body: "Response from the agent",
449
+ user: { id: "user-other", name: "Human User" },
450
+ issue: {
451
+ id: "issue-echo-1",
452
+ identifier: "ENG-603",
453
+ title: "Echo Test Issue",
454
+ team: { id: "team-1" },
455
+ project: null,
456
+ },
457
+ },
458
+ };
459
+
460
+ const api = createApi();
461
+ await postWebhook(api, payload);
462
+
463
+ const logs = infoLogs(api);
464
+ expect(logs.some((l) => l.includes("already processed"))).toBe(true);
465
+ });
466
+ });