also looking at this
feat(events): one notification story — fire every declared event, trim setup to its real job
#1449
14 files
+308
-81
| @@ -1149,7 +1149,7 @@ const result = await migrator.migrateFromRenovate('renovate.json') | ||
| 1149 | 1149 | |
| 1150 | 1150 | ## Integration Ecosystem |
| 1151 | 1151 | |
| 1152 | Buddy includes an extensible plugin system that enables integrations with popular collaboration and project management tools. | |
| 1152 | Buddy 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.) | |
| 1153 | 1153 | |
| 1154 | 1154 | ### Built-in Integrations |
| 1155 | 1155 | |
| @@ -1167,8 +1167,7 @@ echo "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK" > .buddy/slack-webhoo | ||
| 1167 | 1167 | |
| 1168 | 1168 | - Rich setup completion notifications |
| 1169 | 1169 | - 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 | |
| 1172 | 1171 | |
| 1173 | 1172 | #### Discord Integration |
| 1174 | 1173 | |
| @@ -1214,8 +1213,7 @@ Create custom integrations by defining plugins in `.buddy/plugins/`: | ||
| 1214 | 1213 | "version": "1.0.0", |
| 1215 | 1214 | "enabled": true, |
| 1216 | 1215 | "triggers": [ |
| 1217 | { "event": "setup_complete" }, | |
| 1218 | { "event": "validation_error" } | |
| 1216 | { "event": "setup_complete" } | |
| 1219 | 1217 | ], |
| 1220 | 1218 | "hooks": [ |
| 1221 | 1219 | { |
| @@ -1240,13 +1238,14 @@ hooks run, and the plugin file chooses the URL. | ||
| 1240 | 1238 | |
| 1241 | 1239 | ### Plugin Events |
| 1242 | 1240 | |
| 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 | | |
| 1241 | Setup fires exactly one event: **`setup_complete`**, once, when setup finishes | |
| 1242 | — in interactive and non-interactive runs alike. Triggers naming anything else | |
| 1243 | still load (files written against the retired `pre_setup` / `post_setup` / | |
| 1244 | `step_complete` / `validation_error` vocabulary are tolerated) but never fire. | |
| 1245 | ||
| 1246 | For runtime notifications — scans, pull requests, merges — use the | |
| 1247 | `notifications` key in `buddy.config.ts`: an exported webhook env var alone | |
| 1248 | does nothing at runtime. | |
| 1250 | 1249 | |
| 1251 | 1250 | ### Programmatic Usage |
| 1252 | 1251 | |
| @@ -379,7 +379,7 @@ const result = await migrator.migrateFromRenovate('renovate.json') | ||
| 379 | 379 | |
| 380 | 380 | ## Integration Ecosystem |
| 381 | 381 | |
| 382 | Buddy includes an extensible plugin system that enables integrations with popular collaboration and project management tools. | |
| 382 | Buddy 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.) | |
| 383 | 383 | |
| 384 | 384 | ### Built-in Integrations |
| 385 | 385 | |
| @@ -397,8 +397,7 @@ echo "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK" > .buddy/slack-webhoo | ||
| 397 | 397 | |
| 398 | 398 | - Rich setup completion notifications |
| 399 | 399 | - 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 | |
| 402 | 401 | |
| 403 | 402 | #### Discord Integration |
| 404 | 403 | |
| @@ -444,8 +443,7 @@ Create custom integrations by defining plugins in `.buddy/plugins/`: | ||
| 444 | 443 | "version": "1.0.0", |
| 445 | 444 | "enabled": true, |
| 446 | 445 | "triggers": [ |
| 447 | { "event": "setup_complete" }, | |
| 448 | { "event": "validation_error" } | |
| 446 | { "event": "setup_complete" } | |
| 449 | 447 | ], |
| 450 | 448 | "hooks": [ |
| 451 | 449 | { |
| @@ -470,13 +468,14 @@ hooks run, and the plugin file chooses the URL. | ||
| 470 | 468 | |
| 471 | 469 | ### Plugin Events |
| 472 | 470 | |
| 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 | | |
| 471 | Setup fires exactly one event: **`setup_complete`**, once, when setup finishes | |
| 472 | — in interactive and non-interactive runs alike. Triggers naming anything else | |
| 473 | still load (files written against the retired `pre_setup` / `post_setup` / | |
| 474 | `step_complete` / `validation_error` vocabulary are tolerated) but never fire. | |
| 475 | ||
| 476 | For runtime notifications — scans, pull requests, merges — use the | |
| 477 | `notifications` key in `buddy.config.ts`: an exported webhook env var alone | |
| 478 | does nothing at runtime. | |
| 480 | 479 | |
| 481 | 480 | ### Programmatic Usage |
| 482 | 481 | |
| @@ -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, |
| @@ -404,7 +405,12 @@ cli | ||
| 404 | 405 | } |
| 405 | 406 | } |
| 406 | 407 | 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 | } | |
| 408 | 414 | } |
| 409 | 415 | |
| 410 | 416 | // Pre-flight checks |
| @@ -780,6 +786,7 @@ cli | ||
| 780 | 786 | } |
| 781 | 787 | catch (error) { |
| 782 | 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) }) | |
| 783 | 790 | process.exit(1) |
| 784 | 791 | } |
| 785 | 792 | }) |
| @@ -840,6 +847,7 @@ cli | ||
| 840 | 847 | } |
| 841 | 848 | catch (error) { |
| 842 | 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) }) | |
| 843 | 851 | process.exit(1) |
| 844 | 852 | } |
| 845 | 853 | }) |
| @@ -924,6 +932,7 @@ cli | ||
| 924 | 932 | } |
| 925 | 933 | catch (error) { |
| 926 | 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) }) | |
| 927 | 936 | process.exit(1) |
| 928 | 937 | } |
| 929 | 938 | }) |
| @@ -1329,6 +1338,7 @@ cli | ||
| 1329 | 1338 | } |
| 1330 | 1339 | catch (error) { |
| 1331 | 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) }) | |
| 1332 | 1342 | process.exit(1) |
| 1333 | 1343 | } |
| 1334 | 1344 | }) |
| @@ -1400,6 +1410,7 @@ cli | ||
| 1400 | 1410 | } |
| 1401 | 1411 | catch (error) { |
| 1402 | 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) }) | |
| 1403 | 1414 | process.exit(1) |
| 1404 | 1415 | } |
| 1405 | 1416 | }) |
| @@ -1656,6 +1667,10 @@ cli | ||
| 1656 | 1667 | }) |
| 1657 | 1668 | |
| 1658 | 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 | }) | |
| 1659 | 1674 | |
| 1660 | 1675 | // `--format` and `--fail-on` were accepted on this path and applied |
| 1661 | 1676 | // only on the local one, so a pull request review could not be |
| @@ -1759,6 +1774,7 @@ cli | ||
| 1759 | 1774 | } |
| 1760 | 1775 | catch (error) { |
| 1761 | 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) }) | |
| 1762 | 1778 | process.exit(1) |
| 1763 | 1779 | } |
| 1764 | 1780 | }) |
| @@ -3164,6 +3180,12 @@ cli | ||
| 3164 | 3180 | logger.info(`${gateIcon(result)} ${result.name}: ${result.summary}`) |
| 3165 | 3181 | |
| 3166 | 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 | } | |
| 3167 | 3189 | |
| 3168 | 3190 | if (options.dryRun || !supports(provider, 'checkRuns', 'createCheckRun')) { |
| 3169 | 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 |
| @@ -55,7 +55,7 @@ The setup wizard provides a comprehensive configuration experience with advanced | ||
| 55 | 55 | - **⚙️ Workflow Validation** - Real-time YAML syntax and security validation |
| 56 | 56 | - **�� Recovery Capabilities** - Detailed error reporting and setup resumption support |
| 57 | 57 | - **�� 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 | |
| 59 | 59 | |
| 60 | 60 | ## Setup Flow |
| 61 | 61 | |
| @@ -276,6 +276,11 @@ Settings mapped: | ||
| 276 | 276 | |
| 277 | 277 | ## Integration Settings |
| 278 | 278 | |
| 279 | These integrations fire once, when `buddy setup` completes. For runtime | |
| 280 | notifications — scans, pull requests, merges — configure the `notifications` | |
| 281 | key in `buddy.config.ts`; an exported webhook env var alone does nothing at | |
| 282 | runtime. | |
| 283 | ||
| 279 | 284 | ### Slack |
| 280 | 285 | |
| 281 | 286 | ```bash |
| @@ -312,8 +317,7 @@ Create custom integrations in `.buddy/plugins/`: | ||
| 312 | 317 | "version": "1.0.0", |
| 313 | 318 | "enabled": true, |
| 314 | 319 | "triggers": [ |
| 315 | { "event": "setup_complete" }, | |
| 316 | { "event": "validation_error" } | |
| 320 | { "event": "setup_complete" } | |
| 317 | 321 | ], |
| 318 | 322 | "hooks": [ |
| 319 | 323 | { |
| @@ -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 | } | |
| @@ -104,8 +104,14 @@ export interface SetupPlugin { | ||
| 104 | 104 | } |
| 105 | 105 | |
| 106 | 106 | export 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 | |
| 109 | 115 | } |
| 110 | 116 | |
| 111 | 117 | export interface SetupHook { |
| @@ -154,19 +160,6 @@ export interface SetupContext { | ||
| 154 | 160 | plugins: SetupPlugin[] |
| 155 | 161 | } |
| 156 | 162 | |
| 157 | export interface IntegrationPoint { | |
| 158 | tool: string | |
| 159 | endpoint: string | |
| 160 | authentication: AuthConfig | |
| 161 | dataMapping: Record<string, string> | |
| 162 | enabled: boolean | |
| 163 | } | |
| 164 | ||
| 165 | export interface AuthConfig { | |
| 166 | type: 'token' | 'oauth' | 'webhook' | 'api_key' | |
| 167 | credentials: Record<string, string> | |
| 168 | } | |
| 169 | ||
| 170 | 163 | // Configuration Migration Implementation |
| 171 | 164 | export class ConfigurationMigrator { |
| 172 | 165 | async detectExistingTools(): Promise<DetectedTool[]> { |
| @@ -645,7 +638,6 @@ export class PluginManager { | ||
| 645 | 638 | enabled: true, |
| 646 | 639 | triggers: [ |
| 647 | 640 | { event: 'setup_complete' }, |
| 648 | { event: 'validation_error' }, | |
| 649 | 641 | ], |
| 650 | 642 | hooks: [ |
| 651 | 643 | { |
| @@ -657,11 +649,9 @@ export class PluginManager { | ||
| 657 | 649 | }, |
| 658 | 650 | }, |
| 659 | 651 | ], |
| 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: {}, | |
| 665 | 655 | } |
| 666 | 656 | } |
| 667 | 657 | |
| @@ -683,11 +673,7 @@ export class PluginManager { | ||
| 683 | 673 | }, |
| 684 | 674 | }, |
| 685 | 675 | ], |
| 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: {}, | |
| 691 | 677 | } |
| 692 | 678 | } |
| 693 | 679 | |
| @@ -709,9 +695,7 @@ export class PluginManager { | ||
| 709 | 695 | }, |
| 710 | 696 | }, |
| 711 | 697 | ], |
| 712 | configuration: { | |
| 713 | webhook_url: process.env.DISCORD_WEBHOOK_URL || '', | |
| 714 | }, | |
| 698 | configuration: {}, | |
| 715 | 699 | } |
| 716 | 700 | } |
| 717 | 701 | |
| @@ -744,8 +728,39 @@ export class PluginManager { | ||
| 744 | 728 | return plugins |
| 745 | 729 | } |
| 746 | 730 | |
| 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 | ||
| 747 | 762 | 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') | |
| 749 | 764 | if (!webhookUrl) |
| 750 | 765 | return |
| 751 | 766 | |
| @@ -782,7 +797,7 @@ export class PluginManager { | ||
| 782 | 797 | } |
| 783 | 798 | |
| 784 | 799 | 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') | |
| 786 | 801 | if (!webhookUrl) |
| 787 | 802 | return |
| 788 | 803 | |
| @@ -821,9 +836,10 @@ export class PluginManager { | ||
| 821 | 836 | } |
| 822 | 837 | |
| 823 | 838 | 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' | |
| 827 | 843 | |
| 828 | 844 | if (!apiToken || !baseUrl) |
| 829 | 845 | return |
| @@ -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 | |
| @@ -247,7 +247,33 @@ describe('Integration Ecosystem & Plugin Architecture', () => { | ||
| 247 | 247 | const slackPlugins = plugins.filter(p => p.name === 'slack-integration') |
| 248 | 248 | expect(slackPlugins).toHaveLength(1) |
| 249 | 249 | 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 | } | |
| 251 | 277 | }) |
| 252 | 278 | |
| 253 | 279 | it('should load custom plugins from .buddy/plugins directory', async () => { |
| @@ -301,9 +327,9 @@ describe('Integration Ecosystem & Plugin Architecture', () => { | ||
| 301 | 327 | expect(slackPlugins[0].name).toBe('slack-integration') |
| 302 | 328 | expect(slackPlugins[0].version).toBe('1.0.0') |
| 303 | 329 | expect(slackPlugins[0].enabled).toBe(true) |
| 304 | expect(slackPlugins[0].triggers).toHaveLength(2) | |
| 330 | expect(slackPlugins[0].triggers).toEqual([{ event: 'setup_complete' }]) | |
| 305 | 331 | 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({}) | |
| 307 | 333 | }) |
| 308 | 334 | |
| 309 | 335 | it('should discover Discord plugin when webhook URL is configured', async () => { |
| @@ -333,8 +359,8 @@ describe('Integration Ecosystem & Plugin Architecture', () => { | ||
| 333 | 359 | expect(jiraPlugins[0].version).toBe('1.0.0') |
| 334 | 360 | expect(jiraPlugins[0].triggers).toHaveLength(1) |
| 335 | 361 | 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({}) | |
| 338 | 364 | }) |
| 339 | 365 | |
| 340 | 366 | it('should discover multiple plugins when multiple integrations are configured', async () => { |
| @@ -656,15 +682,13 @@ describe('Integration Ecosystem & Plugin Architecture', () => { | ||
| 656 | 682 | const slackPlugin = plugins.find(p => p.name === 'slack-integration') |
| 657 | 683 | |
| 658 | 684 | 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' }]) | |
| 663 | 688 | expect(slackPlugin!.hooks).toHaveLength(1) |
| 664 | 689 | expect(slackPlugin!.hooks[0].name).toBe('notify-slack') |
| 665 | 690 | 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({}) | |
| 668 | 692 | }) |
| 669 | 693 | |
| 670 | 694 | it('should create Discord plugin with correct configuration', async () => { |
| @@ -678,7 +702,7 @@ describe('Integration Ecosystem & Plugin Architecture', () => { | ||
| 678 | 702 | expect(discordPlugin!.hooks).toHaveLength(1) |
| 679 | 703 | expect(discordPlugin!.hooks[0].name).toBe('notify-discord') |
| 680 | 704 | 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({}) | |
| 682 | 706 | }) |
| 683 | 707 | |
| 684 | 708 | it('should create Jira plugin with correct configuration', async () => { |
| @@ -694,19 +718,40 @@ describe('Integration Ecosystem & Plugin Architecture', () => { | ||
| 694 | 718 | expect(jiraPlugin!.hooks).toHaveLength(1) |
| 695 | 719 | expect(jiraPlugin!.hooks[0].name).toBe('create-jira-ticket') |
| 696 | 720 | 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({}) | |
| 700 | 722 | }) |
| 701 | 723 | |
| 702 | 724 | it('should use default project key when not specified', async () => { |
| 703 | 725 | process.env.JIRA_API_TOKEN = 'test-token' |
| 704 | 726 | process.env.JIRA_BASE_URL = 'https://test.atlassian.net' |
| 727 | delete process.env.JIRA_PROJECT_KEY | |
| 705 | 728 | |
| 706 | 729 | 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 | } | |
| 710 | 755 | }) |
| 711 | 756 | }) |
| 712 | 757 | }) |