diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9ed06f7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: node --test "scripts/**/*.test.mjs" diff --git a/README.md b/README.md index 7878e4a..6b4810f 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,25 @@ SecureCheck/ workflows/ scan.yml Reusable workflow; checkout, scanners, notify, upload scripts/ - notify.mjs Builds the Discord embed from scanner counts and posts it + embed.mjs Pure embed-building logic (counts, colour, fields) - unit-tested + embed.test.mjs node --test suite for embed.mjs + notify.mjs Thin entry point: reads env + Claude file, builds via embed.mjs, POSTs claude-review.mjs Sends the PR diff to Claude and emits structured findings package.json @anthropic-ai/sdk dependency for the optional Claude step LICENSE AGPL v3 COMMERCIAL.md Commercial license terms ``` +## Tests + +The embed-building logic (`scripts/embed.mjs`) is pure and unit-tested - no network, no real workflow run: + +```bash +npm test # node --test over scripts/**/*.test.mjs +``` + +CI runs the same suite on every push and pull request (`.github/workflows/ci.yml`). + --- ## License diff --git a/package.json b/package.json index bb6d0e7..878bc06 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "description": "Scripts used by the SecureCheck reusable security-scan GitHub workflow.", + "scripts": { + "test": "node --test \"scripts/**/*.test.mjs\"" + }, "dependencies": { "@anthropic-ai/sdk": "^0.37.0" } diff --git a/scripts/embed.mjs b/scripts/embed.mjs new file mode 100644 index 0000000..3a3c83f --- /dev/null +++ b/scripts/embed.mjs @@ -0,0 +1,196 @@ +// Pure embed-building logic for the SecureCheck Discord notifier. +// +// Everything here is a side-effect-free function of its inputs (the workflow's env +// bag plus the parsed Claude findings), so it can be unit-tested under `node --test` +// without a network call or a real workflow run. `notify.mjs` is the thin entry +// point that reads env, reads the Claude file, builds the payload here, and POSTs it. + +/** Parse an env value to a non-negative-ish integer, defaulting to 0. */ +export function toInt(v) { + const n = parseInt(v ?? '0', 10); + return Number.isFinite(n) ? n : 0; +} + +/** First line of a (possibly multi-line) string. */ +export function firstLine(s) { + return (s ?? '').split('\n')[0]; +} + +/** Human-readable duration: seconds, or `MmSSs` past a minute. */ +export function formatDuration(seconds) { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}m${s.toString().padStart(2, '0')}s`; +} + +/** The 3 slowest non-zero scanner stages, descending. */ +export function topDurations(d) { + return Object.entries(d) + .filter(([, v]) => v > 0) + .sort(([, a], [, b]) => b - a) + .slice(0, 3); +} + +/** Per-scanner finding counts read from the env bag. */ +export function readCounts(env) { + return { + gitleaks: toInt(env.GITLEAKS_COUNT), + semgrep: toInt(env.SEMGREP_COUNT), + trivy: toInt(env.TRIVY_COUNT), + trivyVuln: toInt(env.TRIVY_VULN), + trivyMisconfig: toInt(env.TRIVY_MISCONFIG), + trivyLicense: toInt(env.TRIVY_LICENSE), + eslint: toInt(env.ESLINT_COUNT), + ruff: toInt(env.RUFF_COUNT), + rust: toInt(env.RUST_COUNT), + dotnet: toInt(env.DOTNET_COUNT), + lizard: toInt(env.LIZARD_COUNT), + jscpd: toInt(env.JSCPD_COUNT), + jscpdLines: toInt(env.JSCPD_DUPLICATED_LINES), + claude: toInt(env.CLAUDE_COUNT), + }; +} + +/** Per-stage durations (seconds) read from the env bag. */ +export function readDurations(env) { + return { + gitleaks: toInt(env.GITLEAKS_DURATION), + semgrep: toInt(env.SEMGREP_DURATION), + trivy: toInt(env.TRIVY_DURATION), + eslint: toInt(env.ESLINT_DURATION), + ruff: toInt(env.RUFF_DURATION), + rust: toInt(env.RUST_DURATION), + dotnet: toInt(env.DOTNET_DURATION), + lizard: toInt(env.LIZARD_DURATION), + jscpd: toInt(env.JSCPD_DURATION), + claude: toInt(env.CLAUDE_DURATION), + }; +} + +/** Which optional stages actually ran (drives "skipped" vs a count). */ +export function readRan(env) { + return { + eslint: env.HAS_JS === 'true' && env.ESLINT_CFG === 'true', + ruff: env.HAS_PY === 'true', + rust: env.HAS_RUST === 'true', + dotnet: env.HAS_DOTNET === 'true', + claude: env.CLAUDE_ENABLED === 'true', + }; +} + +/** Security / quality / metrics subtotals and the grand total. */ +export function totals(counts) { + const securityTotal = counts.gitleaks + counts.semgrep + counts.trivy + counts.claude; + const qualityTotal = counts.eslint + counts.ruff + counts.rust + counts.dotnet; + const metricsTotal = counts.lizard + counts.jscpd; + return { securityTotal, qualityTotal, metricsTotal, total: securityTotal + qualityTotal + metricsTotal }; +} + +/** Embed colour: red on a gitleaks hit, orange on many findings, yellow on any, green when clean. */ +export function pickColor(total, counts) { + return total === 0 ? 0x2ecc71 + : counts.gitleaks > 0 ? 0xe74c3c + : total > 20 ? 0xe67e22 + : 0xf1c40f; +} + +/** Build the Discord embed object from the env bag and the severe Claude findings. */ +export function buildEmbed(env, { severeClaude = [], timestamp } = {}) { + const counts = readCounts(env); + const durations = readDurations(env); + const ran = readRan(env); + const { securityTotal, qualityTotal, metricsTotal, total } = totals(counts); + + const isPR = env.EVENT_NAME === 'pull_request'; + const color = pickColor(total, counts); + + const title = isPR + ? `[${env.REPO}] PR #${env.PR_NUMBER}` + : `[${env.REPO}] push by ${env.ACTOR}`; + + const description = isPR + ? env.PR_TITLE || '(no title)' + : firstLine(env.COMMIT_MESSAGE) || `commit ${(env.COMMIT_SHA || '').slice(0, 7)}`; + + const securityLines = [ + `Gitleaks: **${counts.gitleaks}**`, + `Semgrep: **${counts.semgrep}**`, + `Trivy: **${counts.trivy}** (vuln **${counts.trivyVuln}** / misconfig **${counts.trivyMisconfig}** / license **${counts.trivyLicense}**)`, + ran.claude ? `Claude: **${counts.claude}**` : 'Claude: _skipped_', + ]; + + const qualityLines = [ + ran.eslint ? `ESLint: **${counts.eslint}**` : 'ESLint: _skipped_', + ran.ruff ? `ruff: **${counts.ruff}**` : 'ruff: _skipped_', + ran.rust ? `Rust (clippy+fmt): **${counts.rust}**` : 'Rust (clippy+fmt): _skipped_', + ran.dotnet ? `dotnet format: **${counts.dotnet}**` : 'dotnet format: _skipped_', + ]; + + const metricsLines = [ + `Lizard hotspots (CCN >= 15): **${counts.lizard}**`, + `jscpd clones: **${counts.jscpd}** (duplicated lines: **${counts.jscpdLines}**)`, + ]; + + const fields = [ + { name: 'Security', value: securityLines.join('\n'), inline: false }, + { name: 'Quality', value: qualityLines.join('\n'), inline: false }, + { name: 'Metrics', value: metricsLines.join('\n'), inline: false }, + ]; + + if (severeClaude.length > 0) { + fields.push({ + name: 'Claude high-severity', + value: severeClaude + .slice(0, 5) + .map(f => `- \`${f.file ?? '?'}\`${f.line ? `:${f.line}` : ''} - ${f.title}`) + .join('\n'), + inline: false, + }); + } + + const totalDuration = Object.values(durations).reduce((a, b) => a + b, 0); + const perfLines = [ + `Total scan time: **${formatDuration(totalDuration)}**`, + topDurations(durations).map(([k, v]) => `\`${k}\`: ${formatDuration(v)}`).join(' · '), + ].filter(Boolean); + + fields.push({ name: 'Performance', value: perfLines.join('\n'), inline: false }); + + fields.push({ + name: 'Links', + value: [ + `[Workflow run](${env.RUN_URL})`, + isPR && env.PR_URL ? `[Pull request](${env.PR_URL})` : null, + ].filter(Boolean).join(' · '), + inline: false, + }); + + const footer = total === 0 + ? 'All clear' + : `${total} finding${total === 1 ? '' : 's'} (security ${securityTotal}, quality ${qualityTotal}, metrics ${metricsTotal})`; + + return { + title: title.slice(0, 256), + description: description.slice(0, 2048), + color, + url: env.RUN_URL, + fields, + footer: { text: footer }, + timestamp: timestamp ?? new Date().toISOString(), + }; +} + +/** The full Discord webhook payload (username + the single embed). */ +export function buildPayload(env, opts = {}) { + return { + username: 'Security Scanner', + embeds: [buildEmbed(env, opts)], + }; +} + +/** One-line summary used for the notifier's stdout log. */ +export function summaryLine(env) { + const { securityTotal, qualityTotal, metricsTotal, total } = totals(readCounts(env)); + return `Security ${securityTotal}, quality ${qualityTotal}, metrics ${metricsTotal} (total ${total}).`; +} diff --git a/scripts/embed.test.mjs b/scripts/embed.test.mjs new file mode 100644 index 0000000..11ca64a --- /dev/null +++ b/scripts/embed.test.mjs @@ -0,0 +1,165 @@ +// Unit tests for the pure Discord-embed logic in embed.mjs. No network, no real +// workflow run. Run with: node --test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + toInt, + firstLine, + formatDuration, + topDurations, + readCounts, + totals, + pickColor, + buildEmbed, + buildPayload, + summaryLine, +} from './embed.mjs'; + +const TS = '2026-06-08T12:00:00.000Z'; + +// A clean push env with every count at 0 and nothing optional enabled. +function cleanEnv(overrides = {}) { + return { + EVENT_NAME: 'push', + REPO: 'acme/widget', + ACTOR: 'alice', + COMMIT_MESSAGE: 'fix: tidy up\n\nbody', + COMMIT_SHA: 'abcdef1234567', + RUN_URL: 'https://github.com/acme/widget/actions/runs/1', + ...overrides, + }; +} + +function fieldNamed(embed, name) { + return embed.fields.find(f => f.name === name); +} + +test('toInt parses, defaults, and rejects garbage', () => { + assert.equal(toInt('5'), 5); + assert.equal(toInt(undefined), 0); + assert.equal(toInt('nope'), 0); + assert.equal(toInt(''), 0); +}); + +test('firstLine returns the first line only', () => { + assert.equal(firstLine('a\nb\nc'), 'a'); + assert.equal(firstLine(undefined), ''); +}); + +test('formatDuration formats seconds and minutes', () => { + assert.equal(formatDuration(0), '0s'); + assert.equal(formatDuration(59), '59s'); + assert.equal(formatDuration(60), '1m00s'); + assert.equal(formatDuration(125), '2m05s'); +}); + +test('topDurations returns the 3 slowest non-zero stages, descending', () => { + const top = topDurations({ a: 10, b: 0, c: 30, d: 5, e: 20 }); + assert.deepEqual(top.map(([k]) => k), ['c', 'e', 'a']); +}); + +test('totals computes the security/quality/metrics subtotals', () => { + const counts = readCounts({ + GITLEAKS_COUNT: '1', SEMGREP_COUNT: '2', TRIVY_COUNT: '3', CLAUDE_COUNT: '4', + ESLINT_COUNT: '5', RUFF_COUNT: '6', RUST_COUNT: '7', DOTNET_COUNT: '8', + LIZARD_COUNT: '9', JSCPD_COUNT: '10', + }); + const t = totals(counts); + assert.equal(t.securityTotal, 1 + 2 + 3 + 4); + assert.equal(t.qualityTotal, 5 + 6 + 7 + 8); + assert.equal(t.metricsTotal, 9 + 10); + assert.equal(t.total, 55); +}); + +test('pickColor: clean=green, gitleaks=red, >20=orange, else yellow', () => { + assert.equal(pickColor(0, { gitleaks: 0 }), 0x2ecc71); + assert.equal(pickColor(3, { gitleaks: 1 }), 0xe74c3c); // gitleaks beats everything + assert.equal(pickColor(25, { gitleaks: 0 }), 0xe67e22); // many findings + assert.equal(pickColor(4, { gitleaks: 0 }), 0xf1c40f); // some findings +}); + +test('clean push embed is green, "All clear", and marks optional stages skipped', () => { + const embed = buildEmbed(cleanEnv(), { timestamp: TS }); + assert.equal(embed.color, 0x2ecc71); + assert.equal(embed.footer.text, 'All clear'); + assert.equal(embed.title, '[acme/widget] push by alice'); + assert.equal(embed.description, 'fix: tidy up'); // first line only + assert.equal(embed.timestamp, TS); + assert.match(fieldNamed(embed, 'Quality').value, /ESLint: _skipped_/); + assert.match(fieldNamed(embed, 'Security').value, /Claude: _skipped_/); + // a clean push has no PR link + assert.equal(fieldNamed(embed, 'Links').value, '[Workflow run](https://github.com/acme/widget/actions/runs/1)'); +}); + +test('a gitleaks hit turns the embed red and the footer plural-aware', () => { + const embed = buildEmbed(cleanEnv({ GITLEAKS_COUNT: '1' }), { timestamp: TS }); + assert.equal(embed.color, 0xe74c3c); + assert.equal(embed.footer.text, '1 finding (security 1, quality 0, metrics 0)'); +}); + +test('more than 20 findings (no gitleaks) is orange', () => { + const embed = buildEmbed(cleanEnv({ SEMGREP_COUNT: '25' }), { timestamp: TS }); + assert.equal(embed.color, 0xe67e22); + assert.match(embed.footer.text, /^25 findings/); +}); + +test('PR embed uses the PR title, number, and adds a PR link', () => { + const env = cleanEnv({ + EVENT_NAME: 'pull_request', + PR_NUMBER: '42', + PR_TITLE: 'Add rate limiting', + PR_URL: 'https://github.com/acme/widget/pull/42', + }); + const embed = buildEmbed(env, { timestamp: TS }); + assert.equal(embed.title, '[acme/widget] PR #42'); + assert.equal(embed.description, 'Add rate limiting'); + assert.match(fieldNamed(embed, 'Links').value, /\[Pull request\]\(https:\/\/github\.com\/acme\/widget\/pull\/42\)/); +}); + +test('enabled optional stages show counts instead of skipped', () => { + const env = cleanEnv({ + HAS_JS: 'true', ESLINT_CFG: 'true', ESLINT_COUNT: '3', + HAS_PY: 'true', RUFF_COUNT: '1', + CLAUDE_ENABLED: 'true', CLAUDE_COUNT: '2', + }); + const embed = buildEmbed(env, { timestamp: TS }); + assert.match(fieldNamed(embed, 'Quality').value, /ESLint: \*\*3\*\*/); + assert.match(fieldNamed(embed, 'Quality').value, /ruff: \*\*1\*\*/); + assert.match(fieldNamed(embed, 'Security').value, /Claude: \*\*2\*\*/); +}); + +test('severe Claude findings are inlined, capped at 5, with file:line', () => { + const severeClaude = Array.from({ length: 7 }, (_, i) => ({ + file: `src/f${i}.ts`, line: i + 1, title: `issue ${i}`, severity: 'high', + })); + const embed = buildEmbed(cleanEnv(), { severeClaude, timestamp: TS }); + const field = fieldNamed(embed, 'Claude high-severity'); + assert.ok(field, 'expected a Claude high-severity field'); + assert.equal(field.value.split('\n').length, 5); // capped at 5 + assert.match(field.value, /`src\/f0\.ts`:1 - issue 0/); +}); + +test('no Claude field when there are no severe findings', () => { + const embed = buildEmbed(cleanEnv(), { severeClaude: [], timestamp: TS }); + assert.equal(fieldNamed(embed, 'Claude high-severity'), undefined); +}); + +test('performance field surfaces total time and the slowest stages', () => { + const env = cleanEnv({ GITLEAKS_DURATION: '10', TRIVY_DURATION: '90', SEMGREP_DURATION: '30' }); + const embed = buildEmbed(env, { timestamp: TS }); + const perf = fieldNamed(embed, 'Performance').value; + assert.match(perf, /Total scan time: \*\*2m10s\*\*/); // 130s + assert.match(perf, /`trivy`: 1m30s/); +}); + +test('buildPayload wraps a single embed under the bot username', () => { + const payload = buildPayload(cleanEnv(), { timestamp: TS }); + assert.equal(payload.username, 'Security Scanner'); + assert.equal(payload.embeds.length, 1); + assert.equal(payload.embeds[0].footer.text, 'All clear'); +}); + +test('summaryLine reports the subtotals for stdout', () => { + const env = cleanEnv({ GITLEAKS_COUNT: '1', ESLINT_COUNT: '2', LIZARD_COUNT: '3' }); + assert.equal(summaryLine(env), 'Security 1, quality 2, metrics 3 (total 6).'); +}); diff --git a/scripts/notify.mjs b/scripts/notify.mjs index c314688..9e60c38 100644 --- a/scripts/notify.mjs +++ b/scripts/notify.mjs @@ -1,7 +1,13 @@ // Post a single Discord embed summarizing scanner results for one workflow run. // Posts on every run so each repo's scanner activity is visible in the channel. +// +// This is the thin side-effecting entry point: it reads the env bag and the Claude +// findings file, builds the payload via the pure logic in ./embed.mjs, and POSTs it. +// All the formatting lives in embed.mjs so it can be unit-tested without a network +// call. See scripts/embed.test.mjs. import fs from 'node:fs'; +import { buildPayload, summaryLine } from './embed.mjs'; const env = process.env; @@ -10,143 +16,8 @@ if (!env.DISCORD_WEBHOOK_URL) { process.exit(0); } -const counts = { - gitleaks: toInt(env.GITLEAKS_COUNT), - semgrep: toInt(env.SEMGREP_COUNT), - trivy: toInt(env.TRIVY_COUNT), - trivyVuln: toInt(env.TRIVY_VULN), - trivyMisconfig: toInt(env.TRIVY_MISCONFIG), - trivyLicense: toInt(env.TRIVY_LICENSE), - eslint: toInt(env.ESLINT_COUNT), - ruff: toInt(env.RUFF_COUNT), - rust: toInt(env.RUST_COUNT), - dotnet: toInt(env.DOTNET_COUNT), - lizard: toInt(env.LIZARD_COUNT), - jscpd: toInt(env.JSCPD_COUNT), - jscpdLines: toInt(env.JSCPD_DUPLICATED_LINES), - claude: toInt(env.CLAUDE_COUNT), -}; - -const durations = { - gitleaks: toInt(env.GITLEAKS_DURATION), - semgrep: toInt(env.SEMGREP_DURATION), - trivy: toInt(env.TRIVY_DURATION), - eslint: toInt(env.ESLINT_DURATION), - ruff: toInt(env.RUFF_DURATION), - rust: toInt(env.RUST_DURATION), - dotnet: toInt(env.DOTNET_DURATION), - lizard: toInt(env.LIZARD_DURATION), - jscpd: toInt(env.JSCPD_DURATION), - claude: toInt(env.CLAUDE_DURATION), -}; - -const ran = { - eslint: env.HAS_JS === 'true' && env.ESLINT_CFG === 'true', - ruff: env.HAS_PY === 'true', - rust: env.HAS_RUST === 'true', - dotnet: env.HAS_DOTNET === 'true', - claude: env.CLAUDE_ENABLED === 'true', -}; - -const securityTotal = counts.gitleaks + counts.semgrep + counts.trivy + counts.claude; -const qualityTotal = - counts.eslint + counts.ruff + counts.rust + counts.dotnet; -const metricsTotal = counts.lizard + counts.jscpd; -const total = securityTotal + qualityTotal + metricsTotal; - -const isPR = env.EVENT_NAME === 'pull_request'; - -// Color: red on gitleaks, orange on many findings, yellow on any, green clean -const color = - total === 0 ? 0x2ecc71 - : counts.gitleaks > 0 ? 0xe74c3c - : total > 20 ? 0xe67e22 - : 0xf1c40f; - -const severeClaude = readSevereClaudeFindings(); -const title = isPR - ? `[${env.REPO}] PR #${env.PR_NUMBER}` - : `[${env.REPO}] push by ${env.ACTOR}`; - -const description = isPR - ? env.PR_TITLE || '(no title)' - : firstLine(env.COMMIT_MESSAGE) || `commit ${(env.COMMIT_SHA || '').slice(0, 7)}`; - -const securityLines = [ - `Gitleaks: **${counts.gitleaks}**`, - `Semgrep: **${counts.semgrep}**`, - `Trivy: **${counts.trivy}** (vuln **${counts.trivyVuln}** / misconfig **${counts.trivyMisconfig}** / license **${counts.trivyLicense}**)`, - ran.claude ? `Claude: **${counts.claude}**` : 'Claude: _skipped_', -]; - -const qualityLines = [ - ran.eslint ? `ESLint: **${counts.eslint}**` : 'ESLint: _skipped_', - ran.ruff ? `ruff: **${counts.ruff}**` : 'ruff: _skipped_', - ran.rust ? `Rust (clippy+fmt): **${counts.rust}**` : 'Rust (clippy+fmt): _skipped_', - ran.dotnet ? `dotnet format: **${counts.dotnet}**` : 'dotnet format: _skipped_', -]; - -const metricsLines = [ - `Lizard hotspots (CCN >= 15): **${counts.lizard}**`, - `jscpd clones: **${counts.jscpd}** (duplicated lines: **${counts.jscpdLines}**)`, -]; - -const fields = [ - { name: 'Security', value: securityLines.join('\n'), inline: false }, - { name: 'Quality', value: qualityLines.join('\n'), inline: false }, - { name: 'Metrics', value: metricsLines.join('\n'), inline: false }, -]; - -if (severeClaude.length > 0) { - fields.push({ - name: 'Claude high-severity', - value: severeClaude - .slice(0, 5) - .map(f => `- \`${f.file ?? '?'}\`${f.line ? `:${f.line}` : ''} - ${f.title}`) - .join('\n'), - inline: false, - }); -} - -const totalDuration = Object.values(durations).reduce((a, b) => a + b, 0); -const perfLines = [ - `Total scan time: **${formatDuration(totalDuration)}**`, - topDurations(durations).map(([k, v]) => `\`${k}\`: ${formatDuration(v)}`).join(' · '), -].filter(Boolean); - -fields.push({ - name: 'Performance', - value: perfLines.join('\n'), - inline: false, -}); - -fields.push({ - name: 'Links', - value: [ - `[Workflow run](${env.RUN_URL})`, - isPR && env.PR_URL ? `[Pull request](${env.PR_URL})` : null, - ].filter(Boolean).join(' · '), - inline: false, -}); - -const footer = total === 0 - ? 'All clear' - : `${total} finding${total === 1 ? '' : 's'} (security ${securityTotal}, quality ${qualityTotal}, metrics ${metricsTotal})`; - -const embed = { - title: title.slice(0, 256), - description: description.slice(0, 2048), - color, - url: env.RUN_URL, - fields, - footer: { text: footer }, - timestamp: new Date().toISOString(), -}; - -const payload = { - username: 'Security Scanner', - embeds: [embed], -}; +const severeClaude = readSevereClaudeFindings(env); +const payload = buildPayload(env, { severeClaude }); const res = await fetch(env.DISCORD_WEBHOOK_URL, { method: 'POST', @@ -159,33 +30,11 @@ if (!res.ok) { console.error(`Discord webhook failed: ${res.status} ${body}`); process.exit(1); } -console.log(`Notified Discord. Security ${securityTotal}, quality ${qualityTotal}, metrics ${metricsTotal} (total ${total}).`); - -function toInt(v) { - const n = parseInt(v ?? '0', 10); - return Number.isFinite(n) ? n : 0; -} - -function firstLine(s) { - return (s ?? '').split('\n')[0]; -} - -function formatDuration(seconds) { - if (seconds < 60) return `${seconds}s`; - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}m${s.toString().padStart(2, '0')}s`; -} - -function topDurations(d) { - return Object.entries(d) - .filter(([, v]) => v > 0) - .sort(([, a], [, b]) => b - a) - .slice(0, 3); -} +console.log(`Notified Discord. ${summaryLine(env)}`); -function readSevereClaudeFindings() { - if (env.CLAUDE_ENABLED !== 'true') return []; +/** Read and filter the optional Claude review output to critical/high findings. */ +function readSevereClaudeFindings(e) { + if (e.CLAUDE_ENABLED !== 'true') return []; try { const raw = fs.readFileSync('/tmp/claude.json', 'utf8'); const arr = JSON.parse(raw);