Public: anyone can read this; the crew can change it.
`GET …/app/pulls` lists an adapter's recorded pull runs, newest first #10112
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10101-get-app-pulls-lists-an-adapter-s-recorde → main 2 commits fork
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`, | ||
| 22 | 22 | The call is signed as an `admin` the forge vouches for (see "The template" |
| 23 | 23 | below), so an adapter that admits only a signed `admin` answers it. |
| 24 | 24 | |
| 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. | |
| 28 | 33 | |
| 29 | 34 | The rest of the forge's app rules apply to an adapter as they apply to any |
| 30 | 35 | other app: see [](APPS.md). |
src/apps/pull.ts +46 −2
| @@ -3,12 +3,13 @@ | ||
| 3 | 3 | * #10042). `parsePullAnswer` is the boundary: pure, given the status and the |
| 4 | 4 | * raw body, it answers four checked fields or a failed pull with a short |
| 5 | 5 | * 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 | |
| 7 | 8 | * caller that sends the request and times it is `pull_schedule.ts` (#10043). |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | 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"; | |
| 12 | 13 | |
| 13 | 14 | /** A body over this many bytes is a failed pull, never parsed. */ |
| 14 | 15 | export const PULL_BODY_MAX = 64 * 1024; |
| @@ -142,3 +143,46 @@ export function recordPull(db: Database, appId: string, outcome: PullOutcome): P | ||
| 142 | 143 | }); |
| 143 | 144 | }).immediate(); |
| 144 | 145 | } |
| 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 | ||
| 106 | 106 | export { route as getApiV1ReposOwnerNameAppMembers } from "./getApiV1ReposOwnerNameAppMembers.ts"; |
| 107 | 107 | export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNameAppNotes.ts"; |
| 108 | 108 | export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts"; |
| 109 | export { route as getApiV1ReposOwnerNameAppPulls } from "./getApiV1ReposOwnerNameAppPulls.ts"; | |
| 109 | 110 | export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts"; |
| 110 | 111 | export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts"; |
| 111 | 112 | export { route as getApiV1ReposOwnerNameBuildsLabelCart } from "./getApiV1ReposOwnerNameBuildsLabelCart.ts"; |
src/http/api.ts +2
| @@ -357,6 +357,7 @@ import { FORGE_NOT_CARRIED } from "../origins/words.ts"; | ||
| 357 | 357 | import { forgeOriginRoutes } from "./api/forge_origin.ts"; |
| 358 | 358 | import { userRoutes } from "./api/user.ts"; |
| 359 | 359 | import { appRoutes } from "./api/app.ts"; |
| 360 | import { appPullsRoutes } from "./api/app_pulls.ts"; | |
| 360 | 361 | import { buildRoutes } from "./api/builds.ts"; |
| 361 | 362 | import { buildShotRoutes } from "./api/build_shots.ts"; |
| 362 | 363 | import { buildCartRoutes } from "./api/build_cart.ts"; |
| @@ -3684,6 +3685,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3684 | 3685 | }); |
| 3685 | 3686 | |
| 3686 | 3687 | appRoutes(router); |
| 3688 | appPullsRoutes(router); | |
| 3687 | 3689 | buildRoutes(router); |
| 3688 | 3690 | buildShotRoutes(router); |
| 3689 | 3691 | 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 | }); |