@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 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
- Add the plugin path to your OpenClaw config (`~/.openclaw/openclaw.json`):
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/claw-extensions/linear"]
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
- Restart the gateway to load the plugin:
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 (provided as context when dispatched) |
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
- Each agent ID (the JSON key) must match a configured OpenClaw agent in `openclaw.json`. The plugin dispatches to agents via `openclaw agent --agent <id>`.
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);
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "linear",
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
5
  "version": "0.2.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
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;