diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 07dc481..61aba60 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -278,6 +278,42 @@ export class GiteaClient { } } + /** The directories holding these files that hold a `module.json` at a commit (novox/hq issue 278). + * + * **Whether a directory is a module is a fact of the repository, not of the merge.** The mesh read a + * changed file as shared code unless its directory was a module it held or the merge also changed + * that directory's manifest; a change to the catalogue's reference module, which no machine holds, + * rebuilt all 103 modules built from the repository. So every directory above a changed file — never + * the root — is looked up at the merge commit, and the ones holding a manifest are said. + * + * `null` when there are more than `most` directories to look at: not said, and the mesh keeps its old + * rule, which rebuilds too much rather than too little. A failed lookup throws, for the same reason. */ + async moduleDirsAt(owner: string, repo: string, sha: string, paths: string[], most = 300): Promise { + const dirs = directoriesAbove(paths); + if (dirs.length > most) return null; + const out: string[] = []; + for (const dir of dirs) { + if (await this.exists(`/repos/${owner}/${repo}/contents/${encodePath(dir + "/module.json")}?ref=${encodeURIComponent(sha)}`)) { + out.push(dir); + } + } + return out; + } + + /** Whether the forge has something at a path: true for an answer, false for a 404, thrown otherwise. */ + private async exists(path: string): Promise { + let token = await this.tokens.current(); + let res = await this.send(path, {}, token); + if (res.status === 401) { + token = await this.tokens.renew(token); + res = await this.send(path, {}, token); + } + if (res.status === 404) return false; + if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); + await res.body?.cancel(); + return true; + } + async createPullRequest( owner: string, repo: string, @@ -616,3 +652,21 @@ export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] { const at = Date.parse(floor); return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at)); } + +/** Every directory above these files, from the repository's root, the root itself left out; sorted. */ +export function directoriesAbove(paths: string[]): string[] { + const dirs = new Set(); + for (const raw of paths) { + const parts = raw.replace(/^\/+/, "").split("/"); + for (let i = 1; i < parts.length; i++) { + const dir = parts.slice(0, i).join("/"); + if (dir !== "") dirs.add(dir); + } + } + return [...dirs].sort(); +} + +/** A repository path for the forge's URL: each segment escaped, the slashes kept. */ +function encodePath(path: string): string { + return path.split("/").map(encodeURIComponent).join("/"); +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 99eff32..57f5f48 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -107,6 +107,18 @@ async function pollMerged(client: GiteaClient): Promise { // directory moved, and without this every module built from a repository is rebuilt for a // change to any of them (novox/hq 04-ISSUES/131). const changed = await client.listPullFiles(repo.owner, repo.name, pull.number); + // Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a + // change inside one is that module's, held or not, and only a file in none is shared code. Not + // said when the list is cut or the forge could not be asked; the mesh then keeps its old rule. + let moduleDirs: string[] | null = null; + if (!changed.truncated) { + try { + moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths); + } catch (err) { + console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` + + `so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`); + } + } await emit("pull.merged", { owner: repo.owner, repo: repo.name, @@ -123,6 +135,7 @@ async function pollMerged(client: GiteaClient): Promise { // Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten, // not built. removed: changed.removed, + ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), }); // Said, because a trigger that fires silently is indistinguishable from one that did not // fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told. diff --git a/modules/gitea/test/pages.test.ts b/modules/gitea/test/pages.test.ts index f389ab2..063cf32 100644 --- a/modules/gitea/test/pages.test.ts +++ b/modules/gitea/test/pages.test.ts @@ -36,3 +36,38 @@ test("a pass asks only the repositories that moved since the last look, every on assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]); assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string"); }); + +test("the directories above a merge's files that hold a module at the commit are said, the root never", async () => { + const { directoriesAbove } = await import("../client.ts"); + assert.deepEqual(directoriesAbove(["modules/showcase/index.ts", "modules/showcase/daemon/x.ts", "README.md", "/modules/lib/a.go"]), + ["modules", "modules/lib", "modules/showcase", "modules/showcase/daemon"]); + const asked: string[] = []; + const server = createServer((req, res) => { + const url = new URL(req.url ?? "", "http://x"); + asked.push(`${url.pathname}@${url.searchParams.get("ref")}`); + const held = ["/api/v1/repos/novox/mesh-catalog/contents/modules/showcase/module.json"]; + res.statusCode = held.includes(url.pathname) ? 200 : 404; + res.end(res.statusCode === 200 ? "{}" : '{"message":"not found"}'); + }); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as any).port; + const client = new GiteaClient(`http://127.0.0.1:${port}`, "t"); + const got = await client.moduleDirsAt("novox", "mesh-catalog", "abc", ["modules/showcase/index.ts", "modules/lib/x.go"]); + const capped = await client.moduleDirsAt("novox", "mesh-catalog", "abc", ["a/b/c/d.ts"], 2); + server.close(); + assert.deepEqual(got, ["modules/showcase"], "a directory holding a manifest is a module; one holding none is not"); + assert.ok(asked.every((a) => a.endsWith("@abc")), "looked up at the merge commit"); + assert.equal(capped, null, "past the bound it is not said, and the mesh keeps its old rule"); +}); + +test("a lookup the forge refuses is thrown, not read as no module", async () => { + const server = createServer((_req, res) => { + res.statusCode = 500; + res.end("down"); + }); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as any).port; + const client = new GiteaClient(`http://127.0.0.1:${port}`, "t"); + await assert.rejects(client.moduleDirsAt("novox", "mesh-catalog", "abc", ["modules/x/y.ts"])); + server.close(); +});