@calltelemetry/openclaw-linear 0.2.0 → 0.3.0
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 +78 -10
- 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/webhook.ts +209 -0
package/README.md
CHANGED
|
@@ -10,6 +10,17 @@ Webhook-driven Linear integration with OAuth support, multi-agent routing, and a
|
|
|
10
10
|
- **App notifications** — Responds to Linear app mentions and assignments via branded comments
|
|
11
11
|
- **Activity tracking** — Emits thought/action/response events visible in Linear's agent session UI
|
|
12
12
|
|
|
13
|
+
## Quick Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
openclaw plugins install @calltelemetry/openclaw-linear
|
|
17
|
+
openclaw gateway restart
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That's it — the plugin is installed and enabled. Continue with the [setup steps](#setup) below to configure Linear OAuth and webhooks.
|
|
21
|
+
|
|
22
|
+
> To install from a local checkout instead: `openclaw plugins install --link /path/to/linear`
|
|
23
|
+
|
|
13
24
|
## Prerequisites
|
|
14
25
|
|
|
15
26
|
- OpenClaw gateway running (systemd service)
|
|
@@ -153,13 +164,25 @@ export OPENCLAW_GATEWAY_PORT="18789" # if non-default
|
|
|
153
164
|
|
|
154
165
|
### 5. Install the Plugin
|
|
155
166
|
|
|
156
|
-
|
|
167
|
+
If you haven't already installed via [Quick Install](#quick-install):
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
openclaw plugins install @calltelemetry/openclaw-linear
|
|
171
|
+
openclaw gateway restart
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This registers the plugin in your OpenClaw config and restarts the gateway to load it.
|
|
175
|
+
|
|
176
|
+
<details>
|
|
177
|
+
<summary>Manual config (advanced)</summary>
|
|
178
|
+
|
|
179
|
+
If you prefer to manage config by hand, add the plugin path to `~/.openclaw/openclaw.json`:
|
|
157
180
|
|
|
158
181
|
```json
|
|
159
182
|
{
|
|
160
183
|
"plugins": {
|
|
161
184
|
"load": {
|
|
162
|
-
"paths": ["/path/to/
|
|
185
|
+
"paths": ["/path/to/linear"]
|
|
163
186
|
},
|
|
164
187
|
"entries": {
|
|
165
188
|
"linear": {
|
|
@@ -170,11 +193,8 @@ Add the plugin path to your OpenClaw config (`~/.openclaw/openclaw.json`):
|
|
|
170
193
|
}
|
|
171
194
|
```
|
|
172
195
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
```bash
|
|
176
|
-
openclaw gateway restart
|
|
177
|
-
```
|
|
196
|
+
Then restart: `openclaw gateway restart`
|
|
197
|
+
</details>
|
|
178
198
|
|
|
179
199
|
### 6. Run the OAuth Flow
|
|
180
200
|
|
|
@@ -283,13 +303,61 @@ Create `~/.openclaw/agent-profiles.json` to define role-based agents:
|
|
|
283
303
|
| Field | Required | Description |
|
|
284
304
|
|---|---|---|
|
|
285
305
|
| `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 |
|
|
306
|
+
| `mission` | Yes | Agent's role description (injected as system context when the agent is dispatched) |
|
|
307
|
+
| `isDefault` | One agent | The default agent handles OAuth app events, agent sessions, and assignment triage |
|
|
288
308
|
| `mentionAliases` | Yes | @mention triggers in comments (e.g., `@qa` in a comment routes to the QA agent) |
|
|
289
309
|
| `appAliases` | No | Triggers via OAuth app webhook (default agent only, for app-level @mentions) |
|
|
290
310
|
| `avatarUrl` | No | Avatar displayed on branded comments. Falls back to `[Label]` prefix if not set. |
|
|
291
311
|
|
|
292
|
-
|
|
312
|
+
#### How agent-profiles.json connects to openclaw.json
|
|
313
|
+
|
|
314
|
+
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.
|
|
315
|
+
|
|
316
|
+
Example — if `agent-profiles.json` defines `"lead"` and `"qa"`, your `openclaw.json` needs:
|
|
317
|
+
|
|
318
|
+
```json
|
|
319
|
+
{
|
|
320
|
+
"agents": {
|
|
321
|
+
"lead": {
|
|
322
|
+
"model": "claude-sonnet-4-5-20250929",
|
|
323
|
+
"systemPrompt": "You are a product lead agent...",
|
|
324
|
+
"tools": ["linear_list_issues", "linear_create_issue", "linear_add_comment"]
|
|
325
|
+
},
|
|
326
|
+
"qa": {
|
|
327
|
+
"model": "claude-sonnet-4-5-20250929",
|
|
328
|
+
"systemPrompt": "You are a QA engineer agent...",
|
|
329
|
+
"tools": ["linear_list_issues", "linear_add_comment"]
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
#### Routing flow
|
|
336
|
+
|
|
337
|
+
```
|
|
338
|
+
Linear comment "@qa review this test plan"
|
|
339
|
+
→ Plugin matches "qa" in mentionAliases
|
|
340
|
+
→ Looks up agent-profiles.json → finds "qa" profile
|
|
341
|
+
→ Dispatches: openclaw agent --agent qa --message "<issue context + comment>"
|
|
342
|
+
→ OpenClaw loads "qa" agent config from openclaw.json
|
|
343
|
+
→ Agent runs with the qa profile's mission as context
|
|
344
|
+
→ Response posted back to Linear as a branded comment with qa's label/avatar
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
For agent sessions (triggered by the Linear agent UI or app @mentions):
|
|
348
|
+
|
|
349
|
+
```
|
|
350
|
+
Linear AgentSessionEvent.created
|
|
351
|
+
→ Plugin resolves the default agent (isDefault: true)
|
|
352
|
+
→ Runs the 3-stage pipeline (plan → implement → audit)
|
|
353
|
+
→ Each stage dispatches via the default agent's openclaw.json config
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
#### What happens if they don't match
|
|
357
|
+
|
|
358
|
+
- **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.
|
|
359
|
+
- **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.
|
|
360
|
+
- **No agent marked `isDefault`:** Agent sessions and assignment triage will fail with `"No defaultAgentId"` error.
|
|
293
361
|
|
|
294
362
|
### 8. Verify
|
|
295
363
|
|
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/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;
|