ReviewOS

also looking at this

stacks/buddy

feat(setup): make the preset you pick the preset you get

#1411
Closed chris wants to merge feat/preset-wiring into main
10 files +507 -61
README.mdmodified+3-3
Changes to README.md
@@ -600,7 +600,7 @@ buddy dashboard --title "My Dependencies"
600600
601601The unified workflow (`.github/workflows/buddy.yml`) automatically updates your dependency dashboard:
602602
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
604604- **��️ Manual**: Trigger from Actions tab with custom options
605605- **�� Auto-Pin**: Keeps dashboard pinned by default
606606- **�� Dry-Run**: Preview mode available
@@ -972,12 +972,12 @@ buddy generate-workflows
972972
973973The updated workflow system automatically:
974974
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
976976- **Manual trigger**: Any strategy with configurable dry-run option
977977- **Enhanced testing**: Comprehensive validation and summaries
978978- **Failure handling**: Auto-creates GitHub issues
979979- **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
981981
982982### GitHub Actions Permissions Setup
983983
bin/cli.tsmodified+5-2
Changes to bin/cli.ts
@@ -549,19 +549,22 @@ cli
549549 }
550550 }
551551
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
552556 // Step 5: Generate Configuration File
553557 updateProgress(progress, 'Configuration File', true)
554558 displayProgress(progress)
555559
556560 console.log('\n📝 Configuration File')
557 await generateConfigFile(repoInfo, tokenSetup.hasCustomToken)
561 await generateConfigFile(repoInfo, tokenSetup.hasCustomToken, preset)
558562
559563 // Step 6: Generate Workflows
560564 updateProgress(progress, 'Workflow Generation', true)
561565 displayProgress(progress)
562566
563567 console.log('\n🔄 Workflow Generation')
564 const preset = getWorkflowPreset(workflowResponse.useCase)
565568
566569 if (workflowResponse.useCase === 'custom' && !options.nonInteractive) {
567570 await setupCustomWorkflow(preset, logger)
docs/cli/setup.mdmodified+34-3
Changes to docs/cli/setup.md
@@ -280,7 +280,7 @@ Generates two workflows:
280280
281281#### 1. Unified Workflow (`buddy.yml`)
282282
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
284284- **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
285285- **Purpose**: One workflow covering checks, updates, the dashboard, reviews and comment commands
286286- **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
430430
431431### Preset Options
432432
433Every 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.
433The preset you choose decides the generated workflow's update and dashboard
434schedules, its default update strategy, and whether the generated config turns
435auto-merge on. Branch cleanup and the health report are the same in every
436preset. Nothing is locked in — the `schedule:` block is ordinary YAML you can
437edit afterwards.
434438
435439#### Standard Preset
436440
@@ -741,7 +745,34 @@ Configure GitHub Actions permissions:
741745
742746## Preset Details
743747
744A 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.
748A preset sets four things: when dependency updates run, when the dashboard
749refreshes, which update strategy scheduled runs use, and whether the generated
750`buddy.config.ts` enables auto-merge.
751
752Two schedules are the same in every preset, because housekeeping does not get
753cheaper or riskier with cadence: branch cleanup daily at 4 AM UTC, and a
754dependency-health report on Monday at 9 AM UTC.
755
756Where a preset gives updates and the dashboard the same expression — most of
757them do — one cron entry starts both jobs rather than one of them winning.
758A preset that says `manual` for a schedule gets no scheduled trigger for that
759job 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
772Auto-merge is scoped to patch updates wherever a preset enables it. The preset
773carries a bare on/off, and generating an unconditional auto-merge from that
774would hand a new repository something more dangerous than the preset
775describes — widen it deliberately in `buddy.config.ts` if you want more.
745776
746777### Standard Setup (Recommended)
747778
docs/guide/getting-started.mdmodified+1-1
Changes to docs/guide/getting-started.md
@@ -140,7 +140,7 @@ After setup, Buddy creates two workflows:
140140
141141One unified workflow covering checks, updates and the dashboard:
142142
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
144144- Rebase and dashboard checkboxes are event-driven, so ticking a box triggers a run immediately
145145- Runs a daily cleanup pass at 4 AM UTC and a weekly dependency report Monday at 9 AM UTC
146146- Answers `@buddy` comments and supports manual triggers
src/setup.tsmodified+227-39
Changes to src/setup.ts
@@ -1085,7 +1085,92 @@ export async function guideRepositorySettings(repoInfo: RepositoryInfo): Promise
10851085 getDefaultLogger().info(`4. This allows Buddy to create PRs and update issues.\n`)
10861086}
10871087
1088export 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 */
1099function 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 */
1132function 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 */
1153function 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
1169export async function generateConfigFile(
1170 repoInfo: RepositoryInfo,
1171 hasCustomToken: boolean,
1172 preset: WorkflowPreset = getWorkflowPreset('standard'),
1173): Promise<void> {
10891174 const configContent = `import type { BuddyConfig } from '@buddysh/buddy'
10901175
10911176const config: BuddyConfig = {
@@ -1103,15 +1188,11 @@ const config: BuddyConfig = {
11031188 workflows: {
11041189 enabled: true,
11051190 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)}
11131194 packages: {
1114 strategy: 'all',
1195 strategy: '${preset.strategy}',
11151196 ignore: [
11161197 // Add packages to ignore here
11171198 // Example: '@types/node', 'eslint'
@@ -1153,7 +1234,123 @@ function generateComposerSetupSteps(): string {
11531234`
11541235}
11551236
1156export 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 */
1244const CLEANUP_SCHEDULE = '0 4 * * *'
1245
1246/** Cron for the weekly dependency-health report. */
1247const REPORT_SCHEDULE = '0 9 * * 1'
1248
1249/** A job a scheduled tick can start. */
1250export type ScheduledJob = 'update' | 'dashboard' | 'check' | 'report'
1251
1252/** One cron expression and everything it starts. */
1253export 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 */
1285export 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. */
1308const 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. */
1316function 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 */
1331function 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
1348export function generateUnifiedWorkflow(
1349 _hasCustomToken: boolean,
1350 preset: WorkflowPreset = getWorkflowPreset('standard'),
1351): string {
1352 const plan = schedulePlan(preset)
1353
11571354 return `name: Buddy
11581355
11591356on:
@@ -1186,17 +1383,10 @@ on:
11861383 workflow_run:
11871384 types: [completed]
11881385
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.
11891388 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)}
12001390
12011391 workflow_dispatch: # Manual trigger
12021392 inputs:
@@ -1214,7 +1404,7 @@ on:
12141404 strategy:
12151405 description: Update strategy
12161406 required: false
1217 default: patch
1407 default: ${preset.strategy}
12181408 type: choice
12191409 options:
12201410 - all
@@ -1291,6 +1481,7 @@ jobs:
12911481 run_command: \${{ steps.determine.outputs.run_command }}
12921482 run_review: \${{ steps.determine.outputs.run_review }}
12931483 run_fixci: \${{ steps.determine.outputs.run_fixci }}
1484 run_report: \${{ steps.determine.outputs.run_report }}
12941485 steps:
12951486 - name: Determine which jobs to run
12961487 id: determine
@@ -1392,13 +1583,7 @@ jobs:
13921583 fi
13931584 elif [ "\$" = "schedule" ]; then
13941585 # 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)}
14021587 fi
14031588
14041589 # Shared setup job for common dependencies
@@ -1406,7 +1591,7 @@ jobs:
14061591 runs-on: ubuntu-latest
14071592 timeout-minutes: 10
14081593 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' }}
14101595 steps:
14111596 - name: Checkout repository
14121597 uses: actions/checkout@v4
@@ -1550,7 +1735,7 @@ ${generateComposerSetupSteps()}
15501735 runs-on: ubuntu-latest
15511736 timeout-minutes: 20
15521737 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' }}
15541739
15551740 steps:
15561741 - name: Checkout repository
@@ -1751,7 +1936,7 @@ ${generateComposerSetupSteps()}
17511936 - name: Display update configuration
17521937 run: |
17531938 echo "🧪 **Buddy Update Mode**"
1754 echo "Strategy: \$"
1939 echo "Strategy: \$"
17551940 echo "Dry Run: \$"
17561941 echo "Packages: \$"
17571942 echo "Verbose: \$"
@@ -1762,7 +1947,7 @@ ${generateComposerSetupSteps()}
17621947 - name: Run Buddy dependency updates
17631948 if: \${{ github.event.inputs.dry_run != 'true' }}
17641949 run: |
1765 STRATEGY="\$"
1950 STRATEGY="\$"
17661951 PACKAGES="\$"
17671952 VERBOSE="\$"
17681953
@@ -1801,7 +1986,7 @@ ${generateComposerSetupSteps()}
18011986 run: |
18021987 echo "## 🚀 Dependency Update Summary" >> \$GITHUB_STEP_SUMMARY
18031988 echo "" >> \$GITHUB_STEP_SUMMARY
1804 echo "- **Strategy**: \$" >> \$GITHUB_STEP_SUMMARY
1989 echo "- **Strategy**: \$" >> \$GITHUB_STEP_SUMMARY
18051990 echo "- **Triggered by**: \$" >> \$GITHUB_STEP_SUMMARY
18061991 echo "- **Dry run**: \$" >> \$GITHUB_STEP_SUMMARY
18071992 echo "- **Packages**: \$" >> \$GITHUB_STEP_SUMMARY
@@ -1810,11 +1995,11 @@ ${generateComposerSetupSteps()}
18101995 echo "" >> \$GITHUB_STEP_SUMMARY
18111996
18121997 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
18141999 echo "💡 **Tip**: Use 'Actions' tab to manually trigger with custom settings" >> \$GITHUB_STEP_SUMMARY
18152000 else
18162001 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
18182003 fi
18192004
18202005 echo "" >> \$GITHUB_STEP_SUMMARY
@@ -1982,7 +2167,7 @@ ${generateComposerSetupSteps()}
19822167
19832168 if [ "\$" = "schedule" ]; then
19842169 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
19862171 echo "💡 **Tip**: Use 'Actions' tab to manually trigger with custom settings" >> \$GITHUB_STEP_SUMMARY
19872172 else
19882173 echo "🖱️ **Manual Trigger**: This was triggered manually from the Actions tab" >> \$GITHUB_STEP_SUMMARY
@@ -2002,7 +2187,7 @@ ${generateComposerSetupSteps()}
20022187`
20032188}
20042189
2005export async function generateCoreWorkflows(_preset: WorkflowPreset, _repoInfo: RepositoryInfo, hasCustomToken: boolean, logger: Logger): Promise<void> {
2190export async function generateCoreWorkflows(preset: WorkflowPreset, _repoInfo: RepositoryInfo, hasCustomToken: boolean, logger: Logger): Promise<void> {
20062191 // GitLab and Bitbucket get their own pipeline format. Writing GitHub Actions
20072192 // workflows into a GitLab repository would produce files that never run,
20082193 // which reads as a successful setup until the first schedule does nothing.
@@ -2023,9 +2208,12 @@ export async function generateCoreWorkflows(_preset: WorkflowPreset, _repoInfo:
20232208 }
20242209
20252210 // Generate unified workflow that combines all three previous workflows
2026 const unifiedWorkflow = generateUnifiedWorkflow(hasCustomToken)
2211 const unifiedWorkflow = generateUnifiedWorkflow(hasCustomToken, preset)
20272212 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(', ')}`)
20292217
20302218 // Generate the security-audit workflow alongside. Lives as its own
20312219 // file so it runs (and triggers on workflow path filters) independently
test/cli-non-interactive-integration.test.tsmodified+4-3
Changes to test/cli-non-interactive-integration.test.ts
@@ -123,7 +123,8 @@ describe('CLI Non-Interactive Integration Tests', () => {
123123 // Verify the unified workflow has the correct content
124124 const unifiedContent = await fs.readFile('.github/workflows/buddy.yml', 'utf-8')
125125 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\'')
127128 expect(unifiedContent).toContain('default: true') // dry_run default
128129 })
129130
@@ -186,7 +187,7 @@ describe('CLI Non-Interactive Integration Tests', () => {
186187 // Test all the required sections from the specification
187188 const requiredSections = [
188189 'name: Buddy',
189 'cron: \'0 */2 * * *\'',
190 'cron: \'0 9 * * 1,3,5\'',
190191 'workflow_dispatch:',
191192 'strategy:',
192193 'dry_run:',
@@ -251,7 +252,7 @@ describe('CLI Non-Interactive Integration Tests', () => {
251252
252253 const requiredSections = [
253254 'name: Buddy',
254 'cron: \'15 */2 * * *\'',
255 'cron: \'0 9 * * 1,3,5\'',
255256 'workflow_dispatch:',
256257 'bunx @buddysh/buddy dashboard',
257258 'dashboard-update:', // job name
test/cli-setup-extended.test.tsmodified+4-4
Changes to test/cli-setup-extended.test.ts
@@ -9,16 +9,16 @@ describe('CLI Setup - Extended Tests', () => {
99 const workflowWithCustomToken = generateUnifiedWorkflow(true)
1010 expect(workflowWithCustomToken).toContain('name: Buddy')
1111 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\'')
1415 expect(workflowWithCustomToken).toContain('BUDDY_TOKEN')
1516
1617 // Test with default token
1718 const workflowWithDefaultToken = generateUnifiedWorkflow(false)
1819 expect(workflowWithDefaultToken).toContain('name: Buddy')
1920 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\'')
2222 // eslint-disable-next-line no-template-curly-in-string
2323 expect(workflowWithDefaultToken).toContain('$')
2424 // Should not use BUDDY_TOKEN in the actual token environment variable
test/cli-setup-non-interactive.test.tsmodified+2-2
Changes to test/cli-setup-non-interactive.test.ts
@@ -43,13 +43,13 @@ describe('CLI Setup - Non-Interactive Mode', () => {
4343 })
4444
4545 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 () => {
4747 const { generateUnifiedWorkflow } = await import('../src/setup')
4848
4949 const workflow = generateUnifiedWorkflow(false)
5050
5151 expect(workflow).toContain('name: Buddy')
52 expect(workflow).toContain('cron: \'0 */2 * * *\'')
52 expect(workflow).toContain('cron: \'0 9 * * 1,3,5\'')
5353 expect(workflow).toContain('default: true') // dry_run default
5454 expect(workflow).toContain('dependency-update:') // job name
5555 expect(workflow).toContain('fetch-depth: 0')
test/cli-setup-simple.test.tsmodified+4-4
Changes to test/cli-setup-simple.test.ts
@@ -7,7 +7,7 @@ describe('CLI Setup - Enhanced Functions', () => {
77 const workflow = generateUnifiedWorkflow(true)
88
99 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
1111 // GITHUB_TOKEN is the built-in token; BUDDY_TOKEN is passed separately for workflow file permissions
1212 // eslint-disable-next-line no-template-curly-in-string
1313 expect(workflow).toContain('GITHUB_TOKEN: $')
@@ -38,8 +38,8 @@ describe('CLI Setup - Enhanced Functions', () => {
3838 expect(workflow).toContain('dependency-update:')
3939 expect(workflow).toContain('dashboard-update:')
4040 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
4343 expect(workflow).toContain('bunx @buddysh/buddy update-check')
4444 expect(workflow).toContain('bunx @buddysh/buddy update')
4545 expect(workflow).toContain('bunx @buddysh/buddy dashboard')
@@ -70,7 +70,7 @@ describe('CLI Setup - Enhanced Functions', () => {
7070 const workflow = generateUnifiedWorkflow(false)
7171
7272 expect(workflow).toContain('name: Buddy')
73 expect(workflow).toContain('cron: \'0 */2 * * *\'')
73 expect(workflow).toContain('cron: \'0 9 * * 1,3,5\'')
7474 expect(workflow).toContain('default: false') // dry_run default
7575 expect(workflow).toContain('dependency-update:') // job name
7676 expect(workflow).toContain('determine-jobs:') // job coordination
test/workflow-presets.test.tsadded+223-0
Changes to test/workflow-presets.test.ts
@@ -0,0 +1,223 @@
1import type { BuddyConfig } from '../src/types'
2import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
3import { promises as fs } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { validateConfig } from '../src/config-validation'
7import { generateConfigFile, generateUnifiedWorkflow, getWorkflowPreset, schedulePlan } from '../src/setup'
8
9interface Workflow {
10 on: { schedule?: Array<{ cron: string }> }
11 jobs: Record<string, { if?: string, needs?: string[], outputs?: Record<string, string> }>
12}
13
14function parse(useCase: string): Workflow {
15 return Bun.YAML.parse(generateUnifiedWorkflow(false, getWorkflowPreset(useCase))) as Workflow
16}
17
18function 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. */
23function 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 */
34describe('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 */
147describe('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})