@calltelemetry/openclaw-linear 0.2.0 → 0.3.1
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 +120 -14
- package/index.ts +6 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/auth.ts +3 -3
- package/src/cli.ts +203 -0
- package/src/linear-api.ts +2 -2
- package/src/tools.ts +6 -6
- package/src/webhook.ts +209 -0
package/README.md
CHANGED
|
@@ -4,12 +4,60 @@ Webhook-driven Linear integration with OAuth support, multi-agent routing, and a
|
|
|
4
4
|
|
|
5
5
|
## What It Does
|
|
6
6
|
|
|
7
|
+
- **Auto-triage** — New issues are automatically estimated (story points), labeled, and prioritized with a posted assessment
|
|
7
8
|
- **Issue triage** — When an issue is assigned/delegated to the app user, an agent estimates story points, applies labels, and posts an assessment
|
|
8
9
|
- **Agent sessions** — Full plan-approve-implement-audit pipeline triggered from Linear's agent UI
|
|
9
10
|
- **@mention routing** — Comment mentions like `@qa` or `@infra` route to specific role-based agents with different expertise
|
|
10
11
|
- **App notifications** — Responds to Linear app mentions and assignments via branded comments
|
|
11
12
|
- **Activity tracking** — Emits thought/action/response events visible in Linear's agent session UI
|
|
12
13
|
|
|
14
|
+
## Quick Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
openclaw plugins install @calltelemetry/openclaw-linear
|
|
18
|
+
openclaw gateway restart
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's it — the plugin is installed and enabled. Continue with the [setup steps](#setup) below to configure Linear OAuth and webhooks.
|
|
22
|
+
|
|
23
|
+
> To install from a local checkout instead: `openclaw plugins install --link /path/to/linear`
|
|
24
|
+
|
|
25
|
+
## First Run
|
|
26
|
+
|
|
27
|
+
After installing, the plugin loads but **will not process any webhooks or agent tools until you authenticate**. You'll see this in the logs:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Linear agent extension registered (agent: default, token: missing)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This is normal — the plugin does not crash without auth. It registers all routes and CLI commands, but webhook handlers and tools will return errors until a token is available.
|
|
34
|
+
|
|
35
|
+
To authenticate:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Set your OAuth app credentials
|
|
39
|
+
export LINEAR_CLIENT_ID="your_client_id"
|
|
40
|
+
export LINEAR_CLIENT_SECRET="your_client_secret"
|
|
41
|
+
|
|
42
|
+
# Run the OAuth flow
|
|
43
|
+
openclaw openclaw-linear auth
|
|
44
|
+
|
|
45
|
+
# Verify
|
|
46
|
+
openclaw openclaw-linear status
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The auth flow stores tokens in `~/.openclaw/auth-profiles.json`. This file is created automatically — you do not need to create it manually. After auth, restart the gateway:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
openclaw gateway restart
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
You should now see `token: profile` in the logs:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Linear agent extension registered (agent: default, token: profile)
|
|
59
|
+
```
|
|
60
|
+
|
|
13
61
|
## Prerequisites
|
|
14
62
|
|
|
15
63
|
- OpenClaw gateway running (systemd service)
|
|
@@ -153,16 +201,28 @@ export OPENCLAW_GATEWAY_PORT="18789" # if non-default
|
|
|
153
201
|
|
|
154
202
|
### 5. Install the Plugin
|
|
155
203
|
|
|
156
|
-
|
|
204
|
+
If you haven't already installed via [Quick Install](#quick-install):
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
openclaw plugins install @calltelemetry/openclaw-linear
|
|
208
|
+
openclaw gateway restart
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
This registers the plugin in your OpenClaw config and restarts the gateway to load it.
|
|
212
|
+
|
|
213
|
+
<details>
|
|
214
|
+
<summary>Manual config (advanced)</summary>
|
|
215
|
+
|
|
216
|
+
If you prefer to manage config by hand, add the plugin path to `~/.openclaw/openclaw.json`:
|
|
157
217
|
|
|
158
218
|
```json
|
|
159
219
|
{
|
|
160
220
|
"plugins": {
|
|
161
221
|
"load": {
|
|
162
|
-
"paths": ["/path/to/
|
|
222
|
+
"paths": ["/path/to/linear"]
|
|
163
223
|
},
|
|
164
224
|
"entries": {
|
|
165
|
-
"linear": {
|
|
225
|
+
"openclaw-linear": {
|
|
166
226
|
"enabled": true
|
|
167
227
|
}
|
|
168
228
|
}
|
|
@@ -170,11 +230,8 @@ Add the plugin path to your OpenClaw config (`~/.openclaw/openclaw.json`):
|
|
|
170
230
|
}
|
|
171
231
|
```
|
|
172
232
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
```bash
|
|
176
|
-
openclaw gateway restart
|
|
177
|
-
```
|
|
233
|
+
Then restart: `openclaw gateway restart`
|
|
234
|
+
</details>
|
|
178
235
|
|
|
179
236
|
### 6. Run the OAuth Flow
|
|
180
237
|
|
|
@@ -183,7 +240,7 @@ There are two ways to authorize the plugin with Linear.
|
|
|
183
240
|
#### Option A: CLI Flow (Recommended)
|
|
184
241
|
|
|
185
242
|
```bash
|
|
186
|
-
openclaw
|
|
243
|
+
openclaw openclaw-linear auth
|
|
187
244
|
```
|
|
188
245
|
|
|
189
246
|
This launches the OAuth flow interactively:
|
|
@@ -283,13 +340,61 @@ Create `~/.openclaw/agent-profiles.json` to define role-based agents:
|
|
|
283
340
|
| Field | Required | Description |
|
|
284
341
|
|---|---|---|
|
|
285
342
|
| `label` | Yes | Display name in Linear comments |
|
|
286
|
-
| `mission` | Yes | Agent's role description (
|
|
287
|
-
| `isDefault` | One agent | The default agent handles OAuth app events and assignment triage |
|
|
343
|
+
| `mission` | Yes | Agent's role description (injected as system context when the agent is dispatched) |
|
|
344
|
+
| `isDefault` | One agent | The default agent handles OAuth app events, agent sessions, and assignment triage |
|
|
288
345
|
| `mentionAliases` | Yes | @mention triggers in comments (e.g., `@qa` in a comment routes to the QA agent) |
|
|
289
346
|
| `appAliases` | No | Triggers via OAuth app webhook (default agent only, for app-level @mentions) |
|
|
290
347
|
| `avatarUrl` | No | Avatar displayed on branded comments. Falls back to `[Label]` prefix if not set. |
|
|
291
348
|
|
|
292
|
-
|
|
349
|
+
#### How agent-profiles.json connects to openclaw.json
|
|
350
|
+
|
|
351
|
+
Each key in `agent-profiles.json` (e.g., `"lead"`, `"qa"`) must have a matching agent definition in your OpenClaw config (`~/.openclaw/openclaw.json`). The Linear plugin dispatches work via `openclaw agent --agent <id>`, so the agent must actually exist.
|
|
352
|
+
|
|
353
|
+
Example — if `agent-profiles.json` defines `"lead"` and `"qa"`, your `openclaw.json` needs:
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
{
|
|
357
|
+
"agents": {
|
|
358
|
+
"lead": {
|
|
359
|
+
"model": "claude-sonnet-4-5-20250929",
|
|
360
|
+
"systemPrompt": "You are a product lead agent...",
|
|
361
|
+
"tools": ["linear_list_issues", "linear_create_issue", "linear_add_comment"]
|
|
362
|
+
},
|
|
363
|
+
"qa": {
|
|
364
|
+
"model": "claude-sonnet-4-5-20250929",
|
|
365
|
+
"systemPrompt": "You are a QA engineer agent...",
|
|
366
|
+
"tools": ["linear_list_issues", "linear_add_comment"]
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
#### Routing flow
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
Linear comment "@qa review this test plan"
|
|
376
|
+
→ Plugin matches "qa" in mentionAliases
|
|
377
|
+
→ Looks up agent-profiles.json → finds "qa" profile
|
|
378
|
+
→ Dispatches: openclaw agent --agent qa --message "<issue context + comment>"
|
|
379
|
+
→ OpenClaw loads "qa" agent config from openclaw.json
|
|
380
|
+
→ Agent runs with the qa profile's mission as context
|
|
381
|
+
→ Response posted back to Linear as a branded comment with qa's label/avatar
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
For agent sessions (triggered by the Linear agent UI or app @mentions):
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
Linear AgentSessionEvent.created
|
|
388
|
+
→ Plugin resolves the default agent (isDefault: true)
|
|
389
|
+
→ Runs the 3-stage pipeline (plan → implement → audit)
|
|
390
|
+
→ Each stage dispatches via the default agent's openclaw.json config
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
#### What happens if they don't match
|
|
394
|
+
|
|
395
|
+
- **Agent profile exists but no matching openclaw.json agent:** The dispatch fails and an error is logged. The webhook returns 200 (Linear requirement) but no comment is posted.
|
|
396
|
+
- **openclaw.json agent exists but no profile:** The agent works for direct CLI use but won't be reachable from Linear. No @mention alias maps to it.
|
|
397
|
+
- **No agent marked `isDefault`:** Agent sessions and assignment triage will fail with `"No defaultAgentId"` error.
|
|
293
398
|
|
|
294
399
|
### 8. Verify
|
|
295
400
|
|
|
@@ -327,6 +432,7 @@ POST /linear/webhook
|
|
|
327
432
|
+-- AgentSessionEvent.prompted --> Resume pipeline (user approved plan)
|
|
328
433
|
+-- AppUserNotification --> Direct agent response to mention/assignment
|
|
329
434
|
+-- Comment.create --> Route @mention to role-based agent
|
|
435
|
+
+-- Issue.create --> Auto-triage new issues (estimate, labels, priority)
|
|
330
436
|
+-- Issue.update --> Triage if assigned/delegated to app user
|
|
331
437
|
```
|
|
332
438
|
|
|
@@ -381,7 +487,7 @@ Optional settings in `openclaw.json` under the plugin entry:
|
|
|
381
487
|
{
|
|
382
488
|
"plugins": {
|
|
383
489
|
"entries": {
|
|
384
|
-
"linear": {
|
|
490
|
+
"openclaw-linear": {
|
|
385
491
|
"enabled": true,
|
|
386
492
|
"clientId": "...",
|
|
387
493
|
"clientSecret": "...",
|
|
@@ -451,7 +557,7 @@ openclaw logs | grep -i "linear\|plugin\|error"
|
|
|
451
557
|
**Agent sessions not working:**
|
|
452
558
|
- OAuth tokens require `app:assignable` and `app:mentionable` scopes
|
|
453
559
|
- Personal API keys cannot create agent sessions — use OAuth
|
|
454
|
-
- Re-run `openclaw
|
|
560
|
+
- Re-run `openclaw openclaw-linear auth` to get fresh tokens
|
|
455
561
|
|
|
456
562
|
**"No defaultAgentId" error:**
|
|
457
563
|
- Set `defaultAgentId` in plugin config, OR
|
package/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { registerLinearProvider } from "./src/auth.js";
|
|
3
|
+
import { registerCli } from "./src/cli.js";
|
|
3
4
|
import { createLinearTools } from "./src/tools.js";
|
|
4
5
|
import { handleLinearWebhook } from "./src/webhook.js";
|
|
5
6
|
import { handleOAuthCallback } from "./src/oauth-callback.js";
|
|
@@ -20,6 +21,11 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
20
21
|
// Register Linear as an auth provider (OAuth flow with agent scopes)
|
|
21
22
|
registerLinearProvider(api);
|
|
22
23
|
|
|
24
|
+
// Register CLI commands: openclaw openclaw-linear auth|status
|
|
25
|
+
api.registerCli(({ program }) => registerCli(program, api), {
|
|
26
|
+
commands: ["openclaw-linear"],
|
|
27
|
+
});
|
|
28
|
+
|
|
23
29
|
// Register Linear tools for the agent
|
|
24
30
|
api.registerTool((ctx) => {
|
|
25
31
|
return createLinearTools(api, ctx);
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -4,11 +4,11 @@ import type {
|
|
|
4
4
|
ProviderAuthResult
|
|
5
5
|
} from "openclaw/plugin-sdk";
|
|
6
6
|
|
|
7
|
-
const LINEAR_OAUTH_AUTH_URL = "https://linear.app/oauth/authorize";
|
|
8
|
-
const LINEAR_OAUTH_TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
7
|
+
export const LINEAR_OAUTH_AUTH_URL = "https://linear.app/oauth/authorize";
|
|
8
|
+
export const LINEAR_OAUTH_TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
9
9
|
|
|
10
10
|
// Agent scopes: read/write + assignable (appear in assignment menus) + mentionable (respond to @mentions)
|
|
11
|
-
const LINEAR_AGENT_SCOPES = "read,write,app:assignable,app:mentionable";
|
|
11
|
+
export const LINEAR_AGENT_SCOPES = "read,write,app:assignable,app:mentionable";
|
|
12
12
|
|
|
13
13
|
// Token refresh helper — Linear tokens expire; refresh before they do
|
|
14
14
|
export async function refreshLinearToken(
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli.ts — CLI registration for `openclaw openclaw-linear auth` and `openclaw openclaw-linear status`.
|
|
3
|
+
*/
|
|
4
|
+
import type { Command } from "commander";
|
|
5
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { exec } from "node:child_process";
|
|
8
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "./linear-api.js";
|
|
10
|
+
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "./auth.js";
|
|
11
|
+
|
|
12
|
+
function prompt(question: string): Promise<string> {
|
|
13
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
rl.question(question, (answer) => {
|
|
16
|
+
rl.close();
|
|
17
|
+
resolve(answer.trim());
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function openBrowser(url: string): void {
|
|
23
|
+
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
24
|
+
exec(`${cmd} ${JSON.stringify(url)}`, () => {});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
28
|
+
const linear = program
|
|
29
|
+
.command("openclaw-linear")
|
|
30
|
+
.description("Linear plugin — auth and status");
|
|
31
|
+
|
|
32
|
+
// --- openclaw openclaw-linear auth ---
|
|
33
|
+
linear
|
|
34
|
+
.command("auth")
|
|
35
|
+
.description("Run Linear OAuth flow to authorize the agent")
|
|
36
|
+
.action(async () => {
|
|
37
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
38
|
+
const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
|
|
39
|
+
const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
|
|
40
|
+
|
|
41
|
+
if (!clientId || !clientSecret) {
|
|
42
|
+
console.error("Error: Linear client ID and secret must be configured.");
|
|
43
|
+
console.error("Set LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET env vars, or add clientId/clientSecret to plugin config.");
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT ?? "18789";
|
|
49
|
+
const redirectUri = (pluginConfig?.redirectUri as string)
|
|
50
|
+
?? process.env.LINEAR_REDIRECT_URI
|
|
51
|
+
?? `http://localhost:${gatewayPort}/linear/oauth/callback`;
|
|
52
|
+
|
|
53
|
+
const state = Math.random().toString(36).substring(7);
|
|
54
|
+
const authUrl = `${LINEAR_OAUTH_AUTH_URL}?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(LINEAR_AGENT_SCOPES)}&state=${state}&actor=app`;
|
|
55
|
+
|
|
56
|
+
console.log("\nOpening Linear OAuth authorization page...\n");
|
|
57
|
+
console.log(` ${authUrl}\n`);
|
|
58
|
+
openBrowser(authUrl);
|
|
59
|
+
|
|
60
|
+
const code = await prompt("Paste the authorization code from Linear: ");
|
|
61
|
+
if (!code) {
|
|
62
|
+
console.error("No code provided. Aborting.");
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log("Exchanging code for token...");
|
|
68
|
+
|
|
69
|
+
const response = await fetch(LINEAR_OAUTH_TOKEN_URL, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
72
|
+
body: new URLSearchParams({
|
|
73
|
+
grant_type: "authorization_code",
|
|
74
|
+
code,
|
|
75
|
+
client_id: clientId,
|
|
76
|
+
client_secret: clientSecret,
|
|
77
|
+
redirect_uri: redirectUri,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const error = await response.text();
|
|
83
|
+
console.error(`OAuth token exchange failed (${response.status}): ${error}`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tokens = await response.json();
|
|
89
|
+
|
|
90
|
+
// Store in auth profile store
|
|
91
|
+
let store: any = { version: 1, profiles: {} };
|
|
92
|
+
try {
|
|
93
|
+
const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
|
|
94
|
+
store = JSON.parse(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
// Fresh store
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
store.profiles = store.profiles ?? {};
|
|
100
|
+
store.profiles["linear:default"] = {
|
|
101
|
+
type: "oauth",
|
|
102
|
+
provider: "linear",
|
|
103
|
+
accessToken: tokens.access_token,
|
|
104
|
+
access: tokens.access_token,
|
|
105
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
106
|
+
refresh: tokens.refresh_token ?? null,
|
|
107
|
+
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
|
|
108
|
+
expires: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
|
|
109
|
+
scope: tokens.scope,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(store, null, 2), "utf8");
|
|
113
|
+
|
|
114
|
+
const expiresIn = tokens.expires_in ? `${Math.round(tokens.expires_in / 3600)}h` : "unknown";
|
|
115
|
+
console.log(`\nAuthorized! Token stored in auth profile store.`);
|
|
116
|
+
console.log(` Scopes: ${tokens.scope ?? "unknown"}`);
|
|
117
|
+
console.log(` Expires: ${expiresIn}`);
|
|
118
|
+
console.log(`\nRestart the gateway to pick up the new token.`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// --- openclaw openclaw-linear status ---
|
|
122
|
+
linear
|
|
123
|
+
.command("status")
|
|
124
|
+
.description("Show current Linear auth and connection status")
|
|
125
|
+
.action(async () => {
|
|
126
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
127
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
128
|
+
|
|
129
|
+
console.log("\nLinear Auth Status");
|
|
130
|
+
console.log("─".repeat(40));
|
|
131
|
+
console.log(` Source: ${tokenInfo.source}`);
|
|
132
|
+
|
|
133
|
+
if (!tokenInfo.accessToken) {
|
|
134
|
+
console.log(` Token: not found`);
|
|
135
|
+
console.log(`\nRun "openclaw openclaw-linear auth" to authorize.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(` Token: ${tokenInfo.accessToken.slice(0, 12)}...`);
|
|
140
|
+
console.log(` Refresh token: ${tokenInfo.refreshToken ? "present" : "none"}`);
|
|
141
|
+
|
|
142
|
+
if (tokenInfo.expiresAt) {
|
|
143
|
+
const remaining = tokenInfo.expiresAt - Date.now();
|
|
144
|
+
if (remaining <= 0) {
|
|
145
|
+
console.log(` Expires: EXPIRED`);
|
|
146
|
+
} else {
|
|
147
|
+
const hours = Math.floor(remaining / 3_600_000);
|
|
148
|
+
const mins = Math.floor((remaining % 3_600_000) / 60_000);
|
|
149
|
+
console.log(` Expires: ${hours}h ${mins}m`);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
console.log(` Expires: unknown (no expiry set)`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Try reading scope from profile
|
|
156
|
+
try {
|
|
157
|
+
const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
|
|
158
|
+
const store = JSON.parse(raw);
|
|
159
|
+
const scope = store?.profiles?.["linear:default"]?.scope;
|
|
160
|
+
if (scope) console.log(` Scopes: ${scope}`);
|
|
161
|
+
} catch {}
|
|
162
|
+
|
|
163
|
+
// Verify token with API call
|
|
164
|
+
console.log("\nConnection Test");
|
|
165
|
+
console.log("─".repeat(40));
|
|
166
|
+
try {
|
|
167
|
+
const authHeader = tokenInfo.refreshToken
|
|
168
|
+
? `Bearer ${tokenInfo.accessToken}`
|
|
169
|
+
: tokenInfo.accessToken;
|
|
170
|
+
|
|
171
|
+
const res = await fetch(LINEAR_GRAPHQL_URL, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
Authorization: authHeader,
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
query: `{ viewer { id name email } organization { name urlKey } }`,
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
console.log(` API response: ${res.status} ${res.statusText}`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const payload = await res.json();
|
|
188
|
+
if (payload.errors?.length) {
|
|
189
|
+
console.log(` API error: ${payload.errors[0].message}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const { viewer, organization } = payload.data;
|
|
194
|
+
console.log(` API: connected`);
|
|
195
|
+
console.log(` User: ${viewer.name} (${viewer.email})`);
|
|
196
|
+
console.log(` Workspace: ${organization.name} (${organization.urlKey})`);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.log(` API: failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log();
|
|
202
|
+
});
|
|
203
|
+
}
|
package/src/linear-api.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { refreshLinearToken } from "./auth.js";
|
|
4
4
|
|
|
5
|
-
const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
6
|
-
const AUTH_PROFILES_PATH = join(
|
|
5
|
+
export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
6
|
+
export const AUTH_PROFILES_PATH = join(
|
|
7
7
|
process.env.HOME ?? "/home/claw",
|
|
8
8
|
".openclaw",
|
|
9
9
|
"auth-profiles.json",
|
package/src/tools.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
|
2
2
|
import { jsonResult } from "openclaw/plugin-sdk";
|
|
3
3
|
import { LinearClient } from "./client.js";
|
|
4
|
+
import { resolveLinearToken } from "./linear-api.js";
|
|
4
5
|
|
|
5
6
|
export function createLinearTools(api: OpenClawPluginApi, ctx: OpenClawPluginToolContext): AnyAgentTool[] {
|
|
6
7
|
const getClient = () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
throw new Error("Linear access token not found. Please authenticate first.");
|
|
8
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
9
|
+
const resolved = resolveLinearToken(pluginConfig);
|
|
10
|
+
if (!resolved.accessToken) {
|
|
11
|
+
throw new Error("Linear access token not found. Run 'openclaw openclaw-linear auth' to authenticate.");
|
|
12
12
|
}
|
|
13
|
-
return new LinearClient(
|
|
13
|
+
return new LinearClient(resolved.accessToken);
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
return [
|
package/src/webhook.ts
CHANGED
|
@@ -844,6 +844,215 @@ export async function handleLinearWebhook(
|
|
|
844
844
|
return true;
|
|
845
845
|
}
|
|
846
846
|
|
|
847
|
+
// ── Issue.create — auto-triage new issues ───────────────────────
|
|
848
|
+
if (payload.type === "Issue" && payload.action === "create") {
|
|
849
|
+
res.statusCode = 200;
|
|
850
|
+
res.end("ok");
|
|
851
|
+
|
|
852
|
+
const issue = payload.data;
|
|
853
|
+
if (!issue?.id) {
|
|
854
|
+
api.logger.error("Issue.create missing issue data");
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Dedup
|
|
859
|
+
if (wasRecentlyProcessed(`issue-create:${issue.id}`)) {
|
|
860
|
+
api.logger.info(`Issue.create ${issue.id} already processed — skipping`);
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
|
|
865
|
+
|
|
866
|
+
const linearApi = createLinearApi(api);
|
|
867
|
+
if (!linearApi) {
|
|
868
|
+
api.logger.error("No Linear access token — cannot triage new issue");
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const agentId = resolveAgentId(api);
|
|
873
|
+
|
|
874
|
+
// Dispatch triage (non-blocking)
|
|
875
|
+
void (async () => {
|
|
876
|
+
const profiles = loadAgentProfiles();
|
|
877
|
+
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
878
|
+
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
879
|
+
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
880
|
+
let agentSessionId: string | null = null;
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
// Fetch enriched issue + team labels
|
|
884
|
+
let enrichedIssue: any = issue;
|
|
885
|
+
let teamLabels: Array<{ id: string; name: string }> = [];
|
|
886
|
+
try {
|
|
887
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
888
|
+
if (enrichedIssue?.team?.id) {
|
|
889
|
+
teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
|
|
890
|
+
}
|
|
891
|
+
} catch (err) {
|
|
892
|
+
api.logger.warn(`Could not fetch issue details for triage: ${err}`);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
896
|
+
const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
|
|
897
|
+
const currentLabels = enrichedIssue?.labels?.nodes ?? [];
|
|
898
|
+
const currentLabelNames = currentLabels.map((l: any) => l.name).join(", ") || "None";
|
|
899
|
+
const availableLabelList = teamLabels.map((l) => ` - "${l.name}" (id: ${l.id})`).join("\n");
|
|
900
|
+
|
|
901
|
+
// Create agent session
|
|
902
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
903
|
+
agentSessionId = sessionResult.sessionId;
|
|
904
|
+
if (agentSessionId) {
|
|
905
|
+
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
906
|
+
api.logger.info(`Created agent session ${agentSessionId} for Issue.create triage`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (agentSessionId) {
|
|
910
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
911
|
+
type: "thought",
|
|
912
|
+
body: `Triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
913
|
+
}).catch(() => {});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (agentSessionId) {
|
|
917
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
918
|
+
type: "action",
|
|
919
|
+
action: "Triaging",
|
|
920
|
+
parameter: `${enrichedIssue?.identifier ?? issue.id} — estimating, labeling`,
|
|
921
|
+
}).catch(() => {});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const message = [
|
|
925
|
+
`IMPORTANT: You are triaging a new Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
|
|
926
|
+
``,
|
|
927
|
+
`## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
928
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
|
|
929
|
+
``,
|
|
930
|
+
`**Description:**`,
|
|
931
|
+
description,
|
|
932
|
+
``,
|
|
933
|
+
`## Your Triage Tasks`,
|
|
934
|
+
``,
|
|
935
|
+
`1. **Story Points** — Estimate complexity using ${estimationType} scale (1=trivial, 2=small, 3=medium, 5=large, 8=very large, 13=epic)`,
|
|
936
|
+
`2. **Labels** — Select appropriate labels from the team's available labels`,
|
|
937
|
+
`3. **Priority** — Set priority (1=Urgent, 2=High, 3=Medium, 4=Low) if not already set`,
|
|
938
|
+
`4. **Assessment** — Brief analysis of what this issue needs`,
|
|
939
|
+
``,
|
|
940
|
+
`## Available Labels`,
|
|
941
|
+
availableLabelList || " (no labels configured)",
|
|
942
|
+
``,
|
|
943
|
+
`## Response Format`,
|
|
944
|
+
``,
|
|
945
|
+
`You MUST start your response with a JSON block, then follow with your assessment:`,
|
|
946
|
+
``,
|
|
947
|
+
'```json',
|
|
948
|
+
`{`,
|
|
949
|
+
` "estimate": <number>,`,
|
|
950
|
+
` "labelIds": ["<id1>", "<id2>"],`,
|
|
951
|
+
` "priority": <number or null>,`,
|
|
952
|
+
` "assessment": "<one-line summary of your sizing rationale>"`,
|
|
953
|
+
`}`,
|
|
954
|
+
'```',
|
|
955
|
+
``,
|
|
956
|
+
`Then write your full assessment as markdown below the JSON block.`,
|
|
957
|
+
].filter(Boolean).join("\n");
|
|
958
|
+
|
|
959
|
+
const sessionId = `linear-triage-${issue.id}-${Date.now()}`;
|
|
960
|
+
const { runAgent } = await import("./agent.js");
|
|
961
|
+
const result = await runAgent({
|
|
962
|
+
api,
|
|
963
|
+
agentId,
|
|
964
|
+
sessionId,
|
|
965
|
+
message,
|
|
966
|
+
timeoutMs: 3 * 60_000,
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
const responseBody = result.success
|
|
970
|
+
? result.output
|
|
971
|
+
: `I encountered an error triaging this issue. Please triage manually.`;
|
|
972
|
+
|
|
973
|
+
// Parse triage JSON and apply to issue
|
|
974
|
+
let commentBody = responseBody;
|
|
975
|
+
if (result.success) {
|
|
976
|
+
const jsonMatch = responseBody.match(/```json\s*\n?([\s\S]*?)\n?```/);
|
|
977
|
+
if (jsonMatch) {
|
|
978
|
+
try {
|
|
979
|
+
const triage = JSON.parse(jsonMatch[1]);
|
|
980
|
+
const updateInput: Record<string, unknown> = {};
|
|
981
|
+
|
|
982
|
+
if (typeof triage.estimate === "number") {
|
|
983
|
+
updateInput.estimate = triage.estimate;
|
|
984
|
+
}
|
|
985
|
+
if (Array.isArray(triage.labelIds) && triage.labelIds.length > 0) {
|
|
986
|
+
const existingIds = currentLabels.map((l: any) => l.id);
|
|
987
|
+
const allIds = [...new Set([...existingIds, ...triage.labelIds])];
|
|
988
|
+
updateInput.labelIds = allIds;
|
|
989
|
+
}
|
|
990
|
+
if (typeof triage.priority === "number" && triage.priority >= 1 && triage.priority <= 4) {
|
|
991
|
+
updateInput.priority = triage.priority;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (Object.keys(updateInput).length > 0) {
|
|
995
|
+
await linearApi.updateIssue(issue.id, updateInput);
|
|
996
|
+
api.logger.info(`Applied triage to ${enrichedIssue?.identifier ?? issue.id}: ${JSON.stringify(updateInput)}`);
|
|
997
|
+
|
|
998
|
+
if (agentSessionId) {
|
|
999
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1000
|
+
type: "action",
|
|
1001
|
+
action: "Applied triage",
|
|
1002
|
+
result: `estimate=${triage.estimate ?? "unchanged"}, labels=${triage.labelIds?.length ?? 0}, priority=${triage.priority ?? "unchanged"}`,
|
|
1003
|
+
}).catch(() => {});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Strip JSON block from comment
|
|
1008
|
+
commentBody = responseBody.replace(/```json\s*\n?[\s\S]*?\n?```\s*\n?/, "").trim();
|
|
1009
|
+
} catch (parseErr) {
|
|
1010
|
+
api.logger.warn(`Could not parse triage JSON: ${parseErr}`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Post branded triage comment
|
|
1016
|
+
const brandingOpts = avatarUrl
|
|
1017
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1018
|
+
: undefined;
|
|
1019
|
+
|
|
1020
|
+
try {
|
|
1021
|
+
if (brandingOpts) {
|
|
1022
|
+
await linearApi.createComment(issue.id, commentBody, brandingOpts);
|
|
1023
|
+
} else {
|
|
1024
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
1025
|
+
}
|
|
1026
|
+
} catch (brandErr) {
|
|
1027
|
+
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
1028
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (agentSessionId) {
|
|
1032
|
+
const truncated = commentBody.length > 2000
|
|
1033
|
+
? commentBody.slice(0, 2000) + "…"
|
|
1034
|
+
: commentBody;
|
|
1035
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1036
|
+
type: "response",
|
|
1037
|
+
body: truncated,
|
|
1038
|
+
}).catch(() => {});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
api.logger.info(`Triage complete for ${enrichedIssue?.identifier ?? issue.id}`);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
api.logger.error(`Issue.create triage error: ${err}`);
|
|
1044
|
+
if (agentSessionId) {
|
|
1045
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1046
|
+
type: "error",
|
|
1047
|
+
body: `Failed to triage: ${String(err).slice(0, 500)}`,
|
|
1048
|
+
}).catch(() => {});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
})();
|
|
1052
|
+
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
847
1056
|
// ── Default: log unhandled webhook types for debugging ──────────
|
|
848
1057
|
api.logger.warn(`Unhandled webhook type=${payload.type} action=${payload.action} — payload: ${JSON.stringify(payload).slice(0, 500)}`);
|
|
849
1058
|
res.statusCode = 200;
|