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
CLAUDE.mdmodified+11-12
Changes to CLAUDE.md
@@ -1149,7 +1149,7 @@ const result = await migrator.migrateFromRenovate('renovate.json')
11491149
11501150## Integration Ecosystem
11511151
1152Buddy includes an extensible plugin system that enables integrations with popular collaboration and project management tools.
1152Buddy includes a setup-time plugin system that notifies your collaboration tools when `buddy setup` completes. (Runtime events — scans, pull requests, merges — go through the `notifications` key in `buddy.config.ts` instead; that is the system a new integration should hook into.)
11531153
11541154### Built-in Integrations
11551155
@@ -1167,8 +1167,7 @@ echo "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK" > .buddy/slack-webhoo
11671167
11681168- Rich setup completion notifications
11691169- Repository and project details
1170- Error notifications for setup failures
1171- Configurable channel and username
1170- Reads the webhook from the env var, or from the `.buddy/slack-webhook` file
11721171
11731172#### Discord Integration
11741173
@@ -1214,8 +1213,7 @@ Create custom integrations by defining plugins in `.buddy/plugins/`:
12141213 "version": "1.0.0",
12151214 "enabled": true,
12161215 "triggers": [
1217 { "event": "setup_complete" },
1218 { "event": "validation_error" }
1216 { "event": "setup_complete" }
12191217 ],
12201218 "hooks": [
12211219 {
@@ -1240,13 +1238,14 @@ hooks run, and the plugin file chooses the URL.
12401238
12411239### Plugin Events
12421240
1243| Event | Description | Context |
1244|-------|-------------|---------|
1245| `pre_setup` | Before setup begins | Initial configuration |
1246| `post_setup` | After setup completes | Full setup context |
1247| `step_complete` | After each setup step | Step-specific progress |
1248| `validation_error` | When validation fails | Error details and recovery |
1249| `setup_complete` | Final setup completion | Complete project context |
1241Setup fires exactly one event: **`setup_complete`**, once, when setup finishes
1242— in interactive and non-interactive runs alike. Triggers naming anything else
1243still load (files written against the retired `pre_setup` / `post_setup` /
1244`step_complete` / `validation_error` vocabulary are tolerated) but never fire.
1245
1246For runtime notifications — scans, pull requests, merges — use the
1247`notifications` key in `buddy.config.ts`: an exported webhook env var alone
1248does nothing at runtime.
12501249
12511250### Programmatic Usage
12521251
README.mdmodified+11-12
Changes to README.md
@@ -379,7 +379,7 @@ const result = await migrator.migrateFromRenovate('renovate.json')
379379
380380## Integration Ecosystem
381381
382Buddy includes an extensible plugin system that enables integrations with popular collaboration and project management tools.
382Buddy includes a setup-time plugin system that notifies your collaboration tools when `buddy setup` completes. (Runtime events — scans, pull requests, merges — go through the `notifications` key in `buddy.config.ts` instead; that is the system a new integration should hook into.)
383383
384384### Built-in Integrations
385385
@@ -397,8 +397,7 @@ echo "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK" > .buddy/slack-webhoo
397397
398398- Rich setup completion notifications
399399- Repository and project details
400- Error notifications for setup failures
401- Configurable channel and username
400- Reads the webhook from the env var, or from the `.buddy/slack-webhook` file
402401
403402#### Discord Integration
404403
@@ -444,8 +443,7 @@ Create custom integrations by defining plugins in `.buddy/plugins/`:
444443 "version": "1.0.0",
445444 "enabled": true,
446445 "triggers": [
447 { "event": "setup_complete" },
448 { "event": "validation_error" }
446 { "event": "setup_complete" }
449447 ],
450448 "hooks": [
451449 {
@@ -470,13 +468,14 @@ hooks run, and the plugin file chooses the URL.
470468
471469### Plugin Events
472470
473| Event | Description | Context |
474|-------|-------------|---------|
475| `pre_setup` | Before setup begins | Initial configuration |
476| `post_setup` | After setup completes | Full setup context |
477| `step_complete` | After each setup step | Step-specific progress |
478| `validation_error` | When validation fails | Error details and recovery |
479| `setup_complete` | Final setup completion | Complete project context |
471Setup fires exactly one event: **`setup_complete`**, once, when setup finishes
472— in interactive and non-interactive runs alike. Triggers naming anything else
473still load (files written against the retired `pre_setup` / `post_setup` /
474`step_complete` / `validation_error` vocabulary are tolerated) but never fire.
475
476For runtime notifications — scans, pull requests, merges — use the
477`notifications` key in `buddy.config.ts`: an exported webhook env var alone
478does nothing at runtime.
480479
481480### Programmatic Usage
482481
bin/cli.tsmodified+23-1
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,
@@ -404,7 +405,12 @@ cli
404405 }
405406 }
406407 else if (availablePlugins.length > 0 && options.nonInteractive) {
407 console.log(`\n🔌 Found ${availablePlugins.length} integration(s), skipping in non-interactive mode`)
408 // The docs promise setup-completion notifications; silently skipping
409 // them made that a lie in exactly the mode CI uses.
410 console.log(`\n🔌 Found ${availablePlugins.length} integration(s) — enabled for setup notifications`)
411 for (const plugin of availablePlugins) {
412 await pluginManager.loadPlugin(plugin)
413 }
408414 }
409415
410416 // Pre-flight checks
@@ -780,6 +786,7 @@ cli
780786 }
781787 catch (error) {
782788 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) })
783790 process.exit(1)
784791 }
785792 })
@@ -840,6 +847,7 @@ cli
840847 }
841848 catch (error) {
842849 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) })
843851 process.exit(1)
844852 }
845853 })
@@ -924,6 +932,7 @@ cli
924932 }
925933 catch (error) {
926934 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) })
927936 process.exit(1)
928937 }
929938 })
@@ -1329,6 +1338,7 @@ cli
13291338 }
13301339 catch (error) {
13311340 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) })
13321342 process.exit(1)
13331343 }
13341344 })
@@ -1400,6 +1410,7 @@ cli
14001410 }
14011411 catch (error) {
14021412 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) })
14031414 process.exit(1)
14041415 }
14051416 })
@@ -1656,6 +1667,10 @@ cli
16561667 })
16571668
16581669 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 })
16591674
16601675 // `--format` and `--fail-on` were accepted on this path and applied
16611676 // only on the local one, so a pull request review could not be
@@ -1759,6 +1774,7 @@ cli
17591774 }
17601775 catch (error) {
17611776 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) })
17621778 process.exit(1)
17631779 }
17641780 })
@@ -3164,6 +3180,12 @@ cli
31643180 logger.info(`${gateIcon(result)} ${result.name}: ${result.summary}`)
31653181
31663182 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 }
31673189
31683190 if (options.dryRun || !supports(provider, 'checkRuns', 'createCheckRun')) {
31693191 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
docs/cli/setup.mdmodified+1-1
Changes to docs/cli/setup.md
@@ -55,7 +55,7 @@ The setup wizard provides a comprehensive configuration experience with advanced
5555- **⚙️ Workflow Validation** - Real-time YAML syntax and security validation
5656- **�� Recovery Capabilities** - Detailed error reporting and setup resumption support
5757- **�� Configuration Migration** - Seamless import from Renovate and Dependabot configurations
58- **�� Integration Ecosystem** - Extensible plugin system with Slack, Discord, and Jira integrations
58- **�� Integration Ecosystem** - Setup-completion notifications through Slack, Discord, Jira, and custom webhook plugins
5959
6060## Setup Flow
6161
docs/guide/configuration.mdmodified+6-2
Changes to docs/guide/configuration.md
@@ -276,6 +276,11 @@ Settings mapped:
276276
277277## Integration Settings
278278
279These integrations fire once, when `buddy setup` completes. For runtime
280notifications — scans, pull requests, merges — configure the `notifications`
281key in `buddy.config.ts`; an exported webhook env var alone does nothing at
282runtime.
283
279284### Slack
280285
281286```bash
@@ -312,8 +317,7 @@ Create custom integrations in `.buddy/plugins/`:
312317 "version": "1.0.0",
313318 "enabled": true,
314319 "triggers": [
315 { "event": "setup_complete" },
316 { "event": "validation_error" }
320 { "event": "setup_complete" }
317321 ],
318322 "hooks": [
319323 {
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}
src/setup.tsmodified+50-34
Changes to src/setup.ts
@@ -104,8 +104,14 @@ export interface SetupPlugin {
104104}
105105
106106export interface SetupTrigger {
107 event: 'pre_setup' | 'post_setup' | 'step_complete' | 'validation_error' | 'setup_complete'
108 condition?: string
107 /**
108 * Which setup event this trigger matches. Buddy fires exactly one:
109 * `setup_complete`, once, when setup finishes. Unknown names are tolerated
110 * so plugin files written against the retired five-event vocabulary
111 * (`pre_setup`, `post_setup`, `step_complete`, `validation_error`) still
112 * load — their hooks simply never run.
113 */
114 event: string
109115}
110116
111117export interface SetupHook {
@@ -154,19 +160,6 @@ export interface SetupContext {
154160 plugins: SetupPlugin[]
155161}
156162
157export interface IntegrationPoint {
158 tool: string
159 endpoint: string
160 authentication: AuthConfig
161 dataMapping: Record<string, string>
162 enabled: boolean
163}
164
165export interface AuthConfig {
166 type: 'token' | 'oauth' | 'webhook' | 'api_key'
167 credentials: Record<string, string>
168}
169
170163// Configuration Migration Implementation
171164export class ConfigurationMigrator {
172165 async detectExistingTools(): Promise<DetectedTool[]> {
@@ -645,7 +638,6 @@ export class PluginManager {
645638 enabled: true,
646639 triggers: [
647640 { event: 'setup_complete' },
648 { event: 'validation_error' },
649641 ],
650642 hooks: [
651643 {
@@ -657,11 +649,9 @@ export class PluginManager {
657649 },
658650 },
659651 ],
660 configuration: {
661 webhook_url: process.env.SLACK_WEBHOOK_URL || '',
662 channel: '#buddy',
663 username: 'Buddy',
664 },
652 // Nothing reads per-plugin configuration; credentials are resolved
653 // from the env var or the .buddy/ file at send time.
654 configuration: {},
665655 }
666656 }
667657
@@ -683,11 +673,7 @@ export class PluginManager {
683673 },
684674 },
685675 ],
686 configuration: {
687 api_token: process.env.JIRA_API_TOKEN || '',
688 base_url: process.env.JIRA_BASE_URL || '',
689 project_key: process.env.JIRA_PROJECT_KEY || 'BUDDY',
690 },
676 configuration: {},
691677 }
692678 }
693679
@@ -709,9 +695,7 @@ export class PluginManager {
709695 },
710696 },
711697 ],
712 configuration: {
713 webhook_url: process.env.DISCORD_WEBHOOK_URL || '',
714 },
698 configuration: {},
715699 }
716700 }
717701
@@ -744,8 +728,39 @@ export class PluginManager {
744728 return plugins
745729 }
746730
731 /**
732 * The docs offer `.buddy/slack-webhook`-style files as an alternative to
733 * env vars. Discovery honoured them but the senders read env exclusively,
734 * so a file-only setup produced a plugin that fired and sent nothing.
735 */
736 private readIntegrationFile(filePath: string): string | null {
737 try {
738 const content = fs.readFileSync(filePath, 'utf8').trim()
739 return content || null
740 }
741 catch {
742 return null
743 }
744 }
745
746 private readJiraConfigFile(filePath: string): { apiToken?: string, baseUrl?: string, projectKey?: string } {
747 try {
748 const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, string | undefined>
749 if (!parsed || typeof parsed !== 'object')
750 return {}
751 return {
752 apiToken: parsed.api_token ?? parsed.apiToken,
753 baseUrl: parsed.base_url ?? parsed.baseUrl,
754 projectKey: parsed.project_key ?? parsed.projectKey,
755 }
756 }
757 catch {
758 return {}
759 }
760 }
761
747762 private async sendSlackNotification(context: SetupContext): Promise<void> {
748 const webhookUrl = process.env.SLACK_WEBHOOK_URL
763 const webhookUrl = process.env.SLACK_WEBHOOK_URL || this.readIntegrationFile('.buddy/slack-webhook')
749764 if (!webhookUrl)
750765 return
751766
@@ -782,7 +797,7 @@ export class PluginManager {
782797 }
783798
784799 private async sendDiscordNotification(context: SetupContext): Promise<void> {
785 const webhookUrl = process.env.DISCORD_WEBHOOK_URL
800 const webhookUrl = process.env.DISCORD_WEBHOOK_URL || this.readIntegrationFile('.buddy/discord-webhook')
786801 if (!webhookUrl)
787802 return
788803
@@ -821,9 +836,10 @@ export class PluginManager {
821836 }
822837
823838 private async createJiraTicket(context: SetupContext): Promise<void> {
824 const apiToken = process.env.JIRA_API_TOKEN
825 const baseUrl = process.env.JIRA_BASE_URL
826 const projectKey = process.env.JIRA_PROJECT_KEY || 'BUDDY'
839 const fileConfig = this.readJiraConfigFile('.buddy/jira-config.json')
840 const apiToken = process.env.JIRA_API_TOKEN || fileConfig.apiToken
841 const baseUrl = process.env.JIRA_BASE_URL || fileConfig.baseUrl
842 const projectKey = process.env.JIRA_PROJECT_KEY || fileConfig.projectKey || 'BUDDY'
827843
828844 if (!apiToken || !baseUrl)
829845 return
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
test/plugin-architecture.test.tsmodified+63-18
Changes to test/plugin-architecture.test.ts
@@ -247,7 +247,33 @@ describe('Integration Ecosystem & Plugin Architecture', () => {
247247 const slackPlugins = plugins.filter(p => p.name === 'slack-integration')
248248 expect(slackPlugins).toHaveLength(1)
249249 expect(slackPlugins[0].name).toBe('slack-integration')
250 expect(slackPlugins[0].configuration.webhook_url).toBe('') // Environment variable is empty, but file exists so plugin is discovered
250
251 // The file is not just a discovery hint any more: the sender reads
252 // the webhook URL from it when the env var is absent.
253 for (const plugin of plugins)
254 await freshPluginManager.loadPlugin(plugin)
255 freshPluginManager.setContext({
256 step: 'setup_complete',
257 progress: {},
258 config: {},
259 repository: { owner: 'acme', name: 'widgets' },
260 analysis: {},
261 plugins: [],
262 } as any)
263
264 const fetchBefore = globalThis.fetch
265 const posted: string[] = []
266 globalThis.fetch = (async (input: unknown) => {
267 posted.push(String(input))
268 return new Response('{}', { status: 200 })
269 }) as unknown as typeof fetch
270 try {
271 await freshPluginManager.executePluginHooks({ event: 'setup_complete' })
272 expect(posted).toEqual(['https://hooks.slack.com/services/test'])
273 }
274 finally {
275 globalThis.fetch = fetchBefore
276 }
251277 })
252278
253279 it('should load custom plugins from .buddy/plugins directory', async () => {
@@ -301,9 +327,9 @@ describe('Integration Ecosystem & Plugin Architecture', () => {
301327 expect(slackPlugins[0].name).toBe('slack-integration')
302328 expect(slackPlugins[0].version).toBe('1.0.0')
303329 expect(slackPlugins[0].enabled).toBe(true)
304 expect(slackPlugins[0].triggers).toHaveLength(2)
330 expect(slackPlugins[0].triggers).toEqual([{ event: 'setup_complete' }])
305331 expect(slackPlugins[0].hooks).toHaveLength(1)
306 expect(slackPlugins[0].configuration.webhook_url).toBe('https://hooks.slack.com/test')
332 expect(slackPlugins[0].configuration).toEqual({})
307333 })
308334
309335 it('should discover Discord plugin when webhook URL is configured', async () => {
@@ -333,8 +359,8 @@ describe('Integration Ecosystem & Plugin Architecture', () => {
333359 expect(jiraPlugins[0].version).toBe('1.0.0')
334360 expect(jiraPlugins[0].triggers).toHaveLength(1)
335361 expect(jiraPlugins[0].triggers[0].event).toBe('setup_complete')
336 expect(jiraPlugins[0].configuration.api_token).toBe('test-token')
337 expect(jiraPlugins[0].configuration.base_url).toBe('https://test.atlassian.net')
362 // Credentials are resolved at send time, not stored on the plugin.
363 expect(jiraPlugins[0].configuration).toEqual({})
338364 })
339365
340366 it('should discover multiple plugins when multiple integrations are configured', async () => {
@@ -656,15 +682,13 @@ describe('Integration Ecosystem & Plugin Architecture', () => {
656682 const slackPlugin = plugins.find(p => p.name === 'slack-integration')
657683
658684 expect(slackPlugin).toBeDefined()
659 expect(slackPlugin!.triggers).toEqual([
660 { event: 'setup_complete' },
661 { event: 'validation_error' },
662 ])
685 // validation_error is retired: nothing ever emitted it, so the Slack
686 // plugin's second trigger promised error notifications that never came.
687 expect(slackPlugin!.triggers).toEqual([{ event: 'setup_complete' }])
663688 expect(slackPlugin!.hooks).toHaveLength(1)
664689 expect(slackPlugin!.hooks[0].name).toBe('notify-slack')
665690 expect(slackPlugin!.hooks[0].priority).toBe(10)
666 expect(slackPlugin!.configuration.webhook_url).toBe('https://hooks.slack.com/test')
667 expect(slackPlugin!.configuration.channel).toBe('#buddy')
691 expect(slackPlugin!.configuration).toEqual({})
668692 })
669693
670694 it('should create Discord plugin with correct configuration', async () => {
@@ -678,7 +702,7 @@ describe('Integration Ecosystem & Plugin Architecture', () => {
678702 expect(discordPlugin!.hooks).toHaveLength(1)
679703 expect(discordPlugin!.hooks[0].name).toBe('notify-discord')
680704 expect(discordPlugin!.hooks[0].priority).toBe(8)
681 expect(discordPlugin!.configuration.webhook_url).toBe('https://discord.com/api/webhooks/test')
705 expect(discordPlugin!.configuration).toEqual({})
682706 })
683707
684708 it('should create Jira plugin with correct configuration', async () => {
@@ -694,19 +718,40 @@ describe('Integration Ecosystem & Plugin Architecture', () => {
694718 expect(jiraPlugin!.hooks).toHaveLength(1)
695719 expect(jiraPlugin!.hooks[0].name).toBe('create-jira-ticket')
696720 expect(jiraPlugin!.hooks[0].priority).toBe(5)
697 expect(jiraPlugin!.configuration.api_token).toBe('test-token')
698 expect(jiraPlugin!.configuration.base_url).toBe('https://test.atlassian.net')
699 expect(jiraPlugin!.configuration.project_key).toBe('TEST')
721 expect(jiraPlugin!.configuration).toEqual({})
700722 })
701723
702724 it('should use default project key when not specified', async () => {
703725 process.env.JIRA_API_TOKEN = 'test-token'
704726 process.env.JIRA_BASE_URL = 'https://test.atlassian.net'
727 delete process.env.JIRA_PROJECT_KEY
705728
706729 const plugins = await pluginManager.discoverPlugins()
707 const jiraPlugin = plugins.find(p => p.name === 'jira-integration')
708
709 expect(jiraPlugin!.configuration.project_key).toBe('BUDDY')
730 for (const plugin of plugins)
731 await pluginManager.loadPlugin(plugin)
732 pluginManager.setContext({
733 step: 'setup_complete',
734 progress: {},
735 config: {},
736 repository: { owner: 'acme', name: 'widgets' },
737 analysis: {},
738 plugins: [],
739 } as any)
740
741 // The default key is observable on the wire, not on a config block.
742 const fetchBefore = globalThis.fetch
743 const bodies: any[] = []
744 globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
745 bodies.push(JSON.parse(String(init?.body ?? '{}')))
746 return new Response('{}', { status: 200 })
747 }) as unknown as typeof fetch
748 try {
749 await pluginManager.executePluginHooks({ event: 'setup_complete' })
750 expect(bodies[0]?.fields?.project?.key).toBe('BUDDY')
751 }
752 finally {
753 globalThis.fetch = fetchBefore
754 }
710755 })
711756 })
712757})