Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion docs/docs/user-guide/incidents/commander.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -228,7 +231,25 @@ This command will create a case for the current incident.

</div>

{/* 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

Expand Down
99 changes: 95 additions & 4 deletions docs/scripts/check-command-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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}`)
}
Expand All @@ -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) {
Expand All @@ -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
}
91 changes: 82 additions & 9 deletions docs/scripts/check-command-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"])),
[]
[],
)
})

Expand Down Expand Up @@ -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/,
)
})

Expand All @@ -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/)
})
Expand All @@ -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/,
)
})

Expand Down Expand Up @@ -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)}`,
)
})
}
Expand All @@ -154,7 +158,7 @@ test("blank lines and comments in the command list are allowed", () => {
"{/* a note to editors */}",
"<!-- another note -->",
],
["/dispatch-summary"]
["/dispatch-summary"],
)
assert.deepEqual(checkCommandLinks(source), [])
})
Expand All @@ -170,7 +174,7 @@ test("a comment spanning several lines in the command list is allowed", () => {
"<!-- and another",
" one -->",
],
["/dispatch-summary"]
["/dispatch-summary"],
)
assert.deepEqual(checkCommandLinks(source), [])
})
Expand Down Expand Up @@ -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?",
])
})
Loading