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

Merged opened this

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

Merged

scout/forge:scout/5061-get-a-repository-s-tags-answers-items-ne → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/5061-get-a-repository-s-tags-answers-items-ne was merged into main as 5d5db6e.

Review

Sign in to review this change.
7 files changed +67 −21
src/contract/openapi/routes/getApiV1ReposOwnerNameTags.ts +1 −1
@@ -1,3 +1,3 @@
11 import { REPO, type Route } from "../shared.ts";
22
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 {
389389 export async function listRefs(
390390 dir: string,
391391 prefix = "refs/",
392 newestFirst = false,
392393 ): Promise<Result<Ref[], GitFailure>> {
393394 const result = await runGit(
394395 [
395396 "for-each-ref",
outdated

High: git treats the last --sort as the primary key, so this sorts by -refname and not by date. Swap the two: ["--sort=-refname", "--sort=-creatordate"].

397 ...(newestFirst ? ["--sort=-refname", "--sort=-creatordate"] : []),
396398 "--format=%(refname)%00%(objectname)%00%(*objectname)",
397399 "--",
398400 prefix,
src/http/api.ts +26 −12
@@ -2923,20 +2923,34 @@ export function apiRoutes(router: Router<Handler>): void {
29232923 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
29242924 const repo = loadRepo(ctx);
29252925 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");
29262933 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),
29392935 );
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 });
29402954 });
29412955
29422956 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> = {
2020 "/api/v1/repos/:owner/:name/forks": "#4843",
2121 "/api/v1/repos/:owner/:name/labels": "#4843",
2222 "/api/v1/repos/:owner/:name/hooks": "#4843",
23 "/api/v1/repos/:owner/:name/tags": "#5061",
2423 "/api/v1/repos/:owner/:name/commits": "#5083",
2524 "/api/v1/user/origins": "#4841",
2625 "/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 () => {
6262 const got = await phases("/api/v1/repos/alice/r/tags");
6363 expect(got.status).toBe(200);
6464 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 });
6666 });
6767
6868 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", () => {
275275 );
276276 expect(run.state).toBe("ok");
277277 // 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;
279279 while (
280280 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
281281 Date.now() < deadline
test/parity.test.ts +36 −5
@@ -28,10 +28,14 @@ const gitEnv = gitClientEnv({
2828 GIT_COMMITTER_EMAIL: "[email protected]",
2929 });
3030
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> {
3236 const p = Bun.spawn(["git", ...args], {
3337 cwd,
34 env: gitEnv,
38 env: { ...gitEnv, ...env },
3539 stdout: "pipe",
3640 stderr: "pipe",
3741 });
@@ -1013,10 +1017,37 @@ describe("parity: full control through actions and the API", () => {
10131017 await git(["tag", "light"], dir);
10141018 await git(["push", "origin", "--tags"], dir);
10151019 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();
10171023 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);
10201051
10211052 const commits = await call("GET", "/api/v1/repos/alice/pub/commits");
10221053 const oid = commits.list[0]!.oid as string;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs