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
7 changes: 6 additions & 1 deletion lib/render/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface RichMessageInput {
interactiveActions?: InteractiveButton[]; // server-handled (action_id) buttons — rendered before URL buttons
divider?: boolean; // divider before the actions block
footerNote?: string; // optional helpful note in the footer (e.g. "Anthropic budget: $2.43 of $25.00 this month")
footerExtra?: (string | null | undefined)[]; // extra footer segments after the site (e.g. route, source) — "where it came from"
siteLabel?: string; // source footer (preferred)
projectSlug?: string; // source footer fallback
}
Expand Down Expand Up @@ -92,7 +93,11 @@ export function richMessage(input: RichMessageInput): SlackBlock[] {
}

const source = input.siteLabel ?? input.projectSlug;
const footer = [input.footerNote, source].filter((s): s is string => Boolean(s && s.trim())).join(' · ');
// Site URL leads — it's the "where am I" anchor; then any footerExtra (route,
// source server, …); footerNote (e.g. budget) trails.
const footer = [source, ...(input.footerExtra ?? []), input.footerNote]
.filter((s): s is string => Boolean(s && s.trim()))
.join(' · ');
if (footer) blocks.push(context(footer));
return blocks;
}
26 changes: 23 additions & 3 deletions lib/render/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ function cap(s: string): string {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}

/**
* Pull just the call frames out of a stack. A JS stack starts with
* `Error: <message>` (often multi-line) and then ` at …` frames — the
* header repeats the message we already show in the section, so we drop
* everything before the first frame and cap the depth. Falls back to the
* first few lines when no `at` frames are present.
*/
function stackFrames(stack: string, depth = 6): string {
const lines = stack.split('\n');
const first = lines.findIndex((l) => /^\s*at\s/.test(l));
return (first >= 0 ? lines.slice(first) : lines)
.slice(0, depth)
.map((l) => l.trim())
.join('\n');
}

/**
* Opt-in To-Do / Remind buttons. Only rendered when the caller includes
* `'todo'` / `'remind'` in the event's `actions`, so default messages stay clean.
Expand Down Expand Up @@ -74,17 +90,21 @@ export function renderEvent(ev: ParsedEvent, ctx?: RenderContext): Rendered {
return { text: p.text, blocks: (p.blocks as SlackBlock[]) ?? [section(p.text)] };

case 'error': {
// Show the error once: message in the section, only the call frames in the
// code block (the stack's `Error: <message>` header just repeats the message).
let body = String(p.message ?? '');
if (p.stack) {
const trimmed = String(p.stack).split('\n').slice(0, 6).join('\n');
body += `\n\`\`\`${trimmed}\`\`\``;
const frames = stackFrames(String(p.stack));
if (frames) body += `\n\`\`\`${frames}\`\`\``;
}
const errorIssueActions = [...issueButtons(ev, ctx), ...actionButtonsFor(ev)];
return {
text: `Error — ${p.message}`,
blocks: richMessage({
...base, emoji, title: 'Error', body,
meta: [p.route ? `\`${p.route}\`` : null, p.source ? `source: ${p.source}` : null],
// Where it came from lives in the footer next to the site, not a
// separate middle line: `site · route · source`.
footerExtra: [p.route ? `\`${p.route}\`` : null, p.source ? String(p.source) : null],
...(errorIssueActions.length ? { interactiveActions: errorIssueActions, divider: true } : {}),
}),
};
Expand Down
8 changes: 8 additions & 0 deletions scripts/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';

// Preview/branch deploys on Vercel don't have DATABASE_URL — production owns
// the only DB. Skip cleanly there so `next build` still runs and the preview
// URL is browseable for visual review.
if (process.env.VERCEL_ENV && process.env.VERCEL_ENV !== 'production') {
console.log(`[migrate] skipping on VERCEL_ENV=${process.env.VERCEL_ENV}`);
process.exit(0);
}

const url = process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL;
if (!url) { console.error('No DATABASE_URL'); process.exit(1); }

Expand Down
12 changes: 12 additions & 0 deletions tests/render-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ describe('renderEvent — links + footerNote primitives across event types', ()
expect(footer.type).toBe('context');
expect(footer.elements[0].text).toContain('Anthropic budget');
});

it('footer leads with siteLabel before footerNote', () => {
const { blocks } = renderEvent(ev({
type: 'cron',
category: 'ops',
footerNote: 'Anthropic budget: $2.43 of $25.00 this month',
payload: { name: 'nightly', ok: true },
}), { siteLabel: 'demo.dev' });
const footer = blocks.at(-1) as unknown as { type: string; elements: { text: string }[] };
const text = footer.elements[0].text;
expect(text.indexOf('demo.dev')).toBeLessThan(text.indexOf('Anthropic budget'));
});
});

describe('renderEvent — actions: todo / remind button identity', () => {
Expand Down
24 changes: 24 additions & 0 deletions tests/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ describe('renderEvent', () => {
const { blocks } = renderEvent({ type: 'error', payload: { message: 'boom' } } as any, { siteLabel: 'decrevel.dev', projectSlug: 'decrevel-dev' });
expect(JSON.stringify(blocks)).toContain('decrevel.dev');
});

it('error: code block carries only the stack frames, not the message header', () => {
const stack = 'Error: boom happened\n at queryWithCache (/var/task/db.js:25:38430)\n at process (node:internal:104:5)';
const { blocks } = renderEvent({ type: 'error', payload: { message: 'boom happened', stack } } as any, { siteLabel: 'decrevel.dev' });
const sectionText = (blocks.find((b) => b.type === 'section') as any).text.text as string;
// message appears once (as the section lead-in), not duplicated by the stack header.
expect(sectionText.match(/boom happened/g)?.length).toBe(1);
// the code block keeps the frames…
expect(sectionText).toContain('at queryWithCache');
// …but drops the redundant "Error: <message>" header line.
expect(sectionText).not.toContain('Error: boom happened');
});

it('error: route + source land in the footer beside the site, not a separate line', () => {
const { blocks } = renderEvent(
{ type: 'error', payload: { message: 'boom', route: '/api/cron/job-search', source: 'server' } } as any,
{ siteLabel: 'poyse.io' },
);
const footer = blocks.at(-1) as unknown as { type: string; elements: { text: string }[] };
expect(footer.type).toBe('context');
expect(footer.elements[0].text).toContain('poyse.io');
expect(footer.elements[0].text).toContain('/api/cron/job-search');
expect(footer.elements[0].text).toContain('server');
});
});

describe('base-level links + footerNote primitives', () => {
Expand Down
Loading