Developer Guide

Module dependency order

Roughly bottom to top. Anything can require downward; requiring upward is likely not good design and usually means the thing belongs in services/.

util/logger                    depends on nothing
util/{crypto,errors,cache,atomicFile,retry,sleep,url,mrkdwn}
config/                        depends on util
constants.js
store.js, incidents.js         depends on config + util
elastic.js                     depends on config + util
grouping.js, naming.js         depends on config + constants
services/                      depends on all of the above
commands/, watchers/           depends on services
context.js                     assembles the stores
app.js                         wires everything

services/incidentRender.js exists specifically because of that rule. Both the watcher (new alerts folded in) and the button handlers in commands/case.js (a case made, alerts attached) need to re-render an incident message, and a command module requiring the watcher would be backwards coupling. There should be exactly one place that knows how to put an incident on screen.

Adding a slash command

Drop a file in src/commands/ that exports a register function. Discovery is automatic, there's no app.js edit step.

'use strict';

const { COMMANDS } = require('../constants');

module.exports = function registerThing(reg) {
  reg.command(
    COMMANDS.THING,
    async ({ argv, user, reply, ctx, log }) => {
      // user.apiKey is the analyst's decrypted Elastic key
      await reply.ephemeral('hello');
    },
    {
      requireUser: true,
      usage: 'Usage: `/thing <id>`',
      minArgs: 1,
    }
  );
};

Files starting with _ are skipped, for helpers that live alongside commands but aren't commands. index.js is skipped too. Registration order is alphabetical and therefore deterministic.

A module exporting the wrong shape is logged at error and skipped rather than failing silently.

Handler args, on top of what Bolt gives you:

ctx the application context (users, state, incidents, spaces)
log scoped logger, already carrying traceId and slackUserId
reply .ephemeral(msg) / .inChannel(msg), string or Block Kit payload
user the registered user record, when requireUser is set
text, argv trimmed command text, and it split on whitespace
traceId, slackUserId

Options: requireUser, usage, minArgs, userErrorSuffix, autoAck.

Then add the command to COMMANDS in src/constants.js and to manifest.yml. Those two have to agree; constants.js exists so the mismatch is at least in one obvious place.

Adding a button

Add the action_id to ACTIONS in src/constants.js, render the button with that id, and register a handler with reg.action(...). Modal callback ids go in VIEWS the same way.

Buttons carry the incident key in value, as a bare string and nothing else. If the action mutates an incident, take a claim first:

const claim = await withClaim(ctx.incidents, key, slackUserId, async (rec) => {
  // ... network work here. the claim is already held
}, { allowExistingCase: true });

if (!claim.ok) {
  await reply.ephemeral(claimRefusal(claim));
  return;
}

withClaim releases on success as well as on failure, so a body that doesn't happen to call recordCase doesn't leave the incident wedged for a minute.

Then re-render with renderIncident(client, ctx.incidents, key). It swallows Slack failures and returns null, so a stale message never fails the operation the analyst actually asked for.

Tests

npm test
npm run test:watch
npm run test:coverage

Jest, tests/**/*.test.js. Nothing touches a real cluster or a real Slack workspace. The Elastic client is mocked and tests/setup.js pins the config the tests assume, so no .env is needed and forks can run the suite too.

setupFiles (not setupFilesAfterEach) matters here: config/index.js reads process.env at require time, and half the modules under test pull it in.

CI runs npm run check then npm test -- --ci on every push to main and every PR.

Lint and syntax checks

npm run check        # lint + syntax
npm run lint
npm run lint:fix

scripts/check-syntax.js parses every .js file with node --check. It overlaps with eslint but has no dependencies, so it still runs when node_modules is missing or eslint itself is broken.

docs/, coverage/ and data/ are in .eslintignore - the first two ship bundled third-party JS that isn't ours to lint.

Building these docs

pip install mkdocs
mkdocs serve     # http://127.0.0.1:8000
mkdocs build

Source is docs_src/, output is docs/. The built site is committed to the repo by .github/workflows/docs.yml on every push to main, which is what GitHub Pages serves. The workflow ignores changes under docs/ so it doesn't trigger itself.

Add a page by dropping the markdown in docs_src/ and adding it to nav in mkdocs.yml. Then add it to the table of contents in docs_src/index.md too.