Say a pull request nobody checks as a delivery held, owned by mesh-delivery (hq issue 347)
mesh/merge-gate pass: builds gitea, mesh-delivery → 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)
mesh/delivery superseded: a newer delivery to the same trunk took over its walk

The confirmation review asked for the delivery domain's own vocabulary
instead of a provider standing. A gitea health check would need a `tools`
list (turning gitea's tools into seat verbs), a check of another kind
beside it, and would hold the forge's own deliveries at the first-node
gate; so the delivery's owner says it, with the word it already has.

- gitea_open_pulls (gitea): every open pull request from the forge itself,
  with what its base requires and the statuses its head has.
- mesh-delivery asks it every five minutes and keeps its own first-seen
  time per head (the forge's update time moves on every comment). A head
  on a base requiring mesh/merge-gate OR mesh/repo-check (hq requires only
  the second) with neither for ten minutes is a `stalled` line in state
  `unannounced`, which the controller raises as delivery.<id>.stalled (D14);
  H2 has nothing to close. Once announced it is a delivery of its own.
- The gitea-side watcher and its provider standing are gone.
This commit is contained in:
jochen
2026-10-09 13:14:36 +02:00
parent bcc6b3f52a
commit 32d55beb47
16 changed files with 323 additions and 110 deletions
+5 -6
View File
@@ -13,12 +13,11 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
retargeting a pull request moves its own update time and not its repository's, and a pull request opened more retargeting a pull request moves its own update time and not its repository's, and a pull request opened more
than a minute after its push was never announced (issue 347); and every repository on a sweep, every ten than a minute after its push was never announced (issue 347); and every repository on a sweep, every ten
minutes, or when the search fails. minutes, or when the search fails.
- **A pull request nobody checks is said.** Every five minutes the forge itself is asked for every open pull - **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
request; one whose base requires `mesh/merge-gate` and whose head has had no such status for ten minutes is open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
said as this forge failing *the merge check of `<owner>/<repo>#<n>`* (`provisioner.failing`, class `mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
`unannounced`), which the controller keeps as a condition (ADR 0224), said again each pass while it lasts, and `mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
recovered when a check arrives or the pull request closes. What is said is kept in `pulls-unannounced.json` (`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
beside the announcer's record, so a restart recovers what it said.
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own - **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
at the latest, and `mesh-controller.conditions` names none for it. at the latest, and `mesh-controller.conditions` names none for it.
+34
View File
@@ -54,6 +54,20 @@ export interface GiteaPull {
body?: string; body?: string;
} }
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
export interface OpenPull {
owner: string;
repo: string;
number: number;
base: string;
head_sha: string;
html_url?: string;
/** The contexts the base branch's protection requires; none when it requires none, or cannot be read. */
required: string[];
/** The contexts the head has a status of, in any state. */
statuses: string[];
}
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */ /** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
export interface CommitStatus { export interface CommitStatus {
state: "pending" | "success" | "error" | "failure" | "warning"; state: "pending" | "success" | "error" | "failure" | "warning";
@@ -280,6 +294,26 @@ export class GiteaClient {
return out; return out;
} }
/** **Every open pull request, from the forge itself** (novox/hq issue 347): with what its base's protection
* 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<OpenPull[]> {
const out: OpenPull[] = [];
const rules = new Map<string, BranchProtection[] | undefined>();
for (const found of await this.searchOpenPulls()) {
const full = `${found.owner}/${found.name}`;
const pull = await this.getPullRequest(found.owner, found.name, found.number);
if (pull.state !== "open" || !pull.head_sha) continue;
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
const statuses = Object.keys(await this.commitStatuses(found.owner, found.name, pull.head_sha));
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 });
}
return out;
}
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> { async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
indexOf(index); indexOf(index);
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`)); return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
+1 -64
View File
@@ -28,7 +28,7 @@
import { emit, on } from "@novox/mesh-sdk/events"; import { emit, on } from "@novox/mesh-sdk/events";
import { GiteaClient, movedSince } from "./client.js"; import { GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
type RepoCheckFacts, reposToLook, unannounced, type OpenPull } from "./pulls.js"; type RepoCheckFacts, reposToLook } from "./pulls.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -290,68 +290,6 @@ async function pollPulls(client: GiteaClient): Promise<void> {
lastPullLook = began; lastPullLook = began;
} }
// **An open pull request nobody checks is said** (novox/hq issue 347). One whose base requires the merge check and
// whose head has had none for ten minutes was announced by nobody — the fault this watcher exists for, which went
// unseen while the pull request could not merge. Said as this forge failing the merge check, the provider standing
// the controller keeps as a condition (ADR 0224) and says to the operator; said again every pass while it lasts, and
// recovered when a check arrives or the pull request is no longer open. Asked of the forge itself, never of what
// the announcer remembers, so a fault in the announcer cannot hide itself.
const unannouncedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-unannounced.json") : null;
let failing: Record<string, string> = {};
if (unannouncedRecord && existsSync(unannouncedRecord)) {
try {
failing = (JSON.parse(readFileSync(unannouncedRecord, "utf8")) as { failing: Record<string, string> }).failing ?? {};
} catch {
// Unreadable is no record: a pull request still unchecked is said again at once.
}
}
function keepFailing(): void {
if (!unannouncedRecord) return;
mkdirSync(join(unannouncedRecord, ".."), { recursive: true });
const tmp = unannouncedRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ failing }));
renameSync(tmp, unannouncedRecord);
}
const consumerOf = (key: string) => `the merge check of ${key}`;
async function watchUnannounced(client: GiteaClient): Promise<void> {
const open: OpenPull[] = [];
const rules = new Map<string, Awaited<ReturnType<GiteaClient["branchProtections"]>> | undefined>();
for (const found of await client.searchOpenPulls()) {
const full = `${found.owner}/${found.name}`;
const pull = await client.getPullRequest(found.owner, found.name, found.number);
if (pull.state !== "open" || !pull.head_sha) continue;
if (!rules.has(full)) rules.set(full, await client.branchProtections(found.owner, found.name).catch(() => undefined));
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
const statuses = Object.keys(await client.commitStatuses(found.owner, found.name, pull.head_sha));
open.push({ key: `${full}#${pull.number}`, base: pull.base ?? "", head_sha: pull.head_sha, updated_at: pull.updated_at,
statuses, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [] });
}
const now = Date.now();
const unchecked = new Map(unannounced(open, now).map((p) => [p.key, p] as const));
const node = process.env.MESH_NODE ?? "";
for (const [key, p] of unchecked) {
const since = failing[key] ?? p.updated_at ?? new Date(now).toISOString();
await emit("provisioner.failing", {
provider: "git", "provider-node": node, consumer: consumerOf(key), node: "", class: "unannounced",
error: `${key} is open on ${p.base}, which requires ${CHECK_CONTEXT}, and its head ${p.head_sha.slice(0, 12)} has had no ` +
`merge check for ${Math.round((now - Date.parse(p.updated_at ?? since)) / 60_000)} minutes: the mesh was never asked to check it`,
since, attempts: 1,
});
if (!failing[key]) console.error(`[gitea] ${key} has had no merge check for ten minutes: said to the mesh`);
failing[key] = since;
}
for (const key of Object.keys(failing)) {
if (unchecked.has(key)) continue;
await emit("provisioner.recovered", {
provider: "git", "provider-node": node, consumer: consumerOf(key), node: "", since: failing[key], attempts: 0,
why: "it has a merge check, or is no longer open",
});
console.log(`[gitea] ${key} has a merge check now, or is no longer open`);
delete failing[key];
}
keepFailing();
}
// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one // **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one
// that is not a pass also leaves the check's own account as a comment, so the reason is read where the // that is not a pass also leaves the check's own account as a comment, so the reason is read where the
// change is reviewed. An error — the check could not run — is the forge's `error`, never a success. // change is reviewed. An error — the check could not run — is the forge's `error`, never a success.
@@ -425,7 +363,6 @@ if (gitea) {
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), 30_000); tick(() => pollMerged(client), 30_000);
tick(() => pollPulls(client), 30_000); tick(() => pollPulls(client), 30_000);
tick(() => watchUnannounced(client), 5 * 60_000);
await on("mesh-controller.checked", (event) => setVerdict(client, event)); await on("mesh-controller.checked", (event) => setVerdict(client, event));
console.log("[gitea] watching for new repositories and merged pull requests"); console.log("[gitea] watching for new repositories and merged pull requests");
} }
-25
View File
@@ -113,31 +113,6 @@ export function reposToLook(repos: GiteaRepo[], floor: string, pulls: PullSeen[]
return repos.filter((r) => moved.has(r.full_name)); return repos.filter((r) => moved.has(r.full_name));
} }
/** How long a head may go without a merge check before it is said: the announcer's passes are half a minute
* apart and the check is asked at once, so ten minutes is a fault, never a queue. */
export const UNANNOUNCED_AFTER_MS = 10 * 60_000;
/** An open pull request as the watcher of announcements reads it. */
export interface OpenPull {
/** `owner/repo#number`. */
key: string;
base: string;
head_sha: string;
/** When its head last moved, or it was opened or retargeted. */
updated_at?: string;
/** The status contexts its head has, any state. */
statuses: string[];
/** The contexts its base branch's protection requires. */
required: string[];
}
/** The open pull requests whose base requires the merge check and whose head has had none for ten minutes
* (novox/hq issue 347): announced by nobody, checked by nothing, and blocked from merging with no word why. */
export function unannounced(pulls: OpenPull[], now: number, after = UNANNOUNCED_AFTER_MS): OpenPull[] {
return pulls.filter((p) => p.required.includes(CHECK_CONTEXT) && !p.statuses.includes(CHECK_CONTEXT) &&
!!p.updated_at && now - Date.parse(p.updated_at) >= after);
}
// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a // **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a
// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no // failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no
// administrator override — will not merge past one. So the controller's `warning`, a note and never a // administrator override — will not merge past one. So the controller's `warning`, a note and never a
+41 -11
View File
@@ -28,17 +28,47 @@ test("a pull request opened after the margin is looked at, by its own update tim
assert.deepEqual(reposToLook(repos, "", [], false).length, 2, "the first pass looks at every repository"); assert.deepEqual(reposToLook(repos, "", [], false).length, 2, "the first pass looks at every repository");
}); });
test("an open pull request on a protected branch with no merge check for ten minutes is said, and nothing else", async () => { test("every open pull request is read with what its base requires and what its head has (gitea_open_pulls)", async () => {
const { unannounced } = await import("../pulls.ts"); const { GiteaClient } = await import("../client.ts");
const now = Date.parse("2026-10-09T10:30:00Z"); const asked: string[] = [];
const pull = { const server = createServer((req, res) => {
key: "novox/mesh-sdk#14", base: "main", head_sha: "bfe82315f42c", updated_at: "2026-10-09T10:12:00Z", asked.push(req.url ?? "");
statuses: [] as string[], required: ["mesh/merge-gate", "mesh/repo-check"], const url = req.url ?? "";
}; const send = (v: unknown) => res.end(JSON.stringify(v));
assert.deepEqual(unannounced([pull], now).map((p) => p.key), ["novox/mesh-sdk#14"]); if (url.startsWith("/api/v1/repos/issues/search")) {
assert.deepEqual(unannounced([{ ...pull, statuses: ["mesh/merge-gate"] }], now), [], "a head with a merge check"); return send(url.includes("page=1") ? [
assert.deepEqual(unannounced([{ ...pull, required: [] }], now), [], "a branch that requires no merge check"); { number: 243, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, updated_at: "2026-10-09T11:00:00Z" },
assert.deepEqual(unannounced([{ ...pull, updated_at: "2026-10-09T10:25:00Z" }], now), [], "a head five minutes old"); { number: 14, repository: { owner: "novox", name: "mesh-sdk", full_name: "novox/mesh-sdk" } },
] : []);
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/243")) {
return send({ number: 243, state: "open", base: { ref: "main" }, head: { ref: "x", sha: "aaa111" }, html_url: "h" });
}
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) {
return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb222" }, html_url: "s" });
}
if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]);
}
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
}
if (url.includes("/commits/aaa111/status")) return send({ statuses: [] });
if (url.includes("/commits/bbb222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending" }] });
res.statusCode = 404;
res.end("{}");
});
await new Promise<void>((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.map((p) => [p.owner, p.repo, p.number, p.base, p.head_sha, p.required, p.statuses]), [
["novox", "hq", 243, "main", "aaa111", ["mesh/repo-check"], []],
["novox", "mesh-sdk", 14, "main", "bbb222", ["mesh/merge-gate", "mesh/repo-check"], ["mesh/merge-gate"]],
]);
} finally {
server.close();
}
}); });
test("a pull request or issue number that is not one is refused before the forge is asked", async () => { test("a pull request or issue number that is not one is refused before the forge is asked", async () => {
+1 -1
View File
@@ -411,7 +411,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_list_branches", "gitea_list_branches",
"gitea_delete_branch", "gitea_delete_branch",
"gitea_branch_protection_get", "gitea_branch_protection_set", "gitea_branch_protection_get", "gitea_branch_protection_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_contains", "gitea_commit_status", "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_contains", "gitea_commit_status",
"gitea_list_labels", "gitea_create_label", "gitea_list_labels", "gitea_create_label",
"gitea_api", "gitea_api",
], ],
+6
View File
@@ -492,6 +492,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
merge: mergeableUnder(base, rule, statuses, unreadable) }; merge: mergeableUnder(base, rule, statuses, unreadable) };
}, },
}, },
{
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, in any state. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347). Reads only.",
input: {},
run: async () => ({ pulls: await gitea.openPulls() }),
},
{ {
name: "gitea_contains", name: "gitea_contains",
description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.", description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.",
@@ -239,6 +239,18 @@ type fakeForge struct {
contains map[string]bool contains map[string]bool
containsDown bool containsDown bool
containsAsks []string containsAsks []string
// open is what OpenPulls answers; openDown a forge that cannot say.
open []OpenPull
openDown bool
}
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.openDown {
return nil, errors.New("the forge is away")
}
return append([]OpenPull(nil), f.open...), nil
} }
func newFakeForge() *fakeForge { func newFakeForge() *fakeForge {
@@ -41,6 +41,8 @@ type Holder struct {
// askTried is when a check this owner asks was last tried and could not be asked: tried again a minute on. // askTried is when a check this owner asks was last tried and could not be asked: tried again a minute on.
askTried map[string]time.Time askTried map[string]time.Time
refusals []string refusals []string
// unchecked are the open pull requests' heads seen without a merge check (unchecked.go).
unchecked map[string]unchecked
} }
// unmatched is a walk no delivery is known for yet: its merge not heard, or merged before this owner held. // unmatched is a walk no delivery is known for yet: its merge not heard, or merged before this owner held.
@@ -80,6 +82,7 @@ func (h *Holder) init() {
h.dirtyGroup = map[string]bool{} h.dirtyGroup = map[string]bool{}
h.unmatched = map[string]unmatched{} h.unmatched = map[string]unmatched{}
h.askTried = map[string]time.Time{} h.askTried = map[string]time.Time{}
h.unchecked = map[string]unchecked{}
h.started = h.Now() h.started = h.Now()
} }
@@ -68,7 +68,7 @@ func run(h *Holder, l *listening) {
go func() { go func() {
tick := time.NewTicker(10 * time.Second) tick := time.NewTicker(10 * time.Second)
defer tick.Stop() defer tick.Stop()
last := time.Now() last, looked := time.Now(), time.Time{}
for now := range tick.C { for now := range tick.C {
if now.Sub(last) >= 30*time.Second { if now.Sub(last) >= 30*time.Second {
last = now last = now
@@ -76,6 +76,11 @@ func run(h *Holder, l *listening) {
logf("[mesh-delivery] the controller's walks cannot be read: %v", err) logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
} }
} }
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
if now.Sub(looked) >= 5*time.Minute {
looked = now
h.SeeOpenPulls()
}
h.Tick() h.Tick()
h.Flush() h.Flush()
} }
@@ -65,7 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
want = append(want, "seat:"+ControllerSeat+"."+v) want = append(want, "seat:"+ControllerSeat+"."+v)
} }
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
"gitea.gitea_commit_statuses", "gitea.gitea_contains") "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
if !reflect.DeepEqual(m.Invokes, want) { if !reflect.DeepEqual(m.Invokes, want) {
t.Fatalf("invokes %v, wanted %v", m.Invokes, want) t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
} }
@@ -78,6 +78,9 @@ type Forge interface {
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
// Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only. // Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only.
Contains(owner, repo, commit, in string) (bool, error) Contains(owner, repo, commit, in string) (bool, error)
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
// 347). Reads only.
OpenPulls() ([]OpenPull, error)
} }
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses). // ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
@@ -339,6 +342,24 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
return &c, nil return &c, nil
} }
func (f toolForge) OpenPulls() ([]OpenPull, error) {
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
if err != nil {
return nil, err
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the forge refused gitea_open_pulls: %s", lastLine(output))
}
var open struct {
Pulls []OpenPull `json:"pulls"`
}
if err := json.Unmarshal(answer, &open); err != nil || open.Pulls == nil {
return nil, fmt.Errorf("the forge's gitea_open_pulls is not readable: %s", clip(string(answer), 200))
}
return open.Pulls, nil
}
func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) { func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) {
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in}) raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
if err != nil { if err != nil {
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"slices"
"time"
)
// A pull request the mesh was never asked to check (novox/hq issue 347).
//
// The forge's announcer is the only way the mesh hears of a pull request's head, and on 2026-10-09 it missed one: a
// pull request opened after its push moved nothing the announcer read, and waited unchecked, unable to merge, with
// nothing saying so. This owner asks the forge itself for every open pull request every few minutes, and one whose
// base requires the merge check — mesh/merge-gate or mesh/repo-check — with neither on its head for UncheckedAfter
// since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the
// controller raises as `delivery.<id>.stalled` (D14) like any other delivery past its bound. The delivery domain's own
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
// Unannounced is a pull request's head the forge never announced: not a state of the table — no delivery exists —
// only what a stalled line says.
const Unannounced State = "unannounced"
// UncheckedAfter is how long a head may go without a merge check before it is said: the announcer passes every half
// minute and the check is asked at once, so ten minutes is a fault, never a queue.
const UncheckedAfter = 10 * time.Minute
// mergeChecks are the statuses that are the mesh's merge check (novox/hq ADR 0237): a base requiring either is
// one the mesh is meant to check.
var mergeChecks = []string{"mesh/merge-gate", "mesh/repo-check"}
// OpenPull is an open pull request as the forge says it (the gitea module's gitea_open_pulls): its head, its base,
// the contexts the base's protection requires, and the contexts its head has a status of, in any state.
type OpenPull struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Base string `json:"base"`
HeadSHA string `json:"head_sha"`
HTMLURL string `json:"html_url,omitempty"`
Required []string `json:"required"`
Statuses []string `json:"statuses"`
}
// unchecked is a head seen without a merge check, and when this owner first saw it so — its own time, since the
// forge's update time moves on every comment.
type unchecked struct {
pull OpenPull
first time.Time
}
// wantsACheck is whether the base requires the mesh's merge check and the head has none of it.
func (p OpenPull) wantsACheck() bool {
required := slices.ContainsFunc(mergeChecks, func(c string) bool { return slices.Contains(p.Required, c) })
checked := slices.ContainsFunc(mergeChecks, func(c string) bool { return slices.Contains(p.Statuses, c) })
return required && !checked && p.HeadSHA != ""
}
// SeeOpenPulls asks the forge for every open pull request and keeps the heads that want a check and have none.
// A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
func (h *Holder) SeeOpenPulls() {
open, err := h.Forge.OpenPulls()
if err != nil {
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
return
}
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
seen := map[string]unchecked{}
for _, p := range open {
if !p.wantsACheck() {
continue
}
id := IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)
if h.deliveries[id] != nil {
continue // announced: a delivery with its own bound
}
first := now
if was, ok := h.unchecked[id]; ok {
first = was.first
} else {
h.Logf("[mesh-delivery] %s/%s#%d's head %s has no merge check; said if it has none in %s", p.Owner, p.Repo,
p.Number, short(p.HeadSHA), UncheckedAfter)
}
seen[id] = unchecked{pull: p, first: first}
}
h.unchecked = seen
}
// uncheckedLines are the heads unchecked past UncheckedAfter, as stalled lines; under the lock.
func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
var out []StalledLine
for id, u := range h.unchecked {
if h.deliveries[id] != nil || now.Sub(u.first) < UncheckedAfter {
continue
}
out = append(out, StalledLine{ID: id, State: Unannounced, For: now.Sub(u.first).Round(time.Second).String(),
Bound: UncheckedAfter.String(),
H2: "none: the forge never announced it, so there is no delivery to close — the operator's",
Says: fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head has had no merge "+
"check: the mesh was never asked to check it. A new commit on its branch announces it",
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base)})
}
return out
}
func short(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}
@@ -0,0 +1,77 @@
package main
import (
"strings"
"testing"
"time"
)
// **A pull request the mesh was never asked to check is a delivery held, and said** (novox/hq issue 347): one open
// on a base that requires the merge check — mesh/merge-gate or mesh/repo-check, as hq requires only the second —
// whose head has had neither for ten minutes since this owner first saw it so, is a stalled line: the controller
// raises it as `delivery.<id>.stalled` (D14), the delivery domain's own word for a delivery held past its bound.
func TestAPullRequestNobodyCheckedIsAStalledDelivery(t *testing.T) {
w := newWorld(t)
head := "bfe82315f42c8f4509d0598c32569a202d5eada6"
w.forge.open = []OpenPull{
{Owner: "novox", Repo: "hq", Number: 243, Base: "main", HeadSHA: head, Required: []string{"mesh/repo-check"}},
{Owner: "novox", Repo: "sdk", Number: 14, Base: "main", HeadSHA: "aaaa", Required: []string{"mesh/merge-gate", "mesh/repo-check"},
Statuses: []string{"mesh/merge-gate"}},
{Owner: "novox", Repo: "free", Number: 1, Base: "main", HeadSHA: "bbbb", Required: nil},
}
w.h.SeeOpenPulls()
if lines := w.h.Stalled(); len(lines) != 0 {
t.Fatalf("a head just seen is stalled already: %+v", lines)
}
w.later(6 * time.Minute)
w.h.SeeOpenPulls() // seen again: its own first-seen time stands, whatever the forge's update time says
w.later(5 * time.Minute)
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].ID != IDOf("novox/hq", head) || lines[0].State != Unannounced ||
!strings.Contains(lines[0].Says, "no merge check") || !strings.HasPrefix(lines[0].H2, "none") {
t.Fatalf("an unchecked pull request on a base requiring only mesh/repo-check reads %+v", lines)
}
if lines[0].For != "11m0s" {
t.Fatalf("its time is not counted from when this owner first saw it: %s", lines[0].For)
}
// Announced at last: a delivery of its own, with its own bound — no longer this line.
w.h.PullUpdated(pr("novox/hq", 243, head, "decision/x"))
w.h.SeeOpenPulls()
for _, l := range w.h.Stalled() {
if l.State == Unannounced {
t.Fatalf("an announced pull request is still said unannounced: %+v", l)
}
}
}
// One that gets a check, or closes, is dropped; one the forge cannot be asked about is not guessed.
func TestAnUncheckedPullRequestThatGetsACheckOrClosesIsDropped(t *testing.T) {
w := newWorld(t)
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
Required: []string{"mesh/merge-gate"}}}
w.h.SeeOpenPulls()
w.later(11 * time.Minute)
if len(w.h.Stalled()) != 1 {
t.Fatal("not said")
}
w.forge.open[0].Statuses = []string{"mesh/merge-gate"}
w.h.SeeOpenPulls()
if len(w.h.Stalled()) != 0 {
t.Fatal("a pull request with a check is still said")
}
w.forge.open[0].Statuses = nil
w.h.SeeOpenPulls()
w.later(11 * time.Minute)
w.forge.openDown = true
w.h.SeeOpenPulls() // the forge away: what was seen stands, nothing new is guessed
if len(w.h.Stalled()) != 1 {
t.Fatal("a forge that could not be asked dropped or invented a line")
}
w.forge.openDown = false
w.forge.open = nil
w.h.SeeOpenPulls()
if len(w.h.Stalled()) != 0 {
t.Fatal("a closed pull request is still said")
}
}
@@ -189,6 +189,7 @@ func (h *Holder) Stalled() []StalledLine {
out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(), out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(),
Bound: b.For.String(), H2: h2, Says: b.Says}) Bound: b.For.String(), H2: h2, Says: b.Says})
} }
out = append(out, h.uncheckedLines(now)...)
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out return out
} }
+2 -1
View File
@@ -44,7 +44,8 @@
"gitea.gitea_delivery_view", "gitea.gitea_delivery_view",
"gitea.gitea_commit_status", "gitea.gitea_commit_status",
"gitea.gitea_commit_statuses", "gitea.gitea_commit_statuses",
"gitea.gitea_contains" "gitea.gitea_contains",
"gitea.gitea_open_pulls"
], ],
"state": [ "state": [
"deliveries", "deliveries",