@vercel/board 0.28.0 → 0.29.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 (97) hide show
  1. package/dist/{a2-B0_VlbtC.mjs → a2-HUbQJ0RD.mjs} +16 -192
  2. package/dist/access.d.mts +1 -1
  3. package/dist/access.mjs +3 -3
  4. package/dist/{actors-BLXVp4oZ.mjs → actors-DcJhFbwJ.mjs} +2 -2
  5. package/dist/{auth-lTkOgvcE.mjs → auth-JO22iIwr.mjs} +1 -1
  6. package/dist/{backend-ZKYTe3xa.d.mts → backend-CPC_vyOo.d.mts} +5 -5
  7. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  8. package/dist/backend-a2/contract.d.mts +1 -1
  9. package/dist/backend-a2/contract.mjs +3 -3
  10. package/dist/backend-a2/store-memory.d.mts +1 -1
  11. package/dist/backend-a2/store-memory.mjs +2 -2
  12. package/dist/backend-a2/store-postgres.d.mts +1 -1
  13. package/dist/backend-a2/store-postgres.mjs +1 -1
  14. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  15. package/dist/backend-a2/store-redis-http.mjs +1 -1
  16. package/dist/backend-a2/store-redis.d.mts +1 -1
  17. package/dist/backend-a2/store-redis.mjs +1 -1
  18. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  19. package/dist/backend-a2/store-sqlite.mjs +2 -2
  20. package/dist/backend-a2.d.mts +8 -5
  21. package/dist/backend-a2.mjs +2 -1
  22. package/dist/{backend-http-BvNFrvcY.mjs → backend-http-Bmp0IgD3.mjs} +1 -1
  23. package/dist/{backend-http-E7YU0qhn.d.mts → backend-http-CqfKhrnn.d.mts} +5 -3
  24. package/dist/{backend-http-CfLVYqmj.mjs → backend-http-CzIJMm58.mjs} +3 -3
  25. package/dist/backend-http.d.mts +1 -1
  26. package/dist/backend-http.mjs +1 -1
  27. package/dist/backend-http.node.d.mts +1 -1
  28. package/dist/backend-http.node.mjs +1 -1
  29. package/dist/{backend-vercel-CIRObA0t.d.mts → backend-vercel-4tvcPKqX.d.mts} +7 -3
  30. package/dist/{backend-vercel-D87pIp2k.mjs → backend-vercel-BNauAs7w.mjs} +68 -10
  31. package/dist/{backend-vercel-DTrDiSk5.mjs → backend-vercel-DQc_TaHp.mjs} +1 -1
  32. package/dist/backend-vercel.d.mts +1 -1
  33. package/dist/backend-vercel.mjs +1 -1
  34. package/dist/backend-vercel.node.d.mts +1 -1
  35. package/dist/backend-vercel.node.mjs +1 -1
  36. package/dist/blob-DdbpJqN1.mjs +182 -0
  37. package/dist/{blob-B-9AvYis.d.mts → blob-DmiQjrYy.d.mts} +2 -2
  38. package/dist/{chat-provider-DsNEBQiA.d.mts → chat-provider-BWHbChcQ.d.mts} +5 -3
  39. package/dist/{chat-provider-DnHibqnM.mjs → chat-provider-COtsU5jB.mjs} +2 -2
  40. package/dist/cli-commands.mjs +1 -1
  41. package/dist/cli-embedded.d.mts +2 -2
  42. package/dist/cli-embedded.mjs +1 -1
  43. package/dist/cli.mjs +4 -4
  44. package/dist/{client-xLxeDz0f.d.mts → client-BqAjk0Q4.d.mts} +8 -1
  45. package/dist/{commands-BFookFtn.mjs → commands-C3R1AWgm.mjs} +4 -1
  46. package/dist/conformance.d.mts +1 -1
  47. package/dist/{engine-Bfe6pcXb.mjs → engine-DQxXk7dX.mjs} +20 -12
  48. package/dist/{errors-CB4tnZGz.d.mts → errors-DDktVeUa.d.mts} +1 -1
  49. package/dist/{events-CewD4kw7.mjs → events-qf5XG5VQ.mjs} +2 -2
  50. package/dist/{exports-CCzw2Vdw.mjs → exports-BG-0Wyn-.mjs} +9 -9
  51. package/dist/exports-mhAYiU4i.d.mts +5 -0
  52. package/dist/{external-BV_cUncs.mjs → external-D3p5T57R.mjs} +4 -2
  53. package/dist/{folds-Dyjput8M.mjs → folds-BDMSxBUf.mjs} +2 -2
  54. package/dist/{folds-jS9wlh9_.d.mts → folds-ZkHM4M-v.d.mts} +2 -0
  55. package/dist/{index-C05it0mC.d.mts → index-D3VvNfh_.d.mts} +2 -2
  56. package/dist/index.d.mts +5 -5
  57. package/dist/{index.default-DmKxH_E6.mjs → index.default-rB8sJ9RX.mjs} +1 -1
  58. package/dist/index.mjs +4 -4
  59. package/dist/index.node.d.mts +5 -5
  60. package/dist/index.node.mjs +2 -2
  61. package/dist/inspect/app.js +3 -3
  62. package/dist/inspect/engine.js +5 -5
  63. package/dist/{main-eci7w-aP.mjs → main-CVBqe3VO.mjs} +11 -8
  64. package/dist/{middleware-ChKM1zuu.mjs → middleware-CXjEeef4.mjs} +1 -1
  65. package/dist/{project-DMDST9kl.mjs → project-CBXlLftU.mjs} +1 -1
  66. package/dist/{provider-poIkXapd.mjs → provider-BaVmi4Hi.mjs} +3 -1
  67. package/dist/{provider-Cdg1qadh.d.mts → provider-DmSTp9lI.d.mts} +9 -2
  68. package/dist/{redis-tables-DN9PfIDi.mjs → redis-tables-BJQVUspu.mjs} +2 -2
  69. package/dist/{reducers-BcmSs4qf.mjs → reducers-DJzVpXwM.mjs} +3 -3
  70. package/dist/{schemas-Be08nyfW.mjs → schemas-DBmUO6pr.mjs} +2 -1
  71. package/dist/{serve-B09an9DQ.mjs → serve-G9RuaDSr.mjs} +4 -3
  72. package/dist/{server-DTX0prGr.mjs → server-BixV_byP.mjs} +4 -4
  73. package/dist/{serving-CvOWEym2.mjs → serving-B75Y4BF-.mjs} +1 -1
  74. package/dist/{sql-U9WTxrwJ.mjs → sql-D4Z_efJX.mjs} +2 -2
  75. package/dist/{sqlite-db-Ds4majtT.mjs → sqlite-db-Bz2JkfCU.mjs} +1 -1
  76. package/dist/sync-github.d.mts +4 -2
  77. package/dist/sync-github.mjs +4 -14
  78. package/dist/sync-kapso.d.mts +3 -2
  79. package/dist/sync-kapso.mjs +3 -3
  80. package/dist/sync-linear.d.mts +12 -2
  81. package/dist/sync-linear.mjs +189 -9
  82. package/dist/sync-slack.d.mts +41 -5
  83. package/dist/sync-slack.mjs +70 -11
  84. package/dist/sync.chat.d.mts +1 -1
  85. package/dist/sync.chat.mjs +1 -1
  86. package/dist/sync.d.mts +2 -2
  87. package/dist/sync.mjs +3 -3
  88. package/dist/tags-BpFo6q0_.mjs +14 -0
  89. package/dist/{targets-C-wdR5wP.mjs → targets-Cgjs29T0.mjs} +1 -1
  90. package/dist/{tokens-BuCU-61R.mjs → tokens-CC3izSNr.mjs} +1 -1
  91. package/dist/{types-aCAf6lGU.d.mts → types-No0WdhE5.d.mts} +1 -1
  92. package/dist/{watchers-hash-7DJU9b-n.mjs → watchers-hash-BDhLVkV5.mjs} +7 -5
  93. package/docs/02-sync/03-slack.md +6 -2
  94. package/docs/02-sync/07-linear.md +28 -6
  95. package/docs/04-reference/01-cli.md +4 -2
  96. package/package.json +1 -1
  97. package/dist/exports-D22M68rk.d.mts +0 -5
@@ -1,6 +1,7 @@
1
- import { i as UnreadableError, s as channels } from "./provider-poIkXapd.mjs";
2
- import { i as eventSchema, r as envelope } from "./events-CewD4kw7.mjs";
3
- import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-DnHibqnM.mjs";
1
+ import { i as UnreadableError, s as channels } from "./provider-BaVmi4Hi.mjs";
2
+ import { i as eventSchema, r as envelope } from "./events-qf5XG5VQ.mjs";
3
+ import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-COtsU5jB.mjs";
4
+ import { n as tagOf, t as slug } from "./tags-BpFo6q0_.mjs";
4
5
  import "zod";
5
6
  import { connectLinearAdapter } from "@vercel/connect/chat";
6
7
  import { createLinearAdapter } from "@chat-adapter/linear";
@@ -30,6 +31,41 @@ const author = (user, isBot) => ({
30
31
  });
31
32
  /** `ENG` from `ENG-123`, or from a Linear URL that names an issue (`…/issue/ENG-123/…`). */
32
33
  const teamOf = (identifierOrUrl) => /(?:^|\/issue\/)([A-Z0-9_]+)-\d+/i.exec(identifierOrUrl ?? "")?.[1]?.toUpperCase() ?? null;
34
+ /** An issue's description, or null when it has none. */
35
+ const bodyOf = (description) => description?.trim() || null;
36
+ /**
37
+ * An issue's properties as board labels: `priority=high`, `project=v0-accessibility`, a label per
38
+ * Linear label (`tag.bug`), and its assignee (`assignee.ada`). Only what the payload says: a
39
+ * property it carries empty is unset, and one it names without its details is left as it was.
40
+ * `priority` and `project` are single keys, so they're unset by name; `tag.` and `assignee.` are
41
+ * prefixes the mirror owns, so a key under them that Linear doesn't have goes.
42
+ */
43
+ function propertiesOf(data) {
44
+ const labels = {};
45
+ const unset = [];
46
+ const owns = [];
47
+ if (data.priority === 0 || data.priorityLabel === "No priority") unset.push("priority");
48
+ else if (data.priorityLabel && slug(data.priorityLabel)) labels.priority = slug(data.priorityLabel).slice(0, 256);
49
+ if (data.project?.name && slug(data.project.name)) labels.project = slug(data.project.name).slice(0, 256);
50
+ else if (data.project === null || data.projectId === null) unset.push("project");
51
+ if (Array.isArray(data.labels)) {
52
+ owns.push("tag.");
53
+ for (const label of data.labels) {
54
+ const tag = tagOf("tag", label.name);
55
+ if (tag) labels[tag[0]] = tag[1];
56
+ }
57
+ }
58
+ if (data.assignee) {
59
+ owns.push("assignee.");
60
+ const tag = tagOf("assignee", handleOf(data.assignee));
61
+ if (tag) labels[tag[0]] = tag[1];
62
+ } else if (data.assignee === null || data.assigneeId === null) owns.push("assignee.");
63
+ return {
64
+ labels,
65
+ unset,
66
+ owns
67
+ };
68
+ }
33
69
  /** The copy of an issue the board opened: the issue itself, not a comment. */
34
70
  const OPENED = "issue";
35
71
  /** A root's copy: one the board opened, or imported (`lifecycle:<event>`, `root:<event>`). Comment IDs are UUIDs. */
@@ -52,6 +88,51 @@ function titleOf(markdown) {
52
88
  function stateOf(state) {
53
89
  return state?.type ?? state?.name?.toLowerCase() ?? null;
54
90
  }
91
+ const ISSUE_FIELDS = `id identifier title description url createdAt priority priorityLabel
92
+ state { name } assignee { name displayName url } creator { name displayName url } project { name }
93
+ labels(first: 10) { nodes { name } }`;
94
+ const ISSUES_PER_PAGE = 100;
95
+ const DESCRIPTION_IN_LIST = 1500;
96
+ const MAX_COMMENTS = 1e3;
97
+ /** What a read names: a project (by its link), a team (by its key), or an issue (by its ID or link). */
98
+ function readTargetOf(target) {
99
+ const project = /\/project\/(?:[^/?#]*-)?([0-9a-f]{8,})(?:[/?#]|$)/i.exec(target);
100
+ if (project) return {
101
+ kind: "project",
102
+ slugId: project[1]
103
+ };
104
+ const issue = /(?:^|\/issue\/)([A-Z0-9_]+-\d+)(?:[/?#]|$)/i.exec(target.trim());
105
+ if (issue) return {
106
+ kind: "issue",
107
+ id: issue[1].toUpperCase()
108
+ };
109
+ if (/^[A-Z0-9_]+$/i.test(target.trim())) return {
110
+ kind: "team",
111
+ key: target.trim().toUpperCase()
112
+ };
113
+ throw new UnreadableError("invalid_target", `can't read ${target} from Linear: name a project by its link, a team by its key (ENG), or an issue (ENG-123)`);
114
+ }
115
+ /** An issue as a read shows it: its title, its properties on one line, its link, and what it says. */
116
+ function issueMessage(issue, descriptionMax) {
117
+ const properties = [
118
+ issue.state?.name,
119
+ issue.priority ? issue.priorityLabel : void 0,
120
+ issue.assignee ? `assigned to ${handleOf(issue.assignee)}` : "unassigned",
121
+ issue.project?.name,
122
+ ...(issue.labels?.nodes ?? []).map((label) => label.name)
123
+ ].filter(Boolean);
124
+ const description = bodyOf(issue.description);
125
+ const shown = description && descriptionMax !== null && description.length > descriptionMax ? `${description.slice(0, descriptionMax)}…` : description;
126
+ return {
127
+ id: issue.identifier,
128
+ thread: issue.identifier,
129
+ author: handleOf(issue.creator ?? void 0) ?? "unknown",
130
+ text: [`${issue.identifier}: ${issue.title}\n${properties.join(" · ")}\n${issue.url}`, shown].filter(Boolean).join("\n\n"),
131
+ createdAt: issue.createdAt,
132
+ replies: issue.comments?.nodes.length ?? 0,
133
+ files: []
134
+ };
135
+ }
55
136
  var LinearProvider = class extends ChatSyncProvider {
56
137
  linear;
57
138
  teamIds = /* @__PURE__ */ new Map();
@@ -133,13 +214,14 @@ var LinearProvider = class extends ChatSyncProvider {
133
214
  const { data } = payload;
134
215
  const created = payload.action === "create";
135
216
  if (!created && payload.action !== "update") return [];
136
- const retitled = payload.action === "update" && payload.updatedFrom?.title !== void 0;
217
+ const retitled = payload.action === "update" && (payload.updatedFrom?.title !== void 0 || payload.updatedFrom?.description !== void 0);
137
218
  const team = data.team?.key ?? teamOf(data.identifier);
138
219
  const state = stateOf(data.state);
139
220
  const bot = adapter.defaultBotUserId;
140
221
  const own = payload.actor?.id !== void 0 && payload.actor.id === bot;
141
222
  const opens = created ? !own : !own && (bot === null || data.creatorId !== bot) && payload.actor?.type === "user" && !data.archivedAt;
142
223
  const delivery = headers.get("linear-delivery") ?? `${payload.webhookId}:${payload.webhookTimestamp}`;
224
+ const properties = propertiesOf(data);
143
225
  return [{
144
226
  event: {
145
227
  kind: "lifecycle",
@@ -148,11 +230,14 @@ var LinearProvider = class extends ChatSyncProvider {
148
230
  type: created ? "issue.created" : "issue.updated",
149
231
  title: `${data.identifier}: ${data.title}`,
150
232
  url: data.url ?? payload.url ?? null,
233
+ body: bodyOf(data.description),
151
234
  labels: {
152
235
  issue: data.identifier,
153
- ...state ? { state } : {}
236
+ ...state ? { state } : {},
237
+ ...properties.labels
154
238
  },
155
- unset: [],
239
+ unset: properties.unset,
240
+ owns: properties.owns,
156
241
  reply: null,
157
242
  createRoot: opens,
158
243
  ...retitled ? { retitled: true } : {},
@@ -173,6 +258,84 @@ var LinearProvider = class extends ChatSyncProvider {
173
258
  await this.ready();
174
259
  return this.linear.isConnectMode() ? this.linear.withConnectClient(void 0, fn) : fn();
175
260
  }
261
+ /**
262
+ * A project's or team's issues, newest first, or one issue and its comments, read on demand:
263
+ * nothing is synced or stored. `next` pages past a read's `limit`.
264
+ */
265
+ async read(target, { limit, after }) {
266
+ const wanted = readTargetOf(target);
267
+ const query = (text, variables) => this.api(async () => (await this.client().client.rawRequest(text, variables)).data);
268
+ if (wanted.kind === "issue") {
269
+ let issue = null;
270
+ const comments = [];
271
+ let cursor = null;
272
+ do {
273
+ const data = await query(`query BoardReadIssue($id: String!, $first: Int!, $after: String) { issue(id: $id) { ${ISSUE_FIELDS}
274
+ comments(first: $first, after: $after) { nodes { id body createdAt user { name displayName url } } pageInfo { hasNextPage endCursor } } } }`, {
275
+ id: wanted.id,
276
+ first: ISSUES_PER_PAGE,
277
+ after: cursor
278
+ });
279
+ if (!data?.issue) throw new UnreadableError("channel_not_found", `no Linear issue ${wanted.id}`);
280
+ issue = data.issue;
281
+ comments.push(...issue.comments?.nodes ?? []);
282
+ const page = issue.comments?.pageInfo;
283
+ cursor = page?.hasNextPage ? page.endCursor ?? null : null;
284
+ } while (cursor !== null && comments.length < MAX_COMMENTS);
285
+ comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
286
+ const from = after === null ? 0 : Math.max(0, Number.parseInt(after, 10) || 0);
287
+ const shown = comments.slice(from, from + Math.min(limit, ISSUES_PER_PAGE));
288
+ const to = from + shown.length;
289
+ return {
290
+ channel: teamOf(issue.identifier) ?? wanted.id,
291
+ name: null,
292
+ thread: issue.identifier,
293
+ messages: [...from === 0 ? [{
294
+ ...issueMessage(issue, null),
295
+ replies: comments.length
296
+ }] : [], ...shown.map((comment) => ({
297
+ id: comment.id,
298
+ thread: issue.identifier,
299
+ author: handleOf(comment.user ?? void 0) ?? "unknown",
300
+ text: comment.body ?? "",
301
+ createdAt: comment.createdAt,
302
+ replies: 0,
303
+ files: []
304
+ }))],
305
+ more: to < comments.length,
306
+ next: to < comments.length ? String(to) : null
307
+ };
308
+ }
309
+ let filter;
310
+ let channel;
311
+ let name;
312
+ if (wanted.kind === "project") {
313
+ const project = (await query("query BoardReadProject($slugId: String!) { projects(filter: { slugId: { eq: $slugId } }, first: 1) { nodes { id name url } } }", { slugId: wanted.slugId }))?.projects.nodes[0];
314
+ if (!project) throw new UnreadableError("channel_not_found", `no Linear project ${target}`);
315
+ filter = { project: { id: { eq: project.id } } };
316
+ channel = project.url;
317
+ name = project.name;
318
+ } else {
319
+ await this.team(wanted.key);
320
+ filter = { team: { key: { eq: wanted.key } } };
321
+ channel = wanted.key;
322
+ name = null;
323
+ }
324
+ const issues = (await query(`query BoardReadIssues($filter: IssueFilter, $first: Int!, $after: String) {
325
+ issues(filter: $filter, first: $first, after: $after) { nodes { ${ISSUE_FIELDS} comments(first: 20) { nodes { id createdAt } } } pageInfo { hasNextPage endCursor } } }`, {
326
+ filter,
327
+ first: Math.min(limit, ISSUES_PER_PAGE),
328
+ after
329
+ }))?.issues;
330
+ return {
331
+ channel,
332
+ name,
333
+ thread: null,
334
+ messages: (issues?.nodes ?? []).map((issue) => issueMessage(issue, DESCRIPTION_IN_LIST)),
335
+ more: issues?.pageInfo.hasNextPage ?? false,
336
+ next: issues?.pageInfo.hasNextPage ? issues.pageInfo.endCursor ?? null : null
337
+ };
338
+ }
176
339
  async resolveChannel(key) {
177
340
  await this.team(key);
178
341
  return key.toUpperCase();
@@ -207,10 +370,26 @@ var LinearProvider = class extends ChatSyncProvider {
207
370
  async fetchRoot(thread) {
208
371
  return this.api(async () => {
209
372
  const issue = await this.client().issue(parseThread(thread).issue);
210
- const [state, creator] = await Promise.all([issue.state, issue.creator]);
373
+ const [state, creator, project, assignee, tags] = await Promise.all([
374
+ issue.state,
375
+ issue.creator,
376
+ issue.project,
377
+ issue.assignee,
378
+ issue.labels?.()
379
+ ]);
211
380
  const found = stateOf(state);
381
+ const properties = propertiesOf({
382
+ id: issue.id,
383
+ identifier: issue.identifier,
384
+ title: issue.title,
385
+ priority: issue.priority,
386
+ priorityLabel: issue.priorityLabel,
387
+ project,
388
+ assignee,
389
+ labels: tags?.nodes
390
+ });
212
391
  return {
213
- text: `${issue.identifier}: ${issue.title}\n${issue.url}`,
392
+ text: [`${issue.identifier}: ${issue.title}\n${issue.url}`, bodyOf(issue.description)].filter(Boolean).join("\n\n"),
214
393
  raw: { issue: {
215
394
  id: issue.id,
216
395
  identifier: issue.identifier,
@@ -220,7 +399,8 @@ var LinearProvider = class extends ChatSyncProvider {
220
399
  author: author(creator, false),
221
400
  labels: {
222
401
  issue: issue.identifier,
223
- ...found ? { state: found } : {}
402
+ ...found ? { state: found } : {},
403
+ ...properties.labels
224
404
  }
225
405
  };
226
406
  });
@@ -1,6 +1,6 @@
1
- import { f as ExternalLink } from "./folds-jS9wlh9_.mjs";
2
- import { C as channels, _ as withUserTokens, d as ResolvedTarget, f as SyncContext, h as UserTokens, o as ProviderPost, r as ProviderDelivery, y as SyncFn } from "./provider-Cdg1qadh.mjs";
3
- import { n as ChatSyncProvider, t as ChatInput } from "./chat-provider-DsNEBQiA.mjs";
1
+ import { f as ExternalLink } from "./folds-ZkHM4M-v.mjs";
2
+ import { C as channels, _ as withUserTokens, d as ResolvedTarget, f as SyncContext, h as UserTokens, o as ProviderPost, r as ProviderDelivery, y as SyncFn } from "./provider-DmSTp9lI.mjs";
3
+ import { n as ChatSyncProvider, t as ChatInput } from "./chat-provider-BWHbChcQ.mjs";
4
4
  import { z } from "zod";
5
5
  import { Adapter } from "chat";
6
6
  //#region src/sync/slack.d.ts
@@ -67,6 +67,31 @@ export type SlackClient = {
67
67
  delete(args: {
68
68
  file: string;
69
69
  }): Promise<unknown>;
70
+ /** Uploads files and shares them in one message, with `initial_comment` as its text; needs `files:write`. */
71
+ uploadV2?(args: {
72
+ channel_id: string;
73
+ initial_comment: string;
74
+ file_uploads: {
75
+ file: Buffer;
76
+ filename: string;
77
+ }[];
78
+ }): Promise<{
79
+ files?: {
80
+ files?: {
81
+ id?: string;
82
+ }[];
83
+ }[];
84
+ }>;
85
+ /** Where a file was shared (`shares`), to find the message an upload made; needs `files:read`. */
86
+ info?(args: {
87
+ file: string;
88
+ }): Promise<{
89
+ file?: {
90
+ shares?: Partial<Record<'public' | 'private', Record<string, {
91
+ ts?: string;
92
+ }[]>>>;
93
+ };
94
+ }>;
70
95
  };
71
96
  /** Posts an agent's message under its own name; needs `chat:write.customize` for `username` and `icon_url`. */
72
97
  chat?: {
@@ -296,6 +321,7 @@ export declare const SlackEvent: z.ZodDiscriminatedUnion<[z.ZodObject<{
296
321
  type: z.ZodString;
297
322
  title: z.ZodString;
298
323
  url: z.ZodNullable<z.ZodString>;
324
+ body: z.ZodOptional<z.ZodNullable<z.ZodString>>;
299
325
  labels: z.ZodRecord<z.ZodString, z.ZodString>;
300
326
  unset: z.ZodArray<z.ZodString>;
301
327
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -339,7 +365,7 @@ export type SlackOptions = {
339
365
  export declare function slack(app: string | SlackOptions, sync: SyncFn<SlackEvent>): SlackProvider;
340
366
  export declare class SlackProvider extends ChatSyncProvider<SlackEvent> {
341
367
  private readonly web;
342
- private customize;
368
+ private scopes;
343
369
  constructor(given: SlackOptions, sync: SyncFn<SlackEvent>);
344
370
  /**
345
371
  * An agent's message goes out under its own name (`clever-lemur via vboard-factory`), and its
@@ -347,11 +373,21 @@ export declare class SlackProvider extends ChatSyncProvider<SlackEvent> {
347
373
  * `**name**: ` label dropped. Without `chat:write.customize` it posts as the app, label and all.
348
374
  */
349
375
  post(target: string, kind: ResolvedTarget['kind'], body: ProviderPost): Promise<ProviderDelivery>;
376
+ /**
377
+ * A new thread whose root shows its files under its text, as one message: Slack makes
378
+ * that message from an upload's `initial_comment`, and names it only on the file, a
379
+ * moment later. A file upload can't set a username, so it goes out as the app, label and all.
380
+ * Null, before anything is sent, where the client can't, or the app can't read files
381
+ * (`files:read`) to find that message.
382
+ */
383
+ private postWithFiles;
350
384
  /** An edit keeps the label out only where the message went out under the agent's name. */
351
385
  edit(thread: string, message: string, body: ProviderPost): Promise<void>;
352
386
  private postNamed;
353
- /** Whether the app may set a message's username and icon (`chat:write.customize`), asked once. */
387
+ /** Whether the app may set a message's username and icon (`chat:write.customize`). */
354
388
  private canCustomize;
389
+ /** Whether the app has a scope, from its scopes, asked once. */
390
+ private hasScope;
355
391
  /** Whether a message Slack has went out under `name`. */
356
392
  private sentAs;
357
393
  /**
@@ -1,9 +1,9 @@
1
- import { m as MESSAGE_TEXT_MAX } from "./schemas-Be08nyfW.mjs";
2
- import "./external-BV_cUncs.mjs";
3
- import { i as UnreadableError, s as channels } from "./provider-poIkXapd.mjs";
4
- import { i as eventSchema, r as envelope } from "./events-CewD4kw7.mjs";
1
+ import { m as MESSAGE_TEXT_MAX } from "./schemas-DBmUO6pr.mjs";
2
+ import "./external-D3p5T57R.mjs";
3
+ import { i as UnreadableError, s as channels } from "./provider-BaVmi4Hi.mjs";
4
+ import { i as eventSchema, r as envelope } from "./events-qf5XG5VQ.mjs";
5
5
  import { r as withUserTokens, t as connectUserTokens } from "./users-yryHKFMo.mjs";
6
- import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-DnHibqnM.mjs";
6
+ import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-COtsU5jB.mjs";
7
7
  import { z } from "zod";
8
8
  import { stringifyMarkdown } from "chat";
9
9
  import { createSlackAdapter } from "@chat-adapter/slack";
@@ -220,6 +220,8 @@ const SlackEvent = eventSchema("slack", "message");
220
220
  function slack(app, sync) {
221
221
  return new SlackProvider(typeof app === "string" ? { adapter: app } : app, sync);
222
222
  }
223
+ /** How many times to look for the message an upload made, about 15 seconds in all. */
224
+ const SHARE_POLLS = 10;
223
225
  /**
224
226
  * Slack's client retries 10 times over about 30 minutes by default, which outlives any function:
225
227
  * here a call gets 3 more tries. Each still waits out Slack's `Retry-After` (about a minute at
@@ -250,7 +252,7 @@ function fromConnector(connector) {
250
252
  }
251
253
  var SlackProvider = class extends ChatSyncProvider {
252
254
  web;
253
- customize;
255
+ scopes;
254
256
  constructor(given, sync) {
255
257
  const connected = typeof given.adapter === "string" ? fromConnector(given.adapter) : null;
256
258
  const options = connected ? {
@@ -386,6 +388,7 @@ var SlackProvider = class extends ChatSyncProvider {
386
388
  platform: "slack",
387
389
  adapter: options.adapter,
388
390
  canStartThreads: true,
391
+ startsThreadsWithFiles: true,
389
392
  async uploadFiles(thread, files) {
390
393
  const sent = await options.adapter.postMessage(thread, {
391
394
  markdown: "",
@@ -681,8 +684,60 @@ var SlackProvider = class extends ChatSyncProvider {
681
684
  ...given,
682
685
  markdown: withoutHtml(given.markdown)
683
686
  };
687
+ if (kind === "channel" && post.files?.length && post.markdown.trim()) {
688
+ const sent = await this.postWithFiles(target, post.markdown, post.files);
689
+ if (sent) return sent;
690
+ const { files: _files, ...text } = post;
691
+ return (as ? await this.postNamed(target, as, text.markdown) : null) ?? super.post(target, kind, text);
692
+ }
684
693
  return (as && !post.files?.length ? await this.postNamed(target, as, post.markdown) : null) ?? super.post(target, kind, post);
685
694
  }
695
+ /**
696
+ * A new thread whose root shows its files under its text, as one message: Slack makes
697
+ * that message from an upload's `initial_comment`, and names it only on the file, a
698
+ * moment later. A file upload can't set a username, so it goes out as the app, label and all.
699
+ * Null, before anything is sent, where the client can't, or the app can't read files
700
+ * (`files:read`) to find that message.
701
+ */
702
+ async postWithFiles(target, markdown, files) {
703
+ const adapter = await this.ready();
704
+ const web = await this.web();
705
+ const toMrkdwn = adapter.formatConverter?.toResponseUrlText;
706
+ if (!web?.files?.uploadV2 || !web.files.info || !toMrkdwn || !await this.hasScope(web, "files:read")) return null;
707
+ const threadId = this.toChat(target);
708
+ const { channel } = adapter.decodeThreadId(threadId);
709
+ const message = { markdown };
710
+ const resolved = await adapter.resolveMessageMentions?.(message, threadId) ?? message;
711
+ const ids = ((await web.files.uploadV2({
712
+ channel_id: channel,
713
+ initial_comment: toMrkdwn.call(adapter.formatConverter, resolved),
714
+ file_uploads: files.map((file) => ({
715
+ file: Buffer.from(file.data),
716
+ filename: slackFileName(file.filename, file.mimeType)
717
+ }))
718
+ })).files?.[0]?.files ?? []).map((file) => file.id).filter((id) => !!id);
719
+ const found = async () => {
720
+ for (let attempt = 0; attempt < SHARE_POLLS; attempt++) {
721
+ const shares = (await web.files.info({ file: ids[0] })).file?.shares;
722
+ const ts = shares?.public?.[channel]?.[0]?.ts ?? shares?.private?.[channel]?.[0]?.ts;
723
+ if (ts) return ts;
724
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250 * 2 ** attempt, 2e3)));
725
+ }
726
+ throw new Error("never shared");
727
+ };
728
+ if (!ids[0]) throw new Error("slack: files.uploadV2 returned no file IDs");
729
+ let ts;
730
+ try {
731
+ ts = await found();
732
+ } catch (error) {
733
+ throw new Error(`slack: couldn't find the message uploading ${ids[0]} made in ${channel} (${slackError(error)})`);
734
+ }
735
+ return {
736
+ thread: this.toNative(`${adapter.name}:${channel}:${ts}`),
737
+ message: ts,
738
+ files: ids
739
+ };
740
+ }
686
741
  /** An edit keeps the label out only where the message went out under the agent's name. */
687
742
  async edit(thread, message, body) {
688
743
  const { as, ...given } = body;
@@ -720,14 +775,18 @@ var SlackProvider = class extends ChatSyncProvider {
720
775
  message: sent.ts
721
776
  };
722
777
  }
723
- /** Whether the app may set a message's username and icon (`chat:write.customize`), asked once. */
778
+ /** Whether the app may set a message's username and icon (`chat:write.customize`). */
724
779
  canCustomize(web) {
725
- this.customize ??= (async () => (await web.auth?.test())?.response_metadata?.scopes?.includes("chat:write.customize") ?? false)().catch((error) => {
726
- this.customize = void 0;
780
+ return this.hasScope(web, "chat:write.customize");
781
+ }
782
+ /** Whether the app has a scope, from its scopes, asked once. */
783
+ async hasScope(web, scope) {
784
+ this.scopes ??= (async () => (await web.auth?.test())?.response_metadata?.scopes ?? [])().catch((error) => {
785
+ this.scopes = void 0;
727
786
  console.warn(`[board] slack: couldn't check the app's scopes (${slackError(error)}); posting as the app`);
728
- return false;
787
+ return [];
729
788
  });
730
- return this.customize;
789
+ return (await this.scopes).includes(scope);
731
790
  }
732
791
  /** Whether a message Slack has went out under `name`. */
733
792
  async sentAs(thread, message, name) {
@@ -1,2 +1,2 @@
1
- import { i as externalAuthor, n as ChatSyncProvider, r as ChatTransport, t as ChatInput } from "./chat-provider-DsNEBQiA.mjs";
1
+ import { i as externalAuthor, n as ChatSyncProvider, r as ChatTransport, t as ChatInput } from "./chat-provider-BWHbChcQ.mjs";
2
2
  export { ChatInput, ChatSyncProvider, ChatTransport, externalAuthor };
@@ -1,2 +1,2 @@
1
- import { n as externalAuthor, t as ChatSyncProvider } from "./chat-provider-DnHibqnM.mjs";
1
+ import { n as externalAuthor, t as ChatSyncProvider } from "./chat-provider-COtsU5jB.mjs";
2
2
  export { ChatSyncProvider, externalAuthor };
package/dist/sync.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { d as ExternalFile, l as ExternalAuthor, u as ExternalEvent } from "./folds-jS9wlh9_.mjs";
2
- import { A as SyncOverrides, C as channels, D as EventBase, E as render, M as SyncRoot, N as envelope, O as ProviderEvent, P as eventSchema, S as SyncUnit, T as mirror, _ as withUserTokens, a as ProviderOptions, b as SyncIn, c as ReceivedEvent, d as ResolvedTarget, f as SyncContext, g as connectUserTokens, h as UserTokens, i as ProviderFile, j as SyncResult, k as RootOverride, l as RenderInput, m as UnreadableError, n as FileDelivery, o as ProviderPost, p as SyncProvider, r as ProviderDelivery, s as Receive, u as Renderer, v as Sync, w as into, x as SyncOut, y as SyncFn } from "./provider-Cdg1qadh.mjs";
1
+ import { d as ExternalFile, l as ExternalAuthor, u as ExternalEvent } from "./folds-ZkHM4M-v.mjs";
2
+ import { A as SyncOverrides, C as channels, D as EventBase, E as render, M as SyncRoot, N as envelope, O as ProviderEvent, P as eventSchema, S as SyncUnit, T as mirror, _ as withUserTokens, a as ProviderOptions, b as SyncIn, c as ReceivedEvent, d as ResolvedTarget, f as SyncContext, g as connectUserTokens, h as UserTokens, i as ProviderFile, j as SyncResult, k as RootOverride, l as RenderInput, m as UnreadableError, n as FileDelivery, o as ProviderPost, p as SyncProvider, r as ProviderDelivery, s as Receive, u as Renderer, v as Sync, w as into, x as SyncOut, y as SyncFn } from "./provider-DmSTp9lI.mjs";
3
3
  export { EventBase, type ExternalAuthor, ExternalEvent, type ExternalFile, type FileDelivery, type ProviderDelivery, type ProviderEvent, type ProviderFile, type ProviderOptions, type ProviderPost, type Receive, ReceivedEvent, type RenderInput, type Renderer, ResolvedTarget, type RootOverride, type Sync, type SyncContext, type SyncFn, type SyncIn, type SyncOut, SyncOverrides, SyncProvider, type SyncResult, type SyncRoot, type SyncUnit, UnreadableError, type UserTokens, channels, connectUserTokens, envelope, eventSchema, into, mirror, render, withUserTokens };
package/dist/sync.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { n as ExternalEvent } from "./external-BV_cUncs.mjs";
2
- import { c as into, i as UnreadableError, l as mirror, n as ResolvedTarget, r as SyncProvider, s as channels, t as ReceivedEvent, u as render } from "./provider-poIkXapd.mjs";
3
- import { i as eventSchema, n as SyncOverrides, r as envelope, t as EventBase } from "./events-CewD4kw7.mjs";
1
+ import { n as ExternalEvent } from "./external-D3p5T57R.mjs";
2
+ import { c as into, i as UnreadableError, l as mirror, n as ResolvedTarget, r as SyncProvider, s as channels, t as ReceivedEvent, u as render } from "./provider-BaVmi4Hi.mjs";
3
+ import { i as eventSchema, n as SyncOverrides, r as envelope, t as EventBase } from "./events-qf5XG5VQ.mjs";
4
4
  import { r as withUserTokens, t as connectUserTokens } from "./users-yryHKFMo.mjs";
5
5
  export { EventBase, ExternalEvent, ReceivedEvent, ResolvedTarget, SyncOverrides, SyncProvider, UnreadableError, channels, connectUserTokens, envelope, eventSchema, into, mirror, render, withUserTokens };
@@ -0,0 +1,14 @@
1
+ //#region src/sync/tags.ts
2
+ /** `good first issue` → `good-first-issue`: the part of a label key a name gives. Empty when nothing's left. */
3
+ const slug = (name) => name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^[-_.]+|-+$/g, "");
4
+ /**
5
+ * A platform's label or assignee as a board label: `bug` → `tag.bug=bug`,
6
+ * `julianbenegas` → `assignee.julianbenegas=julianbenegas`. The keys don't
7
+ * name the platform, so a filter like `--label tag.bug` holds for any of them.
8
+ */
9
+ function tagOf(prefix, name) {
10
+ const key = name ? slug(name) : "";
11
+ return key ? [`${prefix}.${key}`.slice(0, 64), name.slice(0, 256)] : null;
12
+ }
13
+ //#endregion
14
+ export { tagOf as n, slug as t };
@@ -1,4 +1,4 @@
1
- import { l as isThreadTarget } from "./external-BV_cUncs.mjs";
1
+ import { l as isThreadTarget } from "./external-D3p5T57R.mjs";
2
2
  import { n as replaceMentions, t as extractMentions } from "./mentions-CB6ShY4R.mjs";
3
3
  //#region src/server/targets.ts
4
4
  /** A target as a post or a receipt names it, resolved: a plain one's kind goes by its shape. */
@@ -1,5 +1,5 @@
1
1
  import { l as unauthorized } from "./errors-CusN4x2l.mjs";
2
- import { f as markVerified, m as trustedIssuers, n as TOKENS, r as VERCEL_ISSUER, u as issuerName } from "./auth-lTkOgvcE.mjs";
2
+ import { f as markVerified, m as trustedIssuers, n as TOKENS, r as VERCEL_ISSUER, u as issuerName } from "./auth-JO22iIwr.mjs";
3
3
  import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
4
4
  //#region src/server/identity.ts
5
5
  let fromDisk;
@@ -1,4 +1,4 @@
1
- import { a as ExternalCopy, c as SyncedThread, g as BoardEvent, o as Message } from "./folds-jS9wlh9_.mjs";
1
+ import { a as ExternalCopy, c as SyncedThread, g as BoardEvent, o as Message } from "./folds-ZkHM4M-v.mjs";
2
2
  import { t as A2Store } from "./store-D0MhBvg6-4Z2L0Kqd.mjs";
3
3
  //#region src/store/types.d.ts
4
4
  /**
@@ -1,8 +1,8 @@
1
1
  import { n as DIRECT_UPLOAD_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import { S as storable, a as AttachmentId, c as ChannelPath, d as LabelKey, g as MessageText, l as Emoji, m as MESSAGE_TEXT_MAX, n as ActorId, p as Labels, x as platformAvatar } from "./schemas-Be08nyfW.mjs";
3
- import { a as Link, i as ExternalTarget, n as ExternalEvent, o as Origin, t as ExternalAuthor } from "./external-BV_cUncs.mjs";
2
+ import { S as storable, a as AttachmentId, c as ChannelPath, d as LabelKey, g as MessageText, l as Emoji, m as MESSAGE_TEXT_MAX, n as ActorId, p as Labels, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
3
+ import { a as Link, i as ExternalTarget, n as ExternalEvent, o as Origin, t as ExternalAuthor } from "./external-D3p5T57R.mjs";
4
4
  import { r as badRequest, t as HttpError } from "./errors-CusN4x2l.mjs";
5
- import { o as options } from "./commands-BFookFtn.mjs";
5
+ import { o as options } from "./commands-C3R1AWgm.mjs";
6
6
  import { z } from "zod";
7
7
  //#region src/api/routes.ts
8
8
  /**
@@ -172,7 +172,9 @@ const AckBody = z.object({
172
172
  */
173
173
  const SourceReadQuery = z.object({
174
174
  target: z.string().min(1).max(4e3),
175
- limit: z.coerce.number().int().min(1).max(200).optional()
175
+ limit: z.coerce.number().int().min(1).max(200).optional(),
176
+ /** Where the last read left off: its `next`. */
177
+ after: z.string().min(1).max(400).optional()
176
178
  });
177
179
  /** A channel to add the board's app to, so it can be read on demand: `#name`, an ID, or a link into it. */
178
180
  const SourceJoinBody = z.object({ target: z.string().min(1).max(4e3) });
@@ -662,7 +664,7 @@ const routes = {
662
664
  const SYNC = "@sync";
663
665
  //#endregion
664
666
  //#region src/version.ts
665
- const VERSION = "0.28.0";
667
+ const VERSION = "0.29.0";
666
668
  //#endregion
667
669
  //#region src/server/routing.ts
668
670
  /** board()'s own routes: the files it serves, and sync's, which run where board() does. */
@@ -156,8 +156,12 @@ who can see it there:
156
156
  📎 [shot.png](https://files.slack.com/…) (7 B) didn't come over: this board keeps no files
157
157
  ```
158
158
 
159
- **To Slack:** the text posts first, then the files upload into the same thread as a
160
- message of their own (it needs `files:write`). A failed upload retries alone, and
159
+ **To Slack:** a new thread's root goes out as one message, its files under its text
160
+ (it needs `files:write`, and `files:read` to find the message the upload made;
161
+ without `files:read`, the files follow in a message of their own). A
162
+ file upload can't set a username, so that root goes out as the app, with the
163
+ agent's name in front. A reply's text posts first, then its files upload into the
164
+ same thread as a message of their own. A failed upload retries alone, and
161
165
  deleting the message on the board deletes the files in Slack. If Slack refuses
162
166
  files, the text is edited to add a line per file:
163
167
 
@@ -46,13 +46,16 @@ in the map stays off the board, except threads someone syncs by hand.
46
46
  ## An issue's life in one thread
47
47
 
48
48
  1. **An issue opens.** The board creates a root in the team's channel,
49
- `ENG-123: <title>` with its link, labeled `issue=ENG-123` and `state` with its
50
- workflow state's type.
49
+ `ENG-123: <title>` with its link and, under them, its description. It's
50
+ labeled `issue=ENG-123`, `state` with its workflow state's type, and the
51
+ issue's properties: `priority=high`, `project=<project>`, `tag.<label>` for
52
+ each Linear label, and `assignee.<handle>`.
51
53
  2. **Comments** arrive as replies. A reply to a comment stays in that comment's
52
54
  thread on Linear, and a board reply to one goes back into it. Edits and
53
55
  deletes sync too.
54
- 3. **Moving the issue** to another state updates `state`. A new title edits a
55
- root that Linear opened to say it. An issue that was open before the mirror
56
+ 3. **Moving the issue** to another state updates `state`, and changing a
57
+ property updates its label. A new title or description edits a root that
58
+ Linear opened to say it. An issue that was open before the mirror
56
59
  gets its root the first time a person edits or comments on it. Linear's own
57
60
  automations and archiving don't bring old issues in.
58
61
  4. **A root posted on the board** in a mirrored channel opens an issue in that
@@ -79,8 +82,8 @@ the board follows the team it's in now. The old thread stays as it was.
79
82
 
80
83
  | Event | What the mirror does |
81
84
  | --- | --- |
82
- | `issue.created` | A root labeled `issue=ENG-123` and `state=<type>` |
83
- | `issue.updated` | A new state sets `state`; a new title edits a root Linear opened. A person's edit to an issue the board hasn't seen opens its root, so older issues come in when they're next touched (a bulk edit brings in a whole view). Automations and archiving don't |
85
+ | `issue.created` | A root with the description, labeled `issue=ENG-123`, `state=<type>`, and the issue's properties |
86
+ | `issue.updated` | A new state sets `state`, and properties update their labels; a new title or description edits a root Linear opened. A person's edit to an issue the board hasn't seen opens its root, so older issues come in when they're next touched (a bulk edit brings in a whole view). Automations and archiving don't |
84
87
  | `comment` | A reply. Edits and deletes sync too |
85
88
 
86
89
  `state` is the workflow state's type: `triage`, `backlog`, `unstarted`,
@@ -96,6 +99,25 @@ A comment on an issue that opened before you mapped its team still lands: the
96
99
  board fetches the issue through Linear's API, creates its root, then posts the
97
100
  comment as a reply.
98
101
 
102
+ ## Read on demand
103
+
104
+ `vb sources <key> read` reads Linear without syncing anything, so an agent can
105
+ work through a project's issues without a thread for each one:
106
+
107
+ ```bash
108
+ vb sources linear read https://linear.app/acme/project/checkout-a1b2c3d4e5f6 # a project's issues
109
+ vb sources linear read ENG # a team's issues
110
+ vb sources linear read ENG-123 # one issue and its comments
111
+ ```
112
+
113
+ A project or team read lists issues newest first, each with its state,
114
+ priority, assignee, project, labels, link, and description (cut at 1,500
115
+ characters). An issue read shows the whole description, then its comments,
116
+ oldest first. A page holds at most 100 issues or comments: when there are more,
117
+ the read ends with the `--after` cursor that reads the next page. It reads with
118
+ the Linear app's token, so it needs `manage` in every channel, as every source
119
+ read does.
120
+
99
121
  ## Reactions
100
122
 
101
123
  A reaction on the board goes to its comment on Linear, while it's added: Linear