also looking at this
feat(touch): post the checkboxes the finishing-touch flow waits on
#1424
7 files
+149
-5
| @@ -2749,13 +2749,16 @@ cli | ||
| 2749 | 2749 | cli |
| 2750 | 2750 | .command('touch <pr-number>', 'Run a finishing touch ticked on a pull request, as a stacked PR') |
| 2751 | 2751 | .option('--name <touch>', 'Run a specific touch rather than reading the checkboxes') |
| 2752 | .option('--offer', 'Post the checkboxes on the pull request rather than running one') | |
| 2752 | 2753 | .option('--test-command <command>', 'Command that verifies the change') |
| 2753 | 2754 | .option('--dry-run', 'Report what would run without acting') |
| 2754 | 2755 | .option('--verbose, -v', 'Enable verbose logging') |
| 2755 | 2756 | .example('buddy touch 42') |
| 2757 | .example('buddy touch 42 --offer') | |
| 2756 | 2758 | .example('buddy touch 42 --name unit-tests') |
| 2757 | 2759 | .action(async (prNumber: string, options: CLIOptions & { |
| 2758 | 2760 | name?: string |
| 2761 | offer?: boolean | |
| 2759 | 2762 | testCommand?: string |
| 2760 | 2763 | dryRun?: boolean |
| 2761 | 2764 | }) => { |
| @@ -2773,7 +2776,30 @@ cli | ||
| 2773 | 2776 | process.exit(1) |
| 2774 | 2777 | } |
| 2775 | 2778 | |
| 2776 | const { FINISHING_TOUCHES, getFinishingTouch, parseTouchSelections } = await import('../src/agent/tasks') | |
| 2779 | const { FINISHING_TOUCHES, getFinishingTouch, hasTouchOffer, parseTouchSelections, withTouchOffer } | |
| 2780 | = await import('../src/agent/tasks') | |
| 2781 | ||
| 2782 | // Posting the offer is what makes the checkboxes exist. `renderTouchOffer` | |
| 2783 | // had no production caller, so `buddy touch` read ticks from a body that | |
| 2784 | // nothing ever wrote them into — the documented "tick a box" flow could | |
| 2785 | // not start. | |
| 2786 | if (options.offer) { | |
| 2787 | if (hasTouchOffer(pullRequest.body)) { | |
| 2788 | logger.info(`ℹ️ PR #${pullRequest.number} already carries the finishing-touch offer`) | |
| 2789 | return | |
| 2790 | } | |
| 2791 | ||
| 2792 | const body = withTouchOffer(pullRequest.body) | |
| 2793 | ||
| 2794 | if (options.dryRun) { | |
| 2795 | logger.info(`🔍 [DRY RUN] Would offer finishing touches on PR #${pullRequest.number}`) | |
| 2796 | return | |
| 2797 | } | |
| 2798 | ||
| 2799 | await provider.updatePullRequest(pullRequest.number, { body }) | |
| 2800 | logger.success(`✅ Offered finishing touches on PR #${pullRequest.number}`) | |
| 2801 | return | |
| 2802 | } | |
| 2777 | 2803 | |
| 2778 | 2804 | // The checkboxes are the request. A touch named on the command line is |
| 2779 | 2805 | // the manual path, for a maintainer who did not want to tick a box. |
| @@ -22,7 +22,7 @@ hero: | ||
| 22 | 22 | - file: "on the pull request" |
| 23 | 23 | lang: "ascii" |
| 24 | 24 | content: | |
| 25 | buddy commented on #128 | |
| 25 | buddy updated the description of #128 | |
| 26 | 26 | |
| 27 | 27 | Finishing touches — tick one and I will |
| 28 | 28 | open it as a pull request against this |
| @@ -99,9 +99,14 @@ Touches whose output is a *suggestion* rather than a commit — `simplify` and ` | ||
| 99 | 99 | |
| 100 | 100 | ## Running one |
| 101 | 101 | |
| 102 | From the thread, tick the checkbox Buddy posts. From the CLI: | |
| 102 | Tick the checkbox Buddy posts. It appends them to the pull request's | |
| 103 | description when the request is opened or marked ready — the description | |
| 104 | rather than a comment, because that is what fires the `pull_request: edited` | |
| 105 | event a tick has to be noticed by. Offering is idempotent, so a draft later | |
| 106 | marked ready does not collect a second set of boxes. | |
| 103 | 107 | |
| 104 | 108 | ```bash |
| 109 | buddy touch 128 --offer # post the checkboxes | |
| 105 | 110 | buddy touch 128 # read the ticked boxes and run them |
| 106 | 111 | buddy touch 128 --name unit-tests # run one directly |
| 107 | 112 | buddy touch 128 --test-command "bun test" |
| @@ -144,6 +144,9 @@ export function getFinishingTouch(name: string): FinishingTouch { | ||
| 144 | 144 | return touch |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | /** Opens the offer block, so it can be found and replaced rather than repeated. */ | |
| 148 | export const TOUCH_OFFER_MARKER = '<!-- buddy:touches -->' | |
| 149 | ||
| 147 | 150 | /** |
| 148 | 151 | * Render the offer of available touches as checkbox markers. |
| 149 | 152 | * |
| @@ -161,7 +164,39 @@ export function renderTouchOffer(names: string[] = Object.keys(FINISHING_TOUCHES | ||
| 161 | 164 | if (lines.length === 0) |
| 162 | 165 | return '' |
| 163 | 166 | |
| 164 | return `<details><summary>Finishing touches</summary>\n\nTick one to have it run:\n\n${lines.join('\n')}\n\n</details>` | |
| 167 | return `${TOUCH_OFFER_MARKER}\n<details><summary>Finishing touches</summary>\n\nTick one to have it run:\n\n${lines.join('\n')}\n\n</details>` | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Whether a pull request body already carries the offer. | |
| 172 | * | |
| 173 | * Checked before appending, because the job that posts it runs on more than | |
| 174 | * one event and a body that accumulated the block twice would show a second | |
| 175 | * set of boxes that tick independently of the first. | |
| 176 | * | |
| 177 | * @param body - Pull request body | |
| 178 | */ | |
| 179 | export function hasTouchOffer(body: string | null | undefined): boolean { | |
| 180 | return Boolean(body?.includes(TOUCH_OFFER_MARKER)) | |
| 181 | } | |
| 182 | ||
| 183 | /** | |
| 184 | * Append the offer to a pull request body, once. | |
| 185 | * | |
| 186 | * @param body - Current body | |
| 187 | * @param names - Touches to offer | |
| 188 | * @returns The body with the offer, or unchanged when it already has one | |
| 189 | */ | |
| 190 | export function withTouchOffer(body: string | null | undefined, names?: string[]): string { | |
| 191 | const current = body ?? '' | |
| 192 | if (hasTouchOffer(current)) | |
| 193 | return current | |
| 194 | ||
| 195 | const offer = renderTouchOffer(names) | |
| 196 | if (!offer) | |
| 197 | return current | |
| 198 | ||
| 199 | return `${current.trimEnd()}\n\n${offer}\n` | |
| 165 | 200 | } |
| 166 | 201 | |
| 167 | 202 | /** Parse which touches a maintainer ticked. */ |
| @@ -1823,7 +1823,10 @@ ${generateComposerSetupSteps()} | ||
| 1823 | 1823 | runs-on: ubuntu-latest |
| 1824 | 1824 | timeout-minutes: 30 |
| 1825 | 1825 | needs: [determine-jobs, setup] |
| 1826 | if: \${{ github.event_name == 'pull_request' && github.event.action == 'edited' }} | |
| 1826 | # Two moments, not one: the offer has to be posted before there is a box to | |
| 1827 | # tick, and \`edited\` is when a tick arrives. Offering only on \`edited\` | |
| 1828 | # would mean the checkboxes never appeared at all. | |
| 1829 | if: \${{ github.event_name == 'pull_request' && (github.event.action == 'edited' || github.event.action == 'opened' || github.event.action == 'ready_for_review') }} | |
| 1827 | 1830 | |
| 1828 | 1831 | steps: |
| 1829 | 1832 | - name: Checkout repository |
| @@ -1838,7 +1841,15 @@ ${generateComposerSetupSteps()} | ||
| 1838 | 1841 | - name: Install dependencies |
| 1839 | 1842 | run: bun install |
| 1840 | 1843 | |
| 1844 | # Idempotent: a body that already carries the offer is left alone, so a | |
| 1845 | # pull request opened as a draft and later marked ready is not offered | |
| 1846 | # a second set of boxes that tick independently of the first. | |
| 1847 | - name: Offer finishing touches | |
| 1848 | if: \${{ github.event.action != 'edited' }} | |
| 1849 | run: bunx @buddysh/buddy touch \${{ github.event.pull_request.number }} --offer --verbose | |
| 1850 | ||
| 1841 | 1851 | - name: Run finishing touches |
| 1852 | if: \${{ github.event.action == 'edited' }} | |
| 1842 | 1853 | run: bunx @buddysh/buddy touch \${{ github.event.pull_request.number }} --verbose |
| 1843 | 1854 | |
| 1844 | 1855 | # Post-merge actions. Guarded on the merged flag, because the closed action |
| @@ -2,8 +2,10 @@ import { describe, expect, it } from 'bun:test' | ||
| 2 | 2 | import { |
| 3 | 3 | FINISHING_TOUCHES, |
| 4 | 4 | getFinishingTouch, |
| 5 | hasTouchOffer, | |
| 5 | 6 | parseTouchSelections, |
| 6 | 7 | renderTouchOffer, |
| 8 | withTouchOffer, | |
| 7 | 9 | } from '../src/agent/tasks' |
| 8 | 10 | import { |
| 9 | 11 | constrainLabels, |
| @@ -167,3 +169,57 @@ describe('finishing touches', () => { | ||
| 167 | 169 | expect(parseTouchSelections(renderTouchOffer())).toEqual([]) |
| 168 | 170 | }) |
| 169 | 171 | }) |
| 172 | ||
| 173 | /** | |
| 174 | * `renderTouchOffer` had no production caller. `buddy touch` read ticked boxes | |
| 175 | * out of `pullRequest.body`, and nothing ever wrote any there — so the flow the | |
| 176 | * feature page is built around ("tick the checkbox Buddy posts") could not | |
| 177 | * start. `--offer` is what posts them. | |
| 178 | */ | |
| 179 | describe('offering finishing touches', () => { | |
| 180 | it('success case - appends the offer to a body', () => { | |
| 181 | const body = withTouchOffer('The original description.') | |
| 182 | ||
| 183 | expect(body).toContain('The original description.') | |
| 184 | expect(body).toContain('buddy:touch=unit-tests') | |
| 185 | }) | |
| 186 | ||
| 187 | it('success case - the offer it writes is the one the parser reads', () => { | |
| 188 | // The two halves have to agree on the marker, or a tick is invisible. | |
| 189 | const ticked = withTouchOffer('desc').replace('- [ ] <!-- buddy:touch=unit-tests', '- [x] <!-- buddy:touch=unit-tests') | |
| 190 | ||
| 191 | expect(parseTouchSelections(ticked)).toEqual(['unit-tests']) | |
| 192 | }) | |
| 193 | ||
| 194 | it('failure case - a body that already has the offer is left alone', () => { | |
| 195 | // The job posts on `opened` and on `ready_for_review`, so a draft marked | |
| 196 | // ready would otherwise get a second set of boxes that tick independently | |
| 197 | // of the first. | |
| 198 | const once = withTouchOffer('desc') | |
| 199 | ||
| 200 | expect(withTouchOffer(once)).toBe(once) | |
| 201 | expect(once.match(/buddy:touches/g)).toHaveLength(1) | |
| 202 | }) | |
| 203 | ||
| 204 | it('success case - recognises a body that carries the offer', () => { | |
| 205 | expect(hasTouchOffer(withTouchOffer('desc'))).toBe(true) | |
| 206 | expect(hasTouchOffer('desc')).toBe(false) | |
| 207 | expect(hasTouchOffer(null)).toBe(false) | |
| 208 | }) | |
| 209 | ||
| 210 | it('edge case - an empty body still gets an offer', () => { | |
| 211 | expect(hasTouchOffer(withTouchOffer(null))).toBe(true) | |
| 212 | }) | |
| 213 | ||
| 214 | it('edge case - offering nothing changes nothing', () => { | |
| 215 | // `renderTouchOffer([])` is empty, and an empty offer must not append a | |
| 216 | // marker that `parseTouchSelections` would then find no boxes under. | |
| 217 | expect(withTouchOffer('desc', [])).toBe('desc') | |
| 218 | }) | |
| 219 | ||
| 220 | it('success case - ticks already in the body survive being offered again', () => { | |
| 221 | const ticked = withTouchOffer('desc').replace('- [ ] <!-- buddy:touch=docstrings', '- [x] <!-- buddy:touch=docstrings') | |
| 222 | ||
| 223 | expect(parseTouchSelections(withTouchOffer(ticked))).toEqual(['docstrings']) | |
| 224 | }) | |
| 225 | }) | |
| @@ -108,6 +108,14 @@ describe('generated workflow reachability', () => { | ||
| 108 | 108 | outputs: { run_check: 'true' }, |
| 109 | 109 | expect: ['check', 'touch', 'gate'], |
| 110 | 110 | }, |
| 111 | { | |
| 112 | // The offer has to be posted before there is a box to tick, so `touch` | |
| 113 | // runs on the opening event too. | |
| 114 | event: 'pull request opened (finishing-touch offer)', | |
| 115 | github: { event_name: 'pull_request', event: { action: 'opened', pull_request: { draft: false, merged: false } } }, | |
| 116 | outputs: { run_review: 'true' }, | |
| 117 | expect: ['touch'], | |
| 118 | }, | |
| 111 | 119 | { |
| 112 | 120 | event: 'pull request merged', |
| 113 | 121 | github: { event_name: 'pull_request', event: { action: 'closed', pull_request: { draft: false, merged: true } } }, |