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
2 changes: 2 additions & 0 deletions .github/workflows/docs-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ jobs:
cache: npm
cache-dependency-path: docs/package-lock.json
- run: npm ci --no-audit --no-fund
# onBrokenLinks/onBrokenAnchors are set to "throw", so this is also the
# gate on every internal link and anchor across the site.
- run: npm run build
39 changes: 39 additions & 0 deletions .github/workflows/docs-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Docs checks

# Cheap docs gates, deliberately NOT path-filtered so this job reports on every
# PR and can therefore be made a required check (a path-filtered required check
# permanently blocks PRs that miss the filter). The expensive Docusaurus build
# stays in docs-build.yml, which is path-filtered and advisory.
#
# What this catches that the build cannot: a Slack command listed on the
# Incident Commander page as a plain code span with no link at all. MDX renders
# that without complaint and there is no anchor for onBrokenAnchors to reject,
# so it shipped unnoticed (issue #82).

on:
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
docs-links:
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
# No npm ci: both steps are plain Node with no dependencies, and skipping
# the install is what keeps this cheap enough to run on every PR.
- run: npm run check-command-links
- run: npm test
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,14 @@ You can override their values if you wish to do so. Included below are their des
| `/dispatch-add-timeline-event` | Opens a modal to add an event to the incident timeline. |
| `/dispatch-assign-role` | Opens a modal for assigning a role to a participant. |
| `/dispatch-engage-oncall` | Opens a modal to engage an on-call person. |
| `/dispatch-list-incidents <organization-slug\|'default'> <project-slug\|'default'>` | Lists current active and stable incidents and closed incidents in the last 24 hours. |
| `/dispatch-list-incidents` | Lists current active and stable incidents and closed incidents in the last 24 hours. |
| `/dispatch-list-my-tasks` | Opens a modal with the list of your open and resolved incident tasks. |
| `/dispatch-list-participants` | Opens a modal with the list of incident participants. |
| `/dispatch-list-signals` | Opens a modal with the list of signal definitions configured for the conversation from where command was ran. |
| `/dispatch-list-tasks` | Opens a modal with the list of open and resolved incident tasks. |
| `/dispatch-list-workflows` | List workflows previously run during this incident. |
| `/dispatch-report-executive` | Opens a modal to write an executive report. |
| `/dispatch-report-incident <organization-slug\|'default'>` | Opens a modal to report an incident. This command can be run from non-incident channels where the Dispatch bot is a member. |
| `/dispatch-report-incident` | Opens a modal to report an incident. This command can be run from non-incident channels where the Dispatch bot is a member. |
| `/dispatch-report-tactical` | Opens a modal to write a tactical report. |
| `/dispatch-run-workflow` | Run a workflow and associate artifacts with this incident. |
| `/dispatch-update-incident` | Opens a modal to update the incident. |
Expand Down
27 changes: 24 additions & 3 deletions docs/docs/user-guide/incidents/commander.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ All Slack commands are listed below, or you may view _groups_ of commands relati
- [`/dispatch-add-timeline-event`](#dispatch-add-timeline-event)
- [`/dispatch-assign-role`](#dispatch-assign-role)
- [`/dispatch-engage-oncall`](#dispatch-engage-oncall)
- [`/dispatch-list-incidents`](#dispatch-list-incidents)
- [`/dispatch-list-my-tasks`](#dispatch-list-my-tasks)
- [`/dispatch-list-participants`](#dispatch-list-participants)
- [`/dispatch-list-tasks`](#dispatch-list-tasks)
- [`/dispatch-list-workflows`](#dispatch-list-workflows)
- `/dispatch-list-incidents`
- [`/dispatch-notifications-group`](#dispatch-notifications-group)
- [`/dispatch-report-executive`](#dispatch-report-executive)
- [`/dispatch-report-incident`](#dispatch-report-incident)
Expand All @@ -29,8 +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-list-signals`
- `/dispatch-summary`
- [`/dispatch-list-signals`](#dispatch-list-signals)
- [`/dispatch-summary`](#dispatch-summary)

## People

These commands help manage the people helping resolve the incident.
Expand Down Expand Up @@ -226,3 +227,23 @@ This command will create a case for the current incident.
<div style={{textAlign: 'center'}}>

</div>

{/* The three sections below have no screenshot yet; capturing one needs a live Slack workspace. See issue #135. */}

### /dispatch-list-incidents

Use `/dispatch-list-incidents` to open a modal listing every incident across every project that is currently active or stable, plus any incident closed in the last 24 hours. Each entry links to the incident in Dispatch and shows its commander, project, status, type, severity, and priority.

The command takes no arguments and can be run outside an incident channel as well as inside one. Restricted incidents are never listed, and the modal shows at most 49 incidents; use the Dispatch web interface if your deployment has more.

### /dispatch-list-signals

Use `/dispatch-list-signals` to see which signal definitions send their cases to the conversation you run it from. It opens a modal listing every definition whose case type targets that conversation, showing each one's name and variant alongside a [Snooze](../../administration/settings/signal/filter.mdx#snooze-filter) button that temporarily stops it creating cases. Longer lists are paged 25 at a time.

The command takes no arguments. If no case type targets that conversation, the modal tells you so.

### /dispatch-summary

Use `/dispatch-summary` in an incident or case channel to get an AI-generated read-in summary of what has happened so far. It is most useful when you are pulled in late or take the incident over partway through. The summary is shown only to you, and Dispatch labels it as AI-generated — check it before you act on it.

Dispatch declines and tells you why when read-in summaries are not enabled for the incident or case type, when the incident or case is restricted, or when a case has no dedicated channel. If the project has no artificial intelligence plugin enabled, it reports only that the summary is unavailable — ask your administrator to check the project's plugins.
5 changes: 5 additions & 0 deletions docs/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ const config = {

onBrokenLinks: "throw",

// Docusaurus defaults this to "warn", so a `](#missing)` link renders as a
// link that goes nowhere and the build still succeeds. Throwing is what
// makes the build the authority on whether an anchor resolves.
onBrokenAnchors: "throw",

markdown: {
hooks: {
onBrokenMarkdownLinks: "warn",
Expand Down
4 changes: 3 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
"write-heading-ids": "docusaurus write-heading-ids",
"check-command-links": "node scripts/check-command-links.mjs",
"test": "node --test scripts/check-command-links.test.mjs"
},
"dependencies": {
"@cmfcmf/docusaurus-search-local": "^2.0.1",
Expand Down
216 changes: 216 additions & 0 deletions docs/scripts/check-command-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
#!/usr/bin/env node
// Keeps the "All Slack commands" list on the Incident Commander page in sync
// with the per-command sections below it.
//
// Docusaurus' onBrokenAnchors setting already fails the build on a link whose
// anchor does not resolve, so this deliberately does not reimplement heading
// slugging. It guards the failure the build cannot see: a command listed as a
// plain code span with no link at all renders without complaint.
//
// This is a line parser rather than a remark plugin so the checked function
// stays a pure, dependency-free unit that node:test can exercise directly. The
// trade is that it understands less MDX than the real parser, so the command
// list is held to one uniform shape and anything else in it is an error --
// failing loudly on an unfamiliar line rather than skipping it. A skipped line
// is how this class of bug hides in the first place.

import { readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import path from "node:path"

const DEFAULT_TARGET = "docs/user-guide/incidents/commander.mdx"

const LIST_HEADING = "All Slack commands"

// Entries are `- [`/cmd`](#anchor)`; sections are `### /cmd`. CommonMark allows
// -, * and + as bullet markers, and MDX renders all three identically.
const LINKED_ENTRY = /^\s*[-*+][ \t]+\[`(\/[^`]+)`\]\(([^)]*)\)\s*$/
const BARE_ENTRY = /^\s*[-*+][ \t]+`(\/[^`]+)`\s*$/
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*$/
const EXPLICIT_ID = /[ \t]*\{#([^}\s]+)\}$/
const FENCE = /^\s*(`{3,}|~{3,})/

const COMMENTS = [
["<!--", "-->"],
["{/*", "*/}"],
]

/**
* Blanks out comment spans so a commented-out heading or list entry is not
* read as a real one. Tracked across lines rather than matched by a
* single-line regex, because both comment syntaxes may span newlines.
*/
function stripComments(lines, inFence) {
let closing = null
return lines.map((line, i) => {
if (inFence[i]) return line
let rest = line
let kept = ""
while (rest !== "") {
if (closing) {
const end = rest.indexOf(closing)
if (end === -1) break
rest = rest.slice(end + closing.length)
closing = null
continue
}
const next = COMMENTS.map(([open, close]) => [rest.indexOf(open), open, close])
.filter(([at]) => at !== -1)
.sort((a, b) => a[0] - b[0])[0]
if (!next) {
kept += rest
break
}
const [at, open, close] = next
kept += rest.slice(0, at)
rest = rest.slice(at + open.length)
closing = close
}
return kept
})
}

/** Splits `## Heading {#id}` into its text and its explicit id, if any. */
function parseHeading(line) {
const heading = HEADING.exec(line)
if (!heading) return null
const [, hashes, raw] = heading
const explicit = EXPLICIT_ID.exec(raw)
return {
level: hashes.length,
text: explicit ? raw.slice(0, explicit.index) : raw,
id: explicit ? explicit[1] : null,
}
}

/**
* @param {string} source raw MDX
* @returns {string[]} human-readable problems; empty means the page is consistent
*/
export function checkCommandLinks(source) {
const problems = []
const lines = source.split("\n")

// Fenced code can contain anything that looks like a list entry or heading.
// The closing fence has to use the marker the block opened with, or a ``` in
// a ~~~ block silently flips the rest of the file inside out.
const inFence = []
let openFence = null
for (const line of lines) {
const fence = FENCE.exec(line)
if (fence && (openFence === null || fence[1][0] === openFence[0])) {
inFence.push(true)
openFence = openFence === null ? fence[1] : null
continue
}
inFence.push(openFence !== null)
}

const content = stripComments(lines, inFence)

// Sections are collected across the whole file; the list references them from
// above, and they are the only h3s on the page.
const sections = new Map()
content.forEach((line, i) => {
if (inFence[i]) return
const heading = parseHeading(line)
if (!heading || heading.level !== 3 || !heading.text.startsWith("/")) return
if (sections.has(heading.text)) {
problems.push(`duplicate section "### ${heading.text}" (line ${i + 1})`)
}
// Docusaurus slugs a heading to lowercase and drops the leading slash,
// unless the author pinned an explicit {#id}.
sections.set(heading.text, {
line: i + 1,
anchor: heading.id ?? heading.text.slice(1).toLowerCase(),
})
})

const start = content.findIndex((line, i) => {
if (inFence[i]) return false
const heading = parseHeading(line)
return heading?.level === 2 && heading.text === LIST_HEADING
})
if (start === -1) {
return [`no "## ${LIST_HEADING}" heading found`]
}

const listed = new Map()
for (let i = start + 1; i < lines.length; i++) {
if (inFence[i]) continue
const line = content[i]
if (parseHeading(line)?.level <= 2) break // next section of the page
if (line.trim() === "") continue

const at = `line ${i + 1}`
const bare = BARE_ENTRY.exec(line)
const linked = LINKED_ENTRY.exec(line)
const command = bare?.[1] ?? linked?.[1]

if (!command) {
problems.push(
`${at}: every line in the ${LIST_HEADING} list must be \`- [\`/command\`](#anchor)\`, ` +
`got: ${line.trim()}`
)
continue
}
if (listed.has(command)) {
problems.push(`${at}: \`${command}\` is listed twice (also line ${listed.get(command)})`)
}
listed.set(command, i + 1)

const section = sections.get(command)
if (bare) {
const anchor = section ? section.anchor : command.slice(1)
problems.push(
`${at}: \`${command}\` is listed but not linked; ` +
`use [\`${command}\`](#${anchor})` +
(section ? "" : ` and add a "### ${command}" section`)
)
continue
}

if (!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}`)
}
}

for (const [command, section] of sections) {
if (!listed.has(command)) {
problems.push(`line ${section.line}: "### ${command}" is not in the ${LIST_HEADING} list`)
}
}

return problems
}

async function main(targets) {
let failed = false
for (const target of targets) {
let source
try {
source = await readFile(target, "utf8")
} catch (error) {
console.error(`FAIL ${target}\n cannot be read: ${error.code ?? error.message}`)
failed = true
continue
}
const problems = checkCommandLinks(source)
if (problems.length === 0) {
console.log(`ok ${target}`)
continue
}
failed = true
console.error(`FAIL ${target}`)
for (const problem of problems) console.error(` ${problem}`)
}
if (failed) process.exitCode = 1
}

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)])
}
Loading