spur-board 0.6.1 → 0.7.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.
Files changed (2) hide show
  1. package/dist/daemon.mjs +128 -32
  2. package/package.json +1 -1
package/dist/daemon.mjs CHANGED
@@ -13250,7 +13250,7 @@ var GithubIssuesHttpError = class extends AdapterError {
13250
13250
  this.status = status;
13251
13251
  }
13252
13252
  };
13253
- function object$2(value) {
13253
+ function object$3(value) {
13254
13254
  if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
13255
13255
  throw new AdapterError("transient", "Malformed GitHub issue response");
13256
13256
  }
@@ -13268,14 +13268,14 @@ function issueNumber(ref, repo) {
13268
13268
  return number(Number(match[2]));
13269
13269
  }
13270
13270
  function normalize(value, trackerId, repo, inFilter) {
13271
- const issue = object$2(value);
13271
+ const issue = object$3(value);
13272
13272
  if (issue["pull_request"] !== void 0) throw new AdapterError("transient", "A pull request cannot be a tracker ticket");
13273
13273
  const updatedAt = new Date(string$3(issue["updated_at"]));
13274
13274
  if (Number.isNaN(updatedAt.getTime())) throw new AdapterError("transient", "Malformed GitHub issue timestamp");
13275
13275
  const labels = issue["labels"];
13276
13276
  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"]);
13277
+ const assignee = issue["assignee"] == null ? null : string$3(object$3(issue["assignee"])["login"]);
13278
+ const milestone = issue["milestone"] == null ? null : string$3(object$3(issue["milestone"])["title"]);
13279
13279
  return {
13280
13280
  trackerId,
13281
13281
  id: string$3(issue["node_id"]),
@@ -13284,7 +13284,7 @@ function normalize(value, trackerId, repo, inFilter) {
13284
13284
  status: string$3(issue["state"]),
13285
13285
  assignee,
13286
13286
  sprint: milestone,
13287
- labels: labels.map((label) => string$3(object$2(label)["name"])),
13287
+ labels: labels.map((label) => string$3(object$3(label)["name"])),
13288
13288
  inFilter,
13289
13289
  url: string$3(issue["html_url"]),
13290
13290
  updatedAt: updatedAt.toISOString()
@@ -13293,9 +13293,9 @@ function normalize(value, trackerId, repo, inFilter) {
13293
13293
  function matchesFilter(issue, tracker) {
13294
13294
  if (tracker.label !== void 0) {
13295
13295
  const labels = issue["labels"];
13296
- return Array.isArray(labels) && labels.some((label) => object$2(label)["name"] === tracker.label);
13296
+ return Array.isArray(labels) && labels.some((label) => object$3(label)["name"] === tracker.label);
13297
13297
  }
13298
- return issue["milestone"] != null && object$2(issue["milestone"])["title"] === tracker.milestone;
13298
+ return issue["milestone"] != null && object$3(issue["milestone"])["title"] === tracker.milestone;
13299
13299
  }
13300
13300
  /** Lists tracker issues and resolves manually included keys outside the filter. */
13301
13301
  function createGithubIssuesAdapter(options) {
@@ -13383,7 +13383,7 @@ function createGithubIssuesAdapter(options) {
13383
13383
  if (error instanceof GithubIssuesHttpError && error.status === 404) throw new AdapterError("config", "GitHub Issues repository is unavailable");
13384
13384
  throw error;
13385
13385
  }
13386
- const match = milestones.map(object$2).find((entry) => entry["title"] === options.tracker.milestone);
13386
+ const match = milestones.map(object$3).find((entry) => entry["title"] === options.tracker.milestone);
13387
13387
  if (match === void 0) throw new AdapterError("config", "Configured GitHub milestone is missing");
13388
13388
  return number(match["number"]);
13389
13389
  }
@@ -13411,7 +13411,7 @@ function createGithubIssuesAdapter(options) {
13411
13411
  }
13412
13412
  const facts = /* @__PURE__ */ new Map();
13413
13413
  for (const value of listed) {
13414
- const issue = object$2(value);
13414
+ const issue = object$3(value);
13415
13415
  if (issue["pull_request"] !== void 0) continue;
13416
13416
  const fact = normalize(issue, options.id, options.tracker.repo, true);
13417
13417
  facts.set(number(issue["number"]), fact);
@@ -13422,7 +13422,7 @@ function createGithubIssuesAdapter(options) {
13422
13422
  const issueNo = issueNumber(ref, options.tracker.repo);
13423
13423
  let fact = facts.get(issueNo);
13424
13424
  if (fact === void 0) try {
13425
- const detail = object$2((await request(`${root}/issues/${String(issueNo)}`, ctx.signal)).value);
13425
+ const detail = object$3((await request(`${root}/issues/${String(issueNo)}`, ctx.signal)).value);
13426
13426
  if (detail["pull_request"] !== void 0) {
13427
13427
  entities.push({
13428
13428
  ref,
@@ -13483,7 +13483,7 @@ var JiraHttpError = class extends AdapterError {
13483
13483
  this.status = status;
13484
13484
  }
13485
13485
  };
13486
- function object$1(value) {
13486
+ function object$2(value) {
13487
13487
  if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
13488
13488
  throw new AdapterError("transient", "Invalid Jira response");
13489
13489
  }
@@ -13543,10 +13543,10 @@ function sprintName(value) {
13543
13543
  return null;
13544
13544
  }
13545
13545
  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"]);
13546
+ const issue = object$2(value);
13547
+ const fields = object$2(issue["fields"]);
13548
+ const status = object$2(fields["status"]);
13549
+ const assignee = fields["assignee"] == null ? null : object$2(fields["assignee"]);
13550
13550
  const key = string$2(issue["key"]);
13551
13551
  const updated = string$2(fields["updated"]);
13552
13552
  const updatedAt = new Date(updated);
@@ -13608,14 +13608,14 @@ function createJiraAdapter(options) {
13608
13608
  if (error instanceof JiraHttpError && error.status === 404) throw new AdapterError("config", `Unknown Jira project key ${key}`);
13609
13609
  throw error;
13610
13610
  }
13611
- const names = new Set(array(result).flatMap((entry) => array(object$1(entry)["statuses"]).map((status) => string$2(object$1(status)["name"]))));
13611
+ const names = new Set(array(result).flatMap((entry) => array(object$2(entry)["statuses"]).map((status) => string$2(object$2(status)["name"]))));
13612
13612
  for (const name of [...tracker.doneStatuses, ...tracker.readyStatuses]) if (!names.has(name)) throw new AdapterError("config", `Unknown Jira status ${name} in project ${key}`);
13613
13613
  }
13614
13614
  }
13615
13615
  async function findSprintField(tracker, signal) {
13616
13616
  const fields = array(await request(tracker.baseUrl, "/rest/api/3/field", signal));
13617
13617
  for (const field of fields) {
13618
- const entry = object$1(field);
13618
+ const entry = object$2(field);
13619
13619
  const schema = entry["schema"];
13620
13620
  if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
13621
13621
  if (schema["custom"] === SPRINT_SCHEMA) return string$2(entry["id"]);
@@ -13650,7 +13650,7 @@ function createJiraAdapter(options) {
13650
13650
  ...token === void 0 ? {} : { nextPageToken: token }
13651
13651
  }).toString()}` : "/rest/api/3/search/jql";
13652
13652
  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));
13653
+ const result = object$2(await request(tracker.baseUrl, path, signal, options.searchMethod === "GET" ? void 0 : body));
13654
13654
  issues.push(...array(result["issues"]));
13655
13655
  const next = result["nextPageToken"];
13656
13656
  if (next == null || next === "") {
@@ -13672,7 +13672,7 @@ function createJiraAdapter(options) {
13672
13672
  async function fetchById(tracker, ref, signal, beforeRequest) {
13673
13673
  try {
13674
13674
  beforeRequest();
13675
- const issue = object$1(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.id)}`, signal));
13675
+ const issue = object$2(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.id)}`, signal));
13676
13676
  if (string$2(issue["id"]) !== ref.id) throw new AdapterError("transient", "Jira returned a different ticket id");
13677
13677
  return issue;
13678
13678
  } catch (error) {
@@ -13698,7 +13698,7 @@ function createJiraAdapter(options) {
13698
13698
  }
13699
13699
  async function fetchPending(tracker, ref, signal) {
13700
13700
  try {
13701
- const issue = object$1(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.key)}`, signal));
13701
+ const issue = object$2(await request(tracker.baseUrl, `/rest/api/3/issue/${encodeURIComponent(ref.key)}`, signal));
13702
13702
  if (string$2(issue["key"]) !== ref.key) throw new AdapterError("transient", "Jira returned a different ticket key");
13703
13703
  return {
13704
13704
  status: "ok",
@@ -15254,7 +15254,7 @@ async function* recoverThreads(client, request, botIdentity) {
15254
15254
  }
15255
15255
  //#endregion
15256
15256
  //#region ../gateway-slack/src/socket.ts
15257
- function object(value) {
15257
+ function object$1(value) {
15258
15258
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
15259
15259
  }
15260
15260
  function value(value) {
@@ -15287,11 +15287,11 @@ function createSocket(api, config, botIdentity) {
15287
15287
  async function handle(envelope) {
15288
15288
  if (stopping || sink === void 0) return;
15289
15289
  const type = value(envelope.type);
15290
- const body = object(envelope.body);
15290
+ const body = object$1(envelope.body);
15291
15291
  if (type === void 0 || body === null) return;
15292
15292
  let events = [];
15293
15293
  if (type === "events_api") {
15294
- const event = object(body.event);
15294
+ const event = object$1(body.event);
15295
15295
  const eventType = value(event?.type);
15296
15296
  const channel = value(event?.channel);
15297
15297
  if (event !== null && channel !== void 0 && (eventType === "message" || eventType === "app_mention")) {
@@ -15300,8 +15300,8 @@ function createSocket(api, config, botIdentity) {
15300
15300
  ...event,
15301
15301
  type: "message"
15302
15302
  } : event;
15303
- const nested = object(message.message);
15304
- const previous = object(message.previous_message);
15303
+ const nested = object$1(message.message);
15304
+ const previous = object$1(message.previous_message);
15305
15305
  const threadTs = value(message.thread_ts) ?? value(nested?.thread_ts) ?? value(previous?.thread_ts);
15306
15306
  const messageTs = value(message.ts) ?? value(nested?.ts);
15307
15307
  const deletedRoot = message.subtype === "message_deleted" && threadTs === value(message.deleted_ts);
@@ -15312,8 +15312,8 @@ function createSocket(api, config, botIdentity) {
15312
15312
  });
15313
15313
  }
15314
15314
  } else if (type === "interactive" || type === "block_actions") {
15315
- const actionMessage = object(body.message);
15316
- const channel = value(object(body.channel)?.id);
15315
+ const actionMessage = object$1(body.message);
15316
+ const channel = value(object$1(body.channel)?.id);
15317
15317
  const ts = value(actionMessage?.ts);
15318
15318
  const threadTs = value(actionMessage?.thread_ts) ?? ts;
15319
15319
  const root = channel !== void 0 && threadTs !== void 0 && threadTs !== ts ? await rootFor(api, channel, threadTs) : actionMessage;
@@ -15509,6 +15509,86 @@ function createBoardModules(ctxFor, options = {}) {
15509
15509
  //#endregion
15510
15510
  //#region ../daemon/src/doctor.ts
15511
15511
  const TIMEOUT_MS = 5e3;
15512
+ const GITHUB_FIX = "Check the GitHub token and repository access.";
15513
+ 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.";
15514
+ const GITHUB_TEAMS_FIX = "Use a classic GitHub personal access token with the repo and read:org scopes to read requested review teams.";
15515
+ const GITHUB_ORG_READ_SCOPES = /* @__PURE__ */ new Set([
15516
+ "read:org",
15517
+ "write:org",
15518
+ "admin:org"
15519
+ ]);
15520
+ const GITHUB_PROBE = `query DoctorGithubPermissions($owner: String!, $repo: String!) {
15521
+ repository(owner: $owner, name: $repo) {
15522
+ pullRequests(first: 10, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
15523
+ nodes {
15524
+ statusCheckRollup { contexts(first: 100) { nodes { ... on CheckRun { name } } } }
15525
+ reviewRequests(first: 100) { nodes { requestedReviewer { ... on Team { name } } } }
15526
+ }
15527
+ }
15528
+ defaultBranchRef { target { ... on Commit {
15529
+ statusCheckRollup { contexts(first: 100) { nodes { ... on CheckRun { name } } } }
15530
+ } } }
15531
+ }
15532
+ }`;
15533
+ function object(value) {
15534
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
15535
+ }
15536
+ function scopedGithubError(value) {
15537
+ const error = object(value);
15538
+ const path = error?.["path"];
15539
+ if (!Array.isArray(path)) return null;
15540
+ const fields = path.filter((part) => typeof part === "string");
15541
+ if (error?.["type"] === "FORBIDDEN" && fields.includes("statusCheckRollup") && fields.includes("contexts")) return GITHUB_CHECKS_FIX;
15542
+ if (error?.["type"] === "INSUFFICIENT_SCOPES" && fields.includes("reviewRequests") && fields.includes("requestedReviewer") && fields.includes("name")) return GITHUB_TEAMS_FIX;
15543
+ return null;
15544
+ }
15545
+ async function githubGraphql(ctx, fetcher, token, repo) {
15546
+ const [owner, name] = repo.split("/");
15547
+ if (!owner || !name) return null;
15548
+ const controller = new AbortController();
15549
+ const deadline = setTimeout(() => {
15550
+ controller.abort();
15551
+ }, TIMEOUT_MS);
15552
+ const aborted = new Promise((_resolve, reject) => {
15553
+ controller.signal.addEventListener("abort", () => {
15554
+ reject(/* @__PURE__ */ new Error("GitHub doctor probe timed out"));
15555
+ }, { once: true });
15556
+ });
15557
+ try {
15558
+ return await ctx.logCall("doctor.github", async () => {
15559
+ try {
15560
+ return await Promise.race([(async () => {
15561
+ const response = await fetcher("https://api.github.com/graphql", {
15562
+ method: "POST",
15563
+ headers: {
15564
+ Authorization: `Bearer ${token}`,
15565
+ Accept: "application/vnd.github+json",
15566
+ "Content-Type": "application/json"
15567
+ },
15568
+ body: JSON.stringify({
15569
+ query: GITHUB_PROBE,
15570
+ variables: {
15571
+ owner,
15572
+ repo: name
15573
+ }
15574
+ }),
15575
+ signal: controller.signal
15576
+ });
15577
+ return {
15578
+ response,
15579
+ body: response.ok ? await response.json() : null
15580
+ };
15581
+ })(), aborted]);
15582
+ } catch {
15583
+ throw new Error("GitHub doctor probe failed");
15584
+ }
15585
+ });
15586
+ } catch {
15587
+ return null;
15588
+ } finally {
15589
+ clearTimeout(deadline);
15590
+ }
15591
+ }
15512
15592
  function check(id, ok, fix) {
15513
15593
  return {
15514
15594
  id,
@@ -15578,13 +15658,29 @@ async function runDaemonDoctor(ctx, productVersion, fetcher = fetch) {
15578
15658
  checks.push(check("jira.credentials", jiraOk, "Check the Jira account, API token, and project access."));
15579
15659
  const githubToken = secrets["GITHUB_TOKEN"];
15580
15660
  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: {
15661
+ const githubFailure = (githubToken === void 0 ? [GITHUB_FIX] : await Promise.all([...githubRepos].map(async (repo) => {
15662
+ const response = await request(ctx, fetcher, "github", `https://api.github.com/repos/${repo}`, { headers: {
15583
15663
  Authorization: `Bearer ${githubToken}`,
15584
15664
  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."));
15665
+ } });
15666
+ if (response?.ok !== true) return GITHUB_FIX;
15667
+ const probe = await githubGraphql(ctx, fetcher, githubToken, repo);
15668
+ if (probe?.response.ok !== true) return GITHUB_FIX;
15669
+ const payload = object(probe.body);
15670
+ const errors = payload?.["errors"];
15671
+ if (errors !== void 0) {
15672
+ if (!Array.isArray(errors)) return GITHUB_FIX;
15673
+ const scoped = errors.map(scopedGithubError).find((fix) => fix !== null);
15674
+ if (scoped) return scoped;
15675
+ if (errors.length > 0) return GITHUB_FIX;
15676
+ }
15677
+ const pullRequests = object(object(object(payload?.["data"])?.["repository"])?.["pullRequests"]);
15678
+ if (!Array.isArray(pullRequests?.["nodes"])) return GITHUB_FIX;
15679
+ const scopes = response.headers.get("x-oauth-scopes");
15680
+ if (scopes !== null && !scopes.split(",").some((scope) => GITHUB_ORG_READ_SCOPES.has(scope.trim()))) return GITHUB_TEAMS_FIX;
15681
+ return null;
15682
+ }))).find((failure) => failure !== null) ?? null;
15683
+ checks.push(check("github.credentials", githubFailure === null, githubFailure ?? GITHUB_FIX));
15588
15684
  const spur = await request(ctx, fetcher, "spur", new URL("sessions", `${config.adapters.spur.baseUrl.replace(/\/$/, "")}/`));
15589
15685
  checks.push(check("spur.api", spur?.ok === true, "Start or correct the configured Spur API."));
15590
15686
  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."));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "^24.12.0 || ^26.0.0"