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.

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+6-1
Changes to bin/cli.ts
@@ -404,7 +404,12 @@ cli
404404 }
405405 }
406406 else if (availablePlugins.length > 0 && options.nonInteractive) {
407 console.log(`\n🔌 Found ${availablePlugins.length} integration(s), skipping in non-interactive mode`)
407 // The docs promise setup-completion notifications; silently skipping
408 // them made that a lie in exactly the mode CI uses.
409 console.log(`\n🔌 Found ${availablePlugins.length} integration(s) — enabled for setup notifications`)
410 for (const plugin of availablePlugins) {
411 await pluginManager.loadPlugin(plugin)
412 }
408413 }
409414
410415 // Pre-flight checks
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/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/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})