also looking at this
feat(setup): make the preset you pick the preset you get
#1411
10 files
+507
-61
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.
| @@ -600,7 +600,7 @@ buddy dashboard --title "My Dependencies" | ||
| 600 | 600 | |
| 601 | 601 | The unified workflow (`.github/workflows/buddy.yml`) automatically updates your dependency dashboard: |
| 602 | 602 | |
| 603 | - **�� Scheduled**: Runs every 2 hours, 15 minutes after the dependency update run | |
| 603 | - **�� Scheduled**: On the schedule your preset sets — often the same tick as the dependency update run | |
| 604 | 604 | - **��️ Manual**: Trigger from Actions tab with custom options |
| 605 | 605 | - **�� Auto-Pin**: Keeps dashboard pinned by default |
| 606 | 606 | - **�� Dry-Run**: Preview mode available |
| @@ -972,12 +972,12 @@ buddy generate-workflows | ||
| 972 | 972 | |
| 973 | 973 | The updated workflow system automatically: |
| 974 | 974 | |
| 975 | - **Every 2 hours**: All configured strategies with dry-run by default | |
| 975 | - **On your preset's schedule**: All configured strategies with dry-run by default | |
| 976 | 976 | - **Manual trigger**: Any strategy with configurable dry-run option |
| 977 | 977 | - **Enhanced testing**: Comprehensive validation and summaries |
| 978 | 978 | - **Failure handling**: Auto-creates GitHub issues |
| 979 | 979 | - **Smart summaries**: Rich GitHub Actions summaries |
| 980 | - **Flexible scheduling**: Consistent 2-hour intervals for all presets | |
| 980 | - **Flexible scheduling**: Each preset sets its own update and dashboard cadence | |
| 981 | 981 | |
| 982 | 982 | ### GitHub Actions Permissions Setup |
| 983 | 983 | |
| @@ -549,19 +549,22 @@ cli | ||
| 549 | 549 | } |
| 550 | 550 | } |
| 551 | 551 | |
| 552 | // Resolved before the config file, not after: the preset decides the | |
| 553 | // update strategy and auto-merge policy that file is written with. | |
| 554 | const preset = getWorkflowPreset(workflowResponse.useCase) | |
| 555 | ||
| 552 | 556 | // Step 5: Generate Configuration File |
| 553 | 557 | updateProgress(progress, 'Configuration File', true) |
| 554 | 558 | displayProgress(progress) |
| 555 | 559 | |
| 556 | 560 | console.log('\n📝 Configuration File') |
| 557 | await generateConfigFile(repoInfo, tokenSetup.hasCustomToken) | |
| 561 | await generateConfigFile(repoInfo, tokenSetup.hasCustomToken, preset) | |
| 558 | 562 | |
| 559 | 563 | // Step 6: Generate Workflows |
| 560 | 564 | updateProgress(progress, 'Workflow Generation', true) |
| 561 | 565 | displayProgress(progress) |
| 562 | 566 | |
| 563 | 567 | console.log('\n🔄 Workflow Generation') |
| 564 | const preset = getWorkflowPreset(workflowResponse.useCase) | |
| 565 | 568 | |
| 566 | 569 | if (workflowResponse.useCase === 'custom' && !options.nonInteractive) { |
| 567 | 570 | await setupCustomWorkflow(preset, logger) |
| @@ -280,7 +280,7 @@ Generates two workflows: | ||
| 280 | 280 | |
| 281 | 281 | #### 1. Unified Workflow (`buddy.yml`) |
| 282 | 282 | |
| 283 | - **Schedule**: dependency updates every 2 hours, dashboard 15 minutes later, cleanup daily at 4 AM UTC, health report Monday at 9 AM UTC | |
| 283 | - **Schedule**: set by the preset you chose — see [Preset Details](#preset-details). Branch cleanup (daily, 4 AM UTC) and the dependency-health report (Monday, 9 AM UTC) are fixed, because housekeeping is the same job at any cadence | |
| 284 | 284 | - **Events**: `pull_request`, `issues`, `issue_comment`, `pull_request_review_comment` and `workflow_run`, so a ticked rebase box or an `@buddy` comment is acted on immediately rather than waited for |
| 285 | 285 | - **Purpose**: One workflow covering checks, updates, the dashboard, reviews and comment commands |
| 286 | 286 | - **Features**: `workflow_dispatch` with a `job` input to run a single job by hand |
| @@ -430,7 +430,11 @@ buddy setup --non-interactive --token-setup new-pat | ||
| 430 | 430 | |
| 431 | 431 | ### Preset Options |
| 432 | 432 | |
| 433 | Every preset writes the same `buddy.yml`: dependency updates every 2 hours on the `patch` strategy, the dashboard 15 minutes behind them, and a manual trigger for running any single job. The preset records the cadence you intended — change the cadence itself in the workflow's `schedule:` block afterwards. | |
| 433 | The preset you choose decides the generated workflow's update and dashboard | |
| 434 | schedules, its default update strategy, and whether the generated config turns | |
| 435 | auto-merge on. Branch cleanup and the health report are the same in every | |
| 436 | preset. Nothing is locked in — the `schedule:` block is ordinary YAML you can | |
| 437 | edit afterwards. | |
| 434 | 438 | |
| 435 | 439 | #### Standard Preset |
| 436 | 440 | |
| @@ -741,7 +745,34 @@ Configure GitHub Actions permissions: | ||
| 741 | 745 | |
| 742 | 746 | ## Preset Details |
| 743 | 747 | |
| 744 | A preset is a statement of intent. Setup prints the one you chose and then writes the same `buddy.yml` for every preset: dependency updates every 2 hours (`0 */2 * * *`) on the `patch` strategy, the dashboard 15 minutes later, branch cleanup daily at 4 AM UTC and a dependency-health report on Monday at 9 AM UTC. Edit that `schedule:` block to change the cadence. | |
| 748 | A preset sets four things: when dependency updates run, when the dashboard | |
| 749 | refreshes, which update strategy scheduled runs use, and whether the generated | |
| 750 | `buddy.config.ts` enables auto-merge. | |
| 751 | ||
| 752 | Two schedules are the same in every preset, because housekeeping does not get | |
| 753 | cheaper or riskier with cadence: branch cleanup daily at 4 AM UTC, and a | |
| 754 | dependency-health report on Monday at 9 AM UTC. | |
| 755 | ||
| 756 | Where a preset gives updates and the dashboard the same expression — most of | |
| 757 | them do — one cron entry starts both jobs rather than one of them winning. | |
| 758 | A preset that says `manual` for a schedule gets no scheduled trigger for that | |
| 759 | job at all; run it from the Actions tab. | |
| 760 | ||
| 761 | | Preset | Updates | Dashboard | Strategy | Auto-merge | | |
| 762 | | --- | --- | --- | --- | --- | | |
| 763 | | Standard | `0 9 * * 1,3,5` | same tick | `all` | — | | |
| 764 | | High Frequency | `0 */6 * * *` | `0 9 * * *` | `all` | patch only | | |
| 765 | | Security Focused | `0 */4 * * *` | `0 9 * * *` | `all` | patch only | | |
| 766 | | Minimal | `0 9 * * 1` | same tick | `all` | — | | |
| 767 | | Docker | `0 9 * * 1` | same tick | `all` | — | | |
| 768 | | Monorepo | `0 9 * * *` | same tick | `all` | — | | |
| 769 | | Development/Testing | `*/15 * * * *` | manual | `patch` | — | | |
| 770 | | Custom | manual | manual | `all` | — | | |
| 771 | ||
| 772 | Auto-merge is scoped to patch updates wherever a preset enables it. The preset | |
| 773 | carries a bare on/off, and generating an unconditional auto-merge from that | |
| 774 | would hand a new repository something more dangerous than the preset | |
| 775 | describes — widen it deliberately in `buddy.config.ts` if you want more. | |
| 745 | 776 | |
| 746 | 777 | ### Standard Setup (Recommended) |
| 747 | 778 | |
| @@ -140,7 +140,7 @@ After setup, Buddy creates two workflows: | ||
| 140 | 140 | |
| 141 | 141 | One unified workflow covering checks, updates and the dashboard: |
| 142 | 142 | |
| 143 | - Scans for updates every 2 hours, refreshing the dashboard 15 minutes later | |
| 143 | - Scans for updates on the schedule your [preset](/cli/setup#preset-details) sets, refreshing the dashboard on the same or its own tick | |
| 144 | 144 | - Rebase and dashboard checkboxes are event-driven, so ticking a box triggers a run immediately |
| 145 | 145 | - Runs a daily cleanup pass at 4 AM UTC and a weekly dependency report Monday at 9 AM UTC |
| 146 | 146 | - Answers `@buddy` comments and supports manual triggers |
| @@ -1085,7 +1085,92 @@ export async function guideRepositorySettings(repoInfo: RepositoryInfo): Promise | ||
| 1085 | 1085 | getDefaultLogger().info(`4. This allows Buddy to create PRs and update issues.\n`) |
| 1086 | 1086 | } |
| 1087 | 1087 | |
| 1088 | export async function generateConfigFile(repoInfo: RepositoryInfo, hasCustomToken: boolean): Promise<void> { | |
| 1088 | /** | |
| 1089 | * Render a preset value as source the generated config can contain. | |
| 1090 | * | |
| 1091 | * Hand-rolled rather than `JSON.stringify`, which emits double quotes and no | |
| 1092 | * trailing commas — both of which the project's own lint rules reject in the | |
| 1093 | * very file setup just wrote. | |
| 1094 | * | |
| 1095 | * @param value - Value to render | |
| 1096 | * @param indent - Column the value starts at, so nested braces line up | |
| 1097 | * @returns TypeScript source for the value | |
| 1098 | */ | |
| 1099 | function renderPresetValue(value: unknown, indent: number): string { | |
| 1100 | const pad = ' '.repeat(indent) | |
| 1101 | const inner = ' '.repeat(indent + 2) | |
| 1102 | ||
| 1103 | if (Array.isArray(value)) { | |
| 1104 | if (value.length === 0) | |
| 1105 | return '[]' | |
| 1106 | ||
| 1107 | return `[\n${value.map(item => `${inner}${renderPresetValue(item, indent + 2)},`).join('\n')}\n${pad}]` | |
| 1108 | } | |
| 1109 | ||
| 1110 | if (value && typeof value === 'object') { | |
| 1111 | const entries = Object.entries(value) | |
| 1112 | if (entries.length === 0) | |
| 1113 | return '{}' | |
| 1114 | ||
| 1115 | return `{\n${entries.map(([key, nested]) => `${inner}${key}: ${renderPresetValue(nested, indent + 2)},`).join('\n')}\n${pad}}` | |
| 1116 | } | |
| 1117 | ||
| 1118 | return typeof value === 'string' ? `'${value}'` : String(value) | |
| 1119 | } | |
| 1120 | ||
| 1121 | /** | |
| 1122 | * Narrow a preset's custom workflows to what `BuddyConfig` can hold. | |
| 1123 | * | |
| 1124 | * The presets carry `autoMergeStrategy` per workflow and the config has no | |
| 1125 | * field for it — only the global `pullRequest.autoMerge.strategy`. Emitting it | |
| 1126 | * would put an excess property in the generated `buddy.config.ts` and fail the | |
| 1127 | * type check on a file the user has not written a line of. | |
| 1128 | * | |
| 1129 | * @param preset - The preset being generated | |
| 1130 | * @returns Custom workflows in the shape the config declares | |
| 1131 | */ | |
| 1132 | function presetCustomWorkflows(preset: WorkflowPreset): Array<Record<string, unknown>> { | |
| 1133 | return (preset.custom ?? []).map(({ name, schedule, strategy, autoMerge }) => ({ | |
| 1134 | name, | |
| 1135 | schedule, | |
| 1136 | strategy, | |
| 1137 | autoMerge, | |
| 1138 | })) | |
| 1139 | } | |
| 1140 | ||
| 1141 | /** | |
| 1142 | * Render the auto-merge block for presets that ask for it. | |
| 1143 | * | |
| 1144 | * Restricted to patch updates deliberately. The preset carries a bare boolean, | |
| 1145 | * and the two presets that set it — Security Focused and High Frequency — both | |
| 1146 | * describe auto-merging *patches* rather than everything. Generating an | |
| 1147 | * unconditional auto-merge from a `true` would hand a new repository something | |
| 1148 | * far more dangerous than it asked for. | |
| 1149 | * | |
| 1150 | * @param preset - The preset being generated | |
| 1151 | * @returns A config fragment, or an empty string when auto-merge is off | |
| 1152 | */ | |
| 1153 | function renderAutoMerge(preset: WorkflowPreset): string { | |
| 1154 | if (!preset.autoMerge) | |
| 1155 | return '' | |
| 1156 | ||
| 1157 | return ` | |
| 1158 | pullRequest: { | |
| 1159 | autoMerge: { | |
| 1160 | enabled: true, | |
| 1161 | strategy: 'squash', | |
| 1162 | // The preset asks for auto-merge; this keeps it to the updates it | |
| 1163 | // describes. Widen it deliberately rather than by default. | |
| 1164 | conditions: ['patch-only'], | |
| 1165 | }, | |
| 1166 | },` | |
| 1167 | } | |
| 1168 | ||
| 1169 | export async function generateConfigFile( | |
| 1170 | repoInfo: RepositoryInfo, | |
| 1171 | hasCustomToken: boolean, | |
| 1172 | preset: WorkflowPreset = getWorkflowPreset('standard'), | |
| 1173 | ): Promise<void> { | |
| 1089 | 1174 | const configContent = `import type { BuddyConfig } from '@buddysh/buddy' |
| 1090 | 1175 | |
| 1091 | 1176 | const config: BuddyConfig = { |
| @@ -1103,15 +1188,11 @@ const config: BuddyConfig = { | ||
| 1103 | 1188 | workflows: { |
| 1104 | 1189 | enabled: true, |
| 1105 | 1190 | outputDir: '.github/workflows', |
| 1106 | templates: { | |
| 1107 | daily: true, | |
| 1108 | weekly: true, | |
| 1109 | monthly: true, | |
| 1110 | }, | |
| 1111 | custom: [], | |
| 1112 | }, | |
| 1191 | templates: ${renderPresetValue(preset.templates, 4)}, | |
| 1192 | custom: ${renderPresetValue(presetCustomWorkflows(preset), 4)}, | |
| 1193 | },${renderAutoMerge(preset)} | |
| 1113 | 1194 | packages: { |
| 1114 | strategy: 'all', | |
| 1195 | strategy: '${preset.strategy}', | |
| 1115 | 1196 | ignore: [ |
| 1116 | 1197 | // Add packages to ignore here |
| 1117 | 1198 | // Example: '@types/node', 'eslint' |
| @@ -1153,7 +1234,123 @@ function generateComposerSetupSteps(): string { | ||
| 1153 | 1234 | ` |
| 1154 | 1235 | } |
| 1155 | 1236 | |
| 1156 | export function generateUnifiedWorkflow(_hasCustomToken: boolean): string { | |
| 1237 | /** | |
| 1238 | * Cron for the daily orphan-branch and obsolete-pull-request sweep. | |
| 1239 | * | |
| 1240 | * Not preset-driven: housekeeping is the same job whatever cadence a project | |
| 1241 | * wants for its updates, and a preset that turned it off would silently let | |
| 1242 | * stale branches pile up. | |
| 1243 | */ | |
| 1244 | const CLEANUP_SCHEDULE = '0 4 * * *' | |
| 1245 | ||
| 1246 | /** Cron for the weekly dependency-health report. */ | |
| 1247 | const REPORT_SCHEDULE = '0 9 * * 1' | |
| 1248 | ||
| 1249 | /** A job a scheduled tick can start. */ | |
| 1250 | export type ScheduledJob = 'update' | 'dashboard' | 'check' | 'report' | |
| 1251 | ||
| 1252 | /** One cron expression and everything it starts. */ | |
| 1253 | export interface ScheduleEntry { | |
| 1254 | cron: string | |
| 1255 | jobs: ScheduledJob[] | |
| 1256 | } | |
| 1257 | ||
| 1258 | /** | |
| 1259 | * Work out which jobs each scheduled tick should start. | |
| 1260 | * | |
| 1261 | * The generated workflow names its cron expressions twice — once in the | |
| 1262 | * `schedule:` trigger list and once in the `determine-jobs` step that matches | |
| 1263 | * `github.event.schedule` to decide what to run. Those two have to agree | |
| 1264 | * exactly, and a cron declared in one but not the other produces a workflow | |
| 1265 | * that starts and does nothing. Deriving both from this plan makes that | |
| 1266 | * particular drift impossible rather than merely unlikely. | |
| 1267 | * | |
| 1268 | * Two properties matter and neither is obvious from the preset data: | |
| 1269 | * | |
| 1270 | * - **A shared cron runs every job that asked for it.** Most presets give | |
| 1271 | * updates and the dashboard the same expression, and a naive if/elif chain | |
| 1272 | * would let the first branch win and drop the other job entirely. | |
| 1273 | * - **`manual` means no schedule**, not a cron expression. The `testing` and | |
| 1274 | * `custom` presets use it, and emitting it verbatim yields YAML that GitHub | |
| 1275 | * refuses to parse. | |
| 1276 | * | |
| 1277 | * @param preset - The workflow preset being generated | |
| 1278 | * @returns Distinct cron expressions, each with the jobs it starts | |
| 1279 | * @example | |
| 1280 | * ```ts | |
| 1281 | * schedulePlan(getWorkflowPreset('standard')) | |
| 1282 | * // [{ cron: '0 9 * * 1,3,5', jobs: ['update', 'dashboard'] }, ...] | |
| 1283 | * ``` | |
| 1284 | */ | |
| 1285 | export function schedulePlan(preset: WorkflowPreset): ScheduleEntry[] { | |
| 1286 | const byCron = new Map<string, ScheduledJob[]>() | |
| 1287 | ||
| 1288 | const add = (cron: string | undefined, job: ScheduledJob): void => { | |
| 1289 | if (!cron || cron === 'manual') | |
| 1290 | return | |
| 1291 | ||
| 1292 | const jobs = byCron.get(cron) ?? [] | |
| 1293 | if (!jobs.includes(job)) | |
| 1294 | jobs.push(job) | |
| 1295 | ||
| 1296 | byCron.set(cron, jobs) | |
| 1297 | } | |
| 1298 | ||
| 1299 | add(preset.schedules.updates, 'update') | |
| 1300 | add(preset.schedules.dashboard, 'dashboard') | |
| 1301 | add(CLEANUP_SCHEDULE, 'check') | |
| 1302 | add(REPORT_SCHEDULE, 'report') | |
| 1303 | ||
| 1304 | return [...byCron].map(([cron, jobs]) => ({ cron, jobs })) | |
| 1305 | } | |
| 1306 | ||
| 1307 | /** What each job is for, so the generated YAML explains its own schedule. */ | |
| 1308 | const JOB_PURPOSE: Record<ScheduledJob, string> = { | |
| 1309 | update: 'check for dependency updates', | |
| 1310 | dashboard: 'refresh the dependency dashboard', | |
| 1311 | check: 'sweep orphaned branches and obsolete pull requests', | |
| 1312 | report: 'publish the dependency-health report', | |
| 1313 | } | |
| 1314 | ||
| 1315 | /** Render the `schedule:` trigger list. */ | |
| 1316 | function renderScheduleTriggers(plan: ScheduleEntry[]): string { | |
| 1317 | return plan | |
| 1318 | .map(({ cron, jobs }) => [ | |
| 1319 | ` # ${jobs.map(job => JOB_PURPOSE[job]).join(', then ')}`, | |
| 1320 | ` - cron: '${cron}'`, | |
| 1321 | ].join('\n')) | |
| 1322 | .join('\n') | |
| 1323 | } | |
| 1324 | ||
| 1325 | /** | |
| 1326 | * Render the `determine-jobs` branch that reads a scheduled tick. | |
| 1327 | * | |
| 1328 | * A `case` rather than an if/elif chain, because a cron shared by several jobs | |
| 1329 | * has to start all of them. | |
| 1330 | */ | |
| 1331 | function renderScheduleDispatch(plan: ScheduleEntry[]): string { | |
| 1332 | if (plan.length === 0) | |
| 1333 | return ' echo "ℹ️ No scheduled jobs are configured"' | |
| 1334 | ||
| 1335 | const branches = plan.map(({ cron, jobs }) => [ | |
| 1336 | ` "${cron}")`, | |
| 1337 | ...jobs.map(job => ` echo "run_${job}=true" >> $GITHUB_OUTPUT`), | |
| 1338 | ' ;;', | |
| 1339 | ].join('\n')) | |
| 1340 | ||
| 1341 | return [ | |
| 1342 | ' case "$" in', | |
| 1343 | ...branches, | |
| 1344 | ' esac', | |
| 1345 | ].join('\n') | |
| 1346 | } | |
| 1347 | ||
| 1348 | export function generateUnifiedWorkflow( | |
| 1349 | _hasCustomToken: boolean, | |
| 1350 | preset: WorkflowPreset = getWorkflowPreset('standard'), | |
| 1351 | ): string { | |
| 1352 | const plan = schedulePlan(preset) | |
| 1353 | ||
| 1157 | 1354 | return `name: Buddy |
| 1158 | 1355 | |
| 1159 | 1356 | on: |
| @@ -1186,17 +1383,10 @@ on: | ||
| 1186 | 1383 | workflow_run: |
| 1187 | 1384 | types: [completed] |
| 1188 | 1385 | |
| 1386 | # Generated from the chosen preset. The matcher in \`determine-jobs\` is built | |
| 1387 | # from the same plan, so a cron declared here always has something to run. | |
| 1189 | 1388 | schedule: |
| 1190 | # Update dependencies every 2 hours | |
| 1191 | - cron: '0 */2 * * *' | |
| 1192 | # Update dashboard 15 minutes after dependency updates (ensures updates are reflected) | |
| 1193 | - cron: '15 */2 * * *' | |
| 1194 | # Daily cleanup at 4am UTC — runs the check job for orphan-branch cleanup | |
| 1195 | # and obsolete-PR detection. The PR-edited trigger handles rebase requests | |
| 1196 | # in real time, but cleanup needs a scheduled tick or stale branches pile up. | |
| 1197 | - cron: '0 4 * * *' | |
| 1198 | # Weekly dependency-health report, Monday 09:00 UTC. | |
| 1199 | - cron: '0 9 * * 1' | |
| 1389 | ${renderScheduleTriggers(plan)} | |
| 1200 | 1390 | |
| 1201 | 1391 | workflow_dispatch: # Manual trigger |
| 1202 | 1392 | inputs: |
| @@ -1214,7 +1404,7 @@ on: | ||
| 1214 | 1404 | strategy: |
| 1215 | 1405 | description: Update strategy |
| 1216 | 1406 | required: false |
| 1217 | default: patch | |
| 1407 | default: ${preset.strategy} | |
| 1218 | 1408 | type: choice |
| 1219 | 1409 | options: |
| 1220 | 1410 | - all |
| @@ -1291,6 +1481,7 @@ jobs: | ||
| 1291 | 1481 | run_command: \${{ steps.determine.outputs.run_command }} |
| 1292 | 1482 | run_review: \${{ steps.determine.outputs.run_review }} |
| 1293 | 1483 | run_fixci: \${{ steps.determine.outputs.run_fixci }} |
| 1484 | run_report: \${{ steps.determine.outputs.run_report }} | |
| 1294 | 1485 | steps: |
| 1295 | 1486 | - name: Determine which jobs to run |
| 1296 | 1487 | id: determine |
| @@ -1392,13 +1583,7 @@ jobs: | ||
| 1392 | 1583 | fi |
| 1393 | 1584 | elif [ "\$" = "schedule" ]; then |
| 1394 | 1585 | # Determine based on cron schedule |
| 1395 | if [ "\$" = "0 */2 * * *" ]; then | |
| 1396 | echo "run_update=true" >> \$GITHUB_OUTPUT | |
| 1397 | elif [ "\$" = "15 */2 * * *" ]; then | |
| 1398 | echo "run_dashboard=true" >> \$GITHUB_OUTPUT | |
| 1399 | elif [ "\$" = "0 4 * * *" ]; then | |
| 1400 | echo "run_check=true" >> \$GITHUB_OUTPUT | |
| 1401 | fi | |
| 1586 | ${renderScheduleDispatch(plan)} | |
| 1402 | 1587 | fi |
| 1403 | 1588 | |
| 1404 | 1589 | # Shared setup job for common dependencies |
| @@ -1406,7 +1591,7 @@ jobs: | ||
| 1406 | 1591 | runs-on: ubuntu-latest |
| 1407 | 1592 | timeout-minutes: 10 |
| 1408 | 1593 | needs: determine-jobs |
| 1409 | if: \${{ needs.determine-jobs.outputs.run_check == 'true' || needs.determine-jobs.outputs.run_update == 'true' || needs.determine-jobs.outputs.run_dashboard == 'true' }} | |
| 1594 | if: \${{ needs.determine-jobs.outputs.run_check == 'true' || needs.determine-jobs.outputs.run_update == 'true' || needs.determine-jobs.outputs.run_dashboard == 'true' || needs.determine-jobs.outputs.run_report == 'true' }} | |
| 1410 | 1595 | steps: |
| 1411 | 1596 | - name: Checkout repository |
| 1412 | 1597 | uses: actions/checkout@v4 |
| @@ -1550,7 +1735,7 @@ ${generateComposerSetupSteps()} | ||
| 1550 | 1735 | runs-on: ubuntu-latest |
| 1551 | 1736 | timeout-minutes: 20 |
| 1552 | 1737 | needs: [determine-jobs, setup] |
| 1553 | if: \${{ github.event_name == 'schedule' && github.event.schedule == '0 9 * * 1' }} | |
| 1738 | if: \${{ needs.determine-jobs.outputs.run_report == 'true' }} | |
| 1554 | 1739 | |
| 1555 | 1740 | steps: |
| 1556 | 1741 | - name: Checkout repository |
| @@ -1751,7 +1936,7 @@ ${generateComposerSetupSteps()} | ||
| 1751 | 1936 | - name: Display update configuration |
| 1752 | 1937 | run: | |
| 1753 | 1938 | echo "🧪 **Buddy Update Mode**" |
| 1754 | echo "Strategy: \$" | |
| 1939 | echo "Strategy: \$" | |
| 1755 | 1940 | echo "Dry Run: \$" |
| 1756 | 1941 | echo "Packages: \$" |
| 1757 | 1942 | echo "Verbose: \$" |
| @@ -1762,7 +1947,7 @@ ${generateComposerSetupSteps()} | ||
| 1762 | 1947 | - name: Run Buddy dependency updates |
| 1763 | 1948 | if: \${{ github.event.inputs.dry_run != 'true' }} |
| 1764 | 1949 | run: | |
| 1765 | STRATEGY="\$" | |
| 1950 | STRATEGY="\$" | |
| 1766 | 1951 | PACKAGES="\$" |
| 1767 | 1952 | VERBOSE="\$" |
| 1768 | 1953 | |
| @@ -1801,7 +1986,7 @@ ${generateComposerSetupSteps()} | ||
| 1801 | 1986 | run: | |
| 1802 | 1987 | echo "## 🚀 Dependency Update Summary" >> \$GITHUB_STEP_SUMMARY |
| 1803 | 1988 | echo "" >> \$GITHUB_STEP_SUMMARY |
| 1804 | echo "- **Strategy**: \$" >> \$GITHUB_STEP_SUMMARY | |
| 1989 | echo "- **Strategy**: \$" >> \$GITHUB_STEP_SUMMARY | |
| 1805 | 1990 | echo "- **Triggered by**: \$" >> \$GITHUB_STEP_SUMMARY |
| 1806 | 1991 | echo "- **Dry run**: \$" >> \$GITHUB_STEP_SUMMARY |
| 1807 | 1992 | echo "- **Packages**: \$" >> \$GITHUB_STEP_SUMMARY |
| @@ -1810,11 +1995,11 @@ ${generateComposerSetupSteps()} | ||
| 1810 | 1995 | echo "" >> \$GITHUB_STEP_SUMMARY |
| 1811 | 1996 | |
| 1812 | 1997 | if [ "\$" = "schedule" ]; then |
| 1813 | echo "⏰ **Scheduled Run**: This was triggered automatically every 2 hours" >> \$GITHUB_STEP_SUMMARY | |
| 1998 | echo "⏰ **Scheduled Run**: This was triggered automatically (${preset.name}: \`${preset.schedules.updates}\`)" >> \$GITHUB_STEP_SUMMARY | |
| 1814 | 1999 | echo "💡 **Tip**: Use 'Actions' tab to manually trigger with custom settings" >> \$GITHUB_STEP_SUMMARY |
| 1815 | 2000 | else |
| 1816 | 2001 | echo "🖱️ **Manual Trigger**: This was triggered manually from the Actions tab" >> \$GITHUB_STEP_SUMMARY |
| 1817 | echo "⏰ **Auto-Schedule**: This workflow also runs every 2 hours" >> \$GITHUB_STEP_SUMMARY | |
| 2002 | echo "⏰ **Auto-Schedule**: This workflow also runs on \`${preset.schedules.updates}\`" >> \$GITHUB_STEP_SUMMARY | |
| 1818 | 2003 | fi |
| 1819 | 2004 | |
| 1820 | 2005 | echo "" >> \$GITHUB_STEP_SUMMARY |
| @@ -1982,7 +2167,7 @@ ${generateComposerSetupSteps()} | ||
| 1982 | 2167 | |
| 1983 | 2168 | if [ "\$" = "schedule" ]; then |
| 1984 | 2169 | echo "⏰ **Scheduled Update**: This was triggered automatically" >> \$GITHUB_STEP_SUMMARY |
| 1985 | echo "🔄 **Schedule**: Every 2 hours, 15 minutes after dependency updates" >> \$GITHUB_STEP_SUMMARY | |
| 2170 | echo "🔄 **Schedule**: \`${preset.schedules.dashboard}\` (${preset.name})" >> \$GITHUB_STEP_SUMMARY | |
| 1986 | 2171 | echo "💡 **Tip**: Use 'Actions' tab to manually trigger with custom settings" >> \$GITHUB_STEP_SUMMARY |
| 1987 | 2172 | else |
| 1988 | 2173 | echo "🖱️ **Manual Trigger**: This was triggered manually from the Actions tab" >> \$GITHUB_STEP_SUMMARY |
| @@ -2002,7 +2187,7 @@ ${generateComposerSetupSteps()} | ||
| 2002 | 2187 | ` |
| 2003 | 2188 | } |
| 2004 | 2189 | |
| 2005 | export async function generateCoreWorkflows(_preset: WorkflowPreset, _repoInfo: RepositoryInfo, hasCustomToken: boolean, logger: Logger): Promise<void> { | |
| 2190 | export async function generateCoreWorkflows(preset: WorkflowPreset, _repoInfo: RepositoryInfo, hasCustomToken: boolean, logger: Logger): Promise<void> { | |
| 2006 | 2191 | // GitLab and Bitbucket get their own pipeline format. Writing GitHub Actions |
| 2007 | 2192 | // workflows into a GitLab repository would produce files that never run, |
| 2008 | 2193 | // which reads as a successful setup until the first schedule does nothing. |
| @@ -2023,9 +2208,12 @@ export async function generateCoreWorkflows(_preset: WorkflowPreset, _repoInfo: | ||
| 2023 | 2208 | } |
| 2024 | 2209 | |
| 2025 | 2210 | // Generate unified workflow that combines all three previous workflows |
| 2026 | const unifiedWorkflow = generateUnifiedWorkflow(hasCustomToken) | |
| 2211 | const unifiedWorkflow = generateUnifiedWorkflow(hasCustomToken, preset) | |
| 2027 | 2212 | fs.writeFileSync(path.join(outputDir, 'buddy.yml'), unifiedWorkflow) |
| 2028 | logger.info('Generated unified buddy workflow (combines check, update, and dashboard)') | |
| 2213 | logger.info(`Generated unified buddy workflow for ${preset.name} (combines check, update, and dashboard)`) | |
| 2214 | ||
| 2215 | for (const { cron, jobs } of schedulePlan(preset)) | |
| 2216 | logger.info(` ${cron} — ${jobs.join(', ')}`) | |
| 2029 | 2217 | |
| 2030 | 2218 | // Generate the security-audit workflow alongside. Lives as its own |
| 2031 | 2219 | // file so it runs (and triggers on workflow path filters) independently |
| @@ -123,7 +123,8 @@ describe('CLI Non-Interactive Integration Tests', () => { | ||
| 123 | 123 | // Verify the unified workflow has the correct content |
| 124 | 124 | const unifiedContent = await fs.readFile('.github/workflows/buddy.yml', 'utf-8') |
| 125 | 125 | expect(unifiedContent).toContain('name: Buddy') |
| 126 | expect(unifiedContent).toContain('cron: \'0 */2 * * *\'') | |
| 126 | // The standard preset's own schedule, not a constant the generator invents. | |
| 127 | expect(unifiedContent).toContain('cron: \'0 9 * * 1,3,5\'') | |
| 127 | 128 | expect(unifiedContent).toContain('default: true') // dry_run default |
| 128 | 129 | }) |
| 129 | 130 | |
| @@ -186,7 +187,7 @@ describe('CLI Non-Interactive Integration Tests', () => { | ||
| 186 | 187 | // Test all the required sections from the specification |
| 187 | 188 | const requiredSections = [ |
| 188 | 189 | 'name: Buddy', |
| 189 | 'cron: \'0 */2 * * *\'', | |
| 190 | 'cron: \'0 9 * * 1,3,5\'', | |
| 190 | 191 | 'workflow_dispatch:', |
| 191 | 192 | 'strategy:', |
| 192 | 193 | 'dry_run:', |
| @@ -251,7 +252,7 @@ describe('CLI Non-Interactive Integration Tests', () => { | ||
| 251 | 252 | |
| 252 | 253 | const requiredSections = [ |
| 253 | 254 | 'name: Buddy', |
| 254 | 'cron: \'15 */2 * * *\'', | |
| 255 | 'cron: \'0 9 * * 1,3,5\'', | |
| 255 | 256 | 'workflow_dispatch:', |
| 256 | 257 | 'bunx @buddysh/buddy dashboard', |
| 257 | 258 | 'dashboard-update:', // job name |
| @@ -9,16 +9,16 @@ describe('CLI Setup - Extended Tests', () => { | ||
| 9 | 9 | const workflowWithCustomToken = generateUnifiedWorkflow(true) |
| 10 | 10 | expect(workflowWithCustomToken).toContain('name: Buddy') |
| 11 | 11 | expect(workflowWithCustomToken).toContain('pull_request:') // Rebase checkbox via PR edit event |
| 12 | expect(workflowWithCustomToken).toContain('cron: \'0 */2 * * *\'') // Update job | |
| 13 | expect(workflowWithCustomToken).toContain('cron: \'15 */2 * * *\'') // Dashboard job | |
| 12 | // Standard puts updates and the dashboard on one expression, so one | |
| 13 | // cron entry starts both jobs. | |
| 14 | expect(workflowWithCustomToken).toContain('cron: \'0 9 * * 1,3,5\'') | |
| 14 | 15 | expect(workflowWithCustomToken).toContain('BUDDY_TOKEN') |
| 15 | 16 | |
| 16 | 17 | // Test with default token |
| 17 | 18 | const workflowWithDefaultToken = generateUnifiedWorkflow(false) |
| 18 | 19 | expect(workflowWithDefaultToken).toContain('name: Buddy') |
| 19 | 20 | expect(workflowWithDefaultToken).toContain('pull_request:') // Rebase checkbox via PR edit event |
| 20 | expect(workflowWithDefaultToken).toContain('cron: \'0 */2 * * *\'') // Update job | |
| 21 | expect(workflowWithDefaultToken).toContain('cron: \'15 */2 * * *\'') // Dashboard job | |
| 21 | expect(workflowWithDefaultToken).toContain('cron: \'0 9 * * 1,3,5\'') | |
| 22 | 22 | // eslint-disable-next-line no-template-curly-in-string |
| 23 | 23 | expect(workflowWithDefaultToken).toContain('$') |
| 24 | 24 | // Should not use BUDDY_TOKEN in the actual token environment variable |
| @@ -43,13 +43,13 @@ describe('CLI Setup - Non-Interactive Mode', () => { | ||
| 43 | 43 | }) |
| 44 | 44 | |
| 45 | 45 | describe('Workflow Generation Functions', () => { |
| 46 | it('should generate update workflow with correct schedule for every 2 hours', async () => { | |
| 46 | it('should generate the update schedule the preset asks for', async () => { | |
| 47 | 47 | const { generateUnifiedWorkflow } = await import('../src/setup') |
| 48 | 48 | |
| 49 | 49 | const workflow = generateUnifiedWorkflow(false) |
| 50 | 50 | |
| 51 | 51 | expect(workflow).toContain('name: Buddy') |
| 52 | expect(workflow).toContain('cron: \'0 */2 * * *\'') | |
| 52 | expect(workflow).toContain('cron: \'0 9 * * 1,3,5\'') | |
| 53 | 53 | expect(workflow).toContain('default: true') // dry_run default |
| 54 | 54 | expect(workflow).toContain('dependency-update:') // job name |
| 55 | 55 | expect(workflow).toContain('fetch-depth: 0') |
| @@ -7,7 +7,7 @@ describe('CLI Setup - Enhanced Functions', () => { | ||
| 7 | 7 | const workflow = generateUnifiedWorkflow(true) |
| 8 | 8 | |
| 9 | 9 | expect(workflow).toContain('name: Buddy') |
| 10 | expect(workflow).toContain('cron: \'15 */2 * * *\'') // Updated dashboard schedule | |
| 10 | expect(workflow).toContain('cron: \'0 9 * * 1,3,5\'') // Standard preset schedule | |
| 11 | 11 | // GITHUB_TOKEN is the built-in token; BUDDY_TOKEN is passed separately for workflow file permissions |
| 12 | 12 | // eslint-disable-next-line no-template-curly-in-string |
| 13 | 13 | expect(workflow).toContain('GITHUB_TOKEN: $') |
| @@ -38,8 +38,8 @@ describe('CLI Setup - Enhanced Functions', () => { | ||
| 38 | 38 | expect(workflow).toContain('dependency-update:') |
| 39 | 39 | expect(workflow).toContain('dashboard-update:') |
| 40 | 40 | expect(workflow).toContain('pull_request:') // Rebase checkbox triggers instantly via PR edit event |
| 41 | expect(workflow).toContain('cron: \'0 */2 * * *\'') // Update every 2 hours | |
| 42 | expect(workflow).toContain('cron: \'15 */2 * * *\'') // Dashboard 15 mins after updates | |
| 41 | expect(workflow).toContain('cron: \'0 9 * * 1,3,5\'') // Updates and dashboard | |
| 42 | expect(workflow).toContain('cron: \'0 4 * * *\'') // Housekeeping, never preset-driven | |
| 43 | 43 | expect(workflow).toContain('bunx @buddysh/buddy update-check') |
| 44 | 44 | expect(workflow).toContain('bunx @buddysh/buddy update') |
| 45 | 45 | expect(workflow).toContain('bunx @buddysh/buddy dashboard') |
| @@ -70,7 +70,7 @@ describe('CLI Setup - Enhanced Functions', () => { | ||
| 70 | 70 | const workflow = generateUnifiedWorkflow(false) |
| 71 | 71 | |
| 72 | 72 | expect(workflow).toContain('name: Buddy') |
| 73 | expect(workflow).toContain('cron: \'0 */2 * * *\'') | |
| 73 | expect(workflow).toContain('cron: \'0 9 * * 1,3,5\'') | |
| 74 | 74 | expect(workflow).toContain('default: false') // dry_run default |
| 75 | 75 | expect(workflow).toContain('dependency-update:') // job name |
| 76 | 76 | expect(workflow).toContain('determine-jobs:') // job coordination |
| @@ -0,0 +1,223 @@ | ||
| 1 | import type { BuddyConfig } from '../src/types' | |
| 2 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 3 | import { promises as fs } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { validateConfig } from '../src/config-validation' | |
| 7 | import { generateConfigFile, generateUnifiedWorkflow, getWorkflowPreset, schedulePlan } from '../src/setup' | |
| 8 | ||
| 9 | interface Workflow { | |
| 10 | on: { schedule?: Array<{ cron: string }> } | |
| 11 | jobs: Record<string, { if?: string, needs?: string[], outputs?: Record<string, string> }> | |
| 12 | } | |
| 13 | ||
| 14 | function parse(useCase: string): Workflow { | |
| 15 | return Bun.YAML.parse(generateUnifiedWorkflow(false, getWorkflowPreset(useCase))) as Workflow | |
| 16 | } | |
| 17 | ||
| 18 | function crons(useCase: string): string[] { | |
| 19 | return (parse(useCase).on.schedule ?? []).map(entry => entry.cron) | |
| 20 | } | |
| 21 | ||
| 22 | /** The `determine-jobs` script, where a scheduled tick is matched to its jobs. */ | |
| 23 | function dispatchScript(useCase: string): string { | |
| 24 | const yaml = generateUnifiedWorkflow(false, getWorkflowPreset(useCase)) | |
| 25 | return yaml.slice(yaml.indexOf('case "$"'), yaml.indexOf('# Shared setup job')) | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * `generateCoreWorkflows` took a preset and named the parameter `_preset`. Every | |
| 30 | * repository got the same workflow whichever of the eight a maintainer picked, | |
| 31 | * so the picker, the `--preset` flag and the setup summary all described a | |
| 32 | * choice that had no effect. | |
| 33 | */ | |
| 34 | describe('workflow presets', () => { | |
| 35 | describe('the schedule plan', () => { | |
| 36 | it('success case - a preset\'s own cadence reaches the workflow', () => { | |
| 37 | expect(crons('security')).toContain('0 */4 * * *') | |
| 38 | expect(crons('high-frequency')).toContain('0 */6 * * *') | |
| 39 | expect(crons('testing')).toContain('*/15 * * * *') | |
| 40 | }) | |
| 41 | ||
| 42 | it('success case - two presets do not produce the same workflow', () => { | |
| 43 | // The regression this suite exists for. | |
| 44 | expect(crons('minimal')).not.toEqual(crons('high-frequency')) | |
| 45 | }) | |
| 46 | ||
| 47 | it('success case - a cron shared by two jobs starts both', () => { | |
| 48 | // Standard puts updates and the dashboard on one expression. An if/elif | |
| 49 | // chain would let the first branch win and drop the dashboard entirely. | |
| 50 | const plan = schedulePlan(getWorkflowPreset('standard')) | |
| 51 | const shared = plan.find(entry => entry.cron === '0 9 * * 1,3,5') | |
| 52 | ||
| 53 | expect(shared?.jobs).toEqual(['update', 'dashboard']) | |
| 54 | expect(crons('standard').filter(cron => cron === '0 9 * * 1,3,5')).toHaveLength(1) | |
| 55 | }) | |
| 56 | ||
| 57 | it('success case - a cron shared by three jobs starts all three', () => { | |
| 58 | // Minimal's weekly slot collides with the fixed health-report cron. | |
| 59 | const plan = schedulePlan(getWorkflowPreset('minimal')) | |
| 60 | ||
| 61 | expect(plan.find(entry => entry.cron === '0 9 * * 1')?.jobs) | |
| 62 | .toEqual(['update', 'dashboard', 'report']) | |
| 63 | }) | |
| 64 | ||
| 65 | it('edge case - `manual` means no schedule, not a cron expression', () => { | |
| 66 | // `testing` and `custom` use it for the dashboard. Emitted verbatim it | |
| 67 | // would be YAML GitHub refuses to parse. | |
| 68 | expect(crons('testing')).not.toContain('manual') | |
| 69 | expect(crons('custom')).not.toContain('manual') | |
| 70 | expect(crons('custom')).toEqual(['0 4 * * *', '0 9 * * 1']) | |
| 71 | }) | |
| 72 | ||
| 73 | it('success case - housekeeping runs whatever the preset asks for', () => { | |
| 74 | // A preset that could switch branch cleanup off would let stale | |
| 75 | // branches pile up silently. | |
| 76 | for (const useCase of ['standard', 'high-frequency', 'security', 'minimal', 'testing', 'custom']) | |
| 77 | expect(crons(useCase)).toContain('0 4 * * *') | |
| 78 | }) | |
| 79 | ||
| 80 | it('edge case - an unknown preset falls back to standard', () => { | |
| 81 | expect(crons('nonsense')).toEqual(crons('standard')) | |
| 82 | }) | |
| 83 | }) | |
| 84 | ||
| 85 | describe('triggers and their matcher agree', () => { | |
| 86 | it('success case - every declared cron has a branch that runs something', () => { | |
| 87 | // A cron declared in `schedule:` with no matching branch starts a | |
| 88 | // workflow that does nothing at all — which is how the health report | |
| 89 | // came to be unreachable. | |
| 90 | for (const useCase of ['standard', 'high-frequency', 'security', 'minimal', 'testing', 'custom']) { | |
| 91 | const script = dispatchScript(useCase) | |
| 92 | ||
| 93 | for (const cron of crons(useCase)) | |
| 94 | expect(script).toContain(`"${cron}")`) | |
| 95 | } | |
| 96 | }) | |
| 97 | ||
| 98 | it('success case - every branch corresponds to a declared cron', () => { | |
| 99 | const script = dispatchScript('high-frequency') | |
| 100 | const matched = [...script.matchAll(/"([^"]+)"\)/g)].map(match => match[1]) | |
| 101 | ||
| 102 | expect(matched.sort()).toEqual([...crons('high-frequency')].sort()) | |
| 103 | }) | |
| 104 | }) | |
| 105 | ||
| 106 | describe('the dependency-health report', () => { | |
| 107 | it('success case - is reachable', () => { | |
| 108 | // It was gated on a cron literal that `determine-jobs` never matched, so | |
| 109 | // `setup` was skipped, and the report — which needs setup — was skipped | |
| 110 | // with it. The job was generated and could never once have run. | |
| 111 | const workflow = parse('standard') | |
| 112 | ||
| 113 | expect(workflow.jobs.report.if).toContain('run_report') | |
| 114 | expect(workflow.jobs['determine-jobs'].outputs).toHaveProperty('run_report') | |
| 115 | }) | |
| 116 | ||
| 117 | it('success case - its setup dependency runs on a report-only tick', () => { | |
| 118 | // `report` needs `setup`, and a skipped dependency skips the job that | |
| 119 | // needs it. Gating setup on the update jobs alone was the whole bug. | |
| 120 | expect(parse('standard').jobs.setup.if).toContain('run_report') | |
| 121 | }) | |
| 122 | }) | |
| 123 | ||
| 124 | describe('update strategy', () => { | |
| 125 | it('success case - scheduled runs use the preset strategy', () => { | |
| 126 | const yaml = generateUnifiedWorkflow(false, getWorkflowPreset('testing')) | |
| 127 | ||
| 128 | expect(yaml).toContain(`github.event.inputs.strategy || 'patch'`) | |
| 129 | }) | |
| 130 | ||
| 131 | it('success case - the manual trigger defaults to the same strategy', () => { | |
| 132 | // Two defaults that disagree would make a manual run behave unlike the | |
| 133 | // scheduled one it is meant to stand in for. | |
| 134 | const yaml = generateUnifiedWorkflow(false, getWorkflowPreset('testing')) | |
| 135 | ||
| 136 | expect(yaml).toContain('default: patch') | |
| 137 | }) | |
| 138 | }) | |
| 139 | }) | |
| 140 | ||
| 141 | ||
| 142 | /** | |
| 143 | * The config file discarded the preset too: `strategy` was hardcoded to | |
| 144 | * `'all'`, the templates to the standard set, and `autoMerge` — the one field | |
| 145 | * that changes whether pull requests merge themselves — reached nothing at all. | |
| 146 | */ | |
| 147 | describe('the generated config file', () => { | |
| 148 | let testDir: string | |
| 149 | let originalCwd: string | |
| 150 | ||
| 151 | beforeEach(async () => { | |
| 152 | testDir = await fs.mkdtemp(join(tmpdir(), 'buddy-preset-')) | |
| 153 | originalCwd = process.cwd() | |
| 154 | process.chdir(testDir) | |
| 155 | }) | |
| 156 | ||
| 157 | afterEach(async () => { | |
| 158 | process.chdir(originalCwd) | |
| 159 | await fs.rm(testDir, { recursive: true, force: true }) | |
| 160 | }) | |
| 161 | ||
| 162 | /** Generate for a preset and load the result back as a real module. */ | |
| 163 | async function generated(useCase: string): Promise<BuddyConfig> { | |
| 164 | await generateConfigFile({ owner: 'o', name: 'r' }, false, getWorkflowPreset(useCase)) | |
| 165 | ||
| 166 | // Imported rather than string-matched: this proves the file setup wrote is | |
| 167 | // valid TypeScript that evaluates to a config, not merely that it contains | |
| 168 | // the right substrings. | |
| 169 | const module = await import(`${join(testDir, 'buddy.config.ts')}?t=${useCase}`) | |
| 170 | return module.default as BuddyConfig | |
| 171 | } | |
| 172 | ||
| 173 | it('success case - is a valid config for every preset', async () => { | |
| 174 | for (const useCase of ['standard', 'high-frequency', 'security', 'minimal', 'docker', 'monorepo', 'testing', 'custom']) | |
| 175 | expect(validateConfig(await generated(useCase))).toEqual([]) | |
| 176 | }) | |
| 177 | ||
| 178 | it('success case - the update strategy comes from the preset', async () => { | |
| 179 | expect((await generated('testing')).packages?.strategy).toBe('patch') | |
| 180 | expect((await generated('standard')).packages?.strategy).toBe('all') | |
| 181 | }) | |
| 182 | ||
| 183 | it('success case - the preset\'s custom workflows reach the config', async () => { | |
| 184 | // `buddy generate-workflows` reads these, so a preset that declares them | |
| 185 | // and a config that drops them means the workflows are never written. | |
| 186 | const custom = (await generated('security')).workflows?.custom ?? [] | |
| 187 | ||
| 188 | expect(custom.map(workflow => workflow.name)).toEqual(['security-patches', 'weekly-minor']) | |
| 189 | expect(custom[0].schedule).toBe('0 */6 * * *') | |
| 190 | }) | |
| 191 | ||
| 192 | it('success case - templates come from the preset', async () => { | |
| 193 | expect((await generated('docker')).workflows?.templates).toEqual({ docker: true, weekly: true }) | |
| 194 | expect((await generated('security')).workflows?.templates).toEqual({}) | |
| 195 | }) | |
| 196 | ||
| 197 | it('success case - a preset that asks for auto-merge gets it', async () => { | |
| 198 | const autoMerge = (await generated('security')).pullRequest?.autoMerge | |
| 199 | ||
| 200 | expect(autoMerge?.enabled).toBe(true) | |
| 201 | expect(autoMerge?.strategy).toBe('squash') | |
| 202 | }) | |
| 203 | ||
| 204 | it('failure case - auto-merge stays scoped to patches', async () => { | |
| 205 | // The preset carries a bare boolean. Generating an unconditional | |
| 206 | // auto-merge from a `true` would hand a new repository something far more | |
| 207 | // dangerous than the preset describes. | |
| 208 | expect((await generated('security')).pullRequest?.autoMerge?.conditions).toEqual(['patch-only']) | |
| 209 | }) | |
| 210 | ||
| 211 | it('success case - a preset that does not ask for auto-merge has none', async () => { | |
| 212 | // An empty or missing condition list means nothing auto-merges, but the | |
| 213 | // absence should be explicit rather than relying on that reading. | |
| 214 | expect((await generated('standard')).pullRequest?.autoMerge).toBeUndefined() | |
| 215 | }) | |
| 216 | ||
| 217 | it('edge case - no preset generates a working default', async () => { | |
| 218 | await generateConfigFile({ owner: 'o', name: 'r' }, false) | |
| 219 | const module = await import(`${join(testDir, 'buddy.config.ts')}?t=default`) | |
| 220 | ||
| 221 | expect(validateConfig(module.default)).toEqual([]) | |
| 222 | }) | |
| 223 | }) | |