web-comments 1.0.1 → 1.0.2

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.
@@ -1,47 +1,76 @@
1
1
  import "../chunks/chunk-WSFCRVEQ.js";
2
2
 
3
+ // src/connectors/linear/icon.ts
4
+ var icon = "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuODg2IDQuMThBMTEuOTgyIDExLjk4MiAwIDAgMSAxMS45OSAwQzE4LjYyNCAwIDI0IDUuMzc2IDI0IDEyLjAwOWMwIDMuNjQtMS42MiA2LjkwMy00LjE4IDkuMTA1TDIuODg3IDQuMThaTTEuODE3IDUuNjI2bDE2LjU1NiAxNi41NTZjLS41MjQuMzMtMS4wNzUuNjItMS42NS44NjZMLjk1MSA3LjI3N2MuMjQ3LS41NzUuNTM3LTEuMTI2Ljg2Ni0xLjY1Wk0uMzIyIDkuMTYzbDE0LjUxNSAxNC41MTVjLS43MS4xNzItMS40NDMuMjgyLTIuMTk1LjMyMkwwIDExLjM1OGExMiAxMiAwIDAgMSAuMzIyLTIuMTk1Wm0tLjE3IDQuODYyIDkuODIzIDkuODI0YTEyLjAyIDEyLjAyIDAgMCAxLTkuODI0LTkuODI0WiIvPjwvc3ZnPg==";
5
+
3
6
  // src/connectors/linear/index.ts
4
7
  var API = "https://api.linear.app/graphql";
5
8
  var TOKEN_URL = "https://api.linear.app/oauth/token";
9
+ var REVOKE_URL = "https://api.linear.app/oauth/revoke";
10
+ var AUTHORIZE_URL = "https://linear.app/oauth/authorize";
6
11
  var DAY = 24 * 60 * 60;
7
12
  var MAX_REPLY = 1e4;
8
13
  function linear(options) {
9
14
  const optionsFor = (env) => typeof options === "function" ? options(env) : options;
15
+ const notFound = () => Response.json({ error: "not_found" }, { status: 404 });
10
16
  return {
11
17
  name: "linear",
12
18
  async onEvent(event, context) {
13
19
  const settings = optionsFor(context.env);
14
- if (settings) await mirror(event, context, settings, client(context, settings));
20
+ if (!settings) return;
21
+ await remember(context, event.actor);
22
+ await mirror(event, context, settings);
15
23
  },
16
24
  async fetch(request, context) {
17
- const { pathname } = new URL(request.url);
18
25
  const settings = optionsFor(context.env);
19
- if (!settings || pathname !== `${new URL(context.url).pathname}/webhook`) {
20
- return Response.json({ error: "not_found" }, { status: 404 });
26
+ if (!settings) return notFound();
27
+ const path = new URL(request.url).pathname.slice(new URL(context.url).pathname.length);
28
+ if (path === "/webhook") {
29
+ if (request.method !== "POST") {
30
+ return new Response(null, { status: 405, headers: { allow: "POST" } });
31
+ }
32
+ return webhook(request, context, settings);
33
+ }
34
+ if (path === "/connect" && request.method === "GET") {
35
+ return connect(request, context, settings);
36
+ }
37
+ if (path === "/callback" && request.method === "GET") {
38
+ return callback(request, context, settings);
21
39
  }
22
- if (request.method !== "POST")
23
- return new Response(null, { status: 405, headers: { allow: "POST" } });
24
- return webhook(request, context, settings);
40
+ return notFound();
41
+ },
42
+ async panel(who, context) {
43
+ const settings = optionsFor(context.env);
44
+ return settings ? panelFor(who, context, settings) : null;
45
+ },
46
+ async action(action, who, _input, context) {
47
+ const settings = optionsFor(context.env);
48
+ if (settings && action === "disconnect") await disconnect(who, context);
25
49
  }
26
50
  };
27
51
  }
28
- function client(context, options) {
52
+ var LinearAuthError = class extends Error {
53
+ };
54
+ function client(context, options, person) {
29
55
  const url = options.apiUrl ?? API;
30
56
  return async function call(query, variables = {}, retried = false) {
31
57
  const response = await fetch(url, {
32
58
  method: "POST",
33
59
  headers: {
34
60
  "content-type": "application/json",
35
- authorization: await authorization(context, options, retried)
61
+ authorization: person ? `Bearer ${await personToken(context, options, person)}` : await authorization(context, options, retried)
36
62
  },
37
63
  body: JSON.stringify({ query, variables })
38
64
  });
39
- if (response.status === 401 && "clientId" in options.auth && !retried) {
40
- return call(
41
- query,
42
- variables,
43
- true
44
- );
65
+ if (response.status === 401) {
66
+ if (person) throw new LinearAuthError("Linear: the person's token was refused");
67
+ if ("clientId" in options.auth && !retried) {
68
+ return call(
69
+ query,
70
+ variables,
71
+ true
72
+ );
73
+ }
45
74
  }
46
75
  const body = await response.json().catch(() => null);
47
76
  if (!response.ok || !body?.data || body.errors?.length) {
@@ -57,24 +86,31 @@ async function authorization(context, options, fresh) {
57
86
  if ("accessToken" in auth) return `Bearer ${auth.accessToken}`;
58
87
  const cached = fresh ? null : await context.storage.get("app-token");
59
88
  if (cached) return `Bearer ${cached}`;
89
+ const body = await tokenRequest(options, {
90
+ grant_type: "client_credentials",
91
+ scope: "read,write,issues:create,comments:create"
92
+ });
93
+ if (!body?.access_token) throw new Error("Linear: couldn't get an app token");
94
+ const ttl = Math.max(60, (body.expires_in ?? 30 * DAY) - DAY);
95
+ await context.storage.set("app-token", body.access_token, { ttlSeconds: ttl });
96
+ return `Bearer ${body.access_token}`;
97
+ }
98
+ async function tokenRequest(options, params) {
99
+ const app = oauthApp(options);
100
+ if (!app) return null;
60
101
  const response = await fetch(TOKEN_URL, {
61
102
  method: "POST",
62
103
  headers: {
63
104
  "content-type": "application/x-www-form-urlencoded",
64
- authorization: `Basic ${btoa(`${auth.clientId}:${auth.clientSecret}`)}`
105
+ authorization: `Basic ${btoa(`${app.clientId}:${app.clientSecret}`)}`
65
106
  },
66
- body: new URLSearchParams({
67
- grant_type: "client_credentials",
68
- scope: "read,write,issues:create,comments:create"
69
- })
107
+ body: new URLSearchParams(params)
70
108
  });
71
109
  const body = await response.json().catch(() => null);
72
- if (!response.ok || !body?.access_token) {
73
- throw new Error(`Linear: couldn't get an app token (HTTP ${response.status})`);
74
- }
75
- const ttl = Math.max(60, (body.expires_in ?? 30 * DAY) - DAY);
76
- await context.storage.set("app-token", body.access_token, { ttlSeconds: ttl });
77
- return `Bearer ${body.access_token}`;
110
+ return response.ok ? body : null;
111
+ }
112
+ function oauthApp(options) {
113
+ return "clientId" in options.auth ? options.auth : null;
78
114
  }
79
115
  var actsAsApp = (options) => "clientId" in options.auth;
80
116
  async function teamOf(context, options, call) {
@@ -126,30 +162,272 @@ async function upload(context, call, thread, kind) {
126
162
  return null;
127
163
  }
128
164
  }
165
+ async function readJson(context, key) {
166
+ const value = await context.storage.get(key);
167
+ return value === null ? null : JSON.parse(value);
168
+ }
169
+ var personKey = (identityId) => `person:${identityId}`;
170
+ var byLinearKey = (linearId) => `by-linear:${linearId}`;
171
+ var byEmailKey = (email) => `by-email:${email.toLowerCase()}`;
172
+ async function personToken(context, options, person) {
173
+ if (!person.expiresAt || person.expiresAt > Date.now() + 6e4 || !person.refreshToken) {
174
+ return person.accessToken;
175
+ }
176
+ const body = await tokenRequest(options, {
177
+ grant_type: "refresh_token",
178
+ refresh_token: person.refreshToken
179
+ });
180
+ if (!body?.access_token) throw new LinearAuthError("Linear: couldn't refresh the person's token");
181
+ person.accessToken = body.access_token;
182
+ person.refreshToken = body.refresh_token ?? person.refreshToken;
183
+ person.expiresAt = body.expires_in ? Date.now() + body.expires_in * 1e3 : null;
184
+ const identity = await readJson(context, byLinearKey(person.linearId));
185
+ if (identity) await context.storage.set(personKey(identity.id), JSON.stringify(person));
186
+ return person.accessToken;
187
+ }
188
+ async function remember(context, actor) {
189
+ if (actor.kind !== "human" || !actor.email) return;
190
+ const key = byEmailKey(actor.email);
191
+ const value = JSON.stringify({
192
+ id: actor.id,
193
+ name: actor.name,
194
+ kind: actor.kind,
195
+ email: actor.email,
196
+ ...actor.avatarUrl ? { avatarUrl: actor.avatarUrl } : {}
197
+ });
198
+ if (await context.storage.get(key) !== value) await context.storage.set(key, value);
199
+ }
200
+ async function linearUserByEmail(context, options, email) {
201
+ if (!email) return null;
202
+ const key = `email-user:${email.toLowerCase()}`;
203
+ const cached = await context.storage.get(key);
204
+ if (cached) return cached === "none" ? null : JSON.parse(cached);
205
+ const { users } = await client(context, options)(
206
+ `query($email: String!) { users(filter: { email: { eqIgnoreCase: $email } }) { nodes { id name } } }`,
207
+ { email }
208
+ );
209
+ const user = users.nodes[0] ?? null;
210
+ await context.storage.set(key, user ? JSON.stringify(user) : "none", { ttlSeconds: DAY });
211
+ return user;
212
+ }
213
+ async function personFor(context, actor) {
214
+ if (actor?.id) {
215
+ const connected = await readJson(context, byLinearKey(actor.id));
216
+ if (connected) return connected;
217
+ }
218
+ if (actor?.email) {
219
+ const known = await readJson(context, byEmailKey(actor.email));
220
+ if (known) return known;
221
+ }
222
+ return {
223
+ id: `linear:${actor?.id ?? "unknown"}`,
224
+ name: actor?.name ?? "Someone in Linear",
225
+ kind: actor?.type === "user" || actor?.type === void 0 ? "human" : "agent",
226
+ ...actor?.avatarUrl ? { avatarUrl: actor.avatarUrl } : {}
227
+ };
228
+ }
229
+ async function asActor(context, options, actor, work) {
230
+ const person = await readJson(context, personKey(actor.id));
231
+ if (person) {
232
+ try {
233
+ return await work(client(context, options, person), true);
234
+ } catch (error) {
235
+ if (!(error instanceof LinearAuthError)) throw error;
236
+ await forget(context, actor.id, person);
237
+ }
238
+ }
239
+ return work(client(context, options), false);
240
+ }
241
+ async function forget(context, identityId, person) {
242
+ await context.storage.delete(personKey(identityId));
243
+ await context.storage.delete(byLinearKey(person.linearId));
244
+ }
245
+ function escapeHtml(value) {
246
+ return value.replace(
247
+ /[&<>"']/g,
248
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]
249
+ );
250
+ }
251
+ function message(status, title, text) {
252
+ return new Response(
253
+ `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><body style="font: 15px/1.5 system-ui, sans-serif; max-width: 420px; margin: 15vh auto; padding: 0 24px; color-scheme: light dark"><h1 style="font-size: 22px">${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p></body>`,
254
+ {
255
+ status,
256
+ headers: {
257
+ "content-type": "text/html; charset=utf-8",
258
+ "cache-control": "no-store",
259
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
260
+ }
261
+ }
262
+ );
263
+ }
264
+ function backTo(request, candidate) {
265
+ const origin = new URL(request.url).origin;
266
+ try {
267
+ const url = new URL(candidate ?? "/", origin);
268
+ return url.origin === origin ? url.href : `${origin}/`;
269
+ } catch {
270
+ return `${origin}/`;
271
+ }
272
+ }
273
+ async function connect(request, context, options) {
274
+ const app = oauthApp(options);
275
+ if (!app) return message(404, "Not set up", "Linear accounts can't be connected here.");
276
+ const who = await context.identify(request);
277
+ if (!who) {
278
+ if (await context.signedIn(request)) {
279
+ return message(403, "Not allowed", "You may not use comments here.");
280
+ }
281
+ const login = context.loginUrl(request);
282
+ if (login) return Response.redirect(new URL(login, request.url).href, 302);
283
+ return message(401, "Sign in first", "Sign in, then come back to connect Linear.");
284
+ }
285
+ const state = crypto.randomUUID();
286
+ const back = backTo(request, new URL(request.url).searchParams.get("return"));
287
+ await context.storage.set(`connect:${state}`, JSON.stringify({ identity: who, back }), {
288
+ ttlSeconds: 600
289
+ });
290
+ const authorize = new URL(AUTHORIZE_URL);
291
+ authorize.search = new URLSearchParams({
292
+ client_id: app.clientId,
293
+ redirect_uri: `${context.url}/callback`,
294
+ response_type: "code",
295
+ scope: "read,write",
296
+ state,
297
+ actor: "user",
298
+ prompt: "consent"
299
+ }).toString();
300
+ return Response.redirect(authorize.href, 302);
301
+ }
302
+ async function callback(request, context, options) {
303
+ const params = new URL(request.url).searchParams;
304
+ const state = params.get("state") ?? "";
305
+ const pending = state ? await readJson(context, `connect:${state}`) : null;
306
+ if (!pending) return message(400, "This link expired", "Go back and connect Linear again.");
307
+ await context.storage.delete(`connect:${state}`);
308
+ const who = await context.identify(request);
309
+ if (!who || who.id !== pending.identity.id) {
310
+ return message(403, "Signed in as someone else", "Go back and connect Linear again.");
311
+ }
312
+ const code = params.get("code");
313
+ if (!code) return Response.redirect(pending.back, 302);
314
+ const tokens = await tokenRequest(options, {
315
+ grant_type: "authorization_code",
316
+ code,
317
+ redirect_uri: `${context.url}/callback`
318
+ });
319
+ if (!tokens?.access_token) {
320
+ return message(502, "Linear didn't finish", "Linear turned the connection down. Try again.");
321
+ }
322
+ const person = {
323
+ linearId: "",
324
+ name: "",
325
+ accessToken: tokens.access_token,
326
+ refreshToken: tokens.refresh_token ?? null,
327
+ expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1e3 : null
328
+ };
329
+ const { viewer } = await client(
330
+ context,
331
+ options,
332
+ person
333
+ )("query { viewer { id name } }");
334
+ person.linearId = viewer.id;
335
+ person.name = viewer.name;
336
+ const previous = await readJson(context, byLinearKey(viewer.id));
337
+ if (previous && previous.id !== who.id) await context.storage.delete(personKey(previous.id));
338
+ await context.storage.set(personKey(who.id), JSON.stringify(person));
339
+ await context.storage.set(byLinearKey(viewer.id), JSON.stringify(who));
340
+ return Response.redirect(pending.back, 302);
341
+ }
342
+ async function disconnect(who, context) {
343
+ const person = await readJson(context, personKey(who.id));
344
+ if (!person) return;
345
+ await forget(context, who.id, person);
346
+ await fetch(REVOKE_URL, {
347
+ method: "POST",
348
+ headers: { authorization: `Bearer ${person.accessToken}` }
349
+ }).catch(() => void 0);
350
+ }
351
+ async function panelFor(who, context, options) {
352
+ const person = await readJson(context, personKey(who.id));
353
+ const blocks = [];
354
+ let attention = false;
355
+ if (person) {
356
+ blocks.push(
357
+ {
358
+ type: "status",
359
+ label: `Connected as ${person.name}`,
360
+ tone: "positive",
361
+ description: "What you do here (comments, replies, resolving) shows up in Linear as you."
362
+ },
363
+ {
364
+ type: "button",
365
+ label: "Disconnect",
366
+ action: "disconnect",
367
+ variant: "secondary",
368
+ confirm: "Disconnect your Linear account? What you do here will reach Linear under the app's name again."
369
+ }
370
+ );
371
+ } else {
372
+ const match = await linearUserByEmail(context, options, who.email).catch(() => null);
373
+ if (match) {
374
+ blocks.push({
375
+ type: "status",
376
+ label: `Matched by email: ${match.name}`,
377
+ tone: "neutral",
378
+ description: "You're subscribed to the issues you open, and your replies in Linear come back here as you. Connect your account to post in Linear as yourself too."
379
+ });
380
+ } else {
381
+ attention = true;
382
+ blocks.push({
383
+ type: "status",
384
+ label: "Not connected",
385
+ tone: "notice",
386
+ description: "Linear doesn't know your email, so what you do here reaches it under the app's name, and your replies there come back as someone else. Connect your Linear account to fix both."
387
+ });
388
+ }
389
+ if (oauthApp(options)) {
390
+ blocks.push({
391
+ type: "link",
392
+ label: "Connect Linear",
393
+ href: `${context.basePath}/linear/connect`,
394
+ returnTo: true,
395
+ variant: match ? "secondary" : "accent"
396
+ });
397
+ }
398
+ }
399
+ blocks.push({
400
+ type: "text",
401
+ text: `New comment threads become issues in ${options.team}. Replies go both ways, and resolving a thread completes its issue.`
402
+ });
403
+ return { title: "Linear", icon, attention, blocks };
404
+ }
129
405
  function titleOf(body) {
130
406
  const line = body.split("\n").find((l) => l.trim())?.trim() ?? "Comment";
131
407
  if (line.length <= 80) return line;
132
408
  const cut = line.slice(0, 79);
133
409
  return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), 60))}\u2026`;
134
410
  }
135
- function byline(comment) {
136
- return `\u2014 ${comment.author.name}`;
137
- }
138
- function as(options, comment) {
139
- if (!actsAsApp(options)) return {};
411
+ function attribution(options, comment, asThemselves) {
412
+ if (asThemselves || !actsAsApp(options)) return {};
140
413
  return {
141
414
  createAsUser: comment.author.name,
142
415
  ...comment.author.avatarUrl?.startsWith("https://") ? { displayIconUrl: comment.author.avatarUrl } : {}
143
416
  };
144
417
  }
145
- async function describe(context, call, thread, options) {
418
+ function signed(options, comment, asThemselves) {
419
+ return asThemselves || actsAsApp(options) ? comment.body : `${comment.body}
420
+
421
+ \u2014 ${comment.author.name}`;
422
+ }
423
+ async function describe(context, call, thread, options, asThemselves) {
146
424
  const first = thread.comments[0];
147
425
  const { anchor } = thread;
148
- const where = thread.context?.page.title || thread.url;
426
+ const where = `[${thread.context?.page.title || thread.url}](${context.threadUrl(thread)})`;
149
427
  const lines = [
150
- first.body,
428
+ signed(options, first, asThemselves),
151
429
  "",
152
- actsAsApp(options) ? `On [${where}](${context.threadUrl(thread)})` : `${byline(first)}, on [${where}](${context.threadUrl(thread)})`,
430
+ `On ${where}`,
153
431
  "",
154
432
  `**Element** \`<${anchor.tag}>\` \`${anchor.selector.replace(/`/g, "'")}\`${anchor.text ? ` \u201C${anchor.text}\u201D` : ""}`
155
433
  ];
