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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7865-patch-api-v1-repos-owner-name-labels-id → main 3 commits fork
6 files
changed +148
−4
docs/MCP.md +1 −1
| @@ -143,7 +143,7 @@ for a tunnel and a forge that sleeps. | ||
| 143 | 143 | | `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 | | |
| 144 | 144 | | `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 | | |
| 145 | 145 | | `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 | | |
| 147 | 147 | | `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 | |
| 148 | 148 | | `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 | |
| 149 | 149 | | `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 | ||
| 164 | 164 | export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts"; |
| 165 | 165 | export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts"; |
| 166 | 166 | export { route as patchApiV1ReposOwnerNameIssuesNumber } from "./patchApiV1ReposOwnerNameIssuesNumber.ts"; |
| 167 | export { route as patchApiV1ReposOwnerNameLabelsId } from "./patchApiV1ReposOwnerNameLabelsId.ts"; | |
| 167 | 168 | export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts"; |
| 168 | 169 | export { route as patchApiV1User } from "./patchApiV1User.ts"; |
| 169 | 170 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 4 | 4 | * out of `api.ts` (#6303); the rules live in `actions/repos.ts`. |
| 5 | 5 | */ |
| 6 | 6 | |
| 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"; | |
| 8 | 9 | import { listLabels } from "../../core/issues.ts"; |
| 9 | 10 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 10 | 11 | import { loadRepo, pageOf, pageParams } from "../api.ts"; |
| @@ -40,6 +41,41 @@ export function labelsRoutes(router: Router<Handler>): void { | ||
| 40 | 41 | return json(created.value, 201); |
| 41 | 42 | }); |
| 42 | 43 | |
| 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 | ||
| 43 | 79 | router.add("DELETE", "/api/v1/repos/:owner/:name/labels/:id", (ctx) => { |
| 44 | 80 | const denied = requireScope(ctx, "admin"); |
| 45 | 81 | 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" | ||
| 7 | 7 | |
| 8 | 8 | export const tool: Tool = { |
| 9 | 9 | 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", | |
| 11 | 11 | description: |
| 12 | 12 | "Repo labels: list (default), create, update, remove (admin token).", |
| 13 | 13 | 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 | }); |
new above
offline