@calltelemetry/openclaw-linear 0.2.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 +468 -0
- package/index.ts +56 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +38 -0
- package/src/agent.ts +57 -0
- package/src/auth.ts +130 -0
- package/src/client.ts +93 -0
- package/src/linear-api.ts +384 -0
- package/src/oauth-callback.ts +113 -0
- package/src/pipeline.ts +212 -0
- package/src/tools.ts +84 -0
- package/src/webhook.test.ts +191 -0
- package/src/webhook.ts +852 -0
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
|
+
import { LinearAgentApi, resolveLinearToken } from "./linear-api.js";
|
|
6
|
+
import { runFullPipeline, resumePipeline, type PipelineContext } from "./pipeline.js";
|
|
7
|
+
|
|
8
|
+
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
9
|
+
interface AgentProfile {
|
|
10
|
+
label: string;
|
|
11
|
+
mission: string;
|
|
12
|
+
mentionAliases: string[];
|
|
13
|
+
appAliases?: string[];
|
|
14
|
+
isDefault?: boolean;
|
|
15
|
+
avatarUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
19
|
+
|
|
20
|
+
function loadAgentProfiles(): Record<string, AgentProfile> {
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(PROFILES_PATH, "utf8");
|
|
23
|
+
return JSON.parse(raw).agents ?? {};
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildMentionPattern(profiles: Record<string, AgentProfile>): RegExp | null {
|
|
30
|
+
// Collect mentionAliases from ALL agents (including default).
|
|
31
|
+
// appAliases are excluded — those trigger AgentSessionEvent instead.
|
|
32
|
+
const aliases: string[] = [];
|
|
33
|
+
for (const [, profile] of Object.entries(profiles)) {
|
|
34
|
+
aliases.push(...profile.mentionAliases);
|
|
35
|
+
}
|
|
36
|
+
if (aliases.length === 0) return null;
|
|
37
|
+
// Escape regex special chars in aliases, join with |
|
|
38
|
+
const escaped = aliases.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
39
|
+
return new RegExp(`@(${escaped.join("|")})`, "gi");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveAgentFromAlias(alias: string, profiles: Record<string, AgentProfile>): { agentId: string; label: string } | null {
|
|
43
|
+
const lower = alias.toLowerCase();
|
|
44
|
+
for (const [agentId, profile] of Object.entries(profiles)) {
|
|
45
|
+
if (profile.mentionAliases.some(a => a.toLowerCase() === lower)) {
|
|
46
|
+
return { agentId, label: profile.label };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Store active session plans so we can resume after user approval
|
|
53
|
+
const activeSessions = new Map<string, { plan: string; ctx: PipelineContext }>();
|
|
54
|
+
|
|
55
|
+
// Dedup: track recently processed keys to avoid double-handling
|
|
56
|
+
const recentlyProcessed = new Map<string, number>();
|
|
57
|
+
function wasRecentlyProcessed(key: string): boolean {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
// Clean old entries
|
|
60
|
+
for (const [k, ts] of recentlyProcessed) {
|
|
61
|
+
if (now - ts > 60_000) recentlyProcessed.delete(k);
|
|
62
|
+
}
|
|
63
|
+
if (recentlyProcessed.has(key)) return true;
|
|
64
|
+
recentlyProcessed.set(key, now);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|
69
|
+
const chunks: Buffer[] = [];
|
|
70
|
+
let total = 0;
|
|
71
|
+
return await new Promise<{ ok: boolean; value?: any; error?: string }>((resolve) => {
|
|
72
|
+
req.on("data", (chunk: Buffer) => {
|
|
73
|
+
total += chunk.length;
|
|
74
|
+
if (total > maxBytes) {
|
|
75
|
+
req.destroy();
|
|
76
|
+
resolve({ ok: false, error: "payload too large" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
chunks.push(chunk);
|
|
80
|
+
});
|
|
81
|
+
req.on("end", () => {
|
|
82
|
+
try {
|
|
83
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
84
|
+
resolve({ ok: true, value: JSON.parse(raw) });
|
|
85
|
+
} catch {
|
|
86
|
+
resolve({ ok: false, error: "invalid json" });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createLinearApi(api: OpenClawPluginApi): LinearAgentApi | null {
|
|
93
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
94
|
+
const resolved = resolveLinearToken(pluginConfig);
|
|
95
|
+
|
|
96
|
+
if (!resolved.accessToken) return null;
|
|
97
|
+
|
|
98
|
+
const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
|
|
99
|
+
const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
|
|
100
|
+
|
|
101
|
+
return new LinearAgentApi(resolved.accessToken, {
|
|
102
|
+
refreshToken: resolved.refreshToken,
|
|
103
|
+
expiresAt: resolved.expiresAt,
|
|
104
|
+
clientId: clientId ?? undefined,
|
|
105
|
+
clientSecret: clientSecret ?? undefined,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveAgentId(api: OpenClawPluginApi): string {
|
|
110
|
+
const fromConfig = (api as any).pluginConfig?.defaultAgentId;
|
|
111
|
+
if (typeof fromConfig === "string" && fromConfig) return fromConfig;
|
|
112
|
+
// Fall back to whatever is marked isDefault in agent profiles
|
|
113
|
+
const profiles = loadAgentProfiles();
|
|
114
|
+
const defaultAgent = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
115
|
+
if (!defaultAgent) {
|
|
116
|
+
throw new Error("No defaultAgentId in plugin config and no agent profile marked isDefault. Configure one in agent-profiles.json or set defaultAgentId in plugin config.");
|
|
117
|
+
}
|
|
118
|
+
return defaultAgent[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function handleLinearWebhook(
|
|
122
|
+
api: OpenClawPluginApi,
|
|
123
|
+
req: IncomingMessage,
|
|
124
|
+
res: ServerResponse,
|
|
125
|
+
): Promise<boolean> {
|
|
126
|
+
if (req.method !== "POST") {
|
|
127
|
+
res.statusCode = 405;
|
|
128
|
+
res.end("Method Not Allowed");
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const body = await readJsonBody(req, 1024 * 1024);
|
|
133
|
+
if (!body.ok) {
|
|
134
|
+
res.statusCode = 400;
|
|
135
|
+
res.end(body.error);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const payload = body.value;
|
|
140
|
+
// Debug: log full payload structure for diagnosing webhook types
|
|
141
|
+
const payloadKeys = Object.keys(payload).join(", ");
|
|
142
|
+
api.logger.info(`Linear webhook received: type=${payload.type} action=${payload.action} keys=[${payloadKeys}]`);
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
// ── AppUserNotification — OAuth app webhook for agent mentions/assignments
|
|
146
|
+
if (payload.type === "AppUserNotification") {
|
|
147
|
+
res.statusCode = 200;
|
|
148
|
+
res.end("ok");
|
|
149
|
+
|
|
150
|
+
const notification = payload.notification;
|
|
151
|
+
const notifType = notification?.type;
|
|
152
|
+
api.logger.info(`AppUserNotification: ${notifType} appUserId=${payload.appUserId}`);
|
|
153
|
+
|
|
154
|
+
const issue = notification?.issue;
|
|
155
|
+
const comment = notification?.comment ?? notification?.parentComment;
|
|
156
|
+
|
|
157
|
+
if (!issue?.id) {
|
|
158
|
+
api.logger.error("AppUserNotification missing issue data");
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const linearApi = createLinearApi(api);
|
|
163
|
+
if (!linearApi) {
|
|
164
|
+
api.logger.error("No Linear access token — cannot process agent notification");
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const agentId = resolveAgentId(api);
|
|
169
|
+
|
|
170
|
+
// Fetch full issue details
|
|
171
|
+
let enrichedIssue: any = issue;
|
|
172
|
+
try {
|
|
173
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
179
|
+
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
180
|
+
const commentSummary = comments
|
|
181
|
+
.slice(-5)
|
|
182
|
+
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
|
|
183
|
+
.join("\n");
|
|
184
|
+
|
|
185
|
+
const message = [
|
|
186
|
+
`IMPORTANT: You are responding to a Linear issue notification. Your ENTIRE text output will be automatically posted as a comment on the issue. Do NOT attempt to post to Linear yourself — no tools, no CLI, no API calls. Just write your response as plain text/markdown.`,
|
|
187
|
+
``,
|
|
188
|
+
`You were mentioned/assigned in a Linear issue. Respond naturally and helpfully.`,
|
|
189
|
+
``,
|
|
190
|
+
`## Issue: ${enrichedIssue?.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
191
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
192
|
+
``,
|
|
193
|
+
`**Description:**`,
|
|
194
|
+
description,
|
|
195
|
+
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
196
|
+
comment?.body ? `\n**Triggering comment:**\n> ${comment.body}` : "",
|
|
197
|
+
``,
|
|
198
|
+
`Respond concisely. If there's a task, explain what you'll do and do it.`,
|
|
199
|
+
].filter(Boolean).join("\n");
|
|
200
|
+
|
|
201
|
+
// Dispatch agent with session lifecycle (non-blocking)
|
|
202
|
+
void (async () => {
|
|
203
|
+
const profiles = loadAgentProfiles();
|
|
204
|
+
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
205
|
+
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
206
|
+
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
207
|
+
let agentSessionId: string | null = null;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// 1. Create agent session (non-fatal)
|
|
211
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
212
|
+
agentSessionId = sessionResult.sessionId;
|
|
213
|
+
if (agentSessionId) {
|
|
214
|
+
api.logger.info(`Created agent session ${agentSessionId} for notification`);
|
|
215
|
+
} else {
|
|
216
|
+
api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 2. Emit thought
|
|
220
|
+
if (agentSessionId) {
|
|
221
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
222
|
+
type: "thought",
|
|
223
|
+
body: `Reviewing notification for ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
224
|
+
}).catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 3. Emit action
|
|
228
|
+
if (agentSessionId) {
|
|
229
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
230
|
+
type: "action",
|
|
231
|
+
action: "Processing notification",
|
|
232
|
+
parameter: notifType ?? "unknown",
|
|
233
|
+
}).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 4. Run agent
|
|
237
|
+
const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
|
|
238
|
+
const { runAgent } = await import("./agent.js");
|
|
239
|
+
const result = await runAgent({
|
|
240
|
+
api,
|
|
241
|
+
agentId,
|
|
242
|
+
sessionId,
|
|
243
|
+
message,
|
|
244
|
+
timeoutMs: 3 * 60_000,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const responseBody = result.success
|
|
248
|
+
? result.output
|
|
249
|
+
: `I encountered an error processing this request. Please try again.`;
|
|
250
|
+
|
|
251
|
+
// 5. Post branded comment (fallback to prefix)
|
|
252
|
+
const brandingOpts = avatarUrl
|
|
253
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
254
|
+
: undefined;
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
if (brandingOpts) {
|
|
258
|
+
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
259
|
+
} else {
|
|
260
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
261
|
+
}
|
|
262
|
+
} catch (brandErr) {
|
|
263
|
+
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
264
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 6. Emit response (closes session)
|
|
268
|
+
if (agentSessionId) {
|
|
269
|
+
const truncated = responseBody.length > 2000
|
|
270
|
+
? responseBody.slice(0, 2000) + "…"
|
|
271
|
+
: responseBody;
|
|
272
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
273
|
+
type: "response",
|
|
274
|
+
body: truncated,
|
|
275
|
+
}).catch(() => {});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id}`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
api.logger.error(`AppUserNotification handler error: ${err}`);
|
|
281
|
+
if (agentSessionId) {
|
|
282
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
283
|
+
type: "error",
|
|
284
|
+
body: `Failed to process notification: ${String(err).slice(0, 500)}`,
|
|
285
|
+
}).catch(() => {});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── AgentSessionEvent.created — start the pipeline ──────────────
|
|
294
|
+
if (
|
|
295
|
+
(payload.type === "AgentSessionEvent" && payload.action === "created") ||
|
|
296
|
+
(payload.type === "AgentSession" && payload.action === "create")
|
|
297
|
+
) {
|
|
298
|
+
// Respond within 5 seconds (Linear requirement)
|
|
299
|
+
res.statusCode = 200;
|
|
300
|
+
res.end("ok");
|
|
301
|
+
|
|
302
|
+
const session = payload.agentSession ?? payload.data;
|
|
303
|
+
const issue = session?.issue ?? payload.issue;
|
|
304
|
+
|
|
305
|
+
if (!session?.id || !issue?.id) {
|
|
306
|
+
api.logger.error("AgentSession.created missing session or issue data");
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Dedup: skip if we already handled this session (e.g. from Issue.update delegation)
|
|
311
|
+
if (wasRecentlyProcessed(`session:${session.id}`)) {
|
|
312
|
+
api.logger.info(`AgentSession ${session.id} already handled — skipping`);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const linearApi = createLinearApi(api);
|
|
317
|
+
if (!linearApi) {
|
|
318
|
+
api.logger.error("No Linear access token configured — cannot start pipeline. Run OAuth flow or set LINEAR_ACCESS_TOKEN.");
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const agentId = resolveAgentId(api);
|
|
323
|
+
|
|
324
|
+
const previousComments = payload.previousComments ?? [];
|
|
325
|
+
const guidance = payload.guidance;
|
|
326
|
+
|
|
327
|
+
api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`);
|
|
328
|
+
|
|
329
|
+
const ctx: PipelineContext = {
|
|
330
|
+
api,
|
|
331
|
+
linearApi,
|
|
332
|
+
agentSessionId: session.id,
|
|
333
|
+
agentId,
|
|
334
|
+
issue: {
|
|
335
|
+
id: issue.id,
|
|
336
|
+
identifier: issue.identifier ?? issue.id,
|
|
337
|
+
title: issue.title ?? "(untitled)",
|
|
338
|
+
description: issue.description,
|
|
339
|
+
},
|
|
340
|
+
promptContext: payload.promptContext ?? session.context,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Run pipeline (non-blocking). Stage 1 emits first thought within 10s.
|
|
344
|
+
void (async () => {
|
|
345
|
+
const { runPlannerStage } = await import("./pipeline.js");
|
|
346
|
+
const plan = await runPlannerStage(ctx).catch((err) => {
|
|
347
|
+
api.logger.error(`Planner stage error: ${err}`);
|
|
348
|
+
return null;
|
|
349
|
+
});
|
|
350
|
+
if (plan) {
|
|
351
|
+
activeSessions.set(session.id, { plan, ctx });
|
|
352
|
+
}
|
|
353
|
+
})();
|
|
354
|
+
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── AgentSession.prompted — user replied (plan approval) ────────
|
|
359
|
+
if (
|
|
360
|
+
(payload.type === "AgentSessionEvent" && payload.action === "prompted") ||
|
|
361
|
+
(payload.type === "AgentSession" && payload.action === "prompted")
|
|
362
|
+
) {
|
|
363
|
+
res.statusCode = 200;
|
|
364
|
+
res.end("ok");
|
|
365
|
+
|
|
366
|
+
const session = payload.agentSession ?? payload.data;
|
|
367
|
+
if (!session?.id) {
|
|
368
|
+
api.logger.error("AgentSession.prompted missing session id");
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
api.logger.info(`AgentSession prompted: ${session.id}`);
|
|
373
|
+
|
|
374
|
+
const stored = activeSessions.get(session.id);
|
|
375
|
+
if (!stored) {
|
|
376
|
+
api.logger.warn(`No active session found for ${session.id} — may have been restarted`);
|
|
377
|
+
|
|
378
|
+
// Try to reconstruct context from payload
|
|
379
|
+
const linearApi = createLinearApi(api);
|
|
380
|
+
const issue = session?.issue ?? payload.issue;
|
|
381
|
+
if (!linearApi || !issue?.id) {
|
|
382
|
+
api.logger.error("Cannot reconstruct pipeline context for prompted session");
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const agentId = resolveAgentId(api);
|
|
387
|
+
|
|
388
|
+
// The user's reply is the prompt content — treat as approval with context
|
|
389
|
+
const userReply = session.context?.prompt ?? session.context?.body ?? "";
|
|
390
|
+
api.logger.info(`Prompted session ${session.id} — treating reply as new request`);
|
|
391
|
+
|
|
392
|
+
const ctx: PipelineContext = {
|
|
393
|
+
api,
|
|
394
|
+
linearApi,
|
|
395
|
+
agentSessionId: session.id,
|
|
396
|
+
agentId,
|
|
397
|
+
issue: {
|
|
398
|
+
id: issue.id,
|
|
399
|
+
identifier: issue.identifier ?? issue.id,
|
|
400
|
+
title: issue.title ?? "(untitled)",
|
|
401
|
+
description: issue.description,
|
|
402
|
+
},
|
|
403
|
+
promptContext: session.context,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Start fresh pipeline since we lost the plan
|
|
407
|
+
void runFullPipeline(ctx);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Resume with stored plan
|
|
412
|
+
api.logger.info(`Resuming pipeline for session ${session.id}`);
|
|
413
|
+
activeSessions.delete(session.id);
|
|
414
|
+
|
|
415
|
+
void resumePipeline(stored.ctx, stored.plan);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Comment.create — @mention routing to agents ─────────────────
|
|
420
|
+
if (payload.type === "Comment" && payload.action === "create") {
|
|
421
|
+
res.statusCode = 200;
|
|
422
|
+
res.end("ok");
|
|
423
|
+
|
|
424
|
+
const comment = payload.data;
|
|
425
|
+
const commentBody = comment?.body ?? "";
|
|
426
|
+
const commentor = comment?.user?.name ?? "Unknown";
|
|
427
|
+
const issue = comment?.issue ?? payload.issue;
|
|
428
|
+
|
|
429
|
+
// Load agent profiles and build mention pattern dynamically.
|
|
430
|
+
// Default agent (app mentions) is handled by AgentSessionEvent — never here.
|
|
431
|
+
const profiles = loadAgentProfiles();
|
|
432
|
+
const mentionPattern = buildMentionPattern(profiles);
|
|
433
|
+
if (!mentionPattern) {
|
|
434
|
+
api.logger.info("Comment webhook: no sub-agent profiles configured, ignoring");
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const matches = commentBody.match(mentionPattern);
|
|
439
|
+
if (!matches || matches.length === 0) {
|
|
440
|
+
api.logger.info("Comment webhook: no sub-agent mentions found, ignoring");
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const alias = matches[0].replace("@", "");
|
|
445
|
+
const resolved = resolveAgentFromAlias(alias, profiles);
|
|
446
|
+
if (!resolved) {
|
|
447
|
+
api.logger.info(`Comment webhook: alias "${alias}" not found in profiles, ignoring`);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const mentionedAgent = resolved.agentId;
|
|
452
|
+
|
|
453
|
+
if (!issue?.id) {
|
|
454
|
+
api.logger.error("Comment webhook: missing issue data");
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const linearApi = createLinearApi(api);
|
|
459
|
+
if (!linearApi) {
|
|
460
|
+
api.logger.error("No Linear access token — cannot process comment mention");
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Dedup on comment ID — prevent processing same comment twice
|
|
465
|
+
if (comment?.id && wasRecentlyProcessed(`comment:${comment.id}`)) {
|
|
466
|
+
api.logger.info(`Comment ${comment.id} already processed — skipping`);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
api.logger.info(`Comment mention: @${mentionedAgent} on ${issue.identifier ?? issue.id} by ${commentor}`);
|
|
471
|
+
|
|
472
|
+
// React with eyes to acknowledge the comment
|
|
473
|
+
if (comment?.id) {
|
|
474
|
+
linearApi.createReaction(comment.id, "eyes").catch(() => {});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Fetch full issue details from Linear API for richer context
|
|
478
|
+
let enrichedIssue = issue;
|
|
479
|
+
let recentComments = "";
|
|
480
|
+
try {
|
|
481
|
+
const full = await linearApi.getIssueDetails(issue.id);
|
|
482
|
+
enrichedIssue = { ...issue, ...full };
|
|
483
|
+
// Include last few comments for context (excluding the triggering comment)
|
|
484
|
+
const comments = full.comments?.nodes ?? [];
|
|
485
|
+
const relevant = comments
|
|
486
|
+
.filter((c: any) => c.body !== commentBody)
|
|
487
|
+
.slice(-3)
|
|
488
|
+
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body.slice(0, 200)}`)
|
|
489
|
+
.join("\n");
|
|
490
|
+
if (relevant) recentComments = `\n**Recent Comments:**\n${relevant}\n`;
|
|
491
|
+
} catch (err) {
|
|
492
|
+
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const priority = ["No Priority", "Urgent (P1)", "High (P2)", "Medium (P3)", "Low (P4)"][enrichedIssue.priority] ?? "Unknown";
|
|
496
|
+
const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name).join(", ") || "None";
|
|
497
|
+
const state = enrichedIssue.state?.name ?? "Unknown";
|
|
498
|
+
const assignee = enrichedIssue.assignee?.name ?? "Unassigned";
|
|
499
|
+
|
|
500
|
+
const taskMessage = [
|
|
501
|
+
`IMPORTANT: You are responding to a Linear issue comment. Your ENTIRE text output will be automatically posted as a comment on the issue. Do NOT attempt to post to Linear yourself — no tools, no CLI, no API calls. Just write your response as plain text/markdown.`,
|
|
502
|
+
``,
|
|
503
|
+
`You were mentioned by name. Respond naturally and helpfully as a team member. Be concise, markdown-friendly. Do NOT use JSON or structured output.`,
|
|
504
|
+
``,
|
|
505
|
+
`**Issue:** ${enrichedIssue.identifier ?? enrichedIssue.id} — ${enrichedIssue.title ?? "(untitled)"}`,
|
|
506
|
+
`**Status:** ${state} | **Priority:** ${priority} | **Assignee:** ${assignee} | **Labels:** ${labels}`,
|
|
507
|
+
`**URL:** ${enrichedIssue.url ?? "N/A"}`,
|
|
508
|
+
``,
|
|
509
|
+
enrichedIssue.description ? `**Description:**\n${enrichedIssue.description}\n` : "",
|
|
510
|
+
recentComments,
|
|
511
|
+
`**${commentor} wrote:**`,
|
|
512
|
+
`> ${commentBody}`,
|
|
513
|
+
``,
|
|
514
|
+
`Respond to their message. Be concise and direct. If they're asking you to do work, explain what you'll do and do it.`,
|
|
515
|
+
].filter(Boolean).join("\n");
|
|
516
|
+
|
|
517
|
+
// Dispatch to agent with full session lifecycle (non-blocking)
|
|
518
|
+
void (async () => {
|
|
519
|
+
const label = resolved.label;
|
|
520
|
+
const profile = profiles[mentionedAgent];
|
|
521
|
+
let agentSessionId: string | null = null;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
// 1. Create agent session (non-fatal if fails)
|
|
525
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
526
|
+
agentSessionId = sessionResult.sessionId;
|
|
527
|
+
if (agentSessionId) {
|
|
528
|
+
api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
|
|
529
|
+
} else {
|
|
530
|
+
api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 2. Emit thought
|
|
534
|
+
if (agentSessionId) {
|
|
535
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
536
|
+
type: "thought",
|
|
537
|
+
body: `Analyzing ${enrichedIssue.identifier ?? issue.id}...`,
|
|
538
|
+
}).catch(() => {});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// 3. Emit action
|
|
542
|
+
if (agentSessionId) {
|
|
543
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
544
|
+
type: "action",
|
|
545
|
+
action: "Processing mention",
|
|
546
|
+
parameter: `@${alias} by ${commentor}`,
|
|
547
|
+
}).catch(() => {});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// 4. Run agent subprocess
|
|
551
|
+
const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
|
|
552
|
+
const { runAgent } = await import("./agent.js");
|
|
553
|
+
const result = await runAgent({
|
|
554
|
+
api,
|
|
555
|
+
agentId: mentionedAgent,
|
|
556
|
+
sessionId,
|
|
557
|
+
message: taskMessage,
|
|
558
|
+
timeoutMs: 3 * 60_000,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const responseBody = result.success
|
|
562
|
+
? result.output
|
|
563
|
+
: `I encountered an error processing this request. Please try again or check the logs.`;
|
|
564
|
+
|
|
565
|
+
// 5. Post branded comment (fall back to [Label] prefix if branding fails)
|
|
566
|
+
const brandingOpts = profile?.avatarUrl
|
|
567
|
+
? { createAsUser: label, displayIconUrl: profile.avatarUrl }
|
|
568
|
+
: undefined;
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
if (brandingOpts) {
|
|
572
|
+
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
573
|
+
} else {
|
|
574
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
575
|
+
}
|
|
576
|
+
} catch (brandErr) {
|
|
577
|
+
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
578
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// 6. Emit response activity (closes the session)
|
|
582
|
+
if (agentSessionId) {
|
|
583
|
+
const truncated = responseBody.length > 2000
|
|
584
|
+
? responseBody.slice(0, 2000) + "\u2026"
|
|
585
|
+
: responseBody;
|
|
586
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
587
|
+
type: "response",
|
|
588
|
+
body: truncated,
|
|
589
|
+
}).catch(() => {});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
api.logger.info(`Posted @${mentionedAgent} response to ${issue.identifier ?? issue.id}`);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
api.logger.error(`Comment mention handler error: ${err}`);
|
|
595
|
+
// 7. Emit error activity if session exists
|
|
596
|
+
if (agentSessionId) {
|
|
597
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
598
|
+
type: "error",
|
|
599
|
+
body: `Failed to process mention: ${String(err).slice(0, 500)}`,
|
|
600
|
+
}).catch(() => {});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── Issue.update — handle assignment/delegation to app user ──────
|
|
609
|
+
if (payload.type === "Issue" && payload.action === "update") {
|
|
610
|
+
res.statusCode = 200;
|
|
611
|
+
res.end("ok");
|
|
612
|
+
|
|
613
|
+
const issue = payload.data;
|
|
614
|
+
const updatedFrom = payload.updatedFrom ?? {};
|
|
615
|
+
|
|
616
|
+
// Check both assigneeId and delegateId — Linear uses delegateId for agent delegation
|
|
617
|
+
const assigneeId = issue?.assigneeId;
|
|
618
|
+
const prevAssigneeId = updatedFrom.assigneeId;
|
|
619
|
+
const delegateId = issue?.delegateId;
|
|
620
|
+
const prevDelegateId = updatedFrom.delegateId;
|
|
621
|
+
|
|
622
|
+
api.logger.info(`Issue.update ${issue?.identifier ?? issue?.id}: assigneeId=${assigneeId} prev=${prevAssigneeId} delegateId=${delegateId} prevDelegate=${prevDelegateId}`);
|
|
623
|
+
|
|
624
|
+
// Check if either assignee or delegate changed to our app user
|
|
625
|
+
const assigneeChanged = assigneeId && assigneeId !== prevAssigneeId;
|
|
626
|
+
const delegateChanged = delegateId && delegateId !== prevDelegateId;
|
|
627
|
+
|
|
628
|
+
if (!assigneeChanged && !delegateChanged) {
|
|
629
|
+
api.logger.info("Issue.update: no assignment/delegation change, ignoring");
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const linearApi = createLinearApi(api);
|
|
634
|
+
if (!linearApi) {
|
|
635
|
+
api.logger.error("No Linear access token — cannot process issue update");
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const viewerId = await linearApi.getViewerId();
|
|
640
|
+
const isAssignedToUs = assigneeChanged && assigneeId === viewerId;
|
|
641
|
+
const isDelegatedToUs = delegateChanged && delegateId === viewerId;
|
|
642
|
+
|
|
643
|
+
if (!isAssignedToUs && !isDelegatedToUs) {
|
|
644
|
+
api.logger.info(`Issue.update: assignee=${assigneeId} delegate=${delegateId}, not us (${viewerId}), ignoring`);
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const trigger = isDelegatedToUs ? "delegated" : "assigned";
|
|
649
|
+
api.logger.info(`Issue ${trigger} to our app user (${viewerId}), processing`);
|
|
650
|
+
|
|
651
|
+
// Dedup on assignment/delegation
|
|
652
|
+
const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
|
|
653
|
+
if (wasRecentlyProcessed(dedupKey)) {
|
|
654
|
+
api.logger.info(`${trigger} ${issue.id} -> ${viewerId} already processed — skipping`);
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const agentId = resolveAgentId(api);
|
|
659
|
+
|
|
660
|
+
// Fetch full issue details + team labels for triage
|
|
661
|
+
let enrichedIssue: any = issue;
|
|
662
|
+
let teamLabels: Array<{ id: string; name: string }> = [];
|
|
663
|
+
try {
|
|
664
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
665
|
+
if (enrichedIssue?.team?.id) {
|
|
666
|
+
teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
673
|
+
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
674
|
+
const commentSummary = comments
|
|
675
|
+
.slice(-5)
|
|
676
|
+
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
|
|
677
|
+
.join("\n");
|
|
678
|
+
|
|
679
|
+
const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
|
|
680
|
+
const currentLabels = enrichedIssue?.labels?.nodes ?? [];
|
|
681
|
+
const currentLabelNames = currentLabels.map((l: any) => l.name).join(", ") || "None";
|
|
682
|
+
const availableLabelList = teamLabels.map((l) => ` - "${l.name}" (id: ${l.id})`).join("\n");
|
|
683
|
+
|
|
684
|
+
const message = [
|
|
685
|
+
`IMPORTANT: You are triaging a delegated Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
|
|
686
|
+
``,
|
|
687
|
+
`## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
688
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
|
|
689
|
+
``,
|
|
690
|
+
`**Description:**`,
|
|
691
|
+
description,
|
|
692
|
+
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
693
|
+
``,
|
|
694
|
+
`## Your Triage Tasks`,
|
|
695
|
+
``,
|
|
696
|
+
`1. **Story Points** — Estimate complexity using ${estimationType} scale (1=trivial, 2=small, 3=medium, 5=large, 8=very large, 13=epic)`,
|
|
697
|
+
`2. **Labels** — Select appropriate labels from the team's available labels`,
|
|
698
|
+
`3. **Assessment** — Brief analysis of what this issue needs`,
|
|
699
|
+
``,
|
|
700
|
+
`## Available Labels`,
|
|
701
|
+
availableLabelList || " (no labels configured)",
|
|
702
|
+
``,
|
|
703
|
+
`## Response Format`,
|
|
704
|
+
``,
|
|
705
|
+
`You MUST start your response with a JSON block, then follow with your assessment:`,
|
|
706
|
+
``,
|
|
707
|
+
'```json',
|
|
708
|
+
`{`,
|
|
709
|
+
` "estimate": <number>,`,
|
|
710
|
+
` "labelIds": ["<id1>", "<id2>"],`,
|
|
711
|
+
` "assessment": "<one-line summary of your sizing rationale>"`,
|
|
712
|
+
`}`,
|
|
713
|
+
'```',
|
|
714
|
+
``,
|
|
715
|
+
`Then write your full assessment as markdown below the JSON block.`,
|
|
716
|
+
].filter(Boolean).join("\n");
|
|
717
|
+
|
|
718
|
+
// Dispatch agent with session lifecycle (non-blocking)
|
|
719
|
+
void (async () => {
|
|
720
|
+
const profiles = loadAgentProfiles();
|
|
721
|
+
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
722
|
+
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
723
|
+
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
724
|
+
let agentSessionId: string | null = null;
|
|
725
|
+
|
|
726
|
+
try {
|
|
727
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
728
|
+
agentSessionId = sessionResult.sessionId;
|
|
729
|
+
if (agentSessionId) {
|
|
730
|
+
// Mark session as processed so AgentSessionEvent handler skips it
|
|
731
|
+
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
732
|
+
api.logger.info(`Created agent session ${agentSessionId} for ${trigger}`);
|
|
733
|
+
} else {
|
|
734
|
+
api.logger.warn(`Could not create agent session for assignment: ${sessionResult.error ?? "unknown"}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (agentSessionId) {
|
|
738
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
739
|
+
type: "thought",
|
|
740
|
+
body: `Reviewing assigned issue ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
741
|
+
}).catch(() => {});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (agentSessionId) {
|
|
745
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
746
|
+
type: "action",
|
|
747
|
+
action: "Triaging",
|
|
748
|
+
parameter: `${enrichedIssue?.identifier ?? issue.id} — estimating, labeling, sizing`,
|
|
749
|
+
}).catch(() => {});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const sessionId = `linear-assign-${issue.id}-${Date.now()}`;
|
|
753
|
+
const { runAgent } = await import("./agent.js");
|
|
754
|
+
const result = await runAgent({
|
|
755
|
+
api,
|
|
756
|
+
agentId,
|
|
757
|
+
sessionId,
|
|
758
|
+
message,
|
|
759
|
+
timeoutMs: 3 * 60_000,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const responseBody = result.success
|
|
763
|
+
? result.output
|
|
764
|
+
: `I encountered an error reviewing this assignment. Please try again.`;
|
|
765
|
+
|
|
766
|
+
// Parse triage JSON from agent response and apply to issue
|
|
767
|
+
let commentBody = responseBody;
|
|
768
|
+
if (result.success) {
|
|
769
|
+
const jsonMatch = responseBody.match(/```json\s*\n?([\s\S]*?)\n?```/);
|
|
770
|
+
if (jsonMatch) {
|
|
771
|
+
try {
|
|
772
|
+
const triage = JSON.parse(jsonMatch[1]);
|
|
773
|
+
const updateInput: Record<string, unknown> = {};
|
|
774
|
+
|
|
775
|
+
if (typeof triage.estimate === "number") {
|
|
776
|
+
updateInput.estimate = triage.estimate;
|
|
777
|
+
}
|
|
778
|
+
if (Array.isArray(triage.labelIds) && triage.labelIds.length > 0) {
|
|
779
|
+
// Merge with existing labels
|
|
780
|
+
const existingIds = currentLabels.map((l: any) => l.id);
|
|
781
|
+
const allIds = [...new Set([...existingIds, ...triage.labelIds])];
|
|
782
|
+
updateInput.labelIds = allIds;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (Object.keys(updateInput).length > 0) {
|
|
786
|
+
await linearApi.updateIssue(issue.id, updateInput);
|
|
787
|
+
api.logger.info(`Applied triage to ${enrichedIssue?.identifier ?? issue.id}: ${JSON.stringify(updateInput)}`);
|
|
788
|
+
|
|
789
|
+
if (agentSessionId) {
|
|
790
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
791
|
+
type: "action",
|
|
792
|
+
action: "Applied triage",
|
|
793
|
+
result: `estimate=${triage.estimate ?? "unchanged"}, labels=${triage.labelIds?.length ?? 0} added`,
|
|
794
|
+
}).catch(() => {});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Strip the JSON block from the comment — post only the assessment
|
|
799
|
+
commentBody = responseBody.replace(/```json\s*\n?[\s\S]*?\n?```\s*\n?/, "").trim();
|
|
800
|
+
} catch (parseErr) {
|
|
801
|
+
api.logger.warn(`Could not parse triage JSON: ${parseErr}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Post comment with assessment
|
|
807
|
+
const brandingOpts = avatarUrl
|
|
808
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
809
|
+
: undefined;
|
|
810
|
+
|
|
811
|
+
try {
|
|
812
|
+
if (brandingOpts) {
|
|
813
|
+
await linearApi.createComment(issue.id, commentBody, brandingOpts);
|
|
814
|
+
} else {
|
|
815
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
816
|
+
}
|
|
817
|
+
} catch (brandErr) {
|
|
818
|
+
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
819
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (agentSessionId) {
|
|
823
|
+
const truncated = commentBody.length > 2000
|
|
824
|
+
? commentBody.slice(0, 2000) + "…"
|
|
825
|
+
: commentBody;
|
|
826
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
827
|
+
type: "response",
|
|
828
|
+
body: truncated,
|
|
829
|
+
}).catch(() => {});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
api.logger.info(`Posted assignment response to ${enrichedIssue?.identifier ?? issue.id}`);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
api.logger.error(`Issue assignment handler error: ${err}`);
|
|
835
|
+
if (agentSessionId) {
|
|
836
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
837
|
+
type: "error",
|
|
838
|
+
body: `Failed to process assignment: ${String(err).slice(0, 500)}`,
|
|
839
|
+
}).catch(() => {});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
})();
|
|
843
|
+
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ── Default: log unhandled webhook types for debugging ──────────
|
|
848
|
+
api.logger.warn(`Unhandled webhook type=${payload.type} action=${payload.action} — payload: ${JSON.stringify(payload).slice(0, 500)}`);
|
|
849
|
+
res.statusCode = 200;
|
|
850
|
+
res.end("ok");
|
|
851
|
+
return true;
|
|
852
|
+
}
|