web-comments 0.1.0 → 1.0.1
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 +77 -2
- package/dist/chunks/{chunk-GHNSLFY2.js → chunk-644PTNSO.js} +32 -3
- package/dist/chunks/{chunk-6VWNCJ3A.js → chunk-7K7K7M5O.js} +32 -3
- package/dist/chunks/chunk-RRSB326L.js +720 -0
- package/dist/chunks/{chunk-LBIURYPH.js → chunk-T4WN3STA.js} +55 -8
- package/dist/chunks/{chunk-PLAC65HG.js → chunk-WY5E2C47.js} +31 -3
- package/dist/connectors/linear.js +318 -0
- package/dist/connectors/mcp.js +895 -0
- package/dist/index.js +28 -20
- package/dist/server.js +6 -496
- package/dist/store/d1.js +4 -4
- package/dist/store/drizzle/mysql.js +7 -3
- package/dist/store/drizzle/pg.js +7 -3
- package/dist/store/drizzle/sqlite.js +7 -3
- package/dist/store/drizzle.js +4 -4
- package/dist/store/memory.js +34 -0
- package/dist/types/connectors/linear/index.d.ts +46 -0
- package/dist/types/connectors/mcp/index.d.ts +21 -0
- package/dist/types/connectors/mcp/oauth.d.ts +50 -0
- package/dist/types/connectors/mcp/pages.d.ts +14 -0
- package/dist/types/server/connector.d.ts +102 -0
- package/dist/types/server/events.d.ts +31 -0
- package/dist/types/server/index.d.ts +16 -37
- package/dist/types/server/service.d.ts +53 -0
- package/dist/types/server/store.d.ts +28 -2
- package/dist/types/shared/types.d.ts +9 -0
- package/dist/types/store/drizzle/mysql.d.ts +194 -0
- package/dist/types/store/drizzle/pg.d.ts +183 -0
- package/dist/types/store/drizzle/sqlite.d.ts +194 -0
- package/dist/types/widget/styles.d.ts +1 -1
- package/dist/widget.global.js +28 -20
- package/package.json +21 -9
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
2
|
pg_exports
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-7K7K7M5O.js";
|
|
4
4
|
import {
|
|
5
5
|
mysql_exports
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-WY5E2C47.js";
|
|
7
7
|
import {
|
|
8
8
|
sqlite_exports
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-644PTNSO.js";
|
|
10
10
|
|
|
11
11
|
// src/store/drizzle/index.ts
|
|
12
12
|
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
|
13
13
|
var SCHEMAS = { sqlite: sqlite_exports, pg: pg_exports, mysql: mysql_exports };
|
|
14
14
|
var IN_CHUNK = 90;
|
|
15
15
|
function drizzleStore(options) {
|
|
16
|
-
const { threads, comments, users, createTablesSql } = SCHEMAS[options.dialect];
|
|
16
|
+
const { threads, comments, users, links, values, createTablesSql } = SCHEMAS[options.dialect];
|
|
17
17
|
const db = options.db;
|
|
18
18
|
async function together(build) {
|
|
19
19
|
if (typeof db.batch === "function") {
|
|
@@ -32,6 +32,11 @@ function drizzleStore(options) {
|
|
|
32
32
|
}
|
|
33
33
|
return byThread;
|
|
34
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
|
+
}
|
|
35
40
|
async function threadRows(site, id) {
|
|
36
41
|
const rows = await db.select().from(threads).where(and(eq(threads.site, site), eq(threads.id, id))).limit(1);
|
|
37
42
|
return rows[0];
|
|
@@ -86,11 +91,12 @@ function drizzleStore(options) {
|
|
|
86
91
|
if (!await threadRows(site, id)) return;
|
|
87
92
|
await together((on) => [
|
|
88
93
|
on.delete(comments).where(eq(comments.threadId, id)),
|
|
94
|
+
on.delete(links).where(and(eq(links.site, site), eq(links.threadId, id))),
|
|
89
95
|
on.delete(threads).where(and(eq(threads.site, site), eq(threads.id, id)))
|
|
90
96
|
]);
|
|
91
97
|
},
|
|
92
98
|
async saveUser(who, seenAt) {
|
|
93
|
-
const
|
|
99
|
+
const row = {
|
|
94
100
|
id: who.id,
|
|
95
101
|
name: who.name,
|
|
96
102
|
email: who.email ?? null,
|
|
@@ -98,12 +104,53 @@ function drizzleStore(options) {
|
|
|
98
104
|
kind: who.kind,
|
|
99
105
|
lastSeenAt: seenAt
|
|
100
106
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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));
|
|
104
147
|
}
|
|
105
148
|
};
|
|
106
149
|
}
|
|
150
|
+
function toLink(row) {
|
|
151
|
+
const { site: _, ...link } = row;
|
|
152
|
+
return link;
|
|
153
|
+
}
|
|
107
154
|
function json(value) {
|
|
108
155
|
return value === null ? null : JSON.parse(value);
|
|
109
156
|
}
|
|
@@ -7,10 +7,12 @@ var mysql_exports = {};
|
|
|
7
7
|
__export(mysql_exports, {
|
|
8
8
|
comments: () => comments,
|
|
9
9
|
createTablesSql: () => createTablesSql,
|
|
10
|
+
links: () => links,
|
|
10
11
|
threads: () => threads,
|
|
11
|
-
users: () => users
|
|
12
|
+
users: () => users,
|
|
13
|
+
values: () => values
|
|
12
14
|
});
|
|
13
|
-
import { index, mysqlTable, text, varchar } from "drizzle-orm/mysql-core";
|
|
15
|
+
import { index, mysqlTable, primaryKey, text, varchar } from "drizzle-orm/mysql-core";
|
|
14
16
|
var id = (name) => varchar(name, { length: 64 });
|
|
15
17
|
var threads = mysqlTable(
|
|
16
18
|
"wc_threads",
|
|
@@ -56,16 +58,42 @@ var users = mysqlTable("wc_users", {
|
|
|
56
58
|
kind: varchar("kind", { length: 16 }).notNull(),
|
|
57
59
|
lastSeenAt: varchar("last_seen_at", { length: 32 }).notNull()
|
|
58
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
|
+
});
|
|
59
83
|
var createTablesSql = [
|
|
60
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`,
|
|
61
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`,
|
|
62
|
-
`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
|
|
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`
|
|
63
89
|
];
|
|
64
90
|
|
|
65
91
|
export {
|
|
66
92
|
threads,
|
|
67
93
|
comments,
|
|
68
94
|
users,
|
|
95
|
+
links,
|
|
96
|
+
values,
|
|
69
97
|
createTablesSql,
|
|
70
98
|
mysql_exports
|
|
71
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("", ``);
|
|
161
|
+
if (page) lines.push("", ``);
|
|
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
|
+
};
|