@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.
- package/README.md +28 -2
- 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 +200 -114
- package/src/tools/tools.test.ts +100 -0
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ Go to **Linear Settings > API > Applications** and create an app:
|
|
|
36
36
|
- Enable events: **Agent Sessions**, **Comments**, **Issues**
|
|
37
37
|
- Save your **Client ID** and **Client Secret**
|
|
38
38
|
|
|
39
|
-
> You also need a **workspace webhook**
|
|
39
|
+
> You also need a **workspace webhook** — run `openclaw openclaw-linear webhooks setup` to auto-provision it, or manually create one in Settings > API > Webhooks pointing to the same URL with **Comment + Issue** events enabled. Both webhooks are required.
|
|
40
40
|
|
|
41
41
|
### 3. Set credentials
|
|
42
42
|
|
|
@@ -310,6 +310,25 @@ User comment → Intent Classifier (small model, ~2s) → Route to handler
|
|
|
310
310
|
|
|
311
311
|
> **Tip:** Configure `classifierAgentId` to point to a small/fast model agent (like Haiku) for low-latency, low-cost intent classification. The classifier only needs ~300 tokens per call.
|
|
312
312
|
|
|
313
|
+
### Deduplication
|
|
314
|
+
|
|
315
|
+
The webhook handler prevents double-processing through a two-tier guard system:
|
|
316
|
+
|
|
317
|
+
1. **`activeRuns` (in-memory Set)** — O(1) check if an agent is already running for an issue. Catches feedback loops where our own API calls (e.g., `createComment`, `createSessionOnIssue`) trigger webhooks back to us.
|
|
318
|
+
|
|
319
|
+
2. **`wasRecentlyProcessed` (TTL Map, 60s)** — Catches exact-duplicate webhook deliveries. Each event type uses a specific dedup key:
|
|
320
|
+
|
|
321
|
+
| Event | Dedup Key | Guards (in order) |
|
|
322
|
+
|---|---|---|
|
|
323
|
+
| `AgentSessionEvent.created` | `session:<sessionId>` | activeRuns → wasRecentlyProcessed |
|
|
324
|
+
| `AgentSessionEvent.prompted` | `webhook:<webhookId>` | activeRuns → wasRecentlyProcessed |
|
|
325
|
+
| `Comment.create` | `comment:<commentId>` | wasRecentlyProcessed → viewerId → activeRuns |
|
|
326
|
+
| `Issue.update` | `<trigger>:<issueId>:<viewerId>` | activeRuns → no-change → viewerId → wasRecentlyProcessed |
|
|
327
|
+
| `Issue.create` | `issue-create:<issueId>` | wasRecentlyProcessed → activeRuns → planning mode → bot-created |
|
|
328
|
+
| `AppUserNotification` | *(immediate discard)* | — |
|
|
329
|
+
|
|
330
|
+
**Comment echo prevention:** All comments posted by the handler use `createCommentWithDedup()`, which pre-registers the comment's ID in `wasRecentlyProcessed` immediately after the API returns. When Linear echoes the `Comment.create` webhook back, it's caught before any processing.
|
|
331
|
+
|
|
313
332
|
---
|
|
314
333
|
|
|
315
334
|
## Planning a Project
|
|
@@ -1026,6 +1045,13 @@ openclaw openclaw-linear notify test # Test all targets
|
|
|
1026
1045
|
openclaw openclaw-linear notify test --channel discord # Test one channel
|
|
1027
1046
|
openclaw openclaw-linear notify setup # Interactive setup
|
|
1028
1047
|
|
|
1048
|
+
# Webhooks
|
|
1049
|
+
openclaw openclaw-linear webhooks status # Show webhook config in Linear
|
|
1050
|
+
openclaw openclaw-linear webhooks setup # Auto-provision workspace webhook
|
|
1051
|
+
openclaw openclaw-linear webhooks setup --dry-run # Preview what would change
|
|
1052
|
+
openclaw openclaw-linear webhooks setup --url <url> # Use custom webhook URL
|
|
1053
|
+
openclaw openclaw-linear webhooks delete <id> # Delete a webhook by ID
|
|
1054
|
+
|
|
1029
1055
|
# Dispatch
|
|
1030
1056
|
/dispatch list # Active dispatches
|
|
1031
1057
|
/dispatch status <identifier> # Dispatch details
|
|
@@ -1057,7 +1083,7 @@ journalctl --user -u openclaw-gateway -f # Watch live logs
|
|
|
1057
1083
|
| Agent goes silent | Watchdog auto-kills after `inactivitySec` and retries. Check logs for `Watchdog KILL`. |
|
|
1058
1084
|
| Dispatch stuck after watchdog | Both retries failed. Check `.claw/log.jsonl`. Re-assign issue to restart. |
|
|
1059
1085
|
| `code_run` uses wrong backend | Check `coding-tools.json` — explicit backend > per-agent > global default. |
|
|
1060
|
-
| Webhook events not arriving | Both webhooks must point to `/linear/webhook`. Check tunnel is running. |
|
|
1086
|
+
| Webhook events not arriving | Run `openclaw openclaw-linear webhooks setup` to auto-provision. Both webhooks must point to `/linear/webhook`. Check tunnel is running. |
|
|
1061
1087
|
| OAuth token expired | Auto-refreshes. If stuck, re-run `openclaw openclaw-linear auth` and restart. |
|
|
1062
1088
|
| Audit always fails | Run `openclaw openclaw-linear prompts validate` to check prompt syntax. |
|
|
1063
1089
|
| Multi-repo not detected | Markers must be `<!-- repos: name1, name2 -->`. Names must match `repos` config keys. |
|
package/package.json
CHANGED
|
@@ -95,6 +95,99 @@ export function makeIssueCreate(overrides?: Record<string, unknown>) {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* AgentSessionEvent.created — the actual type from OAuth app webhooks.
|
|
100
|
+
* (makeAgentSessionCreated uses the legacy "AgentSession"/"create" variant.)
|
|
101
|
+
*/
|
|
102
|
+
export function makeAgentSessionEventCreated(overrides?: Record<string, unknown>) {
|
|
103
|
+
return {
|
|
104
|
+
type: "AgentSessionEvent",
|
|
105
|
+
action: "created",
|
|
106
|
+
agentSession: {
|
|
107
|
+
id: "sess-event-1",
|
|
108
|
+
issue: {
|
|
109
|
+
id: "issue-1",
|
|
110
|
+
identifier: "ENG-123",
|
|
111
|
+
title: "Fix webhook routing",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
previousComments: [],
|
|
115
|
+
guidance: "Please investigate this issue",
|
|
116
|
+
...overrides,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* AgentSessionEvent.prompted — follow-up user message in existing session.
|
|
122
|
+
*/
|
|
123
|
+
export function makeAgentSessionEventPrompted(overrides?: Record<string, unknown>) {
|
|
124
|
+
return {
|
|
125
|
+
type: "AgentSessionEvent",
|
|
126
|
+
action: "prompted",
|
|
127
|
+
agentSession: {
|
|
128
|
+
id: "sess-event-1",
|
|
129
|
+
issue: {
|
|
130
|
+
id: "issue-1",
|
|
131
|
+
identifier: "ENG-123",
|
|
132
|
+
title: "Fix webhook routing",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
agentActivity: { content: { body: "Follow-up question here" } },
|
|
136
|
+
webhookId: "webhook-prompted-1",
|
|
137
|
+
...overrides,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Comment.create from the bot's own user (should be skipped by viewerId check).
|
|
143
|
+
*/
|
|
144
|
+
export function makeCommentCreateFromBot(viewerId: string, overrides?: Record<string, unknown>) {
|
|
145
|
+
return {
|
|
146
|
+
type: "Comment",
|
|
147
|
+
action: "create",
|
|
148
|
+
data: {
|
|
149
|
+
id: `comment-bot-${Date.now()}`,
|
|
150
|
+
body: "**[Mal]** Bot response text",
|
|
151
|
+
user: { id: viewerId, name: "CT Claw" },
|
|
152
|
+
issue: {
|
|
153
|
+
id: "issue-1",
|
|
154
|
+
identifier: "ENG-123",
|
|
155
|
+
title: "Fix webhook routing",
|
|
156
|
+
team: { id: "team-1" },
|
|
157
|
+
assignee: { id: viewerId },
|
|
158
|
+
project: null,
|
|
159
|
+
},
|
|
160
|
+
createdAt: new Date().toISOString(),
|
|
161
|
+
},
|
|
162
|
+
...overrides,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Issue.update with assignment/delegation fields for dedup testing.
|
|
168
|
+
*/
|
|
169
|
+
export function makeIssueUpdateWithAssignment(overrides?: Record<string, unknown>) {
|
|
170
|
+
return {
|
|
171
|
+
type: "Issue",
|
|
172
|
+
action: "update",
|
|
173
|
+
data: {
|
|
174
|
+
id: "issue-1",
|
|
175
|
+
identifier: "ENG-123",
|
|
176
|
+
title: "Fix webhook routing",
|
|
177
|
+
state: { name: "In Progress", type: "started" },
|
|
178
|
+
assignee: { id: "viewer-1", name: "Agent" },
|
|
179
|
+
assigneeId: "viewer-1",
|
|
180
|
+
delegateId: null,
|
|
181
|
+
team: { id: "team-1" },
|
|
182
|
+
project: null,
|
|
183
|
+
},
|
|
184
|
+
updatedFrom: {
|
|
185
|
+
assigneeId: null,
|
|
186
|
+
},
|
|
187
|
+
...overrides,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
98
191
|
export function makeAppUserNotification(overrides?: Record<string, unknown>) {
|
|
99
192
|
return {
|
|
100
193
|
type: "AppUserNotification",
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smoke-linear-api.test.ts — Live integration tests against the real Linear API.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify API connectivity, comment lifecycle, and dedup behavior
|
|
5
|
+
* using real API calls. Run separately from unit tests:
|
|
6
|
+
*
|
|
7
|
+
* npx vitest run --config vitest.smoke.config.ts
|
|
8
|
+
*
|
|
9
|
+
* Requires: ~/.openclaw/auth-profiles.json with a valid linear:api-key profile.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
14
|
+
import { LinearAgentApi } from "../api/linear-api.js";
|
|
15
|
+
|
|
16
|
+
// ── Setup ──────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const AUTH_PROFILES_PATH = join(
|
|
19
|
+
process.env.HOME ?? "/home/claw",
|
|
20
|
+
".openclaw",
|
|
21
|
+
"auth-profiles.json",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const TEAM_ID = "08cba264-d774-4afd-bc93-ee8213d12ef8";
|
|
25
|
+
|
|
26
|
+
function loadApiKey(): string {
|
|
27
|
+
const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
|
|
28
|
+
const store = JSON.parse(raw);
|
|
29
|
+
const profile = store?.profiles?.["linear:api-key"];
|
|
30
|
+
const token = profile?.accessToken ?? profile?.access;
|
|
31
|
+
if (!token) throw new Error("No linear:api-key profile found in auth-profiles.json");
|
|
32
|
+
return token;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let api: LinearAgentApi;
|
|
36
|
+
let smokeIssueId: string | null = null;
|
|
37
|
+
const createdCommentIds: string[] = [];
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
const token = loadApiKey();
|
|
41
|
+
api = new LinearAgentApi(token);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
// Cleanup: we can't delete comments via API, but they're prefixed
|
|
46
|
+
// with [SMOKE TEST] for easy identification.
|
|
47
|
+
if (createdCommentIds.length > 0) {
|
|
48
|
+
console.log(
|
|
49
|
+
`Smoke test created ${createdCommentIds.length} comment(s) prefixed with [SMOKE TEST]. ` +
|
|
50
|
+
`Clean up manually if needed.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe("Linear API smoke tests", () => {
|
|
58
|
+
describe("connectivity", () => {
|
|
59
|
+
it("resolves viewer ID", async () => {
|
|
60
|
+
const viewerId = await api.getViewerId();
|
|
61
|
+
expect(viewerId).toBeTruthy();
|
|
62
|
+
expect(typeof viewerId).toBe("string");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("team discovery", () => {
|
|
67
|
+
it("lists teams", async () => {
|
|
68
|
+
const teams = await api.getTeams();
|
|
69
|
+
expect(teams.length).toBeGreaterThan(0);
|
|
70
|
+
expect(teams[0]).toHaveProperty("id");
|
|
71
|
+
expect(teams[0]).toHaveProperty("name");
|
|
72
|
+
expect(teams[0]).toHaveProperty("key");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("finds our configured team", async () => {
|
|
76
|
+
const teams = await api.getTeams();
|
|
77
|
+
const ourTeam = teams.find((t) => t.id === TEAM_ID);
|
|
78
|
+
expect(ourTeam).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("lists team labels", async () => {
|
|
82
|
+
const labels = await api.getTeamLabels(TEAM_ID);
|
|
83
|
+
expect(Array.isArray(labels)).toBe(true);
|
|
84
|
+
// Labels may or may not exist, but the call should succeed
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("lists team workflow states", async () => {
|
|
88
|
+
const states = await api.getTeamStates(TEAM_ID);
|
|
89
|
+
expect(states.length).toBeGreaterThan(0);
|
|
90
|
+
expect(states[0]).toHaveProperty("id");
|
|
91
|
+
expect(states[0]).toHaveProperty("name");
|
|
92
|
+
expect(states[0]).toHaveProperty("type");
|
|
93
|
+
// Should have at least backlog, started, completed types
|
|
94
|
+
const types = states.map((s) => s.type);
|
|
95
|
+
expect(types).toContain("backlog");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("issue operations", () => {
|
|
100
|
+
it("creates a smoke test issue", async () => {
|
|
101
|
+
const states = await api.getTeamStates(TEAM_ID);
|
|
102
|
+
const backlogState = states.find((s) => s.type === "backlog");
|
|
103
|
+
expect(backlogState).toBeTruthy();
|
|
104
|
+
|
|
105
|
+
const result = await api.createIssue({
|
|
106
|
+
teamId: TEAM_ID,
|
|
107
|
+
title: "[SMOKE TEST] Linear Plugin Integration Test",
|
|
108
|
+
description:
|
|
109
|
+
"Auto-generated by smoke tests. Safe to delete.\n\n" +
|
|
110
|
+
`Created: ${new Date().toISOString()}`,
|
|
111
|
+
stateId: backlogState!.id,
|
|
112
|
+
priority: 4, // Low
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(result.id).toBeTruthy();
|
|
116
|
+
expect(result.identifier).toBeTruthy();
|
|
117
|
+
smokeIssueId = result.id;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reads issue details", async () => {
|
|
121
|
+
expect(smokeIssueId).toBeTruthy();
|
|
122
|
+
const issue = await api.getIssueDetails(smokeIssueId!);
|
|
123
|
+
|
|
124
|
+
expect(issue.id).toBe(smokeIssueId);
|
|
125
|
+
expect(issue.identifier).toBeTruthy();
|
|
126
|
+
expect(issue.title).toContain("[SMOKE TEST]");
|
|
127
|
+
expect(issue.state).toHaveProperty("name");
|
|
128
|
+
expect(issue.team).toHaveProperty("id");
|
|
129
|
+
expect(issue.team).toHaveProperty("name");
|
|
130
|
+
expect(issue.labels).toHaveProperty("nodes");
|
|
131
|
+
expect(issue.comments).toHaveProperty("nodes");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("updates issue fields", async () => {
|
|
135
|
+
expect(smokeIssueId).toBeTruthy();
|
|
136
|
+
const success = await api.updateIssue(smokeIssueId!, {
|
|
137
|
+
estimate: 1,
|
|
138
|
+
priority: 4,
|
|
139
|
+
});
|
|
140
|
+
expect(success).toBe(true);
|
|
141
|
+
|
|
142
|
+
// Verify the update
|
|
143
|
+
const issue = await api.getIssueDetails(smokeIssueId!);
|
|
144
|
+
expect(issue.estimate).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("comment lifecycle", () => {
|
|
149
|
+
it("creates a comment", async () => {
|
|
150
|
+
expect(smokeIssueId).toBeTruthy();
|
|
151
|
+
const commentId = await api.createComment(
|
|
152
|
+
smokeIssueId!,
|
|
153
|
+
"[SMOKE TEST] Comment created by integration test.\n\n" +
|
|
154
|
+
`Timestamp: ${new Date().toISOString()}`,
|
|
155
|
+
);
|
|
156
|
+
expect(commentId).toBeTruthy();
|
|
157
|
+
expect(typeof commentId).toBe("string");
|
|
158
|
+
createdCommentIds.push(commentId);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("comment appears in issue details", async () => {
|
|
162
|
+
expect(smokeIssueId).toBeTruthy();
|
|
163
|
+
expect(createdCommentIds.length).toBeGreaterThan(0);
|
|
164
|
+
|
|
165
|
+
const issue = await api.getIssueDetails(smokeIssueId!);
|
|
166
|
+
const comments = issue.comments.nodes;
|
|
167
|
+
const found = comments.some((c) =>
|
|
168
|
+
c.body.includes("[SMOKE TEST] Comment created by integration test"),
|
|
169
|
+
);
|
|
170
|
+
expect(found).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("creates an agent identity comment (requires OAuth — skipped with API key)", async () => {
|
|
174
|
+
expect(smokeIssueId).toBeTruthy();
|
|
175
|
+
// createAsUser posts as a named OpenClaw agent (e.g. "Mal", "Kaylee")
|
|
176
|
+
// with their avatar. Requires OAuth actor=app mode — personal API keys
|
|
177
|
+
// can't use it, so comments fall back to a **[AgentName]** prefix.
|
|
178
|
+
try {
|
|
179
|
+
const commentId = await api.createComment(
|
|
180
|
+
smokeIssueId!,
|
|
181
|
+
"[SMOKE TEST] Agent identity comment test.",
|
|
182
|
+
{
|
|
183
|
+
createAsUser: "Smoke Test Bot",
|
|
184
|
+
displayIconUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
expect(commentId).toBeTruthy();
|
|
188
|
+
createdCommentIds.push(commentId);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const msg = String(err);
|
|
191
|
+
if (msg.includes("createAsUser") || msg.includes("actor=app")) {
|
|
192
|
+
// Expected with API key — agent identity comments require OAuth
|
|
193
|
+
expect(true).toBe(true);
|
|
194
|
+
} else {
|
|
195
|
+
throw err; // Unexpected error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("issue state transitions", () => {
|
|
202
|
+
let teamStates: Array<{ id: string; name: string; type: string }>;
|
|
203
|
+
|
|
204
|
+
it("fetches team workflow states for transition tests", async () => {
|
|
205
|
+
teamStates = await api.getTeamStates(TEAM_ID);
|
|
206
|
+
expect(teamStates.length).toBeGreaterThan(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("transitions issue from backlog to started", async () => {
|
|
210
|
+
expect(smokeIssueId).toBeTruthy();
|
|
211
|
+
const startedState = teamStates.find((s) => s.type === "started");
|
|
212
|
+
expect(startedState).toBeTruthy();
|
|
213
|
+
|
|
214
|
+
const success = await api.updateIssue(smokeIssueId!, { stateId: startedState!.id });
|
|
215
|
+
expect(success).toBe(true);
|
|
216
|
+
|
|
217
|
+
const issue = await api.getIssueDetails(smokeIssueId!);
|
|
218
|
+
expect(issue.state.type).toBe("started");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("transitions issue from started to completed", async () => {
|
|
222
|
+
expect(smokeIssueId).toBeTruthy();
|
|
223
|
+
const completedState = teamStates.find((s) => s.type === "completed");
|
|
224
|
+
expect(completedState).toBeTruthy();
|
|
225
|
+
|
|
226
|
+
const success = await api.updateIssue(smokeIssueId!, { stateId: completedState!.id });
|
|
227
|
+
expect(success).toBe(true);
|
|
228
|
+
|
|
229
|
+
const issue = await api.getIssueDetails(smokeIssueId!);
|
|
230
|
+
expect(issue.state.type).toBe("completed");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("transitions issue from completed to canceled", async () => {
|
|
234
|
+
expect(smokeIssueId).toBeTruthy();
|
|
235
|
+
const canceledState = teamStates.find(
|
|
236
|
+
(s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
|
|
237
|
+
);
|
|
238
|
+
expect(canceledState).toBeTruthy();
|
|
239
|
+
|
|
240
|
+
const success = await api.updateIssue(smokeIssueId!, { stateId: canceledState!.id });
|
|
241
|
+
expect(success).toBe(true);
|
|
242
|
+
|
|
243
|
+
const issue = await api.getIssueDetails(smokeIssueId!);
|
|
244
|
+
expect(issue.state.type).toBe("canceled");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("@mention pattern matching", () => {
|
|
249
|
+
it("buildMentionPattern matches configured aliases", async () => {
|
|
250
|
+
// Test the pattern logic without needing agent-profiles.json.
|
|
251
|
+
// Note: use match() not test() — test() with `g` flag is stateful.
|
|
252
|
+
const aliases = ["mal", "kaylee", "inara", "zoe"];
|
|
253
|
+
const escaped = aliases.map((a) =>
|
|
254
|
+
a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
255
|
+
);
|
|
256
|
+
const pattern = new RegExp(`@(${escaped.join("|")})`, "gi");
|
|
257
|
+
|
|
258
|
+
expect("@mal please fix this".match(pattern)).toBeTruthy();
|
|
259
|
+
expect("Hey @kaylee can you look at this?".match(pattern)).toBeTruthy();
|
|
260
|
+
expect("@Inara write a blog post".match(pattern)).toBeTruthy();
|
|
261
|
+
expect("No mention here".match(pattern)).toBeNull();
|
|
262
|
+
expect("email@mal.com".match(pattern)).toBeTruthy(); // Known edge case
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("dedup dry run", () => {
|
|
267
|
+
it("wasRecentlyProcessed returns false first, true second", async () => {
|
|
268
|
+
// Import the dedup function
|
|
269
|
+
const { _resetForTesting } = await import(
|
|
270
|
+
"../pipeline/webhook.js"
|
|
271
|
+
);
|
|
272
|
+
_resetForTesting();
|
|
273
|
+
|
|
274
|
+
// The wasRecentlyProcessed function is not exported directly,
|
|
275
|
+
// but we can test it indirectly through the webhook handler.
|
|
276
|
+
// For a pure unit test of the dedup function, see webhook-dedup.test.ts.
|
|
277
|
+
// Here we just verify the reset works.
|
|
278
|
+
expect(typeof _resetForTesting).toBe("function");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("webhook management", () => {
|
|
283
|
+
it("lists webhooks", async () => {
|
|
284
|
+
const webhooks = await api.listWebhooks();
|
|
285
|
+
expect(Array.isArray(webhooks)).toBe(true);
|
|
286
|
+
// Should have at least the shape we expect
|
|
287
|
+
if (webhooks.length > 0) {
|
|
288
|
+
expect(webhooks[0]).toHaveProperty("id");
|
|
289
|
+
expect(webhooks[0]).toHaveProperty("url");
|
|
290
|
+
expect(webhooks[0]).toHaveProperty("enabled");
|
|
291
|
+
expect(webhooks[0]).toHaveProperty("resourceTypes");
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("getWebhookStatus reports issues on misconfigured webhook", async () => {
|
|
296
|
+
const { getWebhookStatus } = await import("../../src/infra/webhook-provision.js");
|
|
297
|
+
// Use a URL that won't match any real webhook — should return null
|
|
298
|
+
const status = await getWebhookStatus(api, "https://nonexistent.example.com/webhook");
|
|
299
|
+
expect(status).toBeNull();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("getWebhookStatus finds our webhook if configured", async () => {
|
|
303
|
+
const { getWebhookStatus } = await import("../../src/infra/webhook-provision.js");
|
|
304
|
+
const status = await getWebhookStatus(
|
|
305
|
+
api,
|
|
306
|
+
"https://linear.calltelemetry.com/linear/webhook",
|
|
307
|
+
);
|
|
308
|
+
// May or may not exist — just check the function works
|
|
309
|
+
if (status) {
|
|
310
|
+
expect(status.id).toBeTruthy();
|
|
311
|
+
expect(status.url).toBe("https://linear.calltelemetry.com/linear/webhook");
|
|
312
|
+
expect(Array.isArray(status.issues)).toBe(true);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("provisionWebhook returns already_ok when correctly configured", async () => {
|
|
317
|
+
const { provisionWebhook, getWebhookStatus } = await import("../../src/infra/webhook-provision.js");
|
|
318
|
+
const status = await getWebhookStatus(
|
|
319
|
+
api,
|
|
320
|
+
"https://linear.calltelemetry.com/linear/webhook",
|
|
321
|
+
);
|
|
322
|
+
// Only run if our webhook exists and is correctly configured
|
|
323
|
+
if (status && status.issues.length === 0) {
|
|
324
|
+
const result = await provisionWebhook(
|
|
325
|
+
api,
|
|
326
|
+
"https://linear.calltelemetry.com/linear/webhook",
|
|
327
|
+
);
|
|
328
|
+
expect(result.action).toBe("already_ok");
|
|
329
|
+
expect(result.webhookId).toBe(status.id);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("cleanup", () => {
|
|
335
|
+
it("cancels the smoke test issue", async () => {
|
|
336
|
+
if (!smokeIssueId) return;
|
|
337
|
+
|
|
338
|
+
// Move to cancelled state if available, otherwise just leave it
|
|
339
|
+
try {
|
|
340
|
+
const states = await api.getTeamStates(TEAM_ID);
|
|
341
|
+
const cancelledState = states.find(
|
|
342
|
+
(s) => s.type === "cancelled" || s.name.toLowerCase().includes("cancel"),
|
|
343
|
+
);
|
|
344
|
+
if (cancelledState) {
|
|
345
|
+
await api.updateIssue(smokeIssueId, { stateId: cancelledState.id });
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
// Best effort cleanup
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|