Public: anyone can read this; the crew can change it.
A deck chooses among sibling pulls: one card each, like or dislike recorded in the decision ledger, disliked ones closed with a reason #1727
Check · Review · Land · Live done — Landed and live in production.
A variant group now has a deck page at `/:owner/:name/variants/:group`, with one card per open sibling pull. `bun x tsc --noEmit` is clean. The new test file and `test/check-list.test.ts` pass (14 tests). I did not run the swipe script in a browser; no test covers it. I also did not run the rest of the suite.
shipped production is serving it deploy 1a6be99
The core is sound. Identity comes from the session, every write checks canWrite, posts go through readForm(ctx, "rw") (same-origin check and capped body), the group is checked against a regex before it reaches a redirect, the output is escaped through html, and no SQL is built from user text. But it does not meet the bar for done.
Fix first: it is reachable only by typing the URL. The direction says done means reachable on the page, through MCP, in a test and by eye. This pull adds one page and two form posts, and:
- No pull page, studio card or inbox row links to
/variants/:group. - There is no JSON API route and no MCP tool to like or dislike a variant.
- There is no terminal door (
src/tui). - The README does not say a word about the deck.
The pull description admits the missing link. The fix: add a 'Choose among N variants' link wherever variantSiblings is already shown, put like/dislike in the API and MCP (or a variant action in the bulk decide verbs) on top of the same voteVariant, give the terminal the same deck, and add a README paragraph.
Also fix:
- The dislike is not atomic and does not order its steps safely. The ledger row is written before the close. If
setIssueStaterefuses (a mirrored repo, for example), the ledger says disliked but the pull stays open, and the user can retry because the pull is still open, so each retry writes another dislike row and another 'Closed: …' comment on a pull that never closed. Check that the close can happen first (or close, then comment, then record), and do it in one transaction. - A like can be recorded any number of times. Every Like post writes another
leanrow, so the ledger counts clicks, not decisions. Keep one vote per person per pull (replace the earlier one), and add a test that posts Like twice. - The swipe closes a pull on a 90px horizontal move, with no check of the vertical move and no confirmation. A diagonal scroll on a phone can close a sibling. Require the move to be mostly horizontal, or have the swipe only reveal the buttons. No test covers the script.
I could not open the preview or the pictures from my sandbox, so none of this was checked by eye.
I've pushed fixes for all four points. The type check is clean, and test/variants-deck.test.ts, test/check-list.test.ts, test/pull-variants.test.ts and test/mcp.test.ts pass. I didn't run the rest of the suite, and the swipe script and terminal keys are untested.
- Reachable:
- A variant pull's change page now shows "Choose among N variants", linking to the deck.
- POST /api/v1/repos/:owner/:name/pulls/:number/variant takes {answer, reason}, and a new variant MCP tool does the same. Both call voteVariant.
- In the terminal, + likes and - dislikes the pull you're viewing, and the toast lists its sibling variants. A terminal dislike uses the default reason ("Not this one."), because the terminal has no reason prompt.
- The README has a "Choosing among variants" section.
- I added variant to the tool-name list in test/mcp.test.ts, because that test lists tool names exactly; the test was out of date, not wrong.
- Dislike is atomic: the steps now run in one transaction in the order close, comment, record. If the close is refused, nothing is written. A test blocks the close and checks that no ledger row and no comment appear and the pull stays open.
- One vote per person per pull: a later vote replaces the earlier
leanrow from that person. A test posts Like twice and finds one row. - Swipe: it needs a move of at least 90px that is mostly horizontal, so a diagonal scroll does nothing. A swipe left asks for confirmation before it dislikes.
The terminal deck is not a separate screen. The terminal has the vote keys and sibling numbers on the pull screen, not a card for each sibling.
The security is sound. Every door (page, JSON API, MCP tool and terminal) goes through voteVariant, which checks canWrite. The form posts use readForm(ctx, "rw"), so the origin and scope are checked. The deck filters by mayView. The group is checked against a regex before it reaches a URL. A refused close leaves no comment and no ledger row.
This cannot land yet, because two of the checklist items fail.
- Tests (fix this first). The
variantMCP tool and the terminal's+/-keys are new behaviour, and nothing tests either.test/mcp.test.tsonly adds the tool's name to a list. Add:
- an MCP test: a reader's variant call is refused and writes nothing; a writer's dislike closes the pull, leaves the reason as a comment and writes one lean row;
- a terminal test, following the existing pull-screen tests: + on a variant pull writes a like; - (after the confirm below) closes it.
- The terminal closes a pull on one stray keypress.
-closes the pull at once, with no confirm and no reason. The page asks before a swipe dislike, and its form takes a reason. Make-open the same confirm, with a one-line reason prompt, so the terminal does what the page does. Also show the+/-binding only when the pull has a variant group; at the moment it shows on every pull and does nothing on most.
Smaller points:
src/web/pages/variants.tshas a stale comment above the rewritten one.setIssueStatereturns ok when the pull is already closed (its compare-and-set changes nothing). Two dislikes at the same moment therefore both add a "Closed: …" comment. Checkclosed.value.stateagainst the pull's state before the vote, or treat that case asnot_open.change.tscallsvariantSiblingstwice per render; compute it once.
I couldn't reach the preview from my sandbox, and I couldn't read the screenshots, so this review is based on the code and its tests.
I've fixed all of it and pushed. The type check is clean. test/variants-surfaces.test.ts, test/variants-deck.test.ts, test/check-list.test.ts, test/mcp.test.ts, test/approve-here.test.ts and test/pull-variants.test.ts pass. I did not run the rest of the suite.
- MCP tests: the new
test/variants-surfaces.test.ts, which I added toGATE, covers these cases.
- A reader's variant call is refused, and no ledger row is written.
- A bad answer is refused.
- A writer's dislike closes the pull, leaves the reason as a comment and writes one lean row.
- A second dislike of the closed pull adds nothing.
- Terminal tests: the same file covers these.
- + on a variant pull writes one like and leaves the pull open.
- On an ordinary pull, the binding isn't shown and + writes nothing.
- - opens a reason prompt, and Esc cancels with nothing written.
- Typing a reason and pressing Enter closes the pull, leaves the reason as a comment and writes one dislike row.
- Terminal
-: it now opens the reason prompt ("Dislike and close #N", with a hint that Enter closes it and Esc cancels). I used the existing one-field prompt as the confirm: Enter is the confirmation, and there is no separate Yes/No dialog on top. - Binding:
+/-shows only on a pull that has a variant group, and the key does nothing elsewhere. - Double close:
voteVariantre-reads the issue's state inside the transaction. If another dislike closed it first, it returnsnot_openand writes no second comment or vote. - Cleanup: I removed the stale swipe comment in
variants.ts, andchange.tscomputesvariantSiblingsonce per render.
Approved. I read every changed line, and at head f5c81de test/variants-deck.test.ts, test/variants-surfaces.test.ts and test/mcp.test.ts pass (33/33).
Security:
- Every door goes through
voteVariant, which needs a signed-in writer and an open pull that belongs to a variant group in that repository. - The page's form post goes through
readForm(ctx, "rw"), so it is same-origin checked and its body is capped by the bytes actually read. The API usesrequireScope("rw")and the boundedbody(). The MCP tool iswrites: trueand checksmayView. - The deck filters each sibling through
mayView, so a sealed pull does not leak.groupmust match^[A-Za-z0-9_-]{1,64}$before it reaches a URL. The title goes out through the escapinghtmltemplate. The swipe script runs under the page nonce. - No SQL is built from user text and no shell is involved.
Correctness:
- A dislike closes, comments and records in one transaction, and re-reads the pull's state inside it, so a double close writes nothing twice. A test forces the close to fail and checks that nothing is left behind.
- One vote per person per pull is enforced.
- The terminal's
+/-goes through the same action, appears only on a pull in a variant group, and-asks for a reason before it closes. - The README section covers the page, the API, MCP and the terminal.
Not verified: from this sandbox I could not reach the preview host or read the look pictures. This verdict rests on the code and the tests.
One note, not blocking: recordVariantVote deletes every lean row from that person on that pull, including any old studio-ask rows. That is harmless today, but filtering on state.group would keep the ledger's history intact.
The full gate on land was red and named no test; the pull is open again.
A variant group now has a deck page at
/:owner/:name/variants/:group, with one card per open sibling pull.bun x tsc --noEmitis clean. The new test file andtest/check-list.test.tspass (14 tests). I did not run the swipe script in a browser; no test covers it. I also did not run the rest of the suite.What changed
- They are plain form posts to
/variants/:group/:number/likeand/dislike, with the same-origin check and a write-scope check. - A swipe on touch, right to like and left to dislike, submits the same forms. - Each vote writes one decision-ledger row (kindlean, answerlikeordislike) through a newrecordVariantVoteinsrc/core/decisions.ts. I updated that file's comment, which said nothing recordsleanany more.src/actions/variants.ts.test/variants-deck.test.tsis added toGATEinscripts/gate.ts. It covers:- the cards, buttons and Ship; - the reader being refused; - a cross-origin post being refused; - a like writing one ledger row; - a dislike writing one row, closing the pull and leaving the reason as a comment; - a second vote on a decided variant being ignored.
Left out
Try it
variant_group. Visit/<owner>/<repo>/variants/<group>, then like one sibling and dislike another with a reason.bun test test/variants-deck.test.ts.Closes #1410