diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts index 61721b5d..7fa757f3 100644 --- a/modules/gitea/guard.ts +++ b/modules/gitea/guard.ts @@ -104,6 +104,12 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap const str = (v: unknown) => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined); return { contents: { owner: contents[1], repo: contents[2], branch: str(b.branch), newBranch: str(b.new_branch) } }; } + // A pull request's base changed through the escape hatch would merge what was checked against one branch into + // another; gitea_update_pull_request changes a title and a body, never a base. + if (m === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/pulls\/\d+$/.test(path) && body && typeof body === "object" && + "base" in (body as Record)) { + return { refused: `${m} ${path} changing a pull request's base is refused (novox/hq ADR 0266): its checks were judged against the base it had. Open a new pull request. Nothing was sent` }; + } if (AUTHOR_WRITES.some((w) => w.methods.includes(m) && w.path.test(path))) return { allowed: true }; return { refused: `${m} ${path} is not a write gitea_api makes (novox/hq ADR 0266): it writes only what an author ` + @@ -146,9 +152,25 @@ export function judgeContentsWrite(target: string, defaultBranch: string, rules: return null; } -/** Refuses a protection change that weakens the rule a branch has (novox/hq ADR 0266). */ -export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted): string | null { - if (!before) return null; // A new rule protects what nothing protected. +/** + * Refuses a protection change that weakens what guards a branch (novox/hq ADR 0266): the branch's own rule, or — + * when it has none — every glob rule that covers it, since a rule named for the branch takes the glob's place. + * A rule made new takes the forge's defaults for what it does not say: pushes refused, an administrator's + * override allowed. + */ +export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted, + covering: BranchProtection[] = []): string | null { + if (!before) { + for (const glob of covering) { + const weaker = judgeProtectionChange(glob, { + ...want, + push: want.push ?? false, + blockAdminOverride: want.blockAdminOverride ?? false, + }); + if (weaker) return weaker; + } + return null; // A new rule protects what nothing protected. + } const had = before.enable_status_check ? [...new Set((before.status_check_contexts ?? []).filter(Boolean))] : []; const wanted = new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean)); const dropped = had.filter((c) => !wanted.has(c)); @@ -165,6 +187,36 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro `operator's, at the forge as themselves. Nothing was changed`; } +/** The token's own user's repositories, untouched for this long, are the only ones a tool deletes. */ +export const DELETABLE_AFTER_DAYS = 30; + +/** + * Refuses deleting a repository (novox/hq ADR 0266): deleted and made again under its name, a repository the mesh + * builds from would be one without its protection. A tool deletes only a repository of the token's own user — the + * mesh builds from organisations' and people's repositories, never the tools' own — that no protection rule + * guards, with no commit for DELETABLE_AFTER_DAYS days, and only when confirm names it in full. + */ +export function judgeDelete(fullName: string, confirm: unknown, owner: string, self: string, + rules: BranchProtection[], lastCommit: Date | null, now: Date): string | null { + if (String(confirm ?? "") !== fullName) { + return `confirm must be the repository's full name, ${fullName}, to delete it. Nothing was deleted`; + } + if (owner.toLowerCase() !== self.toLowerCase()) { + return `${fullName} is not a repository of the tools' own user (${self}): a tool deletes only those, never an ` + + `organisation's or a person's — the mesh builds from them (novox/hq ADR 0266). Deleting it is the operator's, at ` + + `the forge as themselves. Nothing was deleted`; + } + if (rules.length) { + return `${fullName} is guarded by the protection rule ${rules.map((r) => r.rule_name ?? r.branch_name).join(", ")}: ` + + `deleting it is the operator's, at the forge as themselves (novox/hq ADR 0266). Nothing was deleted`; + } + if (lastCommit && now.getTime() - lastCommit.getTime() < DELETABLE_AFTER_DAYS * 24 * 3600 * 1000) { + return `${fullName} had a commit on ${lastCommit.toISOString().slice(0, 10)}, within ${DELETABLE_AFTER_DAYS} days: ` + + `a repository in use is not deleted by a tool (novox/hq ADR 0266). Nothing was deleted`; + } + return null; +} + /** The merge methods the forge takes for a merge that is a merge — never `manually-merged`. */ export const MERGE_METHODS = new Set(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 31a04804..02da7e17 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -19,6 +19,7 @@ const state = { sent: [] as Sent[], statuses: [] as { context: string; state: string }[], rules: [] as any[], + lastCommit: "2026-01-01T00:00:00Z", }; const protectedMain = () => ({ rule_name: "main", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true }); @@ -49,6 +50,11 @@ const server = createServer(async (req, res) => { const repo = "/repos/novox/mesh-catalog"; if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" }); if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules); + if (method === "GET" && path === "/user") return json(res, 200, { login: "mesh-admin" }); + if (method === "GET" && path === "/repos/mesh-admin/scratch/branch_protections") return json(res, 200, []); + if (method === "GET" && /^\/repos\/[^/]+\/[^/]+\/commits$/.test(path)) { + return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]); + } if (method === "GET" && path === `${repo}/pulls/7`) { return json(res, 200, { number: 7, title: "x", state: "open", merged: false, head: { ref: "feat/x", sha: "abc1234" }, base: { ref: "main" }, html_url: "http://fake/7" }); @@ -140,10 +146,47 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su assert.equal(sent.body.head_commit_id, "abc1234"); }); -test("gitea_delete_repo refuses a repository a protection rule guards", async () => { - await assert.rejects(tool("gitea_delete_repo").run({ owner: "novox", name: "mesh-catalog", confirm: true }), - /guarded by the protection rule main/); +test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => { + const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm }); + await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/); + await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/); + state.lastCommit = new Date().toISOString(); + await assert.rejects(del("mesh-admin", "scratch", "mesh-admin/scratch"), /within 30 days/); assert.deepEqual(writes(), []); + state.lastCommit = "2026-01-01T00:00:00Z"; + await del("mesh-admin", "scratch", "mesh-admin/scratch"); + assert.deepEqual(writes(), ["DELETE /repos/mesh-admin/scratch"]); +}); + +test("gitea_api sends the path it judged, and refuses moving a pull request's base", async () => { + await tool("gitea_api").run({ path: "/api/v1/repos/novox/mesh-catalog/issues/3/comments/?x=1", method: "POST", body: { body: "hi" } }); + assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments"]); + await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH", + body: { base: "unguarded" } }), /changing a pull request's base/); + await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH", body: { title: "x" } }); + assert.equal(writes().length, 2); +}); + +test("gitea_merge_pull_request merges only into a branch whose protection requires the checks", async () => { + state.rules = []; + await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 }), + /no protection rule requiring the checks guards/); + state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [] }]; + await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 }), + /no protection rule requiring the checks guards/); + assert.deepEqual(writes(), []); +}); + +test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => { + state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true, + status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true }); + const set = (args: Record) => + tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args }); + await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate/); + await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /merge past a status/); + assert.deepEqual(writes(), []); + await set({ status_checks: "mesh/merge-gate", block_admin_override: true }); + assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]); }); test("a repository owner or name is never another path", async () => { diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index c2bf056a..91357c7f 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -14,7 +14,8 @@ import { GiteaClient, type BranchProtection } from "../client.js"; import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { - MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeProtectionChange, + MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, routedPath, + ruleCovers, } from "../guard.js"; /** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */ @@ -94,22 +95,23 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { }, { name: "gitea_delete_repo", - description: "Delete a repository. Destructive and irreversible — requires confirm=true.", + description: "Delete a repository of the tools' own user that no protection rule guards and that had no commit " + + "in 30 days. Destructive and irreversible — confirm must be the repository's full name, owner/name. An " + + "organisation's or a person's repository is the operator's to delete, at the forge (novox/hq ADR 0266).", input: { owner: { type: "string", description: "the repository owner" }, name: { type: "string", description: "the repository name" }, - confirm: { type: "boolean", description: "must be true to actually delete" }, + confirm: { type: "string", description: "the repository's full name, owner/name, to actually delete it" }, }, run: async (args) => { - if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" }; - // A repository a protection rule guards is the mesh's: deleted and made again, it would be one without - // its protection, under the name the mesh builds from (novox/hq ADR 0266). - const guarded = await gitea.branchProtections(String(args.owner), String(args.name)); - if (guarded.length) { - throw new Refused(`${String(args.owner)}/${String(args.name)} is guarded by the protection rule ` + - `${guarded.map((r) => r.rule_name ?? r.branch_name).join(", ")}: deleting it is the operator's, at the forge ` + - `as themselves, never a tool's (novox/hq ADR 0266). Nothing was deleted`); - } + const owner = String(args.owner), name = String(args.name), full = `${owner}/${name}`; + const self = (await gitea.api<{ login?: string }>("/user"))?.login ?? ""; + const commits = await gitea.api>( + `/repos/${owner}/${name}/commits?limit=1`).catch(() => []); + const when = commits?.[0]?.commit?.committer?.date ?? commits?.[0]?.created; + const refused = judgeDelete(full, args.confirm, owner, self, await gitea.branchProtections(owner, name), + when ? new Date(when) : null, new Date()); + if (refused) throw new Refused(refused); await gitea.deleteRepo(String(args.owner), String(args.name)); return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` }; }, @@ -284,6 +286,12 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`); if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`); const rule = await gitea.branchProtection(owner, repo, pull.base); + // Only into a branch whose protection requires the checks: a pull request whose base was moved to a + // branch nothing guards would merge past every check (novox/hq ADR 0266). + if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) { + throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` + + `the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`); + } const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses); const verdict = mergeableUnder(pull.base, rule, statuses); if (verdict.mergeable !== true) { @@ -459,7 +467,9 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { push: args.push === undefined ? undefined : Boolean(args.push), blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override), }; - const weakens = judgeProtectionChange(before, want); + const covering = before ? [] : (await gitea.branchProtections(owner, repo)) + .filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), branch)); + const weakens = judgeProtectionChange(before, want, covering); if (weakens) throw new Refused(weakens); const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, want); return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) }; @@ -633,7 +643,10 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { await gitea.branchProtections(owner, repo)); if (refused) throw new Refused(refused); } - const result = await gitea.api(String(args.path), { + // The path judged is the path sent: normalised, its query kept. + const raw = String(args.path ?? ""); + const query = raw.includes("?") ? raw.slice(raw.indexOf("?")).split("#")[0] : ""; + const result = await gitea.api(routedPath(raw) + query, { method, ...(args.body ? { body: JSON.stringify(args.body) } : {}), });