web-comments 0.0.1 → 1.0.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.
Files changed (63) hide show
  1. package/README.md +263 -6
  2. package/dist/auth/bearer-token.js +43 -0
  3. package/dist/auth/cloudflare-access.js +90 -0
  4. package/dist/auth/dev-user.js +21 -0
  5. package/dist/auth/session-endpoint.js +47 -0
  6. package/dist/chunks/chunk-644PTNSO.js +101 -0
  7. package/dist/chunks/chunk-7K7K7M5O.js +101 -0
  8. package/dist/chunks/chunk-RRSB326L.js +720 -0
  9. package/dist/chunks/chunk-T4WN3STA.js +239 -0
  10. package/dist/chunks/chunk-WSFCRVEQ.js +9 -0
  11. package/dist/chunks/chunk-WY5E2C47.js +99 -0
  12. package/dist/connectors/linear.js +318 -0
  13. package/dist/connectors/mcp.js +890 -0
  14. package/dist/index.js +52 -0
  15. package/dist/react.js +19 -0
  16. package/dist/server.js +11 -0
  17. package/dist/store/d1.js +16 -0
  18. package/dist/store/drizzle/mysql.js +17 -0
  19. package/dist/store/drizzle/pg.js +17 -0
  20. package/dist/store/drizzle/sqlite.js +17 -0
  21. package/dist/store/drizzle.js +10 -0
  22. package/dist/store/memory.js +109 -0
  23. package/dist/store/r2.js +25 -0
  24. package/dist/store/s3.js +43 -0
  25. package/dist/types/auth/bearer-token.d.ts +13 -0
  26. package/dist/types/auth/cloudflare-access.d.ts +33 -0
  27. package/dist/types/auth/dev-user.d.ts +6 -0
  28. package/dist/types/auth/session-endpoint.d.ts +25 -0
  29. package/dist/types/connectors/linear/index.d.ts +46 -0
  30. package/dist/types/connectors/mcp/index.d.ts +21 -0
  31. package/dist/types/connectors/mcp/oauth.d.ts +50 -0
  32. package/dist/types/connectors/mcp/pages.d.ts +14 -0
  33. package/dist/types/react.d.ts +8 -0
  34. package/dist/types/server/auth.d.ts +16 -0
  35. package/dist/types/server/connector.d.ts +102 -0
  36. package/dist/types/server/events.d.ts +31 -0
  37. package/dist/types/server/index.d.ts +50 -0
  38. package/dist/types/server/service.d.ts +53 -0
  39. package/dist/types/server/store.d.ts +85 -0
  40. package/dist/types/server/validate.d.ts +20 -0
  41. package/dist/types/shared/types.d.ts +117 -0
  42. package/dist/types/store/d1.d.ts +9 -0
  43. package/dist/types/store/drizzle/index.d.ts +22 -0
  44. package/dist/types/store/drizzle/mysql.d.ts +700 -0
  45. package/dist/types/store/drizzle/pg.d.ts +659 -0
  46. package/dist/types/store/drizzle/sqlite.d.ts +700 -0
  47. package/dist/types/store/memory.d.ts +10 -0
  48. package/dist/types/store/r2.d.ts +20 -0
  49. package/dist/types/store/s3.d.ts +21 -0
  50. package/dist/types/widget/anchor.d.ts +9 -0
  51. package/dist/types/widget/api.d.ts +43 -0
  52. package/dist/types/widget/app.d.ts +29 -0
  53. package/dist/types/widget/capture.d.ts +33 -0
  54. package/dist/types/widget/format.d.ts +15 -0
  55. package/dist/types/widget/index.d.ts +41 -0
  56. package/dist/types/widget/styles.d.ts +6 -0
  57. package/dist/types/widget/thread.d.ts +32 -0
  58. package/dist/types/widget/tracker.d.ts +68 -0
  59. package/dist/widget.global.js +52 -0
  60. package/package.json +144 -6
  61. package/types/widget.global.d.ts +5 -0
  62. package/index.d.ts +0 -2
  63. package/index.js +0 -6
@@ -0,0 +1,239 @@
1
+ import {
2
+ pg_exports
3
+ } from "./chunk-7K7K7M5O.js";
4
+ import {
5
+ mysql_exports
6
+ } from "./chunk-WY5E2C47.js";
7
+ import {
8
+ sqlite_exports
9
+ } from "./chunk-644PTNSO.js";
10
+
11
+ // src/store/drizzle/index.ts
12
+ import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
13
+ var SCHEMAS = { sqlite: sqlite_exports, pg: pg_exports, mysql: mysql_exports };
14
+ var IN_CHUNK = 90;
15
+ function drizzleStore(options) {
16
+ const { threads, comments, users, links, values, createTablesSql } = SCHEMAS[options.dialect];
17
+ const db = options.db;
18
+ async function together(build) {
19
+ if (typeof db.batch === "function") {
20
+ await db.batch(build(db));
21
+ return;
22
+ }
23
+ await db.transaction(async (tx) => {
24
+ for (const query of build(tx)) await query;
25
+ });
26
+ }
27
+ async function commentsOf(threadIds) {
28
+ const byThread = new Map(threadIds.map((id) => [id, []]));
29
+ for (let i = 0; i < threadIds.length; i += IN_CHUNK) {
30
+ const rows = await db.select().from(comments).where(inArray(comments.threadId, threadIds.slice(i, i + IN_CHUNK))).orderBy(asc(comments.createdAt), asc(comments.id));
31
+ for (const row of rows) byThread.get(row.threadId)?.push(toComment(row));
32
+ }
33
+ return byThread;
34
+ }
35
+ function upsert(table, row, target) {
36
+ const set = { ...row };
37
+ const insert = db.insert(table).values(row);
38
+ return options.dialect === "mysql" ? insert.onDuplicateKeyUpdate({ set }) : insert.onConflictDoUpdate({ target, set });
39
+ }
40
+ async function threadRows(site, id) {
41
+ const rows = await db.select().from(threads).where(and(eq(threads.site, site), eq(threads.id, id))).limit(1);
42
+ return rows[0];
43
+ }
44
+ return {
45
+ async setup() {
46
+ if (options.createTables === false) return;
47
+ for (const statement of createTablesSql) {
48
+ if (options.dialect === "sqlite") await db.run(sql.raw(statement));
49
+ else await db.execute(sql.raw(statement));
50
+ }
51
+ },
52
+ async listThreads(site, { page, limit }) {
53
+ const rows = await db.select().from(threads).where(
54
+ page === void 0 ? eq(threads.site, site) : and(eq(threads.site, site), eq(threads.page, page))
55
+ ).orderBy(page === void 0 ? desc(threads.createdAt) : asc(threads.createdAt)).limit(limit);
56
+ const byThread = await commentsOf(rows.map((row) => row.id));
57
+ return rows.map((row) => toThread(row, byThread.get(row.id) ?? []));
58
+ },
59
+ async getThread(site, id) {
60
+ const row = await threadRows(site, id);
61
+ if (!row) return null;
62
+ const byThread = await commentsOf([row.id]);
63
+ return toThread(row, byThread.get(row.id) ?? []);
64
+ },
65
+ async insertThread(thread, first) {
66
+ await together((on) => [
67
+ on.insert(threads).values(threadRow(thread)),
68
+ on.insert(comments).values(commentRow(first))
69
+ ]);
70
+ },
71
+ async insertComment(site, comment) {
72
+ if (!await threadRows(site, comment.threadId)) return;
73
+ await db.insert(comments).values(commentRow(comment));
74
+ },
75
+ async updateThread(site, id, changes) {
76
+ const set = changedColumns(changes);
77
+ if (Object.keys(set).length === 0) return;
78
+ await db.update(threads).set(set).where(and(eq(threads.site, site), eq(threads.id, id)));
79
+ },
80
+ async getComment(site, id) {
81
+ const rows = await db.select().from(comments).where(eq(comments.id, id)).limit(1);
82
+ const row = rows[0];
83
+ if (!row || !await threadRows(site, row.threadId)) return null;
84
+ return toComment(row);
85
+ },
86
+ async deleteComment(site, id) {
87
+ const comment = await this.getComment(site, id);
88
+ if (comment) await db.delete(comments).where(eq(comments.id, id));
89
+ },
90
+ async deleteThread(site, id) {
91
+ if (!await threadRows(site, id)) return;
92
+ await together((on) => [
93
+ on.delete(comments).where(eq(comments.threadId, id)),
94
+ on.delete(links).where(and(eq(links.site, site), eq(links.threadId, id))),
95
+ on.delete(threads).where(and(eq(threads.site, site), eq(threads.id, id)))
96
+ ]);
97
+ },
98
+ async saveUser(who, seenAt) {
99
+ const row = {
100
+ id: who.id,
101
+ name: who.name,
102
+ email: who.email ?? null,
103
+ avatarUrl: who.avatarUrl ?? null,
104
+ kind: who.kind,
105
+ lastSeenAt: seenAt
106
+ };
107
+ await upsert(users, row, [users.id]);
108
+ },
109
+ async saveLink(site, link) {
110
+ await upsert(links, { site, ...link }, [links.site, links.connector, links.externalId]);
111
+ },
112
+ async findLink(site, connector, externalId) {
113
+ const rows = await db.select().from(links).where(
114
+ and(
115
+ eq(links.site, site),
116
+ eq(links.connector, connector),
117
+ eq(links.externalId, externalId)
118
+ )
119
+ ).limit(1);
120
+ return rows[0] ? toLink(rows[0]) : null;
121
+ },
122
+ async listLinks(site, threadIds) {
123
+ const found = [];
124
+ for (let i = 0; i < threadIds.length; i += IN_CHUNK) {
125
+ const rows = await db.select().from(links).where(
126
+ and(eq(links.site, site), inArray(links.threadId, threadIds.slice(i, i + IN_CHUNK)))
127
+ ).orderBy(asc(links.createdAt));
128
+ found.push(...rows.map(toLink));
129
+ }
130
+ return found;
131
+ },
132
+ async getValue(name) {
133
+ const rows = await db.select({ value: values.value, expiresAt: values.expiresAt }).from(values).where(eq(values.name, name)).limit(1);
134
+ const row = rows[0];
135
+ if (!row) return null;
136
+ if (row.expiresAt !== null && row.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
137
+ await db.delete(values).where(eq(values.name, name));
138
+ return null;
139
+ }
140
+ return row.value;
141
+ },
142
+ async setValue(name, value, expiresAt) {
143
+ await upsert(values, { name, value, expiresAt }, [values.name]);
144
+ },
145
+ async deleteValue(name) {
146
+ await db.delete(values).where(eq(values.name, name));
147
+ }
148
+ };
149
+ }
150
+ function toLink(row) {
151
+ const { site: _, ...link } = row;
152
+ return link;
153
+ }
154
+ function json(value) {
155
+ return value === null ? null : JSON.parse(value);
156
+ }
157
+ function rowAuthor(row) {
158
+ return {
159
+ id: row.authorId,
160
+ name: row.authorName,
161
+ kind: row.authorKind,
162
+ ...row.authorAvatar ? { avatarUrl: row.authorAvatar } : {}
163
+ };
164
+ }
165
+ function toComment(row) {
166
+ return {
167
+ id: row.id,
168
+ threadId: row.threadId,
169
+ body: row.body,
170
+ author: rowAuthor(row),
171
+ createdAt: row.createdAt
172
+ };
173
+ }
174
+ function toThread(row, comments) {
175
+ return {
176
+ id: row.id,
177
+ site: row.site,
178
+ page: row.page,
179
+ url: row.url,
180
+ anchor: JSON.parse(row.anchor),
181
+ context: json(row.context),
182
+ screenshots: JSON.parse(row.screenshots),
183
+ status: row.status,
184
+ author: rowAuthor(row),
185
+ createdAt: row.createdAt,
186
+ updatedAt: row.updatedAt,
187
+ resolvedAt: row.resolvedAt,
188
+ resolvedBy: json(row.resolvedBy),
189
+ comments
190
+ };
191
+ }
192
+ function threadRow(thread) {
193
+ return {
194
+ id: thread.id,
195
+ site: thread.site,
196
+ page: thread.page,
197
+ url: thread.url,
198
+ anchor: JSON.stringify(thread.anchor),
199
+ context: thread.context ? JSON.stringify(thread.context) : null,
200
+ screenshots: JSON.stringify(thread.screenshots),
201
+ status: thread.status,
202
+ authorId: thread.author.id,
203
+ authorName: thread.author.name,
204
+ authorAvatar: thread.author.avatarUrl ?? null,
205
+ authorKind: thread.author.kind,
206
+ createdAt: thread.createdAt,
207
+ updatedAt: thread.updatedAt,
208
+ resolvedAt: thread.resolvedAt,
209
+ resolvedBy: thread.resolvedBy ? JSON.stringify(thread.resolvedBy) : null
210
+ };
211
+ }
212
+ function commentRow(comment) {
213
+ return {
214
+ id: comment.id,
215
+ threadId: comment.threadId,
216
+ body: comment.body,
217
+ authorId: comment.author.id,
218
+ authorName: comment.author.name,
219
+ authorAvatar: comment.author.avatarUrl ?? null,
220
+ authorKind: comment.author.kind,
221
+ createdAt: comment.createdAt
222
+ };
223
+ }
224
+ function changedColumns(changes) {
225
+ const set = {};
226
+ if (changes.status !== void 0) set.status = changes.status;
227
+ if (changes.anchor !== void 0) set.anchor = JSON.stringify(changes.anchor);
228
+ if (changes.screenshots !== void 0) set.screenshots = JSON.stringify(changes.screenshots);
229
+ if (changes.updatedAt !== void 0) set.updatedAt = changes.updatedAt;
230
+ if (changes.resolvedAt !== void 0) set.resolvedAt = changes.resolvedAt;
231
+ if (changes.resolvedBy !== void 0) {
232
+ set.resolvedBy = changes.resolvedBy ? JSON.stringify(changes.resolvedBy) : null;
233
+ }
234
+ return set;
235
+ }
236
+
237
+ export {
238
+ drizzleStore
239
+ };
@@ -0,0 +1,9 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ export {
8
+ __export
9
+ };
@@ -0,0 +1,99 @@
1
+ import {
2
+ __export
3
+ } from "./chunk-WSFCRVEQ.js";
4
+
5
+ // src/store/drizzle/mysql.ts
6
+ var mysql_exports = {};
7
+ __export(mysql_exports, {
8
+ comments: () => comments,
9
+ createTablesSql: () => createTablesSql,
10
+ links: () => links,
11
+ threads: () => threads,
12
+ users: () => users,
13
+ values: () => values
14
+ });
15
+ import { index, mysqlTable, primaryKey, text, varchar } from "drizzle-orm/mysql-core";
16
+ var id = (name) => varchar(name, { length: 64 });
17
+ var threads = mysqlTable(
18
+ "wc_threads",
19
+ {
20
+ id: id("id").primaryKey(),
21
+ site: varchar("site", { length: 128 }).notNull(),
22
+ page: varchar("page", { length: 512 }).notNull(),
23
+ url: text("url").notNull(),
24
+ anchor: text("anchor").notNull(),
25
+ context: text("context"),
26
+ screenshots: text("screenshots").notNull(),
27
+ status: varchar("status", { length: 16 }).notNull(),
28
+ authorId: varchar("author_id", { length: 255 }).notNull(),
29
+ authorName: varchar("author_name", { length: 255 }).notNull(),
30
+ authorAvatar: text("author_avatar"),
31
+ authorKind: varchar("author_kind", { length: 16 }).notNull(),
32
+ createdAt: varchar("created_at", { length: 32 }).notNull(),
33
+ updatedAt: varchar("updated_at", { length: 32 }).notNull(),
34
+ resolvedAt: varchar("resolved_at", { length: 32 }),
35
+ resolvedBy: text("resolved_by")
36
+ },
37
+ (t) => [index("wc_threads_page").on(t.site, t.page, t.createdAt)]
38
+ );
39
+ var comments = mysqlTable(
40
+ "wc_comments",
41
+ {
42
+ id: id("id").primaryKey(),
43
+ threadId: id("thread_id").notNull(),
44
+ body: text("body").notNull(),
45
+ authorId: varchar("author_id", { length: 255 }).notNull(),
46
+ authorName: varchar("author_name", { length: 255 }).notNull(),
47
+ authorAvatar: text("author_avatar"),
48
+ authorKind: varchar("author_kind", { length: 16 }).notNull(),
49
+ createdAt: varchar("created_at", { length: 32 }).notNull()
50
+ },
51
+ (t) => [index("wc_comments_thread").on(t.threadId, t.createdAt)]
52
+ );
53
+ var users = mysqlTable("wc_users", {
54
+ id: varchar("id", { length: 255 }).primaryKey(),
55
+ name: varchar("name", { length: 255 }).notNull(),
56
+ email: varchar("email", { length: 320 }),
57
+ avatarUrl: text("avatar_url"),
58
+ kind: varchar("kind", { length: 16 }).notNull(),
59
+ lastSeenAt: varchar("last_seen_at", { length: 32 }).notNull()
60
+ });
61
+ var links = mysqlTable(
62
+ "wc_links",
63
+ {
64
+ site: varchar("site", { length: 128 }).notNull(),
65
+ connector: varchar("connector", { length: 64 }).notNull(),
66
+ externalId: varchar("external_id", { length: 255 }).notNull(),
67
+ threadId: id("thread_id").notNull(),
68
+ commentId: id("comment_id"),
69
+ url: text("url"),
70
+ title: varchar("title", { length: 255 }),
71
+ createdAt: varchar("created_at", { length: 32 }).notNull()
72
+ },
73
+ (t) => [
74
+ primaryKey({ columns: [t.site, t.connector, t.externalId] }),
75
+ index("wc_links_thread").on(t.site, t.threadId)
76
+ ]
77
+ );
78
+ var values = mysqlTable("wc_values", {
79
+ name: varchar("name", { length: 512 }).primaryKey(),
80
+ value: text("value").notNull(),
81
+ expiresAt: varchar("expires_at", { length: 32 })
82
+ });
83
+ var createTablesSql = [
84
+ `CREATE TABLE IF NOT EXISTS wc_threads (id VARCHAR(64) NOT NULL PRIMARY KEY, site VARCHAR(128) NOT NULL, page VARCHAR(512) NOT NULL, url TEXT NOT NULL, anchor TEXT NOT NULL, context TEXT, screenshots TEXT NOT NULL, status VARCHAR(16) NOT NULL, author_id VARCHAR(255) NOT NULL, author_name VARCHAR(255) NOT NULL, author_avatar TEXT, author_kind VARCHAR(16) NOT NULL, created_at VARCHAR(32) NOT NULL, updated_at VARCHAR(32) NOT NULL, resolved_at VARCHAR(32), resolved_by TEXT, KEY wc_threads_page (site, page, created_at)) DEFAULT CHARSET=utf8mb4`,
85
+ `CREATE TABLE IF NOT EXISTS wc_comments (id VARCHAR(64) NOT NULL PRIMARY KEY, thread_id VARCHAR(64) NOT NULL, body TEXT NOT NULL, author_id VARCHAR(255) NOT NULL, author_name VARCHAR(255) NOT NULL, author_avatar TEXT, author_kind VARCHAR(16) NOT NULL, created_at VARCHAR(32) NOT NULL, KEY wc_comments_thread (thread_id, created_at)) DEFAULT CHARSET=utf8mb4`,
86
+ `CREATE TABLE IF NOT EXISTS wc_users (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(320), avatar_url TEXT, kind VARCHAR(16) NOT NULL, last_seen_at VARCHAR(32) NOT NULL) DEFAULT CHARSET=utf8mb4`,
87
+ `CREATE TABLE IF NOT EXISTS wc_links (site VARCHAR(128) NOT NULL, connector VARCHAR(64) NOT NULL, external_id VARCHAR(255) NOT NULL, thread_id VARCHAR(64) NOT NULL, comment_id VARCHAR(64), url TEXT, title VARCHAR(255), created_at VARCHAR(32) NOT NULL, PRIMARY KEY (site, connector, external_id), KEY wc_links_thread (site, thread_id)) DEFAULT CHARSET=utf8mb4`,
88
+ `CREATE TABLE IF NOT EXISTS wc_values (name VARCHAR(512) NOT NULL PRIMARY KEY, value TEXT NOT NULL, expires_at VARCHAR(32)) DEFAULT CHARSET=utf8mb4`
89
+ ];
90
+
91
+ export {
92
+ threads,
93
+ comments,
94
+ users,
95
+ links,
96
+ values,
97
+ createTablesSql,
98
+ mysql_exports
99
+ };
@@ -0,0 +1,318 @@
1
+ import "../chunks/chunk-WSFCRVEQ.js";
2
+
3
+ // src/connectors/linear/index.ts
4
+ var API = "https://api.linear.app/graphql";
5
+ var TOKEN_URL = "https://api.linear.app/oauth/token";
6
+ var DAY = 24 * 60 * 60;
7
+ var MAX_REPLY = 1e4;
8
+ function linear(options) {
9
+ const optionsFor = (env) => typeof options === "function" ? options(env) : options;
10
+ return {
11
+ name: "linear",
12
+ async onEvent(event, context) {
13
+ const settings = optionsFor(context.env);
14
+ if (settings) await mirror(event, context, settings, client(context, settings));
15
+ },
16
+ async fetch(request, context) {
17
+ const { pathname } = new URL(request.url);
18
+ const settings = optionsFor(context.env);
19
+ if (!settings || pathname !== `${new URL(context.url).pathname}/webhook`) {
20
+ return Response.json({ error: "not_found" }, { status: 404 });
21
+ }
22
+ if (request.method !== "POST")
23
+ return new Response(null, { status: 405, headers: { allow: "POST" } });
24
+ return webhook(request, context, settings);
25
+ }
26
+ };
27
+ }
28
+ function client(context, options) {
29
+ const url = options.apiUrl ?? API;
30
+ return async function call(query, variables = {}, retried = false) {
31
+ const response = await fetch(url, {
32
+ method: "POST",
33
+ headers: {
34
+ "content-type": "application/json",
35
+ authorization: await authorization(context, options, retried)
36
+ },
37
+ body: JSON.stringify({ query, variables })
38
+ });
39
+ if (response.status === 401 && "clientId" in options.auth && !retried) {
40
+ return call(
41
+ query,
42
+ variables,
43
+ true
44
+ );
45
+ }
46
+ const body = await response.json().catch(() => null);
47
+ if (!response.ok || !body?.data || body.errors?.length) {
48
+ const reason = body?.errors?.map((error) => error.message).join("; ") || `HTTP ${response.status}`;
49
+ throw new Error(`Linear: ${reason}`);
50
+ }
51
+ return body.data;
52
+ };
53
+ }
54
+ async function authorization(context, options, fresh) {
55
+ const { auth } = options;
56
+ if ("apiKey" in auth) return auth.apiKey;
57
+ if ("accessToken" in auth) return `Bearer ${auth.accessToken}`;
58
+ const cached = fresh ? null : await context.storage.get("app-token");
59
+ if (cached) return `Bearer ${cached}`;
60
+ const response = await fetch(TOKEN_URL, {
61
+ method: "POST",
62
+ headers: {
63
+ "content-type": "application/x-www-form-urlencoded",
64
+ authorization: `Basic ${btoa(`${auth.clientId}:${auth.clientSecret}`)}`
65
+ },
66
+ body: new URLSearchParams({
67
+ grant_type: "client_credentials",
68
+ scope: "read,write,issues:create,comments:create"
69
+ })
70
+ });
71
+ 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}`;
78
+ }
79
+ var actsAsApp = (options) => "clientId" in options.auth;
80
+ async function teamOf(context, options, call) {
81
+ const key = `team:${options.team}`;
82
+ const cached = await context.storage.get(key);
83
+ if (cached) return JSON.parse(cached);
84
+ const isId = /^[0-9a-f-]{36}$/i.test(options.team);
85
+ const fields = "id states { nodes { id type position } }";
86
+ const found = isId ? (await call(`query($id: String!) { team(id: $id) { ${fields} } }`, {
87
+ id: options.team
88
+ })).team : (await call(
89
+ `query($key: String!) { teams(filter: { key: { eq: $key } }) { nodes { ${fields} } } }`,
90
+ { key: options.team }
91
+ )).teams.nodes[0];
92
+ if (!found) throw new Error(`Linear: no team ${options.team}`);
93
+ const first = (type) => found.states.nodes.filter((state) => state.type === type).sort((a, b) => a.position - b.position)[0]?.id ?? null;
94
+ const team = { id: found.id, completed: first("completed"), unstarted: first("unstarted") };
95
+ await context.storage.set(key, JSON.stringify(team), { ttlSeconds: DAY });
96
+ return team;
97
+ }
98
+ async function upload(context, call, thread, kind) {
99
+ const shot = await context.screenshot(thread, kind);
100
+ if (!shot) return null;
101
+ try {
102
+ const extension = shot.contentType.split("/")[1] ?? "png";
103
+ const { fileUpload } = await call(
104
+ `mutation($contentType: String!, $filename: String!, $size: Int!) {
105
+ fileUpload(contentType: $contentType, filename: $filename, size: $size) {
106
+ uploadFile { uploadUrl assetUrl headers { key value } }
107
+ }
108
+ }`,
109
+ {
110
+ contentType: shot.contentType,
111
+ filename: `${kind}.${extension}`,
112
+ size: shot.body.byteLength
113
+ }
114
+ );
115
+ const file = fileUpload.uploadFile;
116
+ if (!file) return null;
117
+ const headers = new Headers({
118
+ "content-type": shot.contentType,
119
+ "cache-control": "public, max-age=31536000"
120
+ });
121
+ for (const { key, value } of file.headers) headers.set(key, value);
122
+ const put = await fetch(file.uploadUrl, { method: "PUT", headers, body: shot.body });
123
+ return put.ok ? file.assetUrl : null;
124
+ } catch (error) {
125
+ console.error("web-comments: Linear upload failed", error);
126
+ return null;
127
+ }
128
+ }
129
+ function titleOf(body) {
130
+ const line = body.split("\n").find((l) => l.trim())?.trim() ?? "Comment";
131
+ if (line.length <= 80) return line;
132
+ const cut = line.slice(0, 79);
133
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), 60))}\u2026`;
134
+ }
135
+ function byline(comment) {
136
+ return `\u2014 ${comment.author.name}`;
137
+ }
138
+ function as(options, comment) {
139
+ if (!actsAsApp(options)) return {};
140
+ return {
141
+ createAsUser: comment.author.name,
142
+ ...comment.author.avatarUrl?.startsWith("https://") ? { displayIconUrl: comment.author.avatarUrl } : {}
143
+ };
144
+ }
145
+ async function describe(context, call, thread, options) {
146
+ const first = thread.comments[0];
147
+ const { anchor } = thread;
148
+ const where = thread.context?.page.title || thread.url;
149
+ const lines = [
150
+ first.body,
151
+ "",
152
+ actsAsApp(options) ? `On [${where}](${context.threadUrl(thread)})` : `${byline(first)}, on [${where}](${context.threadUrl(thread)})`,
153
+ "",
154
+ `**Element** \`<${anchor.tag}>\` \`${anchor.selector.replace(/`/g, "'")}\`${anchor.text ? ` \u201C${anchor.text}\u201D` : ""}`
155
+ ];
156
+ const [element, page] = await Promise.all([
157
+ upload(context, call, thread, "element"),
158
+ upload(context, call, thread, "page")
159
+ ]);
160
+ if (element) lines.push("", `![The element](${element})`);
161
+ if (page) lines.push("", `![The page](${page})`);
162
+ return lines.join("\n");
163
+ }
164
+ async function mirror(event, context, options, call) {
165
+ switch (event.type) {
166
+ case "thread.created": {
167
+ const { thread } = event;
168
+ if (options.filter && !options.filter(thread)) return;
169
+ const team = await teamOf(context, options, call);
170
+ 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)
181
+ }
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
190
+ });
191
+ return;
192
+ }
193
+ case "comment.created": {
194
+ const issue = await issueOf(context, event.thread.id);
195
+ if (!issue) return;
196
+ const { comment } = event;
197
+ const id = crypto.randomUUID();
198
+ await context.links.save({
199
+ externalId: id,
200
+ threadId: event.thread.id,
201
+ commentId: comment.id
202
+ });
203
+ await call(
204
+ `mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`,
205
+ {
206
+ input: {
207
+ id,
208
+ issueId: issue,
209
+ body: actsAsApp(options) ? comment.body : `${comment.body}
210
+
211
+ ${byline(comment)}`,
212
+ ...as(options, comment)
213
+ }
214
+ }
215
+ );
216
+ return;
217
+ }
218
+ case "thread.resolved":
219
+ case "thread.reopened": {
220
+ const issue = await issueOf(context, event.thread.id);
221
+ 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
+ );
232
+ return;
233
+ }
234
+ case "comment.deleted": {
235
+ const links = await context.links.forThread(event.threadId);
236
+ const mirrored = links.find((link) => link.commentId === event.commentId);
237
+ if (!mirrored) return;
238
+ await call(`mutation($id: String!) { commentDelete(id: $id) { success } }`, {
239
+ id: mirrored.externalId
240
+ });
241
+ return;
242
+ }
243
+ default:
244
+ return;
245
+ }
246
+ }
247
+ async function issueOf(context, threadId) {
248
+ const links = await context.links.forThread(threadId);
249
+ return links.find((link) => link.commentId === null && link.url)?.externalId ?? null;
250
+ }
251
+ async function verified(request, secret) {
252
+ const signature = request.headers.get("linear-signature") ?? "";
253
+ const body = await request.text();
254
+ const key = await crypto.subtle.importKey(
255
+ "raw",
256
+ new TextEncoder().encode(secret),
257
+ { name: "HMAC", hash: "SHA-256" },
258
+ false,
259
+ ["verify"]
260
+ );
261
+ const bytes = signature.match(/^[0-9a-f]{64}$/i)?.[0].match(/../g)?.map((hex) => Number.parseInt(hex, 16));
262
+ if (!bytes) return null;
263
+ const ok = await crypto.subtle.verify(
264
+ "HMAC",
265
+ key,
266
+ new Uint8Array(bytes),
267
+ new TextEncoder().encode(body)
268
+ );
269
+ return ok ? body : null;
270
+ }
271
+ async function webhook(request, context, options) {
272
+ if (!options.webhookSecret)
273
+ return Response.json({ error: "no webhook secret configured" }, { status: 404 });
274
+ const body = await verified(request, options.webhookSecret);
275
+ if (body === null) return Response.json({ error: "bad signature" }, { status: 401 });
276
+ const payload = JSON.parse(body);
277
+ if (Math.abs(Date.now() - payload.webhookTimestamp) > 6e4) {
278
+ return Response.json({ error: "stale webhook" }, { status: 401 });
279
+ }
280
+ context.waitUntil(receive(payload, context));
281
+ return new Response(null, { status: 200 });
282
+ }
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
+ async function receive(payload, context) {
292
+ const { data } = payload;
293
+ if (payload.type === "Comment" && payload.action === "create") {
294
+ const id = String(data.id ?? "");
295
+ if (!id || await context.links.find(id)) return;
296
+ const issue = await context.links.find(String(data.issueId ?? ""));
297
+ if (!issue) return;
298
+ const text = String(data.body ?? "").trim().slice(0, MAX_REPLY);
299
+ if (!text) return;
300
+ const { comment } = await context.as(personOf(payload.actor)).reply(issue.threadId, text);
301
+ await context.links.save({ externalId: id, threadId: issue.threadId, commentId: comment.id });
302
+ return;
303
+ }
304
+ if (payload.type === "Issue" && payload.action === "update" && payload.updatedFrom && "stateId" in payload.updatedFrom) {
305
+ const issue = await context.links.find(String(data.id ?? ""));
306
+ if (!issue) return;
307
+ const state = data.state;
308
+ const done = state?.type === "completed" || state?.type === "canceled";
309
+ const actions = context.as(personOf(payload.actor));
310
+ const thread = await actions.getThread(issue.threadId);
311
+ if (!thread) return;
312
+ if (done && thread.status === "open") await actions.setStatus(thread.id, "resolved");
313
+ if (!done && thread.status === "resolved") await actions.setStatus(thread.id, "open");
314
+ }
315
+ }
316
+ export {
317
+ linear
318
+ };