Public: anyone can read this; the crew can change it.

`GET …/app/pulls` lists an adapter's recorded pull runs, newest first #10112

Merged opened this

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

Merged

scout/forge:scout/10101-get-app-pulls-lists-an-adapter-s-recorde → main 2 commits fork

  • Merged. scout/10101-get-app-pulls-lists-an-adapter-s-recorde was merged into main as f45cdf7.

Review

Sign in to review this change.
7 files changed +211 −5
docs/ADAPTERS.md +8 −3
@@ -22,9 +22,14 @@ pull run in the `pull_run` table (`src/apps/pull.ts`: `parsePullAnswer`,
2222 The call is signed as an `admin` the forge vouches for (see "The template"
2323 below), so an adapter that admits only a signed `admin` answers it.
2424
25 One thing is still missing: no page or API shows the recorded runs yet. They
26 are kept, and they decide when the next pull is due, but no page or API reads
27 them.
25 The API reads the recorded runs: `GET /api/v1/repos/:owner/:name/app/pulls`
26 answers `{ items }`, the app's last pull runs newest first (`?limit=` 1–100,
27 default 20). Each says when (`at`), how long (`durationMs`), `ok` or `failed`
28 (`state`), the sentence saying why when it failed (`error`), the records
29 `written`, `skipped` and `failed`, the `cursor` after it (null after a failed
30 pull) and whether it was a `repeat`. Whoever may read the repository may read
31 them; anyone else gets 404. The rows come through `pullRunLines` in
32 `src/apps/pull.ts`, so a page or MCP can read them the same way.
2833
2934 The rest of the forge's app rules apply to an adapter as they apply to any
3035 other app: see [`docs/APPS.md`](APPS.md).
src/apps/pull.ts +46 −2
@@ -3,12 +3,13 @@
33 * #10042). `parsePullAnswer` is the boundary: pure, given the status and the
44 * raw body, it answers four checked fields or a failed pull with a short
55 * reason naming what was wrong. `recordPull` writes the outcome as a run of
6 * kind `pull` through `core/runs.ts`. Nothing here reaches the network: the
6 * kind `pull` through `core/runs.ts`; `pullRunLines` reads them back for the
7 * API (#10101), a page or MCP. Nothing here reaches the network: the
78 * caller that sends the request and times it is `pull_schedule.ts` (#10043).
89 */
910
1011 import type { Database } from "bun:sqlite";
11 import { createPullRun, lastPullCursor, type PullRun } from "../core/runs.ts";
12 import { createPullRun, lastPullCursor, listPullRuns, type PullRun } from "../core/runs.ts";
1213
1314 /** A body over this many bytes is a failed pull, never parsed. */
1415 export const PULL_BODY_MAX = 64 * 1024;
@@ -142,3 +143,46 @@ export function recordPull(db: Database, appId: string, outcome: PullOutcome): P
142143 });
143144 }).immediate();
144145 }
146
147 /** How many runs `pullRunLines` answers when asked for none in particular, and at most. */
148 export const PULL_RUNS_DEFAULT = 20;
149 export const PULL_RUNS_MAX = 100;
150
151 /** One recorded pull, as a reader sees it: plain data, the same for the API, a page and MCP. */
152 export interface PullRunLine {
153 readonly id: string;
154 /** When it was recorded, ISO 8601. */
155 readonly at: string;
156 readonly durationMs: number;
157 readonly state: "ok" | "failed";
158 /** The sentence saying why it failed; null when it did not. */
159 readonly error: string | null;
160 /** Records written, skipped and failed by the adapter. */
161 readonly written: number;
162 readonly skipped: number;
163 readonly failed: number;
164 /** Where the next pull starts after this one; null after a failed pull. */
165 readonly cursor: string | null;
166 /** A good pull whose cursor matched the last one, so nothing counted as written. */
167 readonly repeat: boolean;
168 }
169
170 /**
171 * An app's last pull runs, newest first: `limit` is clamped to 1–100. Whoever
172 * calls this has already decided the reader may see the app.
173 */
174 export function pullRunLines(db: Database, appId: string, limit = PULL_RUNS_DEFAULT): PullRunLine[] {
175 const n = Number.isFinite(limit) ? Math.max(1, Math.min(PULL_RUNS_MAX, Math.trunc(limit))) : PULL_RUNS_DEFAULT;
176 return listPullRuns(db, appId, n).map((run) => ({
177 id: run.id,
178 at: run.createdAt,
179 durationMs: run.durationMs,
180 state: run.state,
181 error: run.state === "failed" ? run.reason : null,
182 written: run.written,
183 skipped: run.skipped,
184 failed: run.failed,
185 cursor: run.state === "failed" ? null : run.cursor,
186 repeat: run.repeat,
187 }));
188 }
src/contract/openapi/routes/getApiV1ReposOwnerNameAppPulls.ts added +16
@@ -0,0 +1,16 @@
1 import { PULL_RUNS_MAX } from "../../../apps/pull.ts";
2 import { REPO, limitTo, type Route } from "../shared.ts";
3
4 export const route: Route = {
5 method: "get",
6 path: `${REPO}/app/pulls`,
7 summary:
8 "An adapter's recorded pull runs, newest first (default 20): each { id, at, durationMs, state, error, written, skipped, failed, cursor, repeat }, `error` the sentence when failed and `cursor` where the next pull starts",
9 auth: false,
10 query: { limit: limitTo(PULL_RUNS_MAX) },
11 reply: { items: "array" },
12 refusals: {
13 not_found: "404: no such repository or app, or not the caller's to read",
14 invalid_limit: "422: limit is not a whole number from 1 to 100",
15 },
16 };
src/contract/openapi/routes/index.ts +1
@@ -106,6 +106,7 @@ export { route as getApiV1ReposOwnerNameAppEnvOpen } from "./getApiV1ReposOwnerN
106106 export { route as getApiV1ReposOwnerNameAppMembers } from "./getApiV1ReposOwnerNameAppMembers.ts";
107107 export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNameAppNotes.ts";
108108 export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts";
109 export { route as getApiV1ReposOwnerNameAppPulls } from "./getApiV1ReposOwnerNameAppPulls.ts";
109110 export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts";
110111 export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts";
111112 export { route as getApiV1ReposOwnerNameBuildsLabelCart } from "./getApiV1ReposOwnerNameBuildsLabelCart.ts";
src/http/api.ts +2
@@ -357,6 +357,7 @@ import { FORGE_NOT_CARRIED } from "../origins/words.ts";
357357 import { forgeOriginRoutes } from "./api/forge_origin.ts";
358358 import { userRoutes } from "./api/user.ts";
359359 import { appRoutes } from "./api/app.ts";
360 import { appPullsRoutes } from "./api/app_pulls.ts";
360361 import { buildRoutes } from "./api/builds.ts";
361362 import { buildShotRoutes } from "./api/build_shots.ts";
362363 import { buildCartRoutes } from "./api/build_cart.ts";
@@ -3684,6 +3685,7 @@ export function apiRoutes(router: Router<Handler>): void {
36843685 });
36853686
36863687 appRoutes(router);
3688 appPullsRoutes(router);
36873689 buildRoutes(router);
36883690 buildShotRoutes(router);
36893691 buildCartRoutes(router);
src/http/api/app_pulls.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * An adapter's recorded pull runs over the API (#10101):
3 * `GET …/app/pulls?limit=` answers the newest first, for whoever may read the
4 * repository; anyone else, or a repository with no app, gets 404. The rows are
5 * read through `pullRunLines` in `apps/pull.ts`.
6 */
7
8 import { requireReader } from "../../actions/access.ts";
9 import { PULL_RUNS_DEFAULT, PULL_RUNS_MAX, pullRunLines } from "../../apps/pull.ts";
10 import { getAppByRepo } from "../../core/apps.ts";
11 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
12 import { loadRepo } from "../api.ts";
13 import type { Ctx, Handler } from "../context.ts";
14 import { apiError, json, notFound } from "../respond.ts";
15 import type { Router } from "../router.ts";
16
17 /** `?limit=`: absent is the default, a whole number from 1 to 100 is itself, anything else is a 422. */
18 function pullsLimit(ctx: Ctx): number | Response {
19 const raw = ctx.url.searchParams.get("limit");
20 if (raw === null) return PULL_RUNS_DEFAULT;
21 const limit = /^\d{1,3}$/.test(raw) ? Number(raw) : 0;
22 if (limit < 1 || limit > PULL_RUNS_MAX)
23 return apiError("invalid_limit", 422, `limit is a whole number from 1 to ${PULL_RUNS_MAX}`);
24 return limit;
25 }
26
27 export function appPullsRoutes(router: Router<Handler>): void {
28 router.add("GET", "/api/v1/repos/:owner/:name/app/pulls", (ctx) => {
29 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
30 const repo = loadRepo(ctx);
31 if (!repo || !requireReader(ctx.app, ctx.auth.user, repo).ok) return notFound();
32 const limit = pullsLimit(ctx);
33 if (limit instanceof Response) return limit;
34 const items = timePhase(ctx.request, "pulls", () => {
35 const record = getAppByRepo(ctx.app.db, repo.id);
36 return record === undefined ? undefined : pullRunLines(ctx.app.db, record.id, limit);
37 });
38 if (items === undefined) return notFound();
39 return json({ items, next_cursor: null });
40 });
41 }
test/adapter-pulls-api.test.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * `GET …/app/pulls` (#10101): an adapter's recorded pull runs, newest first,
3 * for readers of the repository; a stranger to a private repository gets 404.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { newApp } from "../src/actions/apps.ts";
11 import { pullRunLines, recordPull } from "../src/apps/pull.ts";
12 import { getRepo } from "../src/core/repos.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16 import { promote } from "./helpers/members.ts";
17 import { tokenFor } from "./helpers/tokens.ts";
18
19 describe("app pulls API", () => {
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let aliceRo: string;
24 let bobRo: string;
25
26 const get = (path: string, token?: string) =>
27 fetch(`${servers.url}${path}`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-app-pulls-api-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
34 const b = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username: "bob",
38 email: "[email protected]",
39 password: "a-good-passphrase",
40 });
41 if (!b.ok) throw new Error(b.error);
42 promote(app.db, b.value);
43 const alice = getUserByName(app.db, "alice")!;
44 for (const [name, visibility] of [["hello", "public"], ["secret", "private"]] as const) {
45 const made = await newApp(app, alice, { name, visibility } as never);
46 if (!made.ok) throw new Error(String(made.error));
47 const repo = getRepo(app.db, "alice", name)!;
48 const appId = (app.db.query("SELECT id FROM app WHERE repo_id = ?1").get(repo.id) as { id: string }).id;
49 recordPull(app.db, appId, { ok: true, pulled: 3, skipped: 1, errors: 0, cursor: "c1", durationMs: 42 });
50 recordPull(app.db, appId, { ok: false, reason: "status 500 is not 2xx", durationMs: 7 });
51 }
52 aliceRo = tokenFor(app, "alice", "ro");
53 bobRo = tokenFor(app, "bob", "ro");
54 }, 30_000);
55
56 afterAll(async () => {
57 servers.stop();
58 app.close();
59 await rm(dataDir, { recursive: true, force: true });
60 });
61
62 test("a reader gets the failed run then the good one, newest first", async () => {
63 const res = await get("/api/v1/repos/alice/hello/app/pulls");
64 expect(res.status).toBe(200);
65 const body = (await res.json()) as { items: Record<string, unknown>[] };
66 expect(body.items).toHaveLength(2);
67 expect(body.items[0]).toMatchObject({ state: "failed", error: "status 500 is not 2xx", written: 0, cursor: null, durationMs: 7 });
68 expect(body.items[1]).toMatchObject({ state: "ok", error: null, written: 3, skipped: 1, cursor: "c1", durationMs: 42, repeat: false });
69 expect(typeof body.items[0]!.at).toBe("string");
70 });
71
72 test("limit narrows the list and is refused outside 1–100", async () => {
73 const one = (await (await get("/api/v1/repos/alice/hello/app/pulls?limit=1")).json()) as { items: { state: string }[] };
74 expect(one.items.map((i) => i.state)).toEqual(["failed"]);
75 for (const bad of ["0", "101", "x", "-1"]) {
76 expect((await get(`/api/v1/repos/alice/hello/app/pulls?limit=${bad}`)).status).toBe(422);
77 }
78 });
79
80 test("the owner reads a private app's runs; a stranger and a guest get 404", async () => {
81 const own = await get("/api/v1/repos/alice/secret/app/pulls", aliceRo);
82 expect(own.status).toBe(200);
83 expect(((await own.json()) as { items: unknown[] }).items).toHaveLength(2);
84 expect((await get("/api/v1/repos/alice/secret/app/pulls", bobRo)).status).toBe(404);
85 expect((await get("/api/v1/repos/alice/secret/app/pulls")).status).toBe(404);
86 });
87
88 test("a repository with no app, or none at all, is 404", async () => {
89 expect((await get("/api/v1/repos/alice/nothing/app/pulls")).status).toBe(404);
90 });
91
92 test("pullRunLines clamps its limit", () => {
93 const appId = (app.db.query("SELECT id FROM app LIMIT 1").get() as { id: string }).id;
94 expect(pullRunLines(app.db, appId, 0)).toHaveLength(1);
95 expect(pullRunLines(app.db, appId, 1000)).toHaveLength(2);
96 });
97 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs