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

A fork tree reader returns an app's ancestors, siblings and descendants as plain data, filtered by what the caller may read #10587

Merged opened this

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

Merged

torque/forge:torque/10508-a-fork-tree-reader-returns-an-app-s-ance → main 5 commits fork 2 threads · 2 unresolved

  • Merged. torque/10508-a-fork-tree-reader-returns-an-app-s-ance was merged into main as 259f1fa.

Review

Sign in to review this change.
11 files changed +410 −3
docs/features/fork-tree.md added +31
@@ -0,0 +1,31 @@
1 # The fork tree
2
3 Every repository sits in a tree of forks. The fork tree reader (`forkTree` in `src/actions/fork_tree.ts`) returns
4 the part of it you may read: the highest ancestor you can see, then every fork below it, so siblings and cousins
5 come with it. A fork you cannot read is left out before anything is counted, and everything under it goes too.
6 No git is read.
7
8 ## Terminal
9
10 Open the palette and choose **Fork tree of a repository**, then type `owner/name`. The repository you asked
11 about is marked `*`, each generation is indented, and each fork says whether it `take-updates` or only `offers`.
12 A repository you may not read answers "not found", as everywhere else.
13
14 ## MCP
15
16 ```json
17 { "name": "fork_tree", "arguments": { "repo": "alice/app", "depth": 4, "limit": 200 } }
18 ```
19
20 Returns `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`. `depth` is 1-8 (default 4) generations
21 below the root; `limit` is 1-200 nodes (default 200). `truncated` is true when more exists than is shown.
22
23 - The repository asked about is always in `nodes`, with its ancestors, even when it lies deeper than `depth` or
24 `limit` would reach.
25 - The root's `follow` is `autonomous`; any other fork is `take-updates` or `offers`.
26 - A repo you cannot read gives `{ root: null, nodes: [], truncated: false }`.
27
28 ## Cost
29
30 One query per generation plus one for the follow modes, not one per fork; the test counts statements
31 (`test/fork-tree-reader.test.ts`), not a clock.
src/actions/fork_tree.ts added +107
@@ -0,0 +1,107 @@
1 /**

N+1 queries (visibleForks per parent, forkFollowMode per child, then a second sweep for truncated): about 408 statements for 200 nodes. Batch per level. Also guarantee the requested repo is in nodes when it sits deeper than depth.

2 * A fork tree as plain data: an app's ancestors up to the root, then every
3 * descendant of that root breadth-first, so siblings and cousins come free.
4 *
5 * Only what the caller may read is here. A fork they cannot read is left out
6 * before anything is counted, and everything beneath it goes with it
7 * (`visibleForks`, `forkParentFor`). An ancestor they cannot read ends the
8 * climb: the root is the highest one they may see. No git is read.
9 *
10 * The repo asked about is always in `nodes`, however far below the root it sits
11 * (its ancestors come with it, past `depth` and `limit` if need be). The cost
12 * is one query per generation plus one for the follow modes, not per node.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { forkFollowModes } from "../core/fork_follow.ts";
17 import { canRead, forkParentFor, visibleForksByParent, type Repo } from "../core/repos.ts";
18 import type { User } from "../core/users.ts";
19
20 /** Generations below the root shown when the caller does not say. */
21 export const DEFAULT_DEPTH = 4;
22 /** The most generations below the root any call may ask for. */
23 export const MAX_DEPTH = 8;
24 /** The most nodes any call returns; past it `truncated` is true. A page of a tree, not a crawl. */
25 export const MAX_NODES = 200;
26
27 /** How a node follows its parent: `offers` is the stored `tell-me`; the root of what is visible follows nothing. */
28 export type TreeFollow = "take-updates" | "offers" | "autonomous";
29
30 export interface TreeNode {
31 readonly repo: string;
32 readonly parent: string | null;
33 /** Generations below the root; the root is 0. */
34 readonly depth: number;
35 readonly follow: TreeFollow;
36 }
37
38 export interface ForkTree {
39 readonly root: string;
40 readonly nodes: TreeNode[];
41 readonly truncated: boolean;
42 }
43
44 const nameOf = (r: Repo): string => `${r.ownerName}/${r.name}`;
45
46 const clamp = (v: number | undefined, fallback: number, max: number): number =>
47 v === undefined || !Number.isFinite(v) ? fallback : Math.min(max, Math.max(1, Math.floor(v)));
48
49 /** The tree around `repo`, or undefined when the caller may not read `repo` itself. */
50 export function forkTree(
51 db: Database,
52 repo: Repo,
53 user: User | undefined,
54 opts: { depth?: number; limit?: number } = {},
55 ): ForkTree | undefined {
56 if (!canRead(db, repo, user)) return undefined;
57 const depth = clamp(opts.depth, DEFAULT_DEPTH, MAX_DEPTH);
58 const limit = clamp(opts.limit, MAX_NODES, MAX_NODES);
59
60 // Climb to the highest readable ancestor; `keep` is the path from there to `repo`.
61 let root = repo;
62 const keep = new Set<string>([repo.id]);
63 for (;;) {
64 const up = forkParentFor(db, root, user);
65 if (up === undefined || keep.has(up.id)) break;
66 keep.add(up.id);
67 root = up;
68 }
69 // Generations to show: as asked, but never short of the repo itself.
70 const reach = Math.max(depth, keep.size - 1);
71
72 const rows: { repo: Repo; parent: Repo | null; depth: number }[] = [{ repo: root, parent: null, depth: 0 }];
73 let truncated = false;
74 const visited = new Set<string>([root.id]);
75 let level: Repo[] = [root];
76 for (let d = 1; d <= reach + 1 && level.length > 0; d++) {
77 const by = visibleForksByParent(db, level, user);
78 // One generation past the last shown is read only to know more exists.
79 if (d > reach) {
80 truncated = truncated || [...by.values()].some((kids) => kids.some((k) => !visited.has(k.id)));
81 break;
82 }
83 const next: Repo[] = [];
84 for (const parent of level) {
85 for (const child of by.get(parent.id) ?? []) {
86 if (visited.has(child.id)) continue;
87 // A node on the path to `repo` is never cut: it is the one asked about.
88 if (rows.length >= limit && !keep.has(child.id)) {
89 truncated = true;
90 continue;
91 }
92 visited.add(child.id);
93 rows.push({ repo: child, parent, depth: d });
94 next.push(child);
95 }
96 }
97 level = next;
98 }
99 const modes = forkFollowModes(db, rows.slice(1).map((r) => r.repo.id));
100 const nodes: TreeNode[] = rows.map((r) => ({
101 repo: nameOf(r.repo),
102 parent: r.parent === null ? null : nameOf(r.parent),
103 depth: r.depth,
104 follow: r.parent === null ? "autonomous" : modes.get(r.repo.id) === "take-updates" ? "take-updates" : "offers",
105 }));
106 return { root: nameOf(root), nodes, truncated };
107 }
src/core/fork_follow.ts +11
@@ -13,3 +13,14 @@ export function setForkFollowMode(db: Database, repoId: string, mode: FollowMode
1313 "INSERT INTO fork_follow (repo_id, mode) VALUES (?1, ?2) ON CONFLICT(repo_id) DO UPDATE SET mode = excluded.mode",
1414 ).run(repoId, mode);
1515 }
16
17 /** The stored modes of these repos in one query; a repo with none stored is absent (it follows `DEFAULT_MODE`). */
18 export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<string, FollowMode> {
19 if (repoIds.length === 0) return new Map();
20 const rows = db
21 .query<{ repo_id: string; mode: FollowMode }, string[]>(
22 `SELECT repo_id, mode FROM fork_follow WHERE repo_id IN (${repoIds.map(() => "?").join(",")})`,
23 )
24 .all(...repoIds);
25 return new Map(rows.map((r) => [r.repo_id, r.mode]));
26 }
src/core/repos.ts +17 −3
@@ -714,14 +714,28 @@ const forksOfMany = (db: Database, ids: readonly string[]): Repo[] =>
714714 .map(toRepo);
715715
716716 /** The visible forks of these parents, grouped by parent id: one query. */
717 function visibleForksByParent(
717 export function visibleForksByParent(
718718 db: Database,
719719 parents: readonly Repo[],
720720 viewer: User | undefined,
721721 ): Map<string, Repo[]> {
722722 const byParent = new Map<string, Repo[]>();
723 for (const fork of forksOfMany(db, parents.map((p) => p.id))) {
724 if (fork.forkParentId === null || !canRead(db, fork, viewer)) continue;
723 const forks = forksOfMany(db, parents.map((p) => p.id));
724 // One query says which owners stand; a public repo of one is readable by
725 // everyone (the first two rules of `canRead`), so only the rest ask again.
726 const standing = new Set(
727 forks.length === 0
728 ? []
729 : db
730 .query<{ id: string }, string[]>(
731 `SELECT repo.id AS id FROM repo WHERE repo.id IN (${forks.map(() => "?").join(",")}) AND ${OWNER_STANDING}`,
732 )
733 .all(...forks.map((f) => f.id))
734 .map((r) => r.id),
735 );
736 for (const fork of forks) {
737 const open = fork.visibility === "public" && standing.has(fork.id);
738 if (fork.forkParentId === null || !(open || canRead(db, fork, viewer))) continue;
725739 const siblings = byParent.get(fork.forkParentId) ?? [];
726740 siblings.push(fork);
727741 byParent.set(fork.forkParentId, siblings);
src/http/mcp/tools/fork_tree.ts added +28
@@ -0,0 +1,28 @@
1 import { forkTree } from "../../../actions/fork_tree.ts";

Only an MCP door exists. Add a src/tui/ screen using forkTree and docs/features/fork-tree.md. Run test/mcp-size.test.ts for the tools/list growth.

2 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 /** The whole tree a repo sits in, as the caller may read it. */
5 export const tool: Tool = {
6 name: "fork_tree",
7 doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates` or `offers`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown",
8 description: "The fork tree around a repo: ancestors, siblings and descendants you may read.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 depth: { type: "integer", minimum: 1, maximum: 8 },
14 limit: { type: "integer", minimum: 1, maximum: 200 },
15 },
16 required: ["repo"],
17 },
18 writes: false,
19 async run(ctx, user, args) {
20 const repo = repoArg(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const opts: { depth?: number; limit?: number } = {};
23 if (typeof args["depth"] === "number") opts.depth = args["depth"];
24 if (typeof args["limit"] === "number") opts.limit = args["limit"];
25 const tree = forkTree(ctx.app.db, repo, user, opts);
26 return good(tree ?? { root: null, nodes: [], truncated: false });
27 },
28 };
src/http/mcp/tools/index.ts +1
@@ -72,6 +72,7 @@ export { tool as forge_proposals } from "./forge_proposals.ts";
7272 export { tool as forge_view } from "./forge_view.ts";
7373 export { tool as forges } from "./forges.ts";
7474 export { tool as fork } from "./fork.ts";
75 export { tool as fork_tree } from "./fork_tree.ts";
7576 export { tool as forks } from "./forks.ts";
7677 export { tool as front_page } from "./front_page.ts";
7778 export { tool as health } from "./health.ts";
src/tui/forge.ts +7
@@ -17,6 +17,7 @@ import { sourceFor } from "../view/source.ts";
1717 import { fleetCommand } from "./screens/fleet.ts";
1818 import { dataCommand } from "./screens/data.ts";
1919 import { packsCommand } from "./screens/packs.ts";
20 import { forkTreeScreen } from "./screens/fork_tree.ts";
2021 import { roadmapScreen } from "./screens/roadmap.ts";
2122 import { App, type Ctx, type Screen } from "./app/app.ts";
2223 import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts";
@@ -128,6 +129,12 @@ const REPO_VERBS: readonly {
128129 title: "Held by the Later gate",
129130 open: (f, ctx, repo) => heldScreen(f, ctx, repo),
130131 },
132 {
133 // The ancestors, siblings and descendants of a repository the person may read.
134 id: "fork-tree",
135 title: "Fork tree of a repository",
136 open: (f, ctx, repo) => forkTreeScreen(f, ctx, repo),
137 },
131138 {
132139 id: "roadmap",
133140 title: "Roadmap of a repository",
src/tui/screens/fork_tree.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The fork tree in the terminal: the same reader the MCP `fork_tree` tool
3 * calls (`actions/fork_tree.ts`), drawn as indented lines. It gates exactly as
4 * the tool does: a repo the session's person may not read has no tree.
5 */
6
7 import { forkTree, type ForkTree } from "../../actions/fork_tree.ts";
8 import type { Repo } from "../../core/repos.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import type { Session } from "../session.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import { Scroll } from "./common.ts";
15
16 /** The tree as text lines, `asked` marked with `*`; two spaces per generation. */
17 export function treeLines(tree: ForkTree | undefined, asked: string, width = 80): string[] {
18 if (tree === undefined) return ["Nothing to show."];
19 const lines = tree.nodes.map((n) => {
20 const follow = n.follow === "autonomous" ? "" : ` ${n.follow}`;
21 return truncate(`${" ".repeat(n.depth)}${n.repo === asked ? "* " : ""}${n.repo}${follow}`, width);
22 });
23 if (tree.truncated) lines.push("", "More forks exist than are shown.");
24 return lines;
25 }
26
27 export function forkTreeScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
28 const scroll = new Scroll();
29 let lines: string[] = [];
30 return {
31 title: `Fork tree · ${repo.ownerName}/${repo.name}`,
32 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
33 render(area, s) {
34 const c = ctx.theme.c;
35 lines = treeLines(forkTree(f.db, repo, f.user), `${repo.ownerName}/${repo.name}`, area.w - 4);
36 const from = scroll.window(area.h, lines.length);
37 for (let i = 0; i < area.h; i++) {
38 const line = lines[from + i];
39 if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w);
40 }
41 },
42 key(e) {
43 if (e.key === "esc" || e.key === "q") {
44 ctx.pop();
45 return true;
46 }
47 return scroll.key(e, lines.length);
48 },
49 };
50 }
test/fork-tree-reader.test.ts added +153
@@ -0,0 +1,153 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { MAX_NODES, forkTree } from "../src/actions/fork_tree.ts";
6 import { createRepo, forkRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { setForkFollowMode } from "../src/core/fork_follow.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { promote } from "./helpers/members.ts";
12 import { once } from "./helpers/statements.ts";
13 import { expectWork } from "./helpers/work.ts";
14
15 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
16 if (!r.ok) throw new Error(JSON.stringify(r.error));
17 return r.value;
18 };
19
20 let dir: string;
21 let app: App;
22 const users: Record<string, User> = {};
23 let forge: Repo;
24 let org: Repo;
25 let person: Repo;
26 let hidden: Repo;
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-fork-tree-reader-"));
30 app = createApp({ dataDir: dir, port: 0 });
31 for (const name of ["alice", "bob", "carol", "dan", "erin"]) {
32 users[name] = must(
33 await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: name,
37 email: `${name}@f.dev`,
38 password: "a-good-passphrase",
39 adminIfFirst: name === "alice",
40 }),
41 );
42 promote(app.db, users[name]!);
43 }
44 forge = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" }));
45 org = must(await forkRepo(app, users["bob"]!, forge));
46 must(await forkRepo(app, users["carol"]!, forge));
47 person = must(await forkRepo(app, users["dan"]!, org));
48 hidden = must(await forkRepo(app, users["erin"]!, org, "secret", { visibility: "private" }));
49 must(await forkRepo(app, users["erin"]!, hidden, "under", { visibility: "public" }));
50 setForkFollowMode(app.db, person.id, "take-updates");
51 }, 60_000);
52
53 afterAll(async () => {
54 app.close();
55 await rm(dir, { recursive: true, force: true });
56 });
57
58 const names = (t: { nodes: { repo: string }[] }) => t.nodes.map((n) => n.repo);
59
60 describe("forkTree", () => {
61 test("read from the middle node: root, siblings, descendants, with parent, depth and follow", () => {
62 const t = forkTree(app.db, org, users["alice"]!)!;
63 expect(t.root).toBe("alice/app");
64 expect(t.truncated).toBe(false);
65 const by = Object.fromEntries(t.nodes.map((n) => [n.repo, n]));
66 expect(by["alice/app"]).toEqual({ repo: "alice/app", parent: null, depth: 0, follow: "autonomous" });
67 expect(by["bob/app"]).toEqual({ repo: "bob/app", parent: "alice/app", depth: 1, follow: "offers" });
68 expect(by["carol/app"]!.parent).toBe("alice/app");
69 expect(by["dan/app"]).toEqual({ repo: "dan/app", parent: "bob/app", depth: 2, follow: "take-updates" });
70 });
71
72 test("a stranger and the owner of a private fork get different lists, and the stranger's has no trace", () => {
73 const owner = names(forkTree(app.db, org, users["erin"]!)!);
74 const stranger = forkTree(app.db, org, users["dan"]!)!;
75 const anon = forkTree(app.db, org, undefined)!;
76 expect(owner).toContain("erin/secret");
77 expect(owner).toContain("erin/under");
78 for (const t of [stranger, anon]) {
79 const json = JSON.stringify(t);
80 expect(json).not.toContain("secret");
81 expect(json).not.toContain("under");
82 expect(names(t)).not.toEqual(owner);
83 }
84 });
85
86 test("a repo the caller cannot read yields nothing", () => {
87 expect(forkTree(app.db, hidden, users["dan"]!)).toBeUndefined();
88 });
89
90 test("depth limits generations and says it truncated", () => {
91 const t = forkTree(app.db, forge, users["alice"]!, { depth: 1 })!;
92 expect(t.nodes.every((n) => n.depth <= 1)).toBe(true);
93 expect(t.truncated).toBe(true);
94 expect(forkTree(app.db, forge, users["alice"]!, { depth: 99 })!.nodes.length).toBeGreaterThan(4);
95 });
96
97 test("a limit cuts the nodes and says so; the cap is 200", () => {
98 const t = forkTree(app.db, forge, users["alice"]!, { limit: 3 })!;
99 expect(t.nodes.length).toBe(3);
100 expect(t.truncated).toBe(true);
101 expect(MAX_NODES).toBe(200);
102 });
103
104 test("the repo asked about is in nodes even when it lies deeper than depth", async () => {
105 let at = forge;
106 for (let i = 0; i < 5; i++) {
107 const u = must(
108 await createUser(app.db, { origin: "seed", trusted: true, username: `deep${i}`, email: `deep${i}@f.dev`, password: "a-good-passphrase" }),
109 );
110 promote(app.db, u);
111 at = must(await forkRepo(app, u, at));
112 }
113 const t = forkTree(app.db, at, users["alice"]!, { depth: 1 })!;
114 const me = t.nodes.find((n) => n.repo === "deep4/app")!;
115 expect(me.depth).toBe(5);
116 expect(t.nodes.some((n) => n.repo === "deep3/app")).toBe(true);
117 const tiny = forkTree(app.db, at, users["alice"]!, { limit: 1 })!;
118 expect(tiny.nodes.some((n) => n.repo === "deep4/app")).toBe(true);
119 });
120
121 test("200 forks: capped, truncated, and a warm call stays within a counted number of statements", async () => {
122 const cols = (
123 app.db.query<{ name: string }, []>("PRAGMA table_info(repo)").all()
124 ).map((c) => c.name);
125 const list = (f: (c: string) => string) => cols.map(f).join(",");
126 const base = forge.id;
127 for (let i = 0; i < 205; i++) {
128 const sub = (c: string) =>
129 c === "id" ? `'bulk${i}'` : c === "name" ? `'bulk${i}'` : c === "fork_parent_id" ? `'${base}'` : c;
130 app.db.query(`INSERT INTO repo (${cols.join(",")}) SELECT ${list(sub)} FROM repo WHERE id = ?1`).run(base);
131 }
132 forkTree(app.db, forge, users["bob"]!);
133 let t!: NonNullable<ReturnType<typeof forkTree>>;
134 const n = await once(app.db, () => {
135 t = forkTree(app.db, forge, users["bob"]!)!;
136 });
137 expect(t.nodes.length).toBe(200);
138 expect(t.truncated).toBe(true);
139 // 205 forks: a handful of statements per generation, not per node (was 408).
140 expectWork(n, { max: 25 });
141 });
142 });
143
144 describe("the terminal screen", () => {
145 test("draws the tree from the reader, marks the asked repo, and shows nothing for an unreadable one", async () => {
146 const { treeLines } = await import("../src/tui/screens/fork_tree.ts");
147 const lines = treeLines(forkTree(app.db, org, users["dan"]!), "bob/app");
148 expect(lines).toContain("alice/app");
149 expect(lines).toContain(" * bob/app offers");
150 expect(lines.join("\n")).not.toContain("secret");
151 expect(treeLines(forkTree(app.db, hidden, users["dan"]!), "erin/secret")).toEqual(["Nothing to show."]);
152 });
153 });
test/mcp.test.ts +1
@@ -295,6 +295,7 @@ describe("the forge over MCP", () => {
295295 "follow_mode",
296296 "fork",
297297 "forks",
298 "fork_tree",
298299 "upstream",
299300 "propose_to_parent_forge",
300301 "forge_proposals",
test/palette-roster.test.ts +4
@@ -57,6 +57,7 @@ test("a site-admin person is offered the whole roster", () => {
5757 ["repo", "Open a repository", "owner/name"],
5858 ["issues", "Issues in a repository", "owner/name"],
5959 ["held", "Held by the Later gate", "owner/name"],
60 ["fork-tree", "Fork tree of a repository", "owner/name"],
6061 ["roadmap", "Roadmap of a repository", "owner/name"],
6162 ["pulls", "Pull requests in a repository", "owner/name"],
6263 ["milestones", "Milestones in a repository", "owner/name"],
@@ -174,6 +175,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
174175 ["repo", "Open a repository", "owner/name"],
175176 ["issues", "Issues in a repository", "owner/name"],
176177 ["held", "Held by the Later gate", "owner/name"],
178 ["fork-tree", "Fork tree of a repository", "owner/name"],
177179 ["roadmap", "Roadmap of a repository", "owner/name"],
178180 ["pulls", "Pull requests in a repository", "owner/name"],
179181 ["milestones", "Milestones in a repository", "owner/name"],
@@ -247,6 +249,7 @@ test("a member person is offered the signed-in roster without the admin entries"
247249 ["repo", "Open a repository", "owner/name"],
248250 ["issues", "Issues in a repository", "owner/name"],
249251 ["held", "Held by the Later gate", "owner/name"],
252 ["fork-tree", "Fork tree of a repository", "owner/name"],
250253 ["roadmap", "Roadmap of a repository", "owner/name"],
251254 ["pulls", "Pull requests in a repository", "owner/name"],
252255 ["milestones", "Milestones in a repository", "owner/name"],
@@ -313,6 +316,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
313316 ["repo", "Open a repository", "owner/name"],
314317 ["issues", "Issues in a repository", "owner/name"],
315318 ["held", "Held by the Later gate", "owner/name"],
319 ["fork-tree", "Fork tree of a repository", "owner/name"],
316320 ["roadmap", "Roadmap of a repository", "owner/name"],
317321 ["pulls", "Pull requests in a repository", "owner/name"],
318322 ["milestones", "Milestones in a repository", "owner/name"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs