web-comments 1.0.0 → 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.
- package/README.md +11 -0
- package/dist/chunks/{chunk-RRSB326L.js → chunk-HN5V6BS6.js} +42 -9
- package/dist/connectors/linear.js +367 -87
- package/dist/connectors/mcp.js +96 -8
- package/dist/index.js +19 -19
- package/dist/server.js +1 -1
- package/dist/types/connectors/linear/icon.d.ts +1 -0
- package/dist/types/connectors/linear/index.d.ts +7 -4
- package/dist/types/connectors/mcp/icon.d.ts +1 -0
- package/dist/types/connectors/mcp/oauth.d.ts +13 -0
- package/dist/types/server/connector.d.ts +19 -1
- package/dist/types/server/service.d.ts +2 -0
- package/dist/types/shared/types.d.ts +55 -0
- package/dist/types/widget/api.d.ts +10 -1
- package/dist/types/widget/connectors.d.ts +13 -0
- package/dist/widget.global.js +19 -19
- package/package.json +1 -1
|
@@ -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)
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(`${
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
136
|
-
return
|
|
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
|
-
|
|
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
|
|
428
|
+
signed(options, first, asThemselves),
|
|
151
429
|
"",
|
|
152
|
-
|
|
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("", ``);
|
|
162
440
|
return lines.join("\n");
|
|
163
441
|
}
|
|
164
|
-
async function mirror(event, context, options
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
204
|
-
|
|
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:
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
id: issue,
|
|
229
|
-
|
|
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
|
|
239
|
-
|
|
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
|
|
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(
|
|
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");
|