From 8068cf74a7b8fe3042f0439c2e27c0b876bb5caa Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:44:11 +0100 Subject: [PATCH] gitea: read a repository's settings back in the forge's own form, and take the settings it was missing enable_prune is never in the forge's answer and mirror_interval comes back as a Go duration, so a write that worked failed its read-back. The first is now said as not read back, the second compared as a duration. allow_merge_update, default_update_style and has_code are taken, projects_mode needs has_projects true, and the description names what is left out: the name, the tracker and wiki objects, and the mirror's credentials. --- modules/gitea/protectfull.ts | 44 ++++++++++++++++------ modules/gitea/test/protection-full.test.ts | 26 ++++++++++++- modules/gitea/tools/index.ts | 14 ++++--- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index ee41a55c..9622738f 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -8,7 +8,9 @@ import { TOOLS_ACCOUNT } from "./tools-account.js"; import type { BranchProtection } from "./protection.js"; type Kind = "boolean" | "logins" | "strings" | "number" | "string"; -type Field = { kind: Kind; says: string; parent?: string }; +/** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the + * forge's "8h0m0s"). */ +type Field = { kind: Kind; says: string; parent?: string; unread?: boolean; duration?: boolean }; /** Every field gitea's branch protection API takes on an edit at 1.27.3 (EditBranchProtectionOption, 32 fields), by its * own name, and its kind; rule_name is the branch argument, never a field. A field with a parent is read by the forge @@ -50,24 +52,41 @@ export const PROTECTION_FIELDS: Record = { }; /** The repository settings gitea_repo_settings_set_full sets (gitea's EditRepoOption at 1.27.3), by their own names. Left - * out: name (a rename moves every module's source address) and the tracker and wiki objects. The forge ignores a + * out: name (a rename moves every module's source address), the internal tracker, external tracker and external wiki + * objects, and mirror_username, mirror_password and mirror_token, which are credentials. The forge ignores a * default_branch that does not exist; the read-back below says so. */ export const REPO_FIELDS: Record = Object.fromEntries(([ ["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"], - ["default_branch", "string"], ["has_issues", "boolean"], ["has_wiki", "boolean"], ["has_pull_requests", "boolean"], - ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"], ["has_packages", "boolean"], - ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"], ["allow_merge_commits", "boolean"], - ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"], ["allow_squash_merge", "boolean"], - ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"], ["autodetect_manual_merge", "boolean"], - ["allow_rebase_update", "boolean"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"], + ["default_branch", "string"], ["has_code", "boolean"], ["has_issues", "boolean"], ["has_wiki", "boolean"], + ["has_pull_requests", "boolean"], ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"], + ["has_packages", "boolean"], ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"], + ["allow_merge_commits", "boolean"], ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"], + ["allow_squash_merge", "boolean"], ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"], + ["autodetect_manual_merge", "boolean"], ["allow_rebase_update", "boolean"], ["allow_merge_update", "boolean"], + ["default_update_style", "string"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"], ["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"], -] as [string, Kind][]).map(([name, kind]) => [name, { kind, says: `the repository's ${name.replace(/_/g, " ")}` }])); +] as [string, Kind][]).map(([name, kind]) => [name, { + kind, says: `the repository's ${name.replace(/_/g, " ")}`, + ...(name === "projects_mode" ? { parent: "has_projects", says: "the repository's projects mode; needs has_projects: true in the same call" } : {}), + ...(name === "enable_prune" ? { unread: true, says: "a mirror's prune setting; the forge never answers it, so it is not read back" } : {}), + ...(name === "mirror_interval" ? { duration: true, says: "a mirror's interval, a Go duration such as 8h (the forge answers 8h0m0s)" } : {}), +}])); + +/** A Go duration ("8h", "1h30m", "8h0m0s", "90s") in milliseconds; NaN when it is none. */ +export function durationMs(v: unknown): number { + if (typeof v !== "string" || !/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(v)) return v === "0" ? 0 : NaN; + const unit: Record = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1e3, m: 6e4, h: 3.6e6 }; + let ms = 0; + for (const m of v.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) ms += Number(m[1]) * unit[m[2]]; + return ms; +} /** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it * answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */ -export function unapplied(sent: Record, held: Record): string[] { - const norm = (v: unknown) => Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null); - return Object.keys(sent).filter((k) => norm(sent[k]) !== norm(held[k])) +export function unapplied(sent: Record, held: Record, fields: Record = PROTECTION_FIELDS): string[] { + const norm = (k: string, v: unknown) => fields[k]?.duration ? String(durationMs(v)) + : Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null); + return Object.keys(sent).filter((k) => !fields[k]?.unread && norm(k, sent[k]) !== norm(k, held[k])) .map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`); } @@ -107,6 +126,7 @@ export function fullProtectionBody(args: Record, given: string[ break; case "string": if (typeof v !== "string") throw new Error(`${name} is a string`); + if (field.duration && Number.isNaN(durationMs(v))) throw new Error(`${name} is a duration, such as 8h or 1h30m`); body[name] = v; break; case "strings": diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 99e65356..947b07ab 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -61,8 +61,11 @@ const server = createServer((req, res) => { if (req.method === "GET") return send(200, repoState); if (req.method === "PATCH") { // gitea's updateBasicProperties: a default branch that does not exist is passed over, the call still 200. - const { default_branch, ...rest } = body; + // enable_prune is a mirror's, kept beside the repository and never in its answer; mirror_interval is answered in Go's + // duration form. + const { default_branch, enable_prune, mirror_interval, ...rest } = body; Object.assign(repoState, rest); + if (mirror_interval !== undefined) repoState.mirror_interval = mirror_interval === "8h" ? "8h0m0s" : mirror_interval; if (default_branch !== undefined && branches.includes(default_branch)) repoState.default_branch = default_branch; return send(200, repoState); } @@ -181,3 +184,24 @@ test("every field gitea 1.27.3 edits on a rule is taken: 32, the bypass allowlis assert.equal(Object.keys(PROTECTION_FIELDS).length, 32); for (const f of ["enable_bypass_allowlist", "bypass_allowlist_usernames", "bypass_allowlist_teams"]) assert.ok(f in PROTECTION_FIELDS, f); }); + +// Review round 2: the settings tool's read-back meets the forge's own answer. +test("A: enable_prune, which the forge never answers, is said as not read back, never failed", async () => { + const pruned: any = await setRepo({ enable_prune: true }); + assert.deepEqual(pruned.not_read_back, ["enable_prune"]); +}); + +test("B: mirror_interval is compared as a duration: 8h and the forge's 8h0m0s are one", async () => { + const mirror: any = await setRepo({ mirror_interval: "8h" }); + assert.equal(mirror.after.mirror_interval, "8h0m0s"); + await assert.rejects(setRepo({ mirror_interval: "often" }), /duration/); +}); + +test("C: allow_merge_update, default_update_style and has_code are taken; projects_mode needs has_projects; what is left out is named", async () => { + const more: any = await setRepo({ allow_merge_update: true, default_update_style: "rebase", has_code: true }); + assert.deepEqual(more.set, { allow_merge_update: true, default_update_style: "rebase", has_code: true }); + await assert.rejects(setRepo({ projects_mode: "repo" }), /only when the same call sets has_projects: true/); + await setRepo({ has_projects: true, projects_mode: "repo" }); + const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description; + for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w)); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index f7e56b9e..fdff3104 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -548,9 +548,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " + "description, website, private, template, archived, the issues, wiki, pull requests, projects, releases, packages and " + "actions units, the merge styles it allows and its default merge style, delete the branch after merge, and the mirror " + - "settings: gitea 1.27.3's EditRepoOption by its own names, all but name and the tracker and wiki objects. Only the " + - "fields given change. The repository is read back: a field the forge did not apply (a default branch that does not " + - "exist) fails the call, naming it. A reason (why) is required and logged. Answers the settings before and after.", + "settings: gitea 1.27.3's EditRepoOption by its own names. Left out: name (a rename moves every module's source " + + "address), the internal tracker, external tracker and external wiki objects, and mirror_username, mirror_password " + + "and mirror_token, which are credentials. projects_mode needs has_projects: true in the same call. Only the fields " + + "given change. The repository is read back: a field the forge did not apply (a default branch that does not exist) " + + "fails the call, naming it; mirror_interval is compared as a duration, and enable_prune, which the forge never " + + "answers, is listed in not_read_back. A reason (why) is required and logged. Answers the settings before and after.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, @@ -569,10 +572,11 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini console.log(`[gitea] gitea_repo_settings_set_full ${owner}/${repo}: ${JSON.stringify(body)} — why: ${why}`); await forge.editRepoSettings(owner, repo, body); const after = await forge.repoSettings(owner, repo); - const missed = unapplied(body, after); + const missed = unapplied(body, after, REPO_FIELDS); if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo}'s settings but did not apply ${missed.join("; ")}`); const pick = (r: Record) => Object.fromEntries(Object.keys(REPO_FIELDS).filter((k) => k in r).map((k) => [k, r[k]])); - return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after) }; + return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after), + not_read_back: Object.keys(body).filter((k) => REPO_FIELDS[k]?.unread) }; }, },