Merge pull request 'gitea: say which changed directories hold a module at the merge commit (hq issue 278)' (#96) from fix/a-module-directory-is-never-shared-code into main
mesh/delivery delivered
mesh/delivery delivered
This commit was merged in pull request #96.
This commit is contained in:
@@ -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("/");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user