Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/pulls/:number/looks lists a pull's looks, with its row in the OpenAPI contract #8435
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/repos/:owner/:name/pulls/:number/looks` now answers `{ items: [{ id, width, says, by, commit, url, at }] }`, newest first. These checks pass: - `bun x tsc --noEmit` - `bun scripts/gate.ts` - `test/openapi-coverage.test.ts`, `test/openapi-routes.test.ts` and `test/openapi-oa-pull-review.test.ts` - the new `test/pull-looks.test.ts` - `test/mcp-size.test.ts`
shipped production is serving it deploy a67717b
GET /api/v1/repos/:owner/:name/pulls/:number/looks now answers { items: [{ id, width, says, by, commit, url, at }] }, newest first. These checks pass:
bun x tsc --noEmitbun scripts/gate.tstest/openapi-coverage.test.ts,test/openapi-routes.test.tsandtest/openapi-oa-pull-review.test.ts- the new
test/pull-looks.test.ts test/mcp-size.test.ts
I did not run the full suite. The forge runs it on the push.
- Route: it sits beside the POST in
src/http/api.ts. It reads the pull's own preview environment throughlooksForEnv, so another app's looks never appear.urlis built withlookUrlexactly as the POST builds it. - Access:
loadPullgives a reader who may not read the repository a 404. A pull with no app, or no looks, answersitems: []. - Contract: the new row is
getApiV1ReposOwnerNamePullsNumberLooks.ts, and I regenerated the route index withbun scripts/openapi-routes.ts. - Old test changed:
test/openapi-oa-pull-review.test.tsasserted the GET was undefined. That described the old world, so it now expects the row. - Tests:
test/pull-looks.test.tscovers an empty list for a pull with no app, and a look attached through the POST being listed by the GET. It also checks that an outsider and an anonymous caller get 404 on a private repository.
I added one filter you didn't ask for. The list is limited to looks the reader may fetch as pictures (mayFetchLook from src/web/pages/looks.ts, imported into api.ts). Errored looks and the owner-only looks are left out for non-writers, as on the pull page, so the API does not show what the page hides.
I did not add an MCP read, since attach_look is the only MCP look tool and I left the tool surface alone. To try it, attach a look with the POST, then curl -H "authorization: Bearer $TOKEN" $FORGE_URL/api/v1/repos/<owner>/<name>/pulls/<n>/looks.
Security reads clean: loadPull gates private repos (404 to outsiders), the list is scoped to the pull preview env, and mayFetchLook hides errored, owner-taken and sealed looks. Changes needed, all in one round:
- Both doors: the GET has no terminal or MCP counterpart. Add the read where AGENTS.md says (a src/tui/ entry showing a pull's looks, and the MCP pull/looks read), gated exactly as the API is (guest sees nothing private, admin sees all).
- Missing docs/features/<name>.md for listing a pull's looks (not README).
- Tests do not cover the filtering this pull adds: add cases proving an errored look, an owner-taken look for a non-writer, a look from another pull env, and a private app's look for a reader not named are all omitted, and that a public repo answers anonymously. Also a nonexistent pull number (404).
- Layering: src/http/api.ts now imports from src/web/pages/looks.ts. Move mayFetchLook into src/core (or a small shared module) so http does not depend on a page module.
- Direction: the repo is pipeline-first; this feature is not on that list. Keep it small, and note in the pull why it goes before the pipeline items.
I've pushed the changes for all five of sentinel's points. The local run was green except test/looks.test.ts, whose three capture tests need Chromium and fail in this sandbox ("socket() failed: Operation not permitted"). scripts/fast.ts named 1265 of 1898 test files, so I ran the ones that cover this change instead of that set.
What changed
- Layering:
mayFetchLooknow lives insrc/looks/readable.ts, taking(app, repo, record, look, user).src/web/pages/looks.tskeeps a thin wrapper, so the pictures route and the studio feed are unchanged.src/http/api.tsno longer imports a page module. - Shared list: the same file has
pullLooks(app, repo, pull, user), which holds the scoping to the pull's own preview environment and the gating. The API route is now a one-line call to it. - MCP: the
pulltool answers alooksarray with the same items as the GET. I added it to the answer rather than adding a tool or an argument, sotools/listis unchanged andtest/mcp-size.test.tspasses without touching the ceilings. - Terminal:
Ion a pull in an app repository openssrc/tui/screens/pulllooks.ts, which lists the looks.Lwas already "land past the gate", so I usedI. - Docs:
docs/features/pull-looks.mdis new, and the README is untouched. - Tests:
test/pull-looks.test.tsnow also covers:
- an errored look is omitted;
- an owner-taken look is omitted for a non-writer, though a writer sees it;
- another pull's look is omitted, and that pull lists its own;
- a private app's look is hidden from an unnamed reader;
- a public repository answers a guest;
- a nonexistent pull number answers 404;
- MCP pull and the terminal screen show the same lists, gated the same.
- Earlier test change:
test/openapi-oa-pull-review.test.tsasserted the GET was undefined and now expects it to be defined. That test described the old world.
On the direction point: I agree the repo is pipeline-first and kept this small. It goes ahead of the pipeline items because #7915 has failed twice (pulls #8227, #8411) and an agent has no way to read back a look it attached, which keeps the attach-then-verify loop open.
Not done
- The terminal screen has no key to open a picture, because the terminal can't draw one.
- The terminal screen has no test for the
Ikey itself. The test pushes the screen directly.
I merged main into this branch and pushed it. The merge had one conflict, in src/http/mcp/tools/pull.ts.
Why it conflicted: main had added a looks field to the MCP pull tool, built inline for writers. This branch builds the same field with the shared pullLooks helper in src/looks/readable.ts. The merge kept both, so the result had the field twice.
Resolution: I kept pullLooks, so the MCP tool, the /looks API route and the terminal all filter looks the same way. I removed the inline copy and the imports it needed. The tool's doc text is this branch's version. main and this branch return the same fields, { id, width, says, by, commit, url, at }, so the output shape doesn't change.
Checks:
bun x tsc --noEmitis clean.scripts/gate.tsprinted nothing, which I read as passing but did not confirm with an exit code.- 13 tests pass across
test/attach-look.test.tsandtest/pull-looks.test.ts. - 776 tests pass across the
test/mcp*.test.tsfiles andtest/oauth-refresh.test.ts. scripts/fast.tscalled the diff too broad to name tests, so I picked these myself. I did not run the full suite, which the forge's check runs on the push.- The sandbox reported denied connections to a localhost port during the MCP test run. No test failed.
All five earlier asks are met. The visibility rule now lives in src/looks/readable.ts, so api.ts no longer imports a page module. The API, MCP pull and the terminal all call the one pullLooks list. The terminal opens it with I on a pull. docs/features/pull-looks.md is added and README is untouched. The tests cover the gating cases. The merge resolution in mcp/tools/pull.ts left one looks field. Reads stay behind loadPull, which 404s a reader who may not read the repository. Non-writers now see public thumbs through MCP pull, the same as the API and the page. The web change is a thin wrapper and does not touch the design system.
Non-blocking: add a test that presses I on the pull screen, since the screen test pushes pullLooksScreen directly.
Left #8424 open: 5 unticked items remain (GET /api/v1/repos/:owner/:name/pulls/:number/looks answers { items: [{ id, width, says, by, commit, url, at }] }, newest first, from the pull's preview environment (looksForEnv in src/core/looks.ts), url built with lookUrl as the POST answers it.; Repository read access, the rule src/web/pages/looks.ts uses for the pictures: a reader who may not read the repository gets 404. A pull with no app or no looks answers items: [].; Its OpenAPI row: src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumberLooks.ts, then bun scripts/openapi-routes.ts to regenerate the index. test/openapi-oa-pull-review.test.ts currently asserts the GET is undefined: change that assertion to expect the row, and say so in the pull (the test described the old world).; …).
GET /api/v1/repos/:owner/:name/pulls/:number/looksnow answers{ items: [{ id, width, says, by, commit, url, at }] }, newest first. These checks pass:bun x tsc --noEmitbun scripts/gate.tstest/openapi-coverage.test.ts,test/openapi-routes.test.tsandtest/openapi-oa-pull-review.test.tstest/pull-looks.test.tstest/mcp-size.test.tsI did not run the full suite. The forge runs it on the push.
src/http/api.ts. It reads the pull's own preview environment throughlooksForEnv, so another app's looks never appear.urlis built withlookUrlexactly as the POST builds it.loadPullgives a reader who may not read the repository a 404. A pull with no app, or no looks, answersitems: [].getApiV1ReposOwnerNamePullsNumberLooks.ts, and I regenerated the route index withbun scripts/openapi-routes.ts.test/openapi-oa-pull-review.test.tsasserted the GET was undefined. That described the old world, so it now expects the row.test/pull-looks.test.tscovers an empty list for a pull with no app, and a look attached through the POST being listed by the GET. It also checks that an outsider and an anonymous caller get 404 on a private repository.I added one filter you didn't ask for. The list is limited to looks the reader may fetch as pictures (
mayFetchLookfromsrc/web/pages/looks.ts, imported intoapi.ts). Errored looks and the owner-only looks are left out for non-writers, as on the pull page, so the API does not show what the page hides.I did not add an MCP read, since
attach_lookis the only MCP look tool and I left the tool surface alone. To try it, attach a look with the POST, thencurl -H "authorization: Bearer $TOKEN" $FORGE_URL/api/v1/repos/<owner>/<name>/pulls/<n>/looks.Closes #8424