diff --git a/src/agent/kpi-record.test.ts b/src/agent/kpi-record.test.ts index 293e7b5..c98d3ec 100644 --- a/src/agent/kpi-record.test.ts +++ b/src/agent/kpi-record.test.ts @@ -4,6 +4,27 @@ import { toKpiRecord } from './kpi-record.ts' const NOW = '2026-07-31T00:00:00.000Z' +// The real tool hardcodes summary: '' and the agent is told not to touch it, so +// this is what every GitHub-only day actually looks like. +test('describes the activity when the agent supplied no summary', () => { + const record = toKpiRecord(JSON.stringify({ summary: '', commits: [1, 2, 3, 4], pullRequests: [1, 2, 3, 4, 5, 6] }), NOW) + + assert.equal(record.github_summary, '4 commits, 6 PRs on GitHub') +}) + +test('singularises a one-commit, one-PR day', () => { + const record = toKpiRecord(JSON.stringify({ commits: [1], pullRequests: [1] }), NOW) + + assert.equal(record.github_summary, '1 commit, 1 PR on GitHub') +}) + +// A genuine quiet day still reads as a day, not as a missing record. +test('describes a zero-activity day rather than leaving it blank', () => { + const record = toKpiRecord(JSON.stringify({ commits: [], pullRequests: [] }), NOW) + + assert.equal(record.github_summary, '0 commits, 0 PRs on GitHub') +}) + test('maps a complete GitHub payload', () => { const record = toKpiRecord(JSON.stringify({ summary: 'shipped the thing', commits: [1, 2, 3], pullRequests: [1] }), NOW) diff --git a/src/agent/kpi-record.ts b/src/agent/kpi-record.ts index b534be4..f192553 100644 --- a/src/agent/kpi-record.ts +++ b/src/agent/kpi-record.ts @@ -1,13 +1,23 @@ import { parseJson } from './utils.ts' import type { KpiRecord } from '../schemas/index.ts' +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` + +function describeActivity(commits: number, prs: number): string { + return `${plural(commits, 'commit')}, ${plural(prs, 'PR')} on GitHub` +} + export function toKpiRecord(githubOutput: string, now: string): KpiRecord { const data = parseJson<{ summary?: string; commits?: unknown[]; pullRequests?: unknown[] }>(githubOutput, {}) + const isDigest = Array.isArray(data.commits) || Array.isArray(data.pullRequests) + const commits_count = data.commits?.length ?? 0 + const prs_count = data.pullRequests?.length ?? 0 + const github_summary = data.summary?.trim() || (isDigest ? describeActivity(commits_count, prs_count) : '') return { - github_summary: data.summary ?? '', - commits_count: data.commits?.length ?? 0, - prs_count: data.pullRequests?.length ?? 0, + github_summary, + commits_count, + prs_count, activities: [], created_at: now, updated_at: now, diff --git a/src/agent/notify-error.test.ts b/src/agent/notify-error.test.ts new file mode 100644 index 0000000..31500fb --- /dev/null +++ b/src/agent/notify-error.test.ts @@ -0,0 +1,83 @@ +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { notifyError } from './utils.ts' + +const realFetch = globalThis.fetch +let sent: { text: string; parse_mode: string } | null = null +let status = 200 + +beforeEach(() => { + process.env.TELEGRAM_BOT_TOKEN = 'test-token' + process.env.TELEGRAM_CHAT_ID = '12345' + sent = null + status = 200 + globalThis.fetch = (async (_url: unknown, init: { body: string }) => { + sent = JSON.parse(init.body) + return new Response(JSON.stringify({ ok: status === 200 }), { status, headers: { 'content-type': 'application/json' } }) + }) as unknown as typeof fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +// Telegram's HTML mode rejects any tag outside its whitelist with a 400, and the +// alert is then never delivered. Real errors carry markup: gateway 502 bodies are +// HTML, and reasoning models emit . +test('escapes markup in the error so Telegram can parse the alert', async () => { + await notifyError('AI news search', new Error('Tavily API error 502: upstream connect error')) + + assert.ok(sent) + assert.equal(sent!.parse_mode, 'HTML') + assert.ok(!sent!.text.includes(''), 'raw would 400') + assert.ok(sent!.text.includes('<html>'), 'the error text should survive, escaped') + // The alert's own formatting must stay intact. + assert.ok(sent!.text.includes('Error:')) +}) + +test('escapes markup in the context too', async () => { + await notifyError('