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/cli.mjs +21 -4
- package/dist/{client-Ln76BdDz.mjs → client-D4cveOHQ.mjs} +67 -14
- package/dist/client.mjs +2 -2
- package/dist/config-B1fVOLzp.mjs +229 -0
- package/dist/daemon.mjs +265 -105
- package/dist/mcp.mjs +52 -4
- package/dist/{src-BFgTh55w.mjs → src-g7u1onIP.mjs} +28 -10
- package/package.json +1 -1
- package/dist/config-B_6fA4Au.mjs +0 -111
package/dist/daemon.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as taskKeySchema, B as
|
|
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,
|
|
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 :
|
|
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,
|
|
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,
|
|
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 ?? "
|
|
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 ??
|
|
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 ??
|
|
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:
|
|
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("
|
|
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",
|
|
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:
|
|
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 ??
|
|
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
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
13278
|
-
const milestone = issue["milestone"] == null ? null : string$3(object$
|
|
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$
|
|
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$
|
|
13360
|
+
return Array.isArray(labels) && labels.some((label) => object$3(label)["name"] === tracker.label);
|
|
13297
13361
|
}
|
|
13298
|
-
return issue["milestone"] != null && object$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
13547
|
-
const fields = object$
|
|
13548
|
-
const status = object$
|
|
13549
|
-
const assignee = fields["assignee"] == null ? null : object$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
15582
|
-
|
|
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
|
-
} })
|
|
15586
|
-
|
|
15587
|
-
|
|
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."));
|