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.
scout/forge:scout/8424-get-api-v1-repos-owner-name-pulls-number → main 3 commits fork 2 threads · 2 unresolved
11 files
changed +425
−48
docs/features/pull-looks.md added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | # A pull's looks | |
| 2 | ||
| 3 | Looks are the pictures of a pull's preview: the forge's own once the deploy | |
| 4 | is green, and the ones a person or agent attaches (`POST …/pulls/:number/looks`, | |
| 5 | MCP `attach_look`). This is the read side, so an agent can check what it | |
| 6 | attached. | |
| 7 | ||
| 8 | One list, one rule (`pullLooks` in `src/looks/readable.ts`), three doors: | |
| 9 | ||
| 10 | - **API** — `GET /api/v1/repos/:owner/:name/pulls/:number/looks` answers | |
| 11 | `{ items: [{ id, width, says, by, commit, url, at }] }`, newest first. `url` | |
| 12 | is the same one the POST answers. | |
| 13 | - **MCP** — the `pull` tool carries `looks`, the same items (no new tool). | |
| 14 | - **Terminal** — `I` on a pull opens its looks. | |
| 15 | ||
| 16 | Who sees what is `mayFetchLook`, the rule the pictures themselves use: a | |
| 17 | reader who may not read the repository gets 404; a writer sees every look | |
| 18 | that has a picture; anyone else sees only those taken as nobody, of an app | |
| 19 | they may enter, and not those of a sealed pull. A look that failed (no | |
| 20 | picture) is never listed. Only the pull's own preview environment is read, so | |
| 21 | another pull's or app's looks never appear. A pull with no app or no looks | |
| 22 | answers `items: []`. |
src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumberLooks.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${REPO}/pulls/{number}/looks`, | |
| 6 | summary: "List a pull's looks, newest first, as { items: [{ id, width, says, by, commit, url, at }] }; empty when the pull has no app or no looks", | |
| 7 | auth: false, | |
| 8 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -120,6 +120,7 @@ export { route as getApiV1ReposOwnerNamePrompts } from "./getApiV1ReposOwnerName | ||
| 120 | 120 | export { route as getApiV1ReposOwnerNamePulls } from "./getApiV1ReposOwnerNamePulls.ts"; |
| 121 | 121 | export { route as getApiV1ReposOwnerNamePullsNumber } from "./getApiV1ReposOwnerNamePullsNumber.ts"; |
| 122 | 122 | export { route as getApiV1ReposOwnerNamePullsNumberChecks } from "./getApiV1ReposOwnerNamePullsNumberChecks.ts"; |
| 123 | export { route as getApiV1ReposOwnerNamePullsNumberLooks } from "./getApiV1ReposOwnerNamePullsNumberLooks.ts"; | |
| 123 | 124 | export { route as getApiV1ReposOwnerNamePullsNumberReviewStates } from "./getApiV1ReposOwnerNamePullsNumberReviewStates.ts"; |
| 124 | 125 | export { route as getApiV1ReposOwnerNamePullsNumberReviews } from "./getApiV1ReposOwnerNamePullsNumberReviews.ts"; |
| 125 | 126 | export { route as getApiV1ReposOwnerNamePullsNumberThreads } from "./getApiV1ReposOwnerNamePullsNumberThreads.ts"; |
src/http/api.ts +14
| @@ -92,6 +92,7 @@ import { | ||
| 92 | 92 | } from "../actions/pulls.ts"; |
| 93 | 93 | import { attachLook } from "../actions/attach_look.ts"; |
| 94 | 94 | import { lookUrl } from "../core/looks.ts"; |
| 95 | import { pullLooks } from "../looks/readable.ts"; | |
| 95 | 96 | import { holdPull, releasePull } from "../actions/holds.ts"; |
| 96 | 97 | import { getHold, type Hold } from "../core/holds.ts"; |
| 97 | 98 | import { watchMilestone } from "../actions/watches.ts"; |
| @@ -2632,6 +2633,19 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2632 | 2633 | }, |
| 2633 | 2634 | ); |
| 2634 | 2635 | |
| 2636 | // A pull's looks, newest first, from its own preview environment and only | |
| 2637 | // the ones this reader may fetch as a picture (`looks/readable.ts`). | |
| 2638 | router.add( | |
| 2639 | "GET", | |
| 2640 | "/api/v1/repos/:owner/:name/pulls/:number/looks", | |
| 2641 | (ctx) => { | |
| 2642 | const found = loadPull(ctx); | |
| 2643 | if ("error" in found) return found.error; | |
| 2644 | const items = pullLooks(ctx.app, found.repo, found.pull, ctx.auth.user); | |
| 2645 | return json({ items }); | |
| 2646 | }, | |
| 2647 | ); | |
| 2648 | ||
| 2635 | 2649 | // Line-anchored review comments. The anchor is (path, side, line) in the |
| 2636 | 2650 | // pull's current diff; the server takes the content hash, so a client never |
| 2637 | 2651 | // handles one — and a later force-push marks the comment `outdated`. |
src/http/mcp/tools/pull.ts +3 −22
| @@ -9,16 +9,14 @@ import { nextOfPull } from "../../../flow/next_actor.ts"; | ||
| 9 | 9 | import { stepOfPull } from "../../../flow/path.ts"; |
| 10 | 10 | import { appView } from "../../../actions/apps.ts"; |
| 11 | 11 | import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts"; |
| 12 | import { pullLooks } from "../../../looks/readable.ts"; | |
| 12 | 13 | import { isCrew } from "../../../core/agents.ts"; |
| 13 | 14 | import { markReaders } from "../../../crew/direct.ts"; |
| 14 | import { getAppByRepo } from "../../../core/apps.ts"; | |
| 15 | import { looksForEnv, lookUrl } from "../../../core/looks.ts"; | |
| 16 | import { canWrite } from "../../../core/repos.ts"; | |
| 17 | 15 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 18 | 16 | |
| 19 | 17 | export const tool: Tool = { |
| 20 | 18 | name: "pull", |
| 21 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; `looks` (newest first, for writers: `[{ id, width, says, by, commit, url, at }]`, the pictures taken or attached for the pull's preview; `[]` when there are none), draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 19 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `looks` (newest first: `{ id, width, says, by, commit, url, at }`, the same as `GET …/pulls/:number/looks`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 22 | 20 | description: |
| 23 | 21 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.", |
| 24 | 22 | inputSchema: { |
| @@ -53,25 +51,7 @@ export const tool: Tool = { | ||
| 53 | 51 | const rows = listComments(db, pull.id, { newest: true, limit: EMBEDDED_COMMENTS }); |
| 54 | 52 | const comments = marking ? markReaders(db, repo, rows) : rows; |
| 55 | 53 | const total = countThread(db, pull.id); |
| 56 | // The pictures are the writers' (as `mayFetchLook` keeps them), so | |
| 57 | // anyone else sees none. | |
| 58 | const record = canWrite(db, repo, user) ? getAppByRepo(db, repo.id) : undefined; | |
| 59 | const looks = | |
| 60 | record === undefined | |
| 61 | ? [] | |
| 62 | : looksForEnv(db, record.id, previewEnv(pull.number)) | |
| 63 | .filter((l) => l.error === null) | |
| 64 | .map((l) => ({ | |
| 65 | id: l.id, | |
| 66 | width: l.kind, | |
| 67 | says: l.note ?? "", | |
| 68 | by: l.attachedBy, | |
| 69 | commit: l.commitOid, | |
| 70 | url: lookUrl(`/${repo.ownerName}/${repo.name}`, l), | |
| 71 | at: l.createdAt, | |
| 72 | })); | |
| 73 | 54 | const answer = good({ |
| 74 | looks, | |
| 75 | 55 | comments: comments.map((c) => ({ |
| 76 | 56 | author: c.authorName, |
| 77 | 57 | body: c.body, |
| @@ -80,6 +60,7 @@ export const tool: Tool = { | ||
| 80 | 60 | })), |
| 81 | 61 | commentsTotal: total, |
| 82 | 62 | commentsNext: comments.length > 0 && comments.length < total ? comments[0]!.id : null, |
| 63 | looks: pullLooks(ctx.app, repo, pull, user), | |
| 83 | 64 | number: pull.number, |
| 84 | 65 | title: pull.title, |
| 85 | 66 | state: pull.mergedAt !== null ? "merged" : pull.state, |
src/looks/readable.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * Who may fetch a look, and a pull's looks as a reader may see them: the one | |
| 3 | * rule behind the pictures on the web, the API read, MCP `pull` and the | |
| 4 | * terminal. Pure reads; no page, no request. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { previewEnv } from "../apps/data.ts"; | |
| 9 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; | |
| 10 | import { looksForEnv, lookUrl, type Look } from "../core/looks.ts"; | |
| 11 | import type { Pull } from "../core/pulls.ts"; | |
| 12 | import { canEnterApp, canSeeOwnerFacts, type Repo } from "../core/repos.ts"; | |
| 13 | import { sealedPreview } from "../core/sealed.ts"; | |
| 14 | import type { User } from "../core/users.ts"; | |
| 15 | import { keepLook } from "./service.ts"; | |
| 16 | ||
| 17 | /** | |
| 18 | * Who may fetch a picture (#727, #738). Writers, all of them. Anyone else | |
| 19 | * only what was taken as nobody, and only of an app they may open (anyone | |
| 20 | * for a public app, a writer, the crew or a named person for a private | |
| 21 | * one): the thumb always, a public app's phone and desktop too. A picture | |
| 22 | * taken as the owner, an admin, stays the writers'; so does a thumb from | |
| 23 | * before thumbs were taken as nobody (`keepLook`), until it is retaken. | |
| 24 | */ | |
| 25 | export function mayFetchLook( | |
| 26 | app: App, | |
| 27 | repo: Repo, | |
| 28 | record: AppRecord, | |
| 29 | look: Look, | |
| 30 | user: User | undefined, | |
| 31 | ): boolean { | |
| 32 | if (canSeeOwnerFacts(app.db, repo, user)) return true; | |
| 33 | // A sealed pull's pictures are the writers' (`core/sealed.ts`). | |
| 34 | if (sealedPreview(app.db, repo.id, look.env)) return false; | |
| 35 | // A private app's pictures are for whoever may enter it (#620, #752): a | |
| 36 | // person it names sees them; a reader who is not named does not. | |
| 37 | if (!canEnterApp(app.db, repo, record, user)) return false; | |
| 38 | // Phone and desktop are a reader's only when taken as nobody — a public | |
| 39 | // app's, since #738; one taken as the owner stays the writers' (#727). | |
| 40 | if (look.kind !== "thumb") return look.takenAs === "nobody"; | |
| 41 | return keepLook(app.config.dataDir, look); | |
| 42 | } | |
| 43 | ||
| 44 | export interface PullLook { | |
| 45 | readonly id: string; | |
| 46 | readonly width: string; | |
| 47 | readonly says: string | null; | |
| 48 | readonly by: string | null; | |
| 49 | readonly commit: string; | |
| 50 | readonly url: string; | |
| 51 | readonly at: string; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * A pull's looks, newest first, from its own preview environment and only | |
| 56 | * the ones this reader may fetch. Empty when the repository has no app. | |
| 57 | * The caller has already checked the reader may view the pull. | |
| 58 | */ | |
| 59 | export function pullLooks( | |
| 60 | app: App, | |
| 61 | repo: Repo, | |
| 62 | pull: Pull, | |
| 63 | user: User | undefined, | |
| 64 | ): PullLook[] { | |
| 65 | const record = getAppByRepo(app.db, repo.id); | |
| 66 | if (!record) return []; | |
| 67 | const base = `/${repo.ownerName}/${repo.name}`; | |
| 68 | return looksForEnv(app.db, record.id, previewEnv(pull.number)) | |
| 69 | .filter((l) => l.error === null && mayFetchLook(app, repo, record, l, user)) | |
| 70 | .map((l) => ({ | |
| 71 | id: l.id, | |
| 72 | width: l.kind, | |
| 73 | says: l.note, | |
| 74 | by: l.attachedBy, | |
| 75 | commit: l.commitOid, | |
| 76 | url: lookUrl(base, l), | |
| 77 | at: l.createdAt, | |
| 78 | })); | |
| 79 | } |
src/tui/screens/pull.ts +7
| @@ -46,6 +46,7 @@ import { revertPull } from "../../actions/revert.ts"; | ||
| 46 | 46 | import { takeBackWords } from "../../actions/revert_words.ts"; |
| 47 | 47 | import { openEnvUrl } from "../../actions/apps.ts"; |
| 48 | 48 | import { listRepoRuns, mayReadRunLog } from "../../actions/runs.ts"; |
| 49 | import { pullLooksScreen } from "./pulllooks.ts"; | |
| 49 | 50 | import { previewEnv } from "../../apps/data.ts"; |
| 50 | 51 | import { findingsWords, pullPreviewFindings } from "../../core/preview_findings.ts"; |
| 51 | 52 | import { |
| @@ -936,6 +937,7 @@ export function pullScreen( | ||
| 936 | 937 | ? [binding(["O"], "open the preview")] |
| 937 | 938 | : []), |
| 938 | 939 | ...(isApp ? [binding(["P"], "open production")] : []), |
| 940 | ...(isApp ? [binding(["I"], "looks")] : []), | |
| 939 | 941 | ...(takeBackKey ? [binding(["T"], "take it back")] : []), |
| 940 | 942 | ...(isApp && preview !== undefined && !takeBackKey |
| 941 | 943 | ? [binding(["T"], "the preview's terminal")] |
| @@ -1180,6 +1182,11 @@ export function pullScreen( | ||
| 1180 | 1182 | openPreview(); |
| 1181 | 1183 | return true; |
| 1182 | 1184 | } |
| 1185 | if (e.key === "I" && isApp && repo !== undefined && detail !== undefined) { | |
| 1186 | const pull = getPull(f.db, repo.id, number); | |
| 1187 | if (pull !== undefined) ctx.push(pullLooksScreen(f, ctx, repo, pull)); | |
| 1188 | return true; | |
| 1189 | } | |
| 1183 | 1190 | if (e.key === "P" && isApp) { |
| 1184 | 1191 | openProduction(); |
| 1185 | 1192 | return true; |
src/tui/screens/pulllooks.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | /** | |
| 2 | * `I` on a pull — its looks, newest first: the same read as MCP `pull` and | |
| 3 | * `GET …/pulls/:number/looks` (`pullLooks`), so a reader sees only what the | |
| 4 | * API would show them. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Pull } from "../../core/pulls.ts"; | |
| 8 | import type { Repo } from "../../core/repos.ts"; | |
| 9 | import { pullLooks } from "../../looks/readable.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { binding } from "../app/keys.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | import { emptyState } from "../ui/widgets.ts"; | |
| 14 | import { ListState, ago, drawList, type ListRow } from "./common.ts"; | |
| 15 | ||
| 16 | const plain = (text: string): string => | |
| 17 | text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " "); | |
| 18 | ||
| 19 | export function pullLooksScreen(f: Session, ctx: Ctx, repo: Repo, pull: Pull): Screen { | |
| 20 | const state = new ListState(); | |
| 21 | const looks = pullLooks(f.app, repo, pull, f.user); | |
| 22 | const rows: ListRow[] = looks.map((l) => ({ | |
| 23 | title: plain(`${l.width} · ${l.says ?? "taken by the forge"}`), | |
| 24 | meta: ago(l.at), | |
| 25 | secondary: plain(`${l.by ?? "forge"} · ${l.commit.slice(0, 7)}`), | |
| 26 | })); | |
| 27 | return { | |
| 28 | title: `Looks · #${pull.number}`, | |
| 29 | get keys() { | |
| 30 | return [binding(["j", "k"], "move"), binding(["esc", "q"], "back")]; | |
| 31 | }, | |
| 32 | render(area, s) { | |
| 33 | if (rows.length === 0) { | |
| 34 | emptyState(s, area, ctx.theme.c, { | |
| 35 | title: "No looks yet", | |
| 36 | body: "A look lands once the preview is up, or when someone attaches one.", | |
| 37 | }); | |
| 38 | return; | |
| 39 | } | |
| 40 | drawList(s, area, ctx.theme.c, state, rows); | |
| 41 | }, | |
| 42 | key(e) { | |
| 43 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 44 | else state.key(e, rows.length); | |
| 45 | return true; | |
| 46 | }, | |
| 47 | }; | |
| 48 | } |
src/web/pages/looks.ts +5 −25
| @@ -7,7 +7,6 @@ | ||
| 7 | 7 | * long as anyone likes and is never the wrong commit's. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { sealedPreview } from "../../core/sealed.ts"; | |
| 11 | 10 | import { closeSync, existsSync, openSync, readSync } from "node:fs"; |
| 12 | 11 | import { jpegHeader } from "../../design/image/jpeg.ts"; |
| 13 | 12 | import { isPng, pngHeader } from "../../design/image/png.ts"; |
| @@ -27,10 +26,10 @@ import { | ||
| 27 | 26 | lookVersion, |
| 28 | 27 | } from "../../core/looks.ts"; |
| 29 | 28 | import type { Pull } from "../../core/pulls.ts"; |
| 30 | import { canEnterApp, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 29 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 31 | 30 | import type { Ctx } from "../../http/context.ts"; |
| 32 | 31 | import { notFound } from "../../http/respond.ts"; |
| 33 | import { keepLook } from "../../looks/service.ts"; | |
| 32 | import { mayFetchLook as mayFetchLookFor } from "../../looks/readable.ts"; | |
| 34 | 33 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 35 | 34 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 36 | 35 | import { html, when, type Html } from "../html.ts"; |
| @@ -106,32 +105,13 @@ export function lookImage(ctx: Ctx): Response { | ||
| 106 | 105 | return new Response(Bun.file(path), { headers }); |
| 107 | 106 | } |
| 108 | 107 | |
| 109 | /** | |
| 110 | * Who may fetch a picture (#727, #738). Writers, all of them. Anyone else | |
| 111 | * only what was taken as nobody, and only of an app they may open (anyone | |
| 112 | * for a public app, a writer, the crew or a named person for a private | |
| 113 | * one): the thumb always, a public app's phone and desktop too. A picture | |
| 114 | * taken as the owner, an admin, stays the writers'; so does a thumb from | |
| 115 | * before thumbs were taken as nobody (`keepLook`), until it is retaken. | |
| 116 | */ | |
| 117 | export function mayFetchLook( | |
| 108 | /** `mayFetchLook` for a request (`looks/readable.ts`). */ | |
| 109 | export const mayFetchLook = ( | |
| 118 | 110 | ctx: Ctx, |
| 119 | 111 | repo: Repo, |
| 120 | 112 | record: AppRecord, |
| 121 | 113 | look: Look, |
| 122 | ): boolean { | |
| 123 | const user = ctx.auth.user; | |
| 124 | if (canSeeOwnerFacts(ctx.app.db, repo, user)) return true; | |
| 125 | // A sealed pull's pictures are the writers' (`core/sealed.ts`). | |
| 126 | if (sealedPreview(ctx.app.db, repo.id, look.env)) return false; | |
| 127 | // A private app's pictures are for whoever may enter it (#620, #752): a | |
| 128 | // person it names sees them; a reader who is not named does not. | |
| 129 | if (!canEnterApp(ctx.app.db, repo, record, user)) return false; | |
| 130 | // Phone and desktop are a reader's only when taken as nobody — a public | |
| 131 | // app's, since #738; one taken as the owner stays the writers' (#727). | |
| 132 | if (look.kind !== "thumb") return look.takenAs === "nobody"; | |
| 133 | return keepLook(ctx.app.config.dataDir, look); | |
| 134 | } | |
| 114 | ): boolean => mayFetchLookFor(ctx.app, repo, record, look, ctx.auth.user); | |
| 135 | 115 | |
| 136 | 116 | /** |
| 137 | 117 | * The Look card on a pull: phone and desktop side by side, the commit |
test/openapi-oa-pull-review.test.ts +1 −1
| @@ -16,5 +16,5 @@ test("review-states and looks are described with their method, auth and refusals | ||
| 16 | 16 | expect(look?.requestBody).toBeDefined(); |
| 17 | 17 | expect(look?.responses?.["201"]).toBeDefined(); |
| 18 | 18 | expect(JSON.stringify(look)).toContain("not_open"); |
| 19 | expect(paths[`${R}/looks`]?.get).toBeUndefined(); | |
| 19 | expect(paths[`${R}/looks`]?.get).toBeDefined(); | |
| 20 | 20 | }); |
test/pull-looks.test.ts added +237
| @@ -0,0 +1,237 @@ | ||
| 1 | /** | |
| 2 | * GET …/pulls/:number/looks lists what POST attached, and only what the | |
| 3 | * reader may fetch: scoped to the pull's own environment, no errored look, | |
| 4 | * no owner-taken look for a non-writer, nothing of a private app to a reader | |
| 5 | * it does not name, nothing of a private repository to an outsider. MCP `pull` | |
| 6 | * carries the same list. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { previewEnv } from "../src/apps/data.ts"; | |
| 16 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 17 | import { recordLook } from "../src/core/looks.ts"; | |
| 18 | import type { Pull } from "../src/core/pulls.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 21 | import { encodePng } from "../src/design/image/png.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | import { getRepo } from "../src/core/repos.ts"; | |
| 24 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 25 | import { pullLooksScreen } from "../src/tui/screens/pulllooks.ts"; | |
| 26 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | import { quietLogs } from "./helpers/globals.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | const PASSWORD = "a-good-passphrase"; | |
| 33 | const PHONE = Buffer.from( | |
| 34 | encodePng(390, 844, new Uint8Array(390 * 844 * 4).fill(200)), | |
| 35 | ).toString("base64"); | |
| 36 | ||
| 37 | let dataDir: string; | |
| 38 | let app: App; | |
| 39 | let servers: Servers; | |
| 40 | const tokens: Record<string, string> = {}; | |
| 41 | const pulls: Record<string, Pull[]> = {}; | |
| 42 | const apps: Record<string, string> = {}; | |
| 43 | ||
| 44 | const url = (repo: string, n: number) => | |
| 45 | `${servers.url}/api/v1/repos/alice/${repo}/pulls/${n}/looks`; | |
| 46 | const get = (repo: string, token: string | null, n = pulls[repo]![0]!.number) => | |
| 47 | fetch(url(repo, n), { headers: token ? { authorization: `Bearer ${token}` } : {} }); | |
| 48 | const items = async (repo: string, token: string | null) => | |
| 49 | ((await (await get(repo, token)).json()) as { items: Record<string, any>[] }).items; | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-pull-looks-")); | |
| 53 | app = createApp({ dataDir, port: 0 }); | |
| 54 | const person = async (username: string): Promise<User> => { | |
| 55 | const made = await createUser(app.db, { | |
| 56 | origin: "seed", | |
| 57 | trusted: true, | |
| 58 | username, | |
| 59 | email: `${username}@forge.local`, | |
| 60 | password: PASSWORD, | |
| 61 | adminIfFirst: username === "alice", | |
| 62 | }); | |
| 63 | if (!made.ok) throw new Error(made.error); | |
| 64 | return made.value; | |
| 65 | }; | |
| 66 | const alice = await person("alice"); | |
| 67 | const bob = await person("bob"); | |
| 68 | const repoWith = async ( | |
| 69 | name: string, | |
| 70 | visibility: "public" | "private", | |
| 71 | access: "public" | "private" | undefined, | |
| 72 | ) => { | |
| 73 | const made = await createRepo(app, alice, { name, visibility }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | const repo = made.value; | |
| 76 | await commitToBranch(app, alice, repo, { | |
| 77 | message: "seed", | |
| 78 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 79 | }); | |
| 80 | pulls[name] = []; | |
| 81 | for (const branch of ["one", "two"]) { | |
| 82 | await commitToBranch(app, alice, repo, { | |
| 83 | branch, | |
| 84 | base: "main", | |
| 85 | message: branch, | |
| 86 | files: [{ path: `${branch}.md`, content: "x\n" }], | |
| 87 | }); | |
| 88 | const opened = await openPull(app, alice, repo, { | |
| 89 | title: `Change ${branch}`, | |
| 90 | body: "", | |
| 91 | baseBranch: "main", | |
| 92 | headBranch: branch, | |
outdated
Only the happy path is tested. Add cases for each thing mayFetchLook/looksForEnv filter out; none of that filtering would fail a test today. |
||
| 93 | }); | |
| 94 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 95 | pulls[name]!.push(opened.value); | |
| 96 | } | |
| 97 | if (access !== undefined) { | |
| 98 | const inserted = insertApp(app.db, { repoId: repo.id, slug: name, access }); | |
| 99 | if (!inserted.ok) throw new Error(inserted.error); | |
| 100 | apps[name] = inserted.value.id; | |
| 101 | } | |
| 102 | }; | |
| 103 | await repoWith("bare", "public", undefined); | |
| 104 | await repoWith("garden", "public", "public"); | |
| 105 | await repoWith("hush", "public", "private"); | |
| 106 | await repoWith("den", "private", "public"); | |
| 107 | servers = await serve(app); | |
| 108 | tokens["alice"] = createToken(app.db, alice.id, "t", "rw").token; | |
| 109 | tokens["bob"] = createToken(app.db, bob.id, "t", "rw").token; | |
| 110 | }, 30_000); | |
| 111 | ||
| 112 | afterAll(async () => { | |
| 113 | servers.stop(); | |
| 114 | app.close(); | |
| 115 | await rm(dataDir, { recursive: true, force: true }); | |
| 116 | }); | |
| 117 | ||
| 118 | const record = (name: string, n: number, extra: Record<string, unknown>) => | |
| 119 | recordLook(app.db, { | |
| 120 | appId: apps[name]!, | |
| 121 | env: previewEnv(n), | |
| 122 | commitOid: "a".repeat(40), | |
| 123 | kind: "phone", | |
| 124 | path: "looks/x.png", | |
| 125 | takenAs: "nobody", | |
| 126 | ...extra, | |
| 127 | } as Parameters<typeof recordLook>[1]); | |
| 128 | ||
| 129 | test("a pull with no app answers an empty list", async () => { | |
| 130 | const res = await get("bare", tokens["alice"]!); | |
| 131 | expect(res.status).toBe(200); | |
| 132 | expect(await res.json()).toEqual({ items: [] }); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a nonexistent pull number is a 404", async () => { | |
| 136 | expect((await get("garden", tokens["alice"]!, 999)).status).toBe(404); | |
| 137 | expect((await get("garden", tokens["alice"]!, 0)).status).toBe(404); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a look attached through the POST is listed by the GET", async () => { | |
| 141 | const n = pulls["den"]![0]!.number; | |
| 142 | const posted = await fetch(url("den", n), { | |
| 143 | method: "POST", | |
| 144 | headers: { "content-type": "application/json", authorization: `Bearer ${tokens["alice"]}` }, | |
| 145 | body: JSON.stringify({ width: "phone", says: "the empty den", image: PHONE }), | |
| 146 | }); | |
| 147 | expect(posted.status).toBe(201); | |
| 148 | const made = (await posted.json()) as { id: string; url: string }; | |
| 149 | const listed = await items("den", tokens["alice"]!); | |
| 150 | expect(listed).toHaveLength(1); | |
| 151 | expect(listed[0]).toMatchObject({ | |
| 152 | id: made.id, | |
| 153 | width: "phone", | |
| 154 | says: "the empty den", | |
| 155 | by: "alice", | |
| 156 | url: made.url, | |
| 157 | }); | |
| 158 | expect(typeof listed[0]!.at).toBe("string"); | |
| 159 | // A private repository does not exist to an outsider or a guest. | |
| 160 | expect((await get("den", tokens["bob"]!)).status).toBe(404); | |
| 161 | expect((await get("den", null)).status).toBe(404); | |
| 162 | }); | |
| 163 | ||
| 164 | test("only what the reader may fetch is listed, and only this pull's", async () => { | |
| 165 | const [one, two] = pulls["garden"]!; | |
| 166 | record("garden", one!.number, { kind: "desktop", takenAs: "nobody", note: "seen" }); | |
| 167 | // An errored look, an owner-taken look and another pull's look. | |
| 168 | record("garden", one!.number, { commitOid: "b".repeat(40), error: "boom", path: "" }); | |
| 169 | record("garden", one!.number, { commitOid: "c".repeat(40), takenAs: "owner" }); | |
| 170 | record("garden", two!.number, { commitOid: "d".repeat(40), note: "elsewhere" }); | |
| 171 | ||
| 172 | const mine = await items("garden", tokens["alice"]!); | |
| 173 | expect(mine.map((i) => i.commit).sort()).toEqual(["a".repeat(40), "c".repeat(40)]); | |
| 174 | ||
| 175 | // A reader who cannot write, and a guest (a public repository answers anonymously). | |
| 176 | for (const who of [tokens["bob"]!, null]) { | |
| 177 | const seen = await items("garden", who); | |
| 178 | expect(seen).toHaveLength(1); | |
| 179 | expect(seen[0]).toMatchObject({ commit: "a".repeat(40), width: "desktop", says: "seen" }); | |
| 180 | } | |
| 181 | // The other pull lists its own. | |
| 182 | const other = await items("garden", tokens["alice"]!).then(() => | |
| 183 | get("garden", tokens["alice"]!, two!.number).then((r) => r.json()), | |
| 184 | ); | |
| 185 | expect((other as { items: { says: string }[] }).items.map((i) => i.says)).toEqual(["elsewhere"]); | |
| 186 | }); | |
| 187 | ||
| 188 | test("a private app's looks are not a reader's unless named", async () => { | |
| 189 | const one = pulls["hush"]![0]!; | |
| 190 | record("hush", one.number, { note: "private" }); | |
| 191 | expect(await items("hush", tokens["alice"]!)).toHaveLength(1); | |
| 192 | expect(await items("hush", tokens["bob"]!)).toEqual([]); | |
| 193 | expect(await items("hush", null)).toEqual([]); | |
| 194 | }); | |
| 195 | ||
| 196 | test("MCP pull carries the same looks, gated the same", async () => { | |
| 197 | const call = async (token: string, repo: string) => { | |
| 198 | const res = await fetch(`${servers.url}/mcp`, { | |
| 199 | method: "POST", | |
| 200 | headers: { | |
| 201 | "content-type": "application/json", | |
| 202 | accept: "application/json, text/event-stream", | |
| 203 | authorization: `Bearer ${token}`, | |
| 204 | }, | |
| 205 | body: JSON.stringify({ | |
| 206 | jsonrpc: "2.0", | |
| 207 | id: 1, | |
| 208 | method: "tools/call", | |
| 209 | params: { name: "pull", arguments: { repo: `alice/${repo}`, number: pulls[repo]![0]!.number } }, | |
| 210 | }), | |
| 211 | }); | |
| 212 | const body = (await res.json()) as any; | |
| 213 | return JSON.parse(body.result.content[0].text) as { looks: { says: string | null }[] }; | |
| 214 | }; | |
| 215 | expect((await call(tokens["alice"]!, "hush")).looks).toHaveLength(1); | |
| 216 | expect((await call(tokens["bob"]!, "hush")).looks).toEqual([]); | |
| 217 | expect((await call(tokens["bob"]!, "garden")).looks).toHaveLength(1); | |
| 218 | }); | |
| 219 | ||
| 220 | test("the terminal screen lists the same looks, gated the same", async () => { | |
| 221 | const shown = async (username: string, repoName: string) => { | |
| 222 | const person = getUserByName(app.db, username)!; | |
| 223 | const repo = getRepo(app.db, "alice", repoName)!; | |
| 224 | const { app: tui, session } = createForgeApp( | |
| 225 | app, | |
| 226 | { sshUsername: username, remoteAddress: "127.0.0.1", user: person }, | |
| 227 | { terminal: headlessTerminal(140, 30), exitHooks: false }, | |
| 228 | ); | |
| 229 | tui.start(); | |
| 230 | await settleApp(tui); | |
| 231 | tui.push(pullLooksScreen(session, tui.ctx, repo, pulls[repoName]![0]!)); | |
| 232 | await settleApp(tui); | |
| 233 | return tui.screen.toText(); | |
| 234 | }; | |
| 235 | expect(await shown("alice", "hush")).toContain("phone · private"); | |
| 236 | expect(await shown("bob", "hush")).toContain("No looks yet"); | |
| 237 | }); |
new above
offline
http importing a web page module inverts the layering; move mayFetchLook to core/ and import it from both.