diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 33afefe5..6a2ba438 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -533,7 +533,7 @@ export class GiteaClient { * **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 }> { + async commitTrees(owner: string, repo: string, sha: string, paths?: Record): Promise<{ root: string; trees: Record; paths?: 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`); const entries: any[] = []; @@ -560,7 +560,22 @@ export class GiteaClient { 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 }; + if (paths === undefined) return { root, trees }; + // **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a + // directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails + // naming both, and nothing is answered in part. + const objectOf = new Map(); + for (const e of entries) if (typeof e?.path === "string" && (e.type === "tree" || e.type === "blob")) objectOf.set(e.path, String(e.sha)); + const named: Record> = {}; + for (const [dir, list] of Object.entries(paths)) { + named[dir] = {}; + for (const p of list) { + const id = objectOf.get(dir === "" ? p : `${dir}/${p}`); + if (!id) throw new Error(`the path ${p} of ${dir === "" ? "the repository root" : dir} is not in ${owner}/${repo} at ${sha}`); + named[dir][p] = id; + } + } + return { root, trees, paths: named }; } /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ diff --git a/modules/gitea/test/commit-trees.test.ts b/modules/gitea/test/commit-trees.test.ts index 51801d7d..e685a5c0 100644 --- a/modules/gitea/test/commit-trees.test.ts +++ b/modules/gitea/test/commit-trees.test.ts @@ -171,3 +171,53 @@ test("the root tree is git's hash of the root's own entries, never the listing's server.close(); } }); + +test("named paths: each asked path's git object id at the commit, a file's or a directory's, by directory (1758 S1, ADR 0499 §3)", async () => { + const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + const got = await client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "module.json"], "modules/b": ["c"], "": ["README.md", "modules/d"] }); + assert.deepEqual(got.paths, { + "modules/a": { "main.go": h("6"), "module.json": h("5") }, + "modules/b": { c: h("8") }, + "": { "README.md": h("1"), "modules/d": h("a") }, + }); + assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") }); + assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered"); + } finally { + server.close(); + } +}); + +test("a named path the listing lacks fails naming the directory and the path, and answers nothing", async () => { + const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "gone.go"] }), (err: Error) => { + assert.match(err.message, /modules\/a/); + assert.match(err.message, /gone\.go/); + return true; + }); + for (const bad of [[""], ["../x"], ["/abs"], ["a//b"]]) { + await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": bad }), /./, JSON.stringify(bad)); + } + } finally { + server.close(); + } +}); + +test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another shape before the forge is asked", async () => { + const { getGiteaTools } = await import("../tools/index.ts"); + const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!; + assert.ok("paths" in tool.input, "the tool names the argument"); + const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any); + assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } }); + const before = asked.length; + for (const paths of ["modules/a", ["main.go"], { "modules/a": "main.go" }, { "modules/a": [1] }]) { + await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths } as any), /paths/, JSON.stringify(paths)); + } + assert.equal(asked.length, before, "the forge was asked for a refused call"); + } finally { + server.close(); + } +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index d639a6b8..f5bbb0b1 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -60,6 +60,22 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] { })); } +/** The `paths` argument of gitea_commit_trees: {directory: [path relative to it]}, each path plain (no empty, `.` or `..` + * segment, not absolute); anything else is refused before the forge is asked. Absent is absent. */ +function namedPaths(raw: unknown): Record | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("paths is {directory: [path, ...]}"); + const out: Record = {}; + for (const [dir, list] of Object.entries(raw as Record)) { + if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) throw new Error(`paths of ${dir || "the root"} is a list of paths`); + for (const p of list as string[]) { + if (p === "" || p.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`paths: ${JSON.stringify(p)} of ${dir || "the root"} is not a plain relative path`); + } + out[dir] = list as string[]; + } + return out; +} + export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { return namesChecked(allGiteaTools(gitea, admin)); } @@ -525,18 +541,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { 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.", + 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). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. 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" }, + paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" }, }, 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); + return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths)); }, }, {