also looking at this
feat(setup): make the preset you pick the preset you get
#1411
10 files
+507
-61
| @@ -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 |