also looking at this
feat(events): one notification story — fire every declared event, trim setup to its real job
#1449
14 files
+308
-81
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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 | |
| @@ -404,7 +404,12 @@ cli | ||
| 404 | 404 | } |
| 405 | 405 | } |
| 406 | 406 | 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 | } | |
| 408 | 413 | } |
| 409 | 414 | |
| 410 | 415 | // Pre-flight checks |
| @@ -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 | { |
| @@ -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 |
| @@ -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 | }) |