@vercel/board 0.17.0 → 0.18.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.
@@ -0,0 +1,219 @@
1
+ import { S as SyncUnit, c as ReceivedEvent, d as ResolvedTarget, f as SyncContext, o as ProviderPost, r as ProviderDelivery, y as SyncFn } from "./provider-B0b9Qxw4.mjs";
2
+ import { n as ChatSyncProvider, t as ChatInput } from "./chat-provider-DNJQyajS.mjs";
3
+ import { z } from "zod";
4
+ import { Adapter } from "chat";
5
+ //#region src/sync/linear.d.ts
6
+ export declare const LinearEvent: z.ZodDiscriminatedUnion<[z.ZodObject<{
7
+ channel: z.ZodString;
8
+ thread: z.ZodString;
9
+ subthread: z.ZodNullable<z.ZodString>;
10
+ kind: z.ZodLiteral<"message">;
11
+ message: z.ZodString;
12
+ isRoot: z.ZodBoolean;
13
+ text: z.ZodString;
14
+ context: z.ZodNullable<z.ZodString>;
15
+ author: z.ZodObject<{
16
+ id: z.ZodString;
17
+ handle: z.ZodString;
18
+ name: z.ZodString;
19
+ avatar: z.ZodNullable<z.ZodString>;
20
+ isBot: z.ZodBoolean;
21
+ }, z.core.$strip>;
22
+ files: z.ZodOptional<z.ZodArray<z.ZodObject<{
23
+ type: z.ZodEnum<{
24
+ audio: "audio";
25
+ file: "file";
26
+ image: "image";
27
+ video: "video";
28
+ }>;
29
+ name: z.ZodOptional<z.ZodString>;
30
+ mimeType: z.ZodOptional<z.ZodString>;
31
+ size: z.ZodOptional<z.ZodNumber>;
32
+ url: z.ZodOptional<z.ZodString>;
33
+ fetchMetadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
34
+ }, z.core.$strip>>>;
35
+ links: z.ZodOptional<z.ZodArray<z.ZodObject<{
36
+ url: z.ZodString;
37
+ thread: z.ZodString;
38
+ message: z.ZodString;
39
+ snapshot: z.ZodNullable<z.ZodObject<{
40
+ author: z.ZodString;
41
+ text: z.ZodString;
42
+ createdAt: z.ZodNullable<z.ZodString>;
43
+ files: z.ZodArray<z.ZodObject<{
44
+ name: z.ZodString;
45
+ url: z.ZodNullable<z.ZodString>;
46
+ }, z.core.$strip>>;
47
+ }, z.core.$strip>>;
48
+ }, z.core.$strip>>>;
49
+ id: z.ZodString;
50
+ key: z.ZodString;
51
+ raw: z.ZodUnknown;
52
+ platform: z.ZodLiteral<"linear">;
53
+ type: z.ZodLiteral<"comment">;
54
+ }, z.core.$strip>, z.ZodObject<{
55
+ channel: z.ZodString;
56
+ thread: z.ZodString;
57
+ subthread: z.ZodNullable<z.ZodString>;
58
+ kind: z.ZodLiteral<"edited">;
59
+ message: z.ZodString;
60
+ text: z.ZodString;
61
+ author: z.ZodObject<{
62
+ id: z.ZodString;
63
+ handle: z.ZodString;
64
+ name: z.ZodString;
65
+ avatar: z.ZodNullable<z.ZodString>;
66
+ isBot: z.ZodBoolean;
67
+ }, z.core.$strip>;
68
+ links: z.ZodOptional<z.ZodArray<z.ZodObject<{
69
+ url: z.ZodString;
70
+ thread: z.ZodString;
71
+ message: z.ZodString;
72
+ snapshot: z.ZodNullable<z.ZodObject<{
73
+ author: z.ZodString;
74
+ text: z.ZodString;
75
+ createdAt: z.ZodNullable<z.ZodString>;
76
+ files: z.ZodArray<z.ZodObject<{
77
+ name: z.ZodString;
78
+ url: z.ZodNullable<z.ZodString>;
79
+ }, z.core.$strip>>;
80
+ }, z.core.$strip>>;
81
+ }, z.core.$strip>>>;
82
+ linksOnly: z.ZodOptional<z.ZodBoolean>;
83
+ id: z.ZodString;
84
+ key: z.ZodString;
85
+ raw: z.ZodUnknown;
86
+ platform: z.ZodLiteral<"linear">;
87
+ type: z.ZodLiteral<"message.edited">;
88
+ }, z.core.$strip>, z.ZodObject<{
89
+ channel: z.ZodString;
90
+ thread: z.ZodString;
91
+ subthread: z.ZodNullable<z.ZodString>;
92
+ kind: z.ZodLiteral<"deleted">;
93
+ message: z.ZodString;
94
+ id: z.ZodString;
95
+ key: z.ZodString;
96
+ raw: z.ZodUnknown;
97
+ platform: z.ZodLiteral<"linear">;
98
+ type: z.ZodLiteral<"message.deleted">;
99
+ }, z.core.$strip>, z.ZodObject<{
100
+ channel: z.ZodString;
101
+ thread: z.ZodString;
102
+ subthread: z.ZodNullable<z.ZodString>;
103
+ kind: z.ZodLiteral<"reaction">;
104
+ message: z.ZodString;
105
+ emoji: z.ZodString;
106
+ added: z.ZodBoolean;
107
+ author: z.ZodObject<{
108
+ id: z.ZodString;
109
+ handle: z.ZodString;
110
+ name: z.ZodString;
111
+ avatar: z.ZodNullable<z.ZodString>;
112
+ isBot: z.ZodBoolean;
113
+ }, z.core.$strip>;
114
+ id: z.ZodString;
115
+ key: z.ZodString;
116
+ raw: z.ZodUnknown;
117
+ platform: z.ZodLiteral<"linear">;
118
+ type: z.ZodEnum<{
119
+ "reaction.added": "reaction.added";
120
+ "reaction.removed": "reaction.removed";
121
+ }>;
122
+ }, z.core.$strip>, z.ZodObject<{
123
+ channel: z.ZodString;
124
+ thread: z.ZodString;
125
+ subthread: z.ZodNullable<z.ZodString>;
126
+ kind: z.ZodLiteral<"lifecycle">;
127
+ type: z.ZodString;
128
+ title: z.ZodString;
129
+ url: z.ZodNullable<z.ZodString>;
130
+ labels: z.ZodRecord<z.ZodString, z.ZodString>;
131
+ unset: z.ZodArray<z.ZodString>;
132
+ owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
133
+ reply: z.ZodNullable<z.ZodString>;
134
+ createRoot: z.ZodBoolean;
135
+ retitled: z.ZodOptional<z.ZodBoolean>;
136
+ author: z.ZodObject<{
137
+ id: z.ZodString;
138
+ handle: z.ZodString;
139
+ name: z.ZodString;
140
+ avatar: z.ZodNullable<z.ZodString>;
141
+ isBot: z.ZodBoolean;
142
+ }, z.core.$strip>;
143
+ id: z.ZodString;
144
+ key: z.ZodString;
145
+ raw: z.ZodUnknown;
146
+ platform: z.ZodLiteral<"linear">;
147
+ }, z.core.$strip>], "kind">;
148
+ export type LinearEvent = z.infer<typeof LinearEvent>;
149
+ export type LinearOptions = {
150
+ /**
151
+ * A Vercel Connect connector (`'linear/acme'`) holding a Linear app's token: board builds the
152
+ * Chat SDK adapter from it. Or your own adapter from `createLinearAdapter`, in its default
153
+ * `comments` mode, with the app's own token: the adapter skips what its token's user writes.
154
+ */
155
+ adapter: Adapter | string;
156
+ };
157
+ /**
158
+ * Syncs Linear with the board: `app` is a Vercel Connect connector (`'linear/acme'`) or the
159
+ * options, and `sync` is what each event does, both ways, like `teams({ ENG: 'dev' })`. Each
160
+ * issue is a thread: its comments are replies, and a board root in a mirrored team opens an issue.
161
+ */
162
+ export declare function linear(app: string | LinearOptions, sync: SyncFn<LinearEvent>): LinearProvider;
163
+ /**
164
+ * Routes each event by its Linear team. Keys are team keys (`'ENG'`, the prefix of `ENG-123`);
165
+ * values are a board channel to `mirror`, or a sync function.
166
+ */
167
+ export declare const teams: (map: Record<string, SyncUnit<LinearEvent>>) => SyncFn<LinearEvent>;
168
+ export declare class LinearProvider extends ChatSyncProvider<LinearEvent> {
169
+ private readonly linear;
170
+ private readonly teamIds;
171
+ constructor(given: LinearOptions, sync: SyncFn<LinearEvent>);
172
+ /**
173
+ * Runs a Linear API call. A Connect token lives in the adapter's request context, which only a
174
+ * webhook sets up: anything else (a delivery, a root fetched on a later pull) asks for one here.
175
+ */
176
+ private api;
177
+ resolveChannel(key: string): Promise<string>;
178
+ /**
179
+ * `ENG` is a team; `ENG:<uuid>`, a bare UUID, or `ENG-12` is an issue, by its team and UUID:
180
+ * comments come in under the UUID, which stays when the issue is renamed.
181
+ */
182
+ resolveTarget(target: string): Promise<ResolvedTarget>;
183
+ fetchRoot(thread: string): Promise<{
184
+ text: string;
185
+ raw: {
186
+ issue: {
187
+ id: string;
188
+ identifier: string;
189
+ title: string;
190
+ url: string;
191
+ };
192
+ };
193
+ author: {
194
+ id: string;
195
+ handle: string;
196
+ name: string;
197
+ avatar: string | null;
198
+ isBot: boolean;
199
+ };
200
+ labels: {
201
+ issue: string;
202
+ state?: string | undefined;
203
+ };
204
+ }>;
205
+ private client;
206
+ /** A team's ID from its key, which Linear's API asks for to open an issue. */
207
+ private team;
208
+ protected normalize(key: string, input: ChatInput): Promise<ReceivedEvent>;
209
+ private teamOfIssue;
210
+ inboxChannel(key: string, thread: string): string;
211
+ /** A root in a mirrored team opens an issue, titled with its first line; a reply is a comment. */
212
+ post(target: string, kind: ResolvedTarget['kind'], post: ProviderPost): Promise<ProviderDelivery>;
213
+ edit(thread: string, message: string, post: ProviderPost): Promise<undefined>;
214
+ delete(thread: string, message: string): Promise<void>;
215
+ react(thread: string, message: string, emoji: string, added: boolean): Promise<void>;
216
+ startTyping(): Promise<void>;
217
+ event(context: SyncContext): LinearEvent;
218
+ }
219
+ //#endregion
@@ -0,0 +1,311 @@
1
+ import { i as UnreadableError, s as channels } from "./provider-B3DpYSDq.mjs";
2
+ import { i as eventSchema, r as envelope } from "./events-CewD4kw7.mjs";
3
+ import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-YHy-WbrB.mjs";
4
+ import "zod";
5
+ import { connectLinearAdapter } from "@vercel/connect/chat";
6
+ import { createLinearAdapter } from "@chat-adapter/linear";
7
+ //#region src/sync/linear.ts
8
+ const LinearEvent = eventSchema("linear", "comment");
9
+ /**
10
+ * Syncs Linear with the board: `app` is a Vercel Connect connector (`'linear/acme'`) or the
11
+ * options, and `sync` is what each event does, both ways, like `teams({ ENG: 'dev' })`. Each
12
+ * issue is a thread: its comments are replies, and a board root in a mirrored team opens an issue.
13
+ */
14
+ function linear(app, sync) {
15
+ return new LinearProvider(typeof app === "string" ? { adapter: app } : app, sync);
16
+ }
17
+ /**
18
+ * Routes each event by its Linear team. Keys are team keys (`'ENG'`, the prefix of `ENG-123`);
19
+ * values are a board channel to `mirror`, or a sync function.
20
+ */
21
+ const teams = (map) => channels(map);
22
+ /** A person's handle, the end of their profile URL (`…/profiles/ada`), as the adapter reads it. */
23
+ const handleOf = (user) => /\/profiles\/([^/?#]+)/.exec(user?.url ?? "")?.[1] ?? user?.displayName ?? user?.name;
24
+ const author = (user, isBot) => ({
25
+ id: `linear:user:${user?.id ?? "unknown"}`,
26
+ handle: handleOf(user) ?? "unknown",
27
+ name: user?.name ?? user?.displayName ?? "unknown",
28
+ avatar: user?.avatarUrl ?? null,
29
+ isBot: isBot || user?.type !== void 0 && user.type !== "user"
30
+ });
31
+ /** `ENG` from `ENG-123`, or from a Linear URL that names an issue (`…/issue/ENG-123/…`). */
32
+ const teamOf = (identifierOrUrl) => /(?:^|\/issue\/)([A-Z0-9_]+)-\d+/i.exec(identifierOrUrl ?? "")?.[1]?.toUpperCase() ?? null;
33
+ /** The copy of an issue the board opened: the issue itself, not a comment. */
34
+ const OPENED = "issue";
35
+ /** A root's copy: one the board opened, or imported (`lifecycle:<event>`, `root:<event>`). Comment IDs are UUIDs. */
36
+ const isIssue = (message) => message === OPENED || message.includes(":");
37
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
38
+ /** `ENG:<issueId>`, or `ENG:<issueId>:c:<commentId>` (a comment thread): the team, and the issue's UUID. */
39
+ function parseThread(thread) {
40
+ const match = /^([A-Z0-9_]+):([^:]+)(?::c:([^:]+))?$/i.exec(thread);
41
+ if (!match) throw new Error(`invalid Linear thread: ${thread}`);
42
+ return {
43
+ team: match[1].toUpperCase(),
44
+ issue: match[2],
45
+ comment: match[3] ?? null
46
+ };
47
+ }
48
+ /** A board message's first line, as an issue title: Linear titles are plain text. */
49
+ function titleOf(markdown) {
50
+ return (markdown.split("\n").map((text) => text.trim()).find(Boolean) ?? "").replace(/^\*\*[^*\n]+\*\*:\s*/, "").replace(/\*\*|__|`/g, "").replace(/^[#>\s]+/, "").replace(/\s+/g, " ").trim().slice(0, 255) || "Untitled";
51
+ }
52
+ function stateOf(state) {
53
+ return state?.type ?? state?.name?.toLowerCase() ?? null;
54
+ }
55
+ var LinearProvider = class extends ChatSyncProvider {
56
+ linear;
57
+ teamIds = /* @__PURE__ */ new Map();
58
+ constructor(given, sync) {
59
+ const adapter = typeof given.adapter === "string" ? createLinearAdapter({
60
+ ...connectLinearAdapter(given.adapter),
61
+ mode: "comments"
62
+ }) : given.adapter;
63
+ super({
64
+ platform: "linear",
65
+ adapter,
66
+ canStartThreads: true,
67
+ resolveChannel: (key) => Promise.resolve(key.toUpperCase()),
68
+ resolveTarget: (target) => Promise.resolve({
69
+ target,
70
+ channel: target,
71
+ kind: "channel"
72
+ }),
73
+ groupThread: (thread) => thread.replace(/:c:[^:]+$/, ""),
74
+ channelOf: (thread) => thread.slice(0, thread.indexOf(":")),
75
+ toChatId: (id) => id.slice(id.indexOf(":") + 1),
76
+ isRoot: () => false,
77
+ context: () => null,
78
+ markdown(message) {
79
+ const body = message.raw?.comment?.body;
80
+ return typeof body === "string" && body.trim() ? body.trim() : markdownOf(message);
81
+ },
82
+ async avatar(message) {
83
+ return message.raw?.comment?.user?.avatarUrl ?? null;
84
+ },
85
+ missed(_headers, body) {
86
+ let payload;
87
+ try {
88
+ payload = JSON.parse(body);
89
+ } catch {
90
+ return [];
91
+ }
92
+ if (payload.type !== "Comment" || payload.action !== "update" && payload.action !== "remove") return [];
93
+ const { data } = payload;
94
+ const issueId = data.issueId ?? data.issue?.id;
95
+ if (!issueId || !data.user) return [];
96
+ const message = adapter.parseMessage({
97
+ kind: "comment",
98
+ organizationId: payload.organizationId,
99
+ comment: {
100
+ ...data,
101
+ issueId,
102
+ parentId: data.parentId ?? void 0,
103
+ url: payload.url,
104
+ user: {
105
+ type: "user",
106
+ id: data.user.id,
107
+ displayName: handleOf(data.user) ?? "unknown",
108
+ fullName: data.user.name ?? "unknown",
109
+ email: void 0,
110
+ avatarUrl: data.user.avatarUrl ?? void 0
111
+ }
112
+ }
113
+ });
114
+ if (message.author.isMe) return [];
115
+ return payload.action === "update" ? [{
116
+ kind: "edited",
117
+ message
118
+ }] : [{
119
+ kind: "deleted",
120
+ thread: message.threadId,
121
+ message: message.id,
122
+ raw: payload
123
+ }];
124
+ },
125
+ intercept(key, headers, body) {
126
+ let payload;
127
+ try {
128
+ payload = JSON.parse(body);
129
+ } catch {
130
+ return [];
131
+ }
132
+ if (payload.type !== "Issue" || !payload.data?.identifier) return [];
133
+ const { data } = payload;
134
+ const created = payload.action === "create";
135
+ const moved = payload.action === "update" && payload.updatedFrom?.stateId !== void 0;
136
+ const retitled = payload.action === "update" && payload.updatedFrom?.title !== void 0;
137
+ if (!created && !moved && !retitled) return [];
138
+ const team = data.team?.key ?? teamOf(data.identifier);
139
+ const state = stateOf(data.state);
140
+ const own = payload.actor?.id !== void 0 && payload.actor.id === adapter.defaultBotUserId;
141
+ const delivery = headers.get("linear-delivery") ?? `${payload.webhookId}:${payload.webhookTimestamp}`;
142
+ return [{
143
+ event: {
144
+ kind: "lifecycle",
145
+ channel: `${key}:${team}`,
146
+ thread: `${key}:${team}:${data.id}`,
147
+ type: created ? "issue.created" : "issue.updated",
148
+ title: `${data.identifier}: ${data.title}`,
149
+ url: data.url ?? payload.url ?? null,
150
+ labels: {
151
+ issue: data.identifier,
152
+ ...state && (created || moved) ? { state } : {}
153
+ },
154
+ unset: [],
155
+ reply: null,
156
+ createRoot: created && !own,
157
+ ...retitled ? { retitled: true } : {},
158
+ author: author(payload.actor, own)
159
+ },
160
+ raw: payload,
161
+ dedupe: `lifecycle:${delivery}`
162
+ }];
163
+ }
164
+ }, sync);
165
+ this.linear = adapter;
166
+ }
167
+ /**
168
+ * Runs a Linear API call. A Connect token lives in the adapter's request context, which only a
169
+ * webhook sets up: anything else (a delivery, a root fetched on a later pull) asks for one here.
170
+ */
171
+ async api(fn) {
172
+ await this.ready();
173
+ return this.linear.isConnectMode() ? this.linear.withConnectClient(void 0, fn) : fn();
174
+ }
175
+ async resolveChannel(key) {
176
+ await this.team(key);
177
+ return key.toUpperCase();
178
+ }
179
+ /**
180
+ * `ENG` is a team; `ENG:<uuid>`, a bare UUID, or `ENG-12` is an issue, by its team and UUID:
181
+ * comments come in under the UUID, which stays when the issue is renamed.
182
+ */
183
+ async resolveTarget(target) {
184
+ if (/:c:[^:]+$/.test(target)) throw new Error(`a comment thread can't be a target; sync its issue, ${target.replace(/:c:[^:]+$/, "")}`);
185
+ if (/^[A-Z0-9_]+$/i.test(target)) return {
186
+ target: target.toUpperCase(),
187
+ channel: target.toUpperCase(),
188
+ kind: "channel"
189
+ };
190
+ const scoped = /^([A-Z0-9_]+):([^:]+)$/i.exec(target);
191
+ if (scoped && UUID.test(scoped[2])) return {
192
+ target: `${scoped[1].toUpperCase()}:${scoped[2]}`,
193
+ channel: scoped[1].toUpperCase(),
194
+ kind: "thread"
195
+ };
196
+ if (!UUID.test(target) && !/^[A-Z0-9_]+-\d+$/i.test(target)) throw new Error(`invalid Linear target: ${target}`);
197
+ const issue = await this.api(() => this.client().issue(target));
198
+ const team = teamOf(issue.identifier) ?? await this.api(async () => (await issue.team)?.key);
199
+ if (!team) throw new Error(`Linear didn't say which team ${target} is in`);
200
+ return {
201
+ target: `${team.toUpperCase()}:${issue.id}`,
202
+ channel: team.toUpperCase(),
203
+ kind: "thread"
204
+ };
205
+ }
206
+ async fetchRoot(thread) {
207
+ return this.api(async () => {
208
+ const issue = await this.client().issue(parseThread(thread).issue);
209
+ const [state, creator] = await Promise.all([issue.state, issue.creator]);
210
+ const found = stateOf(state);
211
+ return {
212
+ text: `${issue.identifier}: ${issue.title}\n${issue.url}`,
213
+ raw: { issue: {
214
+ id: issue.id,
215
+ identifier: issue.identifier,
216
+ title: issue.title,
217
+ url: issue.url
218
+ } },
219
+ author: author(creator, false),
220
+ labels: {
221
+ issue: issue.identifier,
222
+ ...found ? { state: found } : {}
223
+ }
224
+ };
225
+ });
226
+ }
227
+ client() {
228
+ return this.linear.linearClient;
229
+ }
230
+ /** A team's ID from its key, which Linear's API asks for to open an issue. */
231
+ async team(key) {
232
+ const wanted = key.toUpperCase();
233
+ const known = this.teamIds.get(wanted);
234
+ if (known) return known;
235
+ const { nodes } = await this.api(() => this.client().teams({ filter: { key: { eq: wanted } } }));
236
+ const team = nodes.find((node) => node.key.toUpperCase() === wanted);
237
+ if (!team) throw new UnreadableError("channel_not_found", `no Linear team ${wanted}`);
238
+ this.teamIds.set(wanted, team.id);
239
+ return team.id;
240
+ }
241
+ async normalize(key, input) {
242
+ const received = await super.normalize(key, input);
243
+ if (input.kind === "reaction") return received;
244
+ const raw = input.kind === "deleted" ? input.raw : input.message.raw;
245
+ const comment = raw?.comment ?? raw?.data;
246
+ const issue = comment?.issueId ?? raw?.data?.issue?.id;
247
+ const root = comment?.parentId || comment?.id;
248
+ if (!issue || !root) return received;
249
+ const team = teamOf(raw?.comment?.url ?? raw?.url) ?? teamOf(raw?.data?.issue?.identifier) ?? await this.teamOfIssue(issue);
250
+ const thread = `${key}:${team}:${issue}`;
251
+ const event = {
252
+ ...received.event,
253
+ channel: `${key}:${team}`,
254
+ thread,
255
+ subthread: `${thread}:c:${root}`
256
+ };
257
+ return {
258
+ ...received,
259
+ event
260
+ };
261
+ }
262
+ async teamOfIssue(id) {
263
+ return this.api(async () => {
264
+ const issue = await this.client().issue(id);
265
+ const team = teamOf(issue.identifier) ?? (await issue.team)?.key;
266
+ if (!team) throw new Error(`Linear didn't say which team issue ${id} is in`);
267
+ return team.toUpperCase();
268
+ });
269
+ }
270
+ inboxChannel(key, thread) {
271
+ return `${key}:${parseThread(thread.slice(key.length + 1)).team}`;
272
+ }
273
+ /** A root in a mirrored team opens an issue, titled with its first line; a reply is a comment. */
274
+ async post(target, kind, post) {
275
+ if (kind === "thread") return {
276
+ ...await this.api(() => super.post(target, kind, post)),
277
+ thread: target
278
+ };
279
+ const teamId = await this.team(target);
280
+ const created = await this.api(() => this.client().createIssue({
281
+ teamId,
282
+ title: titleOf(post.markdown),
283
+ description: post.markdown
284
+ }));
285
+ if (!created.issueId) throw new Error(`Linear didn't return the issue it opened in ${target}`);
286
+ return {
287
+ thread: `${target.toUpperCase()}:${created.issueId}`,
288
+ message: OPENED
289
+ };
290
+ }
291
+ async edit(thread, message, post) {
292
+ if (message === OPENED) return void await this.api(() => this.client().updateIssue(parseThread(thread).issue, { description: post.markdown }));
293
+ if (!isIssue(message)) await this.api(() => super.edit(thread, message, post));
294
+ }
295
+ async delete(thread, message) {
296
+ if (!isIssue(message)) await this.api(() => super.delete(thread, message));
297
+ }
298
+ async react(thread, message, emoji, added) {
299
+ if (added && !isIssue(message)) await this.api(() => super.react(thread, message, emoji, added));
300
+ }
301
+ async startTyping() {}
302
+ event(context) {
303
+ const base = envelope(this.platform, context.key, context.event, context.raw ?? {}, context.id);
304
+ return LinearEvent.parse({
305
+ ...base,
306
+ type: context.event.kind === "message" ? "comment" : base.type
307
+ });
308
+ }
309
+ };
310
+ //#endregion
311
+ export { LinearEvent, LinearProvider, linear, teams };
@@ -631,7 +631,7 @@ const routes = {
631
631
  const SYNC = "@sync";
632
632
  //#endregion
633
633
  //#region src/version.ts
634
- const VERSION = "0.17.0";
634
+ const VERSION = "0.18.1";
635
635
  //#endregion
636
636
  //#region src/server/routing.ts
637
637
  /** board()'s own routes: the files it serves, and sync's, which run where board() does. */
@@ -116,8 +116,9 @@ Slack, `'owner/repo'` for GitHub, a phone number ID for WhatsApp.
116
116
  something (WhatsApp edits, GitHub typing), that delivery is skipped.
117
117
  2. **Starting threads.** A root posted on the board, by anyone or any watcher,
118
118
  goes to the sync function of each platform that can start threads in a
119
- channel, which may `start` one; `mirror` does in its channel. Slack can.
120
- GitHub and WhatsApp can't, so roots never open an issue or a conversation.
119
+ channel, which may `start` one; `mirror` does in its channel. Slack and
120
+ Linear can; on Linear a root opens an issue. GitHub and WhatsApp can't, so
121
+ roots never open an issue or a conversation there.
121
122
  3. **Inbound.** A platform event goes to the sync function when its thread is
122
123
  already synced, or when its channel is one the helpers route (their keys;
123
124
  a function of your own at the top gets every channel). The function decides
@@ -255,7 +256,8 @@ branch, a custom environment).
255
256
  **Without Connect,** create the app on the platform yourself, point its webhook
256
257
  at the same route, and pass the platform's own secrets to the Chat SDK adapter.
257
258
  Each platform page has the steps: [Slack](./03-slack.md#setup),
258
- [GitHub](./04-github.md#setup), [WhatsApp](./05-whatsapp.md#setup).
259
+ [GitHub](./04-github.md#setup), [WhatsApp](./05-whatsapp.md#setup),
260
+ [Linear](./07-linear.md#setup).
259
261
 
260
262
  ## When delivery fails
261
263
 
@@ -116,7 +116,7 @@ call, like `import`.
116
116
 
117
117
  ## Helpers
118
118
 
119
- `channels`, `repos`, and `numbers` take a map from platform channels to what
119
+ `channels`, `repos`, `numbers`, and `teams` take a map from platform channels to what
120
120
  each one does: a sync function, or a board channel as a string.
121
121
 
122
122
  | Helper | From | Keys | A string means |
@@ -124,11 +124,12 @@ each one does: a sync function, or a board channel as a string.
124
124
  | `channels` | `@vercel/board/sync-slack`, `@vercel/board/sync` | Slack channels, by name (`'#prs'`) or ID | `mirror` |
125
125
  | `repos` | `@vercel/board/sync-github` | Repos (`'owner/repo'`), or `{ pulls, issues }` to route each | `into` |
126
126
  | `numbers` | `@vercel/board/sync-kapso` | WhatsApp phone number IDs | `mirror` |
127
+ | `teams` | `@vercel/board/sync-linear` | Linear team keys (`'ENG'`) | `mirror` |
127
128
 
128
129
  Each routes an event by its `channel`. A new root goes to every channel's
129
130
  function, with `channel` set to that one. A thread the board syncs in a channel
130
131
  that isn't in the map (someone ran `vb sync` on it) keeps syncing: both ways
131
- under `channels` and `numbers`, and in only under `repos`.
132
+ under `channels`, `numbers`, and `teams`, and in only under `repos`.
132
133
 
133
134
  The units are plain sync functions, from `@vercel/board/sync`:
134
135
 
@@ -212,13 +213,15 @@ Then, by kind:
212
213
  - **`edited`:** `message`, `text`, `author`, `links`.
213
214
  - **`deleted`:** `message`.
214
215
  - **`reaction`:** `message`, `emoji`, `added`, `author`.
215
- - **`lifecycle`:** `type`, `title`, `url`, `labels`, `reply`, and `author`. Only
216
- GitHub sends these: see the [events table](./04-github.md#events).
216
+ - **`lifecycle`:** `type`, `title`, `url`, `labels`, `reply`, and `author`. GitHub
217
+ and Linear send these: see GitHub's [events table](./04-github.md#events) and
218
+ Linear's [events table](./07-linear.md#events).
217
219
 
218
220
  GitHub events also carry `subject: { type: 'pull_request' | 'issue', repo, number }`,
219
221
  comments and reactions included. The types are exported: `SlackEvent` from
220
222
  `@vercel/board/sync-slack`, `GitHubEvent` and `GitHubPayload` from
221
- `@vercel/board/sync-github`, and `WhatsAppEvent` from `@vercel/board/sync-kapso`.
223
+ `@vercel/board/sync-github`, `WhatsAppEvent` from `@vercel/board/sync-kapso`, and `LinearEvent` from
224
+ `@vercel/board/sync-linear`.
222
225
 
223
226
  ## Permalinks
224
227
 
@@ -256,5 +256,6 @@ review threads), `channelOf`, `toChatId` and `fromChatId` (ID encodings),
256
256
  `markdown` and `author` (reading a message), `context`, `fetchRoot`, `links`,
257
257
  `read`, `uploadFiles`, `refusesFiles`, `startTyping`, and `intercept` and `missed`
258
258
  (webhook events the adapter doesn't emit). The built-in providers in
259
- `@vercel/board/sync-slack`, `@vercel/board/sync-github`, and `@vercel/board/sync-kapso` are
259
+ `@vercel/board/sync-slack`, `@vercel/board/sync-github`, `@vercel/board/sync-kapso`, and
260
+ `@vercel/board/sync-linear` are
260
261
  working examples of each.