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

GET a repository's branches answers `{ items, next_cursor }`, and every test that reads it is updated #5085

Merged opened this

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

Merged

scout/forge:scout/5060-get-a-repository-s-branches-answers-item → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/5060-get-a-repository-s-branches-answers-item was merged into main as a5aa24a.

Review

Sign in to review this change.
14 files changed +116 −86
README.md +1 −1
@@ -208,7 +208,7 @@ runs no check and no preview until a writer approves its head or lets it run
208208 (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull
209209 opened with it says "proposed by child … from …" on every tab of the pull's
210210 page, in the terminal's pull view, and as `proposal` in the pull's JSON
211 (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422. `GET …/issues?fields=brief` lists issues with only number, kind, title, state, labels, assignee, author, originAuthor, authorIsAgent, createdAt and updatedAt (and `untrusted` where it applies); no bodies or comments. `fields` is `brief` or left out, and anything else answers 422 `invalid_fields`. `GET …/repos/:owner/:name/events` answers `{ items, next_cursor }`, newest first, `limit` 1 to 200 (default 50) and `cursor` from `next_cursor` (anything else is a 422); it used to answer a bare array of every event, so a client that read the array reads `items` now. A request reads at most 1,000 events, so when most of them are hidden from the reader a page can come back short with a `next_cursor`: keep following it until it is null. MCP's `events` tool answers the same.
211 (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422. `GET …/issues?fields=brief` lists issues with only number, kind, title, state, labels, assignee, author, originAuthor, authorIsAgent, createdAt and updatedAt (and `untrusted` where it applies); no bodies or comments. `fields` is `brief` or left out, and anything else answers 422 `invalid_fields`. `GET …/repos/:owner/:name/events` answers `{ items, next_cursor }`, newest first, `limit` 1 to 200 (default 50) and `cursor` from `next_cursor` (anything else is a 422); it used to answer a bare array of every event, so a client that read the array reads `items` now. A request reads at most 1,000 events, so when most of them are hidden from the reader a page can come back short with a `next_cursor`: keep following it until it is null. MCP's `events` tool answers the same. `GET …/repos/:owner/:name/branches` answers `{ items, next_cursor }` in the order it always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the name of the last branch on it); anything else is a 422, and a cursor naming a branch deleted between two requests is `invalid_cursor`, so start over. It used to answer a bare array, so a client that read the array reads `items` now.
212212
213213 ### Proposing to a parent forge
214214
src/http/api.ts +19 −6
@@ -2834,21 +2834,34 @@ export function apiRoutes(router: Router<Handler>): void {
28342834 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
28352835 const repo = loadRepo(ctx);
28362836 if (!repo) return notFound();
2837 const q = ctx.url.searchParams;
2838 const limit = eventsLimit(q.get("limit") ?? undefined);
2839 if (limit === undefined)
2840 return apiError("invalid_limit", 422, "limit is a whole number from 1 to 200");
2841 const cursor = q.get("cursor");
2842 if (cursor !== null && (cursor === "" || cursor.length > 255))
2843 return apiError("invalid_cursor", 422, "cursor is the next_cursor of a page before");
28372844 const listed = await timePhaseAsync(ctx.request, "git", () =>
28382845 listBranches(dirOf(ctx, repo), repo.defaultBranch),
28392846 );
2840 if (!listed.ok) return timePhase(ctx.request, "render", () => json([]));
2841 return timePhase(ctx.request, "render", () =>
2842 json(
2843 listed.value.branches.map((b) => ({
2847 const rows = listed.ok
2848 ? listed.value.branches.map((b) => ({
28442849 name: b.name,
28452850 oid: b.oid,
28462851 isDefault: b.isDefault,
28472852 ahead: b.ahead,
28482853 behind: b.behind,
28492854 protected: b.isDefault && repo.protectDefault,
2850 })),
2851 ),
2855 }))
2856 : [];
2857 // The cursor is the name of the last branch of the page before.
2858 const start = cursor === null ? 0 : rows.findIndex((r) => r.name === cursor) + 1;
2859 if (start === 0 && cursor !== null)
2860 return apiError("invalid_cursor", 422, "cursor is the next_cursor of a page before");
2861 const items = rows.slice(start, start + limit);
2862 const more = start + limit < rows.length;
2863 return timePhase(ctx.request, "render", () =>
2864 json({ items, next_cursor: more ? items[items.length - 1]!.name : null }),
28522865 );
28532866 });
28542867
test/api-branches-timing.test.ts +1 −1
@@ -57,7 +57,7 @@ test("the branches list names session, git, render in order", async () => {
5757 const got = await phases("/api/v1/repos/alice/r/branches");
5858 expect(got.status).toBe(200);
5959 expect(got.names).toEqual(["session", "git", "render"]);
60 expect(Array.isArray(JSON.parse(got.body))).toBe(true);
60 expect(Array.isArray(JSON.parse(got.body).items)).toBe(true);
6161 });
6262
6363 test("a missing repository is a 404 naming session", async () => {
test/api-list-shape.test.ts −1
@@ -23,7 +23,6 @@ const BARE: Record<string, string> = {
2323 "/api/v1/repos/:owner/:name/forks": "#4843",
2424 "/api/v1/repos/:owner/:name/labels": "#4843",
2525 "/api/v1/repos/:owner/:name/hooks": "#4843",
26 "/api/v1/repos/:owner/:name/branches": "#5060",
2726 "/api/v1/repos/:owner/:name/tags": "#5061",
2827 "/api/v1/repos/:owner/:name/commits": "#5083",
2928 "/api/v1/user/origins": "#4841",
test/branches.test.ts +40 −5
@@ -120,7 +120,9 @@ describe("branches on a native repo", () => {
120120 });
121121
122122 const branches = async (): Promise<BranchRow[]> =>
123 read<BranchRow[]>(await api("/api/v1/repos/alice/app/branches"));
123 (
124 await read<{ items: BranchRow[] }>(await api("/api/v1/repos/alice/app/branches"))
125 ).items;
124126
125127 const create = (payload: unknown) =>
126128 api("/api/v1/repos/alice/app/branches", {
@@ -207,6 +209,39 @@ describe("branches on a native repo", () => {
207209 expect(rows[0]!.isDefault).toBe(true);
208210 });
209211
212 test("the list is { items, next_cursor }, pages with limit to the end, and refuses bad input", async () => {
213 const all = await branches();
214 expect(all.length).toBeGreaterThanOrEqual(2);
215 const whole = await read<{ items: unknown[]; next_cursor: string | null }>(
216 await api("/api/v1/repos/alice/app/branches"),
217 );
218 expect(Object.keys(whole).sort()).toEqual(["items", "next_cursor"]);
219 expect(whole.next_cursor).toBeNull();
220
221 const seen: string[] = [];
222 let cursor: string | null = null;
223 for (let i = 0; i < all.length + 1; i++) {
224 const q: string = `limit=1${cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`}`;
225 const page = await read<{ items: BranchRow[]; next_cursor: string | null }>(
226 await api(`/api/v1/repos/alice/app/branches?${q}`),
227 );
228 expect(page.items.length).toBe(1);
229 seen.push(page.items[0]!.name);
230 cursor = page.next_cursor;
231 if (cursor === null) break;
232 }
233 expect(seen).toEqual(all.map((b) => b.name));
234
235 for (const bad of ["0", "201", "x", "-1", "1.5"]) {
236 const r = await api(`/api/v1/repos/alice/app/branches?limit=${bad}`);
237 expect(r.status).toBe(422);
238 expect((await read<{ error: string }>(r)).error).toBe("invalid_limit");
239 }
240 const r = await api("/api/v1/repos/alice/app/branches?cursor=no-such-branch");
241 expect(r.status).toBe(422);
242 expect((await read<{ error: string }>(r)).error).toBe("invalid_cursor");
243 });
244
210245 test("protection is reported per branch, on the default only", async () => {
211246 await api(
212247 "/api/v1/repos/alice/app",
@@ -384,12 +419,12 @@ describe("branches on a mirror go through the origin", () => {
384419 });
385420
386421 const localBranches = async (): Promise<BranchRow[]> =>
387 read<BranchRow[]>(await api("/api/v1/repos/alice/gears/branches"));
422 (
423 await read<{ items: BranchRow[] }>(await api("/api/v1/repos/alice/gears/branches"))
424 ).items;
388425
389426 const upstreamBranches = async (): Promise<{ name: string }[]> =>
390 read<{ name: string }[]>(
391 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
392 );
427 (await read<{ items: { name: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`))).items;
393428
394429 test("creating a branch lands it upstream, then here", async () => {
395430 const created = await api("/api/v1/repos/alice/gears/branches", {
test/edits-security.test.ts +12 −6
@@ -363,9 +363,11 @@ describe("edits: refusals that must leave nothing behind", () => {
363363 expect(b.ahead).toBeNull();
364364 expect(b.behind).toBeNull();
365365 }
366 const rows = await read<{ ahead: number | null }[]>(
367 await api("/api/v1/repos/alice/app/branches"),
368 );
366 const rows = (
367 await read<{ items: { ahead: number | null }[] }>(
368 await api("/api/v1/repos/alice/app/branches"),
369 )
370 ).items;
369371 expect(rows.every((b) => b.ahead === null)).toBe(true);
370372 });
371373 });
@@ -411,9 +413,13 @@ describe("edits on a mirror: the origin's refusal is final", () => {
411413 .split("\n")
412414 .filter((r) => r !== "");
413415 const upstreamBranches = async (): Promise<string[]> =>
414 read<{ name: string }[]>(
415 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
416 ).then((rows) => rows.map((b) => b.name).sort());
416 (
417 await read<{ items: { name: string }[] }>(
418 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
419 )
420 ).items
421 .map((b) => b.name)
422 .sort();
417423
418424 beforeAll(async () => {
419425 dataDir = await mkdtemp(join(tmpdir(), "forge-mr-"));
test/edits.test.ts +4 −8
@@ -209,10 +209,10 @@ describe("edits: files, branches and a pull in one call", () => {
209209 expect(edited.pull).toMatchObject({ title: "Add new.txt" });
210210
211211 // The branch exists, carries the file, and main does not.
212 const branches = await read<{ name: string }[]>(
212 const branches = await read<{ items: { name: string }[] }>(
213213 await api("/api/v1/repos/alice/app/branches"),
214214 );
215 expect(branches.map((b) => b.name)).toContain("feat/edit");
215 expect(branches.items.map((b) => b.name)).toContain("feat/edit");
216216 expect((await fileAt("alice/app", "new.txt", "feat/edit")).status).toBe(
217217 200,
218218 );
@@ -454,9 +454,7 @@ describe("edits on a mirror land upstream first", () => {
454454 expect(response.status).toBe(201);
455455 const edited = await read<EditResponse>(response);
456456
457 const upstreamMain = await read<{ name: string; oid: string }[]>(
458 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
459 );
457 const upstreamMain = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`))).items;

Nit: await (await read<…>(…)).items has a redundant outer await (.items is not a promise), and the line is far past the width the rest of the file keeps. Not blocking.

460458 expect(upstreamMain.find((b) => b.name === "main")?.oid).toBe(
461459 edited.commit.commitOid,
462460 );
@@ -482,9 +480,7 @@ describe("edits on a mirror land upstream first", () => {
482480 expect(response.status).toBe(201);
483481 const edited = await read<EditResponse>(response);
484482
485 const branches = await read<{ name: string; oid: string }[]>(
486 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
487 );
483 const branches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`))).items;
488484 expect(branches.find((b) => b.name === "feat/mirror")?.oid).toBe(
489485 edited.commit.commitOid,
490486 );
test/fork-flow.test.ts +8 −24
@@ -344,11 +344,7 @@ describe("the fork flow", () => {
344344 expect(result.pull?.local).toBeNull();
345345
346346 // The branch really landed on the fork's git, at the commit we made.
347 const branches = await read<{ name: string; oid: string }[]>(
348 await fetch(
349 `${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/gears/branches`,
350 ),
351 );
347 const branches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/gears/branches`))).items;
352348 const landed = branches.find((b) => b.name === "fix-typo");
353349 expect(landed?.oid).toBe(result.commit.commitOid);
354350
@@ -437,11 +433,7 @@ describe("the fork flow", () => {
437433 expect(after).toBe(before);
438434
439435 // The branch is on the upstream's own git.
440 const branches = await read<{ name: string; oid: string }[]>(
441 await fetch(
442 `${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/own/branches`,
443 ),
444 );
436 const branches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/own/branches`))).items;
445437 expect(branches.find((b) => b.name === "direct")?.oid).toBe(
446438 result.commit.commitOid,
447439 );
@@ -482,11 +474,7 @@ describe("the fork flow", () => {
482474 }>(response);
483475 expect(result.via).toBe("fork");
484476
485 const branches = await read<{ name: string; oid: string }[]>(
486 await fetch(
487 `${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/widgets/branches`,
488 ),
489 );
477 const branches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/widgets/branches`))).items;
490478 expect(branches.find((b) => b.name === "gh-fix")?.oid).toBe(
491479 result.commit.commitOid,
492480 );
@@ -745,11 +733,7 @@ describe("the fork flow", () => {
745733 const pull = await read<{ number: number }>(proposed);
746734
747735 // The branch is on the upstream's own git, at bob's commit…
748 const branches = await read<{ name: string; oid: string }[]>(
749 await fetch(
750 `${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/own/branches`,
751 ),
752 );
736 const branches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/${ORIGIN_LOGIN}/own/branches`))).items;
753737 expect(branches.find((b) => b.name === "refined")?.oid).toBe(commitOid);
754738 // …the pull was opened there from it, as a bare branch…
755739 const opened = forgejo.state.requests.filter(
@@ -760,10 +744,10 @@ describe("the fork flow", () => {
760744 expect(sent.head).toBe("refined");
761745 expect(sent.base).toBe("main");
762746 // …and the mirror shows both the branch and the pull.
763 const local = await read<{ name: string; oid: string }[]>(
747 const local = await read<{ items: { name: string; oid: string }[] }>(
764748 await api(`/api/v1/repos/${mirror.ownerName}/${mirror.name}/branches`),
765749 );
766 expect(local.find((b) => b.name === "refined")?.oid).toBe(commitOid);
750 expect(local.items.find((b) => b.name === "refined")?.oid).toBe(commitOid);
767751 const listed = await read<{ items: { number: number }[] }>(
768752 await api(`/api/v1/repos/${mirror.ownerName}/${mirror.name}/pulls`),
769753 );
@@ -814,10 +798,10 @@ describe("the fork flow", () => {
814798 expect(seeded).toBe(1);
815799 expect(job?.stage).toBe("done");
816800 expect(job?.repo?.name).toBe("late");
817 const branches = await read<{ name: string }[]>(
801 const branches = await read<{ items: { name: string }[] }>(
818802 await api(`/api/v1/repos/${job!.repo!.ownerName}/late/branches`),
819803 );
820 expect(branches.map((b) => b.name)).toContain("main");
804 expect(branches.items.map((b) => b.name)).toContain("main");
821805 }, 60_000);
822806
823807 test("a native repo has no upstream to contribute to", async () => {
test/origins-security-push.test.ts +2 −2
@@ -361,10 +361,10 @@ describe("push-through, adversarially", () => {
361361 };
362362
363363 const localMain = async (): Promise<string> => {
364 const branches = await read<{ name: string; oid: string }[]>(
364 const branches = await read<{ items: { name: string; oid: string }[] }>(
365365 await api("/api/v1/repos/alice/gears/branches"),
366366 );
367 return branches.find((b) => b.name === "main")?.oid ?? "";
367 return branches.items.find((b) => b.name === "main")?.oid ?? "";
368368 };
369369 const originRef = async (ref: string): Promise<string> =>
370370 (await git(["rev-parse", "--verify", "--quiet", ref], origin.dir)).out;
test/origins.test.ts +10 −6
@@ -403,9 +403,11 @@ describe("origins: connecting, mirroring, writing through", () => {
403403 expect(repo.pinned).toBe(false);
404404
405405 // The git objects really arrived.
406 const branches = (await (
407 await api("/api/v1/repos/alice/widgets/branches")
408 ).json()) as { name: string }[];
406 const branches = (
407 (await (await api("/api/v1/repos/alice/widgets/branches")).json()) as {
408 items: { name: string }[];
409 }
410 ).items;
409411 expect(branches.map((b) => b.name)).toEqual(["main"]);
410412 const readme = (await (
411413 await api("/api/v1/repos/alice/widgets/contents/README.md")
@@ -581,9 +583,11 @@ describe("origins: connecting, mirroring, writing through", () => {
581583 });
582584 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
583585 const branchesOf = async (): Promise<Map<string, string>> => {
584 const listed = (await (
585 await fetch(`${originServers.url}/api/v1/repos/octo/widgets/branches`)
586 ).json()) as { name: string; oid: string }[];
586 const listed = (
587 (await (
588 await fetch(`${originServers.url}/api/v1/repos/octo/widgets/branches`)
589 ).json()) as { items: { name: string; oid: string }[] }
590 ).items;
587591 return new Map(listed.map((b) => [b.name, b.oid]));
588592 };
589593 const before = await branchesOf();
test/parity.test.ts +1 −1
@@ -342,7 +342,7 @@ describe("parity: full control through actions and the API", () => {
342342 const dir = join(app.reposRoot, "alice", "pub.git");
343343 expect(await git(["symbolic-ref", "HEAD"], dir)).toBe("refs/heads/feature");
344344 const branches = await call("GET", "/api/v1/repos/alice/pub/branches");
345 expect(branches.list.find((b) => b.isDefault)?.name).toBe("feature");
345 expect((branches.json.items as Json[]).find((b) => b.isDefault)?.name).toBe("feature");
346346 const back = await call("PATCH", "/api/v1/repos/alice/pub", {
347347 token: aliceAdmin,
348348 body: { defaultBranch: "main" },
test/push-through.test.ts +8 −16
@@ -218,16 +218,14 @@ describe("push-through: the origin gets the last word", () => {
218218 expect(pushed.code).toBe(0);
219219
220220 // The origin's ref actually moved — this is the whole feature.
221 const upstreamBranches = await read<{ name: string; oid: string }[]>(
222 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
223 );
221 const upstreamBranches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`))).items;
224222 expect(upstreamBranches.find((b) => b.name === "main")?.oid).toBe(local);
225223
226224 // And the local ref moved too, so the mirror is not behind its origin.
227 const localBranches = await read<{ name: string; oid: string }[]>(
225 const localBranches = await read<{ items: { name: string; oid: string }[] }>(
228226 await api("/api/v1/repos/alice/gears/branches"),
229227 );
230 expect(localBranches.find((b) => b.name === "main")?.oid).toBe(local);
228 expect(localBranches.items.find((b) => b.name === "main")?.oid).toBe(local);
231229 });
232230
233231 // ---- the refusal path ----
@@ -263,13 +261,11 @@ describe("push-through: the origin gets the last word", () => {
263261
264262 // Nothing moved on either side: the local ref is still the old commit,
265263 // which is exactly what "the origin is truth" has to mean.
266 const localBranches = await read<{ name: string; oid: string }[]>(
264 const localBranches = await read<{ items: { name: string; oid: string }[] }>(
267265 await api("/api/v1/repos/alice/gears/branches"),
268266 );
269 expect(localBranches.find((b) => b.name === "main")?.oid).toBe(before);
270 const upstreamBranches = await read<{ name: string; oid: string }[]>(
271 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
272 );
267 expect(localBranches.items.find((b) => b.name === "main")?.oid).toBe(before);
268 const upstreamBranches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`))).items;
273269 expect(upstreamBranches.find((b) => b.name === "main")?.oid).toBe(before);
274270
275271 await upstream("/api/v1/repos/octo/gears", {
@@ -287,15 +283,11 @@ describe("push-through: the origin gets the last word", () => {
287283 const tip = (await git(["rev-parse", "HEAD"], clone)).out;
288284
289285 expect((await git(["push", "-q", "origin", "topic"], clone)).code).toBe(0);
290 let upstreamBranches = await read<{ name: string; oid: string }[]>(
291 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
292 );
286 let upstreamBranches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`))).items;
293287 expect(upstreamBranches.find((b) => b.name === "topic")?.oid).toBe(tip);
294288
295289 expect((await git(["push", "-q", "origin", ":topic"], clone)).code).toBe(0);
296 upstreamBranches = await read<{ name: string; oid: string }[]>(
297 await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`),
298 );
290 upstreamBranches = await (await read<{ items: { name: string; oid: string }[] }>(await fetch(`${originServers.url}/api/v1/repos/octo/gears/branches`))).items;
299291 expect(upstreamBranches.map((b) => b.name)).not.toContain("topic");
300292 });
301293
test/read-api.test.ts +5 −6
@@ -69,12 +69,11 @@ describe("read-back API (contents, branches, commits)", () => {
6969 });
7070
7171 test("branches lists heads with the default flagged", async () => {
72 const branches = (await (
73 await api("/api/v1/repos/dev/lib/branches")
74 ).json()) as {
75 name: string;
76 isDefault: boolean;
77 }[];
72 const branches = (
73 (await (await api("/api/v1/repos/dev/lib/branches")).json()) as {
74 items: { name: string; isDefault: boolean }[];
75 }
76 ).items;
7877 const names = branches.map((b) => b.name).sort();
7978 expect(names).toEqual(["feature", "main"]);
8079 expect(branches.find((b) => b.name === "main")?.isDefault).toBe(true);
test/web-workflows.test.ts +5 −3
@@ -392,9 +392,11 @@ describe("fork at the origin, and propose a change through it", () => {
392392 expect(flash).toContain("octo/gears#1");
393393
394394 // The branch really landed on the fork's git.
395 const branches = (await (
396 await fetch(`${upServers.url}/api/v1/repos/me/gears/branches`)
397 ).json()) as { name: string }[];
395 const branches = (
396 (await (
397 await fetch(`${upServers.url}/api/v1/repos/me/gears/branches`)
398 ).json()) as { items: { name: string }[] }
399 ).items;
398400 expect(branches.map((b) => b.name)).toContain("alice/fix-typo");
399401
400402 // And the pull went upstream with the cross-repository head form.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs