Announce a pull request opened after its push, and say one nobody checks (hq issue 347)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)

The announcer read only the repositories whose update time moved since its
last pass, less a minute. Opening, reopening or retargeting a pull request
does not move a repository's update time, so a pull request opened more
than a minute after its push was never announced, never checked, and
nothing said so (mesh-sdk #14, 2026-10-09).

- Each pass also looks at every repository holding a pull request updated
  since the floor, by the forge's own search; every ten minutes, or when the
  search fails, at every repository.
- Every five minutes the forge is asked for every open pull request; one on
  a base requiring mesh/merge-gate whose head has had none for ten minutes
  is said as the forge failing that pull request's merge check (a provider
  standing, ADR 0224), which the controller keeps as a condition, and is
  recovered when a check arrives or it closes.
- The client refuses a pull request or issue number that is not one before
  asking the forge (a misnamed tool argument sent pulls/NaN).
This commit is contained in:
jochen
2026-10-09 12:55:09 +02:00
parent 7339ce8556
commit 67bce7e189
5 changed files with 248 additions and 3 deletions
+40
View File
@@ -194,6 +194,7 @@ export class GiteaClient {
}
async getIssue(owner: string, repo: string, index: number): Promise<GiteaIssue> {
indexOf(index);
return GiteaClient.mapIssue(await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`));
}
@@ -209,6 +210,7 @@ export class GiteaClient {
/** Patch an issue's state — the one edit the close tool needs. */
async setIssueState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaIssue> {
indexOf(index);
return GiteaClient.mapIssue(
await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`, {
method: "PATCH",
@@ -218,6 +220,7 @@ export class GiteaClient {
}
async addComment(owner: string, repo: string, index: number, body: string): Promise<{ id: number; html_url: string }> {
indexOf(index);
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/${index}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
@@ -257,7 +260,28 @@ export class GiteaClient {
return (prs ?? []).map(GiteaClient.mapPull);
}
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
* page by page (novox/hq issue 347): each with its repository and its own update time. */
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
for (let page = 1; page <= 20; page++) {
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
if (since) qs.set("since", since);
const found = await this.request<any[]>(`/repos/issues/search?${qs}`);
for (const i of found ?? []) {
const r = i?.repository ?? {};
const [fo, fn] = String(r.full_name ?? "").split("/");
const owner = String(r.owner ?? fo ?? "");
const name = String(r.name ?? fn ?? "");
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
}
if (!found || found.length < 50) break;
}
return out;
}
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
}
@@ -275,6 +299,7 @@ export class GiteaClient {
* deleted is gone from its source, and the mesh forgets it rather than asking its build (novox/hq ADR
* 0236). The forge says `deleted`; `removed` is read the same. */
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; removed: string[]; truncated: boolean }> {
indexOf(index);
const paths: string[] = [];
const removed: string[] = [];
let pageSize = 0;
@@ -408,6 +433,7 @@ export class GiteaClient {
/** Close or reopen a pull request without merging it. A pull request is an issue to the forge's
* state machine, and the pulls endpoint takes the same `state`. */
async setPullState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ state }) }),
);
@@ -415,6 +441,7 @@ export class GiteaClient {
/** Change a pull request's title or body; a field left undefined is left alone. */
async updatePullRequest(owner: string, repo: string, index: number, data: { title?: string; body?: string }): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify(data) }),
);
@@ -422,11 +449,13 @@ export class GiteaClient {
/** The unified diff of a pull request, as text. */
async pullDiff(owner: string, repo: string, index: number): Promise<string> {
indexOf(index);
return this.requestText(`/repos/${owner}/${repo}/pulls/${index}.diff`);
}
/** Every comment on an issue or pull request, oldest first. */
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
indexOf(index);
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/issues/${index}/comments`);
return (raw ?? []).map((c) => ({
id: Number(c?.id ?? 0),
@@ -464,6 +493,7 @@ export class GiteaClient {
}
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
indexOf(index);
await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
method: "POST",
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }),
@@ -729,6 +759,16 @@ export class GiteaAdmin {
}
}
/** A pull request's or issue's number, refused unless it is one: a tool asked with a misnamed argument hands over
* `NaN`, and the forge was asked about `pulls/NaN` (novox/hq issue 347). */
export function indexOf(index: number): number {
if (!Number.isInteger(index) || index <= 0) {
throw new Error(`a pull request or issue number is a whole number above 0, and ${String(index)} was given — ` +
"is the argument named \`number\`? Nothing was asked of the forge");
}
return index;
}
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {