ReviewOS

also looking at this

stacks/buddy

feat(events): one notification story — fire every declared event, trim setup to its real job

#1449
Merged glennmichael123 wants to merge refactor/setup-plugin-trim into main
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.

bin/cli.tsmodified+17-0
Changes to bin/cli.ts
@@ -18,6 +18,7 @@ import {
1818 parseDashboardActions,
1919 uncheckDashboardActions,
2020} from '../src/dashboard/dashboard-actions'
21import { emitEvent } from '../src/events'
2122import { manifestUpdates, parseManifest } from '../src/pr/pr-manifest'
2223import {
2324 analyzeProject,
@@ -785,6 +786,7 @@ cli
785786 }
786787 catch (error) {
787788 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) })
788790 process.exit(1)
789791 }
790792 })
@@ -845,6 +847,7 @@ cli
845847 }
846848 catch (error) {
847849 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) })
848851 process.exit(1)
849852 }
850853 })
@@ -929,6 +932,7 @@ cli
929932 }
930933 catch (error) {
931934 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) })
932936 process.exit(1)
933937 }
934938 })
@@ -1334,6 +1338,7 @@ cli
13341338 }
13351339 catch (error) {
13361340 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) })
13371342 process.exit(1)
13381343 }
13391344 })
@@ -1405,6 +1410,7 @@ cli
14051410 }
14061411 catch (error) {
14071412 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) })
14081414 process.exit(1)
14091415 }
14101416 })
@@ -1661,6 +1667,10 @@ cli
16611667 })
16621668
16631669 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 })
16641674
16651675 // `--format` and `--fail-on` were accepted on this path and applied
16661676 // only on the local one, so a pull request review could not be
@@ -1764,6 +1774,7 @@ cli
17641774 }
17651775 catch (error) {
17661776 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) })
17671778 process.exit(1)
17681779 }
17691780 })
@@ -3169,6 +3180,12 @@ cli
31693180 logger.info(`${gateIcon(result)} ${result.name}: ${result.summary}`)
31703181
31713182 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 }
31723189
31733190 if (options.dryRun || !supports(provider, 'checkRuns', 'createCheckRun')) {
31743191 logger.info(summary.summary)
docs/api/configuration.mdmodified+20-0
Changes to docs/api/configuration.md
@@ -488,6 +488,26 @@ interface NotificationSettings {
488488Credentials are referenced by environment variable name, never inlined, so a
489489destination can be committed without committing its credential.
490490
491Every 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
507Webhook consumers receive `{ version, event, payload, sentAt }` JSON. When
508`secretEnv` is set, each request carries an `x-buddy-signature: sha256=<hmac>`
509header signing the raw body.
510
491511## Pull Requests
492512
493513```typescript
src/buddy.tsmodified+21-1
Changes to src/buddy.ts
@@ -568,10 +568,22 @@ export class Buddy {
568568
569569 try {
570570 const service = new SecurityAdvisoryService(this.logger)
571 return await service.annotateUpdates(
571 const annotated = await service.annotateUpdates(
572572 updates,
573573 this.config.security?.minimumSeverity ?? 'low',
574574 )
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
575587 }
576588 catch (error) {
577589 this.logger.warn('Security advisory lookup failed, continuing without it:', error)
@@ -752,6 +764,7 @@ export class Buddy {
752764 }
753765
754766 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' })
755768 // The pull request is closed either way; a branch that would
756769 // not delete is worth a warning, not a failed run.
757770 try {
@@ -860,6 +873,7 @@ export class Buddy {
860873 assignees: this.config.pullRequest?.assignees,
861874 })
862875
876 await this.events.emit('pr.updated', { number: existingPR.number, title: prTitle, url: existingPR.url })
863877 this.logger.success(`✅ Updated existing PR #${existingPR.number}: ${prTitle}`)
864878 this.logger.info(`🔗 ${existingPR.url}`)
865879 continue
@@ -904,6 +918,7 @@ export class Buddy {
904918 assignees: this.config.pullRequest?.assignees,
905919 })
906920
921 await this.events.emit('pr.updated', { number: existingPRForBranch.number, title: prTitle, url: existingPRForBranch.url })
907922 this.logger.success(`✅ Updated existing PR #${existingPRForBranch.number}: ${prTitle}`)
908923 this.logger.info(`🔗 ${existingPRForBranch.url}`)
909924 continue
@@ -950,6 +965,7 @@ export class Buddy {
950965 assignees: this.config.pullRequest?.assignees,
951966 })
952967
968 await this.events.emit('pr.updated', { number: recentlyClosed.number, title: prTitle, url: recentlyClosed.url })
953969 this.logger.success(`✅ Reopened and updated PR #${recentlyClosed.number}: ${prTitle}`)
954970 this.logger.info(`🔗 ${recentlyClosed.url}`)
955971 reopened = true
@@ -1021,6 +1037,7 @@ export class Buddy {
10211037 assignees: this.config.pullRequest?.assignees,
10221038 })
10231039
1040 await this.events.emit('pr.updated', { number: recentlyClosed.number, title: prTitle, url: recentlyClosed.url })
10241041 this.logger.success(`✅ Reopened and updated PR #${recentlyClosed.number}: ${prTitle}`)
10251042 this.logger.info(`🔗 ${recentlyClosed.url}`)
10261043 reopenedFromClosed = true
@@ -2966,6 +2983,7 @@ export class Buddy {
29662983 }
29672984
29682985 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' })
29692987
29702988 // Try to delete the branch if it's a buddy branch
29712989 if (pr.head.startsWith('buddy/')) {
@@ -3197,6 +3215,7 @@ export class Buddy {
31973215 }
31983216
31993217 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' })
32003219
32013220 // Try to delete the branch if it's a buddy branch
32023221 if (pr.head.startsWith('buddy/')) {
@@ -3384,6 +3403,7 @@ export class Buddy {
33843403 }
33853404
33863405 this.logger.success(`✅ Dashboard updated: ${issue.url}`)
3406 await this.events.emit('dashboard.updated', { number: issue.number, url: issue.url })
33873407 return issue
33883408 }
33893409 catch (error) {
src/ci/run.tsmodified+7-0
Changes to src/ci/run.ts
@@ -6,6 +6,7 @@
66 * log, diagnose, report, count the attempt — and a second copy would drift
77 * from the first the way the two review paths did.
88 */
9import { emitEvent } from '../events'
910import type { PullRequest } from '../types'
1011import type { BuddyConfig } from '../types'
1112import type { GitProvider } from '../git/provider'
@@ -172,6 +173,12 @@ export async function runFixCi(options: RunFixCiOptions): Promise<string> {
172173 }
173174 }
174175
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
175182 return `${outcome.action}${outcome.fixed ? ' (fixed)' : ''}`
176183}
177184
src/events/bus.tsmodified+24-0
Changes to src/events/bus.ts
@@ -106,6 +106,30 @@ export function describeEvent<K extends keyof BuddyEvents>(event: K, payload: Bu
106106 const advisories = payload as BuddyEvents['security.advisories']
107107 return `${advisories.packages.length} dependency/dependencies with known advisories`
108108 }
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 }
109133 case 'run.failed': {
110134 const failure = payload as BuddyEvents['run.failed']
111135 return `\`${failure.command}\` failed: ${failure.error}`
src/events/index.tsmodified+27-0
Changes to src/events/index.ts
@@ -1,3 +1,8 @@
1import type { BuddyEvents } from './bus'
2import type { NotificationConfig } from './sinks'
3import { EventBus } from './bus'
4import { createSinks } from './sinks'
5
16export { describeEvent, EVENT_NAMES, EventBus } from './bus'
27export type { BuddyEvents, EventSink } from './bus'
38export {
@@ -9,3 +14,25 @@ export {
914 WEBHOOK_PAYLOAD_VERSION,
1015} from './sinks'
1116export 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 */
25export 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}
test/buddy-orchestration.test.tsmodified+9-0
Changes to test/buddy-orchestration.test.ts
@@ -353,9 +353,14 @@ describe('buddy orchestration', () => {
353353 stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })])
354354 const provider = makeCloseProvider([lodashPR()])
355355 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]) } }
356358
357359 await buddy.checkAndCloseSatisfiedPRs(provider, false)
358360
361 const closedEvent = emitted.find(([event]) => event === 'pr.closed')
362 expect(closedEvent?.[1]).toMatchObject({ number: 123, reason: expect.stringContaining('satisfied') })
363
359364 expect(provider.createComment).toHaveBeenCalledTimes(1)
360365 const [prNumber, comment] = provider.createComment.mock.calls[0]
361366 expect(prNumber).toBe(123)
@@ -547,9 +552,13 @@ describe('buddy orchestration', () => {
547552 })
548553
549554 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]) } }
550557 const issue = await buddy.createOrUpdateDashboard()
551558
552559 expect(issue.number).toBe(42)
560 const dashboardEvent = emitted.find(([event]) => event === 'dashboard.updated')
561 expect(dashboardEvent?.[1]).toMatchObject({ number: 42 })
553562
554563 const post = fetchCalls.find(call => call.method === 'POST' && call.url.endsWith('/issues'))
555564 expect(post).toBeDefined()
test/events.test.tsmodified+35-0
Changes to test/events.test.ts
@@ -1,6 +1,7 @@
11import type { EventSink } from '../src/events/bus'
22import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
33import process from 'node:process'
4import { emitEvent } from '../src/events'
45import { describeEvent, EventBus } from '../src/events/bus'
56import { createSinks, createWebhookSink, sign, WEBHOOK_PAYLOAD_VERSION } from '../src/events/sinks'
67import { Logger } from '../src/utils/logger'
@@ -94,6 +95,40 @@ describe('event descriptions', () => {
9495 expect(describeEvent('pr.created', { number: 7, title: 'bump react', url: '', packages: [] })).toContain('#7')
9596 expect(describeEvent('pr.merged', { number: 7, title: 'bump react', strategy: 'squash' })).toContain('Merged #7')
9697 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
107describe('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()
97132 })
98133})
99134