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 #8452

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8424-get-api-v1-repos-owner-name-pulls-number → main 3 commits fork

  • Merged. scout/8424-get-api-v1-repos-owner-name-pulls-number was merged into main as ada9030.

Review

Sign in to review this change.
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
120120 export { route as getApiV1ReposOwnerNamePulls } from "./getApiV1ReposOwnerNamePulls.ts";
121121 export { route as getApiV1ReposOwnerNamePullsNumber } from "./getApiV1ReposOwnerNamePullsNumber.ts";
122122 export { route as getApiV1ReposOwnerNamePullsNumberChecks } from "./getApiV1ReposOwnerNamePullsNumberChecks.ts";
123 export { route as getApiV1ReposOwnerNamePullsNumberLooks } from "./getApiV1ReposOwnerNamePullsNumberLooks.ts";
123124 export { route as getApiV1ReposOwnerNamePullsNumberReviewStates } from "./getApiV1ReposOwnerNamePullsNumberReviewStates.ts";
124125 export { route as getApiV1ReposOwnerNamePullsNumberReviews } from "./getApiV1ReposOwnerNamePullsNumberReviews.ts";
125126 export { route as getApiV1ReposOwnerNamePullsNumberThreads } from "./getApiV1ReposOwnerNamePullsNumberThreads.ts";
src/http/api.ts +14
@@ -92,6 +92,7 @@ import {
9292 } from "../actions/pulls.ts";
9393 import { attachLook } from "../actions/attach_look.ts";
9494 import { lookUrl } from "../core/looks.ts";
95 import { pullLooks } from "../looks/readable.ts";
9596 import { holdPull, releasePull } from "../actions/holds.ts";
9697 import { getHold, type Hold } from "../core/holds.ts";
9798 import { watchMilestone } from "../actions/watches.ts";
@@ -2632,6 +2633,19 @@ export function apiRoutes(router: Router<Handler>): void {
26322633 },
26332634 );
26342635
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
26352649 // Line-anchored review comments. The anchor is (path, side, line) in the
26362650 // pull's current diff; the server takes the content hash, so a client never
26372651 // 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";
99 import { stepOfPull } from "../../../flow/path.ts";
1010 import { appView } from "../../../actions/apps.ts";
1111 import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts";
12 import { pullLooks } from "../../../looks/readable.ts";
1213 import { isCrew } from "../../../core/agents.ts";
1314 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";
1715 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
1816
1917 export const tool: Tool = {
2018 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",
2220 description:
2321 "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.",
2422 inputSchema: {
@@ -53,25 +51,7 @@ export const tool: Tool = {
5351 const rows = listComments(db, pull.id, { newest: true, limit: EMBEDDED_COMMENTS });
5452 const comments = marking ? markReaders(db, repo, rows) : rows;
5553 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 }));
7354 const answer = good({
74 looks,
7555 comments: comments.map((c) => ({
7656 author: c.authorName,
7757 body: c.body,
@@ -80,6 +60,7 @@ export const tool: Tool = {
8060 })),
8161 commentsTotal: total,
8262 commentsNext: comments.length > 0 && comments.length < total ? comments[0]!.id : null,
63 looks: pullLooks(ctx.app, repo, pull, user),
8364 number: pull.number,
8465 title: pull.title,
8566 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";
4646 import { takeBackWords } from "../../actions/revert_words.ts";
4747 import { openEnvUrl } from "../../actions/apps.ts";
4848 import { listRepoRuns, mayReadRunLog } from "../../actions/runs.ts";
49 import { pullLooksScreen } from "./pulllooks.ts";
4950 import { previewEnv } from "../../apps/data.ts";
5051 import { findingsWords, pullPreviewFindings } from "../../core/preview_findings.ts";
5152 import {
@@ -936,6 +937,7 @@ export function pullScreen(
936937 ? [binding(["O"], "open the preview")]
937938 : []),
938939 ...(isApp ? [binding(["P"], "open production")] : []),
940 ...(isApp ? [binding(["I"], "looks")] : []),
939941 ...(takeBackKey ? [binding(["T"], "take it back")] : []),
940942 ...(isApp && preview !== undefined && !takeBackKey
941943 ? [binding(["T"], "the preview's terminal")]
@@ -1180,6 +1182,11 @@ export function pullScreen(
11801182 openPreview();
11811183 return true;
11821184 }
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 }
11831190 if (e.key === "P" && isApp) {
11841191 openProduction();
11851192 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 @@
77 * long as anyone likes and is never the wrong commit's.
88 */
99
10 import { sealedPreview } from "../../core/sealed.ts";
1110 import { closeSync, existsSync, openSync, readSync } from "node:fs";
1211 import { jpegHeader } from "../../design/image/jpeg.ts";
1312 import { isPng, pngHeader } from "../../design/image/png.ts";
@@ -27,10 +26,10 @@ import {
2726 lookVersion,
2827 } from "../../core/looks.ts";
2928 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";
3130 import type { Ctx } from "../../http/context.ts";
3231 import { notFound } from "../../http/respond.ts";
33 import { keepLook } from "../../looks/service.ts";
32 import { mayFetchLook as mayFetchLookFor } from "../../looks/readable.ts";
3433 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3534 import { loadRepo, repoBase } from "../chrome.ts";
3635 import { html, when, type Html } from "../html.ts";
@@ -106,32 +105,13 @@ export function lookImage(ctx: Ctx): Response {
106105 return new Response(Bun.file(path), { headers });
107106 }
108107
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 = (
118110 ctx: Ctx,
119111 repo: Repo,
120112 record: AppRecord,
121113 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);
135115
136116 /**
137117 * 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
1616 expect(look?.requestBody).toBeDefined();
1717 expect(look?.responses?.["201"]).toBeDefined();
1818 expect(JSON.stringify(look)).toContain("not_open");
19 expect(paths[`${R}/looks`]?.get).toBeUndefined();
19 expect(paths[`${R}/looks`]?.get).toBeDefined();
2020 });
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,
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs