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

PATCH /api/v1/repos/{owner}/{name}/labels/{id} renames or recolours a label, as MCP repo_labels update and the labels page already do #7878

Merged opened this

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

Merged

scout/forge:scout/7865-patch-api-v1-repos-owner-name-labels-id → main 3 commits fork

  • Merged. scout/7865-patch-api-v1-repos-owner-name-labels-id was merged into main as 7fdce41.

Review

Sign in to review this change.
6 files changed +148 −4
docs/MCP.md +1 −1
@@ -143,7 +143,7 @@ for a tunnel and a forge that sleeps.
143143 | `release_keeper` | `{}`: what the release keeper (#7557) did: its releases newest first (version, stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases | |
144144 | `releases` | `{shipped: true}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows. `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
145145 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
146 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
146 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
147147 | `repo_settings` | `{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
148148 | `report` | `{ repo, title, body?, page?, agent? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text. With a token the report carries your account; `agent` without one is shown as a claim | yes |
149149 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` with `filedBy` (`signed`, `claimed` or `anonymous`) and `untrusted` unless a writer filed it, and `byAgent` counts; with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
src/contract/openapi/routes/index.ts +1
@@ -164,6 +164,7 @@ export { route as patchApiV1ForgeFollowing } from "./patchApiV1ForgeFollowing.ts
164164 export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts";
165165 export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts";
166166 export { route as patchApiV1ReposOwnerNameIssuesNumber } from "./patchApiV1ReposOwnerNameIssuesNumber.ts";
167 export { route as patchApiV1ReposOwnerNameLabelsId } from "./patchApiV1ReposOwnerNameLabelsId.ts";
167168 export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts";
168169 export { route as patchApiV1User } from "./patchApiV1User.ts";
169170 export { route as postApiV1AdminAccountsUsernameCeiling } from "./postApiV1AdminAccountsUsernameCeiling.ts";
src/contract/openapi/routes/patchApiV1ReposOwnerNameLabelsId.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "patch", path: `${REPO}/labels/{id}`, summary: "Rename or recolour a label by id (admin scope, repository admin); body { name?, color? } with at least one; answers the label { id, name, color } and keeps its id; 404 when the repository has no such label, 422 for a taken name or a colour that is not six hex digits", auth: true, body: { name: "string", color: "string" }, refusals: { forbidden: "a token below admin scope" } };
src/http/api/labels.ts +38 −2
@@ -1,10 +1,11 @@
11 /**
22 * A repository's labels over the API: `GET` and `POST`
3 * `/api/v1/repos/:owner/:name/labels`, and `DELETE` `…/labels/:id`. Moved
3 * `/api/v1/repos/:owner/:name/labels`, and `PATCH` and `DELETE` `…/labels/:id`. Moved
44 * out of `api.ts` (#6303); the rules live in `actions/repos.ts`.
55 */
66
7 import { createLabel, deleteLabel } from "../../actions/repos.ts";
7 import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts";
8 import { LABEL_WORDS } from "../../actions/label_words.ts";
89 import { listLabels } from "../../core/issues.ts";
910 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1011 import { loadRepo, pageOf, pageParams } from "../api.ts";
@@ -40,6 +41,41 @@ export function labelsRoutes(router: Router<Handler>): void {
4041 return json(created.value, 201);
4142 });
4243
44 router.add("PATCH", "/api/v1/repos/:owner/:name/labels/:id", async (ctx) => {
45 const denied = requireScope(ctx, "admin");
46 if (denied) return denied;
47 const repo = loadRepo(ctx);
48 if (!repo) return notFound();
49 const input = await body(ctx.request);
50 if (input.name === undefined && input.color === undefined)
51 return json({ error: "invalid", message: "update names a new name, a new color, or both" }, 422);
52 if (
53 (input.name !== undefined && typeof input.name !== "string") ||
54 (input.color !== undefined && typeof input.color !== "string")
55 )
56 return json({ error: "invalid", message: LABEL_WORDS.shape }, 422);
57 const id = ctx.params.id!;
58 const current = listLabels(ctx.app.db, repo.id).find((l) => l.id === id);
59 if (!current) return notFound();
60 const updated = updateLabel(
61 ctx.app,
62 ctx.auth.user,
63 repo,
64 id,
65 input.name === undefined ? current.name : (input.name as string),
66 input.color === undefined ? current.color : (input.color as string),
67 );
68 if (!updated.ok) {
69 if (updated.error === "invalid" || updated.error === "taken")
70 return json(
71 { error: updated.error, message: updated.error === "taken" ? LABEL_WORDS.taken : LABEL_WORDS.shape },
72 422,
73 );
74 return respondAccess(updated.error, 422);
75 }
76 return json(updated.value);
77 });
78
4379 router.add("DELETE", "/api/v1/repos/:owner/:name/labels/:id", (ctx) => {
4480 const denied = requireScope(ctx, "admin");
4581 if (denied) return denied;
src/http/mcp/tools/repo_labels.ts +1 −1
@@ -7,7 +7,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
77
88 export const tool: Tool = {
99 name: "repo_labels",
10 doc: "`{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin",
10 doc: "`{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin",
1111 description:
1212 "Repo labels: list (default), create, update, remove (admin token).",
1313 inputSchema: {
test/api-labels-update.test.ts added +104
@@ -0,0 +1,104 @@
1 /** `PATCH /api/v1/repos/:owner/:name/labels/:id` renames or recolours a label, keeping its id. */
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 { LABEL_WORDS } from "../src/actions/label_words.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { createIssue, labelsFor, setIssueLabels } from "../src/core/issues.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13
14 describe("PATCH …/labels/:id", () => {
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let admin: string;
19 let rw: string;
20 let bob: string;
21 let issueId: string;
22 const base = () => `${servers.url}/api/v1/repos/alice/game/labels`;
23 const patch = (token: string, id: string, payload: unknown) =>
24 fetch(`${base()}/${id}`, {
25 method: "PATCH",
26 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
27 body: JSON.stringify(payload),
28 });
29 const create = async (name: string, color = "d73a4a") => {
30 const res = await fetch(base(), {
31 method: "POST",
32 headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
33 body: JSON.stringify({ name, color }),
34 });
35 return (await res.json()) as { id: string; name: string; color: string };
36 };
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-api-labels-update-"));
40 app = createApp({ dataDir, port: 0 });
41 servers = await serve(app);
42 const mk = async (username: string, first = false) => {
43 const u = await createUser(app.db, {
44 origin: "seed", trusted: true, username,
45 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
46 });
47 if (!u.ok) throw new Error(u.error);
48 return u.value;
49 };
50 const alice = await mk("alice", true);
51 const b = await mk("bob");
52 admin = createToken(app.db, alice.id, "a", "admin").token;
53 rw = createToken(app.db, alice.id, "rw", "rw").token;
54 bob = createToken(app.db, b.id, "b", "admin").token;
55 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
56 if (!made.ok) throw new Error(String(made.error));
57 const issue = createIssue(app.db, { repoId: made.value.id, authorId: alice.id, title: "t" });
58 if (!issue.ok) throw new Error(issue.error);
59 issueId = issue.value.id;
60 }, 30_000);
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 test("renames, recolours, and an issue still carries the label", async () => {
69 const label = await create("bgu");
70 setIssueLabels(app.db, issueId, [label.id]);
71 const renamed = await patch(admin, label.id, { name: "bug" });
72 expect(renamed.status).toBe(200);
73 expect(await renamed.json()).toEqual({ id: label.id, name: "bug", color: "d73a4a" });
74 const recoloured = await patch(admin, label.id, { color: "0E8A16" });
75 expect(recoloured.status).toBe(200);
76 expect(await recoloured.json()).toEqual({ id: label.id, name: "bug", color: "0e8a16" });
77 expect(labelsFor(app.db, issueId)).toEqual([{ id: label.id, name: "bug", color: "0e8a16" }]);
78 });
79
80 test("only an admin token may", async () => {
81 const [label] = ((await (await fetch(base())).json()) as any).items;
82 expect((await patch(rw, label.id, { name: "x" })).status).toBe(403);
83 expect((await patch(bob, label.id, { name: "x" })).status).toBe(404);
84 expect((await fetch(base() + "/" + label.id, { method: "PATCH", body: "{}" })).status).toBe(401);
85 expect((await (await fetch(base())).json() as any).items[0].name).toBe("bug");
86 });
87
88 test("an unknown id is 404", async () => {
89 expect((await patch(admin, "nope", { name: "x" })).status).toBe(404);
90 });
91
92 test("nothing to change, a taken name and a bad colour are 422", async () => {
93 const other = await create("ui", "6b7280");
94 const none = await patch(admin, other.id, {});
95 expect(none.status).toBe(422);
96 const taken = await patch(admin, other.id, { name: "BUG" });
97 expect(taken.status).toBe(422);
98 expect(await taken.json()).toEqual({ error: "taken", message: LABEL_WORDS.taken });
99 const bad = await patch(admin, other.id, { color: "red" });
100 expect(bad.status).toBe(422);
101 expect(await bad.json()).toEqual({ error: "invalid", message: LABEL_WORDS.shape });
102 expect((await patch(admin, other.id, { name: 5 })).status).toBe(422);
103 });
104 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs