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
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