Merge pull request 'Announce a pull request opened after its push, and say one nobody checks (hq issue 347)' (#159) from fix/347-a-pull-request-opened-after-its-push-is-never-checked into main

This commit was merged in pull request #159.
This commit is contained in:
2026-10-09 11:38:48 +00:00
16 changed files with 468 additions and 7 deletions
+20
View File
@@ -4,6 +4,26 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
`tools/`, `provisioner/`). This README covers the Go bundle beside it.
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
- **Which repositories a pass looks at.** Each that moved since the last pass, less a minute's margin (issue 250);
each holding a pull request updated since then, found by the forge's own search — opening, reopening or
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
minutes, or when the search fails.
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
- **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
at the latest, and `mesh-controller.conditions` names none for it.
- **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a
misnamed argument sent `pulls/NaN`.
## The package registry's retention (novox/hq ADR 0251 §4, to-be 51)
Every publish adds a version to the registry, and until this nothing removed one. The Go bundle
+74
View File
@@ -54,6 +54,20 @@ export interface GiteaPull {
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. */
export interface CommitStatus {
state: "pending" | "success" | "error" | "failure" | "warning";
@@ -194,6 +208,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 +224,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 +234,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 +274,48 @@ 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;
}
/** **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> {
indexOf(index);
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
}
@@ -275,6 +333,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 +467,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 +475,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 +483,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 +527,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 +793,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[] {
+19 -2
View File
@@ -28,7 +28,7 @@
import { emit, on } from "@novox/mesh-sdk/events";
import { GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
type RepoCheckFacts } 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
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -213,12 +213,29 @@ function keepHeads(): void {
renameSync(tmp, pullsRecord);
}
let lastPullLook = "";
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
const SWEEP_MS = 10 * 60_000;
let lastSweep = 0;
async function pollPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString();
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
let changed = false;
for (const repo of movedSince(await client.listAllRepos(), floor)) {
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
let sweep = Date.now() - lastSweep >= SWEEP_MS;
let updated: { owner: string; name: string; updated_at?: string }[] = [];
if (floor && !sweep) {
try {
updated = await client.searchOpenPulls(floor);
} catch (err) {
console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`);
sweep = true;
}
}
if (sweep) lastSweep = Date.now();
for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) {
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", sort: "recentupdate", limit: "20" });
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
const key = `${repo.full_name}#${pull.number}`;
+22 -1
View File
@@ -9,7 +9,8 @@
//
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
import type { CommitStatus, GiteaPull } from "./client.js";
import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js";
import { movedSince } from "./client.js";
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
export const CHECK_CONTEXT = "mesh/merge-gate";
@@ -92,6 +93,26 @@ export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Ann
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
}
/** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
export interface PullSeen {
owner: string;
name: string;
updated_at?: string;
}
/** **The repositories a pass looks at** (novox/hq issue 347): every one that moved since the floor, every one
* holding a pull request updated since the floor — opening, reopening or retargeting a pull request moves its own
* update time and not its repository's — and every one on a sweep or the first pass. */
export function reposToLook(repos: GiteaRepo[], floor: string, pulls: PullSeen[], sweep: boolean): GiteaRepo[] {
if (sweep || !floor) return repos;
const moved = new Set(movedSince(repos, floor).map((r) => r.full_name));
const at = Date.parse(floor);
for (const p of pulls) {
if (!p.updated_at || !(Date.parse(p.updated_at) < at)) moved.add(`${p.owner}/${p.name}`);
}
return repos.filter((r) => moved.has(r.full_name));
}
// **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
// administrator override — will not merge past one. So the controller's `warning`, a note and never a
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
// **A pull request opened after its push is still checked** (novox/hq issue 347). The announcer read only the
// repositories whose update time moved since its last pass, less a minute's margin; opening, reopening or
// retargeting a pull request does not move a repository's update time, so a pull request opened more than a
// minute or so after its push was never announced, never checked, and nothing said so.
const repo = (full: string, updated: string) => {
const [owner, name] = full.split("/");
return { full_name: full, owner, name, private: false, html_url: "", updated_at: updated };
};
test("a pull request opened after the margin is looked at, by its own update time", async () => {
const { reposToLook } = await import("../pulls.ts");
const pushed = "2026-10-09T10:00:00Z"; // the branch pushed: the repository's update time
// The pull request opened two minutes later; the last pass was at 10:02:30, so the floor is 10:01:30.
const floor = "2026-10-09T10:01:30Z";
const repos = [repo("novox/mesh-sdk", pushed), repo("novox/hq", "2026-10-09T09:00:00Z")];
const opened = [{ owner: "novox", name: "mesh-sdk", updated_at: "2026-10-09T10:02:00Z" }];
assert.deepEqual(reposToLook(repos, floor, opened, false).map((r) => r.full_name), ["novox/mesh-sdk"],
"the repository of a pull request updated since the floor, though the repository itself did not move");
assert.deepEqual(reposToLook(repos, floor, [], false).map((r) => r.full_name), [],
"without the pull request's own time nothing is looked at — the fault");
assert.deepEqual(reposToLook(repos, floor, [], true).map((r) => r.full_name).sort(), ["novox/hq", "novox/mesh-sdk"],
"a sweep looks at every repository");
assert.deepEqual(reposToLook(repos, "", [], false).length, 2, "the first pass looks at every repository");
});
test("every open pull request is read with what its base requires and what its head has (gitea_open_pulls)", async () => {
const { GiteaClient } = await import("../client.ts");
const asked: string[] = [];
const server = createServer((req, res) => {
asked.push(req.url ?? "");
const url = req.url ?? "";
const send = (v: unknown) => res.end(JSON.stringify(v));
if (url.startsWith("/api/v1/repos/issues/search")) {
return send(url.includes("page=1") ? [
{ number: 243, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, updated_at: "2026-10-09T11:00:00Z" },
{ 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 () => {
const { GiteaClient } = await import("../client.ts");
let asked = 0;
const server = createServer((_req, res) => {
asked++;
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 {
await assert.rejects(client.getPullRequest("novox", "hq", Number(undefined)), /whole number/);
await assert.rejects(client.addComment("novox", "hq", 0, "x"), /whole number/);
await assert.rejects(client.listPullFiles("novox", "hq", 1.5), /whole number/);
assert.equal(asked, 0, "the forge was asked about pulls/NaN");
} finally {
server.close();
}
});
+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_delete_branch",
"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_api",
],
+6
View File
@@ -492,6 +492,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
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",
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
containsDown bool
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 {
@@ -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 map[string]time.Time
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.
@@ -80,6 +82,7 @@ func (h *Holder) init() {
h.dirtyGroup = map[string]bool{}
h.unmatched = map[string]unmatched{}
h.askTried = map[string]time.Time{}
h.unchecked = map[string]unchecked{}
h.started = h.Now()
}
@@ -68,7 +68,7 @@ func run(h *Holder, l *listening) {
go func() {
tick := time.NewTicker(10 * time.Second)
defer tick.Stop()
last := time.Now()
last, looked := time.Now(), time.Time{}
for now := range tick.C {
if now.Sub(last) >= 30*time.Second {
last = now
@@ -76,6 +76,11 @@ func run(h *Holder, l *listening) {
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.Flush()
}
@@ -65,7 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
want = append(want, "seat:"+ControllerSeat+"."+v)
}
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) {
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)
// 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)
// 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).
@@ -339,6 +342,24 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
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) {
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
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(),
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 })
return out
}
+2 -1
View File
@@ -44,7 +44,8 @@
"gitea.gitea_delivery_view",
"gitea.gitea_commit_status",
"gitea.gitea_commit_statuses",
"gitea.gitea_contains"
"gitea.gitea_contains",
"gitea.gitea_open_pulls"
],
"state": [
"deliveries",