@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 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** (Settings > API > Webhooks) pointing to the same URL with Comment + Issue + User events enabled. Both webhooks are required.
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. |
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-linear",
3
3
  "name": "Linear Agent",
4
4
  "description": "Linear integration with OAuth support, agent pipeline, and webhook-driven AI agent lifecycle",
5
- "version": "0.8.1",
5
+ "version": "0.8.2",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ });