From 8c20fb02b94729ff6850ca2c496c0d31638c765f Mon Sep 17 00:00:00 2001 From: Matt Decrevel Date: Mon, 1 Jun 2026 09:25:24 -0500 Subject: [PATCH 1/3] fix(render): footer leads with siteLabel before footerNote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorders the context footer from ' · ' to ' · '. The site URL is the 'where am I' anchor for the reader; the footerNote (e.g. 'Anthropic budget: $X of $Y this month') is secondary detail that trails it. Existing tests use .toContain so they still pass. Adds a positive ordering test that asserts siteLabel appears before footerNote. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/render/blocks.ts | 3 ++- tests/render-types.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/render/blocks.ts b/lib/render/blocks.ts index 2a45466..e3e98d7 100644 --- a/lib/render/blocks.ts +++ b/lib/render/blocks.ts @@ -92,7 +92,8 @@ 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; footerNote (e.g. budget) trails. + const footer = [source, input.footerNote].filter((s): s is string => Boolean(s && s.trim())).join(' · '); if (footer) blocks.push(context(footer)); return blocks; } diff --git a/tests/render-types.test.ts b/tests/render-types.test.ts index fa483b0..829cce2 100644 --- a/tests/render-types.test.ts +++ b/tests/render-types.test.ts @@ -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', () => { From 6227d221219dc1c8eefcced1d592ad9d571ea79b Mon Sep 17 00:00:00 2001 From: Matt Decrevel Date: Mon, 1 Jun 2026 09:44:25 -0500 Subject: [PATCH 2/3] fix(render): show errors once and move source into the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error renderer printed the message in the section AND the full stack as a code block; since a JS stack's first line is `Error: `, the text duplicated. Now the section leads with the message and the code block carries only the call frames. Route + source move from a middle meta line into the footer next to the site: `site · route · source` (via richMessage footerExtra). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/render/blocks.ts | 8 ++++++-- lib/render/index.ts | 26 +++++++++++++++++++++++--- tests/render.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/lib/render/blocks.ts b/lib/render/blocks.ts index e3e98d7..194b785 100644 --- a/lib/render/blocks.ts +++ b/lib/render/blocks.ts @@ -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 } @@ -92,8 +93,11 @@ export function richMessage(input: RichMessageInput): SlackBlock[] { } const source = input.siteLabel ?? input.projectSlug; - // Site URL leads — it's the "where am I" anchor; footerNote (e.g. budget) trails. - const footer = [source, input.footerNote].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; } diff --git a/lib/render/index.ts b/lib/render/index.ts index 81e4cec..1ce2637 100644 --- a/lib/render/index.ts +++ b/lib/render/index.ts @@ -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: ` (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. @@ -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: ` 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 } : {}), }), }; diff --git a/tests/render.test.ts b/tests/render.test.ts index 8c92858..b727bfe 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -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: " 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', () => { From a3dbee1a21c25fcf5d120852a6dc80824b58ee95 Mon Sep 17 00:00:00 2001 From: Matt Decrevel Date: Mon, 1 Jun 2026 09:46:06 -0500 Subject: [PATCH 3/3] fix(build): migrate.ts no-ops on non-prod Vercel preview deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview/branch deploys don't have DATABASE_URL — production owns the only DB. Without this guard, every PR's preview deploy fails the build on 'No DATABASE_URL', even when the PR has nothing to do with the DB (see e.g. claude/footer-site-first). Skips cleanly when VERCEL_ENV is set to anything other than 'production', so 'next build' still runs and the preview URL is browseable for visual review. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/migrate.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 87acdfe..eef091e 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -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); }