@@ -161,32 +439,36 @@ async function describe(context, call, thread, options) {
161
439
  if (page) lines.push("", `![The page](${page})`);
162
440
  return lines.join("\n");
163
441
  }
164
- async function mirror(event, context, options, call) {
442
+ async function mirror(event, context, options) {
165
443
  switch (event.type) {
166
444
  case "thread.created": {
167
445
  const { thread } = event;
168
446
  if (options.filter && !options.filter(thread)) return;
169
- const team = await teamOf(context, options, call);
170
447
  const first = thread.comments[0];
171
- const { issueCreate } = await call(
172
- `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id identifier url } } }`,
173
- {
174
- input: {
175
- teamId: team.id,
176
- title: titleOf(first.body),
177
- description: await describe(context, call, thread, options),
178
- ...options.labelIds?.length ? { labelIds: options.labelIds } : {},
179
- ...options.projectId ? { projectId: options.projectId } : {},
180
- ...as(options, first)
448
+ await asActor(context, options, event.actor, async (call, asThemselves) => {
449
+ const team = await teamOf(context, options, call);
450
+ const follower = asThemselves ? null : await linearUserByEmail(context, options, event.actor.email).catch(() => null);
451
+ const { issueCreate } = await call(
452
+ `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id identifier url } } }`,
453
+ {
454
+ input: {
455
+ teamId: team.id,
456
+ title: titleOf(first.body),
457
+ description: await describe(context, call, thread, options, asThemselves),
458
+ ...options.labelIds?.length ? { labelIds: options.labelIds } : {},
459
+ ...options.projectId ? { projectId: options.projectId } : {},
460
+ ...follower ? { subscriberIds: [follower.id] } : {},
461
+ ...attribution(options, first, asThemselves)
462
+ }
181
463
  }
182
- }
183
- );
184
- const { issue } = issueCreate;
185
- await context.links.save({
186
- externalId: issue.id,
187
- threadId: thread.id,
188
- url: issue.url,
189
- title: issue.identifier
464
+ );
465
+ const { issue } = issueCreate;
466
+ await context.links.save({
467
+ externalId: issue.id,
468
+ threadId: thread.id,
469
+ url: issue.url,
470
+ title: issue.identifier
471
+ });
190
472
  });
191
473
  return;
192
474
  }
@@ -200,18 +482,18 @@ async function mirror(event, context, options, call) {
200
482
  threadId: event.thread.id,
201
483
  commentId: comment.id
202
484
  });
203
- await call(
204
- `mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`,
205
- {
485
+ await asActor(
486
+ context,
487
+ options,
488
+ event.actor,
489
+ (call, asThemselves) => call(`mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`, {
206
490
  input: {
207
491
  id,
208
492
  issueId: issue,
209
- body: actsAsApp(options) ? comment.body : `${comment.body}
210
-
211
- ${byline(comment)}`,
212
- ...as(options, comment)
493
+ body: signed(options, comment, asThemselves),
494
+ ...attribution(options, comment, asThemselves)
213
495
  }
214
- }
496
+ })
215
497
  );
216
498
  return;
217
499
  }
@@ -219,25 +501,29 @@ ${byline(comment)}`,
219
501
  case "thread.reopened": {
220
502
  const issue = await issueOf(context, event.thread.id);
221
503
  if (!issue) return;
222
- const team = await teamOf(context, options, call);
223
- const stateId = event.type === "thread.resolved" ? team.completed : team.unstarted;
224
- if (!stateId) return;
225
- await call(
226
- `mutation($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } }`,
227
- {
228
- id: issue,
229
- stateId
230
- }
231
- );
504
+ await asActor(context, options, event.actor, async (call) => {
505
+ const team = await teamOf(context, options, call);
506
+ const stateId = event.type === "thread.resolved" ? team.completed : team.unstarted;
507
+ if (!stateId) return;
508
+ await call(
509
+ `mutation($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } }`,
510
+ { id: issue, stateId }
511
+ );
512
+ });
232
513
  return;
233
514
  }
234
515
  case "comment.deleted": {
235
516
  const links = await context.links.forThread(event.threadId);
236
517
  const mirrored = links.find((link) => link.commentId === event.commentId);
237
518
  if (!mirrored) return;
238
- await call(`mutation($id: String!) { commentDelete(id: $id) { success } }`, {
239
- id: mirrored.externalId
240
- });
519
+ await asActor(
520
+ context,
521
+ options,
522
+ event.actor,
523
+ (call) => call(`mutation($id: String!) { commentDelete(id: $id) { success } }`, {
524
+ id: mirrored.externalId
525
+ })
526
+ );
241
527
  return;
242
528
  }
243
529
  default:
@@ -269,8 +555,9 @@ async function verified(request, secret) {
269
555
  return ok ? body : null;
270
556
  }
271
557
  async function webhook(request, context, options) {
272
- if (!options.webhookSecret)
558
+ if (!options.webhookSecret) {
273
559
  return Response.json({ error: "no webhook secret configured" }, { status: 404 });
560
+ }
274
561
  const body = await verified(request, options.webhookSecret);
275
562
  if (body === null) return Response.json({ error: "bad signature" }, { status: 401 });
276
563
  const payload = JSON.parse(body);
@@ -280,14 +567,6 @@ async function webhook(request, context, options) {
280
567
  context.waitUntil(receive(payload, context));
281
568
  return new Response(null, { status: 200 });
282
569
  }
283
- function personOf(actor) {
284
- return {
285
- id: `linear:${actor?.id ?? "unknown"}`,
286
- name: actor?.name ?? "Someone in Linear",
287
- kind: actor?.type === "user" || actor?.type === void 0 ? "human" : "agent",
288
- ...actor?.avatarUrl ? { avatarUrl: actor.avatarUrl } : {}
289
- };
290
- }
291
570
  async function receive(payload, context) {
292
571
  const { data } = payload;
293
572
  if (payload.type === "Comment" && payload.action === "create") {
@@ -297,7 +576,8 @@ async function receive(payload, context) {
297
576
  if (!issue) return;
298
577
  const text = String(data.body ?? "").trim().slice(0, MAX_REPLY);
299
578
  if (!text) return;
300
- const { comment } = await context.as(personOf(payload.actor)).reply(issue.threadId, text);
579
+ const person = await personFor(context, payload.actor);
580
+ const { comment } = await context.as(person).reply(issue.threadId, text);
301
581
  await context.links.save({ externalId: id, threadId: issue.threadId, commentId: comment.id });
302
582
  return;
303
583
  }
@@ -306,7 +586,7 @@ async function receive(payload, context) {
306
586
  if (!issue) return;
307
587
  const state = data.state;
308
588
  const done = state?.type === "completed" || state?.type === "canceled";
309
- const actions = context.as(personOf(payload.actor));
589
+ const actions = context.as(await personFor(context, payload.actor));
310
590
  const thread = await actions.getThread(issue.threadId);
311
591
  if (!thread) return;
312
592
  if (done && thread.status === "open") await actions.setStatus(thread.id, "resolved");