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

GET …/labels and MCP repo_labels list say each label's open count, from the same query as the labels page #8110

Merged opened this

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

Merged

mason/forge:mason/7867-get-labels-and-mcp-repo-labels-list-say → main 1 commit fork

  • Merged. mason/7867-get-labels-and-mcp-repo-labels-list-say was merged into main as 1cc206e.

Review

Sign in to review this change.
2 files changed +108 −1
src/contract/openapi/routes/getApiV1ReposOwnerNameLabels.ts +1 −1
@@ -4,7 +4,7 @@ export const route: Route = {
44 method: "get",
55 path: `${REPO}/labels`,
66 summary:
7 "List a repository's labels by name, each { id, name, color }; a private repository needs a token; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor",
7 "List a repository's labels by name, each { id, name, color } and, for a repository writer, open (its open issues and pulls, as on the labels page; a reader is told none, since sealed ones are counted); a private repository needs a token; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor",
88 auth: false,
99 query: { limit: "integer", cursor: "string" },
1010 refusals: {
test/api-labels-counts.test.ts added +107
@@ -0,0 +1,107 @@
1 /** `GET …/labels` and MCP `repo_labels` list tell a writer each label's open count, and a reader none. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { createIssue, createLabel, openLabelCounts, setIssueLabels } from "../src/core/issues.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12
13 describe("label open counts over the API and MCP", () => {
14 let dataDir: string;
15 let app: App;
16 let servers: Servers;
17 let admin: string;
18 let bob: string;
19 let sealedOnly: string;
20 let both: string;
21 let n = 1;
22
23 const api = async (token: string) => {
24 const res = await fetch(`${servers.url}/api/v1/repos/alice/game/labels`, {
25 headers: { authorization: `Bearer ${token}` },
26 });
27 return ((await res.json()) as any).items as Array<{ id: string; name: string; open?: number }>;
28 };
29 const mcp = async (token: string) => {
30 const res = await fetch(`${servers.url}/mcp`, {
31 method: "POST",
32 headers: {
33 "content-type": "application/json",
34 accept: "application/json, text/event-stream",
35 authorization: `Bearer ${token}`,
36 },
37 body: JSON.stringify({
38 jsonrpc: "2.0",
39 id: n++,
40 method: "tools/call",
41 params: { name: "repo_labels", arguments: { repo: "alice/game" } },
42 }),
43 });
44 const body: any = await res.json();
45 return JSON.parse(body.result.content[0].text).items as Array<{ id: string; name: string; open?: number }>;
46 };
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-api-labels-counts-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 const mk = async (username: string, first = false) => {
53 const u = await createUser(app.db, {
54 origin: "seed", trusted: true, username,
55 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
56 });
57 if (!u.ok) throw new Error(u.error);
58 return u.value;
59 };
60 const alice = await mk("alice", true);
61 const b = await mk("bob");
62 admin = createToken(app.db, alice.id, "a", "admin").token;
63 bob = createToken(app.db, b.id, "b", "admin").token;
64 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
65 if (!made.ok) throw new Error(String(made.error));
66 const mkLabel = (name: string) => {
67 const l = createLabel(app.db, made.value.id, name, "d73a4a");
68 if (!l.ok) throw new Error(l.error);
69 return l.value.id;
70 };
71 const mkIssue = (title: string, sealed: boolean, labels: string[], close = false) => {
72 const i = createIssue(app.db, { repoId: made.value.id, authorId: alice.id, title, sealed });
73 if (!i.ok) throw new Error(i.error);
74 setIssueLabels(app.db, i.value.id, labels);
75 if (close) app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(i.value.id);
76 };
77 both = mkLabel("both");
78 sealedOnly = mkLabel("sealed-only");
79 mkLabel("unused");
80 mkIssue("open", false, [both]);
81 mkIssue("closed", false, [both], true);
82 mkIssue("secret", true, [both, sealedOnly]);
83 }, 30_000);
84
85 afterAll(async () => {
86 servers.stop();
87 app.close();
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 test("a writer's API and MCP lists carry the same counts as openLabelCounts", async () => {
92 const viaApi = await api(admin);
93 const viaMcp = await mcp(admin);
94 expect(viaMcp).toEqual(viaApi);
95 const counts = openLabelCounts(app.db, app.db.query<{ id: string }, []>("SELECT id FROM repo").get()!.id);
96 for (const l of viaApi) expect(l.open).toBe(counts.get(l.id) ?? 0);
97 const by = Object.fromEntries(viaApi.map((l) => [l.name, l.open]));
98 expect(by).toEqual({ both: 2, "sealed-only": 1, unused: 0 });
99 });
100
101 test("a reader who cannot see sealed issues is told no count at all", async () => {
102 for (const items of [await api(bob), await mcp(bob)]) {
103 expect(items.length).toBe(3);
104 for (const l of items) expect("open" in l).toBe(false);
105 }
106 });
107 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs