diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 49ec9d52..cd856a09 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -92,6 +92,8 @@ export interface OpenPull { repo: string; number: number; base: string; + /** The pull request's head branch: what a delivery group is named by (novox/hq ADR 0249). */ + head?: string; head_sha: string; html_url?: string; /** The contexts the base branch's protection requires; none when it requires none, or cannot be read. */ @@ -374,7 +376,7 @@ export class GiteaClient { const rule = rules.get(base); const checks = await this.headChecks(found.owner, found.name, pull.head_sha); const labels = found.labels ?? []; - out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha, + out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head: pull.head, head_sha: pull.head_sha, html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [], statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name) }); } @@ -510,6 +512,45 @@ export class GiteaClient { return out; } + /** **A commit's trees: its root, and the tree of every directory that holds a `module.json`** (novox/hq issue 1234, + * ADR 0437 §4 and §5), from the forge's one recursive listing of the commit, read page by page. A directory is + * spelled as the repository spells it, no slash at either end, the root as `""`. + * + * **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages + * run past a bound): the call fails naming the repository and the commit, never answering the directories it did + * see, since a directory left out would be taken for one without a module. Reads only. */ + async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record }> { + const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; + const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); + let root = ""; + const entries: any[] = []; + for (let page = 1; ; page++) { + if (page > 200) throw cut("more than 200 pages"); + const got = await this.request(`${base}&page=${page}&per_page=1000`); + if (page === 1) { + root = String(got?.sha ?? ""); + if (!/^[0-9a-f]{40,64}$/i.test(root)) throw new Error(`the forge named no root tree for ${owner}/${repo} at ${sha}`); + } + if (!Array.isArray(got?.tree)) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} has no tree`); + entries.push(...got.tree); + if (got.truncated !== true) break; + // Truncated with nothing more to ask for: the forge itself could not give the rest. + if (got.tree.length === 0 || entries.length >= Number(got.total_count ?? 0)) throw cut("truncated"); + } + const trees: Record = {}; + const dirOf = new Map(); + for (const e of entries) if (e?.type === "tree" && typeof e.path === "string") dirOf.set(e.path, String(e.sha)); + for (const e of entries) { + if (e?.type !== "blob" || typeof e.path !== "string") continue; + if (e.path !== "module.json" && !e.path.endsWith("/module.json")) continue; + const dir = e.path === "module.json" ? "" : e.path.slice(0, -"/module.json".length); + const tree = dir === "" ? root : dirOf.get(dir); + if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`); + trees[dir] = tree; + } + return { root, trees }; + } + /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ async holdsFile(owner: string, repo: string, sha: string, file: string): Promise { return this.exists(`${repoPath(owner, repo)}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`); diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts index 1843e38c..6e06d472 100644 --- a/modules/gitea/guard.ts +++ b/modules/gitea/guard.ts @@ -94,6 +94,8 @@ export const QUERY_KEYS: ReadonlySet = new Set([ "limit", "page", "state", "type", "q", "sort", "order", "since", "before", "ref", "sha", "path", "labels", "milestones", "keyword", "stat", "verification", "files", "not", "mode", "include_desc", "archived", "private", "exclusive", "uid", "priority_repo_id", "created_by", "assigned_by", "mentioned_by", "whitespace", "binary", + // Read flags of the forge's tree listing (gitea_commit_trees, novox/hq issue 1234): they widen what one read answers, never what a call may write. + "recursive", "per_page", ]); /** The query a call sends: only allow-listed keys, each as given; refused when any other key is there. */ diff --git a/modules/gitea/test/commit-trees.test.ts b/modules/gitea/test/commit-trees.test.ts new file mode 100644 index 00000000..e2a74dc8 --- /dev/null +++ b/modules/gitea/test/commit-trees.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createServer } from "node:http"; + +// Fixtures follow Gitea's GitTreeResponse as its API documents it (swagger: sha, url, tree[{path, mode, type, size, +// sha, url}], truncated, page, total_count) and as the forge answers GET /repos/{o}/{r}/git/trees/{sha}; the entry +// fields are those, with sha values written out (no live capture: the answer's shape is what matters here). + +const COMMIT = "c".repeat(40); +const h = (c: string) => c.repeat(40); +const entry = (path: string, type: "tree" | "blob", sha: string) => ({ + path, mode: type === "tree" ? "040000" : "100644", type, size: type === "tree" ? 0 : 12, sha, url: `u/${sha}`, +}); +const LISTING = [ + entry("README.md", "blob", h("1")), + entry("module.json", "blob", h("2")), + entry("modules", "tree", h("3")), + entry("modules/a", "tree", h("4")), + entry("modules/a/module.json", "blob", h("5")), + entry("modules/a/main.go", "blob", h("6")), + entry("modules/b", "tree", h("7")), + entry("modules/b/c", "tree", h("8")), + entry("modules/b/c/module.json", "blob", h("9")), + entry("modules/d", "tree", h("a")), + entry("modules/d/notes.md", "blob", h("b")), +]; + +async function serve(pages: any[]) { + const { GiteaClient } = await import("../client.ts"); + const asked: string[] = []; + const server = createServer((req, res) => { + asked.push(req.url ?? ""); + const url = new URL(req.url ?? "", "http://x"); + if (url.pathname !== `/api/v1/repos/novox/hq/git/trees/${COMMIT}` || url.searchParams.get("recursive") !== "true") { + res.statusCode = 404; + return res.end("{}"); + } + const page = Number(url.searchParams.get("page") ?? 1); + res.end(JSON.stringify(pages[page - 1] ?? { sha: h("0"), tree: [], truncated: false, page, total_count: 0 })); + }); + await new Promise((r) => server.listen(0, r)); + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + return { client, server, asked }; +} + +test("a commit's trees: the root and the tree of each directory holding a module.json", async () => { + // 1234/AC1 and 1234/AC4 + const { client, server } = await serve([{ sha: h("0"), url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + const got = await client.commitTrees("novox", "hq", COMMIT); + assert.deepEqual(got, { root: h("0"), trees: { "": h("0"), "modules/a": h("4"), "modules/b/c": h("8") } }); + } finally { + server.close(); + } +}); + +test("a listing the forge pages is read page by page", async () => { + // 1234/AC1 + const { client, server, asked } = await serve([ + { sha: h("0"), tree: LISTING.slice(0, 5), truncated: true, page: 1, total_count: LISTING.length }, + { sha: h("0"), tree: LISTING.slice(5, 9), truncated: true, page: 2, total_count: LISTING.length }, + { sha: h("0"), tree: LISTING.slice(9), truncated: false, page: 3, total_count: LISTING.length }, + ]); + try { + const got = await client.commitTrees("novox", "hq", COMMIT); + assert.deepEqual(got.trees, { "": h("0"), "modules/a": h("4"), "modules/b/c": h("8") }); + assert.equal(asked.length, 3, `asked ${asked.join(" ")}`); + } finally { + server.close(); + } +}); + +test("a listing the forge says is cut fails, naming the repository and the commit, and answers no trees", async () => { + // 1234/AC2 + const { client, server } = await serve([{ sha: h("0"), tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]); + try { + await assert.rejects(client.commitTrees("novox", "hq", COMMIT), (err: Error) => { + assert.match(err.message, /novox\/hq/); + assert.ok(err.message.includes(COMMIT), err.message); + assert.match(err.message, /cut/); + return true; + }); + } finally { + server.close(); + } +}); + +test("a sha that is not a full commit, or no owner or repo, is refused before the forge is asked", async () => { + // 1234/AC3 + const { getGiteaTools } = await import("../tools/index.ts"); + const { client, server, asked } = await serve([]); + try { + const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!; + assert.ok(tool, "the tool is listed"); + for (const args of [ + { owner: "novox", repo: "hq", sha: "abc1234" }, + { owner: "novox", repo: "hq", sha: "main" }, + { owner: "novox", repo: "hq", sha: "" }, + { owner: "novox", repo: "hq", sha: "c".repeat(41) }, + { owner: "", repo: "hq", sha: COMMIT }, + { owner: "novox", repo: " ", sha: COMMIT }, + ]) await assert.rejects(tool.run(args as any), /./, JSON.stringify(args)); + assert.equal(asked.length, 0, `the forge was asked ${asked.join(" ")}`); + } finally { + server.close(); + } +}); + +test("gitea_open_pulls says each pull request's head branch beside its head sha", async () => { + // 1234/AC5 + const { GiteaClient } = await import("../client.ts"); + const server = createServer((req, res) => { + const url = req.url ?? ""; + const send = (v: unknown) => res.end(JSON.stringify(v)); + if (url.startsWith("/api/v1/repos/issues/search")) { + return send(url.includes("page=1") ? [{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" } }] : []); + } + if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) { + return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "feat/x", sha: "aaa1111" }, html_url: "h" }); + } + if (url.includes("/branch_protections")) { res.statusCode = 403; return res.end("{}"); } + if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) return send({ name: "main" }); + if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] }); + res.statusCode = 404; + res.end("{}"); + }); + await new Promise((r) => server.listen(0, r)); + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + try { + const [p] = await client.openPulls(); + assert.deepEqual(p, { owner: "novox", repo: "hq", number: 7, base: "main", head: "feat/x", head_sha: "aaa1111", html_url: "h", + required: [], statuses: [], checks: {}, labels: [] }); + } finally { + server.close(); + } +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 1df14e36..437cdd4c 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -539,7 +539,7 @@ test("the tools register once there is a way to a token, and the first call mint "gitea_list_branches", "gitea_delete_branch", "gitea_branch_protection_get", "gitea_branch_protection_set", - "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_contains", + "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", "gitea_list_labels", "gitea_create_label", "gitea_api", ], diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index eba1b8e1..d639a6b8 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -523,6 +523,22 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini input: {}, run: async () => ({ pulls: await gitea.openPulls() }), }, + { + name: "gitea_commit_trees", + description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + sha: { type: "string", description: "the commit, its full 40-hex sha" }, + }, + run: async (args) => { + const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); + const sha = String(args.sha ?? "").trim(); + if (!owner || !repo) throw new Error("name the repository: owner and repo"); + if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha"); + return gitea.commitTrees(owner, repo, sha); + }, + }, { name: "gitea_contains", description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.",