also looking at this
fix: keep the low-tier promises — bulk-update label, event-name validation, workflow personalisation
#1434
9 files
+325
-13
| @@ -290,6 +290,12 @@ interface CLIOptions { | ||
| 290 | 290 | ignore?: string |
| 291 | 291 | dryRun?: boolean |
| 292 | 292 | respectLatest?: boolean |
| 293 | /** Comma-separated reviewers `update` assigns, overriding `pullRequest.reviewers` */ | |
| 294 | reviewers?: string | |
| 295 | /** Comma-separated assignees `update` assigns, overriding `pullRequest.assignees` */ | |
| 296 | assignees?: string | |
| 297 | /** Comma-separated labels `update` adds, overriding `pullRequest.labels` */ | |
| 298 | labels?: string | |
| 293 | 299 | /** Which settings pages `open-settings` should open: repo, org or both. */ |
| 294 | 300 | type?: string |
| 295 | 301 | /** Set by the generated workflow: honour the `ai.review` filters. */ |
| @@ -844,6 +850,9 @@ cli | ||
| 844 | 850 | .option('--strategy <type>', 'Update strategy: major|minor|patch|all', { default: 'all' }) |
| 845 | 851 | .option('--ignore <names>', 'Comma-separated list of packages to ignore') |
| 846 | 852 | .option('--dry-run', 'Preview changes without making them') |
| 853 | .option('--reviewers <names>', 'Comma-separated reviewers to assign (overrides pullRequest.reviewers)') | |
| 854 | .option('--assignees <names>', 'Comma-separated assignees to assign (overrides pullRequest.assignees)') | |
| 855 | .option('--labels <names>', 'Comma-separated labels to add (overrides pullRequest.labels)') | |
| 847 | 856 | .option('--respect-latest', 'Respect "latest", "*", and other dynamic version indicators (default: true)') |
| 848 | 857 | .option('--no-respect-latest', 'Allow updating "latest", "*", and other dynamic version indicators') |
| 849 | 858 | .example('buddy update') |
| @@ -864,9 +873,26 @@ cli | ||
| 864 | 873 | ignore = options.ignore.split(',').map(p => p.trim()) |
| 865 | 874 | } |
| 866 | 875 | |
| 876 | // Per-run PR overrides, from the flags a generated workflow passes. | |
| 877 | // Folded in only when given: `pullRequest` staying unset is itself a | |
| 878 | // setting, and materialising an empty block would change what runs. | |
| 879 | const splitList = (value?: string): string[] | undefined => | |
| 880 | value ? value.split(',').map(part => part.trim()).filter(Boolean) : undefined | |
| 881 | const reviewers = splitList(options.reviewers) | |
| 882 | const assignees = splitList(options.assignees) | |
| 883 | const labels = splitList(options.labels) | |
| 884 | const prOverrides = { | |
| 885 | ...(reviewers ? { reviewers } : {}), | |
| 886 | ...(assignees ? { assignees } : {}), | |
| 887 | ...(labels ? { labels } : {}), | |
| 888 | } | |
| 889 | ||
| 867 | 890 | const finalConfig: BuddyConfig = { |
| 868 | 891 | ...config, |
| 869 | 892 | verbose: options.verbose ?? config.verbose, |
| 893 | ...(Object.keys(prOverrides).length | |
| 894 | ? { pullRequest: { ...config.pullRequest, ...prOverrides } } | |
| 895 | : {}), | |
| 870 | 896 | packages: { |
| 871 | 897 | ...config.packages, |
| 872 | 898 | strategy: options.strategy ?? config.packages?.strategy ?? 'all', |
| @@ -1864,6 +1890,13 @@ cli | ||
| 1864 | 1890 | const logger = options.verbose ? Logger.verbose() : Logger.quiet() |
| 1865 | 1891 | const config = await resolveConfig(options.config) |
| 1866 | 1892 | |
| 1893 | // Explicit `false` only, the same reading the dashboard uses: a | |
| 1894 | // repository that never wrote a `workflows` block keeps the command. | |
| 1895 | if (config.workflows?.enabled === false) { | |
| 1896 | logger.info('\u2139\ufe0f Workflow generation is disabled by config (workflows.enabled: false)') | |
| 1897 | return | |
| 1898 | } | |
| 1899 | ||
| 1867 | 1900 | console.log('⚠️ The "generate-workflows" command is deprecated.') |
| 1868 | 1901 | console.log('💡 Use "buddy setup" for a better interactive experience.\n') |
| 1869 | 1902 | |
| @@ -78,6 +78,9 @@ buddy update [options] | ||
| 78 | 78 | - `--strategy <type>` - Update strategy: major|minor|patch|all (default: all) |
| 79 | 79 | - `--ignore <names>` - Comma-separated list of packages to ignore |
| 80 | 80 | - `--dry-run` - Preview changes without making them |
| 81 | - `--reviewers <names>` - Comma-separated reviewers to assign, overriding `pullRequest.reviewers` | |
| 82 | - `--assignees <names>` - Comma-separated assignees to assign, overriding `pullRequest.assignees` | |
| 83 | - `--labels <names>` - Comma-separated labels to add, overriding `pullRequest.labels` | |
| 81 | 84 | - `--respect-latest` - Leave `latest`, `*` and other dynamic ranges alone (default) |
| 82 | 85 | - `--no-respect-latest` - Update dynamic ranges too |
| 83 | 86 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { BuddyConfig } from './types' |
| 2 | 2 | import type { GitProviderName } from './git/provider' |
| 3 | 3 | import { BUILTIN_ANALYZERS } from './analysis/engine' |
| 4 | import { EVENT_NAMES } from './events' | |
| 4 | 5 | import { IMPLEMENTED_PROVIDERS, PROVIDER_TRACKING_ISSUES } from './git/provider' |
| 5 | 6 | import { ConfigurationError } from './types' |
| 6 | 7 | |
| @@ -177,6 +178,7 @@ export function validateConfig(config: BuddyConfig): ConfigIssue[] { | ||
| 177 | 178 | validateSchedules(issues, config) |
| 178 | 179 | validateReleaseNotes(issues, config) |
| 179 | 180 | validateDashboard(issues, config) |
| 181 | validateNotifications(issues, config) | |
| 180 | 182 | validateAi(issues, config) |
| 181 | 183 | validateAnalysis(issues, config) |
| 182 | 184 | |
| @@ -688,6 +690,70 @@ function validateDashboard(issues: ConfigIssue[], config: BuddyConfig): void { | ||
| 688 | 690 | issues.push({ path: 'dashboard.title', message: `expected a non-empty string, got ${quote(dashboard.title)}` }) |
| 689 | 691 | } |
| 690 | 692 | |
| 693 | function validateNotifications(issues: ConfigIssue[], config: BuddyConfig): void { | |
| 694 | const notifications = config.notifications | |
| 695 | if (notifications === undefined) | |
| 696 | return | |
| 697 | if (!isPlainObject(notifications)) { | |
| 698 | issues.push({ path: 'notifications', message: `expected an object, got ${quote(notifications)}` }) | |
| 699 | return | |
| 700 | } | |
| 701 | ||
| 702 | // An `events` filter naming an event that does not exist subscribes to | |
| 703 | // nothing: the sink is built, the credential is read, and every | |
| 704 | // notification is silently dropped. `EVENT_NAMES` exists for exactly this | |
| 705 | // check. | |
| 706 | const checkEvents = (path: string, events: unknown): void => { | |
| 707 | if (events === undefined) | |
| 708 | return | |
| 709 | if (!Array.isArray(events)) { | |
| 710 | issues.push({ path, message: `expected an array of event names, got ${quote(events)}` }) | |
| 711 | return | |
| 712 | } | |
| 713 | events.forEach((event, index) => { | |
| 714 | checkEnum(issues, `${path}[${index}]`, event, EVENT_NAMES) | |
| 715 | }) | |
| 716 | } | |
| 717 | ||
| 718 | const checkEnvName = (path: string, value: unknown): void => { | |
| 719 | if (value !== undefined && (typeof value !== 'string' || !value.trim())) | |
| 720 | issues.push({ path, message: `expected an environment variable name, got ${quote(value)}` }) | |
| 721 | } | |
| 722 | ||
| 723 | for (const channel of ['slack', 'discord'] as const) { | |
| 724 | const target = notifications[channel] | |
| 725 | if (target === undefined) | |
| 726 | continue | |
| 727 | if (!isPlainObject(target)) { | |
| 728 | issues.push({ path: `notifications.${channel}`, message: `expected an object, got ${quote(target)}` }) | |
| 729 | continue | |
| 730 | } | |
| 731 | checkEnvName(`notifications.${channel}.webhookEnv`, target.webhookEnv) | |
| 732 | checkEvents(`notifications.${channel}.events`, target.events) | |
| 733 | } | |
| 734 | ||
| 735 | const webhooks = notifications.webhooks | |
| 736 | if (webhooks === undefined) | |
| 737 | return | |
| 738 | if (!Array.isArray(webhooks)) { | |
| 739 | issues.push({ path: 'notifications.webhooks', message: `expected an array, got ${quote(webhooks)}` }) | |
| 740 | return | |
| 741 | } | |
| 742 | webhooks.forEach((webhook, index) => { | |
| 743 | const base = `notifications.webhooks[${index}]` | |
| 744 | if (!isPlainObject(webhook)) { | |
| 745 | issues.push({ path: base, message: `expected an object, got ${quote(webhook)}` }) | |
| 746 | return | |
| 747 | } | |
| 748 | if (webhook.url === undefined) | |
| 749 | issues.push({ path: `${base}.url`, message: 'a webhook needs a url' }) | |
| 750 | else | |
| 751 | checkUrl(issues, `${base}.url`, webhook.url) | |
| 752 | checkEnvName(`${base}.secretEnv`, webhook.secretEnv) | |
| 753 | checkEvents(`${base}.events`, webhook.events) | |
| 754 | }) | |
| 755 | } | |
| 756 | ||
| 691 | 757 | /** |
| 692 | 758 | * Render validation issues as a single multi-line, human-readable block. |
| 693 | 759 | * |
| @@ -158,6 +158,12 @@ export class PullRequestGenerator { | ||
| 158 | 158 | labels.push(update.name) |
| 159 | 159 | } |
| 160 | 160 | |
| 161 | // The docs have promised `bulk-update` on PRs carrying five or more | |
| 162 | // updates since before anything applied it; a filter watching for the | |
| 163 | // label saw grouped PRs slip past unlabelled. | |
| 164 | if (group.updates.length >= 5) | |
| 165 | labels.push('bulk-update') | |
| 166 | ||
| 161 | 167 | return labels |
| 162 | 168 | } |
| 163 | 169 | |
| @@ -12,6 +12,7 @@ export interface WorkflowConfig { | ||
| 12 | 12 | conditions?: string[] |
| 13 | 13 | } |
| 14 | 14 | reviewers?: string[] |
| 15 | assignees?: string[] | |
| 15 | 16 | labels?: string[] |
| 16 | 17 | } |
| 17 | 18 | |
| @@ -65,6 +66,18 @@ export class GitHubActionsTemplate { | ||
| 65 | 66 | * Generate GitHub Actions workflow |
| 66 | 67 | */ |
| 67 | 68 | static generateWorkflow(config: WorkflowConfig): string { |
| 69 | // Reviewers, assignees and labels travel as flags. The workflow runs | |
| 70 | // `./buddy update` against whatever buddy.config.ts the checkout carries, | |
| 71 | // so a per-workflow override can only reach it through the command line — | |
| 72 | // these fields were accepted and silently dropped before this. | |
| 73 | const list = (values?: string[]): string => | |
| 74 | (values ?? []).map(value => value.replace(/'/g, '')).filter(Boolean).join(',') | |
| 75 | const prFlags = [ | |
| 76 | list(config.reviewers) ? ` --reviewers '${list(config.reviewers)}'` : '', | |
| 77 | list(config.assignees) ? ` --assignees '${list(config.assignees)}'` : '', | |
| 78 | list(config.labels) ? ` --labels '${list(config.labels)}'` : '', | |
| 79 | ].join('') | |
| 80 | ||
| 68 | 81 | const workflow = `name: ${config.name} |
| 69 | 82 | |
| 70 | 83 | on: |
| @@ -129,9 +142,9 @@ jobs: | ||
| 129 | 142 | DRY_RUN="\$" |
| 130 | 143 | |
| 131 | 144 | if [ "\$DRY_RUN" = "true" ]; then |
| 132 | ./buddy update --strategy "\$STRATEGY" --dry-run --verbose | |
| 145 | ./buddy update --strategy "\$STRATEGY" --dry-run --verbose${prFlags} | |
| 133 | 146 | else |
| 134 | ./buddy update --strategy "\$STRATEGY" --verbose | |
| 147 | ./buddy update --strategy "\$STRATEGY" --verbose${prFlags} | |
| 135 | 148 | fi |
| 136 | 149 | |
| 137 | 150 | - name: Auto-merge eligible PRs |
| @@ -148,9 +161,10 @@ jobs: | ||
| 148 | 161 | * Generate workflow for different scheduling strategies |
| 149 | 162 | */ |
| 150 | 163 | static generateScheduledWorkflows(config?: BuddyConfig): Record<string, string> { |
| 164 | // Global reviewers and labels are deliberately not baked in: the runtime | |
| 165 | // reads `pullRequest` from buddy.config.ts on every run, and freezing | |
| 166 | // today's values into the YAML would shadow every later config edit. | |
| 151 | 167 | const autoMergeConfig = config?.pullRequest?.autoMerge |
| 152 | const reviewers = config?.pullRequest?.reviewers || [] | |
| 153 | const labels = config?.pullRequest?.labels || [] | |
| 154 | 168 | |
| 155 | 169 | // Determine auto-merge settings based on configuration |
| 156 | 170 | const dailyAutoMerge = autoMergeConfig?.enabled && autoMergeConfig.conditions?.includes('patch-only') |
| @@ -169,8 +183,6 @@ jobs: | ||
| 169 | 183 | schedule: config?.schedule?.cron || '0 2 * * *', // 2 AM daily |
| 170 | 184 | strategy: 'patch', |
| 171 | 185 | autoMerge: dailyAutoMerge, |
| 172 | reviewers, | |
| 173 | labels, | |
| 174 | 186 | }), |
| 175 | 187 | |
| 176 | 188 | 'dependency-updates-weekly.yml': this.generateWorkflow({ |
| @@ -178,8 +190,6 @@ jobs: | ||
| 178 | 190 | schedule: '0 2 * * 1', // 2 AM Monday |
| 179 | 191 | strategy: 'minor', |
| 180 | 192 | autoMerge: weeklyAutoMerge, |
| 181 | reviewers, | |
| 182 | labels, | |
| 183 | 193 | }), |
| 184 | 194 | |
| 185 | 195 | 'dependency-updates-monthly.yml': this.generateWorkflow({ |
| @@ -187,8 +197,6 @@ jobs: | ||
| 187 | 197 | schedule: '0 2 1 * *', // 2 AM first of month |
| 188 | 198 | strategy: 'major', |
| 189 | 199 | autoMerge: monthlyAutoMerge, |
| 190 | reviewers, | |
| 191 | labels, | |
| 192 | 200 | }), |
| 193 | 201 | } |
| 194 | 202 | } |
| @@ -602,6 +610,7 @@ jobs: | ||
| 602 | 610 | strategy?: 'major' | 'minor' | 'patch' | 'all' |
| 603 | 611 | autoMerge?: boolean | { enabled: boolean, strategy: 'merge' | 'squash' | 'rebase', conditions?: string[] } |
| 604 | 612 | reviewers?: string[] |
| 613 | assignees?: string[] | |
| 605 | 614 | labels?: string[] |
| 606 | 615 | }, |
| 607 | 616 | config?: BuddyConfig, |
| @@ -632,8 +641,12 @@ jobs: | ||
| 632 | 641 | schedule: customConfig.schedule, |
| 633 | 642 | strategy: customConfig.strategy || 'all', |
| 634 | 643 | autoMerge: autoMergeConfig, |
| 635 | reviewers: customConfig.reviewers || config?.pullRequest?.reviewers || [], | |
| 636 | labels: customConfig.labels || config?.pullRequest?.labels || [], | |
| 644 | // Only the per-workflow overrides are baked in; global `pullRequest` | |
| 645 | // settings are read from config at runtime, and freezing them into the | |
| 646 | // YAML would shadow every later edit. | |
| 647 | reviewers: customConfig.reviewers ?? [], | |
| 648 | assignees: customConfig.assignees ?? [], | |
| 649 | labels: customConfig.labels ?? [], | |
| 637 | 650 | }) |
| 638 | 651 | } |
| 639 | 652 | |
| @@ -1,3 +1,6 @@ | ||
| 1 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { join } from 'node:path' | |
| 1 | 4 | import { describe, expect, it } from 'bun:test' |
| 2 | 5 | |
| 3 | 6 | /** |
| @@ -13,13 +16,15 @@ const CLI = new URL('../bin/cli.ts', import.meta.url).pathname | ||
| 13 | 16 | * Run the CLI and capture what a user would see. |
| 14 | 17 | * |
| 15 | 18 | * @param args - Arguments to pass |
| 19 | * @param cwd - Directory to run in (default: this repository) | |
| 16 | 20 | * @returns Exit code and combined output |
| 17 | 21 | */ |
| 18 | async function run(args: string[]): Promise<{ code: number, output: string }> { | |
| 22 | async function run(args: string[], cwd?: string): Promise<{ code: number, output: string }> { | |
| 19 | 23 | const proc = Bun.spawn(['bun', CLI, ...args], { |
| 20 | 24 | stdout: 'pipe', |
| 21 | 25 | stderr: 'pipe', |
| 22 | 26 | env: { ...process.env, APP_ENV: 'test' }, |
| 27 | ...(cwd ? { cwd } : {}), | |
| 23 | 28 | }) |
| 24 | 29 | |
| 25 | 30 | const [stdout, stderr] = await Promise.all([ |
| @@ -66,6 +71,28 @@ describe('CLI surface', () => { | ||
| 66 | 71 | expect(output).not.toContain('Opening GitHub Actions settings') |
| 67 | 72 | }, 30000) |
| 68 | 73 | |
| 74 | it('success case - generate-workflows declines when workflows.enabled is false', async () => { | |
| 75 | // `workflows.enabled` was declared, documented and read by nothing — the | |
| 76 | // command generated the full set of workflow files regardless. | |
| 77 | const dir = mkdtempSync(join(tmpdir(), 'buddy-workflows-')) | |
| 78 | writeFileSync(join(dir, 'buddy.config.ts'), 'export default { workflows: { enabled: false } }\n') | |
| 79 | ||
| 80 | try { | |
| 81 | const { code, output } = await run(['generate-workflows', '--verbose'], dir) | |
| 82 | ||
| 83 | expect(code).toBe(0) | |
| 84 | expect(output).toContain('disabled by config') | |
| 85 | // Bun's native glob rather than node:fs — another suite's fs module | |
| 86 | // mock leaks into this file under CI's run order, and existsSync came | |
| 87 | // back undefined there. | |
| 88 | const entries = Array.from(new Bun.Glob('**/*').scanSync({ cwd: dir, onlyFiles: false })) | |
| 89 | expect(entries).toEqual(['buddy.config.ts']) | |
| 90 | } | |
| 91 | finally { | |
| 92 | rmSync(dir, { recursive: true, force: true }) | |
| 93 | } | |
| 94 | }, 30000) | |
| 95 | ||
| 69 | 96 | it('failure case - an unknown flag is rejected rather than ignored', async () => { |
| 70 | 97 | // clapp hard-errors on unknown options, which is why a documented-but- |
| 71 | 98 | // unimplemented flag crashed rather than degrading. |
| @@ -192,6 +192,46 @@ describe('config-validation', () => { | ||
| 192 | 192 | }) |
| 193 | 193 | }) |
| 194 | 194 | |
| 195 | describe('notifications', () => { | |
| 196 | it('success case - a valid notifications block passes', () => { | |
| 197 | const issues = validateConfig({ | |
| 198 | notifications: { | |
| 199 | slack: { webhookEnv: 'SLACK_WEBHOOK_URL', events: ['pr.created', 'run.failed'] }, | |
| 200 | webhooks: [{ url: 'https://example.com/hook', secretEnv: 'HOOK_SECRET', events: ['scan.completed'] }], | |
| 201 | }, | |
| 202 | }) | |
| 203 | ||
| 204 | expect(issues).toHaveLength(0) | |
| 205 | }) | |
| 206 | ||
| 207 | it('failure case - a mistyped event name is reported, not silently dropped', () => { | |
| 208 | // `events` naming an event that does not exist used to build the sink | |
| 209 | // and then filter every real event out — notifications configured, | |
| 210 | // credential read, nothing ever delivered. | |
| 211 | const issues = validateConfig({ | |
| 212 | notifications: { slack: { events: ['pr.opened'] } }, | |
| 213 | }) | |
| 214 | ||
| 215 | expect(issues.some(issue => issue.path === 'notifications.slack.events[0]')).toBe(true) | |
| 216 | }) | |
| 217 | ||
| 218 | it('failure case - a webhook without a url is reported', () => { | |
| 219 | const issues = validateConfig({ | |
| 220 | notifications: { webhooks: [{ events: ['pr.created'] }] }, | |
| 221 | } as any) | |
| 222 | ||
| 223 | expect(issues.some(issue => issue.path === 'notifications.webhooks[0].url')).toBe(true) | |
| 224 | }) | |
| 225 | ||
| 226 | it('edge case - webhook event filters are validated too', () => { | |
| 227 | const issues = validateConfig({ | |
| 228 | notifications: { webhooks: [{ url: 'https://example.com/hook', events: ['nope'] }] }, | |
| 229 | }) | |
| 230 | ||
| 231 | expect(issues.some(issue => issue.path === 'notifications.webhooks[0].events[0]')).toBe(true) | |
| 232 | }) | |
| 233 | }) | |
| 234 | ||
| 195 | 235 | describe('formatConfigIssues', () => { |
| 196 | 236 | it('should render one line per issue', () => { |
| 197 | 237 | const formatted = formatConfigIssues([ |
| @@ -0,0 +1,88 @@ | ||
| 1 | import type { BuddyConfig } from '../src/types' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { GitHubActionsTemplate } from '../src/templates/github-actions' | |
| 4 | ||
| 5 | /** | |
| 6 | * `workflows.custom[].assignees` was declared, validated and documented, and | |
| 7 | * `generateCustomWorkflow` did not even accept the field; `reviewers` and | |
| 8 | * `labels` were accepted and then dropped by `generateWorkflow`. The workflow | |
| 9 | * runs `./buddy update` against the repository's own buddy.config.ts, so a | |
| 10 | * per-workflow override can only travel as a command-line flag — these assert | |
| 11 | * it does. | |
| 12 | */ | |
| 13 | describe('custom workflow personalisation', () => { | |
| 14 | it('success case - reviewers, assignees and labels become update flags', () => { | |
| 15 | const yaml = GitHubActionsTemplate.generateCustomWorkflow({ | |
| 16 | name: 'Team Updates', | |
| 17 | schedule: '0 4 * * *', | |
| 18 | strategy: 'patch', | |
| 19 | reviewers: ['alice', 'bob'], | |
| 20 | assignees: ['carol'], | |
| 21 | labels: ['deps', 'automated'], | |
| 22 | }) | |
| 23 | ||
| 24 | expect(yaml).toContain(`--reviewers 'alice,bob'`) | |
| 25 | expect(yaml).toContain(`--assignees 'carol'`) | |
| 26 | expect(yaml).toContain(`--labels 'deps,automated'`) | |
| 27 | }) | |
| 28 | ||
| 29 | it('success case - both the dry-run and live invocations carry the flags', () => { | |
| 30 | const yaml = GitHubActionsTemplate.generateCustomWorkflow({ | |
| 31 | name: 'Team Updates', | |
| 32 | schedule: '0 4 * * *', | |
| 33 | assignees: ['carol'], | |
| 34 | }) | |
| 35 | ||
| 36 | // `./buddy update-check` also matches a bare 'update' substring. | |
| 37 | const invocations = yaml.split('\n').filter(line => line.includes('./buddy update --')) | |
| 38 | expect(invocations).toHaveLength(2) | |
| 39 | for (const line of invocations) | |
| 40 | expect(line).toContain(`--assignees 'carol'`) | |
| 41 | }) | |
| 42 | ||
| 43 | it('edge case - no overrides means no flags', () => { | |
| 44 | const yaml = GitHubActionsTemplate.generateCustomWorkflow({ | |
| 45 | name: 'Plain', | |
| 46 | schedule: '0 4 * * *', | |
| 47 | }) | |
| 48 | ||
| 49 | expect(yaml).not.toContain('--reviewers') | |
| 50 | expect(yaml).not.toContain('--assignees') | |
| 51 | expect(yaml).not.toContain('--labels') | |
| 52 | }) | |
| 53 | ||
| 54 | it('edge case - a quote in a name cannot escape the shell argument', () => { | |
| 55 | const yaml = GitHubActionsTemplate.generateCustomWorkflow({ | |
| 56 | name: 'Hostile', | |
| 57 | schedule: '0 4 * * *', | |
| 58 | labels: [`a'; rm -rf /; '`], | |
| 59 | }) | |
| 60 | ||
| 61 | const flag = /--labels '([^']*)'/.exec(yaml) | |
| 62 | expect(flag).not.toBeNull() | |
| 63 | expect(flag?.[1]).not.toContain(`'`) | |
| 64 | }) | |
| 65 | ||
| 66 | it('failure case - global pullRequest settings are not frozen into the YAML', () => { | |
| 67 | // The runtime reads `pullRequest` from buddy.config.ts on every run; | |
| 68 | // baking today's values into the workflow file would shadow every later | |
| 69 | // config edit for as long as the file lives. | |
| 70 | const config: BuddyConfig = { | |
| 71 | pullRequest: { reviewers: ['global-reviewer'], labels: ['global-label'] }, | |
| 72 | } | |
| 73 | ||
| 74 | const custom = GitHubActionsTemplate.generateCustomWorkflow( | |
| 75 | { name: 'Plain', schedule: '0 4 * * *' }, | |
| 76 | config, | |
| 77 | ) | |
| 78 | expect(custom).not.toContain('global-reviewer') | |
| 79 | expect(custom).not.toContain('global-label') | |
| 80 | ||
| 81 | const scheduled = GitHubActionsTemplate.generateScheduledWorkflows(config) | |
| 82 | expect(Object.keys(scheduled)).not.toHaveLength(0) | |
| 83 | for (const yaml of Object.values(scheduled)) { | |
| 84 | expect(yaml).not.toContain('global-reviewer') | |
| 85 | expect(yaml).not.toContain('global-label') | |
| 86 | } | |
| 87 | }) | |
| 88 | }) | |
| @@ -124,3 +124,39 @@ describe('pull request labels', () => { | ||
| 124 | 124 | expect(labels).not.toContain('should-not-appear') |
| 125 | 125 | }) |
| 126 | 126 | }) |
| 127 | ||
| 128 | /** | |
| 129 | * The docs promise `bulk-update` on PRs carrying five or more updates under | |
| 130 | * "Dynamic Labeling"; the promise predates any code applying it, so a filter | |
| 131 | * watching for the label saw every grouped PR slip past unlabelled. | |
| 132 | */ | |
| 133 | describe('bulk-update label', () => { | |
| 134 | const updateFor = (name: string): PackageUpdate => ({ | |
| 135 | name, | |
| 136 | currentVersion: '1.0.0', | |
| 137 | newVersion: '1.0.1', | |
| 138 | updateType: 'patch', | |
| 139 | dependencyType: 'dependencies', | |
| 140 | file: 'package.json', | |
| 141 | }) | |
| 142 | ||
| 143 | const groupOf = (count: number): UpdateGroup => ({ | |
| 144 | name: 'Patch Updates', | |
| 145 | updateType: 'patch', | |
| 146 | title: 'chore(deps): update all non-major dependencies', | |
| 147 | body: '', | |
| 148 | updates: Array.from({ length: count }, (_, index) => updateFor(`package-${index}`)), | |
| 149 | }) | |
| 150 | ||
| 151 | const generator = new PullRequestGenerator({ | |
| 152 | repository: { provider: 'github', owner: 'stacksjs', name: 'buddy' }, | |
| 153 | }) | |
| 154 | ||
| 155 | it('success case - five updates are labelled bulk-update', () => { | |
| 156 | expect(generator.generateLabels(groupOf(5))).toContain('bulk-update') | |
| 157 | }) | |
| 158 | ||
| 159 | it('edge case - four updates are not a bulk update', () => { | |
| 160 | expect(generator.generateLabels(groupOf(4))).not.toContain('bulk-update') | |
| 161 | }) | |
| 162 | }) | |