diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index ee0572ee..55bba9dc 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -122,13 +122,71 @@ export interface ReadyMark { label_id: number; } -/** The ready mark a pull request's comments make, oldest first as the forge lists them: the newest that names a head. */ -export function readyMarkOf(labelID: number, comments: GiteaComment[]): ReadyMark { - for (let i = comments.length - 1; i >= 0; i--) { - const m = READY_AT.exec(comments[i].body); - if (m) return { head: m[1].toLowerCase(), by: comments[i].user, at: comments[i].created_at, label_id: labelID }; +/** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which + * the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors= …`. A file + * that cannot be read leaves the mesh's own forge account alone, said; never an empty list read as everyone. */ +export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER, + read: (f: string) => string = (f) => readFileSync(f, "utf8")): string[] { + const fallback = own ? [own] : []; + if (!file) return fallback; + try { + const line = read(file).split("\n").find((l) => l.startsWith("authors=")); + const authors = (line ?? "").slice("authors=".length).split(/[\s,]+/).filter(Boolean); + if (authors.length) return authors; + console.error(`[gitea] ${file} names no account that may mark a pull request ready; only ${fallback.join(", ") || "none"} may`); + } catch (err) { + console.error(`[gitea] ${file} cannot be read (${err instanceof Error ? err.message : err}); only ` + + `${fallback.join(", ") || "none"} may mark a pull request ready`); } - return { head: "", label_id: labelID }; + return fallback; +} + +/** One event of an issue's or pull request's timeline, as the forge keeps it, oldest first: a comment, a push to a + * pull request's branch (`pull_push`), a label added (body "1") or taken off (body ""), and the rest. */ +export interface TimelineEvent { + id: number; + type: string; + body: string; + created_at?: string; + user?: string; + label?: { id: number; name: string }; +} + +/** The comments that wrote "ready at" without counting, by id: each said once in the log (novox/hq issue 722). */ +const ignoredSaid = new Set(); + +/** **The ready mark a pull request's timeline makes** (novox/hq issue 722), read in the forge's order — every event is + * one row of one table, numbered as it happened, so the order is exact where times share a second. A "ready at" + * comment counts only when its author is one of the accounts given, and only when it was written after the label + * mesh/ready was last added and after the last push to the pull request's branch: a push or the label taken off + * undoes every mark before it, so a mark older than the latest push never counts, even with the label still on. A + * comment by any other account is ignored, and said once. */ +export function readyMarkOf(labelID: number, events: TimelineEvent[], authors: string[], + say: (line: string) => void = (l) => console.error(l)): ReadyMark { + const listed = new Set(authors.map((a) => a.toLowerCase())); + let labelled = false; + let mark: ReadyMark | undefined; + for (const e of [...events].sort((x, y) => x.id - y.id)) { + if (e.type === "pull_push") { + mark = undefined; + } else if (e.type === "label" && e.label?.name === READY_LABEL) { + labelled = e.body === "1"; + mark = undefined; + } else if (e.type === "comment") { + const m = READY_AT.exec(e.body); + if (!m) continue; + if (!listed.has(String(e.user ?? "").toLowerCase())) { + if (!ignoredSaid.has(e.id)) { + ignoredSaid.add(e.id); + say(`[gitea] a "ready at ${m[1].slice(0, 12)}" comment (${e.id}) by ${e.user ?? "an unknown account"} is ignored: ` + + `only ${authors.join(", ") || "no account"} may mark a pull request ready (novox/hq issue 722)`); + } + continue; + } + if (labelled) mark = { head: m[1].toLowerCase(), by: e.user, at: e.created_at, label_id: labelID }; + } + } + return mark ?? { head: "", label_id: labelID }; } /** One status on a head, the newest of its context: its state, what it said, and when it was set. */ @@ -384,7 +442,7 @@ export class GiteaClient { * requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull * request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a * fault in the announcer cannot hide itself. Reads only. */ - async openPulls(): Promise { + async openPulls(readyAuthors: string[] = []): Promise { const out: OpenPull[] = []; const rules = new Map(); for (const found of await this.searchOpenPulls()) { @@ -395,9 +453,10 @@ export class GiteaClient { const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main")); const checks = await this.headChecks(found.owner, found.name, pull.head_sha); const labels = found.labels ?? []; - // The ready mark (novox/hq issue 722): its comments are read only for a pull request that carries the label. + // The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label. const label = labels.find((l) => l.name === READY_LABEL); - const ready = label ? readyMarkOf(label.id, await this.listComments(found.owner, found.name, pull.number)) : undefined; + const ready = label ? readyMarkOf(label.id, await this.timeline(found.owner, found.name, pull.number), readyAuthors) + : undefined; out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", 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), ...(ready ? { ready } : {}) }); @@ -673,6 +732,24 @@ export class GiteaClient { return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`); } + /** **Every event of an issue's or pull request's timeline**, oldest first, page by page (novox/hq issue 722): a long + * thread hides nothing. Bounded at 100 pages of 50; a longer one is said, and its first 5000 events read. */ + async timeline(owner: string, repo: string, index: number): Promise { + indexOf(index); + const out: TimelineEvent[] = []; + for (let page = 1; page <= 100; page++) { + const raw = await this.request(`${repoPath(owner, repo)}/issues/${index}/timeline?page=${page}&limit=50`); + for (const e of raw ?? []) { + out.push({ id: Number(e?.id ?? 0), type: String(e?.type ?? ""), body: String(e?.body ?? ""), + created_at: e?.created_at, user: e?.user?.login, + ...(e?.label ? { label: { id: Number(e.label.id ?? 0), name: String(e.label.name ?? "") } } : {}) }); + } + if (!raw || raw.length < 50) return out; + } + console.error(`[gitea] ${owner}/${repo}#${index} has more than 5000 timeline events; only the first 5000 are read`); + return out; + } + /** Every comment on an issue or pull request, oldest first. */ async listComments(owner: string, repo: string, index: number): Promise { indexOf(index); diff --git a/modules/gitea/module.json b/modules/gitea/module.json index c97134a5..c3290a80 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -109,6 +109,13 @@ } } }, + "settings": { + "ready-authors": { + "kind": "preference", + "default": "mesh-admin jschoubben", + "why": "the forge accounts whose comment \"ready at \" marks a pull request ready for mesh-delivery to merge, separated by spaces: the mesh's own forge account and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word" + } + }, "resources": [ { "id": "mesh-state", @@ -122,6 +129,14 @@ "path": "${dir:mesh-state}/state", "mode": "0700" }, + { + "id": "ready-authors", + "type": "file", + "path": "${dir:mesh-state}/ready-authors", + "mode": "0600", + "trusted": true, + "content": "# The accounts whose \"ready at \" comment marks a pull request ready (novox/hq issue 722), from the setting\n# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.\nauthors=${setting:ready-authors}\n" + }, { "id": "state", "type": "directory", @@ -246,7 +261,8 @@ "MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", - "MESH_RECEIVES": "${dir:grants}/npm.json" + "MESH_RECEIVES": "${dir:grants}/npm.json", + "MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors" } }, { diff --git a/modules/gitea/test/announcing.test.ts b/modules/gitea/test/announcing.test.ts index 54daea78..01ce7a05 100644 --- a/modules/gitea/test/announcing.test.ts +++ b/modules/gitea/test/announcing.test.ts @@ -139,10 +139,15 @@ test("a merged pull request whose branch was deleted is announced with its branc }); // **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the -// newest comment "ready at ". mesh-delivery merges on it; without the label no comment is read. -test("a pull request's ready mark is read: the label and the head the newest ready comment names", async () => { - const { GiteaClient } = await import("../client.ts"); - const head = "c".repeat(40), older = "d".repeat(40); +// newest comment "ready at " by a listed account, written after the label was added and after the last +// push. Read from the pull request's whole timeline, page by page; without the label nothing is read. +const head = "c".repeat(40), older = "d".repeat(40); +const ev = (id: number, type: string, body: string, user = "mesh-admin", label?: { id: number; name: string }) => + ({ id, type, body, user: { login: user }, created_at: `2026-10-14T10:${String(id % 60).padStart(2, "0")}:00Z`, + ...(label ? { label } : {}) }); +const readyLabel = { id: 31, name: "mesh/ready" }; + +function forgeWith(timeline: unknown[]) { const asked: string[] = []; const server = createServer((req, res) => { const url = req.url ?? ""; @@ -161,28 +166,91 @@ test("a pull request's ready mark is read: the label and the head the newest rea if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) { return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" }); } - if (url.startsWith("/api/v1/repos/novox/hq/issues/7/comments")) { - return send([ - { id: 1, user: { login: "mesh" }, body: `ready at ${older}`, created_at: "2026-10-14T10:00:00Z" }, - { id: 2, user: { login: "mesh" }, body: `Reviewed: approve.\nready at ${head}\n`, created_at: "2026-10-14T11:00:00Z" }, - { id: 3, user: { login: "jochen" }, body: "thanks", created_at: "2026-10-14T12:00:00Z" }, - ]); + const t = /\/issues\/7\/timeline\?page=(\d+)&limit=50/.exec(url); + if (t) { + const page = Number(t[1]); + return send(timeline.slice((page - 1) * 50, page * 50)); } if (url.includes("/branch_protections")) return send([]); if (url.includes("/status")) return send({ statuses: [] }); res.statusCode = 404; res.end("{}"); }); + return { server, asked }; +} + +async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) { + const { GiteaClient } = await import("../client.ts"); + const { server, asked } = forgeWith(timeline); 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 open = await client.openPulls(); - assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]); - assert.deepEqual(open[0].ready, { head, by: "mesh", at: "2026-10-14T11:00:00Z", label_id: 31 }, - "the newest comment that names a head"); - assert.equal(open[1].ready, undefined, "no label, no mark"); - assert.ok(!asked.some((u) => u.includes("/issues/8/comments")), "no comment read without the label"); + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + return { open: await client.openPulls(authors), asked }; } finally { server.close(); } +} + +test("a pull request's ready mark is read: the label and the head the newest listed ready comment names", async () => { + const { open, asked } = await readOpen([ + ev(1, "pull_push", JSON.stringify({ commit_ids: [head] })), + ev(2, "label", "1", "mesh-admin", readyLabel), + ev(3, "comment", `ready at ${older}`), + ev(4, "comment", `Reviewed: approve.\nready at ${head}\n`), + ev(5, "comment", "thanks", "jschoubben"), + ]); + assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]); + assert.deepEqual(open[0].ready, { head, by: "mesh-admin", at: "2026-10-14T10:04:00Z", label_id: 31 }); + assert.equal(open[1].ready, undefined, "no label, no mark"); + assert.ok(!asked.some((u) => u.includes("/issues/8/")), "no timeline read without the label"); +}); + +test("a ready comment by an account not listed is ignored, and said", async () => { + const { readyMarkOf } = await import("../client.ts"); + const said: string[] = []; + const mark = readyMarkOf(31, [ + ev(1, "pull_push", "{}") as any, ev(2, "label", "1", "mesh-admin", readyLabel) as any, + { ...ev(3, "comment", `ready at ${head}`, "outsider"), user: "outsider" }, + ].map((e: any) => ({ ...e, user: typeof e.user === "string" ? e.user : e.user.login })), ["mesh-admin"], (l) => said.push(l)); + assert.equal(mark.head, "", "an outsider's mark counts"); + assert.equal(said.length, 1); + assert.match(said[0], /by outsider is ignored/); +}); + +test("a ready comment older than the latest push, or than the label, never counts", async () => { + const pushedAfter = await readOpen([ + ev(1, "label", "1", "mesh-admin", readyLabel), + ev(2, "comment", `ready at ${head}`), + ev(3, "pull_push", JSON.stringify({ commit_ids: [head] })), + ]); + assert.equal(pushedAfter.open[0].ready?.head, "", "a mark written before the latest push counts, the label still on"); + const beforeLabel = await readOpen([ + ev(1, "pull_push", "{}"), + ev(2, "comment", `ready at ${head}`), + ev(3, "label", "1", "mesh-admin", readyLabel), + ]); + assert.equal(beforeLabel.open[0].ready?.head, "", "a mark written before the label was added counts"); + const takenOff = await readOpen([ + ev(1, "label", "1", "mesh-admin", readyLabel), + ev(2, "comment", `ready at ${head}`), + ev(3, "label", "", "mesh-admin", readyLabel), + ]); + assert.equal(takenOff.open[0].ready?.head, "", "a mark whose label was taken off since counts"); +}); + +test("a long thread hides nothing: every page of the timeline is read", async () => { + const events: unknown[] = [ev(1, "pull_push", "{}"), ev(2, "label", "1", "mesh-admin", readyLabel), + ev(3, "comment", `ready at ${older}`)]; + for (let id = 4; id < 130; id++) events.push(ev(id, "comment", "a long discussion", "jschoubben")); + events.push(ev(130, "comment", `ready at ${head}`, "jschoubben")); + const { open, asked } = await readOpen(events); + assert.equal(open[0].ready?.head, head, "the newest mark, on the third page, is not read"); + assert.equal(asked.filter((u) => u.includes("/issues/7/timeline")).length, 3); +}); + +test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => { + const { readyAuthors } = await import("../client.ts"); + assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]); + assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }), ["mesh-admin"]); + assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n"), ["mesh-admin"]); }); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 99803c6c..b8c46151 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -10,7 +10,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; -import { GiteaClient, type BranchProtection } from "../client.js"; +import { GiteaClient, readyAuthors, type BranchProtection } from "../client.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -514,9 +514,9 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { }, { name: "gitea_open_pulls", - description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head the newest comment \"ready at \" names — which mesh-delivery merges on (issue 722). Reads only.", + description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head named by the newest comment \"ready at \" that a listed account (the setting ready-authors) wrote after the label was added and after the last push — which mesh-delivery merges on (issue 722). Reads only.", input: {}, - run: async () => ({ pulls: await gitea.openPulls() }), + run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }), }, { name: "gitea_contains",