also looking at this
feat(setup): make the presets deliver their descriptions, and delete the mode nothing could reach
#1440
8 files
+99
-50
| @@ -12,11 +12,10 @@ A **mode** is a playbook plus the capability tiers it may draw tools from. The t | ||
| 12 | 12 | |---|---|---| |
| 13 | 13 | | `plan` | read | No | |
| 14 | 14 | | `review` | read, comment | No | |
| 15 | | `restricted` | read | No | | |
| 16 | 15 | | `fix-ci` | read, write, shell, git, comment | Yes, on the working branch | |
| 17 | 16 | | `implement` | read, write, shell, git, comment | Yes, on the working branch | |
| 18 | 17 | |
| 19 | `restricted` is what a run triggered by someone **without write access** gets. Public repositories accept input from anyone, so the actor is gated the same way the task is. | |
| 18 | A run triggered by someone **without write access** never reaches a mode at all: public repositories accept input from anyone, so `checkPermission` refuses non-collaborators every agent-running command before one is chosen. | |
| 20 | 19 | |
| 21 | 20 | An unknown mode name raises an error rather than falling back to a default — silently landing in a permissive mode on a typo would be the worst possible failure. |
| 22 | 21 | |
| @@ -246,7 +246,7 @@ Choose from carefully crafted presets: | ||
| 246 | 246 | | **Development/Testing** | Manual triggers + frequent checks for testing | |
| 247 | 247 | | **Custom Configuration** | Create your own schedule | |
| 248 | 248 | |
| 249 | The preset names the intent you picked; the workflow it writes is the same either way. See [Preset Details](#preset-details) for what that means once setup is done. | |
| 249 | Each preset writes its own schedule into the workflow — the cadences and per-slot update strategies are live data, not labels. See [Preset Details](#preset-details) for what each generates. | |
| 250 | 250 | |
| 251 | 251 | ### Step 9: Configuration File Generation |
| 252 | 252 | |
| @@ -82,30 +82,12 @@ Name the files that would change and why, the order of the work, the risks, | ||
| 82 | 82 | and how the result would be verified.`, |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | /** | |
| 86 | * The tier every non-collaborator gets. | |
| 87 | * | |
| 88 | * Public repositories accept input from anyone, so a run triggered by someone | |
| 89 | * without write access is capped at reading — the same reasoning as | |
| 90 | * review-mode's tier list, applied to the actor rather than the task. | |
| 91 | */ | |
| 92 | export const restrictedMode: AgentMode = { | |
| 93 | name: 'restricted', | |
| 94 | tiers: ['read'], | |
| 95 | maxToolCalls: 20, | |
| 96 | playbook: `${SHARED_PREAMBLE} | |
| 97 | ||
| 98 | This run was triggered by someone without write access to the repository. You | |
| 99 | can read and answer, but cannot modify anything or post on their behalf.`, | |
| 100 | } | |
| 101 | ||
| 102 | 85 | /** Every built-in mode, by name. */ |
| 103 | 86 | export const AGENT_MODES: Record<string, AgentMode> = { |
| 104 | 87 | 'review': reviewMode, |
| 105 | 88 | 'implement': implementMode, |
| 106 | 89 | 'fix-ci': fixCiMode, |
| 107 | 90 | 'plan': planMode, |
| 108 | 'restricted': restrictedMode, | |
| 109 | 91 | } |
| 110 | 92 | |
| 111 | 93 | /** |
| @@ -1390,6 +1390,8 @@ export type ScheduledJob = 'update' | 'dashboard' | 'check' | 'report' | ||
| 1390 | 1390 | export interface ScheduleEntry { |
| 1391 | 1391 | cron: string |
| 1392 | 1392 | jobs: ScheduledJob[] |
| 1393 | /** Strategy an `update` tick runs with; absent falls back to the preset default */ | |
| 1394 | updateStrategy?: string | |
| 1393 | 1395 | } |
| 1394 | 1396 | |
| 1395 | 1397 | /** |
| @@ -1421,8 +1423,9 @@ export interface ScheduleEntry { | ||
| 1421 | 1423 | */ |
| 1422 | 1424 | export function schedulePlan(preset: WorkflowPreset): ScheduleEntry[] { |
| 1423 | 1425 | const byCron = new Map<string, ScheduledJob[]>() |
| 1426 | const strategyByCron = new Map<string, string>() | |
| 1424 | 1427 | |
| 1425 | const add = (cron: string | undefined, job: ScheduledJob): void => { | |
| 1428 | const add = (cron: string | undefined, job: ScheduledJob, strategy?: string): void => { | |
| 1426 | 1429 | if (!cron || cron === 'manual') |
| 1427 | 1430 | return |
| 1428 | 1431 | |
| @@ -1431,14 +1434,26 @@ export function schedulePlan(preset: WorkflowPreset): ScheduleEntry[] { | ||
| 1431 | 1434 | jobs.push(job) |
| 1432 | 1435 | |
| 1433 | 1436 | byCron.set(cron, jobs) |
| 1437 | ||
| 1438 | if (job === 'update' && strategy && !strategyByCron.has(cron)) | |
| 1439 | strategyByCron.set(cron, strategy) | |
| 1434 | 1440 | } |
| 1435 | 1441 | |
| 1436 | add(preset.schedules.updates, 'update') | |
| 1442 | add(preset.schedules.updates, 'update', preset.strategy) | |
| 1443 | // The preset's custom cadences are real schedule slots, each with its own | |
| 1444 | // strategy — this is what lets a preset deliver "daily patch, weekly minor, | |
| 1445 | // monthly major" instead of one cron proposing everything. | |
| 1446 | for (const custom of preset.custom ?? []) | |
| 1447 | add(custom.schedule, 'update', custom.strategy) | |
| 1437 | 1448 | add(preset.schedules.dashboard, 'dashboard') |
| 1438 | 1449 | add(CLEANUP_SCHEDULE, 'check') |
| 1439 | 1450 | add(REPORT_SCHEDULE, 'report') |
| 1440 | 1451 | |
| 1441 | return [...byCron].map(([cron, jobs]) => ({ cron, jobs })) | |
| 1452 | return [...byCron].map(([cron, jobs]) => ({ | |
| 1453 | cron, | |
| 1454 | jobs, | |
| 1455 | ...(strategyByCron.has(cron) ? { updateStrategy: strategyByCron.get(cron) } : {}), | |
| 1456 | })) | |
| 1442 | 1457 | } |
| 1443 | 1458 | |
| 1444 | 1459 | /** What each job is for, so the generated YAML explains its own schedule. */ |
| @@ -1469,9 +1484,10 @@ function renderScheduleDispatch(plan: ScheduleEntry[]): string { | ||
| 1469 | 1484 | if (plan.length === 0) |
| 1470 | 1485 | return ' echo "ℹ️ No scheduled jobs are configured"' |
| 1471 | 1486 | |
| 1472 | const branches = plan.map(({ cron, jobs }) => [ | |
| 1487 | const branches = plan.map(({ cron, jobs, updateStrategy }) => [ | |
| 1473 | 1488 | ` "${cron}")`, |
| 1474 | 1489 | ...jobs.map(job => ` echo "run_${job}=true" >> $GITHUB_OUTPUT`), |
| 1490 | ...(updateStrategy ? [` echo "update_strategy=${updateStrategy}" >> $GITHUB_OUTPUT`] : []), | |
| 1475 | 1491 | ' ;;', |
| 1476 | 1492 | ].join('\n')) |
| 1477 | 1493 | |
| @@ -1620,6 +1636,7 @@ jobs: | ||
| 1620 | 1636 | run_fixci: \${{ steps.determine.outputs.run_fixci }} |
| 1621 | 1637 | run_report: \${{ steps.determine.outputs.run_report }} |
| 1622 | 1638 | run_any: \${{ steps.determine.outputs.run_any }} |
| 1639 | update_strategy: \${{ steps.determine.outputs.update_strategy }} | |
| 1623 | 1640 | steps: |
| 1624 | 1641 | - name: Determine which jobs to run |
| 1625 | 1642 | id: determine |
| @@ -2104,7 +2121,7 @@ ${generateComposerSetupSteps()} | ||
| 2104 | 2121 | - name: Display update configuration |
| 2105 | 2122 | run: | |
| 2106 | 2123 | echo "🧪 **Buddy Update Mode**" |
| 2107 | echo "Strategy: \$" | |
| 2124 | echo "Strategy: \$" | |
| 2108 | 2125 | echo "Dry Run: \$" |
| 2109 | 2126 | echo "Packages: \$" |
| 2110 | 2127 | echo "Verbose: \$" |
| @@ -2115,7 +2132,7 @@ ${generateComposerSetupSteps()} | ||
| 2115 | 2132 | - name: Run Buddy dependency updates |
| 2116 | 2133 | if: \${{ github.event.inputs.dry_run != 'true' }} |
| 2117 | 2134 | run: | |
| 2118 | STRATEGY="\$" | |
| 2135 | STRATEGY="\$" | |
| 2119 | 2136 | PACKAGES="\$" |
| 2120 | 2137 | VERBOSE="\$" |
| 2121 | 2138 | |
| @@ -2154,7 +2171,7 @@ ${generateComposerSetupSteps()} | ||
| 2154 | 2171 | run: | |
| 2155 | 2172 | echo "## 🚀 Dependency Update Summary" >> \$GITHUB_STEP_SUMMARY |
| 2156 | 2173 | echo "" >> \$GITHUB_STEP_SUMMARY |
| 2157 | echo "- **Strategy**: \$" >> \$GITHUB_STEP_SUMMARY | |
| 2174 | echo "- **Strategy**: \$" >> \$GITHUB_STEP_SUMMARY | |
| 2158 | 2175 | echo "- **Triggered by**: \$" >> \$GITHUB_STEP_SUMMARY |
| 2159 | 2176 | echo "- **Dry run**: \$" >> \$GITHUB_STEP_SUMMARY |
| 2160 | 2177 | echo "- **Packages**: \$" >> \$GITHUB_STEP_SUMMARY |
| @@ -2427,11 +2444,16 @@ export function getWorkflowPreset(useCase: string): WorkflowPreset { | ||
| 2427 | 2444 | }, |
| 2428 | 2445 | schedules: { |
| 2429 | 2446 | dashboard: '0 9 * * 1,3,5', |
| 2430 | updates: '0 9 * * 1,3,5', | |
| 2447 | updates: '0 9 * * *', | |
| 2431 | 2448 | }, |
| 2432 | strategy: 'all', | |
| 2449 | // The description is the contract: patches daily on the main slot, | |
| 2450 | // minors weekly and majors monthly on their own slots below. | |
| 2451 | strategy: 'patch', | |
| 2433 | 2452 | autoMerge: false, |
| 2434 | custom: [], | |
| 2453 | custom: [ | |
| 2454 | { name: 'weekly-minor', schedule: '0 9 * * 1', strategy: 'minor', autoMerge: false }, | |
| 2455 | { name: 'monthly-major', schedule: '0 9 1 * *', strategy: 'major', autoMerge: false }, | |
| 2456 | ], | |
| 2435 | 2457 | }, |
| 2436 | 2458 | 'high-frequency': { |
| 2437 | 2459 | name: 'High Frequency Updates', |
| @@ -2439,9 +2461,12 @@ export function getWorkflowPreset(useCase: string): WorkflowPreset { | ||
| 2439 | 2461 | templates: {}, |
| 2440 | 2462 | schedules: { |
| 2441 | 2463 | dashboard: '0 9 * * *', |
| 2442 | updates: '0 */6 * * *', | |
| 2464 | // The four custom slots below are the cadence; a fifth catch-all | |
| 2465 | // here would double-run beside them with a wider strategy than the | |
| 2466 | // preset describes. | |
| 2467 | updates: 'manual', | |
| 2443 | 2468 | }, |
| 2444 | strategy: 'all', | |
| 2469 | strategy: 'patch', | |
| 2445 | 2470 | autoMerge: true, |
| 2446 | 2471 | custom: [ |
| 2447 | 2472 | { name: 'morning-updates', schedule: '0 6 * * *', strategy: 'patch', autoMerge: true, autoMergeStrategy: 'squash' }, |
| @@ -2458,10 +2483,12 @@ export function getWorkflowPreset(useCase: string): WorkflowPreset { | ||
| 2458 | 2483 | dashboard: '0 9 * * *', |
| 2459 | 2484 | updates: '0 */4 * * *', |
| 2460 | 2485 | }, |
| 2461 | strategy: 'all', | |
| 2486 | // "Frequent patch updates": the every-four-hours slot proposes | |
| 2487 | // patches and minors wait for the weekly slot. The old | |
| 2488 | // security-patches slot duplicated the main cadence and is gone. | |
| 2489 | strategy: 'patch', | |
| 2462 | 2490 | autoMerge: true, |
| 2463 | 2491 | custom: [ |
| 2464 | { name: 'security-patches', schedule: '0 */6 * * *', strategy: 'patch', autoMerge: true, autoMergeStrategy: 'squash' }, | |
| 2465 | 2492 | { name: 'weekly-minor', schedule: '0 9 * * 1', strategy: 'minor', autoMerge: false }, |
| 2466 | 2493 | ], |
| 2467 | 2494 | }, |
| @@ -2476,9 +2503,11 @@ export function getWorkflowPreset(useCase: string): WorkflowPreset { | ||
| 2476 | 2503 | dashboard: '0 9 * * 1', |
| 2477 | 2504 | updates: '0 9 * * 1', |
| 2478 | 2505 | }, |
| 2479 | strategy: 'all', | |
| 2506 | strategy: 'patch', | |
| 2480 | 2507 | autoMerge: false, |
| 2481 | custom: [], | |
| 2508 | custom: [ | |
| 2509 | { name: 'monthly-updates', schedule: '0 9 1 * *', strategy: 'all', autoMerge: false }, | |
| 2510 | ], | |
| 2482 | 2511 | }, |
| 2483 | 2512 | 'docker': { |
| 2484 | 2513 | name: 'Docker Project', |
| @@ -157,8 +157,12 @@ describe('mode tool permissions', () => { | ||
| 157 | 157 | expect(output.content).toContain('No such tool') |
| 158 | 158 | }) |
| 159 | 159 | |
| 160 | it('failure case - the restricted tier grants reads only', () => { | |
| 161 | const belt = new Toolbelt(getAgentMode('restricted')) | |
| 160 | it('failure case - the plan mode grants reads only', () => { | |
| 161 | // The read tier alone: no comment tool, nothing that writes. (This was | |
| 162 | // `restricted` until that mode was deleted — no code path could select | |
| 163 | // it, since checkPermission refuses non-collaborators before a mode is | |
| 164 | // ever chosen.) | |
| 165 | const belt = new Toolbelt(getAgentMode('plan')) | |
| 162 | 166 | |
| 163 | 167 | expect(belt.names()).toEqual(['read_file', 'list_dir']) |
| 164 | 168 | }) |
| @@ -73,7 +73,7 @@ describe('CLI Setup - Extended Tests', () => { | ||
| 73 | 73 | // Security preset |
| 74 | 74 | const security = getWorkflowPreset('security') |
| 75 | 75 | expect(security.name).toBe('Security Focused') |
| 76 | expect(security.custom?.some(c => c.name === 'security-patches')).toBe(true) | |
| 76 | expect(security.custom?.some(c => c.name === 'weekly-minor')).toBe(true) | |
| 77 | 77 | |
| 78 | 78 | // Minimal preset |
| 79 | 79 | const minimal = getWorkflowPreset('minimal') |
| @@ -35,7 +35,8 @@ describe('workflow presets', () => { | ||
| 35 | 35 | describe('the schedule plan', () => { |
| 36 | 36 | it('success case - a preset\'s own cadence reaches the workflow', () => { |
| 37 | 37 | expect(crons('security')).toContain('0 */4 * * *') |
| 38 | expect(crons('high-frequency')).toContain('0 */6 * * *') | |
| 38 | expect(crons('high-frequency')).toContain('0 6 * * *') | |
| 39 | expect(crons('standard')).toContain('0 9 1 * *') | |
| 39 | 40 | expect(crons('testing')).toContain('*/15 * * * *') |
| 40 | 41 | }) |
| 41 | 42 | |
| @@ -45,13 +46,13 @@ describe('workflow presets', () => { | ||
| 45 | 46 | }) |
| 46 | 47 | |
| 47 | 48 | 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 | // Monorepo puts updates and the dashboard on one expression. An if/elif | |
| 49 | 50 | // 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') | |
| 51 | const plan = schedulePlan(getWorkflowPreset('monorepo')) | |
| 52 | const shared = plan.find(entry => entry.cron === '0 9 * * *') | |
| 52 | 53 | |
| 53 | 54 | expect(shared?.jobs).toEqual(['update', 'dashboard']) |
| 54 | expect(crons('standard').filter(cron => cron === '0 9 * * 1,3,5')).toHaveLength(1) | |
| 55 | expect(crons('monorepo').filter(cron => cron === '0 9 * * *')).toHaveLength(1) | |
| 55 | 56 | }) |
| 56 | 57 | |
| 57 | 58 | it('success case - a cron shared by three jobs starts all three', () => { |
| @@ -82,6 +83,40 @@ describe('workflow presets', () => { | ||
| 82 | 83 | }) |
| 83 | 84 | }) |
| 84 | 85 | |
| 86 | describe('per-slot update strategies', () => { | |
| 87 | it('success case - a custom cadence is a real schedule slot with its own strategy', () => { | |
| 88 | const plan = schedulePlan(getWorkflowPreset('standard')) | |
| 89 | ||
| 90 | expect(plan.find(entry => entry.cron === '0 9 * * *')?.updateStrategy).toBe('patch') | |
| 91 | expect(plan.find(entry => entry.cron === '0 9 * * 1')?.updateStrategy).toBe('minor') | |
| 92 | expect(plan.find(entry => entry.cron === '0 9 1 * *')?.updateStrategy).toBe('major') | |
| 93 | }) | |
| 94 | ||
| 95 | it('success case - the tick tells the update job which strategy fired', () => { | |
| 96 | const yaml = generateUnifiedWorkflow(false, getWorkflowPreset('standard')) | |
| 97 | ||
| 98 | expect(yaml).toContain('"0 9 1 * *")') | |
| 99 | expect(yaml).toContain('echo "update_strategy=major" >> $GITHUB_OUTPUT') | |
| 100 | expect(yaml).toContain('needs.determine-jobs.outputs.update_strategy') | |
| 101 | }) | |
| 102 | ||
| 103 | it('success case - high-frequency is its four slots, not a fifth catch-all', () => { | |
| 104 | const plan = schedulePlan(getWorkflowPreset('high-frequency')) | |
| 105 | const updates = plan.filter(entry => entry.jobs.includes('update')) | |
| 106 | ||
| 107 | expect(updates.map(entry => entry.cron).sort()).toEqual(['0 0 * * *', '0 12 * * *', '0 18 * * *', '0 6 * * *']) | |
| 108 | // Midnight is the minor slot the preset describes as manual-review. | |
| 109 | expect(plan.find(entry => entry.cron === '0 0 * * *')?.updateStrategy).toBe('minor') | |
| 110 | }) | |
| 111 | ||
| 112 | it('success case - security stops duplicating its own patch cadence', () => { | |
| 113 | const plan = schedulePlan(getWorkflowPreset('security')) | |
| 114 | ||
| 115 | expect(plan.some(entry => entry.cron === '0 */6 * * *')).toBe(false) | |
| 116 | expect(plan.find(entry => entry.cron === '0 */4 * * *')?.updateStrategy).toBe('patch') | |
| 117 | }) | |
| 118 | }) | |
| 119 | ||
| 85 | 120 | describe('triggers and their matcher agree', () => { |
| 86 | 121 | it('success case - every declared cron has a branch that runs something', () => { |
| 87 | 122 | // A cron declared in `schedule:` with no matching branch starts a |
| @@ -129,7 +164,7 @@ describe('workflow presets', () => { | ||
| 129 | 164 | it('success case - scheduled runs use the preset strategy', () => { |
| 130 | 165 | const yaml = generateUnifiedWorkflow(false, getWorkflowPreset('testing')) |
| 131 | 166 | |
| 132 | expect(yaml).toContain(`github.event.inputs.strategy || 'patch'`) | |
| 167 | expect(yaml).toContain(`github.event.inputs.strategy || needs.determine-jobs.outputs.update_strategy || 'patch'`) | |
| 133 | 168 | }) |
| 134 | 169 | |
| 135 | 170 | it('success case - the manual trigger defaults to the same strategy', () => { |
| @@ -181,7 +216,8 @@ describe('the generated config file', () => { | ||
| 181 | 216 | |
| 182 | 217 | it('success case - the update strategy comes from the preset', async () => { |
| 183 | 218 | expect((await generated('testing')).packages?.strategy).toBe('patch') |
| 184 | expect((await generated('standard')).packages?.strategy).toBe('all') | |
| 219 | // Standard's main slot is the daily-patch cadence its description names. | |
| 220 | expect((await generated('standard')).packages?.strategy).toBe('patch') | |
| 185 | 221 | }) |
| 186 | 222 | |
| 187 | 223 | it('success case - the preset\'s custom workflows reach the config', async () => { |
| @@ -189,8 +225,8 @@ describe('the generated config file', () => { | ||
| 189 | 225 | // and a config that drops them means the workflows are never written. |
| 190 | 226 | const custom = (await generated('security')).workflows?.custom ?? [] |
| 191 | 227 | |
| 192 | expect(custom.map(workflow => workflow.name)).toEqual(['security-patches', 'weekly-minor']) | |
| 193 | expect(custom[0].schedule).toBe('0 */6 * * *') | |
| 228 | expect(custom.map(workflow => workflow.name)).toEqual(['weekly-minor']) | |
| 229 | expect(custom[0].schedule).toBe('0 9 * * 1') | |
| 194 | 230 | }) |
| 195 | 231 | |
| 196 | 232 | it('success case - templates come from the preset', async () => { |