gitea: say which changed directories hold a module at the merge commit (hq issue 278)

The controller read a changed file as shared code unless its directory was a module it holds or
the merge also changed that directory's manifest. A merge touching modules/showcase/index.ts -
the catalogue's reference module, held by no machine - therefore rebuilt all 103 modules built
from this repository on 2026-10-06, 88 of them byte-identical, with the build agent first only
because everything else is built by it.

Whether a directory is a module is a fact of the repository at the commit, so the announcer now
looks it up: every directory above a changed file (never the root) is asked for its module.json
at the merge commit, and the ones that have one go out as module_dirs, with module_dirs_said.
Not said when the file list is cut, past 300 directories, or when the forge cannot be asked;
the controller then keeps its old rule, which rebuilds too much rather than too little.

modules/showcase stays: TestTheShowcaseModuleIsAValidManifest in mesh-controller parses it and
hq to-be 18 and 20 name it as the reference module.
This commit is contained in:
jochen
2026-10-06 19:55:17 +02:00
parent 9d407248b3
commit 1178db279e
3 changed files with 102 additions and 0 deletions
+54
View File
@@ -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<string[] | null> {
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<boolean> {
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<string>();
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("/");
}
+13
View File
@@ -107,6 +107,18 @@ async function pollMerged(client: GiteaClient): Promise<void> {
// 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<void> {
// 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.
+35
View File
@@ -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<void>((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<void>((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();
});