also looking at this
feat(events): one notification story — fire every declared event, trim setup to its real job
#1449
14 files
+308
-81
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -18,6 +18,7 @@ import { | ||
| 18 | 18 | parseDashboardActions, |
| 19 | 19 | uncheckDashboardActions, |
| 20 | 20 | } from '../src/dashboard/dashboard-actions' |
| 21 | import { emitEvent } from '../src/events' | |
| 21 | 22 | import { manifestUpdates, parseManifest } from '../src/pr/pr-manifest' |
| 22 | 23 | import { |
| 23 | 24 | analyzeProject, |
| @@ -785,6 +786,7 @@ cli | ||
| 785 | 786 | } |
| 786 | 787 | catch (error) { |
| 787 | 788 | logger.error('Scan failed:', error) |
| 789 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'run.failed', { command: 'scan', error: error instanceof Error ? error.message : String(error) }) | |
| 788 | 790 | process.exit(1) |
| 789 | 791 | } |
| 790 | 792 | }) |
| @@ -845,6 +847,7 @@ cli | ||
| 845 | 847 | } |
| 846 | 848 | catch (error) { |
| 847 | 849 | logger.error('Dashboard creation failed:', error) |
| 850 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'run.failed', { command: 'dashboard', error: error instanceof Error ? error.message : String(error) }) | |
| 848 | 851 | process.exit(1) |
| 849 | 852 | } |
| 850 | 853 | }) |
| @@ -929,6 +932,7 @@ cli | ||
| 929 | 932 | } |
| 930 | 933 | catch (error) { |
| 931 | 934 | logger.error('Update failed:', error) |
| 935 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'run.failed', { command: 'update', error: error instanceof Error ? error.message : String(error) }) | |
| 932 | 936 | process.exit(1) |
| 933 | 937 | } |
| 934 | 938 | }) |
| @@ -1334,6 +1338,7 @@ cli | ||
| 1334 | 1338 | } |
| 1335 | 1339 | catch (error) { |
| 1336 | 1340 | logger.error('update-check failed:', error) |
| 1341 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'run.failed', { command: 'update-check', error: error instanceof Error ? error.message : String(error) }) | |
| 1337 | 1342 | process.exit(1) |
| 1338 | 1343 | } |
| 1339 | 1344 | }) |
| @@ -1405,6 +1410,7 @@ cli | ||
| 1405 | 1410 | } |
| 1406 | 1411 | catch (error) { |
| 1407 | 1412 | logger.error('fix-ci failed:', error) |
| 1413 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'run.failed', { command: 'fix-ci', error: error instanceof Error ? error.message : String(error) }) | |
| 1408 | 1414 | process.exit(1) |
| 1409 | 1415 | } |
| 1410 | 1416 | }) |
| @@ -1661,6 +1667,10 @@ cli | ||
| 1661 | 1667 | }) |
| 1662 | 1668 | |
| 1663 | 1669 | logger.success(`✅ ${status}`) |
| 1670 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'review.completed', { | |
| 1671 | number: Number.parseInt(prNumber, 10), | |
| 1672 | findings: result?.findings.length ?? 0, | |
| 1673 | }) | |
| 1664 | 1674 | |
| 1665 | 1675 | // `--format` and `--fail-on` were accepted on this path and applied |
| 1666 | 1676 | // only on the local one, so a pull request review could not be |
| @@ -1764,6 +1774,7 @@ cli | ||
| 1764 | 1774 | } |
| 1765 | 1775 | catch (error) { |
| 1766 | 1776 | logger.error('Review failed:', error) |
| 1777 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'run.failed', { command: 'review', error: error instanceof Error ? error.message : String(error) }) | |
| 1767 | 1778 | process.exit(1) |
| 1768 | 1779 | } |
| 1769 | 1780 | }) |
| @@ -3169,6 +3180,12 @@ cli | ||
| 3169 | 3180 | logger.info(`${gateIcon(result)} ${result.name}: ${result.summary}`) |
| 3170 | 3181 | |
| 3171 | 3182 | const blocking = summary.conclusion === 'failure' |
| 3183 | if (blocking) { | |
| 3184 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'gate.failed', { | |
| 3185 | number, | |
| 3186 | checks: results.filter(result => !result.passed).map(result => result.name), | |
| 3187 | }) | |
| 3188 | } | |
| 3172 | 3189 | |
| 3173 | 3190 | if (options.dryRun || !supports(provider, 'checkRuns', 'createCheckRun')) { |
| 3174 | 3191 | logger.info(summary.summary) |
| @@ -488,6 +488,26 @@ interface NotificationSettings { | ||
| 488 | 488 | Credentials are referenced by environment variable name, never inlined, so a |
| 489 | 489 | destination can be committed without committing its credential. |
| 490 | 490 | |
| 491 | Every event can be named in an `events` filter, and every one of them fires: | |
| 492 | ||
| 493 | | Event | Fired when | | |
| 494 | |-------|-----------| | |
| 495 | | `scan.completed` | a dependency scan finishes, with update counts | | |
| 496 | | `pr.created` | buddy opens a pull request | | |
| 497 | | `pr.updated` | buddy updates or reopens an existing pull request | | |
| 498 | | `pr.closed` | buddy auto-closes a pull request (satisfied, obsolete, or no longer produced by the configuration) | | |
| 499 | | `pr.merged` | buddy auto-merges an eligible pull request | | |
| 500 | | `security.advisories` | a scan flags dependencies with known advisories | | |
| 501 | | `dashboard.updated` | the dependency dashboard issue is created or refreshed | | |
| 502 | | `review.completed` | a pull request review finishes, with the finding count | | |
| 503 | | `gate.failed` | merge gates conclude with a blocking failure | | |
| 504 | | `fixci.completed` | a fix-ci run completes its diagnosis or repair | | |
| 505 | | `run.failed` | a runtime command (scan, update, update-check, dashboard, review, fix-ci) fails | | |
| 506 | ||
| 507 | Webhook consumers receive `{ version, event, payload, sentAt }` JSON. When | |
| 508 | `secretEnv` is set, each request carries an `x-buddy-signature: sha256=<hmac>` | |
| 509 | header signing the raw body. | |
| 510 | ||
| 491 | 511 | ## Pull Requests |
| 492 | 512 | |
| 493 | 513 | ```typescript |
| @@ -568,10 +568,22 @@ export class Buddy { | ||
| 568 | 568 | |
| 569 | 569 | try { |
| 570 | 570 | const service = new SecurityAdvisoryService(this.logger) |
| 571 | return await service.annotateUpdates( | |
| 571 | const annotated = await service.annotateUpdates( | |
| 572 | 572 | updates, |
| 573 | 573 | this.config.security?.minimumSeverity ?? 'low', |
| 574 | 574 | ) |
| 575 | ||
| 576 | const flagged = annotated.filter(update => update.securityAdvisories?.length) | |
| 577 | if (flagged.length > 0) { | |
| 578 | await this.events.emit('security.advisories', { | |
| 579 | packages: flagged.map(update => ({ | |
| 580 | name: update.name, | |
| 581 | severity: update.securityAdvisories?.[0]?.severity ?? 'unknown', | |
| 582 | })), | |
| 583 | }) | |
| 584 | } | |
| 585 | ||
| 586 | return annotated | |
| 575 | 587 | } |
| 576 | 588 | catch (error) { |
| 577 | 589 | this.logger.warn('Security advisory lookup failed, continuing without it:', error) |
| @@ -752,6 +764,7 @@ export class Buddy { | ||
| 752 | 764 | } |
| 753 | 765 | |
| 754 | 766 | await gitProvider.closePullRequest(existingPR.number) |
| 767 | await this.events.emit('pr.closed', { number: existingPR.number, title: existingPR.title, reason: 'no longer produced by the current configuration' }) | |
| 755 | 768 | // The pull request is closed either way; a branch that would |
| 756 | 769 | // not delete is worth a warning, not a failed run. |
| 757 | 770 | try { |
| @@ -860,6 +873,7 @@ export class Buddy { | ||
| 860 | 873 | assignees: this.config.pullRequest?.assignees, |
| 861 | 874 | }) |
| 862 | 875 | |
| 876 | await this.events.emit('pr.updated', { number: existingPR.number, title: prTitle, url: existingPR.url }) | |
| 863 | 877 | this.logger.success(`✅ Updated existing PR #${existingPR.number}: ${prTitle}`) |
| 864 | 878 | this.logger.info(`🔗 ${existingPR.url}`) |
| 865 | 879 | continue |
| @@ -904,6 +918,7 @@ export class Buddy { | ||
| 904 | 918 | assignees: this.config.pullRequest?.assignees, |
| 905 | 919 | }) |
| 906 | 920 | |
| 921 | await this.events.emit('pr.updated', { number: existingPRForBranch.number, title: prTitle, url: existingPRForBranch.url }) | |
| 907 | 922 | this.logger.success(`✅ Updated existing PR #${existingPRForBranch.number}: ${prTitle}`) |
| 908 | 923 | this.logger.info(`🔗 ${existingPRForBranch.url}`) |
| 909 | 924 | continue |
| @@ -950,6 +965,7 @@ export class Buddy { | ||
| 950 | 965 | assignees: this.config.pullRequest?.assignees, |
| 951 | 966 | }) |
| 952 | 967 | |
| 968 | await this.events.emit('pr.updated', { number: recentlyClosed.number, title: prTitle, url: recentlyClosed.url }) | |
| 953 | 969 | this.logger.success(`✅ Reopened and updated PR #${recentlyClosed.number}: ${prTitle}`) |
| 954 | 970 | this.logger.info(`🔗 ${recentlyClosed.url}`) |
| 955 | 971 | reopened = true |
| @@ -1021,6 +1037,7 @@ export class Buddy { | ||
| 1021 | 1037 | assignees: this.config.pullRequest?.assignees, |
| 1022 | 1038 | }) |
| 1023 | 1039 | |
| 1040 | await this.events.emit('pr.updated', { number: recentlyClosed.number, title: prTitle, url: recentlyClosed.url }) | |
| 1024 | 1041 | this.logger.success(`✅ Reopened and updated PR #${recentlyClosed.number}: ${prTitle}`) |
| 1025 | 1042 | this.logger.info(`🔗 ${recentlyClosed.url}`) |
| 1026 | 1043 | reopenedFromClosed = true |
| @@ -2966,6 +2983,7 @@ export class Buddy { | ||
| 2966 | 2983 | } |
| 2967 | 2984 | |
| 2968 | 2985 | await gitProvider.closePullRequest(pr.number) |
| 2986 | await this.events.emit('pr.closed', { number: pr.number, title: pr.title, reason: 'obsolete — its dependency files no longer exist' }) | |
| 2969 | 2987 | |
| 2970 | 2988 | // Try to delete the branch if it's a buddy branch |
| 2971 | 2989 | if (pr.head.startsWith('buddy/')) { |
| @@ -3197,6 +3215,7 @@ export class Buddy { | ||
| 3197 | 3215 | } |
| 3198 | 3216 | |
| 3199 | 3217 | await gitProvider.closePullRequest(pr.number) |
| 3218 | await this.events.emit('pr.closed', { number: pr.number, title: pr.title, reason: 'satisfied — every package already at or beyond target' }) | |
| 3200 | 3219 | |
| 3201 | 3220 | // Try to delete the branch if it's a buddy branch |
| 3202 | 3221 | if (pr.head.startsWith('buddy/')) { |
| @@ -3384,6 +3403,7 @@ export class Buddy { | ||
| 3384 | 3403 | } |
| 3385 | 3404 | |
| 3386 | 3405 | this.logger.success(`✅ Dashboard updated: ${issue.url}`) |
| 3406 | await this.events.emit('dashboard.updated', { number: issue.number, url: issue.url }) | |
| 3387 | 3407 | return issue |
| 3388 | 3408 | } |
| 3389 | 3409 | catch (error) { |
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * log, diagnose, report, count the attempt — and a second copy would drift |
| 7 | 7 | * from the first the way the two review paths did. |
| 8 | 8 | */ |
| 9 | import { emitEvent } from '../events' | |
| 9 | 10 | import type { PullRequest } from '../types' |
| 10 | 11 | import type { BuddyConfig } from '../types' |
| 11 | 12 | import type { GitProvider } from '../git/provider' |
| @@ -172,6 +173,12 @@ export async function runFixCi(options: RunFixCiOptions): Promise<string> { | ||
| 172 | 173 | } |
| 173 | 174 | } |
| 174 | 175 | |
| 176 | await emitEvent(config.notifications as Parameters<typeof emitEvent>[0], 'fixci.completed', { | |
| 177 | ...(options.prNumber ? { number: options.prNumber } : {}), | |
| 178 | action: outcome.action, | |
| 179 | fixed: outcome.fixed, | |
| 180 | }) | |
| 181 | ||
| 175 | 182 | return `${outcome.action}${outcome.fixed ? ' (fixed)' : ''}` |
| 176 | 183 | } |
| 177 | 184 | |
| @@ -106,6 +106,30 @@ export function describeEvent<K extends keyof BuddyEvents>(event: K, payload: Bu | ||
| 106 | 106 | const advisories = payload as BuddyEvents['security.advisories'] |
| 107 | 107 | return `${advisories.packages.length} dependency/dependencies with known advisories` |
| 108 | 108 | } |
| 109 | case 'pr.updated': { | |
| 110 | const pr = payload as BuddyEvents['pr.updated'] | |
| 111 | return `Updated #${pr.number}: ${pr.title}` | |
| 112 | } | |
| 113 | case 'pr.closed': { | |
| 114 | const pr = payload as BuddyEvents['pr.closed'] | |
| 115 | return `Closed #${pr.number} (${pr.reason}): ${pr.title}` | |
| 116 | } | |
| 117 | case 'dashboard.updated': { | |
| 118 | const dashboard = payload as BuddyEvents['dashboard.updated'] | |
| 119 | return `Dashboard refreshed: #${dashboard.number}` | |
| 120 | } | |
| 121 | case 'review.completed': { | |
| 122 | const review = payload as BuddyEvents['review.completed'] | |
| 123 | return `Reviewed #${review.number}: ${review.findings} finding(s)` | |
| 124 | } | |
| 125 | case 'fixci.completed': { | |
| 126 | const fix = payload as BuddyEvents['fixci.completed'] | |
| 127 | return `fix-ci ${fix.action}${fix.fixed ? ' (fixed)' : ''}${fix.number ? ` on #${fix.number}` : ''}` | |
| 128 | } | |
| 129 | case 'gate.failed': { | |
| 130 | const gates = payload as BuddyEvents['gate.failed'] | |
| 131 | return `Gates failed on #${gates.number}: ${gates.checks.join(', ')}` | |
| 132 | } | |
| 109 | 133 | case 'run.failed': { |
| 110 | 134 | const failure = payload as BuddyEvents['run.failed'] |
| 111 | 135 | return `\`${failure.command}\` failed: ${failure.error}` |
| @@ -1,3 +1,8 @@ | ||
| 1 | import type { BuddyEvents } from './bus' | |
| 2 | import type { NotificationConfig } from './sinks' | |
| 3 | import { EventBus } from './bus' | |
| 4 | import { createSinks } from './sinks' | |
| 5 | ||
| 1 | 6 | export { describeEvent, EVENT_NAMES, EventBus } from './bus' |
| 2 | 7 | export type { BuddyEvents, EventSink } from './bus' |
| 3 | 8 | export { |
| @@ -9,3 +14,25 @@ export { | ||
| 9 | 14 | WEBHOOK_PAYLOAD_VERSION, |
| 10 | 15 | } from './sinks' |
| 11 | 16 | export type { NotificationConfig } from './sinks' |
| 17 | ||
| 18 | /** | |
| 19 | * Fire one event through a bus built from the given notification config. | |
| 20 | * | |
| 21 | * For call sites that do not hold a `Buddy` instance — CLI actions, the | |
| 22 | * review and CI paths. Sinks are built per call, which is cheap and only | |
| 23 | * happens when notifications are configured at all. | |
| 24 | */ | |
| 25 | export async function emitEvent<K extends keyof BuddyEvents>( | |
| 26 | notifications: NotificationConfig | undefined, | |
| 27 | event: K, | |
| 28 | payload: BuddyEvents[K], | |
| 29 | ): Promise<void> { | |
| 30 | const sinks = createSinks(notifications) | |
| 31 | if (sinks.length === 0) | |
| 32 | return | |
| 33 | ||
| 34 | const bus = new EventBus() | |
| 35 | for (const sink of sinks) | |
| 36 | bus.register(sink) | |
| 37 | await bus.emit(event, payload) | |
| 38 | } | |
| @@ -353,9 +353,14 @@ describe('buddy orchestration', () => { | ||
| 353 | 353 | stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })]) |
| 354 | 354 | const provider = makeCloseProvider([lodashPR()]) |
| 355 | 355 | const buddy = new Buddy(baseConfig()) |
| 356 | const emitted: Array<[string, unknown]> = [] | |
| 357 | ;(buddy as any).eventBus = { emit: async (event: string, payload: unknown) => { emitted.push([event, payload]) } } | |
| 356 | 358 | |
| 357 | 359 | await buddy.checkAndCloseSatisfiedPRs(provider, false) |
| 358 | 360 | |
| 361 | const closedEvent = emitted.find(([event]) => event === 'pr.closed') | |
| 362 | expect(closedEvent?.[1]).toMatchObject({ number: 123, reason: expect.stringContaining('satisfied') }) | |
| 363 | ||
| 359 | 364 | expect(provider.createComment).toHaveBeenCalledTimes(1) |
| 360 | 365 | const [prNumber, comment] = provider.createComment.mock.calls[0] |
| 361 | 366 | expect(prNumber).toBe(123) |
| @@ -547,9 +552,13 @@ describe('buddy orchestration', () => { | ||
| 547 | 552 | }) |
| 548 | 553 | |
| 549 | 554 | const buddy = new Buddy(dashboardConfig(), projectDir) |
| 555 | const emitted: Array<[string, unknown]> = [] | |
| 556 | ;(buddy as any).eventBus = { emit: async (event: string, payload: unknown) => { emitted.push([event, payload]) } } | |
| 550 | 557 | const issue = await buddy.createOrUpdateDashboard() |
| 551 | 558 | |
| 552 | 559 | expect(issue.number).toBe(42) |
| 560 | const dashboardEvent = emitted.find(([event]) => event === 'dashboard.updated') | |
| 561 | expect(dashboardEvent?.[1]).toMatchObject({ number: 42 }) | |
| 553 | 562 | |
| 554 | 563 | const post = fetchCalls.find(call => call.method === 'POST' && call.url.endsWith('/issues')) |
| 555 | 564 | expect(post).toBeDefined() |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { EventSink } from '../src/events/bus' |
| 2 | 2 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' |
| 3 | 3 | import process from 'node:process' |
| 4 | import { emitEvent } from '../src/events' | |
| 4 | 5 | import { describeEvent, EventBus } from '../src/events/bus' |
| 5 | 6 | import { createSinks, createWebhookSink, sign, WEBHOOK_PAYLOAD_VERSION } from '../src/events/sinks' |
| 6 | 7 | import { Logger } from '../src/utils/logger' |
| @@ -94,6 +95,40 @@ describe('event descriptions', () => { | ||
| 94 | 95 | expect(describeEvent('pr.created', { number: 7, title: 'bump react', url: '', packages: [] })).toContain('#7') |
| 95 | 96 | expect(describeEvent('pr.merged', { number: 7, title: 'bump react', strategy: 'squash' })).toContain('Merged #7') |
| 96 | 97 | expect(describeEvent('run.failed', { command: 'scan', error: 'boom' })).toContain('boom') |
| 98 | expect(describeEvent('pr.updated', { number: 8, title: 'bump react', url: '' })).toContain('Updated #8') | |
| 99 | expect(describeEvent('pr.closed', { number: 9, title: 'bump react', reason: 'satisfied' })).toContain('satisfied') | |
| 100 | expect(describeEvent('dashboard.updated', { number: 3, url: '' })).toContain('#3') | |
| 101 | expect(describeEvent('review.completed', { number: 4, findings: 2 })).toContain('2 finding(s)') | |
| 102 | expect(describeEvent('fixci.completed', { number: 5, action: 'lockfile', fixed: true })).toContain('(fixed)') | |
| 103 | expect(describeEvent('gate.failed', { number: 6, checks: ['title-format'] })).toContain('title-format') | |
| 104 | }) | |
| 105 | }) | |
| 106 | ||
| 107 | describe('emitEvent', () => { | |
| 108 | it('success case - builds sinks from the config and delivers the event', async () => { | |
| 109 | const bodies: any[] = [] | |
| 110 | globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { | |
| 111 | bodies.push(JSON.parse(String(init?.body ?? '{}'))) | |
| 112 | return new Response('{}', { status: 200 }) | |
| 113 | }) as unknown as typeof fetch | |
| 114 | ||
| 115 | await emitEvent( | |
| 116 | { webhooks: [{ url: 'https://example.test/hook' }] }, | |
| 117 | 'gate.failed', | |
| 118 | { number: 12, checks: ['description'] }, | |
| 119 | ) | |
| 120 | ||
| 121 | expect(bodies).toHaveLength(1) | |
| 122 | expect(bodies[0].event).toBe('gate.failed') | |
| 123 | expect(bodies[0].payload.checks).toEqual(['description']) | |
| 124 | }) | |
| 125 | ||
| 126 | it('edge case - no configuration means no sinks and no network', async () => { | |
| 127 | globalThis.fetch = (async () => { | |
| 128 | throw new Error('unexpected network call') | |
| 129 | }) as unknown as typeof fetch | |
| 130 | ||
| 131 | await expect(emitEvent(undefined, 'run.failed', { command: 'scan', error: 'x' })).resolves.toBeUndefined() | |
| 97 | 132 | }) |
| 98 | 133 | }) |
| 99 | 134 | |