@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.
- package/README.md +37 -4
- 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 +631 -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/intent-classify.test.ts +43 -0
- package/src/pipeline/intent-classify.ts +10 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +372 -112
- package/src/tools/tools.test.ts +100 -0
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
|
|
|
12
12
|
- **New issue?** Agent estimates story points, adds labels, sets priority.
|
|
13
13
|
- **Assign to agent?** A worker implements it, an independent auditor verifies it, done.
|
|
14
14
|
- **Comment anything?** The bot understands natural language — no magic commands needed.
|
|
15
|
+
- **Say "close this" or "mark as done"?** Agent writes a closure report and transitions the issue to completed.
|
|
15
16
|
- **Say "let's plan the features"?** A planner interviews you, writes user stories, and builds your full issue hierarchy.
|
|
16
17
|
- **Plan looks good?** A different AI model automatically audits the plan before dispatch.
|
|
17
18
|
- **Agent goes silent?** A watchdog kills it and retries automatically.
|
|
@@ -36,7 +37,7 @@ Go to **Linear Settings > API > Applications** and create an app:
|
|
|
36
37
|
- Enable events: **Agent Sessions**, **Comments**, **Issues**
|
|
37
38
|
- Save your **Client ID** and **Client Secret**
|
|
38
39
|
|
|
39
|
-
> You also need a **workspace webhook**
|
|
40
|
+
> 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
41
|
|
|
41
42
|
### 3. Set credentials
|
|
42
43
|
|
|
@@ -118,7 +119,7 @@ Every issue moves through a clear pipeline. Here's exactly what happens at each
|
|
|
118
119
|
|
|
119
120
|
**Trigger:** You create a new issue.
|
|
120
121
|
|
|
121
|
-
The agent reads your issue, estimates story points, adds labels, sets priority, and posts an assessment comment — all within seconds.
|
|
122
|
+
The agent reads your issue, estimates story points, adds labels, sets priority, and posts an assessment comment — all within seconds. Triage runs in **read-only mode** (no file writes, no code execution) to prevent side effects.
|
|
122
123
|
|
|
123
124
|
**What you'll see in Linear:**
|
|
124
125
|
|
|
@@ -305,11 +306,35 @@ User comment → Intent Classifier (small model, ~2s) → Route to handler
|
|
|
305
306
|
| "hey kaylee can you look at this?" | Routes to Kaylee (no `@` needed) |
|
|
306
307
|
| "what can I do here?" | Default agent responds (not silently dropped) |
|
|
307
308
|
| "fix the search bug" | Default agent dispatches work |
|
|
309
|
+
| "close this" / "mark as done" / "this is resolved" | Generates closure report, transitions issue to completed |
|
|
308
310
|
|
|
309
311
|
`@mentions` still work as a fast path — if you write `@kaylee`, the classifier is skipped entirely for speed.
|
|
310
312
|
|
|
311
313
|
> **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
314
|
|
|
315
|
+
### Deduplication
|
|
316
|
+
|
|
317
|
+
The webhook handler prevents double-processing through a two-tier guard system:
|
|
318
|
+
|
|
319
|
+
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.
|
|
320
|
+
|
|
321
|
+
2. **`wasRecentlyProcessed` (TTL Map, 60s)** — Catches exact-duplicate webhook deliveries. Each event type uses a specific dedup key:
|
|
322
|
+
|
|
323
|
+
| Event | Dedup Key | Guards (in order) |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| `AgentSessionEvent.created` | `session:<sessionId>` | activeRuns → wasRecentlyProcessed |
|
|
326
|
+
| `AgentSessionEvent.prompted` | `webhook:<webhookId>` | activeRuns → wasRecentlyProcessed |
|
|
327
|
+
| `Comment.create` | `comment:<commentId>` | wasRecentlyProcessed → viewerId → activeRuns |
|
|
328
|
+
| `Issue.update` | `<trigger>:<issueId>:<viewerId>` | activeRuns → no-change → viewerId → wasRecentlyProcessed |
|
|
329
|
+
| `Issue.create` | `issue-create:<issueId>` | wasRecentlyProcessed → activeRuns → planning mode → bot-created |
|
|
330
|
+
| `AppUserNotification` | *(immediate discard)* | — |
|
|
331
|
+
|
|
332
|
+
`AppUserNotification` events are discarded because they duplicate events already received via the workspace webhook (e.g., `Comment.create` for mentions, `Issue.update` for assignments). Processing both would cause double agent runs.
|
|
333
|
+
|
|
334
|
+
**Response delivery:** When an agent session exists, responses are delivered via `emitActivity(type: "response")` — not `createComment`. This prevents duplicate visible messages on the issue. `createComment` is only used as a fallback when `emitActivity` fails or when no agent session exists.
|
|
335
|
+
|
|
336
|
+
**Comment echo prevention:** Comments posted outside of sessions 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.
|
|
337
|
+
|
|
313
338
|
---
|
|
314
339
|
|
|
315
340
|
## Planning a Project
|
|
@@ -417,6 +442,7 @@ If an issue gets stuck (all retries failed), dependent issues are blocked and yo
|
|
|
417
442
|
| Comment anything on an issue | Intent classifier routes to the right handler |
|
|
418
443
|
| Mention an agent by name (with or without `@`) | That agent responds |
|
|
419
444
|
| Ask a question or request work | Default agent handles it |
|
|
445
|
+
| Say "close this" / "mark as done" / "this is resolved" | Closure report posted, issue moved to completed |
|
|
420
446
|
| Say "plan this project" (on a project issue) | Planning interview starts |
|
|
421
447
|
| Reply during planning | Issues created/updated with user stories & AC |
|
|
422
448
|
| Say "looks good" / "finalize plan" | Validates → cross-model review → approval |
|
|
@@ -880,7 +906,7 @@ Every warning and error includes a `→` line telling you what to do. Run `docto
|
|
|
880
906
|
|
|
881
907
|
### Unit tests
|
|
882
908
|
|
|
883
|
-
|
|
909
|
+
524 tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, cross-model review, notifications, and infrastructure:
|
|
884
910
|
|
|
885
911
|
```bash
|
|
886
912
|
cd ~/claw-extensions/linear
|
|
@@ -1026,6 +1052,13 @@ openclaw openclaw-linear notify test # Test all targets
|
|
|
1026
1052
|
openclaw openclaw-linear notify test --channel discord # Test one channel
|
|
1027
1053
|
openclaw openclaw-linear notify setup # Interactive setup
|
|
1028
1054
|
|
|
1055
|
+
# Webhooks
|
|
1056
|
+
openclaw openclaw-linear webhooks status # Show webhook config in Linear
|
|
1057
|
+
openclaw openclaw-linear webhooks setup # Auto-provision workspace webhook
|
|
1058
|
+
openclaw openclaw-linear webhooks setup --dry-run # Preview what would change
|
|
1059
|
+
openclaw openclaw-linear webhooks setup --url <url> # Use custom webhook URL
|
|
1060
|
+
openclaw openclaw-linear webhooks delete <id> # Delete a webhook by ID
|
|
1061
|
+
|
|
1029
1062
|
# Dispatch
|
|
1030
1063
|
/dispatch list # Active dispatches
|
|
1031
1064
|
/dispatch status <identifier> # Dispatch details
|
|
@@ -1057,7 +1090,7 @@ journalctl --user -u openclaw-gateway -f # Watch live logs
|
|
|
1057
1090
|
| Agent goes silent | Watchdog auto-kills after `inactivitySec` and retries. Check logs for `Watchdog KILL`. |
|
|
1058
1091
|
| Dispatch stuck after watchdog | Both retries failed. Check `.claw/log.jsonl`. Re-assign issue to restart. |
|
|
1059
1092
|
| `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. |
|
|
1093
|
+
| 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
1094
|
| OAuth token expired | Auto-refreshes. If stuck, re-run `openclaw openclaw-linear auth` and restart. |
|
|
1062
1095
|
| Audit always fails | Run `openclaw openclaw-linear prompts validate` to check prompt syntax. |
|
|
1063
1096
|
| 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
|
+
});
|