@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.
- package/README.md +28 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/__test__/fixtures/webhook-payloads.ts +93 -0
- package/src/__test__/smoke-linear-api.test.ts +352 -0
- package/src/__test__/webhook-scenarios.test.ts +570 -0
- package/src/agent/agent.ts +69 -5
- package/src/api/linear-api.test.ts +37 -0
- package/src/api/linear-api.ts +96 -5
- package/src/infra/cli.ts +150 -0
- package/src/infra/doctor.test.ts +17 -2
- package/src/infra/doctor.ts +70 -1
- package/src/infra/webhook-provision.test.ts +162 -0
- package/src/infra/webhook-provision.ts +152 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +218 -264
- package/src/tools/tools.test.ts +100 -0
|
@@ -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
|
+
});
|