ReviewOS

also looking at this

stacks/buddy

feat(touch): post the checkboxes the finishing-touch flow waits on

#1424
Merged glennmichael123 wants to merge fix/touch-offer into main
7 files +149 -5
bin/cli.tsmodified+27-1
Changes to bin/cli.ts
@@ -2749,13 +2749,16 @@ cli
27492749cli
27502750 .command('touch <pr-number>', 'Run a finishing touch ticked on a pull request, as a stacked PR')
27512751 .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')
27522753 .option('--test-command <command>', 'Command that verifies the change')
27532754 .option('--dry-run', 'Report what would run without acting')
27542755 .option('--verbose, -v', 'Enable verbose logging')
27552756 .example('buddy touch 42')
2757 .example('buddy touch 42 --offer')
27562758 .example('buddy touch 42 --name unit-tests')
27572759 .action(async (prNumber: string, options: CLIOptions & {
27582760 name?: string
2761 offer?: boolean
27592762 testCommand?: string
27602763 dryRun?: boolean
27612764 }) => {
@@ -2773,7 +2776,30 @@ cli
27732776 process.exit(1)
27742777 }
27752778
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 }
27772803
27782804 // The checkboxes are the request. A touch named on the command line is
27792805 // the manual path, for a maintainer who did not want to tick a box.
docs/features/finishing-touches.mdmodified+7-2
Changes to docs/features/finishing-touches.md
@@ -22,7 +22,7 @@ hero:
2222 - file: "on the pull request"
2323 lang: "ascii"
2424 content: |
25 buddy commented on #128
25 buddy updated the description of #128
2626
2727 Finishing touches — tick one and I will
2828 open it as a pull request against this
@@ -99,9 +99,14 @@ Touches whose output is a *suggestion* rather than a commit — `simplify` and `
9999
100100## Running one
101101
102From the thread, tick the checkbox Buddy posts. From the CLI:
102Tick the checkbox Buddy posts. It appends them to the pull request's
103description when the request is opened or marked ready — the description
104rather than a comment, because that is what fires the `pull_request: edited`
105event a tick has to be noticed by. Offering is idempotent, so a draft later
106marked ready does not collect a second set of boxes.
103107
104108```bash
109buddy touch 128 --offer # post the checkboxes
105110buddy touch 128 # read the ticked boxes and run them
106111buddy touch 128 --name unit-tests # run one directly
107112buddy touch 128 --test-command "bun test"
src/agent/index.tsmodified+3-0
Changes to src/agent/index.ts
@@ -18,6 +18,9 @@ export {
1818 parseTouchSelections,
1919 planTouch,
2020 renderTouchOffer,
21 TOUCH_OFFER_MARKER,
22 hasTouchOffer,
23 withTouchOffer,
2124 simplifyTouch,
2225 unitTestsTouch,
2326} from './tasks'
src/agent/tasks.tsmodified+36-1
Changes to src/agent/tasks.ts
@@ -144,6 +144,9 @@ export function getFinishingTouch(name: string): FinishingTouch {
144144 return touch
145145}
146146
147/** Opens the offer block, so it can be found and replaced rather than repeated. */
148export const TOUCH_OFFER_MARKER = '<!-- buddy:touches -->'
149
147150/**
148151 * Render the offer of available touches as checkbox markers.
149152 *
@@ -161,7 +164,39 @@ export function renderTouchOffer(names: string[] = Object.keys(FINISHING_TOUCHES
161164 if (lines.length === 0)
162165 return ''
163166
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 */
179export 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 */
190export 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`
165200}
166201
167202/** Parse which touches a maintainer ticked. */
src/setup.tsmodified+12-1
Changes to src/setup.ts
@@ -1823,7 +1823,10 @@ ${generateComposerSetupSteps()}
18231823 runs-on: ubuntu-latest
18241824 timeout-minutes: 30
18251825 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') }}
18271830
18281831 steps:
18291832 - name: Checkout repository
@@ -1838,7 +1841,15 @@ ${generateComposerSetupSteps()}
18381841 - name: Install dependencies
18391842 run: bun install
18401843
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
18411851 - name: Run finishing touches
1852 if: \${{ github.event.action == 'edited' }}
18421853 run: bunx @buddysh/buddy touch \${{ github.event.pull_request.number }} --verbose
18431854
18441855 # Post-merge actions. Guarded on the merged flag, because the closed action
test/issues-and-touches.test.tsmodified+56-0
Changes to test/issues-and-touches.test.ts
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'bun:test'
22import {
33 FINISHING_TOUCHES,
44 getFinishingTouch,
5 hasTouchOffer,
56 parseTouchSelections,
67 renderTouchOffer,
8 withTouchOffer,
79} from '../src/agent/tasks'
810import {
911 constrainLabels,
@@ -167,3 +169,57 @@ describe('finishing touches', () => {
167169 expect(parseTouchSelections(renderTouchOffer())).toEqual([])
168170 })
169171})
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 */
179describe('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})
test/workflow-reachability.test.tsmodified+8-0
Changes to test/workflow-reachability.test.ts
@@ -108,6 +108,14 @@ describe('generated workflow reachability', () => {
108108 outputs: { run_check: 'true' },
109109 expect: ['check', 'touch', 'gate'],
110110 },
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 },
111119 {
112120 event: 'pull request merged',
113121 github: { event_name: 'pull_request', event: { action: 'closed', pull_request: { draft: false, merged: true } } },