From ac4ba1e1f2e63648eb7cb9a2e2e9322b23493ec3 Mon Sep 17 00:00:00 2001 From: Jamyn Shanley <696574+Jamyn@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:07:54 +0000 Subject: [PATCH] docs: document the three undocumented Slack case commands Closes #134 --- .../settings/plugins/configuring-slack.mdx | 3 + docs/docs/user-guide/incidents/commander.mdx | 23 ++++- docs/scripts/check-command-links.mjs | 99 ++++++++++++++++++- docs/scripts/check-command-links.test.mjs | 91 +++++++++++++++-- 4 files changed, 202 insertions(+), 14 deletions(-) diff --git a/docs/docs/administration/settings/plugins/configuring-slack.mdx b/docs/docs/administration/settings/plugins/configuring-slack.mdx index 48fae9a3..3daab7f8 100644 --- a/docs/docs/administration/settings/plugins/configuring-slack.mdx +++ b/docs/docs/administration/settings/plugins/configuring-slack.mdx @@ -198,6 +198,9 @@ You can override their values if you wish to do so. Included below are their des | `/dispatch-update-participant` | Opens a modal to update participant metadata. | | `/dispatch-create-task` | Opens a modal to create an incident task. | | `/dispatch-create-case` | Opens a modal to create a case. | +| `/dispatch-update-case` | Opens a modal to update the case. Run from a case channel. | +| `/dispatch-escalate-case` | Opens a modal to escalate the case to an incident. Run from a case channel. | +| `/dispatch-engage-user` | Opens a modal to ask a case participant to confirm their identity with MFA. Run from a case channel. | | `/dispatch-summary` | If allowed for this case/incident type, will create an AI-generated read-in summary. | ### Contact Information Resolver Plugin diff --git a/docs/docs/user-guide/incidents/commander.mdx b/docs/docs/user-guide/incidents/commander.mdx index 99d92888..5cbeee7b 100644 --- a/docs/docs/user-guide/incidents/commander.mdx +++ b/docs/docs/user-guide/incidents/commander.mdx @@ -29,6 +29,9 @@ All Slack commands are listed below, or you may view _groups_ of commands relati - [`/dispatch-run-workflow`](#dispatch-run-workflow) - [`/dispatch-create-task`](#dispatch-create-task) - [`/dispatch-create-case`](#dispatch-create-case) +- [`/dispatch-update-case`](#dispatch-update-case) +- [`/dispatch-escalate-case`](#dispatch-escalate-case) +- [`/dispatch-engage-user`](#dispatch-engage-user) - [`/dispatch-list-signals`](#dispatch-list-signals) - [`/dispatch-summary`](#dispatch-summary) @@ -228,7 +231,25 @@ This command will create a case for the current incident. -{/* The three sections below have no screenshot yet; capturing one needs a live Slack workspace. See issue #135. */} +{/* The sections below have no screenshot yet; capturing one needs a live Slack workspace. See issue #135. */} + +### /dispatch-update-case + +Run `/dispatch-update-case` from a case's channel to open a modal for its title, description, resolution and resolution reason, assignee, status, type, priority and visibility. + +A case cannot be escalated from here — the status select deliberately omits `Escalated`. Use [`/dispatch-escalate-case`](#dispatch-escalate-case) instead, which creates the incident the case becomes. + +### /dispatch-escalate-case + +Run `/dispatch-escalate-case` from a case's channel to turn it into an incident. The modal opens pre-filled from the case — its title, description and project — so you can accept the defaults or adjust them, then pick the incident's type, priority and severity. + +Dispatch tells you and does nothing if the case has already been escalated; a case escalates once. + +### /dispatch-engage-user + +Run `/dispatch-engage-user` from a case's channel to ask someone to confirm their identity through your multi-factor provider — the usual case being a report that some account did something its owner may not have done. + +Pick the person and edit the message they will see, or accept the default. Dispatch adds them to the case if they are not already on it, then sends the prompt and reports the outcome back in the channel. It says so in the channel instead if the person has no account in your Slack workspace. ### /dispatch-list-incidents diff --git a/docs/scripts/check-command-links.mjs b/docs/scripts/check-command-links.mjs index 9feaa57c..026d3f2e 100644 --- a/docs/scripts/check-command-links.mjs +++ b/docs/scripts/check-command-links.mjs @@ -20,6 +20,14 @@ import path from "node:path" const DEFAULT_TARGET = "docs/user-guide/incidents/commander.mdx" +// The pages a registered command has to appear on, and the source of truth it +// is checked against. Relative to the repository root, not this directory. +const CONFIG_SOURCE = "src/dispatch/plugins/dispatch_slack/config.py" +const DOCUMENTED_IN = [ + "docs/docs/user-guide/incidents/commander.mdx", + "docs/docs/administration/settings/plugins/configuring-slack.mdx", +] + const LIST_HEADING = "All Slack commands" // Entries are `- [`/cmd`](#anchor)`; sections are `### /cmd`. CommonMark allows @@ -150,7 +158,7 @@ export function checkCommandLinks(source) { if (!command) { problems.push( `${at}: every line in the ${LIST_HEADING} list must be \`- [\`/command\`](#anchor)\`, ` + - `got: ${line.trim()}` + `got: ${line.trim()}`, ) continue } @@ -165,13 +173,15 @@ export function checkCommandLinks(source) { problems.push( `${at}: \`${command}\` is listed but not linked; ` + `use [\`${command}\`](#${anchor})` + - (section ? "" : ` and add a "### ${command}" section`) + (section ? "" : ` and add a "### ${command}" section`), ) continue } if (!section) { - problems.push(`${at}: \`${command}\` links to ${linked[2]} but has no "### ${command}" section`) + problems.push( + `${at}: \`${command}\` links to ${linked[2]} but has no "### ${command}" section`, + ) } else if (linked[2] !== `#${section.anchor}`) { problems.push(`${at}: \`${command}\` links to ${linked[2]}, expected #${section.anchor}`) } @@ -186,6 +196,82 @@ export function checkCommandLinks(source) { return problems } +// `slack_command_x: str = Field(\n "/dispatch-x",`. A regex rather than a +// Python parse: the field shape is uniform and enforced by ruff-format, and a +// dependency-free script is what lets the docs CI job skip `npm ci`. +const COMMAND_DEFAULT = /slack_command_\w+\s*:\s*str\s*=\s*Field\(\s*"(\/[^"]+)"/g + +/** + * Every command the Slack plugin registers, read off its configuration schema. + * + * @param {string} source raw `config.py` + * @returns {string[]} the default command strings, in file order + */ +export function registeredCommands(source) { + return [...source.matchAll(COMMAND_DEFAULT)].map((m) => m[1]) +} + +/** + * Commands that exist in the code but appear on none of the documentation + * pages, which is what `checkCommandLinks` cannot see: it only checks that a + * page agrees with itself, so a command missing from every page reports ok. + * + * The test is a plain substring, not the list/section shape `checkCommandLinks` + * enforces -- the two pages present commands differently (a linked list and + * sections on one, a table on the other) and only one of them is worth holding + * to a fixed shape. + * + * @param {string} configSource raw `config.py` + * @param {string[]} pages raw MDX of every page a command may be documented on + * @returns {string[]} human-readable problems; empty means every command is documented + */ +export function checkCommandCoverage(configSource, pages) { + const commands = registeredCommands(configSource) + if (commands.length === 0) { + return [`no slack_command_* defaults found in ${CONFIG_SOURCE}; has the field shape changed?`] + } + + const problems = [] + for (const command of commands) { + // Word-boundary the trailing edge so `/dispatch-list-task` does not count + // itself as documented by a mention of `/dispatch-list-tasks`. + const mentioned = pages.some((page) => new RegExp(`${command}(?![\\w-])`).test(page)) + if (!mentioned) { + problems.push(`\`${command}\` is registered in ${CONFIG_SOURCE} but documented nowhere`) + } + } + return problems +} + +async function checkCoverage(root) { + let configSource + try { + configSource = await readFile(path.join(root, CONFIG_SOURCE), "utf8") + } catch (error) { + console.error(`FAIL ${CONFIG_SOURCE}\n cannot be read: ${error.code ?? error.message}`) + return false + } + + const pages = [] + for (const page of DOCUMENTED_IN) { + try { + pages.push(await readFile(path.join(root, page), "utf8")) + } catch (error) { + console.error(`FAIL ${page}\n cannot be read: ${error.code ?? error.message}`) + return false + } + } + + const problems = checkCommandCoverage(configSource, pages) + if (problems.length === 0) { + console.log(`ok every command in ${CONFIG_SOURCE} is documented`) + return true + } + console.error("FAIL command coverage") + for (const problem of problems) console.error(` ${problem}`) + return false +} + async function main(targets) { let failed = false for (const target of targets) { @@ -207,10 +293,15 @@ async function main(targets) { for (const problem of problems) console.error(` ${problem}`) } if (failed) process.exitCode = 1 + return !failed } if (process.argv[1] === fileURLToPath(import.meta.url)) { const args = process.argv.slice(2) const here = path.dirname(fileURLToPath(import.meta.url)) - await main(args.length ? args : [path.join(here, "..", DEFAULT_TARGET)]) + const linksOk = await main(args.length ? args : [path.join(here, "..", DEFAULT_TARGET)]) + // Only when run over the real pages: an explicit target is a one-off check of + // some other file and says nothing about whether the docs cover the code. + const coverageOk = args.length ? true : await checkCoverage(path.join(here, "..", "..")) + if (!linksOk || !coverageOk) process.exitCode = 1 } diff --git a/docs/scripts/check-command-links.test.mjs b/docs/scripts/check-command-links.test.mjs index c4b2e773..b8b75bf3 100644 --- a/docs/scripts/check-command-links.test.mjs +++ b/docs/scripts/check-command-links.test.mjs @@ -4,7 +4,11 @@ import { test } from "node:test" import { fileURLToPath } from "node:url" import path from "node:path" -import { checkCommandLinks } from "./check-command-links.mjs" +import { + checkCommandCoverage, + checkCommandLinks, + registeredCommands, +} from "./check-command-links.mjs" const here = path.dirname(fileURLToPath(import.meta.url)) const commanderPage = path.join(here, "..", "docs/user-guide/incidents/commander.mdx") @@ -37,7 +41,7 @@ test("the real Incident Commander page is consistent", async () => { test("a linked command with a matching section passes", () => { assert.deepEqual( checkCommandLinks(page(["- [`/dispatch-summary`](#dispatch-summary)"], ["/dispatch-summary"])), - [] + [], ) }) @@ -68,7 +72,7 @@ test("a linked command with no section fails", () => { test("a link pointing at the wrong anchor fails", () => { assert.match( only(page(["- [`/dispatch-summary`](#dispatch-sumary)"], ["/dispatch-summary"])), - /links to #dispatch-sumary, expected #dispatch-summary/ + /links to #dispatch-sumary, expected #dispatch-summary/, ) }) @@ -79,7 +83,7 @@ test("a section missing from the list fails", () => { test("a command listed twice fails", () => { const source = page( ["- [`/dispatch-summary`](#dispatch-summary)", "- [`/dispatch-summary`](#dispatch-summary)"], - ["/dispatch-summary"] + ["/dispatch-summary"], ) assert.match(only(source), /is listed twice/) }) @@ -89,14 +93,14 @@ test("a command listed twice fails", () => { test("a heading is expected to resolve to its lowercased anchor", () => { assert.deepEqual( checkCommandLinks(page(["- [`/dispatch-Summary`](#dispatch-summary)"], ["/dispatch-Summary"])), - [] + [], ) }) test("a link matching the heading's case rather than the generated id fails", () => { assert.match( only(page(["- [`/dispatch-Summary`](#dispatch-Summary)"], ["/dispatch-Summary"])), - /expected #dispatch-summary/ + /expected #dispatch-summary/, ) }) @@ -141,7 +145,7 @@ for (const [name, entry] of [ const problems = checkCommandLinks(page([entry], [])) assert.ok( problems.some((p) => /must be `- \[`\/command`\]\(#anchor\)`/.test(p)), - `expected a shape complaint, got ${JSON.stringify(problems)}` + `expected a shape complaint, got ${JSON.stringify(problems)}`, ) }) } @@ -154,7 +158,7 @@ test("blank lines and comments in the command list are allowed", () => { "{/* a note to editors */}", "", ], - ["/dispatch-summary"] + ["/dispatch-summary"], ) assert.deepEqual(checkCommandLinks(source), []) }) @@ -170,7 +174,7 @@ test("a comment spanning several lines in the command list is allowed", () => { "", ], - ["/dispatch-summary"] + ["/dispatch-summary"], ) assert.deepEqual(checkCommandLinks(source), []) }) @@ -235,3 +239,72 @@ test("a page without the command list is reported, not silently skipped", () => 'no "## All Slack commands" heading found', ]) }) + +// --- coverage against the code (#134) --------------------------------------- +// +// checkCommandLinks only checks that a page agrees with itself, so a command +// documented on no page at all reports ok. These cover the check that reads the +// plugin's configuration schema instead. + +const configPy = path.join(here, "..", "..", "src/dispatch/plugins/dispatch_slack/config.py") +const adminPage = path.join( + here, + "..", + "docs/administration/settings/plugins/configuring-slack.mdx", +) + +/** A `config.py` fragment declaring the given commands. */ +function config(commands) { + return commands + .map( + (c, i) => + ` slack_command_${i}: str = Field(\n "${c}",\n title="T",\n )\n`, + ) + .join("\n") +} + +test("every registered command really is documented", async () => { + const [source, ...pages] = await Promise.all( + [configPy, commanderPage, adminPage].map((f) => readFile(f, "utf8")), + ) + assert.deepEqual(checkCommandCoverage(source, pages), []) +}) + +test("the real config.py still parses as the expected field shape", async () => { + const commands = registeredCommands(await readFile(configPy, "utf8")) + assert.ok(commands.length > 15, `only found ${commands.length} commands`) + assert.ok( + commands.every((c) => c.startsWith("/dispatch-")), + commands.join(", "), + ) +}) + +test("a command documented on any one page is enough", () => { + assert.deepEqual( + checkCommandCoverage(config(["/dispatch-x"]), ["nothing", "`/dispatch-x` is a"]), + [], + ) +}) + +test("a command documented on no page is reported", () => { + assert.deepEqual(checkCommandCoverage(config(["/dispatch-x"]), ["", ""]), [ + "`/dispatch-x` is registered in src/dispatch/plugins/dispatch_slack/config.py but documented nowhere", + ]) +}) + +test("a longer command name does not count as documenting a shorter one", () => { + // Without the trailing word boundary, `/dispatch-list-tasks` in the docs + // would silently vouch for an undocumented `/dispatch-list-task`. + assert.deepEqual( + checkCommandCoverage(config(["/dispatch-list-task"]), ["`/dispatch-list-tasks`"]), + [ + "`/dispatch-list-task` is registered in src/dispatch/plugins/dispatch_slack/config.py but documented nowhere", + ], + ) +}) + +test("a config.py the regex cannot read is reported, not treated as passing", () => { + assert.deepEqual(checkCommandCoverage("class C:\n pass\n", ["", ""]), [ + "no slack_command_* defaults found in src/dispatch/plugins/dispatch_slack/config.py; has the field shape changed?", + ]) +})