spur-board 0.5.0 → 0.6.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.
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { R as configSchema, s as OPERATIONS, z as SLACK_BOT_SCOPES } from "./src-VWyX7Nwm.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-BnkZmz1i.mjs";
3
- import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-UyXnaekw.mjs";
1
+ import { B as SLACK_BOT_SCOPES, R as configSchema, s as OPERATIONS, z as requiredSecretKeys } from "./src-Cy74ZQfR.mjs";
2
+ import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-Dw9ZovyI.mjs";
3
+ import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-CeDZQlvy.mjs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir, hostname, userInfo } from "node:os";
@@ -190,12 +190,7 @@ function localSecrets(config, path, ports) {
190
190
  } catch {
191
191
  names = {};
192
192
  }
193
- const slackConfigured = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
194
- const required = [
195
- "GITHUB_TOKEN",
196
- ...config.projects.some((project) => project.tracker.type === "jira") ? ["JIRA_EMAIL", "JIRA_API_TOKEN"] : [],
197
- ...slackConfigured ? ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] : []
198
- ];
193
+ const required = requiredSecretKeys(config);
199
194
  const missing = required.filter((name) => !names[name]);
200
195
  return {
201
196
  ...result("secrets", missing.length === 0, `Add ${missing.join(", ")} to the private secrets.env file.`),
@@ -1,4 +1,4 @@
1
- import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-VWyX7Nwm.mjs";
1
+ import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-Cy74ZQfR.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { request } from "node:http";
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as requestTokenEnrollment, t as requestOperation } from "./client-BnkZmz1i.mjs";
1
+ import { n as requestTokenEnrollment, t as requestOperation } from "./client-Dw9ZovyI.mjs";
2
2
  export { requestOperation, requestTokenEnrollment };
@@ -1,5 +1,5 @@
1
- import { R as configSchema } from "./src-VWyX7Nwm.mjs";
2
- import { r as CliError } from "./client-BnkZmz1i.mjs";
1
+ import { R as configSchema } from "./src-Cy74ZQfR.mjs";
2
+ import { r as CliError } from "./client-Dw9ZovyI.mjs";
3
3
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
package/dist/daemon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as taskKeySchema, 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 SLACK_BOT_SCOPES } from "./src-VWyX7Nwm.mjs";
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-Cy74ZQfR.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";
@@ -12736,6 +12736,11 @@ function retryAt(headers, now) {
12736
12736
  }
12737
12737
  //#endregion
12738
12738
  //#region ../adapters/src/github/client.ts
12739
+ function teamNameScopeDenied(error, query) {
12740
+ if (error.type !== "INSUFFICIENT_SCOPES" || !query.includes("... on Team { name }")) return false;
12741
+ if (error.path !== void 0) return error.path.includes("reviewRequests") && error.path.includes("requestedReviewer") && error.path.at(-1) === "name";
12742
+ return /\bname\b/i.test(error.message) && /\bread:(?:org|discussion)\b/i.test(error.message);
12743
+ }
12739
12744
  function record$2(value) {
12740
12745
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new AdapterError("transient", "Malformed GitHub response");
12741
12746
  return value;
@@ -12824,6 +12829,8 @@ var GithubClient = class {
12824
12829
  status: 429,
12825
12830
  headers: response.headers
12826
12831
  }, Date.now());
12832
+ if (errors.some((error) => teamNameScopeDenied(error, query))) throw new AdapterError("auth", "GitHub token needs read:org for team review requests");
12833
+ if (errors.some((error) => error.type === "INSUFFICIENT_SCOPES")) throw new AdapterError("auth", "GitHub token lacks a required GraphQL read scope");
12827
12834
  const data = record$2(wire.data);
12828
12835
  const rate = record$2(data.rateLimit);
12829
12836
  const remaining = rate.remaining;
@@ -13056,6 +13063,12 @@ function githubSources(config, options) {
13056
13063
  function scoped(errors, root) {
13057
13064
  return errors.filter((error) => error.path?.[0] === root);
13058
13065
  }
13066
+ function checkAccessDenied(errors) {
13067
+ return errors.some((error) => error.type === "FORBIDDEN" && error.path?.includes("statusCheckRollup") === true && error.path.includes("contexts"));
13068
+ }
13069
+ function requireCheckAccess(errors) {
13070
+ if (checkAccessDenied(errors)) throw new AdapterError("auth", "GitHub token lacks Checks read permission");
13071
+ }
13059
13072
  function rejectUnscoped(errors, roots) {
13060
13073
  if (errors.some((error) => error.path === void 0 || !roots.includes(String(error.path[0])))) throw new AdapterError("transient", "Unscoped GitHub GraphQL error");
13061
13074
  }
@@ -13097,6 +13110,7 @@ async function fullPr(client, pr, owner, name, number, signal, budget) {
13097
13110
  }, signal);
13098
13111
  budget(response.budget);
13099
13112
  rejectUnscoped(response.errors, ["repository"]);
13113
+ requireCheckAccess(response.errors);
13100
13114
  if (response.errors.length > 0) return void 0;
13101
13115
  const pagePr = record$2(nested(response.data, "repository", "pullRequest"));
13102
13116
  const page = field === "contexts" ? contexts(pagePr) : record$2(pagePr[field]);
@@ -13154,6 +13168,7 @@ async function pollRepository({ id, repo, login, prefixes, client, ctx }) {
13154
13168
  const found = discover(nodes, ctx.cursor, login);
13155
13169
  const bootstrapCursor = bootstrap && discoveryOk ? latestUpdatedAt(connectionNodes(nested(response.data, "repository", "mergedBaseline")), found.cursor) ?? sourceCursorSchema.parse("1970-01-01T00:00:00.000Z") : void 0;
13156
13170
  const process = async (number, wire, errors, ref) => {
13171
+ requireCheckAccess(errors);
13157
13172
  const error = errors[0];
13158
13173
  if (error !== void 0) {
13159
13174
  const direct = error.path === void 0 || error.path.length <= 2;
@@ -13457,6 +13472,7 @@ function createGithubIssuesAdapter(options) {
13457
13472
  //#endregion
13458
13473
  //#region ../adapters/src/jira/jira.ts
13459
13474
  const SEARCH_PAGE_LIMIT = 20;
13475
+ const MAX_GET_SEARCH_URL_LENGTH = 2e3;
13460
13476
  const OUTSIDE_BATCH_SIZE = 50;
13461
13477
  const FALLBACK_REQUEST_LIMIT = 100;
13462
13478
  const SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint";
@@ -13571,6 +13587,7 @@ function createJiraAdapter(options) {
13571
13587
  if (signal.aborted) throw new AdapterError("transient", "Jira poll aborted");
13572
13588
  throw new AdapterError("transient", "Jira request failed");
13573
13589
  }
13590
+ if (response.status === 414 && options.searchMethod === "GET") throw new AdapterError("config", "Jira GET search URL is too long");
13574
13591
  if (!response.ok) throw new JiraHttpError(response.status, classifyHttpFailure({
13575
13592
  status: response.status,
13576
13593
  headers: response.headers
@@ -13612,19 +13629,28 @@ function createJiraAdapter(options) {
13612
13629
  const seen = /* @__PURE__ */ new Set();
13613
13630
  for (let page = 0; page < SEARCH_PAGE_LIMIT; page += 1) {
13614
13631
  beforePage?.();
13615
- const result = object$1(await request(tracker.baseUrl, "/rest/api/3/search/jql", signal, {
13632
+ const fields = [
13633
+ "summary",
13634
+ "status",
13635
+ "assignee",
13636
+ "labels",
13637
+ "updated",
13638
+ ...sprintField === null ? [] : [sprintField]
13639
+ ];
13640
+ const body = {
13616
13641
  jql,
13617
13642
  maxResults: 100,
13618
- fields: [
13619
- "summary",
13620
- "status",
13621
- "assignee",
13622
- "labels",
13623
- "updated",
13624
- ...sprintField === null ? [] : [sprintField]
13625
- ],
13643
+ fields,
13626
13644
  ...token === void 0 ? {} : { nextPageToken: token }
13627
- }));
13645
+ };
13646
+ const path = options.searchMethod === "GET" ? `/rest/api/3/search/jql?${new URLSearchParams({
13647
+ jql,
13648
+ maxResults: "100",
13649
+ fields: fields.join(","),
13650
+ ...token === void 0 ? {} : { nextPageToken: token }
13651
+ }).toString()}` : "/rest/api/3/search/jql";
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));
13628
13654
  issues.push(...array(result["issues"]));
13629
13655
  const next = result["nextPageToken"];
13630
13656
  if (next == null || next === "") {
@@ -13878,9 +13904,15 @@ function applyEntityOutcomes(input) {
13878
13904
  function startRunner(options) {
13879
13905
  const startedAt = (options.now ?? Date.now)();
13880
13906
  const sources = options.entries.map((entry) => runSource(entry, options, startedAt));
13881
- return { async stop() {
13882
- await Promise.all(sources.map((source) => source.stop()));
13883
- } };
13907
+ return {
13908
+ firstAttempts: Promise.all(sources.map(async (source) => ({
13909
+ source: source.id,
13910
+ outcome: await source.firstAttempt
13911
+ }))),
13912
+ async stop() {
13913
+ await Promise.all(sources.map((source) => source.stop()));
13914
+ }
13915
+ };
13884
13916
  }
13885
13917
  function runSource(entry, options, runnerStartMs) {
13886
13918
  const { adapter, reserveRemaining } = entry;
@@ -13954,7 +13986,7 @@ function runSource(entry, options, runnerStartMs) {
13954
13986
  if (stopping) return;
13955
13987
  pollTimer = setTimeout(() => {
13956
13988
  pollTimer = void 0;
13957
- current = poll();
13989
+ current = poll().then(() => void 0);
13958
13990
  }, delayMs);
13959
13991
  }
13960
13992
  async function poll() {
@@ -14050,22 +14082,28 @@ function runSource(entry, options, runnerStartMs) {
14050
14082
  now: now()
14051
14083
  });
14052
14084
  failures = delay.failures;
14053
- armDeadline();
14085
+ if (!options.oneShot) armDeadline();
14054
14086
  await report();
14055
- schedule(delay.delayMs);
14087
+ if (!options.oneShot) schedule(delay.delayMs);
14088
+ return outcome;
14056
14089
  }
14057
- armDeadline();
14058
- current = poll();
14059
- return { async stop() {
14060
- stopping = true;
14061
- clearTimeout(pollTimer);
14062
- clearTimeout(deadlineTimer);
14063
- clearTimeout(hardBoundTimer);
14064
- pollTimer = deadlineTimer = hardBoundTimer = void 0;
14065
- controller?.abort();
14066
- await current;
14067
- await reports;
14068
- } };
14090
+ if (!options.oneShot) armDeadline();
14091
+ const firstAttempt = poll();
14092
+ current = firstAttempt.then(() => void 0);
14093
+ return {
14094
+ id: source,
14095
+ firstAttempt,
14096
+ async stop() {
14097
+ stopping = true;
14098
+ clearTimeout(pollTimer);
14099
+ clearTimeout(deadlineTimer);
14100
+ clearTimeout(hardBoundTimer);
14101
+ pollTimer = deadlineTimer = hardBoundTimer = void 0;
14102
+ controller?.abort();
14103
+ await current;
14104
+ await reports;
14105
+ }
14106
+ };
14069
14107
  }
14070
14108
  /**
14071
14109
  * Settles with `work`, or with undefined as soon as `signal` aborts, so `stop()` never waits
@@ -14325,7 +14363,8 @@ function startAdapters(config, core, options = {}) {
14325
14363
  },
14326
14364
  intervalMs: config.adapters.jira.intervalMs,
14327
14365
  ...options.fetch === void 0 ? {} : { fetch: options.fetch },
14328
- warn: options.warn ?? (() => void 0)
14366
+ warn: options.warn ?? (() => void 0),
14367
+ ...options.jiraSearchMethod === void 0 ? {} : { searchMethod: options.jiraSearchMethod }
14329
14368
  }) : blindSource(id, config.adapters.jira.intervalMs, "Jira credential is missing") });
14330
14369
  } else {
14331
14370
  const id = `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
@@ -14362,12 +14401,16 @@ function startAdapters(config, core, options = {}) {
14362
14401
  }) }],
14363
14402
  core,
14364
14403
  config,
14365
- ...options.now === void 0 ? {} : { now: options.now }
14404
+ ...options.now === void 0 ? {} : { now: options.now },
14405
+ ...options.oneShot === void 0 ? {} : { oneShot: options.oneShot }
14366
14406
  });
14367
- return { async stop() {
14368
- await runner.stop();
14369
- await Promise.allSettled([...notices]);
14370
- } };
14407
+ return {
14408
+ firstAttempts: runner.firstAttempts,
14409
+ async stop() {
14410
+ await runner.stop();
14411
+ await Promise.allSettled([...notices]);
14412
+ }
14413
+ };
14371
14414
  }
14372
14415
  //#endregion
14373
14416
  //#region ../gateway-slack/src/api.ts
@@ -15518,11 +15561,7 @@ async function runDaemonDoctor(ctx, productVersion, fetcher = fetch) {
15518
15561
  const checks = [];
15519
15562
  const jiraTrackers = config.projects.flatMap((project) => project.tracker.type === "jira" ? [project.tracker] : []);
15520
15563
  const slackConfigured = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
15521
- const missing = [
15522
- "GITHUB_TOKEN",
15523
- ...jiraTrackers.length > 0 ? ["JIRA_EMAIL", "JIRA_API_TOKEN"] : [],
15524
- ...slackConfigured ? ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] : []
15525
- ].filter((name) => !secrets[name]);
15564
+ const missing = requiredSecretKeys(config).filter((name) => !secrets[name]);
15526
15565
  checks.push(check("secrets", missing.length === 0, `Add ${missing.join(", ")} to the private secrets.env file.`));
15527
15566
  const jiraEmail = secrets["JIRA_EMAIL"];
15528
15567
  const jiraToken = secrets["JIRA_API_TOKEN"];
package/dist/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-VWyX7Nwm.mjs";
2
- import { r as CliError, t as requestOperation } from "./client-BnkZmz1i.mjs";
3
- import { n as loadClientConfig } from "./config-UyXnaekw.mjs";
1
+ import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-Cy74ZQfR.mjs";
2
+ import { r as CliError, t as requestOperation } from "./client-Dw9ZovyI.mjs";
3
+ import { n as loadClientConfig } from "./config-CeDZQlvy.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { z } from "zod";
@@ -303,6 +303,49 @@ const configSchema = z.strictObject({
303
303
  }).prefault({})
304
304
  }).prefault({})
305
305
  });
306
+ /** Recognized names in the private secrets.env file; values never belong in config.yaml. */
307
+ const SECRET_ENV_KEYS = [
308
+ {
309
+ name: "GITHUB_TOKEN",
310
+ requiredWhen: "always",
311
+ purpose: "Read GitHub repositories and check runs."
312
+ },
313
+ {
314
+ name: "JIRA_EMAIL",
315
+ requiredWhen: "jira",
316
+ purpose: "Jira account email."
317
+ },
318
+ {
319
+ name: "JIRA_API_TOKEN",
320
+ requiredWhen: "jira",
321
+ purpose: "Jira API token."
322
+ },
323
+ {
324
+ name: "SLACK_BOT_TOKEN",
325
+ requiredWhen: "slack",
326
+ purpose: "Slack bot Web API token."
327
+ },
328
+ {
329
+ name: "SLACK_APP_TOKEN",
330
+ requiredWhen: "slack",
331
+ purpose: "Slack app-level Socket Mode token."
332
+ },
333
+ {
334
+ name: "SLACK_APP_CONFIG_TOKEN",
335
+ requiredWhen: "optional",
336
+ purpose: "Verify the installed slash command through the Slack app manifest API."
337
+ },
338
+ {
339
+ name: "SLACK_API_URL",
340
+ requiredWhen: "optional",
341
+ purpose: "Override the Slack API URL for local testing."
342
+ }
343
+ ];
344
+ function requiredSecretKeys(config) {
345
+ const jira = config.projects.some((project) => project.tracker.type === "jira");
346
+ const slack = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
347
+ return SECRET_ENV_KEYS.filter(({ requiredWhen }) => requiredWhen === "always" || requiredWhen === "jira" && jira || requiredWhen === "slack" && slack).map(({ name }) => name);
348
+ }
306
349
  //#endregion
307
350
  //#region ../contracts/src/errors.ts
308
351
  /**
@@ -2635,4 +2678,4 @@ var AdapterError = class extends Error {
2635
2678
  }
2636
2679
  };
2637
2680
  //#endregion
2638
- export { taskKeySchema as A, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, DEFAULT_SLACK_APP_NAME as I, DEFAULT_SLACK_COMMAND as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, configSchema as R, prRefSchema as S, actorIdSchema as T, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y, SLACK_BOT_SCOPES as z };
2681
+ export { taskKeySchema as A, SLACK_BOT_SCOPES as B, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, DEFAULT_SLACK_APP_NAME as I, DEFAULT_SLACK_COMMAND as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, configSchema as R, prRefSchema as S, actorIdSchema as T, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y, requiredSecretKeys as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "^24.12.0 || ^26.0.0"