Public: anyone can read this; the crew can change it.
GET a repository's tags answers `{ items, next_cursor }`, and every test that reads it is updated #5082
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5061-get-a-repository-s-tags-answers-items-ne → main 6 commits fork 4 threads · 4 unresolved
7 files
changed +67
−21
src/contract/openapi/routes/getApiV1ReposOwnerNameTags.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/tags`, summary: "List a repository's tags: [{ name, oid, commit }]", auth: false }; | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/tags`, summary: "List a repository's tags, newest first; answers { items: [{ name, oid, commit }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", auth: false }; |
src/git/read.ts +2
| @@ -389,10 +389,12 @@ export interface Ref { | ||
| 389 | 389 | export async function listRefs( |
| 390 | 390 | dir: string, |
| 391 | 391 | prefix = "refs/", |
| 392 | newestFirst = false, | |
| 392 | 393 | ): Promise<Result<Ref[], GitFailure>> { |
| 393 | 394 | const result = await runGit( |
| 394 | 395 | [ |
| 395 | 396 | "for-each-ref", |
| 397 | ...(newestFirst ? ["--sort=-refname", "--sort=-creatordate"] : []), | |
| 396 | 398 | "--format=%(refname)%00%(objectname)%00%(*objectname)", |
| 397 | 399 | "--", |
| 398 | 400 | prefix, |
src/http/api.ts +26 −12
| @@ -2923,20 +2923,34 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2923 | 2923 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 2924 | 2924 | const repo = loadRepo(ctx); |
| 2925 | 2925 | if (!repo) return notFound(); |
| 2926 | const q = ctx.url.searchParams; | |
| 2927 | const limit = eventsLimit(q.get("limit")); | |
| 2928 | if (limit === undefined) | |
| 2929 | return apiError("invalid_limit", 422, `limit is a whole number from 1 to ${EVENTS_LIMIT_MAX}`); | |
| 2930 | const cursor = q.get("cursor"); | |
| 2931 | if (cursor !== null && (cursor.length > 200 || !isSafeRef(cursor))) | |
| 2932 | return apiError("invalid_cursor", 422, "cursor is the next_cursor of a page before"); | |
| 2926 | 2933 | const refs = await timePhaseAsync(ctx.request, "git", () => |
| 2927 | listRefs(dirOf(ctx, repo), "refs/tags/"), | |
| 2928 | ); | |
| 2929 | if (!refs.ok) return timePhase(ctx.request, "render", () => json([])); | |
| 2930 | return timePhase(ctx.request, "render", () => | |
| 2931 | json( | |
| 2932 | refs.value.map((r) => ({ | |
| 2933 | name: r.name.replace("refs/tags/", ""), | |
| 2934 | oid: r.oid, | |
| 2935 | // Annotated tags point at a tag object; this is the commit beneath. | |
| 2936 | commit: r.peeled ?? r.oid, | |
| 2937 | })), | |
| 2938 | ), | |
| 2934 | listRefs(dirOf(ctx, repo), "refs/tags/", true), | |
| 2939 | 2935 | ); |
| 2936 | return timePhase(ctx.request, "render", () => { | |
| 2937 | // Newest first; the cursor is the last tag name of the page before. | |
| 2938 | const all = refs.ok | |
| 2939 | ? refs.value.map((r) => ({ | |
| 2940 | name: r.name.replace("refs/tags/", ""), | |
| 2941 | oid: r.oid, | |
| 2942 | // Annotated tags point at a tag object; this is the commit beneath. | |
| 2943 | commit: r.peeled ?? r.oid, | |
| 2944 | })) | |
| 2945 | : []; | |
| 2946 | const at = cursor === null ? -1 : all.findIndex((t) => t.name === cursor); | |
| 2947 | if (cursor !== null && at < 0) | |
| 2948 | return apiError("invalid_cursor", 422, "cursor names a tag that is gone: start again without it"); | |
| 2949 | const from = at + 1; | |
| 2950 | const items = all.slice(from, from + limit); | |
| 2951 | const more = from + limit < all.length; | |
| 2952 | return json({ items, next_cursor: more ? items[items.length - 1]!.name : null }); | |
| 2953 | }); | |
| 2940 | 2954 | }); |
| 2941 | 2955 | |
| 2942 | 2956 | router.add("GET", "/api/v1/repos/:owner/:name/commits", async (ctx) => { |
test/api-list-shape.test.ts −1
| @@ -20,7 +20,6 @@ const BARE: Record<string, string> = { | ||
| 20 | 20 | "/api/v1/repos/:owner/:name/forks": "#4843", |
| 21 | 21 | "/api/v1/repos/:owner/:name/labels": "#4843", |
| 22 | 22 | "/api/v1/repos/:owner/:name/hooks": "#4843", |
| 23 | "/api/v1/repos/:owner/:name/tags": "#5061", | |
| 24 | 23 | "/api/v1/repos/:owner/:name/commits": "#5083", |
| 25 | 24 | "/api/v1/user/origins": "#4841", |
| 26 | 25 | "/api/v1/user/origin-apps": "#4841", |
test/api-tags-timing.test.ts +1 −1
| @@ -62,7 +62,7 @@ test("the API tags list names session, git and render in order", async () => { | ||
| 62 | 62 | const got = await phases("/api/v1/repos/alice/r/tags"); |
| 63 | 63 | expect(got.status).toBe(200); |
| 64 | 64 | expect(got.names).toEqual(["session", "git", "render"]); |
| 65 | expect(JSON.parse(got.body)).toEqual([]); | |
| 65 | expect(JSON.parse(got.body)).toEqual({ items: [], next_cursor: null }); | |
| 66 | 66 | }); |
| 67 | 67 | |
| 68 | 68 | test("a missing repository is a 404 that still names session", async () => { |
test/apps.test.ts +1 −1
| @@ -275,7 +275,7 @@ describe("apps: production, previews, data, gates", () => { | ||
| 275 | 275 | ); |
| 276 | 276 | expect(run.state).toBe("ok"); |
| 277 | 277 | // The preview is gone: no process, no directory, an honest page. |
| 278 | const deadline = Date.now() + 10_000; | |
| 278 | const deadline = Date.now() + 40_000; | |
| 279 | 279 | while ( |
| 280 | 280 | existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) && |
| 281 | 281 | Date.now() < deadline |
test/parity.test.ts +36 −5
| @@ -28,10 +28,14 @@ const gitEnv = gitClientEnv({ | ||
| 28 | 28 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 29 | 29 | }); |
| 30 | 30 | |
| 31 | async function git(args: string[], cwd: string): Promise<string> { | |
| 31 | async function git( | |
| 32 | args: string[], | |
| 33 | cwd: string, | |
| 34 | env: Record<string, string> = {}, | |
| 35 | ): Promise<string> { | |
| 32 | 36 | const p = Bun.spawn(["git", ...args], { |
| 33 | 37 | cwd, |
| 34 | env: gitEnv, | |
| 38 | env: { ...gitEnv, ...env }, | |
| 35 | 39 | stdout: "pipe", |
| 36 | 40 | stderr: "pipe", |
| 37 | 41 | }); |
| @@ -1013,10 +1017,37 @@ describe("parity: full control through actions and the API", () => { | ||
| 1013 | 1017 | await git(["tag", "light"], dir); |
| 1014 | 1018 | await git(["push", "origin", "--tags"], dir); |
| 1015 | 1019 | const tags = await call("GET", "/api/v1/repos/alice/pub/tags"); |
| 1016 | expect(tags.list.map((t) => t.name).sort()).toEqual(["light", "v1"]); | |
| 1020 | const items = tags.json.items as Json[]; | |
| 1021 | expect(items.map((t) => t.name).sort()).toEqual(["light", "v1"]); | |
| 1022 | expect(tags.json.next_cursor).toBeNull(); | |
| 1017 | 1023 | const head = await git(["rev-parse", "HEAD"], dir); |
| 1018 | for (const t of tags.list) expect(t.commit).toBe(head); | |
| 1019 | expect(tags.list.find((t) => t.name === "v1")?.oid).not.toBe(head); | |
| 1024 | for (const t of items) expect(t.commit).toBe(head); | |
| 1025 | expect(items.find((t) => t.name === "v1")?.oid).not.toBe(head); | |
| 1026 | ||
| 1027 | const first = await call("GET", "/api/v1/repos/alice/pub/tags?limit=1"); | |
| 1028 | expect((first.json.items as Json[]).length).toBe(1); | |
| 1029 | const tagCursor = first.json.next_cursor as string; | |
| 1030 | expect(tagCursor).toBe(String((first.json.items as Json[])[0]!.name)); | |
| 1031 | const rest = await call( | |
| 1032 | "GET", | |
| 1033 | `/api/v1/repos/alice/pub/tags?limit=1&cursor=${encodeURIComponent(tagCursor)}`, | |
| 1034 | ); | |
| 1035 | expect((rest.json.items as Json[]).map((t) => t.name)).not.toContain(tagCursor); | |
| 1036 | expect(rest.json.next_cursor).toBeNull(); | |
| 1037 | // Newest first by date, not by name: the older tag has the later name. | |
| 1038 | await git(["tag", "-a", "zzz-old", "-m", "old"], dir, { GIT_COMMITTER_DATE: "2020-01-01T00:00:00Z" }); | |
| 1039 | await git(["tag", "-a", "aaa-new", "-m", "new"], dir, { GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" }); | |
| 1040 | await git(["push", "origin", "--tags"], dir); | |
| 1041 | const dated = await call("GET", "/api/v1/repos/alice/pub/tags"); | |
| 1042 | const order = (dated.json.items as Json[]).map((t) => t.name); | |
| 1043 | expect(order.slice(-2)).toEqual(["aaa-new", "zzz-old"]); | |
| 1044 | // A cursor whose tag is gone is refused, not read as the first page. | |
| 1045 | const gone = await call("GET", "/api/v1/repos/alice/pub/tags?cursor=no-such-tag"); | |
| 1046 | expect(gone.status).toBe(422); | |
| 1047 | expect(gone.json.error).toBe("invalid_cursor"); | |
| 1048 | expect((await call("GET", "/api/v1/repos/alice/pub/tags?limit=0")).status).toBe(422); | |
| 1049 | expect((await call("GET", "/api/v1/repos/alice/pub/tags?limit=999")).status).toBe(422); | |
| 1050 | expect((await call("GET", "/api/v1/repos/alice/pub/tags?cursor=a..b")).status).toBe(422); | |
| 1020 | 1051 | |
| 1021 | 1052 | const commits = await call("GET", "/api/v1/repos/alice/pub/commits"); |
| 1022 | 1053 | const oid = commits.list[0]!.oid as string; |
new above
offline
High: git treats the last
--sortas the primary key, so this sorts by-refnameand not by date. Swap the two:["--sort=-refname", "--sort=-creatordate"].