spur-board 0.7.0 → 0.8.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/dist/daemon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as taskKeySchema, B as SLACK_BOT_SCOPES, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as configSchema, S as prRefSchema, T as actorIdSchema, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES, z as requiredSecretKeys } from "./src-BFgTh55w.mjs";
1
+ import { A as taskKeySchema, B as requiredSecretKeys, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as RESERVED_PROJECT, S as prRefSchema, T as actorIdSchema, V as SLACK_BOT_SCOPES, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES, z as configSchema } from "./src-g7u1onIP.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { closeSync, existsSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, writeSync } from "node:fs";
@@ -7661,18 +7661,92 @@ async function scanTransaction(store, fn) {
7661
7661
  throw new Error("Gateway secret scan replay limit exceeded");
7662
7662
  }
7663
7663
  //#endregion
7664
+ //#region ../core/src/app/board-utils.ts
7665
+ function trackerSource(project) {
7666
+ return project.tracker.type === "jira" ? `jira:${project.id}` : `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
7667
+ }
7668
+ function projectForKey(config, key) {
7669
+ const lowerKey = key.toLowerCase();
7670
+ const matched = config.projects.filter((project) => {
7671
+ const tracker = project.tracker;
7672
+ if (tracker.type === "github-issues") {
7673
+ const prefix = `${tracker.repo.toLowerCase()}#`;
7674
+ const short = tracker.repo.split("/")[1];
7675
+ return lowerKey.startsWith(prefix) || short !== void 0 && lowerKey.startsWith(`${short.toLowerCase()}#`);
7676
+ }
7677
+ const prefix = /^([A-Za-z][A-Za-z0-9_]*)-/.exec(key)?.[1]?.toUpperCase();
7678
+ return prefix !== void 0 && (tracker.projectKeys.length === 0 || tracker.projectKeys.includes(prefix));
7679
+ });
7680
+ return matched.length === 1 ? matched[0] ?? null : null;
7681
+ }
7682
+ async function emit(trx, kind, project, taskId, at, payload, actor = "system") {
7683
+ const parsed = parseActorId(actor);
7684
+ if (parsed === null) throw new Error("Invalid journal actor");
7685
+ await trx.insertInto("event").values({
7686
+ at,
7687
+ project,
7688
+ task_id: taskId,
7689
+ kind,
7690
+ actor: parsed.actor,
7691
+ actor_ref: parsed.actor === "agent" ? JSON.stringify(parsed.actorRef) : null,
7692
+ payload: toJson(eventPayloadSchemas[kind], payload),
7693
+ v: 1,
7694
+ request_id: null
7695
+ }).execute();
7696
+ }
7697
+ function taskView(task) {
7698
+ return {
7699
+ key: task.key,
7700
+ project: task.project,
7701
+ column: task.column,
7702
+ membership: task.membership
7703
+ };
7704
+ }
7705
+ function linkView(link) {
7706
+ return {
7707
+ kind: link.entity,
7708
+ ref: link.entity === "pr" ? `${link.repo ?? ""}#${String(link.number ?? "")}` : link.entity === "session" ? link.session_id ?? "" : link.ticket_key ?? "",
7709
+ role: link.role
7710
+ };
7711
+ }
7712
+ //#endregion
7664
7713
  //#region ../core/src/app/denied-event.ts
7665
7714
  const actorRefSchema$2 = z.strictObject({
7666
7715
  project: z.string().min(1),
7667
7716
  session: z.string().min(1)
7668
7717
  });
7718
+ /**
7719
+ * The journal project of a denial with no resolved target, in order: the configured project
7720
+ * the input's `task` key or `project` names; else the only configured project listing the agent
7721
+ * actor's Spur project; else `RESERVED_PROJECT`. Never throws.
7722
+ */
7723
+ function deniedProject(config, actor, input) {
7724
+ if (typeof input === "object" && input !== null) {
7725
+ const hints = input;
7726
+ if (typeof hints.task === "string") {
7727
+ const project = projectForKey(config, hints.task);
7728
+ if (project !== null) return project.id;
7729
+ }
7730
+ if (typeof hints.project === "string") {
7731
+ const named = hints.project;
7732
+ if (config.projects.some((project) => project.id === named)) return named;
7733
+ }
7734
+ }
7735
+ const parsed = parseActorId(actor);
7736
+ if (parsed?.actor === "agent") {
7737
+ const owners = config.projects.filter((project) => project.spurProjects.some((spur) => spur.name === parsed.actorRef.project));
7738
+ if (owners.length === 1 && owners[0] !== void 0) return owners[0].id;
7739
+ }
7740
+ return RESERVED_PROJECT;
7741
+ }
7669
7742
  /** Persist an authorization refusal through the caller's transaction. */
7670
- async function writeDeniedEvent(trx, actor, operation, target, at) {
7743
+ async function writeDeniedEvent(trx, denial) {
7744
+ const { config, actor, operation, input, target, at } = denial;
7671
7745
  const parsed = parseActorId(actor);
7672
7746
  if (parsed === null) throw new Error("Invalid actor");
7673
7747
  await trx.insertInto("event").values({
7674
7748
  at,
7675
- project: target.kind === "found" ? target.value.project : "board",
7749
+ project: target.kind === "found" ? target.value.project : deniedProject(config, actor, input),
7676
7750
  task_id: target.kind === "found" ? target.value.task?.id ?? null : null,
7677
7751
  kind: "operation_denied",
7678
7752
  actor: parsed.actor,
@@ -7787,7 +7861,14 @@ function createApply(deps) {
7787
7861
  ...mode === void 0 ? {} : { mode }
7788
7862
  });
7789
7863
  if (decision.status === "denied") {
7790
- await writeDeniedEvent(trx, actor, entry.id, target, at);
7864
+ await writeDeniedEvent(trx, {
7865
+ config,
7866
+ actor,
7867
+ operation: entry.id,
7868
+ input: parsedInput,
7869
+ target,
7870
+ at
7871
+ });
7791
7872
  deniedHint = decision.hint;
7792
7873
  return;
7793
7874
  }
@@ -7849,7 +7930,14 @@ function createQuery(deps) {
7849
7930
  target
7850
7931
  });
7851
7932
  if (decision.status === "denied") {
7852
- await writeDeniedEvent(trx, actor, entry.id, target, new Date(clock.now()).toISOString());
7933
+ await writeDeniedEvent(trx, {
7934
+ config,
7935
+ actor,
7936
+ operation: entry.id,
7937
+ input: parsedInput,
7938
+ target,
7939
+ at: new Date(clock.now()).toISOString()
7940
+ });
7853
7941
  return {
7854
7942
  status: "denied",
7855
7943
  hint: decision.hint
@@ -8490,11 +8578,13 @@ const NO_FEED = /* @__PURE__ */ new Set([
8490
8578
  "answer_undelivered",
8491
8579
  "transport_health"
8492
8580
  ]);
8581
+ /** Display label of a feed line with no task; not part of the line's identity. */
8582
+ const TASKLESS_FEED_KEY = "spur-board";
8493
8583
  async function journal$2(trx, item, kind, at, rule) {
8494
8584
  const task = item.task_id === null ? void 0 : await trx.selectFrom("task").select(["project"]).where("id", "=", item.task_id).executeTakeFirstOrThrow();
8495
8585
  await trx.insertInto("event").values({
8496
8586
  at: at.toISOString(),
8497
- project: task?.project ?? "board",
8587
+ project: task?.project ?? "_board",
8498
8588
  task_id: item.task_id,
8499
8589
  kind,
8500
8590
  actor: "system",
@@ -8531,7 +8621,7 @@ async function raiseItem(trx, input) {
8531
8621
  }).returningAll().executeTakeFirstOrThrow();
8532
8622
  await journal$2(trx, item, "from_you_raised", input.at, input.rule);
8533
8623
  if (!NO_FEED.has(item.kind) && !input.suppressFeed) {
8534
- const key = (item.task_id === null ? null : await trx.selectFrom("task").select("key").where("id", "=", item.task_id).executeTakeFirstOrThrow())?.key ?? "board";
8624
+ const key = (item.task_id === null ? null : await trx.selectFrom("task").select("key").where("id", "=", item.task_id).executeTakeFirstOrThrow())?.key ?? TASKLESS_FEED_KEY;
8535
8625
  const line = await enqueueFeedLine(trx, {
8536
8626
  itemId: item.id,
8537
8627
  itemKey: item.item_key,
@@ -8580,7 +8670,7 @@ async function clearItem(trx, itemId, at) {
8580
8670
  itemKey: item.item_key,
8581
8671
  raise: item.raise_no,
8582
8672
  lineId: item.line_outbox_id,
8583
- key: task?.key ?? "board",
8673
+ key: task?.key ?? TASKLESS_FEED_KEY,
8584
8674
  taskId: item.task_id,
8585
8675
  at
8586
8676
  });
@@ -8595,7 +8685,7 @@ function effectiveStatus(row, at) {
8595
8685
  async function journalToken(trx, kind, name, at) {
8596
8686
  await trx.insertInto("event").values({
8597
8687
  at: at.toISOString(),
8598
- project: "board",
8688
+ project: RESERVED_PROJECT,
8599
8689
  task_id: null,
8600
8690
  kind,
8601
8691
  actor: kind === "token_approved" || kind === "token_denied" || kind === "token_revoked" ? "operator" : "system",
@@ -8821,12 +8911,15 @@ async function createApiServer(options) {
8821
8911
  trustProxy: false
8822
8912
  });
8823
8913
  const snapshot = await buildCatalogSnapshot(OPERATIONS);
8914
+ /** Lets clients notice a daemon restart and check its catalog again. */
8915
+ const instance = randomUUID();
8824
8916
  const callers = /* @__PURE__ */ new WeakMap();
8825
8917
  let doctorInFlight = null;
8826
8918
  let doctorCached = null;
8827
8919
  let doctorNextProbeAt = 0;
8828
8920
  app.addHook("onRequest", async (request, reply) => {
8829
8921
  reply.header("x-request-id", request.id);
8922
+ reply.header("x-board-instance", instance);
8830
8923
  if (request.headers.origin !== void 0) throw new CommandError("forbidden", "Browser origins are not accepted.");
8831
8924
  const address = app.server.address();
8832
8925
  const port = address !== null && typeof address === "object" ? address.port : options.port;
@@ -8912,7 +9005,7 @@ async function createApiServer(options) {
8912
9005
  const caller = callers.get(request);
8913
9006
  if (caller === void 0) throw new Error("Operation identity was not resolved.");
8914
9007
  const operation = OPERATIONS.find((entry) => entry.id === request.params.id);
8915
- if (operation === void 0) throw new CommandError("usage", "Unknown catalog operation.");
9008
+ if (operation === void 0) throw new CommandError("version_mismatch", "This daemon does not know the operation. Run `board service restart` to update the daemon, then retry.");
8916
9009
  const body = inputBody(request.body);
8917
9010
  const parsed = operation.input.safeParse(body.input);
8918
9011
  if (!parsed.success) throw new CommandError(errorCodeOf(parsed.error), "Fix the operation input.");
@@ -9530,55 +9623,6 @@ function createBlockMutations(config) {
9530
9623
  };
9531
9624
  }
9532
9625
  //#endregion
9533
- //#region ../core/src/app/board-utils.ts
9534
- function trackerSource(project) {
9535
- return project.tracker.type === "jira" ? `jira:${project.id}` : `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
9536
- }
9537
- function projectForKey(config, key) {
9538
- const lowerKey = key.toLowerCase();
9539
- const matched = config.projects.filter((project) => {
9540
- const tracker = project.tracker;
9541
- if (tracker.type === "github-issues") {
9542
- const prefix = `${tracker.repo.toLowerCase()}#`;
9543
- const short = tracker.repo.split("/")[1];
9544
- return lowerKey.startsWith(prefix) || short !== void 0 && lowerKey.startsWith(`${short.toLowerCase()}#`);
9545
- }
9546
- const prefix = /^([A-Za-z][A-Za-z0-9_]*)-/.exec(key)?.[1]?.toUpperCase();
9547
- return prefix !== void 0 && (tracker.projectKeys.length === 0 || tracker.projectKeys.includes(prefix));
9548
- });
9549
- return matched.length === 1 ? matched[0] ?? null : null;
9550
- }
9551
- async function emit(trx, kind, project, taskId, at, payload, actor = "system") {
9552
- const parsed = parseActorId(actor);
9553
- if (parsed === null) throw new Error("Invalid journal actor");
9554
- await trx.insertInto("event").values({
9555
- at,
9556
- project,
9557
- task_id: taskId,
9558
- kind,
9559
- actor: parsed.actor,
9560
- actor_ref: parsed.actor === "agent" ? JSON.stringify(parsed.actorRef) : null,
9561
- payload: toJson(eventPayloadSchemas[kind], payload),
9562
- v: 1,
9563
- request_id: null
9564
- }).execute();
9565
- }
9566
- function taskView(task) {
9567
- return {
9568
- key: task.key,
9569
- project: task.project,
9570
- column: task.column,
9571
- membership: task.membership
9572
- };
9573
- }
9574
- function linkView(link) {
9575
- return {
9576
- kind: link.entity,
9577
- ref: link.entity === "pr" ? `${link.repo ?? ""}#${String(link.number ?? "")}` : link.entity === "session" ? link.session_id ?? "" : link.ticket_key ?? "",
9578
- role: link.role
9579
- };
9580
- }
9581
- //#endregion
9582
9626
  //#region ../core/src/app/membership.ts
9583
9627
  function canonicalKey(config, key) {
9584
9628
  const parsed = parseRef$1(key, "task");
@@ -9988,6 +10032,7 @@ const EVENT_SUMMARIES = {
9988
10032
  doorbell_read: "Doorbell read",
9989
10033
  doorbell_undelivered: "Doorbell delivery exhausted",
9990
10034
  inbound_failed: "Inbound message failed",
10035
+ inbound_skipped: "Inbound message skipped",
9991
10036
  from_you_raised: "Operator item raised",
9992
10037
  from_you_cleared: "Operator item cleared",
9993
10038
  flag_raised: "Flag raised",
@@ -11100,7 +11145,7 @@ function createObservationCore(deps) {
11100
11145
  key,
11101
11146
  value: "true"
11102
11147
  }).execute();
11103
- await emit(trx, "spur_unknown_state", "board", null, new Date(clock.now()).toISOString(), {
11148
+ await emit(trx, "spur_unknown_state", RESERVED_PROJECT, null, new Date(clock.now()).toISOString(), {
11104
11149
  kind,
11105
11150
  value
11106
11151
  });
@@ -11343,18 +11388,12 @@ async function journal(trx, input) {
11343
11388
  async function taskContext(trx, event, gateway) {
11344
11389
  if (!("conversation" in event) || event.conversation === void 0) return {
11345
11390
  taskId: null,
11346
- project: "board",
11347
- taskKey: null
11391
+ project: null
11348
11392
  };
11349
- const row = await trx.selectFrom("conversation").leftJoin("task", "task.id", "conversation.task_id").select([
11350
- "conversation.task_id as taskId",
11351
- "task.project",
11352
- "task.key"
11353
- ]).where("conversation.gateway", "=", gateway).where("conversation.ref", "=", event.conversation).executeTakeFirst();
11393
+ const row = await trx.selectFrom("conversation").leftJoin("task", "task.id", "conversation.task_id").select(["conversation.task_id as taskId", "task.project"]).where("conversation.gateway", "=", gateway).where("conversation.ref", "=", event.conversation).executeTakeFirst();
11354
11394
  return {
11355
11395
  taskId: row?.taskId ?? null,
11356
- project: row?.project ?? "board",
11357
- taskKey: row?.key ?? null
11396
+ project: row?.project ?? null
11358
11397
  };
11359
11398
  }
11360
11399
  async function raiseInboundItem(trx, input) {
@@ -11370,6 +11409,10 @@ async function raiseInboundItem(trx, input) {
11370
11409
  if (input.retry && item.inbound_event_json === null) await trx.updateTable("operator_item").set({ inbound_event_json: toJson(inboundEventSchema, input.event) }).where("id", "=", item.id).execute();
11371
11410
  return item;
11372
11411
  }
11412
+ /** The operation input a parsed query command carries; legacy stored commands carry none. */
11413
+ function commandInput(event) {
11414
+ return event.kind === "command" && event.command?.kind === "query" ? event.command.input : void 0;
11415
+ }
11373
11416
  async function deny(trx, input) {
11374
11417
  const context = await taskContext(trx, input.event, input.gateway);
11375
11418
  await journal(trx, {
@@ -11382,7 +11425,7 @@ async function deny(trx, input) {
11382
11425
  },
11383
11426
  actor: input.actor,
11384
11427
  taskId: context.taskId,
11385
- project: context.project,
11428
+ project: context.project ?? deniedProject(input.config, input.actor, commandInput(input.event)),
11386
11429
  at: input.at
11387
11430
  });
11388
11431
  return input.privateResponse ? {
@@ -11390,6 +11433,24 @@ async function deny(trx, input) {
11390
11433
  response: "not allowed"
11391
11434
  } : { status: "ignored" };
11392
11435
  }
11436
+ /** Journal an inbound message the board does not act on; no gateway response. */
11437
+ async function skip(trx, input) {
11438
+ const context = await taskContext(trx, input.event, input.gateway);
11439
+ await journal(trx, {
11440
+ kind: "inbound_skipped",
11441
+ payload: {
11442
+ gateway: input.gateway,
11443
+ reason: input.reason,
11444
+ normalizedEvent: input.event,
11445
+ ..."author" in input.event ? { platformUser: input.event.author } : {}
11446
+ },
11447
+ actor: input.actor,
11448
+ taskId: context.taskId,
11449
+ project: context.project ?? "_board",
11450
+ at: input.at
11451
+ });
11452
+ return { status: "ignored" };
11453
+ }
11393
11454
  async function linkedComment(trx, event) {
11394
11455
  return trx.selectFrom("comment").selectAll().where("conversation_ref", "=", event.conversation).where("message_ref", "=", event.message).executeTakeFirst();
11395
11456
  }
@@ -11470,7 +11531,7 @@ function createInbound(input) {
11470
11531
  }
11471
11532
  async function processMessage(trx, event, at) {
11472
11533
  const conversation = await sourceContext(trx, event, at);
11473
- if (conversation?.block_id === null || conversation === void 0) return deny(trx, {
11534
+ if (conversation?.block_id === null || conversation === void 0) return skip(trx, {
11474
11535
  event,
11475
11536
  reason: "Unknown board conversation.",
11476
11537
  actor: "operator",
@@ -11498,7 +11559,7 @@ function createInbound(input) {
11498
11559
  return { status: "applied" };
11499
11560
  }
11500
11561
  const comment = await linkedComment(trx, event);
11501
- if (comment === void 0 || comment.author !== "operator") return deny(trx, {
11562
+ if (comment === void 0 || comment.author !== "operator") return skip(trx, {
11502
11563
  event,
11503
11564
  reason: "Original operator comment not found.",
11504
11565
  actor: "system",
@@ -11547,6 +11608,7 @@ function createInbound(input) {
11547
11608
  }
11548
11609
  async function runUser(trx, event, at) {
11549
11610
  if (event.kind === "command" && event.name !== void 0 && event.name !== config.slack.command) return deny(trx, {
11611
+ config,
11550
11612
  event,
11551
11613
  reason: "Command name does not match the configured command.",
11552
11614
  actor: "system",
@@ -11554,6 +11616,7 @@ function createInbound(input) {
11554
11616
  at
11555
11617
  });
11556
11618
  if (event.kind !== "message_deleted" && event.author !== config.operator.slack) return deny(trx, {
11619
+ config,
11557
11620
  event,
11558
11621
  reason: "Only the configured operator may act.",
11559
11622
  actor: "system",
@@ -11563,6 +11626,7 @@ function createInbound(input) {
11563
11626
  });
11564
11627
  if (event.kind === "action" || event.kind === "command") {
11565
11628
  if (!(event.kind === "action" ? event.sourceSpace !== null : true)) return deny(trx, {
11629
+ config,
11566
11630
  event,
11567
11631
  reason: "Interaction came from outside a board space.",
11568
11632
  actor: "operator",
@@ -11663,7 +11727,7 @@ function createInbound(input) {
11663
11727
  },
11664
11728
  actor: "system",
11665
11729
  taskId: context.taskId,
11666
- project: context.project,
11730
+ project: context.project ?? "_board",
11667
11731
  at
11668
11732
  });
11669
11733
  await raiseInboundItem(trx, {
@@ -13250,7 +13314,7 @@ var GithubIssuesHttpError = class extends AdapterError {
13250
13314
  this.status = status;
13251
13315
  }
13252
13316
  };
13253
- function object$2(value) {
13317
+ function object$3(value) {
13254
13318
  if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
13255
13319
  throw new AdapterError("transient", "Malformed GitHub issue response");
13256
13320
  }
@@ -13268,14 +13332,14 @@ function issueNumber(ref, repo) {
13268
13332
  return number(Number(match[2]));
13269
13333
  }
13270
13334
  function normalize(value, trackerId, repo, inFilter) {
13271
- const issue = object$2(value);
13335
+ const issue = object$3(value);
13272
13336
  if (issue["pull_request"] !== void 0) throw new AdapterError("transient", "A pull request cannot be a tracker ticket");
13273
13337
  const updatedAt = new Date(string$3(issue["updated_at"]));
13274
13338
  if (Number.isNaN(updatedAt.getTime())) throw new AdapterError("transient", "Malformed GitHub issue timestamp");
13275
13339
  const labels = issue["labels"];
13276
13340
  if (!Array.isArray(labels)) throw new AdapterError("transient", "Malformed GitHub issue labels");
13277
- const assignee = issue["assignee"] == null ? null : string$3(object$2(issue["assignee"])["login"]);
13278
- const milestone = issue["milestone"] == null ? null : string$3(object$2(issue["milestone"])["title"]);
13341
+ const assignee = issue["assignee"] == null ? null : string$3(object$3(issue["assignee"])["login"]);
13342
+ const milestone = issue["milestone"] == null ? null : string$3(object$3(issue["milestone"])["title"]);
13279
13343
  return {
13280
13344
  trackerId,
13281
13345
  id: string$3(issue["node_id"]),
@@ -13284,7 +13348,7 @@ function normalize(value, trackerId, repo, inFilter) {
13284
13348
  status: string$3(issue["state"]),
13285
13349
  assignee,
13286
13350
  sprint: milestone,
13287
- labels: labels.map((label) => string$3(object$2(label)["name"])),
13351
+ labels: labels.map((label) => string$3(object$3(label)["name"])),
13288
13352
  inFilter,
13289
13353
  url: string$3(issue["html_url"]),
13290
13354
  updatedAt: updatedAt.toISOString()
@@ -13293,9 +13357,9 @@ function normalize(value, trackerId, repo, inFilter) {
13293
13357
  function matchesFilter(issue, tracker) {
13294
13358
  if (tracker.label !== void 0) {
13295
13359
  const labels = issue["labels"];
13296
- return Array.isArray(labels) && labels.some((label) => object$2(label)["name"] === tracker.label);
13360
+ return Array.isArray(labels) && labels.some((label) => object$3(label)["name"] === tracker.label);
13297
13361
  }
13298
- return issue["milestone"] != null && object$2(issue["milestone"])["title"] === tracker.milestone;
13362
+ return issue["milestone"] != null && object$3(issue["milestone"])["title"] === tracker.milestone;
13299
13363
  }
13300
13364
  /** Lists tracker issues and resolves manually included keys outside the filter. */
13301
13365
  function createGithubIssuesAdapter(options) {
@@ -13383,7 +13447,7 @@ function createGithubIssuesAdapter(options) {
13383
13447
  if (error instanceof GithubIssuesHttpError && error.status === 404) throw new AdapterError("config", "GitHub Issues repository is unavailable");
13384
13448
  throw error;
13385
13449
  }
13386
- const match = milestones.map(object$2).find((entry) => entry["title"] === options.tracker.milestone);
13450
+ const match = milestones.map(object$3).find((entry) => entry["title"] === options.tracker.milestone);
13387
13451
  if (match === void 0) throw new AdapterError("config", "Configured GitHub milestone is missing");
13388
13452
  return number(match["number"]);
13389
13453
  }
@@ -13411,7 +13475,7 @@ function createGithubIssuesAdapter(options) {
13411
13475
  }
13412
13476
  const facts = /* @__PURE__ */ new Map();
13413
13477
  for (const value of listed) {
13414
- const issue = object$2(value);
13478
+ const issue = object$3(value);
13415
13479
  if (issue["pull_request"] !== void 0) continue;
13416
13480
  const fact = normalize(issue, options.id, options.tracker.repo, true);
13417
13481
  facts.set(number(issue["number"]), fact);
@@ -13422,7 +13486,7 @@ function createGithubIssuesAdapter(options) {
13422
13486
  const issueNo = issueNumber(ref, options.tracker.repo);
13423
13487
  let fact = facts.get(issueNo);
13424
13488
  if (fact === void 0) try {
13425
- const detail = object$2((await request(`${root}/issues/${String(issueNo)}`, ctx.signal)).value);
13489
+ const detail = object$3((await request(`${root}/issues/${String(issueNo)}`, ctx.signal)).value);
13426
13490
  if (detail["pull_request"] !== void 0) {
13427
13491
  entities.push({
13428
13492
  ref,
@@ -13483,7 +13547,7 @@ var JiraHttpError = class extends AdapterError {
13483
13547
  this.status = status;
13484
13548
  }
13485
13549
  };
13486
- function object$1(value) {
13550
+ function object$2(value) {
13487
13551
  if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
13488
13552
  throw new AdapterError("transient", "Invalid Jira response");
13489
13553
  }
@@ -13543,10 +13607,10 @@ function sprintName(value) {
13543
13607
  return null;
13544
13608
  }
13545
13609
  function issueFacts(value, trackerId, baseUrl, sprintField, inFilter) {
13546
- const issue = object$1(value);
13547
- const fields = object$1(issue["fields"]);
13548
- const status = object$1(fields["status"]);
13549
- const assignee = fields["assignee"] == null ? null : object$1(fields["assignee"]);
13610
+ const issue = object$2(value);
13611
+ const fields = object$2(issue["fields"]);
13612
+ const status = object$2(fields["status"]);
13613
+ const assignee = fields["assignee"] == null ? null : object$2(fields["assignee"]);
13550
13614
  const key = string$2(issue["key"]);
13551
13615
  const updated = string$2(fields["updated"]);
13552
13616
  const updatedAt = new Date(updated);
@@ -13608,14 +13672,14 @@ function createJiraAdapter(options) {
13608
13672
  if (error instanceof JiraHttpError && error.status === 404) throw new AdapterError("config", `Unknown Jira project key ${key}`);
13609
13673
  throw error;
13610
13674
  }
13611
- const names = new Set(array(result).flatMap((entry) => array(object$1(entry)["statuses"]).map((status) => string$2(object$1(status)["name"]))));
13675
+ const names = new Set(array(result).flatMap((entry) => array(object$2(entry)["statuses"]).map((status) => string$2(object$2(status)["name"]))));
13612
13676
  for (const name of [...tracker.doneStatuses, ...tracker.readyStatuses]) if (!names.has(name)) throw new AdapterError("config", `Unknown Jira status ${name} in project ${key}`);
13613
13677
  }
13614
13678
  }
13615
13679
  async function findSprintField(tracker, signal) {
13616
13680
  const fields = array(await request(tracker.baseUrl, "/rest/api/3/field", signal));
13617
13681
  for (const field of fields) {
13618
- const entry = object$1(field);
13682
+ const entry = object$2(field);
13619
13683
  const schema = entry["schema"];
13620
13684
  if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
13621
13685
  if (schema["custom"] === SPRINT_SCHEMA) return string$2(entry["id"]);
@@ -13650,7 +13714,7 @@ function createJiraAdapter(options) {
13650
13714
  ...token === void 0 ? {} : { nextPageToken: token }
13651
13715
  }).toString()}` : "/rest/api/3/search/jql";
13652
13716
  if (options.searchMethod === "GET" && new URL(path, tracker.baseUrl).href.length > MAX_GET_SEARCH_URL_LENGTH) throw new AdapterError("config", `Jira GET search URL is too long (over ${String(MAX_GET_SEARCH_URL_LENGTH)} characters)`);
13653
- const result = object$1(await request(tracker.baseUrl, path, signal, options.searchMethod === "GET" ? void 0 : body));
13717
+ const result = object$2(await request(tracker.baseUrl, path, signal, options.searchMethod === "GET" ? void 0 : body));
13654
13718
  issues.push(...array(result["issues"]));
13655
13719
  const next = result["nextPageToken"];
13656
13720
  if (next == null || next === "") {
@@ -13672,7 +13736,7 @@ function createJiraAdapter(options) {
13672
13736
  async function fetchById(tracker, ref, signal, beforeRequest) {
13673
13737
  try {
13674
13738
  beforeRequest();
13675
- const issue = object$1(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.id)}`, signal));
13739
+ const issue = object$2(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.id)}`, signal));
13676
13740
  if (string$2(issue["id"]) !== ref.id) throw new AdapterError("transient", "Jira returned a different ticket id");
13677
13741
  return issue;
13678
13742
  } catch (error) {
@@ -13698,7 +13762,7 @@ function createJiraAdapter(options) {
13698
13762
  }
13699
13763
  async function fetchPending(tracker, ref, signal) {
13700
13764
  try {
13701
- const issue = object$1(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.key)}`, signal));
13765
+ const issue = object$2(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.key)}`, signal));
13702
13766
  if (string$2(issue["key"]) !== ref.key) throw new AdapterError("transient", "Jira returned a different ticket key");
13703
13767
  return {
13704
13768
  status: "ok",
@@ -15254,7 +15318,7 @@ async function* recoverThreads(client, request, botIdentity) {
15254
15318
  }
15255
15319
  //#endregion
15256
15320
  //#region ../gateway-slack/src/socket.ts
15257
- function object(value) {
15321
+ function object$1(value) {
15258
15322
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
15259
15323
  }
15260
15324
  function value(value) {
@@ -15287,11 +15351,11 @@ function createSocket(api, config, botIdentity) {
15287
15351
  async function handle(envelope) {
15288
15352
  if (stopping || sink === void 0) return;
15289
15353
  const type = value(envelope.type);
15290
- const body = object(envelope.body);
15354
+ const body = object$1(envelope.body);
15291
15355
  if (type === void 0 || body === null) return;
15292
15356
  let events = [];
15293
15357
  if (type === "events_api") {
15294
- const event = object(body.event);
15358
+ const event = object$1(body.event);
15295
15359
  const eventType = value(event?.type);
15296
15360
  const channel = value(event?.channel);
15297
15361
  if (event !== null && channel !== void 0 && (eventType === "message" || eventType === "app_mention")) {
@@ -15300,8 +15364,8 @@ function createSocket(api, config, botIdentity) {
15300
15364
  ...event,
15301
15365
  type: "message"
15302
15366
  } : event;
15303
- const nested = object(message.message);
15304
- const previous = object(message.previous_message);
15367
+ const nested = object$1(message.message);
15368
+ const previous = object$1(message.previous_message);
15305
15369
  const threadTs = value(message.thread_ts) ?? value(nested?.thread_ts) ?? value(previous?.thread_ts);
15306
15370
  const messageTs = value(message.ts) ?? value(nested?.ts);
15307
15371
  const deletedRoot = message.subtype === "message_deleted" && threadTs === value(message.deleted_ts);
@@ -15312,8 +15376,8 @@ function createSocket(api, config, botIdentity) {
15312
15376
  });
15313
15377
  }
15314
15378
  } else if (type === "interactive" || type === "block_actions") {
15315
- const actionMessage = object(body.message);
15316
- const channel = value(object(body.channel)?.id);
15379
+ const actionMessage = object$1(body.message);
15380
+ const channel = value(object$1(body.channel)?.id);
15317
15381
  const ts = value(actionMessage?.ts);
15318
15382
  const threadTs = value(actionMessage?.thread_ts) ?? ts;
15319
15383
  const root = channel !== void 0 && threadTs !== void 0 && threadTs !== ts ? await rootFor(api, channel, threadTs) : actionMessage;
@@ -15509,6 +15573,86 @@ function createBoardModules(ctxFor, options = {}) {
15509
15573
  //#endregion
15510
15574
  //#region ../daemon/src/doctor.ts
15511
15575
  const TIMEOUT_MS = 5e3;
15576
+ const GITHUB_FIX = "Check the GitHub token and repository access.";
15577
+ const GITHUB_CHECKS_FIX = "Use a classic GitHub personal access token with the repo scope, authorized for organization SSO where required, to read check runs.";
15578
+ const GITHUB_TEAMS_FIX = "Use a classic GitHub personal access token with the repo and read:org scopes to read requested review teams.";
15579
+ const GITHUB_ORG_READ_SCOPES = /* @__PURE__ */ new Set([
15580
+ "read:org",
15581
+ "write:org",
15582
+ "admin:org"
15583
+ ]);
15584
+ const GITHUB_PROBE = `query DoctorGithubPermissions($owner: String!, $repo: String!) {
15585
+ repository(owner: $owner, name: $repo) {
15586
+ pullRequests(first: 10, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
15587
+ nodes {
15588
+ statusCheckRollup { contexts(first: 100) { nodes { ... on CheckRun { name } } } }
15589
+ reviewRequests(first: 100) { nodes { requestedReviewer { ... on Team { name } } } }
15590
+ }
15591
+ }
15592
+ defaultBranchRef { target { ... on Commit {
15593
+ statusCheckRollup { contexts(first: 100) { nodes { ... on CheckRun { name } } } }
15594
+ } } }
15595
+ }
15596
+ }`;
15597
+ function object(value) {
15598
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
15599
+ }
15600
+ function scopedGithubError(value) {
15601
+ const error = object(value);
15602
+ const path = error?.["path"];
15603
+ if (!Array.isArray(path)) return null;
15604
+ const fields = path.filter((part) => typeof part === "string");
15605
+ if (error?.["type"] === "FORBIDDEN" && fields.includes("statusCheckRollup") && fields.includes("contexts")) return GITHUB_CHECKS_FIX;
15606
+ if (error?.["type"] === "INSUFFICIENT_SCOPES" && fields.includes("reviewRequests") && fields.includes("requestedReviewer") && fields.includes("name")) return GITHUB_TEAMS_FIX;
15607
+ return null;
15608
+ }
15609
+ async function githubGraphql(ctx, fetcher, token, repo) {
15610
+ const [owner, name] = repo.split("/");
15611
+ if (!owner || !name) return null;
15612
+ const controller = new AbortController();
15613
+ const deadline = setTimeout(() => {
15614
+ controller.abort();
15615
+ }, TIMEOUT_MS);
15616
+ const aborted = new Promise((_resolve, reject) => {
15617
+ controller.signal.addEventListener("abort", () => {
15618
+ reject(/* @__PURE__ */ new Error("GitHub doctor probe timed out"));
15619
+ }, { once: true });
15620
+ });
15621
+ try {
15622
+ return await ctx.logCall("doctor.github", async () => {
15623
+ try {
15624
+ return await Promise.race([(async () => {
15625
+ const response = await fetcher("https://api.github.com/graphql", {
15626
+ method: "POST",
15627
+ headers: {
15628
+ Authorization: `Bearer ${token}`,
15629
+ Accept: "application/vnd.github+json",
15630
+ "Content-Type": "application/json"
15631
+ },
15632
+ body: JSON.stringify({
15633
+ query: GITHUB_PROBE,
15634
+ variables: {
15635
+ owner,
15636
+ repo: name
15637
+ }
15638
+ }),
15639
+ signal: controller.signal
15640
+ });
15641
+ return {
15642
+ response,
15643
+ body: response.ok ? await response.json() : null
15644
+ };
15645
+ })(), aborted]);
15646
+ } catch {
15647
+ throw new Error("GitHub doctor probe failed");
15648
+ }
15649
+ });
15650
+ } catch {
15651
+ return null;
15652
+ } finally {
15653
+ clearTimeout(deadline);
15654
+ }
15655
+ }
15512
15656
  function check(id, ok, fix) {
15513
15657
  return {
15514
15658
  id,
@@ -15578,13 +15722,29 @@ async function runDaemonDoctor(ctx, productVersion, fetcher = fetch) {
15578
15722
  checks.push(check("jira.credentials", jiraOk, "Check the Jira account, API token, and project access."));
15579
15723
  const githubToken = secrets["GITHUB_TOKEN"];
15580
15724
  const githubRepos = new Set(config.projects.flatMap((project) => project.repos));
15581
- const githubOk = githubToken !== void 0 && (await Promise.all([...githubRepos].map(async (repo) => {
15582
- return (await request(ctx, fetcher, "github", `https://api.github.com/repos/${repo}`, { headers: {
15725
+ const githubFailure = (githubToken === void 0 ? [GITHUB_FIX] : await Promise.all([...githubRepos].map(async (repo) => {
15726
+ const response = await request(ctx, fetcher, "github", `https://api.github.com/repos/${repo}`, { headers: {
15583
15727
  Authorization: `Bearer ${githubToken}`,
15584
15728
  Accept: "application/vnd.github+json"
15585
- } }))?.ok === true;
15586
- }))).every(Boolean);
15587
- checks.push(check("github.credentials", githubOk, "Check the GitHub token and repository access."));
15729
+ } });
15730
+ if (response?.ok !== true) return GITHUB_FIX;
15731
+ const probe = await githubGraphql(ctx, fetcher, githubToken, repo);
15732
+ if (probe?.response.ok !== true) return GITHUB_FIX;
15733
+ const payload = object(probe.body);
15734
+ const errors = payload?.["errors"];
15735
+ if (errors !== void 0) {
15736
+ if (!Array.isArray(errors)) return GITHUB_FIX;
15737
+ const scoped = errors.map(scopedGithubError).find((fix) => fix !== null);
15738
+ if (scoped) return scoped;
15739
+ if (errors.length > 0) return GITHUB_FIX;
15740
+ }
15741
+ const pullRequests = object(object(object(payload?.["data"])?.["repository"])?.["pullRequests"]);
15742
+ if (!Array.isArray(pullRequests?.["nodes"])) return GITHUB_FIX;
15743
+ const scopes = response.headers.get("x-oauth-scopes");
15744
+ if (scopes !== null && !scopes.split(",").some((scope) => GITHUB_ORG_READ_SCOPES.has(scope.trim()))) return GITHUB_TEAMS_FIX;
15745
+ return null;
15746
+ }))).find((failure) => failure !== null) ?? null;
15747
+ checks.push(check("github.credentials", githubFailure === null, githubFailure ?? GITHUB_FIX));
15588
15748
  const spur = await request(ctx, fetcher, "spur", new URL("sessions", `${config.adapters.spur.baseUrl.replace(/\/$/, "")}/`));
15589
15749
  checks.push(check("spur.api", spur?.ok === true, "Start or correct the configured Spur API."));
15590
15750
  if (!slackConfigured) checks.push(warning("slack.configured", "Slack is not configured: questions addressed to you are only visible through board inbox. Run the spur-board-setup skill to configure slack.channels."));