Merge pull request 'No ready mark: the Approve is asked once the checks pass, and a group merges in one pass on its one Approve (issues 1022, 978)' (#352) from feat/one-group-one-plan into main

This commit was merged in pull request #352.
This commit is contained in:
2026-10-17 20:11:06 +00:00
51 changed files with 1474 additions and 2734 deletions
+2 -132
View File
@@ -8,7 +8,6 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { Refused, checkSentPath, ruleCovers, pathSegment, pathSegments } from "./guard.js";
import { isCommit } from "./checks.js";
import { TOOLS_ACCOUNT } from "./tools-account.js";
/** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every
* call, whether the names came from a caller or from the forge's own answer. */
@@ -104,92 +103,6 @@ export interface OpenPull {
checks: Record<string, HeadCheck>;
/** The pull request's labels, by name. */
labels?: string[];
/** Its ready mark (novox/hq issue 722): the label `mesh/ready` and the newest comment "ready at <full head sha>", the
* head a reviewer approved. Absent without the label; `head` empty when the label names no head in a comment. */
ready?: ReadyMark;
}
/** The label that marks a pull request ready to merge at the head a comment names (novox/hq issue 722). */
export const READY_LABEL = "mesh/ready";
/** A comment that names the head a pull request is marked ready at: "ready at <full sha>", on a line of its own. */
const READY_AT = /^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b/im;
/** A pull request's ready mark: the head it names, who said it and when, and the label's id, to take it off. */
export interface ReadyMark {
head: string;
by?: string;
at?: string;
label_id: number;
}
/** **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=<account> <account> …`. A file
* that cannot be read leaves the mesh's own forge accounts alone, said; never an empty list read as everyone: the
* account the tools act as (novox/hq ADR 0365) and the admin account, whose marks were made before it. */
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"),
tools = process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT): string[] {
const fallback = [...new Set([tools, own].filter((a): a is string => !!a))];
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 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<number>();
/** **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. */
@@ -445,7 +358,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(readyAuthors: string[] = []): Promise<OpenPull[]> {
async openPulls(): Promise<OpenPull[]> {
const out: OpenPull[] = [];
const rules = new Map<string, BranchProtection | null | undefined>();
for (const found of await this.searchOpenPulls()) {
@@ -458,13 +371,9 @@ export class GiteaClient {
const rule = rules.get(base);
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
const labels = found.labels ?? [];
// 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.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 } : {}) });
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name) });
}
return out;
}
@@ -768,45 +677,6 @@ export class GiteaClient {
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
}
/** **Whether a pull request carries an approving review at a head** (novox/hq ADR 0382 §6): a review of the forge that
* approves, is not dismissed and was made at that very head; or the mesh's ready mark naming that head (issue 722), which is
* how an agent's approval is read where the forge refuses an agent's approve. Reads only. */
async approvedAt(owner: string, repo: string, index: number, head: string, authors?: string[]): Promise<boolean> {
indexOf(index);
const want = head.toLowerCase();
for (let page = 1; page <= 20; page++) {
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/pulls/${index}/reviews?page=${page}&limit=50`);
for (const r of raw ?? []) {
if (String(r?.state ?? "").toUpperCase() === "APPROVED" && !r?.dismissed && !r?.stale &&
String(r?.commit_id ?? "").toLowerCase() === want) return true;
}
if (!raw || raw.length < 50) break;
}
const labels = await this.request<any[]>(`${repoPath(owner, repo)}/issues/${index}/labels`);
const label = (labels ?? []).find((l) => l?.name === READY_LABEL);
if (!label) return false;
const ready = readyMarkOf(Number(label.id ?? 0), await this.timeline(owner, repo, index), authors ?? readyAuthors());
return !!ready && ready.head.toLowerCase() === want;
}
/** **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<TimelineEvent[]> {
indexOf(index);
const out: TimelineEvent[] = [];
for (let page = 1; page <= 100; page++) {
const raw = await this.request<any[]>(`${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<GiteaComment[]> {
indexOf(index);
-3
View File
@@ -1,3 +0,0 @@
# The accounts whose "ready at <sha>" comment marks a pull request ready (novox/hq issue 722), from the setting
# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.
authors=${config:ready-authors}
+33 -7
View File
@@ -42,7 +42,7 @@ import { commitStatusTool } from "./delivery.js";
import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, 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, composedGate, groupGateStatus, headsToAnnounce, statusesFor, type Announced, type Checked,
repoCheckFacts, reposToLook } from "./pulls.js";
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
@@ -361,8 +361,9 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
{ took: c.took, at: new Date().toISOString(), head: c.commit });
}
if (c.group) {
// A delivery group's composed check (novox/hq ADR 0239): its verdict is the group owner's to say, on each
// member's head, as mesh/delivery-group — never this head's merge gate.
// A delivery group's composed check (novox/hq ADR 0239, ADR 0403 §1): its verdict is each member's merge gate, at the
// head it read; the group's own status, mesh/delivery-group, is its owner's to say.
await setGroupVerdict(client, c);
return;
}
if (!c.owner || !c.repo || !c.commit || !c.verdict) {
@@ -396,27 +397,52 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined);
if (head && !sameCommit(c.trunk, head)) {
const description = outOfDateWords(c.trunk, head);
for (const context of [CHECK_CONTEXT, ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) {
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) {
await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description,
...(target ? { target_url: target } : {}) });
}
// Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date.
const said = commentFor(c, await repoCheckFacts(client, c, trunk));
if (c.number) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description));
if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description));
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` +
`${description}`);
return;
}
}
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
for (const status of statusesFor(c, facts)) {
const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record<string, { description?: string }>)))[CHECK_CONTEXT]?.description : undefined;
for (const status of statusesFor(c, facts, gateNow)) {
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
}
const comment = commentFor(c, facts);
const comment = composedGate(c) ? null : commentFor(c, facts);
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
}
/** **The composed verdict sets each member's gate** (novox/hq ADR 0403 §1, issue 978), in each head's own turn of the gate
* writer (gate.ts), judged against the trunk as a member's own verdict is: one that names a trunk commit that is no longer
* the trunk's head is out of date when it lands, and said so, pending. */
async function setGroupVerdict(client: GiteaClient, c: Checked): Promise<void> {
for (const m of c.members ?? []) {
if (!m.owner || !m.repo || !m.commit || !c.verdict) {
console.error(`[gitea] a composed verdict of ${c.group} names a member with no repository, commit or verdict; ignored`);
continue;
}
await gates.verdict(m.owner, m.repo, m.commit, async () => {
const pull = m.number ? await client.getPullRequest(m.owner, m.repo, m.number).catch(() => undefined) : undefined;
const target = pull?.html_url || undefined;
let status = groupGateStatus(c, m);
if (m.trunk && pull?.base) {
const head = await client.branchHead(m.owner, m.repo, pull.base).catch(() => undefined);
if (head && !sameCommit(m.trunk, head)) status = { state: "pending", context: CHECK_CONTEXT, description: outOfDateWords(m.trunk, head) };
}
await client.setCommitStatus(m.owner, m.repo, m.commit, target ? { ...status, target_url: target } : status);
console.log(`[gitea] ${m.owner}/${m.repo}#${m.number ?? "?"} at ${m.commit.slice(0, 8)}: the group ${c.group}'s composed check ${c.verdict}, ` +
`its merge gate ${status.state}`);
}, c.id);
}
}
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
+16 -5
View File
@@ -11,7 +11,7 @@ import type { CommitStatus, GiteaClient } from "./client.js";
import { mergeableUnder, newestByContext } from "./checks.js";
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
import { groupRefusal, memberKey, namesSiblings, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk } from "./trunk.js";
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
@@ -47,6 +47,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
method: { type: "string", description: "merge (default) | fast-forward-only | squash | rebase | rebase-merge. " +
"Only merge and fast-forward-only can be read back to prove the head you named is what landed" },
delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" },
approved_heads: { type: "array", items: { type: "string" }, description: "the heads the operator's Approve covers, " +
"each owner/repo@<full sha>, one per member of the group (novox/hq ADR 0421 §9): refused unless this pull " +
"request's repository is named at the head it has now. Required when the pull request's verdict read siblings, " +
"where each sibling's head must be named too; there the Approve takes the place of an approving review" },
},
run: async (args) => {
const owner = checkName("owner", args.owner);
@@ -82,6 +86,14 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` +
`${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`);
}
// **Only a head the operator's Approve covers** (novox/hq ADR 0421 §9): mesh-delivery hands the heads its Approve
// named; a pull request whose head differs is refused, so a push between the Approve and this call merges nothing.
const approved = approvedHeadsOf(args.approved_heads);
if (approved) {
const why = notApproved(approved, `${owner}/${repo}`, number, String(pull.head_sha));
if (why) throw new Refused(`${owner}/${repo}#${number} is not merged: ${why}. A merge lands the heads the operator ` +
`approved (novox/hq ADR 0421). Nothing was merged`);
}
const rule = await gitea.appliedProtection(owner, repo, pull.base);
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
// branch nothing guards would merge past every check (novox/hq ADR 0266).
@@ -154,13 +166,12 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
`here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`);
}
if (siblings.length) {
const missing = await groupRefusal({ headChecks: (o, r, sha) => gitea.headChecks(o, r, sha),
approvedAt: (o, r, n, h) => gitea.approvedAt(o, r, n, h) },
{ owner, repo, number, head: String(pull.head_sha) }, siblings);
const missing = await groupRefusal({ headChecks: (o, r, sha) => gitea.headChecks(o, r, sha) },
{ owner, repo, number, head: String(pull.head_sha) }, siblings, approved);
if (missing) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict was judged beside ${siblings.length} ` +
`sibling(s), and ${missing}. A member judged beside its siblings merges once its group passed at the heads that ` +
`verdict read and each of them is approved there (novox/hq ADR 0382). Nothing was merged`);
`verdict read and the operator's Approve covers each of them (novox/hq ADR 0382, 0421). Nothing was merged`);
}
}
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha);
-15
View File
@@ -169,12 +169,6 @@
"description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"<team>\": {\"repos\": [\"<owner>/<repo>\", ...], \"members\": [\"<node>\", ...]}}. The forge gets a team agent-read-<team> that reads only, with exactly those repositories and the accounts agent-<node>; a team taken out of the value is emptied. Nothing else on the forge is touched; an agent account is in no team until it is listed here.",
"trusted": true,
"default": {}
},
"ready-authors": {
"type": "string",
"description": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, 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",
"trusted": true,
"default": "mesh-tools mesh-admin jschoubben"
}
},
"facts": {
@@ -198,14 +192,6 @@
"mode": "0700",
"unknown": "program"
},
{
"id": "ready-authors",
"type": "file",
"path": "${dir:mesh-state}/ready-authors",
"mode": "0600",
"trusted": true,
"template": "files/ready-authors"
},
{
"id": "forge-patches",
"type": "directory",
@@ -402,7 +388,6 @@
"MESH_GITEA_TOOLS_USER": "mesh-tools",
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
"MESH_RECEIVES": "${dir:grants}/npm.json",
"MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors",
"MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047",
"MESH_GITEA_HOOK_PORT": "3047",
"MESH_GITEA_HOOK_HOST": "172.17.0.1"
+32 -2
View File
@@ -58,8 +58,11 @@ export interface Checked {
"repo-check"?: Layer;
/** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */
plan?: ChangePlan;
/** Set on a delivery group's composed check (novox/hq ADR 0239): not this head's merge gate. */
/** Set on a delivery group's composed check (novox/hq ADR 0239): its verdict is each member's merge gate (ADR 0403 §1,
* issue 978), set in setGroupVerdict, never read as this head's own. */
group?: string;
/** The members a composed check read, each at its head and the trunk commit it was merged onto (ADR 0323 §6). */
members?: { owner: string; repo: string; number?: number; commit: string; trunk?: string }[];
/** The trunk commit the head was merged into and judged as, twelve characters (novox/hq ADR 0323 §3); absent from a
* controller whose build seat judged the head alone. Beside are the other repositories' commits the check read,
* by directory; took how long it ran. */
@@ -234,8 +237,35 @@ export function statusFor(c: Checked): CommitStatus {
return judgedFirst(c, { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description });
}
/**
* **A member's gate from its group's composed verdict** (novox/hq ADR 0403 §1, issue 978): the composed check, which
* composed every machine with all the members' heads, sets each member's `mesh/merge-gate` at the head it read. The
* description names what it judged first, as a member's own gate does (ADR 0323 §3), and the group.
*/
export function groupGateStatus(c: Checked, m: NonNullable<Checked["members"]>[number]): CommitStatus {
const said = described(c.verdict, `group ${c.group} composed: ${c.summary}`);
const first = m.trunk ? `head ${m.commit.slice(0, 12)} merged into trunk ${m.trunk.slice(0, 12)} — ` : "";
return { state: stateOf(c.verdict), context: CHECK_CONTEXT, description: clipped(first + said) };
}
/** Whether a verdict's gate layer is the group's composed check's (ADR 0403 §1): no gate was run for the head alone. */
export function composedGate(c: Checked): boolean {
return c.gate?.verdict === "composed";
}
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}): CommitStatus[] {
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] {
if (composedGate(c)) {
// A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
const out: CommitStatus[] = [];
if (!(gateNow ?? "").includes(" composed: ")) {
out.push({ state: "pending", context: CHECK_CONTEXT, description: "waits for its group's composed check" });
}
const repo = c["repo-check"];
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
return out;
}
const out = [statusFor(c)];
const repo = c["repo-check"];
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
+35 -12
View File
@@ -18,6 +18,7 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { CommitStatus, GiteaPull, HeadCheck } from "./client.js";
import { Refused } from "./guard.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, clipped } from "./pulls.js";
import { sameCommit, twelve, type OutOfDateWriter } from "./trunk.js";
@@ -250,20 +251,43 @@ export async function weighSiblings(forge: SiblingWeighing, gates: OutOfDateWrit
/** What the rule reads beside the pull request being merged. */
export interface GroupForge {
headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>>;
/** Whether the sibling's head carries an approving review at that head. */
approvedAt(owner: string, repo: string, number: number, head: string): Promise<boolean>;
}
/** The heads the operator's Approve covers (novox/hq ADR 0421 §9, issue 1022), as mesh-delivery hands them to the merge
* tool: `owner/repo@<full sha>`, one per member, by repository. A malformed entry is refused, never skipped. */
export function approvedHeadsOf(given: unknown): Map<string, string> | undefined {
if (given === undefined || given === null) return undefined;
if (!Array.isArray(given)) throw new Refused("approved_heads must be a list of owner/repo@<full sha>. Nothing was merged");
const out = new Map<string, string>();
for (const entry of given) {
const m = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)@([0-9a-f]{40}|[0-9a-f]{64})$/i.exec(String(entry));
if (!m) throw new Refused(`approved_heads holds ${JSON.stringify(entry)}, which is not owner/repo@<full sha>. Nothing was merged`);
out.set(m[1].toLowerCase(), m[2].toLowerCase());
}
return out;
}
/** Why a pull request's head is not one the Approve covers, in words, or nothing: its repository is not named, or names
* another head. */
export function notApproved(approved: Map<string, string>, repository: string, number: number, head: string): string | undefined {
const covered = approved.get(repository.toLowerCase());
const at = `${repository.split("/")[1]}#${number}@${twelve(head)}`;
if (covered === undefined) return `${at} is not among the heads the Approve covers`;
if (covered !== head.toLowerCase()) return `${at} is not the head the Approve covers (${twelve(covered)})`;
return undefined;
}
/**
* What stops a pull request whose verdict read siblings from merging, in words, or nothing: mesh/delivery-group must be
* a success at its own head and at every sibling head the verdict read, and every sibling head read must carry an
* approving review at that head. Every missing thing is named.
* a success at its own head and at every sibling head the verdict read, and every head read must be one the operator's
* Approve covers (ADR 0421 §9: the Approve is the review; the merge tool takes the approved heads from mesh-delivery).
* Every missing thing is named.
*/
export async function groupRefusal(forge: GroupForge, self: { owner: string; repo: string; number: number; head: string },
siblings: SiblingNamed[]): Promise<string | undefined> {
siblings: SiblingNamed[], approved: Map<string, string> | undefined): Promise<string | undefined> {
const missing: string[] = [];
const members = [{ repository: `${self.owner}/${self.repo}`, number: self.number, head: self.head, sibling: false },
...siblings.map((s) => ({ repository: s.repository, number: s.number, head: s.head, sibling: true }))];
const members = [{ repository: `${self.owner}/${self.repo}`, number: self.number, head: self.head },
...siblings.map((s) => ({ repository: s.repository, number: s.number, head: s.head }))];
for (const m of members) {
const [owner, repo] = m.repository.split("/");
const at = `${repo}#${m.number}@${twelve(m.head)}`;
@@ -275,11 +299,10 @@ export async function groupRefusal(forge: GroupForge, self: { owner: string; rep
continue;
}
if (group?.state !== "success") missing.push(`${GROUP_CONTEXT} is ${group?.state ?? "missing"} at ${at}`);
if (!m.sibling) continue;
try {
if (!(await forge.approvedAt(owner, repo, m.number, m.head))) missing.push(`${at} carries no approving review at that head`);
} catch (err) {
missing.push(`whether ${at} carries an approving review could not be read (${err instanceof Error ? err.message : err})`);
if (!approved) missing.push(`${at} is covered by no Approve: the merge was handed no approved heads`);
else {
const why = notApproved(approved, m.repository, m.number, m.head);
if (why) missing.push(why);
}
}
return missing.length ? missing.join("; ") : undefined;
+10 -84
View File
@@ -145,16 +145,11 @@ test("a merged pull request whose branch was deleted is announced with its branc
assert.equal(headBranchOf(undefined), undefined);
});
// **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 <full head sha>" 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.
// **An open pull request is read from the forge with no ready mark** (novox/hq ADR 0421 §4, issue 1022): its head, base,
// required contexts and statuses, and no timeline.
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[]) {
function forgeWith() {
const asked: string[] = [];
const server = createServer((req, res) => {
const url = req.url ?? "";
@@ -162,8 +157,7 @@ function forgeWith(timeline: unknown[]) {
const send = (v: unknown) => res.end(JSON.stringify(v));
if (url.startsWith("/api/v1/repos/issues/search")) {
return send(url.includes("page=1") ? [
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" },
labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] },
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [{ id: 2, name: "bug" }] },
{ number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] },
] : []);
}
@@ -173,11 +167,6 @@ function forgeWith(timeline: unknown[]) {
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" });
}
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;
@@ -186,80 +175,17 @@ function forgeWith(timeline: unknown[]) {
return { server, asked };
}
async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) {
test("an open pull request is read with its head and statuses, and no ready mark nor timeline", async () => {
const { GiteaClient } = await import("../client.ts");
const { server, asked } = forgeWith(timeline);
const { server, asked } = forgeWith();
await new Promise<void>((r) => server.listen(0, r));
try {
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
return { open: await client.openPulls(authors), asked };
const open = await client.openPulls();
assert.deepEqual(open.map((p) => [p.number, p.head_sha]), [[7, head], [8, older]]);
assert.equal((open[0] as any).ready, undefined);
assert.ok(!asked.some((u) => u.includes("/timeline")), "no timeline is read");
} 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"]);
// The tools' account and the admin's, whose marks were made before ADR 0365 (issue 469).
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n", "mesh-tools"), ["mesh-tools", "mesh-admin"]);
assert.deepEqual(readyAuthors(undefined, "mesh-admin", undefined, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
});
+32 -27
View File
@@ -566,12 +566,16 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
});
// **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6, issue 867):
// the verdict names the sibling; mesh/delivery-group must be a success at the member's head and the sibling's head that
// verdict read, and the sibling's head must carry an approving review. A verdict that read none merges as before.
test("gitea_merge_pull_request merges a verdict that read siblings only with its group passed and each sibling head reviewed", async () => {
// **A member judged beside its siblings merges only with its group passed and the Approve covering each head** (novox/hq
// ADR 0382 §6 as ADR 0421 §9 changes it, issues 867 and 1022): the verdict names the sibling; mesh/delivery-group must
// be a success at the member's head and the sibling's head that verdict read, and mesh-delivery hands the heads the
// operator's Approve covers: every head read must be among them. No forge review is asked. A verdict that read none
// merges as before.
test("gitea_merge_pull_request merges a verdict that read siblings only with its group passed and each head approved", async () => {
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD];
const merge = (approved_heads?: string[]) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED, ...(approved_heads ? { approved_heads } : {}) });
const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${SIBLING_HEAD.slice(0, 8)}`;
const named = (siblings: unknown[]) => keepSiblings(siblingsFile(stateDir),
{ "novox/mesh-catalog#7": { head: REVIEWED, siblings: siblings as any } });
@@ -579,31 +583,31 @@ test("gitea_merge_pull_request merges a verdict that read siblings only with its
{ context: "mesh/repo-check", state: "success", description: `${beside} — pass: y` }];
named([{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: "c".repeat(40) }]);
// Nothing passed: every missing thing is named, nothing merged.
// Nothing passed and no approved heads handed: every missing thing is named, nothing merged.
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /is not merged: its verdict was judged beside 1 sibling\(s\), and mesh\/delivery-group is missing at mesh-catalog#7@abc1234def56; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review at that head/);
assert.match(err.message, /is not merged: its verdict was judged beside 1 sibling\(s\), and mesh\/delivery-group is missing at mesh-catalog#7@abc1234def56; mesh-catalog#7@abc1234def56 is covered by no Approve: the merge was handed no approved heads; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb is covered by no Approve/);
return true;
});
assert.equal(state.merged, false);
// The group passed at the member's head only: the sibling's head still lacks it.
state.groupStatus = "success";
await assert.rejects(merge(), /mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review/);
// Passed at both heads, but the only approval is at another head, or dismissed: not an approval at the head read.
await assert.rejects(merge(both), /mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb\. A member judged beside/);
// Passed at both heads, but the Approve covers the sibling at another head, or does not name it: not an approved head.
state.siblingGroup = "success";
state.reviews = [{ state: "APPROVED", commit_id: "d".repeat(40) }, { state: "APPROVED", commit_id: SIBLING_HEAD, dismissed: true },
{ state: "COMMENT", commit_id: SIBLING_HEAD }];
await assert.rejects(merge(), /judged beside 1 sibling\(s\), and mesh-host#114@bbbbbbbbbbbb carries no approving review at that head\. A member judged beside/);
await assert.rejects(merge(["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + "d".repeat(40)]),
/mesh-host#114@bbbbbbbbbbbb is not the head the Approve covers \(dddddddddddd\)\. A member judged beside/);
await assert.rejects(merge(["novox/mesh-catalog@" + REVIEWED]), /mesh-host#114@bbbbbbbbbbbb is not among the heads the Approve covers/);
assert.equal(state.merged, false);
// Passed and approved at the very heads read: it merges.
state.reviews.push({ state: "APPROVED", commit_id: SIBLING_HEAD });
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
// Passed and approved at the very heads read, with no forge review of the sibling: it merges.
assert.deepEqual(state.reviews, [], "no review is asked of the forge");
assert.equal(((await merge(both)) as Record<string, unknown>).merged, true);
// A status that says it read siblings, with no record of them, is no verdict to merge on.
state.merged = false;
keepSiblings(siblingsFile(stateDir), {});
await assert.rejects(merge(), /its verdict read siblings, and which ones is not held here/);
await assert.rejects(merge(both), /its verdict read siblings, and which ones is not held here/);
assert.equal(state.merged, false);
// A verdict that read none (an older controller, or a group of one) merges exactly as before.
@@ -612,16 +616,17 @@ test("gitea_merge_pull_request merges a verdict that read siblings only with its
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
test("a verdict put out of date by a sibling's move is refused in its own words, and asked again once", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
const words = "out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd; judged again on a recheck or when it is merged";
state.statuses = judgedAt(TRUNK).map((s) => ({ ...s, state: "pending", description: words }));
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /its verdict was put out of date by a sibling's move \(out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd/);
assert.match(err.message, /was asked/);
assert.match(err.message, /never merges by itself/);
return true;
});
// **The merge lands only the head the Approve named** (novox/hq ADR 0421 §9, issue 1022): a pull request whose head
// differs from the approved one, or whose repository the Approve does not name, is refused with nothing merged.
test("gitea_merge_pull_request refuses a head the Approve did not name", async () => {
state.merged = false;
state.statuses = judgedAt(TRUNK);
const merge = (approved_heads: unknown) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED, approved_heads });
await assert.rejects(merge(["novox/mesh-catalog@" + "e".repeat(40)]), /is not the head the Approve covers \(eeeeeeeeeeee\)/);
await assert.rejects(merge(["novox/mesh-host@" + REVIEWED]), /is not among the heads the Approve covers/);
await assert.rejects(merge(["novox/mesh-catalog@abc123"]), /which is not owner\/repo@<full sha>/);
await assert.rejects(merge("novox/mesh-catalog@" + REVIEWED), /must be a list/);
assert.equal(state.merged, false);
assert.equal(state.emitted.length, 1, "the check is asked, once");
assert.equal(((await merge(["novox/mesh-catalog@" + REVIEWED.toUpperCase()])) as Record<string, unknown>).merged, true);
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
// **No ready mark is left in the forge module** (novox/hq ADR 0421 §4, issue 1022; how it is checked): no label, comment
// reading, trusted setting, file or merge rule of the mark stands in any of its sources, its manifest or its files. Tests
// are left out: they name the words to prove them gone.
const root = new URL("..", import.meta.url).pathname;
const FORBIDDEN = ["mesh/ready", "ready at", "ready-authors", "READY_AUTHORS", "readyAuthors", "readyMarkOf", "READY_LABEL", "approvedAt("];
function sources(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
if (name === "node_modules" || name === "dist" || name === "test") continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) out.push(...sources(path));
else if (/\.(ts|json|go)$/.test(name) || !name.includes(".")) out.push(path);
}
return out;
}
test("no ready mark, trusted ready-authors setting or approval-by-mark rule is left in the module", () => {
const found: string[] = [];
for (const file of sources(root)) {
if (/package-lock|go\.sum/.test(file)) continue;
const text = readFileSync(file, "utf8");
for (const word of FORBIDDEN) if (text.includes(word)) found.push(`${file.slice(root.length)} holds "${word}"`);
}
assert.deepEqual(found, [], "the ready mark is gone (ADR 0421 §4)");
});
+38
View File
@@ -154,3 +154,41 @@ test("a head announced is pending in the build queue, since the local time it wa
assert.equal(pendingWords(new Date(2026, 9, 11, 3, 8, 50)), "waiting in the build queue since 03:08");
assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00");
});
// **A group's composed verdict is each member's merge gate** (novox/hq ADR 0403 §1, issue 978): set at the head the
// composed check read, naming the trunk commit it was merged onto, the group, and what the check said.
test("a composed verdict sets every member's gate at its own head, naming its trunk and the group", async () => {
const { groupGateStatus, CHECK_CONTEXT } = await import("../pulls.ts");
const c = { owner: "", repo: "", commit: "", id: "build-1", group: "feat/x", verdict: "pass", summary: "both compose",
members: [{ owner: "novox", repo: "mesh-controller", number: 1, commit: "c".repeat(40), trunk: "a".repeat(40) },
{ owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40) }] };
const [first, second] = c.members.map((m) => groupGateStatus(c, m));
assert.equal(first.context, CHECK_CONTEXT);
assert.equal(first.state, "success");
assert.match(first.description, /^head cccccccccccc merged into trunk aaaaaaaaaaaa — pass: group feat\/x composed: both compose/);
assert.equal(second.state, "success");
assert.match(second.description, /^pass: group feat\/x composed: both compose/, "no trunk named, none said");
// A composed check that failed is every member's failure, never a success.
assert.equal(groupGateStatus({ ...c, verdict: "fail", summary: "a and b do not compose" }, c.members[0]).state, "failure");
assert.equal(groupGateStatus({ ...c, verdict: "error", summary: "could not run" }, c.members[0]).state, "error");
});
// A member's own verdict whose gate layer is the group's composed check's sets its repository check only, and never the
// gate as an error: the gate stays pending until the composed verdict lands, in either order (ADR 0403 §1, issue 978).
test("a member's own verdict with a composed gate layer leaves the gate to the composed verdict, in either order", async () => {
const { statusesFor, composedGate, groupGateStatus, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
const c = { owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40), id: "build-1", verdict: "pass", summary: "x",
gate: { verdict: "composed", summary: "judged by its group" }, "repo-check": { verdict: "pass", summary: "suite passed" } };
assert.ok(composedGate(c));
// Before the composed verdict: the repository check, and the gate pending, saying it waits.
const before = statusesFor(c, {}, undefined);
assert.deepEqual(before.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "pending"], [REPO_CHECK_CONTEXT, "success"]]);
assert.match(before[0].description, /waits for its group's composed check/);
assert.ok(!before.some((s) => s.state === "error"));
// After it: the standing composed gate is left alone.
const standing = groupGateStatus({ ...c, group: "feat/x", members: [] }, { owner: "novox", repo: "mesh-catalog", commit: c.commit }).description;
const after = statusesFor(c, {}, standing);
assert.deepEqual(after.map((s) => [s.context, s.state]), [[REPO_CHECK_CONTEXT, "success"]]);
// A verdict with an ordinary gate is as before.
assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false);
});
+13 -12
View File
@@ -10,7 +10,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile,
import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile,
siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js";
import { weighTrunks } from "../dist/trunk.js";
import { commentFor, statusesFor } from "../dist/pulls.js";
@@ -224,27 +224,28 @@ test("a trunk that moved tells the siblings' weighing; one first seen does not",
// --- the merge rule -------------------------------------------------------------------------------------------
test("the group rule names every missing thing: the group status at each head read, and each sibling head's review", async () => {
test("the group rule names every missing thing: the group status at each head read, and each head the Approve does not cover", async () => {
const self = { owner: "novox", repo: "mesh-controller", number: 346, head: MEMBER_HEAD };
const statuses: Record<string, string> = {};
const approved = new Set<string>();
const forge = {
headChecks: async (_o: string, r: string, sha: string) => (statuses[`${r}@${sha}`] ? { "mesh/delivery-group": { state: statuses[`${r}@${sha}`] } } : {}),
approvedAt: async (_o: string, r: string, n: number, head: string) => approved.has(`${r}#${n}@${head}`),
};
const siblings = siblingsOf([sibling]);
const none = await groupRefusal(forge, self, siblings);
assert.match(none!, /mesh\/delivery-group is missing at mesh-controller#346@aaaaaaaaaaaa; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review at that head/);
const none = await groupRefusal(forge, self, siblings, undefined);
assert.match(none!, /mesh\/delivery-group is missing at mesh-controller#346@aaaaaaaaaaaa; mesh-controller#346@aaaaaaaaaaaa is covered by no Approve: the merge was handed no approved heads; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb is covered by no Approve/);
statuses[`mesh-controller@${MEMBER_HEAD}`] = "success";
statuses[`mesh-host@${HOST_HEAD}`] = "pending";
assert.match((await groupRefusal(forge, self, siblings))!, /^mesh\/delivery-group is pending at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review/);
const approved = approvedHeadsOf([`novox/mesh-controller@${MEMBER_HEAD}`])!;
assert.match((await groupRefusal(forge, self, siblings, approved))!, /^mesh\/delivery-group is pending at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb is not among the heads the Approve covers$/);
statuses[`mesh-host@${HOST_HEAD}`] = "success";
assert.match((await groupRefusal(forge, self, siblings))!, /^mesh-host#114@bbbbbbbbbbbb carries no approving review at that head$/);
approved.add(`mesh-host#114@${HOST_HEAD}`);
assert.equal(await groupRefusal(forge, self, siblings), undefined);
assert.match((await groupRefusal(forge, self, siblings, approved))!, /^mesh-host#114@bbbbbbbbbbbb is not among the heads the Approve covers$/);
approved.set("novox/mesh-host", "c".repeat(40));
assert.match((await groupRefusal(forge, self, siblings, approved))!, /^mesh-host#114@bbbbbbbbbbbb is not the head the Approve covers \(cccccccccccc\)$/);
approved.set("novox/mesh-host", HOST_HEAD.toLowerCase());
assert.equal(await groupRefusal(forge, self, siblings, approved), undefined);
// A read that fails is named, never read as a pass.
const broken = { ...forge, approvedAt: async () => { throw new Error("the forge is away"); } };
assert.match((await groupRefusal(broken, self, siblings))!, /could not be read \(the forge is away\)/);
const broken = { headChecks: async () => { throw new Error("the forge is away"); } };
assert.match((await groupRefusal(broken, self, siblings, approved))!, /could not be read \(the forge is away\)/);
assert.equal(besideWords(siblings), "beside mesh-host#114@bbbbbbbb");
});
+1 -6
View File
@@ -282,15 +282,10 @@ test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it
assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/);
});
test("the manifest hands the tools their account, and the merge-ready authors name it rather than the administrator", async () => {
test("the manifest hands the tools their account, and the Go forge bundle holds no administrator password", async () => {
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
const code = manifest.build.artifacts.find((a: any) => a.name === "code");
assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT);
const authors = String(manifest.config["ready-authors"].default).split(/\s+/);
assert.ok(authors.includes(TOOLS_ACCOUNT));
// Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as
// their own keeps counting as ready after the delivery.
assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting");
// The Go forge bundle reads the token the tools' account holds, never the administrator's password.
const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge");
assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE);
+3 -3
View File
@@ -73,12 +73,12 @@ test("each forge event is named in the module's stream and trimmed", async () =>
head: { sha: "a".repeat(40) }, base: { ref: "main" } }, repository: { name: "hq", owner: { login: "novox" } } });
assert.equal(merged.name, "forge.pull-request.merged");
assert.equal(merged.body.merge_commit_sha, "c".repeat(40));
assert.equal(eventOf("pull_request", { action: "label_updated", pull_request: { number: 3 }, label: { name: "mesh/ready" } }).name,
assert.equal(eventOf("pull_request", { action: "label_updated", pull_request: { number: 3 }, label: { name: "kind/bug" } }).name,
"forge.pull-request.labeled");
const comment = eventOf("issue_comment", { action: "created", is_pull: true, issue: { number: 3 },
comment: { id: 9, body: "ready at abc", user: { login: "jo" } } });
comment: { id: 9, body: "looks fine to me", user: { login: "jo" } } });
assert.equal(comment.name, "forge.pull-request.commented");
assert.equal(comment.body.comment, "ready at abc");
assert.equal(comment.body.comment, "looks fine to me");
assert.equal(eventOf("push", { ref: "refs/heads/main", after: "d".repeat(40) }).name, "forge.push");
assert.equal(eventOf("pull_request_review_approved", {}).name, "forge.other");
});
+3 -3
View File
@@ -10,7 +10,7 @@
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { emit } from "@novox/mesh-sdk/events";
import { GiteaAdmin, GiteaClient, readyAuthors, type BranchProtection } from "../client.js";
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
@@ -518,9 +518,9 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
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 named by the newest comment \"ready at <full head sha>\" 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.",
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). Reads only.",
input: {},
run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }),
run: async () => ({ pulls: await gitea.openPulls() }),
},
{
name: "gitea_contains",
+1 -1
View File
@@ -73,7 +73,7 @@ function login(u: any): string | undefined {
/**
* The event a forge delivery is: its kind (X-Gitea-Event) and the payload's action, trimmed to ids, repository, refs,
* shas, author, labels and a comment's body (a ready mark is one, ADR 0364). A merge is pull-request.merged, a comment on
* shas, author, labels and a comment's body. A merge is pull-request.merged, a comment on
* a pull request pull-request.commented.
*/
export function eventOf(kind: string, payload: any): ForgeEvent {
@@ -294,7 +294,7 @@ func (h *Holder) orphanLines(now time.Time) []StalledLine {
continue
}
w := s.walk
if lapsed, behind := h.lapsedOf(w); len(lapsed) > 0 || len(behind) > 0 {
if len(h.lapsedOn(w)) > 0 {
// Owed its word by stops that lapsed, and not given it past the grace (review of issue 753): said, why.
says := fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, which the stopped deliveries whose "+
"merge it answers owe it (novox/hq issue 753), and it has not started", w.ID, w.Repository, shortOf(w.Commit, 8))
@@ -402,35 +402,19 @@ func (h *Holder) endedSaid(w Walk) string {
// a walk whose every other delivery ended is never left without one. A walk at the merge of a delivery stopped for
// itself, or by a person, gets nothing from it. Under the lock.
//
// In its group's order (review of issue 753): a member gives its word only once every member ordered before it reached
// the machines, or is itself a stop that lapsed whose merge this same walk answers — the walk then delivers both, in
// the group's order among its tiers, as members sharing one walk do. A member behind one still undelivered gives
// nothing; lapsedBehind names that one.
// No member is ordered before another (novox/hq ADR 0418 §2): each such delivery gives its word at once.
func (h *Holder) lapsedOn(w Walk) []string {
out, _ := h.lapsedOf(w)
return out
}
// lapsedOf is lapsedOn, and, by delivery, the member before it in its group that holds back the word of a stop that
// lapsed whose merge the walk answers. Under the lock.
func (h *Holder) lapsedOf(w Walk) ([]string, map[string]string) {
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits {
return nil, nil
return nil
}
var out []string
behind := map[string]string{}
for _, d := range h.deliveries {
if !h.lapsedAnswered(w, d) {
continue
if h.lapsedAnswered(w, d) {
out = append(out, d.ID)
}
if before := h.lapsedBehind(w, d); before != "" {
behind[d.ID] = before
continue
}
out = append(out, d.ID)
}
sort.Strings(out)
return out, behind
return out
}
// lapsedAnswered is whether a delivery's stop lapsed and the walk answers its merge, not left out.
@@ -445,52 +429,17 @@ func (h *Holder) lapsedAnswered(w Walk, d *Delivery) bool {
return !left
}
// lapsedBehind is the member ordered before a delivery in its group that has not reached the machines and that this
// walk does not deliver with it, with its state; empty for none.
func (h *Holder) lapsedBehind(w Walk, d *Delivery) string {
g := h.groups[d.Group]
if d.Group == "" || g == nil {
return ""
}
members := h.membersOf(g)
byID := map[string]*Delivery{}
for _, m := range members {
byID[m.ID] = m
}
for _, m := range h.inOrder(g, members) {
if m.ID == d.ID {
return ""
}
o := byID[m.ID]
if o == nil || memberReached(o) || h.lapsedAnswered(w, o) {
continue
}
return fmt.Sprintf("%s (%s)", o.ID, o.State)
}
return ""
}
// lapsedSaid is why a walk whose word stops that lapsed give still waits: the members of their groups not yet
// delivered before them, what the last look held it for, or the controller's refusal of the word. Under the lock.
func (h *Holder) lapsedSaid(w Walk) string {
var parts []string
_, behind := h.lapsedOf(w)
ids := make([]string, 0, len(behind))
for id := range behind {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
parts = append(parts, fmt.Sprintf("%s waits for %s, ordered before it in its group", id, behind[id]))
}
if why := h.waitsWhy[w.ID]; why != "" {
parts = append(parts, "not let go yet: "+why)
}
if err := h.deliverErr[w.ID]; err != "" {
parts = append(parts, "the controller refused its word: "+err)
}
lapsed, _ := h.lapsedOf(w)
for _, id := range lapsed {
for _, id := range h.lapsedOn(w) {
if d := h.deliveries[id]; d != nil && strings.Contains(d.LetGoWhy, lapsedWord(w.ID)) {
parts = append(parts, fmt.Sprintf("its word was given for %s at %s, and the controller has not started it",
id, d.LetGo.Local().Format("15:04:05")))
@@ -499,10 +448,9 @@ func (h *Holder) lapsedSaid(w Walk) string {
return strings.Join(parts, "; ")
}
// lapsedOwed is whether a walk's word is owed by stops that lapsed: given now, or once a member before one is delivered.
// lapsedOwed is whether a walk's word is owed by stops that lapsed.
func (h *Holder) lapsedOwed(w Walk) bool {
lapsed, behind := h.lapsedOf(w)
return len(lapsed) > 0 || len(behind) > 0
return len(h.lapsedOn(w)) > 0
}
// lapsedTurns registers, for every open walk waiting for this owner's word, the turn of each delivery whose stop
@@ -679,7 +627,7 @@ func (h *Holder) WalksWaiting() []StalledLine {
case !live && h.lapsedOwed(w):
// Its word comes from the stopped deliveries whose merge it answers (novox/hq issue 753): said, never
// unheld, while the controller has not taken it, with what holds it.
lapsed, _ := h.lapsedOf(w)
lapsed := h.lapsedOn(w)
says := "its word is owed by the stopped deliveries whose merge it answers, each stopped for a member " +
"delivered since (novox/hq issue 753)"
if len(lapsed) > 0 {
@@ -11,17 +11,17 @@ import (
"git.novox.be/novox/mesh-sdk/go/asks"
)
// A merge into main waits for the operator's Approve (novox/hq ADR 0398 §3, issue 948).
// A merge into main waits for the operator's Approve (novox/hq ADR 0398 §3, issue 948, ADR 0421).
//
// Where a ready mark at the head, a passing check and the group's turn used to call the merge tool at once, this
// owner now asks the operator on the router (the operator channel, as the knowledge base asks for a record): Approve
// or Decline, each option binding the act "merge these heads". It merges only after an Approve whose warrant holds for
// that ask — the same asker, the same ask's digest, an option it offered, in time, and the act that option bound.
// This owner asks the operator on the router (the operator channel, as the knowledge base asks for a record) as soon as
// a pull request's checks pass: Approve or Decline, each option binding the act "merge these heads". It merges only after
// an Approve whose warrant holds for that ask — the same asker, the same ask's digest, an option it offered, in time, and
// the act that option bound.
//
// - Asked once per head. A group is asked once, for all its members' heads together, by the first member that reaches
// this point; the Approve covers every member at those heads.
// - A push after the ask cancels it (the mark is cleared with it): the new head needs a new mark and a new Approve.
// - A Decline merges nothing, is said once on the pull request, and clears the mark.
// - A push after the ask ends it, said once on the pull request: the new head is asked on its own.
// - A Decline merges nothing and is said once on the pull request.
// - An ask that ended without a choice (expired, cancelled, refused by the router) is asked again when the router says so.
// - The ask, its state and the warrant's words are kept on the delivery's ReadyRun and shown by `show`.
//
@@ -119,7 +119,7 @@ func repoName(repository string) string {
func pullSaid(d *Delivery) string { return fmt.Sprintf("%s#%d", repoName(d.Repository), d.Number) }
// composeMergeAsk is the ask for a delivery, or for a group's members together (in the order they merge).
// composeMergeAsk is the ask for a delivery, or for a group's members together.
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, now time.Time) asks.Ask {
var a asks.Ask
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
@@ -127,30 +127,32 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
a.About = "delivery." + phone(repoName(d.Repository), 40) + "." + strconv.Itoa(d.Number)
a.About = strings.ReplaceAll(a.About, " ", "-")
var reviewers, reaches, details []string
unreviewed := ""
for _, m := range members {
by, at := "a reviewer", ""
if m.Ready != nil {
if m.Ready.By != "" {
by = m.Ready.By
}
at = m.Ready.MarkedAt
words, ok := reviewWords(m)
if !ok && unreviewed == "" {
unreviewed = pullSaid(m) + " is " + words
}
reviewers = append(reviewers, fmt.Sprintf("%s marked %s ready at %s", by, pullSaid(m), shortOf(m.Commit, 12)))
reviewers = append(reviewers, fmt.Sprintf("%s: %s", pullSaid(m), words))
plan := "no delivery plan yet"
if m.Plan != nil && m.Plan.Summary != "" {
plan = m.Plan.Summary
}
reaches = append(reaches, plan)
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; reviewer: %s, marked %s; reaches: %s",
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), by, at, plan))
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; review: %s; reaches: %s",
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, plan))
}
if group == "" {
a.Headline = phone("Merge into main: "+pullSaid(d)+" "+d.Title, asks.HeadlineLength)
summary := fmt.Sprintf("%s %s passed its checks. Approve merges it into main at %s; Decline leaves it unmerged.",
pullSaid(d), d.Title, shortOf(d.Commit, 12))
if unreviewed != "" {
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
}
a.Explanation = strings.Join([]string{
phone(fmt.Sprintf("%s %s passed its checks and was reviewed. Approve merges it into main at %s; Decline leaves it unmerged.",
pullSaid(d), d.Title, shortOf(d.Commit, 12)), asks.SummaryLength),
phone(summary, asks.SummaryLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Reviewer: "+reviewers[0], asks.PointLength),
phone("Review: "+reviewers[0], asks.PointLength),
phone("Reaches: "+reaches[0], asks.PointLength),
}, "\n")
} else {
@@ -159,11 +161,15 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
names = append(names, pullSaid(m))
}
a.Headline = phone("Merge into main: group "+group+" ("+strings.Join(names, ", ")+")", asks.HeadlineLength)
summary := fmt.Sprintf("The group %s, %d pull requests, passed its checks. Approve merges all of them into main in one pass, at the heads named; Decline leaves them unmerged.",
group, len(members))
if unreviewed != "" {
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
}
a.Explanation = strings.Join([]string{
phone(fmt.Sprintf("The group %s, %d pull requests, passed its checks and was reviewed. Approve merges all of them into main in their order, at the heads named; Decline leaves them unmerged.",
group, len(members)), asks.SummaryLength),
phone("Members, in order: "+strings.Join(names, ", "), asks.PointLength),
phone(summary, asks.SummaryLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Review: "+strings.Join(reviewers, "; "), asks.PointLength),
phone("Reaches: "+strings.Join(reaches, "; "), asks.PointLength),
}, "\n")
}
@@ -178,8 +184,8 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
}(), "\n")
}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "the pull request is merged into main at the head named (a group: all its members, in order)"},
{OptionDecline, "Decline", "nothing is merged and the ready mark is taken off"},
{OptionApprove, "Approve", "the pull request is merged into main at the head named (a group: all its members, in one pass)"},
{OptionDecline, "Decline", "nothing is merged"},
} {
binds, _ := asks.ActDigest(mergeAct(id, o.id, heads))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
@@ -187,7 +193,7 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
return a
}
// askMembers are the deliveries one ask covers: the group's members not yet merged, in the group's order, or the delivery.
// askMembers are the deliveries one ask covers: the group's members not yet merged, by id, or the delivery.
func (h *Holder) askMembers(d *Delivery) []*Delivery {
g := h.groups[d.Group]
if d.Group == "" || g == nil {
@@ -195,7 +201,9 @@ func (h *Holder) askMembers(d *Delivery) []*Delivery {
}
var out []*Delivery
seen := map[string]bool{}
for _, id := range append(append([]string(nil), g.Order...), g.Members...) {
ids := append([]string(nil), g.Members...)
sort.Strings(ids)
for _, id := range ids {
if o := h.deliveries[id]; o != nil && !seen[id] && o.MergedAs == "" && !o.State.Final() {
seen[id] = true
out = append(out, o)
@@ -261,16 +269,20 @@ func (h *Holder) headsHold(d *Delivery, c *ReadyRun) bool {
return true
}
// endStaleAsks ends every ask whose heads are no longer its group's: a push to any member, marked or not, ends the
// approval (ADR 0398 §3). Under the lock; the cancels run with the look's asks.
// endStaleAsks ends every ask whose heads are no longer its group's: a push to any member ends the approval (ADR 0398
// §3), and says so once on each pull request it covered (ADR 0421 §6). An ask for a head a newer one replaced ends too.
// A Decline stands, and is not ended. Under the lock; the cancels run with the look's asks.
func (h *Holder) endStaleAsks() {
for _, d := range h.deliveries {
r := d.Ready
if r == nil || r.Merged != nil || d.MergedAs != "" || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 {
if r == nil || r.Merged != nil || d.MergedAs != "" || r.Declined != nil || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 {
continue
}
if !h.headsHold(d, r) {
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "the heads it covers are no longer the group's heads"))
switch {
case d.State.Final():
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "its head "+shortOf(d.Commit, 12)+" is no longer the pull request's head, or the pull request closed"))
case !h.headsHold(d, r):
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "a head it covers moved on: the heads it covers are no longer the group's heads"))
}
}
}
@@ -307,14 +319,12 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
ask := composeMergeAsk(id, d, group, members, heads, now)
taken := []*Delivery{}
for _, m := range members {
if m.Ready == nil {
continue
}
h.runOf(m, now)
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks)
h.keep(m)
taken = append(taken, m)
}
h.Logf("[mesh-delivery] %s is marked ready and its checks passed: the operator is asked to Approve its merge into main (%s, ask %d)",
h.Logf("[mesh-delivery] %s passed its checks: the operator is asked to Approve its merge into main (%s, ask %d)",
d.ID, id, r.Asks)
return "the operator's Approve is asked (" + id + ")", func() {
if err := h.Operator.Ask(ask); err != nil {
@@ -332,19 +342,35 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
}
}
// dropAsk forgets a pending ask on every delivery it covers and takes it back from the router; under the lock.
// dropAsk forgets a pending ask on every delivery it covers and takes it back from the router; under the lock. What it
// says is said once on each pull request it covered (ADR 0421 §6): the Approve is ended, and why.
func (h *Holder) dropAsk(id, why string) func() {
var said []func()
seen := map[string]bool{}
var covered []*Delivery
for _, d := range h.deliveries {
if d.Ready != nil && d.Ready.AskID == id {
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
if d.Ready.Merged == nil {
d.Ready.Approved, d.Ready.Warrant = nil, ""
}
h.keep(d)
covered = append(covered, d)
}
}
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
for _, d := range covered {
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
if d.Ready.Merged == nil {
d.Ready.Approved, d.Ready.Warrant = nil, ""
}
h.keep(d)
if k := fmt.Sprintf("%s#%d", d.Repository, d.Number); !seen[k] && d.Number != 0 {
seen[k] = true
said = append(said, h.sayOn(d, "mesh-delivery: the operator's Approve of this pull request's merge into main is ended: "+why+
". Nothing is merged on it. The operator is asked again once the checks pass at the new head."))
}
}
h.Logf("[mesh-delivery] the operator's ask %s is cancelled: %s", id, why)
return func() {
for _, s := range said {
s()
}
if h.Operator == nil {
return
}
@@ -452,7 +478,7 @@ func (h *Holder) decided(id, state string, w *asks.Warrant) []func() {
r.Declined = &now
h.Logf("[mesh-delivery] %s: the operator declined its merge into main (%s)", d.ID, r.Warrant)
said = append(said, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** The merge of this pull request into main at %s was "+
"declined: %s. The ready mark is taken off; a new mark, once reviewed, asks again.", d.Commit, r.Warrant)))
"declined: %s. A new head asks the operator again.", d.Commit, r.Warrant)))
}
h.keep(d)
}
@@ -18,9 +18,10 @@ func markedGreen(t *testing.T) *world {
w.op.auto = false
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Title = "Fix the apostrophe (issue 948, issue 12)"
p.Body = "Reviewed-by: reviewer@shanks at " + readyHead
w.h.PullUpdated(p)
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
return w
@@ -41,7 +42,7 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
len(a.Options) != 2 || a.Options[0].ID != OptionApprove || a.Options[1].ID != OptionDecline {
t.Fatalf("the ask is not the merge ask: %+v", a)
}
for _, want := range []string{"issue 948", "issue 12", "reviewer", "builds app"} {
for _, want := range []string{"issue 948", "issue 12", "reviewed by reviewer on shanks", "builds app"} {
if !strings.Contains(a.Explanation, want) {
t.Fatalf("the ask does not say %q: %s", want, a.Explanation)
}
@@ -103,7 +104,7 @@ func TestAnAnswerThatIsNotForThisAskMergesNothing(t *testing.T) {
}
}
func TestADeclinedMergeIsNotMergedSaidOnceAndItsMarkTakenOff(t *testing.T) {
func TestADeclinedMergeIsNotMergedAndSaidOnce(t *testing.T) {
w := markedGreen(t)
w.op.answer(w.op.open()[0], OptionDecline)
for range 3 {
@@ -117,18 +118,12 @@ func TestADeclinedMergeIsNotMergedSaidOnceAndItsMarkTakenOff(t *testing.T) {
!strings.Contains(w.forge.comments[0], "chose Decline") {
t.Fatalf("the Decline is not said once on the pull request, with its warrant: %v", w.forge.comments)
}
if len(w.forge.unlabels) == 0 || w.forge.unlabels[0] != "novox/app#7-31" {
t.Fatalf("the mark was not taken off: %v", w.forge.unlabels)
}
}
func TestAPushAfterTheAskCancelsItAndTheNewHeadNeedsANewMarkAndApprove(t *testing.T) {
func TestAPushAfterTheAskCancelsItAndTheNewHeadIsAskedOnItsOwnWhenItsChecksPass(t *testing.T) {
w := markedGreen(t)
old := w.op.open()[0]
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)} // the old mark, on the new head
w.h.SeeOpenPulls()
w.settleAll()
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != old || len(w.op.open()) != 0 {
t.Fatalf("the ask was not cancelled: cancelled %v, open %v", w.op.cancelled, w.op.open())
@@ -139,9 +134,8 @@ func TestAPushAfterTheAskCancelsItAndTheNewHeadNeedsANewMarkAndApprove(t *testin
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready.AskID != "" {
t.Fatalf("the cancelled ask is still kept as waiting: %+v", d.Ready)
}
// The new head, marked by its reviewer, is asked afresh.
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, pushed)}
w.h.SeeOpenPulls()
// The new head is asked afresh once its checks pass.
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
w.settleAll()
if open := w.op.open(); len(open) != 1 || open[0] == old {
t.Fatalf("the new head was not asked afresh: %v", open)
@@ -165,18 +159,16 @@ func TestAnExpiredAskIsAskedAgainOnce(t *testing.T) {
}
}
func TestAGroupIsAskedOnceForAllItsHeadsAndMergedInOrderOnOneApprove(t *testing.T) {
func TestAGroupIsAskedOnceForAllItsHeadsAndMergedInOnePassOnOneApprove(t *testing.T) {
w := newWorld(t)
w.op.auto = false
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
openPull("novox/mesh-catalog", 2, catHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
@@ -191,41 +183,23 @@ func TestAGroupIsAskedOnceForAllItsHeadsAndMergedInOrderOnOneApprove(t *testing.
}
w.op.answer(a.ID, OptionApprove)
w.settleAll()
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead {
t.Fatalf("the first member was not merged alone on the Approve: %v", w.forge.merges)
}
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
w.settleAll()
if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead || w.op.count() != 1 {
t.Fatalf("the next member was not merged on the same Approve: merges %v, asks %d", w.forge.merges, w.op.count())
if len(w.forge.merges) != 2 || w.op.count() != 1 {
t.Fatalf("both members were not merged in one pass on the one Approve: merges %v, asks %d", w.forge.merges, w.op.count())
}
}
// A push to a group member without a mark ends the group's ask (ADR 0398 §3, issue 948; review round 1): the Approve
// that follows would be for heads the group no longer has.
func TestAPushToAnUnmarkedGroupMemberEndsTheGroupsAskAndApprove(t *testing.T) {
func TestAPushToAGroupMemberEndsTheGroupsAskAndApproveAndSaysSoOnEveryMember(t *testing.T) {
w := newWorld(t)
w.op.auto = false
const ctlHead, catHead, catPushed = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order {
var o []string
for _, m := range ms {
if m.Repository == "novox/mesh-controller" {
o = append([]string{m.ID}, o...)
} else {
o = append(o, m.ID)
}
}
return Order{Order: o}
}
_, _ = ctl, cat
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
openPull("novox/mesh-catalog", 2, catHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
@@ -236,7 +210,7 @@ func TestAPushToAnUnmarkedGroupMemberEndsTheGroupsAskAndApprove(t *testing.T) {
}
// The catalog member is pushed; its new head has no mark yet.
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catPushed, "feat/x", "modules/app/module.json"))
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
{Owner: "novox", Repo: "mesh-catalog", Number: 2, Base: "main", HeadSHA: catPushed}}
w.h.SeeOpenPulls()
w.settleAll()
@@ -328,13 +302,12 @@ func TestIssue1025AMergeInAGroupCarriesTheGroupAndItsHeads(t *testing.T) {
w.op.auto = false
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
openPull("novox/mesh-catalog", 2, catHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
@@ -346,14 +319,15 @@ func TestIssue1025AMergeInAGroupCarriesTheGroupAndItsHeads(t *testing.T) {
w.op.answer(open[0], OptionApprove)
w.settleAll()
got := mergedSaid(w)
if len(got) != 1 {
t.Fatalf("announced %d; wanted the first member's merge once: %v", len(got), got)
if len(got) != 2 {
t.Fatalf("announced %d; wanted each member's merge once, from the one pass: %v", len(got), got)
}
m := got[0]
hs, _ := m["heads"].(map[string]any)
if m["group"] != "feat/x" || m["ask_id"] != open[0] || m["option"] != OptionApprove || m["repo"] != "mesh-controller" ||
hs[ctl] != ctlHead || hs[cat] != catHead || len(hs) != 2 {
t.Fatalf("the group's merge event: %v", m)
for _, m := range got {
hs, _ := m["heads"].(map[string]any)
if m["group"] != "feat/x" || m["ask_id"] != open[0] || m["option"] != OptionApprove ||
hs[ctl] != ctlHead || hs[cat] != catHead || len(hs) != 2 {
t.Fatalf("the group's merge event: %v", m)
}
}
}
@@ -1,3 +1,5 @@
//go:build integration
package main
import (
@@ -21,9 +23,10 @@ import (
"github.com/nats-io/nats.go/jetstream"
)
// **The acceptance of acting on events** (novox/hq ADR 0406, design 47 milestone 2, ADR 0391 phase 3, issue 975): a
// pull request with a passing verdict gets its `mesh/ready` label event and its `ready at <head>` comment event, and
// the merge is asked within five seconds of the comment event — with no tick, no poll and no reconcile to find it.
// **The acceptance of acting on events** (novox/hq ADR 0406, design 47 milestone 2, ADR 0391 phase 3, issue 975; ADR
// 0421, issue 1022): a pull request whose verdict passes is asked of the operator within seconds of the verdict's event,
// with no label, comment or mark written by anyone, and the merge is asked within five seconds of the Approve's event —
// with no tick, no poll and no reconcile to find it.
// Proved over a real bus: a JetStream server is linked into the test and started for it alone; the module is this
// test binary started again as the bundle, speaking MCP on its standard streams to a runtime played here, which does
// what the tool runner does for a bundle — its state on the buckets the controller would make from the manifest, its
@@ -390,7 +393,7 @@ func (b *testBus) publish(t *testing.T, subject string, body any) time.Time {
return time.Now()
}
func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(t *testing.T) {
func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealBus(t *testing.T) {
b := newTestBus(t)
rt := b.start(t)
ctx, cancel := context.WithCancel(context.Background())
@@ -402,55 +405,22 @@ func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(
defer rt.mu.Unlock()
return len(rt.subs) >= len(followed)+1
})
for _, k := range followed {
if strings.Contains(k, "labeled") || strings.Contains(k, "commented") {
t.Fatalf("the module follows %s: a label or a comment starts nothing here (ADR 0421 §4)", k)
}
}
// A pull request into main is announced, and the controller's verdict on its head passes.
const head = "1111111111111111111111111111111111111111"
announced := pr("novox/app", 7, head, "fix/x", "src/a.go")
announced.BaseSHA = "t1t1t1t1t1t1"
announced.Title = "Fix the apostrophe (issue 975)"
announced.Body = "Reviewed-by: lead@shanks at " + head
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
rt.until("the module to keep the delivery of the head as ready", 10*time.Second, func() bool {
keys, err := b.kvs["deliveries"].Keys(context.Background())
if err != nil {
return false
}
for _, k := range keys {
e, err := b.kvs["deliveries"].Get(context.Background(), k)
var d Delivery
if err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Ready && d.Commit == head {
return true
}
}
return false
})
// The pull request's key holds its head, the trunk commit it was asked against and the verdict.
rt.until("the pull request's key to hold head, trunk and verdict", 5*time.Second, func() bool {
e, err := b.kvs["pulls"].Get(context.Background(), "novox.app.7")
var k PullKey
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
})
reads := len(rt.asksOf("gitea.gitea_open_pulls"))
passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
// The reviewer's label and comment: the forge shows the label alone at first, and the mark whole once the comment is
// written. Nothing is asked for the label alone.
rt.mu.Lock()
rt.openPulls = []OpenPull{markedPull("novox/app", 7, head, "")}
rt.mu.Unlock()
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.labeled", labeled(7, ReadyLabel))
rt.until("the forge's open pull requests to be read for the label", 5*time.Second, func() bool {
return len(rt.asksOf("gitea.gitea_open_pulls")) > reads
})
rt.mu.Lock()
rt.openPulls = []OpenPull{markedPull("novox/app", 7, head, head)}
nothing := len(rt.operatorAsks)
rt.mu.Unlock()
if nothing != 0 {
t.Fatalf("the operator was asked for a label with no head named")
}
commented := b.publish(t, "mesh.mod.gitea.event.forge.pull-request.commented", commentedReady(7, head))
// The operator is asked, within the comment's event, and answers at once (a person's Approve, proven by the router).
// The operator is asked, within seconds of the verdict's event, with no label, comment or mark written by anyone.
rt.until("the operator to be asked to Approve the merge", 5*time.Second, func() bool {
rt.mu.Lock()
defer rt.mu.Unlock()
@@ -458,25 +428,39 @@ func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(
})
rt.mu.Lock()
ask := rt.operatorAsks[0]
asked := time.Now()
rt.mu.Unlock()
if !strings.Contains(ask.Explanation, "reviewed by lead") {
t.Fatalf("the ask does not name the reviewer: %s", ask.Explanation)
}
t.Logf("the operator was asked %s after the verdict's event", asked.Sub(passed))
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
t.Fatalf("merged before the Approve")
}
// The pull request's key holds its head, the trunk commit it was asked against and the verdict.
rt.until("the pull request's key to hold head, trunk and verdict", 5*time.Second, func() bool {
e, err := b.kvs["pulls"].Get(context.Background(), "novox.app.7")
var k PullKey
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
})
// The operator approves (a person's Approve, proven by the router): the merge is asked within seconds.
opt, _ := ask.Option(OptionApprove)
b.publish(t, "mesh.seat."+asks.Seat+".event.decided."+Module, asks.Warrant{Ask: ask.ID, Asker: Module,
approved := b.publish(t, "mesh.seat."+asks.Seat+".event.decided."+Module, asks.Warrant{Ask: ask.ID, Asker: Module,
Outcome: asks.OutcomeChosen, Option: OptionApprove, Label: opt.Label, Level: opt.Level,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"}, At: time.Now(), AskDigest: ask.Digest()})
rt.until("the merge to be asked of the forge", 5*time.Second, func() bool { return len(rt.asksOf("gitea.gitea_merge_pull_request")) >= 1 })
merges := rt.asksOf("gitea.gitea_merge_pull_request")
if len(merges) != 1 || merges[0].Body["head"] != head || merges[0].Body["number"] != float64(7) {
t.Fatalf("the merge was asked %+v; wanted once, for pull request 7 at the marked head", merges)
t.Fatalf("the merge was asked %+v; wanted once, for pull request 7 at the approved head", merges)
}
if took := merges[0].At.Sub(commented); took > 5*time.Second {
t.Fatalf("the merge was asked %s after the ready comment's event; wanted within 5s", took)
if got := fmt.Sprint(merges[0].Body["approved_heads"]); got != "[novox/app@"+head+"]" {
t.Fatalf("the merge tool was handed the approved heads %s", got)
}
if took := merges[0].At.Sub(approved); took > 5*time.Second {
t.Fatalf("the merge was asked %s after the Approve's event; wanted within 5s", took)
} else {
t.Logf("the merge was asked %s after the ready comment's event", took)
}
// The forge's open pull requests were read for the mark by the events, not by a poll: the label's and the comment's.
if got := len(rt.asksOf("gitea.gitea_open_pulls")) - reads; got < 2 || got > 3 {
t.Fatalf("the forge's open pull requests were read %d times for one mark", got)
t.Logf("the merge was asked %s after the Approve's event", took)
}
// Quiet follows: nothing asks the forge or the controller again with no event to say so.
quiet := len(rt.asked)
@@ -489,28 +473,92 @@ func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(
}
}
// A ready mark on a pull request whose check nobody asked yet asks it at once, on the ready stream (the check's `ready`).
func TestAReadyMarkAsksTheCheckOfAPullRequestWithNoVerdictAsReady(t *testing.T) {
// **The acceptance of one group, one Approve** (novox/hq ADR 0418 §4, ADR 0421, design 47 milestone 3, issues 978 and
// 1022): a group of two pull requests on one branch name, in two repositories, with green checks, gets one ask of the
// operator — no label, comment or mark written by anyone — and on the Approve both merge in one pass, each handed the
// approved heads of both.
func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeInOnePassOverARealBus(t *testing.T) {
b := newTestBus(t)
rt := b.start(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rt.deliverEvents(ctx)
rt.until("the module to subscribe", 20*time.Second, func() bool {
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
rt.mu.Lock()
defer rt.mu.Unlock()
return len(rt.subs) >= len(followed)+1
})
const head = "2222222222222222222222222222222222222222"
b.publish(t, "mesh.mod.gitea.event.pull.updated", pr("novox/app", 8, head, "fix/y", "src/a.go"))
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"),
pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} {
p.BaseSHA = "t1t1t1t1t1t1"
p.Body = "Reviewed-by: lead@shanks at " + p.HeadSHA
b.publish(t, "mesh.mod.gitea.event.pull.updated", p)
}
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t1t1t1t1t1t1"))
// Both members green: the composed check is asked, once, for both.
rt.until("the composed check to be asked", 10*time.Second, func() bool {
return len(rt.asksOf("seat:"+ControllerSeat+".delivery-check")) >= 1
})
if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 {
t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n)
}
rt.mu.Lock()
rt.openPulls = []OpenPull{markedPull("novox/app", 8, head, head)}
nothing := len(rt.operatorAsks)
rt.mu.Unlock()
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.commented", commentedReady(8, head))
rt.until("the check to be asked", 5*time.Second, func() bool { return len(rt.asksOf("seat:"+ControllerSeat+".delivery-check")) >= 1 })
body := rt.asksOf("seat:" + ControllerSeat + ".delivery-check")[0].Body
var members []Member
if err := json.Unmarshal([]byte(fmt.Sprint(body["members"])), &members); err != nil || len(members) != 1 || !members[0].Ready {
t.Fatalf("the check was asked for %v (%v); wanted one member with ready", body["members"], err)
if nothing != 0 {
t.Fatalf("the operator was asked before the group's composed check passed")
}
// The composed check passes at both heads: the operator is asked once, for the group.
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", map[string]any{"group": "feat/x", "id": "check-1", "verdict": "pass",
"summary": "both compose", "members": []map[string]any{
{"owner": "novox", "repo": "mesh-controller", "commit": ctlHead, "trunk": "t1t1t1t1t1t1"},
{"owner": "novox", "repo": "mesh-catalog", "commit": catHead, "trunk": "t1t1t1t1t1t1"}}})
rt.until("the operator to be asked once for the group", 10*time.Second, func() bool {
rt.mu.Lock()
defer rt.mu.Unlock()
return len(rt.operatorAsks) == 1
})
time.Sleep(500 * time.Millisecond)
rt.mu.Lock()
asked, ask := len(rt.operatorAsks), rt.operatorAsks[0]
rt.mu.Unlock()
if asked != 1 {
t.Fatalf("the operator was asked %d times for one group", asked)
}
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
t.Fatalf("merged before the Approve")
}
opt, _ := ask.Option(OptionApprove)
approved := b.publish(t, "mesh.seat."+asks.Seat+".event.decided."+Module, asks.Warrant{Ask: ask.ID, Asker: Module,
Outcome: asks.OutcomeChosen, Option: OptionApprove, Label: opt.Label, Level: opt.Level,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"}, At: time.Now(), AskDigest: ask.Digest()})
// Both merge on that one Approve, in one pass: asked within seconds of the Approve's event, each with both heads.
rt.until("both merges to be asked of the forge", 5*time.Second, func() bool { return len(rt.asksOf("gitea.gitea_merge_pull_request")) >= 2 })
merges := rt.asksOf("gitea.gitea_merge_pull_request")
if len(merges) != 2 {
t.Fatalf("the merge was asked %d times; wanted once for each member: %+v", len(merges), merges)
}
heads := map[string]bool{}
for _, m := range merges {
heads[fmt.Sprint(m.Body["repo"], "@", m.Body["head"])] = true
if got := fmt.Sprint(m.Body["approved_heads"]); got != "[novox/mesh-catalog@"+catHead+" novox/mesh-controller@"+ctlHead+"]" {
t.Fatalf("a merge was handed the approved heads %s", got)
}
if took := m.At.Sub(approved); took > 5*time.Second {
t.Fatalf("a merge was asked %s after the Approve's event; wanted within 5s", took)
}
}
if !heads["mesh-controller@"+ctlHead] || !heads["mesh-catalog@"+catHead] {
t.Fatalf("the merges were asked for %v", heads)
}
rt.mu.Lock()
again := len(rt.operatorAsks)
rt.mu.Unlock()
if again != 1 {
t.Fatalf("a second ask was made: %d", again)
}
}
@@ -241,7 +241,6 @@ func TestACoveredGroupMemberLetsTheNextOneGo(t *testing.T) {
const ctl, lab = "novox/mesh-controller", "novox/mesh-lab"
const ctlHead, labHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
ctlID, labID := IDOf(ctl, ctlHead), IDOf(lab, labHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctlID, labID}} }
pc, pl := pr(ctl, 1, ctlHead, "feat/g", "cmd/a.go"), pr(lab, 2, labHead, "feat/g", "replays/a.go")
w.h.PullUpdated(pc)
w.h.PullUpdated(pl)
@@ -347,7 +346,6 @@ func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) {
return []*Delivery{d, other, coverer(cat, "systemd")}
}(),
&Group{ID: "feat/j", Members: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")}, Closed: true,
Order: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")},
Check: &GroupCheck{Asked: "check-1", Verdict: "pass", Commits: map[string]string{IDOf(cat, head): head,
IDOf("novox/mesh-host", "abababababababab"): "abababababababab"}}},
Covered},
@@ -418,8 +416,15 @@ func TestAGroupMemberWaitsForItsComposedCheckToAnswer(t *testing.T) {
}
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.settleAll()
if s := w.state(aID); s != Published || len(w.ctl.delivers) != 0 {
t.Fatalf("its composed check passed, but the other member is not merged: %s, let go %v", s, w.ctl.delivers)
}
// The other member merges: every member is on the trunk, and the walk is let go (ADR 0418 §2).
w.h.PullMerged(mergedAt(pb, "m2m2m2m2m2m2", t0))
w.h.WalkMoved(w.ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true)))
w.settleAll()
if s := w.state(aID); s != Delivering || !containsString(w.ctl.delivers, "plan-a") {
t.Fatalf("its composed check passed, and it is %s, let go %v", s, w.ctl.delivers)
t.Fatalf("every member merged, and it is %s, let go %v", s, w.ctl.delivers)
}
}
@@ -448,16 +453,18 @@ func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
Plan: planMoving(a, "a"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-a", Commit: "m1m1m1m1m1m1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
db := &Delivery{ID: bID, Repository: b, Commit: bHead, Number: 2, Base: "main", Branch: "feat/k", Group: "feat/k",
State: Ready, Since: t0, Created: t0.Add(-time.Hour), Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"}}
g := &Group{ID: "feat/k", Members: []string{aID, bID}, Order: []string{aID, bID}, Closed: true,
OrderOf: map[string]string{aID: aHead, bID: bHead},
Check: &GroupCheck{Asked: "check-old", At: t0, Commits: c.commits}}
State: Published, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m2m2m2m2m2m2", MergedAt: t0,
Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-b", Commit: "m2m2m2m2m2m2", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
g := &Group{ID: "feat/k", Members: []string{aID, bID}, Closed: true,
Check: &GroupCheck{Asked: "check-old", At: t0, Commits: c.commits}}
for _, d := range []*Delivery{da, db} {
_ = store.PutDelivery(d)
}
_ = store.PutGroup(g)
ctl := newFakeController()
ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true))
ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true))
w := newWorldOver(t, store, ctl, newFakeForge(), t0.Add(time.Minute))
w.settleAll()
if s := w.state(aID); s != Held {
@@ -207,13 +207,7 @@ func ViewBody(d *Delivery, g *Group) string {
b.WriteString("\n")
}
if g != nil {
fmt.Fprintf(&b, "**Group** `%s`, in order: %s\n", g.ID, strings.Join(g.Order, " → "))
for _, p := range g.Pairs {
fmt.Fprintf(&b, "- %s\n", p.orderSaid())
}
for _, c := range g.Contradictions {
fmt.Fprintf(&b, "- **refused**: %s\n", c.Said)
}
fmt.Fprintf(&b, "**Group** `%s`: %s\n", g.ID, strings.Join(g.Members, ", "))
if g.Check != nil && g.Check.Verdict != "" {
fmt.Fprintf(&b, "- composed together: %s — %s\n", g.Check.Verdict, g.Check.Summary)
}
@@ -1,36 +1,16 @@
package main
import (
"regexp"
"slices"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Events that start a step (novox/hq ADR 0406 §1, issue 975).
// Events that start a step (novox/hq ADR 0406 §1, issue 975, ADR 0421 issue 1022).
//
// This owner acts on events: a ready mark, a verdict, an Approve, a group's turn. The forge's label and comment events
// say that a pull request's ready mark may have changed; they do not say whether it counts. Which accounts' "ready at"
// comments count is the forge module's rule (its trusted setting ready-authors, the gitea module's readyMarkOf), so a
// label added or a "ready at" comment written makes this owner read the forge's open pull requests once, and the mark
// it reads there is the mark (seeMarks). The label taken off needs no read: it only clears.
// ForgePullEvent is the forge module's `forge.pull-request.labeled`, `.unlabeled` and `.commented`
// (modules/gitea/webhook.ts): the pull request, and the label added or taken off, or the comment written.
type ForgePullEvent struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
HeadSHA string `json:"head_sha,omitempty"`
Base string `json:"base,omitempty"`
Labels []string `json:"labels,omitempty"`
Label string `json:"label,omitempty"`
Author string `json:"author,omitempty"`
Comment string `json:"comment,omitempty"`
CommentID int `json:"comment_id,omitempty"`
}
// This owner acts on events: a verdict, a commit status, an Approve. The operator is asked within the event that makes
// the Approve possible (the checks passing), with no label or comment written by anyone: the forge's label and comment
// events start nothing here.
// ForgeStatusEvent is the forge module's `forge.status`: a commit's status was set.
type ForgeStatusEvent struct {
@@ -41,15 +21,7 @@ type ForgeStatusEvent struct {
State string `json:"state"`
}
// readyAtComment is a comment that names the head a pull request is marked ready at, as the forge module reads it
// (modules/gitea/client.ts READY_AT): "ready at <full sha>" on a line of its own.
var readyAtComment = regexp.MustCompile(`(?im)^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b`)
func eventPullKey(e ForgePullEvent) string {
return strings.ToLower(e.Owner+"/"+e.Repo) + "#" + strconv.Itoa(e.Number)
}
// wantForge says the forge's open pull requests are to be read at the next look: a ready mark may have been made.
// wantForge says the forge's open pull requests are to be read at the next look: a check may have answered.
func (h *Holder) wantForge() {
h.mu.Lock()
h.forgeWanted = true
@@ -65,33 +37,6 @@ func (h *Holder) takeForgeWanted() bool {
return wanted
}
// PullLabeled takes a label added to a pull request: the ready label asks for the mark to be read.
func (h *Holder) PullLabeled(e ForgePullEvent) {
if e.Label == ReadyLabel {
h.wantForge()
}
}
// PullUnlabeled takes a label taken off a pull request: the ready label clears its mark, with no read.
func (h *Holder) PullUnlabeled(e ForgePullEvent) {
if e.Label != ReadyLabel {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if _, marked := h.marks[eventPullKey(e)]; marked {
delete(h.marks, eventPullKey(e))
h.Logf("[mesh-delivery] %s's ready label was taken off: its mark is cleared", eventPullKey(e))
}
}
// PullCommented takes a comment on a pull request: one that names a head to be ready at asks for the mark to be read.
func (h *Holder) PullCommented(e ForgePullEvent) {
if readyAtComment.MatchString(e.Comment) {
h.wantForge()
}
}
// AskDecided takes the router's `decided` event for an ask this owner made: its warrant is applied to the deliveries
// the ask covers, and what is said on their pull requests is said.
func (h *Holder) AskDecided(id, state string, w *asks.Warrant) {
@@ -2,19 +2,15 @@ package main
import (
"encoding/json"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// **A ready mark, a verdict and an Approve are acted on as events** (novox/hq ADR 0406 §1, issue 975): the merge
// is asked within the event that makes it possible, never at the next tick or the next minute.
const (
labeledEvent = "gitea.forge.pull-request.labeled"
unlabeledEvent = "gitea.forge.pull-request.unlabeled"
commentedEvent = "gitea.forge.pull-request.commented"
)
// **A verdict and an Approve are acted on as events** (novox/hq ADR 0406 §1, issue 975, ADR 0421 §1, issue 1022): the
// operator is asked within the event that makes the Approve possible, and the merge within the Approve's event, never at
// the next tick or the next minute, and with no label or comment written by anyone.
// hear is the runtime handing this owner one event, as handle takes it.
func hear(t *testing.T, w *world, key string, body any) {
@@ -29,76 +25,40 @@ func hear(t *testing.T, w *world, key string, body any) {
w.h.React() // the look the event pokes, taken by the holder's worker
}
func labeled(number int, label string) ForgePullEvent {
return ForgePullEvent{Owner: "novox", Repo: "app", Number: number, HeadSHA: readyHead, Base: "main", Label: label,
Labels: []string{label}}
}
func commentedReady(number int, head string) ForgePullEvent {
return ForgePullEvent{Owner: "novox", Repo: "app", Number: number, Author: "reviewer", CommentID: 5,
Comment: "ready at " + head + "\n\napproved: the tests are the change's"}
}
// greenAnnounced is a pull request into main, announced and judged green, the operator not answering by himself.
func greenAnnounced(t *testing.T) *world {
// greenAnnounced is a pull request into main, announced, the operator not answering by himself.
func announced(t *testing.T) *world {
w := newWorld(t)
w.op.auto = false
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
return w
}
func TestALabelAndAReadyAtCommentAskTheOperatorAtOnceWithNoTick(t *testing.T) {
w := greenAnnounced(t)
// The label first: the forge reads it without a comment naming a head yet. Nothing is asked.
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, "")}
hear(t, w, labeledEvent, labeled(7, ReadyLabel))
if w.op.count() != 0 || len(w.forge.merges) != 0 {
t.Fatalf("a label with no head named was acted on: %d asks, merges %v", w.op.count(), w.forge.merges)
// Issue 1022, design 47 acceptance test 6: the Approve is asked within the event of the checks passing, with no ready
// mark, label or comment written by anyone.
func TestAPassingVerdictAsksTheOperatorAtOnceWithNoMark(t *testing.T) {
w := announced(t)
if w.op.count() != 0 {
t.Fatalf("asked before the checks passed: %d", w.op.count())
}
// The comment naming the head: the forge now reads the mark whole, and the ask is made within this event.
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
hear(t, w, commentedEvent, commentedReady(7, readyHead))
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if w.op.count() != 1 {
t.Fatalf("the comment that completes the mark asked the operator %d times, wanted once, with no tick", w.op.count())
t.Fatalf("a pull request whose checks passed asked the operator %d times, wanted once, with no mark", w.op.count())
}
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready == nil || d.Ready.By != "reviewer" || d.Ready.AskID == "" {
t.Fatalf("the mark is not kept on the delivery: %+v", d.Ready)
if len(w.forge.comments) != 0 || len(w.forge.calls) != 0 {
t.Fatalf("something was written on the forge to ask: %v", w.forge.calls)
}
// Asked once for the head, however often it is looked at again.
hear(t, w, "gitea.forge.status", ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/merge-gate", State: "success"})
if w.op.count() != 1 {
t.Fatalf("asked %d times for one head", w.op.count())
}
}
func TestACommentThatIsNotAReadyMarkReadsNothingFromTheForge(t *testing.T) {
w := greenAnnounced(t)
before := w.forge.openCalls
c := commentedReady(7, readyHead)
c.Comment = "looks fine to me, though I would not say ready at all"
hear(t, w, commentedEvent, c)
hear(t, w, labeledEvent, labeled(7, "kind/bug"))
if w.forge.openCalls != before {
t.Fatalf("the forge's open pull requests were read %d times for events that are no ready mark", w.forge.openCalls-before)
}
}
func TestTheReadyLabelTakenOffClearsTheMarkAtOnce(t *testing.T) {
w := greenAnnounced(t)
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
hear(t, w, labeledEvent, labeled(7, ReadyLabel))
w.h.mu.Lock()
marks := len(w.h.marks)
w.h.mu.Unlock()
if marks != 1 {
t.Fatalf("the label did not make a mark: %d", marks)
}
asked := w.op.count()
// The forge no longer shows the label; the event alone says so, and the forge is not read for it.
reads := w.forge.openCalls
w.forge.open = nil
hear(t, w, unlabeledEvent, labeled(7, ReadyLabel))
w.h.mu.Lock()
marks = len(w.h.marks)
w.h.mu.Unlock()
if marks != 0 || w.forge.openCalls != reads || w.op.count() != asked {
t.Fatalf("the mark was not cleared by the event alone: marks %d, reads %d, asks %d", marks, w.forge.openCalls-reads, w.op.count()-asked)
func TestAFailedVerdictAsksNothing(t *testing.T) {
w := announced(t)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
if w.op.count() != 0 {
t.Fatalf("a pull request whose checks failed asked the operator %d times", w.op.count())
}
}
@@ -111,43 +71,82 @@ func approve(w *world) {
w.h.React()
}
func TestAnApproveHeardAsAnEventAsksTheMergeAtOnce(t *testing.T) {
w := greenAnnounced(t)
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
hear(t, w, commentedEvent, commentedReady(7, readyHead))
open := w.op.open()
if len(open) != 1 || len(w.forge.merges) != 0 {
t.Fatalf("asks %v, merges %v", open, w.forge.merges)
func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
w := announced(t)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if len(w.op.open()) != 1 || len(w.forge.merges) != 0 {
t.Fatalf("asks %v, merges %v", w.op.open(), w.forge.merges)
}
approve(w)
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
t.Fatalf("the Approve, heard as an event, merged %v; wanted once at the marked head", w.forge.merges)
t.Fatalf("the Approve, heard as an event, merged %v; wanted once at the approved head", w.forge.merges)
}
if got := w.forge.approvedHeads[0]; len(got) != 1 || got[0] != "novox/app@"+readyHead {
t.Fatalf("the merge tool was not handed the approved heads: %v", got)
}
}
func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
// Another merge moves the trunk: the verdict is out of date and the merge tool refuses, asking the check again.
// Issue 1022 (ADR 0421 §3): a passing verdict that arrives out of date is not asked; its check against the trunk's
// head is, and the Approve is asked on the passing verdict.
func TestAStalePassingVerdictAsksTheCheckAgainBeforeTheOperator(t *testing.T) {
w := announced(t)
// Another merge moved the trunk before the verdict, judged against the trunk before, arrived.
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if w.op.count() != 0 {
t.Fatalf("the operator was asked for a verdict out of date")
}
if len(w.ctl.checks) != 1 || w.ctl.baseSHAs[0] != "t2t2t2t2t2t2" {
t.Fatalf("its check against the trunk's head was not asked once: %v %v", w.ctl.checks, w.ctl.baseSHAs)
}
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
if w.op.count() != 1 {
t.Fatalf("the passing verdict at the trunk's head asked the operator %d times, wanted once", w.op.count())
}
if len(w.ctl.checks) != 1 {
t.Fatalf("its check was asked again: %v", w.ctl.checks)
}
}
// Issue 1022 (ADR 0421 §3, §4): an ask raised stays when the trunk moves; the Approve is given; the merge waits for the
// fresh verdict and lands on it; no second Approve is asked.
func TestAnAskRaisedStaysThroughATrunkMoveAndTheMergeLandsOnTheFreshVerdict(t *testing.T) {
w := announced(t)
id := IDOf("novox/app", readyHead)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
w.h.React()
if w.state(id) != Proposed || w.op.count() != 1 || len(w.op.open()) != 1 {
t.Fatalf("after the trunk moved: %s, asks %d, open %v", w.state(id), w.op.count(), w.op.open())
}
if len(w.ctl.checks) != 1 {
t.Fatalf("its check against the trunk's head was not asked at once: %v", w.ctl.checks)
}
approve(w)
if len(w.forge.merges) != 0 {
t.Fatalf("merged on a verdict out of date: %v", w.forge.merges)
}
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
if len(w.forge.merges) != 1 || w.delivery(id).Ready.Merged == nil || w.op.count() != 1 {
t.Fatalf("the passing verdict did not ask the merge at once, on the same Approve: %v %+v asks %d", w.forge.merges,
w.delivery(id).Ready, w.op.count())
}
}
// Issue 1022: an Approve whose merge the tool refused is asked again at once on the verdict that decides it.
func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
w := announced(t)
id := IDOf("novox/app", readyHead)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = errAskedAgain
w.op.auto = false
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
hear(t, w, commentedEvent, commentedReady(7, readyHead))
approve(w)
if len(w.forge.merges) != 1 {
t.Fatalf("the merge tool was asked %v; wanted once to judge it again", w.forge.merges)
t.Fatalf("the merge tool was asked %v; wanted once", w.forge.merges)
}
// The verdict against the trunk's head passes: merged within the verdict's own event, no minute waited.
w.forge.refuse = nil
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
raw, _ := json.Marshal(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
if err := handle(w.h, stdio.Envelope{Key: ControllerSeat + ".checked", Body: raw}); err != nil {
t.Fatal(err)
}
w.h.React()
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
if len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
t.Fatalf("the passing verdict did not ask the merge at once: %v %+v", w.forge.merges, w.delivery(id).Ready)
}
@@ -160,3 +159,108 @@ type askedAgainError struct{}
func (*askedAgainError) Error() string {
return "the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked"
}
// Issue 1022 (ADR 0421 §5): the review record is read and put in the ask; it is never a gate.
func TestTheAskSaysFirstWhenThePullRequestIsNotReviewedAtThisHead(t *testing.T) {
for _, c := range []struct{ name, body, want string }{
{"no line", "Fixes issue 7.", "not reviewed at this head"},
{"another head", "Reviewed-by: lead@shanks at " + pushed, "the Reviewed-by line names " + pushed[:12]},
} {
t.Run(c.name, func(t *testing.T) {
w := newWorld(t)
w.op.auto = false
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Body = c.body
w.h.PullUpdated(p)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if w.op.count() != 1 {
t.Fatalf("an unreviewed pull request must still be asked: %d", w.op.count())
}
a := w.op.ask(w.op.open()[0])
first := strings.SplitN(a.Explanation, "\n", 2)[0]
if !strings.Contains(first, "not reviewed at this head") || !strings.Contains(a.Explanation, c.want) {
t.Fatalf("the ask does not say first that it is not reviewed at this head:\n%s", a.Explanation)
}
})
}
}
func TestTheAskNamesTheReviewerOfTheHead(t *testing.T) {
w := newWorld(t)
w.op.auto = false
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Body = "Fixes issue 7.\n\nReviewed-by: lead@shanks at " + readyHead + "\n"
w.h.PullUpdated(p)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
a := w.op.ask(w.op.open()[0])
if strings.Contains(a.Explanation, "not reviewed") || !strings.Contains(a.Explanation, "reviewed by lead") {
t.Fatalf("a reviewed head is not said reviewed:\n%s", a.Explanation)
}
}
// The description corrected without a push is read again.
func TestTheReviewLineCorrectedInTheDescriptionIsReadWithoutAPush(t *testing.T) {
w := newWorld(t)
w.op.auto = false
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
w.h.PullUpdated(p)
p.Body = "Reviewed-by: lead@shanks at " + readyHead
w.h.PullUpdated(p)
if d := w.delivery(IDOf("novox/app", readyHead)); d.ReviewedBy != "lead@shanks" || d.ReviewedAt != readyHead {
t.Fatalf("the corrected line is not read: %q %q", d.ReviewedBy, d.ReviewedAt)
}
}
// Issue 1022 (ADR 0421 §6): a push ends the Approve and says so once on the pull request.
func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
w := announced(t)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
old := w.op.open()[0]
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.h.React()
w.h.React()
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != old || len(w.op.open()) != 0 {
t.Fatalf("the ask was not cancelled: %v, open %v", w.op.cancelled, w.op.open())
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "Approve") ||
!strings.Contains(w.forge.comments[0], "ended") {
t.Fatalf("the end of the Approve is not said once on the pull request: %v", w.forge.comments)
}
// The new head is asked on its own when its checks pass.
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
if open := w.op.open(); len(open) != 1 || open[0] == old {
t.Fatalf("the new head was not asked afresh: %v", open)
}
if len(w.forge.comments) != 1 {
t.Fatalf("said more than once: %v", w.forge.comments)
}
}
// Issue 1022 (ADR 0421 §7): a check that failed after the ask merges nothing and is said once and as a stalled line.
func TestACheckThatFailsAfterTheAskIsNotMergedAndIsSaid(t *testing.T) {
w := announced(t)
id := IDOf("novox/app", readyHead)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if _, err := w.h.Recheck(id, "a second look", "tester"); err != nil {
t.Fatal(err)
}
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
approve(w)
if len(w.forge.merges) != 0 {
t.Fatalf("merged past a failed check: %v", w.forge.merges)
}
w.h.React()
said := 0
for _, c := range w.forge.comments {
if strings.Contains(c, "not merged") {
said++
}
}
if said != 1 {
t.Fatalf("the failure is not said once: %v", w.forge.comments)
}
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].ID != id || strings.Contains(lines[0].Says, "marked") {
t.Fatalf("the failure is not a stalled line: %+v", lines)
}
}
@@ -89,7 +89,6 @@ type fakeController struct {
mu sync.Mutex
walks map[string]Walk
held bool
order func([]Member) Order
checks []string
asked int
delivers []string
@@ -110,6 +109,8 @@ type fakeController struct {
walkReads int
// checkReady is the ready flag of each member of each check asked.
checkReady []bool
// baseSHAs is the base head each member of each check asked was sent with.
baseSHAs []string
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
refuseDeliver bool
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
@@ -148,22 +149,6 @@ func (c *fakeController) Plan(repository, base, head string, paths, dirs, remove
}
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
}
func (c *fakeController) Order(ms []Member) (Order, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return Order{}, errors.New("down")
}
if c.order != nil {
return c.order(ms), nil
}
var ids []string
for _, m := range ms {
ids = append(ids, m.ID)
}
sort.Strings(ids)
return Order{Order: ids}, nil
}
func (c *fakeController) Check(group string, ms []Member) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -182,6 +167,7 @@ func (c *fakeController) Check(group string, ms []Member) (string, error) {
c.checks = append(c.checks, group+":"+strings.Join(heads, ","))
for _, m := range ms {
c.checkReady = append(c.checkReady, m.Ready)
c.baseSHAs = append(c.baseSHAs, m.BaseSHA)
}
return id, nil
}
@@ -392,7 +378,9 @@ type fakeForge struct {
notes map[string][]string
views map[string]string
statuses map[string]string
down bool
// statusWrites counts the statuses set.
statusWrites int
down bool
// viewErr is what View answers instead, when set: a pull request the forge no longer holds.
viewErr error
viewCalls int
@@ -410,12 +398,13 @@ type fakeForge struct {
open []OpenPull
openDown bool
openCalls int
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
merges, comments, unlabels []string
refuse error
// calls are the labels and comments in the order asked (novox/hq issue 913); labelErr fails a Label.
calls []string
labelErr error
// merges and comments are what was asked of the forge for an approved merge (novox/hq issue 722); approvedHeads the
// heads each merge was handed as approved (ADR 0421 §9).
merges, comments []string
approvedHeads [][]string
refuse error
// calls are the comments in the order asked.
calls []string
}
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
@@ -467,6 +456,7 @@ func (f *fakeForge) Status(owner, repo, commit, context, state, description, tar
if f.down {
return errors.New("the forge is away")
}
f.statusWrites++
f.statuses[owner+"/"+repo+"@"+commit+" "+context] = state + " " + description + " → " + target
return nil
}
@@ -690,11 +680,12 @@ func aWalk(id, repo, commit string, waits bool) Walk {
return w
}
// Merge, Comment and Unlabel record what was asked (novox/hq issue 722); Merge answers refuse when set.
func (f *fakeForge) Merge(owner, repo string, number int, head string) error {
// Merge and Comment record what was asked (novox/hq issue 722); Merge answers refuse when set.
func (f *fakeForge) Merge(owner, repo string, number int, head string, approved []string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head))
f.approvedHeads = append(f.approvedHeads, approved)
return f.refuse
}
@@ -705,20 +696,3 @@ func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
f.calls = append(f.calls, "comment "+body)
return nil
}
func (f *fakeForge) Label(owner, repo string, number int, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.labelErr != nil {
return f.labelErr
}
f.calls = append(f.calls, fmt.Sprintf("label %s/%s#%d %s", owner, repo, number, name))
return nil
}
func (f *fakeForge) Unlabel(owner, repo string, number, label int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.unlabels = append(f.unlabels, fmt.Sprintf("%s/%s#%d-%d", owner, repo, number, label))
return nil
}
@@ -0,0 +1,190 @@
package main
import (
"encoding/json"
"sort"
"testing"
"time"
)
// **A group is merged in one pass on its one Approve, and delivered by one plan** (novox/hq ADR 0418, ADR 0419, ADR
// 0421, issues 978 and 1047): the members are not ordered, none waits for another's merge or delivery, and a trunk
// move does not ask the operator a second time.
const (
gCtl, gCat = "novox/mesh-controller", "novox/mesh-catalog"
gHc, gHk = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
gTrunk0, gT1 = "a2c2c35000000000000000000000000000000000", "d28ca95000000000000000000000000000000000"
)
// approvedGroup is a group of two pull requests on one branch, checks green, composed green and the operator's Approve
// asked and not yet answered (w.op.auto is off).
func approvedGroup(t *testing.T) *world {
w := newWorld(t)
w.op.auto = false
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
w.settleAll()
g := w.h.groups["feat/g"]
if g == nil || g.Check == nil {
t.Fatalf("no composed check was asked: %+v", g)
}
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
Members: []struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Commit string `json:"commit"`
Trunk string `json:"trunk,omitempty"`
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
w.settleAll()
if w.op.count() != 1 || len(w.op.open()) != 1 {
t.Fatalf("the green group is asked once: %d asks, open %v", w.op.count(), w.op.open())
}
return w
}
// Issue 1047 (the rule the lead settled, ADR 0421 §3): an approved group whose trunk moved through a merge that is not
// its member's has each out-of-date member's check asked at once, then the composed check, and merges on the same
// Approve — with no second one asked.
func TestAnApprovedGroupWhoseTrunkMovedIsCheckedAgainAndMergedOnTheSameApprove(t *testing.T) {
w := approvedGroup(t)
asked := len(w.ctl.checks)
w.h.PullMerged(merged(gCtl, 5, "5555555555555555", gT1))
w.settleAll()
// The member whose trunk moved has its own check asked, in the same look; the other member's trunk did not move.
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+IDOf(gCtl, gHc) {
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
}
if w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1[:12] && w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1 {
t.Fatalf("its check was not asked against the trunk's head: %v", w.ctl.baseSHAs)
}
// The Approve is given while the checks run: nothing merges on the verdict out of date.
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("merged while the group's checks were out of date: %v", w.forge.merges)
}
// The member is judged again: the composed check is asked on the new verdicts.
w.later(time.Minute)
w.h.Checked(judged(gCtl, 1, gHc, "pass", gT1))
w.settleAll()
g := w.h.groups["feat/g"]
last := w.ctl.checks[len(w.ctl.checks)-1]
if last != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
t.Fatalf("the composed check was not asked again: %v", w.ctl.checks)
}
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
w.settleAll()
sort.Strings(w.forge.merges)
if len(w.forge.merges) != 2 || w.forge.merges[0] != "novox/mesh-catalog#2@"+gHk || w.forge.merges[1] != "novox/mesh-controller#1@"+gHc {
t.Fatalf("both members were not merged on the new composed verdict: %v", w.forge.merges)
}
if w.op.count() != 1 {
t.Fatalf("a second Approve was asked: %d", w.op.count())
}
}
// ADR 0418 §4: a member the forge refuses is merged again on its fresh check, while the other merged in the same pass.
func TestAMemberTheForgeRefusesIsMergedAgainWhileTheOtherMergedInTheSamePass(t *testing.T) {
w := approvedGroup(t)
w.forge.refuse = errAskedAgain
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
if len(w.forge.merges) != 2 {
t.Fatalf("both members are asked in the same pass: %v", w.forge.merges)
}
w.forge.refuse = nil
w.later(2 * time.Minute)
w.settleAll()
if len(w.forge.merges) != 4 {
t.Fatalf("a refused member is asked again a minute on: %v", w.forge.merges)
}
if d := w.delivery(IDOf(gCat, gHk)); d.Ready.Merged == nil {
t.Fatalf("not merged: %+v", d.Ready)
}
}
// Issue 978 (the pin): a feature's single pull request in one repository is a lone delivery, asked alone.
func TestAFeaturesSinglePullRequestIsALoneDeliveryAskedAlone(t *testing.T) {
w := newWorld(t)
w.op.auto = false
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.settleAll()
d := w.delivery(IDOf("novox/app", readyHead))
if d.Group != "" || len(w.h.groups) != 0 {
t.Fatalf("a single pull request made a group: %q %v", d.Group, w.h.groups)
}
if w.op.count() != 1 {
t.Fatalf("a lone delivery is asked once, alone: %d", w.op.count())
}
}
// A group answer says its members' gate is the composed check's (agreed with the controller, issue 977).
func TestTheGroupsAnswerSaysItsGateIsTheComposedChecks(t *testing.T) {
w := approvedGroup(t)
lines := w.h.Groups(false)
if len(lines) != 1 || !lines[0].ComposedGate {
t.Fatalf("groups: %+v", lines)
}
raw, _ := json.Marshal(lines[0])
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil || got["composed_gate"] != true {
t.Fatalf("the group line says %s", raw)
}
if _, ordered := got["order"]; ordered {
t.Fatalf("a group line still says an order: %s", raw)
}
}
// ADR 0403 §1: a member whose own gate the group's composed check judges instead (the controller says its gate layer
// "composed") counts as passed here once its repository's check did not fail, so the group's composed check is asked.
func TestAMemberWhoseGateIsTheComposedChecksCountsAsPassed(t *testing.T) {
w := newWorld(t)
w.op.auto = false
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
for _, c := range []struct {
repo string
n int
head string
}{{gCtl, 1, gHc}, {gCat, 2, gHk}} {
v := judged(c.repo, c.n, c.head, "pass", gTrunk0)
v.Gate = &struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
Modules []string `json:"modules,omitempty"`
Dependents []string `json:"dependents,omitempty"`
}{Verdict: GateComposed, Summary: "judged by its group's composed check"}
v.RepoCheck = &struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
}{Verdict: "pass", Summary: "the repository's own check passed"}
w.h.Checked(v)
}
w.settleAll()
if len(w.ctl.checks) != 1 || w.ctl.checks[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
t.Fatalf("the composed check was not asked for members judged by it: %v", w.ctl.checks)
}
// A member whose repository check failed is not passed, whatever the composed check will say.
w2 := newWorld(t)
w2.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
v := judged(gCtl, 1, gHc, "pass", gTrunk0)
v.Gate = &struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
Modules []string `json:"modules,omitempty"`
Dependents []string `json:"dependents,omitempty"`
}{Verdict: GateComposed}
v.RepoCheck = &struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
}{Verdict: "fail", Summary: "a test failed"}
w2.h.Checked(v)
if w2.state(IDOf(gCtl, gHc)) == Ready {
t.Fatalf("a member whose repository check failed is ready")
}
}
+95 -456
View File
@@ -84,10 +84,8 @@ type Holder struct {
coverAskedSince map[string]time.Time
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
tried owedTries
// marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go).
marks map[string]*marked
// forgeWanted is whether the forge's open pull requests are to be read at the next React: a ready mark may have been
// made (events.go). reactMu keeps one React at a time.
// forgeWanted is whether the forge's open pull requests are to be read at the next React: a check may have
// answered (events.go). reactMu keeps one React at a time.
forgeWanted bool
// rereadWalks is whether the controller's walks are to be read once more: a merge passed the bound a walk opens
// within, and the walks were last read before it (unwalkedPast).
@@ -148,7 +146,6 @@ func (h *Holder) init() {
h.askTried = map[string]time.Time{}
h.walkAsked = map[string]time.Time{}
h.unchecked = map[string]unchecked{}
h.marks = map[string]*marked{}
h.walks = map[string]seenWalk{}
h.orphans = map[string]time.Time{}
h.asking = map[string]bool{}
@@ -342,6 +339,16 @@ func (h *Holder) PullUpdated(p PullEvent) (isNew bool) {
d.BaseSHA = p.BaseSHA
h.keep(d)
}
// The review record, corrected in the description without a push, is read again (ADR 0421 §5).
if by, at := ReviewIn(p.Body); p.Body != "" && (by != d.ReviewedBy || at != d.ReviewedAt) {
d.ReviewedBy, d.ReviewedAt = by, at
h.keep(d)
}
// An `after:` line written into the description later is told about too (novox/hq ADR 0419 §2).
if after := AfterIn(p.Body); p.Body != "" && !slices.Equal(after, d.After) {
d.After = after
h.keep(d)
}
if d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil {
d.AskedAgain = &now
h.keep(d)
@@ -403,6 +410,7 @@ func (h *Holder) freshAnnouncement(p PullEvent, now time.Time, startedOver strin
Title: p.Title, Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths,
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, BaseSHA: p.BaseSHA}
d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body)
why := fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)
if startedOver != "" {
why = fmt.Sprintf("%s; %s", why, startedOver)
@@ -419,24 +427,15 @@ func (h *Holder) takePlaceOf(old string, d *Delivery) bool {
if old == "" {
return false
}
for _, g := range h.groups {
_, ordered := g.OrderOf[old]
if !g.Closed || !ordered && !containsString(g.Order, old) {
continue
}
if ordered {
delete(g.OrderOf, old)
g.OrderOf[d.ID] = d.Commit
}
was := h.deliveries[old]
if was == nil || was.Group == "" {
return false
}
if g := h.groups[was.Group]; g != nil && g.Closed {
if !containsString(g.Members, d.ID) {
g.Members = append(g.Members, d.ID)
sort.Strings(g.Members)
}
for i, id := range g.Order {
if id == old {
g.Order[i] = d.ID
}
}
d.Group = g.ID
g.Updated = h.Now()
h.keep(d)
@@ -526,7 +525,6 @@ func (h *Holder) PullMerged(p PullEvent) {
}
h.mu.Lock()
defer h.mu.Unlock()
delete(h.marks, strings.ToLower(p.Owner+"/"+p.Repo)+fmt.Sprintf("#%d", p.Number)) // a merged pull request keeps no mark
h.forgetUnchecked(p.Owner, p.Repo, p.Number)
var d *Delivery
if p.HeadSHA != "" {
@@ -689,7 +687,6 @@ func verdictOfState(s string) string {
// go of it within the week anyway.
func (h *Holder) PullClosed(p PullEvent) {
h.mu.Lock()
delete(h.marks, strings.ToLower(p.Owner+"/"+p.Repo)+fmt.Sprintf("#%d", p.Number)) // a closed pull request keeps no mark
h.forgetUnchecked(p.Owner, p.Repo, p.Number)
for _, d := range h.deliveries {
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
@@ -828,7 +825,9 @@ func (h *Holder) Checked(c CheckedEvent) {
d.Plan = c.Plan
}
if why := d.outOfDate(); why != "" {
// Kept and shown, and it does not count: the delivery stays proposed (ADR 0323 §7).
// Kept and shown, and it does not count: the delivery stays proposed (ADR 0323 §7). If it passes, its check is
// asked against the trunk's head before the operator is (ADR 0421 §3, askFresh).
d.FreshWanted = d.Check.Passes()
h.Logf("[mesh-delivery] %s: its verdict %s is %s", d.ID, c.ID, why)
}
h.settleAndKeep(d)
@@ -845,7 +844,7 @@ func (h *Holder) WalkMoved(w Walk) {
func (h *Holder) walkMoved(w Walk) {
for _, d := range h.deliveries {
if d.Group != "" {
h.mergeAgainNow(d) // a walk moved: a group member's turn to merge may have come, asked at once
h.mergeAgainNow(d) // a walk moved: a group member's merge may be decided by it, asked at once
}
}
if w.Release != nil {
@@ -1090,7 +1089,7 @@ func (h *Holder) Reconcile() error {
// --- the look: groups, turns, adoption --------------------------------------------------------------------
// look takes every step the facts allow that needs another's word: a group's order and composed check, a
// look takes every step the facts allow that needs another's word: a group's composed check, a
// delivery's turn, a walk with no delivery made one. The asking happens outside the lock.
func (h *Holder) look() {
for _, ask := range h.plan() {
@@ -1327,7 +1326,7 @@ func (h *Holder) plan() []func() {
}
}
// Groups: membership, order, composed check, and their turns.
// Groups: membership, composed check, and their word.
h.turning = map[string]*turnWant{}
grouped := map[string]bool{}
for _, g := range h.groups {
@@ -1710,7 +1709,7 @@ func (h *Holder) askCheck(d *Delivery, now time.Time) func() {
if d.State != Proposed || d.Check != nil || d.CheckAsk != nil || d.MergedAs != "" || d.Number == 0 {
return nil
}
if now.Sub(d.Since) < checkGrace && d.Ready == nil { // a ready mark makes the check worth asking at once
if now.Sub(d.Since) < checkGrace && d.Ready == nil { // an Approve asked makes the check worth asking at once
h.lookAt(d.Since.Add(checkGrace), "a proposed delivery's check, asked once the grace has passed with no verdict")
return nil
}
@@ -1836,34 +1835,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
return nil
}
commits := commitsOf(members)
// The order, worked out again whenever a member's head moved.
if !sameMembers(g.OrderOf, commits) && !g.Closed {
ms := make([]Member, 0, len(members))
for _, d := range members {
ms = append(ms, MemberOf(d))
}
id := g.ID
asks = append(asks, func() {
o, err := h.Controller.Order(ms)
h.mu.Lock()
defer h.mu.Unlock()
g := h.groups[id]
if g == nil {
return
}
if err != nil {
h.Logf("[mesh-delivery] group %s's order cannot be asked yet: %v", id, err)
h.again("a group's order that could not be asked")
return
}
g.Order, g.Pairs, g.Cycle, g.Contradictions, g.OrderOf = o.Order, o.Pairs, o.Cycle, o.Contradictions, commits
g.Check = nil // a check of another set of heads is no check of these
g.Updated = h.Now()
h.keepGroup(g)
})
return asks
}
// The composed check, once every member is ready and the order holds. Asked again for new heads, even once the
// The composed check, once every member is ready. Asked again for new heads, even once the
// group is closed: a member rebased or pushed is a new head, and the group waits for its composed check rather
// than being held for a person (novox/hq ADR 0323 §6, issue 406). One out of date is asked again once a member
// was judged again after it went out of date: a recheck, or the merge tool's ask.
@@ -1875,8 +1847,8 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
}
}
again := g.Check != nil && (!sameMembers(g.Check.Commits, commits) || g.Check.OutOfDate != "" && rejudgedSince(members, g.Check.OutOfDateAt))
if allReady && len(g.Cycle) == 0 && (g.Check == nil && !g.Closed || again) {
ms := h.inOrder(g, members)
if allReady && (g.Check == nil && !g.Closed || again) {
ms := membersOfAsk(members)
id := g.ID
asks = append(asks, func() {
asked, err := h.Controller.Check(id, ms)
@@ -1931,425 +1903,120 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
Since: now, Last: d.Repository + "#" + fmt.Sprint(d.Number) + " " + d.HTMLURL})
}
g.Owed = append(g.Owed, Effect{Kind: EffectEmit, Line: string(mustJSON(map[string]any{"group": g.ID,
"state": state, "why": why, "members": g.Members, "order": g.Order})), Since: now})
"state": state, "why": why, "members": g.Members})), Since: now})
g.Updated = now
h.keepGroup(g)
}
// Its turns, in its order.
// Each member's mesh/merge-gate is the composed check's (ADR 0403 §1, §6): pending, saying why, while no composed
// verdict stands at these heads. The verdict itself sets it, success or failure, in the forge module.
if why := composedPending(g, members); why != "" {
if g.SaidGate == nil {
g.SaidGate = map[string]string{}
}
for _, d := range members {
if d.MergedAs != "" || d.State.Final() || g.SaidGate[d.ID] == why {
continue
}
g.SaidGate[d.ID] = why
g.Owed = append(g.Owed, Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/merge-gate", State: "pending",
Line: clip("group "+g.ID+": "+why, 140), Since: now, Last: d.Repository + "#" + fmt.Sprint(d.Number) + " " + d.HTMLURL})
g.Updated = now
h.keepGroup(g)
}
} else if len(g.SaidGate) > 0 {
g.SaidGate = nil
h.keepGroup(g)
}
// Its word, once, when every member is merged (ADR 0418 §2).
if g.Closed {
asks = append(asks, h.groupTurns(g, members)...)
asks = append(asks, h.groupWord(g, members)...)
}
return asks
}
// inOrder are a group's members as the controller ordered them.
func (h *Holder) inOrder(g *Group, members []*Delivery) []Member {
byID := map[string]*Delivery{}
for _, d := range members {
byID[d.ID] = d
// composedPending is why a group member's gate stands pending: the composed check has given no verdict at the members'
// heads as they are, or its verdict is out of date; empty once one stands, which the forge module then sets as every
// member's gate (novox/hq ADR 0403 §6).
func composedPending(g *Group, members []*Delivery) string {
c := g.Check
switch {
case c == nil || c.Verdict == "":
return "its group's composed check has not given a verdict at the members' heads yet"
case !sameMembers(c.Commits, commitsOf(members)):
return "composed again for the group's new heads"
case c.OutOfDate != "":
return c.OutOfDate
}
var out []Member
for _, id := range g.Order {
if d := byID[id]; d != nil {
out = append(out, MemberOf(d))
delete(byID, id)
}
}
var rest []string
for id := range byID {
rest = append(rest, id)
}
sort.Strings(rest)
for _, id := range rest {
out = append(out, MemberOf(byID[id]))
return ""
}
// membersOfAsk are a group's members as the controller is asked about them: by id. No order is asked for or kept; the
// plan's tiers take it from the module graph (novox/hq ADR 0418 §2, ADR 0419).
func membersOfAsk(members []*Delivery) []Member {
sorted := append([]*Delivery(nil), members...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
out := make([]Member, 0, len(sorted))
for _, d := range sorted {
out = append(out, MemberOf(d))
}
return out
}
// groupTurns walks a delivering group in its order: the next member is let go only when every member before
// it is delivered; a member that failed or was stopped stops every member after it, naming it. What was
// delivered before it stays, each judged at its own gate (ADR 0239 decision 4); the failed member's builds
// were put back at its gate by the controller. A superseded member counts as delivered once a later delivered
// walk carried it, holds the members after it while a later merge can still carry it, and stops them, saying
// why, only when none can (novox/hq issue 445).
func (h *Holder) groupTurns(g *Group, members []*Delivery) []func() {
waits := map[string]carryWait{}
asks := h.turnsOf(g, members, waits)
// What each member's turn waits on a carrier for is kept on it, and cleared once it no longer does, so `show`
// and `stalled` name the carrier and until when (ADR 0276 decision 9, novox/hq issue 445).
// groupWord gives a closed group's deliveries their turn for the plan's word, once every member is merged (novox/hq
// ADR 0418 §2, issue 978): no member has a turn of its own, and none waits for another's delivery. The word itself is
// asked once per plan by words, when every live delivery the plan carries is at its turn. A member that failed or was
// stopped stops no other: a group's plan that fails fails as every plan does (ADR 0418 §5), and a stop is a person's.
func (h *Holder) groupWord(g *Group, members []*Delivery) []func() {
for _, d := range members {
w := waits[d.ID]
if d.State != Published || !d.LetGo.IsZero() {
w = carryWait{} // only a member still waiting for its turn waits for a carrier
if d.MergedAs == "" && !d.State.Final() {
return nil // a member not yet on the trunk: the plan waits for it, nothing is let go
}
var until *time.Time
if !w.until.IsZero() {
u := w.until
until = &u
h.lookAt(u, "a group member's turn that waits for a later merge to carry it, until the wait's bound")
}
if d.TurnAwaits != w.said || (d.TurnAwaitsUntil == nil) != (until == nil) ||
until != nil && !d.TurnAwaitsUntil.Equal(*until) {
d.TurnAwaits, d.TurnAwaitsUntil = w.said, until
h.keep(d)
}
}
return asks
}
// turnsOf is groupTurns' walk through the order; waits receives, by member, the carrier its turn waits for.
func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWait) []func() {
ordered := h.inOrder(g, members)
byID := map[string]*Delivery{}
for _, d := range members {
byID[d.ID] = d
}
// Composed for these very heads: a verdict on another set of heads is no verdict on these.
composed := g.Check != nil && (g.Check.Verdict == "pass" || g.Check.Verdict == "warning") &&
sameMembers(g.Check.Commits, commitsOf(members)) && g.Check.OutOfDate == ""
// A member's turn registered, the members after it that the same walk carries take theirs too: one walk is let
// go once, for all of them (ADR 0276 decision 5). A member on another walk, or not at its turn, waits.
sorted := append([]*Delivery(nil), members...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
var asks []func()
sharing := ""
for i, m := range ordered {
d := byID[m.ID]
// A superseded member a later delivered walk carried counts as reached, as in GroupState: a group counts a
// covered member as delivered, so the member after it gets its turn (ADR 0270 decision 2, novox/hq
// issues 442 and 445).
// A stop or failure a later walk delivered since counts as reached too (novox/hq issues 480 and 747).
if memberReached(d) {
continue
}
if sharing != "" && (d.Walk == nil || d.Walk.ID != sharing) {
return asks
}
for _, d := range sorted {
switch {
case d.State == Superseded:
// Superseded is no failure: the merge that took its walk over may still deliver its change. While a later
// merge into its trunk that can carry it is open, within that merge's own bound, the members after it
// wait, as for a member still delivering (ADR 0239 decision 4: the next starts when the one before is
// delivered). Only when none can carry it are they stopped, saying why (novox/hq issue 445).
w := h.carryAwaited(d, h.Now())
// A delivered carrier is asked of the forge here too: coverOf asks only once every module has a
// delivered carrier, and while another module waits on an open one, this answer is what is awaited.
for _, in := range w.ask {
if a := h.askContains(d, in, h.Now()); a != nil {
asks = append(asks, a)
}
}
if w.covered() {
continue // every module delivered by a later merge the forge says contains it: reached
}
if w.why != "" {
for _, later := range ordered[i+1:] {
if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID,
d.ID, w.why)); a != nil {
asks = append(asks, a)
}
}
return asks
}
w.said = fmt.Sprintf("its turn in group %s waits for %s, superseded, to be carried by %s, until %s", g.ID,
d.ID, w.said, w.until.UTC().Format(time.RFC3339))
for _, later := range ordered[i+1:] {
waits[later.ID] = w
}
// Under ADR 0276 a member is superseded when its waiting walk was folded into a successor that does not
// answer its merge, and the member after it was folded into the same successor: the carrier is then in
// the next member's own walk, which waits for that member's turn. Waiting there would wait for itself
// until the carrier's bound. That walk delivers both, in the group's order among its tiers (decision 5),
// so the members on it take their turn as members sharing one walk do; a member on another walk waits.
// It is shared only when that one walk carries every module of the member not yet carried, each by a
// merge the forge said contains the member's: a carrier of some modules on it, others elsewhere, would
// let the next member go on a walk that cannot deliver the member before it (review of #203).
shared, ask := h.sharedWalk(d, w, h.Now())
asks = append(asks, ask...)
if shared != "" && sharing == "" {
sharing = shared
continue
}
return asks
case h.stopLapsed(d):
// Stopped for a member whose change reached the machines since: it stops nothing more. Its own change is
// delivered by a later walk of its repository that contains it, which then covers it (novox/hq issue 747).
case memberReached(d), h.stopLapsed(d):
continue
case d.State == Failed || d.State == Stopped:
for _, later := range ordered[i+1:] {
if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil {
asks = append(asks, a)
}
}
return asks
case d.State == Held:
// Held for a composed check that has since passed for the heads that merged: lifted, published again,
// and its turn taken in order (novox/hq issue 316). Any other hold is a person's.
// Held for a composed check that has since passed for the heads that merged: lifted, published again
// (novox/hq issue 316). Any other hold is a person's.
if composed && heldForGroup(d, g) && walkCanGoOn(d) {
d.HeldWhy = ""
d.Lifted = fmt.Sprintf("its group %s's composed check said %s for the heads that merged: %s", g.ID,
g.Check.Verdict, g.Check.Summary)
h.settleAndKeep(d)
if d.State == Published {
if a := h.turn(d, fmt.Sprintf("its turn in group %s (%d of %d)", g.ID, i+1, len(ordered))); a != nil {
asks, sharing = append(asks, a), d.Walk.ID
continue
if a := h.turn(d, "its group "+g.ID+": every member is merged"); a != nil {
asks = append(asks, a)
}
}
}
return asks
case d.State == Published:
// Asked and not answered is not "did not pass": it waits, published, for the answer (issue 316). Nor is a
// composed check of other heads — a member rebased or pushed — or one out of date: it runs again for these
// heads, and the member waits for it (novox/hq ADR 0323 §6, issue 406).
// composed check of other heads, or one out of date: it runs again for these heads, and the member waits
// for it (novox/hq ADR 0323 §6, issue 406).
if !composed && d.HeldWhy == "" && g.Check != nil && (g.Check.Verdict == "" || g.Check.OutOfDate != "" ||
!sameMembers(g.Check.Commits, commitsOf(members))) {
return asks
continue
}
if !composed && d.HeldWhy == "" {
d.HeldWhy = fmt.Sprintf("its group %s's composed check did not pass for the heads that merged; a person "+
"decides that it goes on", g.ID)
d.Unwalked = false
h.settleAndKeep(d)
return asks
}
if a := h.turn(d, fmt.Sprintf("its turn in group %s (%d of %d)", g.ID, i+1, len(ordered))); a != nil {
asks, sharing = append(asks, a), d.Walk.ID
delete(waits, d.ID)
continue
}
return asks
default:
return asks // not on the trunk yet, held, or delivering: the members after it wait
}
}
return asks
}
// containsGrace is how long a later merge whose walk was delivered may still be awaited as a superseded member's
// carrier while the forge has not said whether its merge commit contains the member's: as long as a published
// delivery may wait for its turn (Bounds[Published]). A forge that cannot answer means "not yet covered" (ADR
// 0270), and without this bound the members after it would wait on that answer for ever (novox/hq issue 445).
const containsGrace = 30 * time.Minute
// readyCarrierBound is how long a merged carrier with no walk yet may be awaited from its merge. Its state, ready,
// has no bound of its own: it waits in a batch, which closes at the latest ten minutes after its first merge
// (ADR 0276 decision 1, `merge-window-at-most`), and then behind at most the one open walk, within that walk's
// bound (decisions 4 and 9). A merge no walk answers is held after `unwalked`, and a held one is no carrier.
const readyCarrierBound = 10*time.Minute + 2*time.Hour
// carryWait is what a superseded member's group waits for: the later merges that may still carry it, said, and
// the moment the wait ends at the latest; or why none can carry it. Neither means every module it moves was
// delivered by a later merge the forge says contains it: covered.
type carryWait struct {
by []*Delivery
// byModule are the carriers awaited for each module not yet carried.
byModule map[string][]*Delivery
said string
until time.Time
why string
// ask are the delivered carriers' merge commits the forge has not yet said contain the member's.
ask []string
}
func (w carryWait) covered() bool { return len(w.by) == 0 && w.why == "" }
// sharedWalk is the walk, not started and waiting for this owner's word, that carries every module of a superseded
// member not yet carried, each by a carrier the forge said contains the member's merge: the walk the members after
// it may take their turn on. Whether a carrier on it contains the merge, when not yet known, is asked of the forge,
// and the members wait for the answer within the carrier's bound (ADR 0270, novox/hq issue 445).
func (h *Holder) sharedWalk(d *Delivery, w carryWait, now time.Time) (string, []func()) {
var walks []string
for _, c := range w.by {
if !c.State.Final() && c.Walk != nil && c.Walk.Waits && !c.Walk.Started() && !containsString(walks, c.Walk.ID) {
walks = append(walks, c.Walk.ID)
}
}
sort.Strings(walks)
var asks []func()
for _, walk := range walks {
every, said := true, true
var unknown []string
for _, carriers := range w.byModule {
on, yes := false, false
for _, c := range carriers {
if c.State.Final() || c.Walk == nil || c.Walk.ID != walk || !c.Walk.Waits || c.Walk.Started() {
continue
}
on = true
contained, known := d.ContainedIn[c.MergedAs]
switch {
case known && contained:
yes = true
case !known && !containsString(unknown, c.MergedAs):
unknown = append(unknown, c.MergedAs)
}
}
every = every && on
said = said && yes
}
if !every {
continue // a module is carried elsewhere, or by nothing on this walk: it cannot deliver the member
}
if said {
return walk, asks
}
for _, in := range unknown {
if a := h.askContains(d, in, now); a != nil {
if a := h.turn(d, "its group "+g.ID+": every member is merged"); a != nil {
asks = append(asks, a)
}
}
}
return "", asks
}
// carryAwaited says which later merges into a superseded member's trunk may still carry every module it moves —
// the cover GroupState counts as reached (ADR 0270's covered test, novox/hq issue 442) — and until when, or,
// when none can, why. A later merge of the same repository into the same trunk on a passing check, whose plan
// moves the module and whose merge commit the forge has not said leaves this one out, can carry it while it is
// merged and not yet ended, not held for a person, and within its bound (ADR 0239 decision 9, ADR 0276 decision
// 9: every wait has a bound): its state's, or readyCarrierBound from its merge while no walk carries it yet; once
// delivered, while the forge has not yet said whether it contains this merge, within containsGrace. A carrier
// superseded in turn ends; the later merge that took it over is a carrier of its own (novox/hq issue 445).
func (h *Holder) carryAwaited(d *Delivery, now time.Time) carryWait {
if d.MergedAs == "" || d.MergedAt.IsZero() || d.Plan == nil || len(d.Plan.Moved) == 0 {
return carryWait{why: "nothing records which modules it moves, so no later merge can carry it"}
}
var w carryWait
seen := map[string]bool{}
modules:
for _, module := range d.Plan.Moved {
found := false
var latest time.Time // the last moment a carrier of this module may still be awaited
var not []string
for _, c := range h.deliveries {
if !mayCarry(c, d, module) {
continue
}
contained, known := d.ContainedIn[c.MergedAs]
var until time.Time
switch {
case known && !contained:
not = append(not, c.ID+" does not contain its merge")
continue
case c.State == Delivered && carries(c, d, module) && known:
continue modules // delivered and contained: this module is carried
case c.State == Delivered && carries(c, d, module):
until = c.Since.Add(containsGrace)
if now.After(until) {
not = append(not, fmt.Sprintf("the forge did not say within %s whether %s contains its merge", containsGrace, c.ID))
continue
}
if !containsString(w.ask, c.MergedAs) {
w.ask = append(w.ask, c.MergedAs)
}
case c.State.Final() || c.State == Held:
continue // ended without carrying it, or held for a person: no carrier to wait for
case c.State == Ready:
until = c.MergedAt.Add(readyCarrierBound)
if now.After(until) {
not = append(not, fmt.Sprintf("%s is merged and no walk carried it within %s", c.ID, readyCarrierBound))
continue
}
default:
b, bounded := boundOf(c)
if pastBound(c, now) {
not = append(not, fmt.Sprintf("%s is past its bound (%s %s)", c.ID, c.State, b.For))
continue
}
if until = now.Add(Bounds[Delivering].For); bounded && b.For > 0 {
until = boundSince(c).Add(b.For)
}
}
found = true
if w.byModule == nil {
w.byModule = map[string][]*Delivery{}
}
w.byModule[module] = append(w.byModule[module], c)
if until.After(latest) {
latest = until
}
if !seen[c.ID] {
seen[c.ID] = true
w.by = append(w.by, c)
}
}
if !found {
why := fmt.Sprintf("no later merge into %s can carry %s", d.Base, module)
if len(not) > 0 {
sort.Strings(not)
why += ": " + strings.Join(not, "; ")
}
return carryWait{why: why}
}
// The wait ends when the first module runs out of carriers, whatever the others still have.
if w.until.IsZero() || latest.Before(w.until) {
w.until = latest
}
}
sort.Slice(w.by, func(i, j int) bool { return w.by[i].ID < w.by[j].ID })
var said []string
for _, c := range w.by {
s := fmt.Sprintf("%s (%s", c.ID, c.State)
if c.Walk != nil {
s += ", walk " + c.Walk.ID
}
said = append(said, s+")")
}
w.said = strings.Join(said, ", ")
return w
}
// mayCarry is whether c is a later merge that could carry module for d, by what is known before its walk ends: of
// the same repository, merged later into the same trunk, on its own passing check, its plan moving the module.
func mayCarry(c, d *Delivery, module string) bool {
return c.ID != d.ID && strings.EqualFold(c.Repository, d.Repository) && strings.EqualFold(c.Base, d.Base) &&
c.MergedAs != "" && !sameCommit(c.MergedAs, d.MergedAs) && c.MergedAt.After(d.MergedAt) && c.Check.Passes() &&
c.Plan != nil && slices.Contains(c.Plan.Moved, module)
}
// stopInGroup stops a member after one that failed, and leaves its merge out of its walk if it has one open.
//
// **Only its own change** (novox/hq issue 459, ADR 0299): one walk carries every merge of its batch, so ending the
// member's walk ended every other delivery on it, which nobody stopped. Its merge is left out instead: the
// controller ends the walk and walks the other merges again without it, and without each later merge of its
// repository, which contains it. Every member a pass stops on one walk is gathered first, and the walk is asked
// once, by the first member's ask, after the pass: a member is never walked again because its stop came second.
func (h *Holder) stopInGroup(d *Delivery, cause, why string) func() {
if d == nil || d.State.Final() {
return nil
}
t, err := Apply(d, EvStop, true, Facts{Now: h.Now(), By: byOwner, Why: why})
if err != nil {
h.refuse(err)
return nil
}
d.StoppedFor = cause // the member it was stopped for: once that one is delivered, the stop lapses (novox/hq issue 747)
h.moved(d, []Transition{t})
// Its open walk; or, its walk stopped for another merge, the batch or walk that walks its merge again (review
// of #221): a merge walked again and then stopped by its group is left out there too.
var walk string
switch {
case d.Walk == nil:
return nil
case d.Walk.State == walkBuilding || d.Walk.State == walkRolling:
walk = d.Walk.ID
case d.walkedAgain():
walk = d.Walk.WalkedAgainBy
default:
return nil
}
if !h.leave(walk, d) {
return nil // the first member left out of this walk asks for every one
}
return func() {
without, shared := h.leaving459(walk)
if !shared {
if err := h.Controller.Stop(walk, why, byOwner); err != nil {
h.Logf("[mesh-delivery] the walk %s could not be stopped: %v", walk, err)
}
return
}
h.Logf("[mesh-delivery] %s", h.stopLeaving(walk, why, byOwner, without))
}
return asks
}
// stopLeaving asks the controller to stop a walk or batch without merges (ADR 0299) and says what came of it. A
@@ -2654,8 +2321,7 @@ func againWords(a StopAnswer) string {
// 0270's covered test); a closed group whose members all ended is then delivered, and otherwise stopped, naming
// the member. A group with nothing in progress never reads delivering.
//
// groupTurns agrees: the members after a carried member take their turn, and they wait while a later merge can still
// carry it (novox/hq issue 445).
// groupWord agrees: a group's plan is let go once, when every member is merged (novox/hq ADR 0418 §2).
func GroupState(g *Group, members []*Delivery) (string, string) {
count := map[State]int{}
reached, ended := 0, 0
@@ -2710,8 +2376,6 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
return "rejected", "composed together: " + g.Check.Summary
case g.Closed:
return "delivering", fmt.Sprintf("%d of %d delivered", reached, n)
case len(g.Cycle) > 0:
return "rejected", cycleSaid(g)
case count[Rejected] > 0:
return "rejected", "a member's own check failed"
case g.Check != nil && g.Check.OutOfDate != "":
@@ -2761,31 +2425,6 @@ func containsString(xs []string, x string) bool {
return false
}
// cycleSaid is why a group's order is refused: each contradiction no precedence resolves, with the rules on
// both sides and how to declare the order — or, for a cycle through more members, the pairs in it.
func cycleSaid(g *Group) string {
why := "its order contradicts itself: "
if len(g.Contradictions) > 0 {
var said []string
for _, c := range g.Contradictions {
said = append(said, c.Said)
}
return why + strings.Join(said, "; ")
}
var pairs []string
for _, p := range g.Pairs {
if slices.Contains(g.Cycle, p.Before) && slices.Contains(g.Cycle, p.After) {
pairs = append(pairs, p.orderSaid())
}
}
why += strings.Join(g.Cycle, ", ")
if len(pairs) > 0 {
why += " (" + strings.Join(pairs, "; ") + "); declare the order with a line `after: <repository>` in " +
"the description of the pull request that goes later"
}
return why
}
// rejudgedSince is whether a member was judged again after a moment: a verdict of its own heard since.
func rejudgedSince(members []*Delivery, at time.Time) bool {
for _, d := range members {
@@ -350,16 +350,13 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
}
}
// A delivery group (ADR 0239 decision 4): two heads sharing a branch name across repositories, ordered by the
// controller, checked together, ready only when both are and their composition passed — and delivered in
// order, whatever order they merged in.
func TestAGroupIsOrderedCheckedTogetherAndDeliveredInOrder(t *testing.T) {
// A delivery group (ADR 0239 decision 4, ADR 0418): two heads sharing a branch name across repositories, checked
// together, ready only when both are and their composition passed — and let go for delivery once, when every member is
// merged, whatever order they merged in. No member waits for another's delivery.
func TestAGroupIsCheckedTogetherAndLetGoOnceEveryMemberIsMerged(t *testing.T) {
w := newWorld(t)
const ctlHead, catHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order {
return Order{Order: []string{ctl, cat}, Pairs: []Pair{{Before: ctl, After: cat, Why: "version skew"}}}
}
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
if w.delivery(ctl).Group != "feat/x" || w.delivery(cat).Group != "feat/x" {
@@ -372,8 +369,8 @@ func TestAGroupIsOrderedCheckedTogetherAndDeliveredInOrder(t *testing.T) {
}
w.h.Checked(verdict("novox/mesh-catalog", 2, catHead, "pass"))
w.settleAll()
if len(w.ctl.checks) != 1 || w.ctl.checks[0] != "feat/x:"+ctl+","+cat {
t.Fatalf("the composed check asked %v, wanted the members in order", w.ctl.checks)
if len(w.ctl.checks) != 1 || w.ctl.checks[0] != "feat/x:"+cat+","+ctl {
t.Fatalf("the composed check asked %v, wanted the members by id", w.ctl.checks)
}
if state, _ := GroupState(w.h.groups["feat/x"], w.h.membersOf(w.h.groups["feat/x"])); state != "checking" {
t.Fatalf("before its composed verdict the group is %s", state)
@@ -387,34 +384,34 @@ func TestAGroupIsOrderedCheckedTogetherAndDeliveredInOrder(t *testing.T) {
t.Fatalf("the group's status on a member's head: %v", w.forge.statuses)
}
// The catalogue merges first; its walk waits — the controller has not been delivered.
// The catalogue merges first; its walk waits — the controller's member is not merged yet, so nothing is let go.
w.h.PullMerged(merged("novox/mesh-catalog", 2, catHead, "m2m2m2m2m2m2"))
w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", "novox/mesh-catalog", "m2m2m2m2m2m2", true)))
w.settleAll()
if w.state(cat) != Published || len(w.ctl.delivers) != 0 {
t.Fatalf("a member merged before the one it goes after was let go: %s %v", w.state(cat), w.ctl.delivers)
t.Fatalf("a member was let go before every member merged: %s %v", w.state(cat), w.ctl.delivers)
}
// The controller merges: its walk is on the controller's own path, started at the merge.
// The controller merges: its walk is on the controller's own path, started at the merge. Every member is merged,
// so the catalogue's walk is let go at once, without waiting for the controller's delivery.
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
ctlWalk := w.ctl.put(aWalk("plan-ctl", "novox/mesh-controller", "m1m1m1m1m1m1", false))
w.h.WalkMoved(ctlWalk)
w.settleAll()
if w.state(ctl) != Delivering || len(w.ctl.delivers) != 0 {
t.Fatalf("the controller's member is %s, let go %v", w.state(ctl), w.ctl.delivers)
}
ctlWalk.State = walkDone
w.h.WalkMoved(w.ctl.put(ctlWalk))
w.settleAll()
if w.state(ctl) != Delivered || len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-cat" {
t.Fatalf("once the controller was delivered, the catalogue was not let go: %s %v", w.state(ctl), w.ctl.delivers)
if w.state(ctl) != Delivering || len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-cat" {
t.Fatalf("every member merged, the catalogue was not let go once: %s %v", w.state(ctl), w.ctl.delivers)
}
if w.state(cat) != Delivering {
t.Fatalf("the catalogue's member is %s", w.state(cat))
}
w.settleAll()
if len(w.ctl.delivers) != 1 {
t.Fatalf("the word was given more than once: %v", w.ctl.delivers)
}
}
// A member that fails stops every member after it, naming it; what was delivered before it stays.
func TestAFailedMemberStopsTheMembersAfterItAndLeavesThoseBefore(t *testing.T) {
// A member that fails stops no other (ADR 0418 §5): the plan that fails fails as every plan does, and what was delivered
// stays. Nothing is stopped member by member.
func TestAFailedMemberStopsNoOtherMember(t *testing.T) {
w := newWorld(t)
heads := map[string]string{"novox/a": "a1a1a1a1a1a1a1", "novox/b": "b2b2b2b2b2b2b2", "novox/c": "c3c3c3c3c3c3c3"}
ids := map[string]string{}
@@ -431,107 +428,108 @@ func TestAFailedMemberStopsTheMembersAfterItAndLeavesThoseBefore(t *testing.T) {
w.h.WalkMoved(w.ctl.put(aWalk("plan-"+repo[6:], repo, mergeCommit, true)))
}
w.settleAll()
if len(w.ctl.delivers) != 3 {
t.Fatalf("every member merged, yet the walks were let go %v", w.ctl.delivers)
}
a := w.ctl.walk("plan-a")
a.State = walkDone
w.h.WalkMoved(w.ctl.put(a))
w.settleAll()
b := w.ctl.walk("plan-b")
if b.Delivery == nil || b.Delivery.Go == nil {
t.Fatalf("b was not let go after a was delivered: %v", w.ctl.delivers)
}
b.State = walkFailed
b.Note = "b failed its gate on anchor"
w.h.WalkMoved(w.ctl.put(b))
w.settleAll()
if w.state(ids["novox/a"]) != Delivered || w.state(ids["novox/b"]) != Failed || w.state(ids["novox/c"]) != Stopped {
if w.state(ids["novox/a"]) != Delivered || w.state(ids["novox/b"]) != Failed || w.state(ids["novox/c"]) == Stopped {
t.Fatalf("a %s, b %s, c %s", w.state(ids["novox/a"]), w.state(ids["novox/b"]), w.state(ids["novox/c"]))
}
c := w.delivery(ids["novox/c"])
if why := c.Transitions[len(c.Transitions)-1].Why; !strings.Contains(why, ids["novox/b"]) {
t.Fatalf("c's stop does not name the member that failed: %q", why)
}
if len(w.ctl.stops) != 1 || w.ctl.stops[0] != "plan-c" {
t.Fatalf("c's walk was not ended: %v", w.ctl.stops)
if len(w.ctl.stops) != 0 || len(w.ctl.withouts) != 0 {
t.Fatalf("a walk was stopped because another failed: %v %v", w.ctl.stops, w.ctl.withouts)
}
if state, _ := GroupState(w.h.groups["feat/y"], w.h.membersOf(w.h.groups["feat/y"])); state != "failed" {
t.Fatalf("the group is %s", state)
}
}
// A group whose declared order contradicts its inferred one is rejected, and checked never.
func TestAGroupWithACycleIsRejected(t *testing.T) {
// ADR 0419 §2, issue 978: a description's `after:` line is not read, and says so once on its pull request. The
// composed check is asked as for any other group: no order is asked of the controller first, and none gates it.
func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T) {
w := newWorld(t)
w.ctl.order = func(ms []Member) Order { return Order{Cycle: []string{ms[0].ID, ms[1].ID}} }
w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z"))
w.op.auto = false
pa := pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z")
pa.Body = "Goes after the other.\n\nafter: novox/b\n"
w.h.PullUpdated(pa)
w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z"))
w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass"))
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
w.settleAll()
g := w.h.groups["feat/z"]
if state, why := GroupState(g, w.h.membersOf(g)); state != "rejected" || !strings.Contains(why, "contradicts") {
t.Fatalf("a cycle left the group %s: %s", state, why)
}
if len(w.ctl.checks) != 0 {
t.Fatal("a group with a cycle was checked")
}
}
// novox/hq issue 309: a group whose rules order one pair both ways at one rank is refused with both rules
// and how to declare the order; a pair a rule won by precedence says what it won over, and the group is
// checked as any other.
func TestAGroupSaysWhichOrderRuleWonOrWhyNoneDid(t *testing.T) {
w := newWorld(t)
said := "novox/a@a1a1a1a1a1a1 and novox/b@b2b2b2b2b2b2: built by puts novox/a@a1a1a1a1a1a1 first, built by puts " +
"novox/b@b2b2b2b2b2b2 first; neither outranks the other: declare the order with a line `after: novox/a` in " +
"novox/b#2's description, or `after: novox/b` in novox/a#1's"
w.ctl.order = func(ms []Member) Order {
return Order{Cycle: []string{ms[0].ID, ms[1].ID}, Contradictions: []Contradiction{{
Members: []string{ms[0].ID, ms[1].ID}, Said: said,
Rules: []Pair{{Before: ms[0].ID, After: ms[1].ID, Why: "built by"}, {Before: ms[1].ID, After: ms[0].ID, Why: "built by"}}}}}
}
w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z"))
w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z"))
w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass"))
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
w.later(time.Minute)
w.settleAll()
g := w.h.groups["feat/z"]
if state, why := GroupState(g, w.h.membersOf(g)); state != "rejected" || !strings.Contains(why, said) {
t.Fatalf("a contradiction was said %s: %s", state, why)
if len(w.ctl.checks) != 1 {
t.Fatalf("the composed check was not asked once: %v", w.ctl.checks)
}
if lines := w.h.Groups(false); len(lines) != 1 || len(lines[0].Contradictions) != 1 {
t.Fatalf("groups does not list the contradiction: %+v", lines)
if got := w.ctl.checks[0]; got != "feat/z:"+IDOf("novox/a", "a1a1a1a1a1a1a1")+","+IDOf("novox/b", "b2b2b2b2b2b2b2") {
t.Fatalf("the members asked: %s", got)
}
// Resolved by precedence: ordered, the pair saying what it won over, and checked.
w2 := newWorld(t)
w2.ctl.order = func(ms []Member) Order {
return Order{Order: []string{ms[0].ID, ms[1].ID},
Pairs: []Pair{{Before: ms[0].ID, After: ms[1].ID, Why: "built by", Over: []string{"engine before controller"}}}}
}
w2.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z"))
w2.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z"))
w2.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass"))
w2.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
w2.settleAll()
g = w2.h.groups["feat/z"]
if len(g.Cycle) > 0 || len(w2.ctl.checks) != 1 {
t.Fatalf("a group ordered by precedence was not checked: cycle %v, checks %v", g.Cycle, w2.ctl.checks)
}
if got := g.Pairs[0].orderSaid(); !strings.HasSuffix(got, ": built by, over engine before controller") {
t.Fatalf("the pair is said %q", got)
}
}
// A cycle through members with no contradiction of its own names the pairs in it.
func TestACycleWithoutAContradictionNamesItsPairs(t *testing.T) {
g := &Group{Cycle: []string{"a", "b", "c"}, Pairs: []Pair{{Before: "a", After: "b", Why: "built by"},
{Before: "b", After: "c", Why: "declared"}, {Before: "c", After: "a", Why: "version skew"}}}
why := cycleSaid(g)
for _, w := range []string{"a before b: built by", "b before c: declared", "c before a: version skew", "`after: <repository>`"} {
if !strings.Contains(why, w) {
t.Errorf("the cycle does not say %q: %s", w, why)
said := 0
for _, c := range w.forge.comments {
if strings.Contains(c, "not read") && strings.Contains(c, "novox/a#1") {
said++
}
}
if said != 1 {
t.Fatalf("the after: line is not said once on its pull request: %v", w.forge.comments)
}
// A new head of the same pull request, with the line still there, is not told again.
pa2 := pr("novox/a", 1, "a9a9a9a9a9a9a9", "feat/z")
pa2.Body = pa.Body
w.h.PullUpdated(pa2)
w.settleAll()
said = 0
for _, c := range w.forge.comments {
if strings.Contains(c, "not read") {
said++
}
}
if said != 1 {
t.Fatalf("said again for a new head: %v", w.forge.comments)
}
}
// A group's members have their mesh/merge-gate said pending, saying why, until a composed verdict stands at their heads
// (ADR 0403 §1, §6); the verdict then sets the gate in the forge module.
func TestAGroupMembersGateIsPendingUntilItsComposedVerdictStands(t *testing.T) {
w := newWorld(t)
w.op.auto = false
const ha, hb = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"
w.h.PullUpdated(pr("novox/a", 1, ha, "feat/z"))
w.h.PullUpdated(pr("novox/b", 2, hb, "feat/z"))
w.h.Checked(verdict("novox/a", 1, ha, "pass"))
w.h.Checked(verdict("novox/b", 2, hb, "pass"))
w.settleAll()
for _, at := range []string{"novox/a@" + ha, "novox/b@" + hb} {
if s := w.forge.statuses[at+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") || !strings.Contains(s, "composed check") {
t.Fatalf("the member's gate on %s is %q, wanted pending with its reason", at, s)
}
}
// Said once while the reason stands.
w.later(time.Minute)
before := w.forge.statusWrites
w.settleAll()
if w.forge.statusWrites != before {
t.Fatalf("the pending gate was written again with no change")
}
// A composed verdict stands: mesh-delivery says nothing more of the gate (the forge module sets it).
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "pass", Summary: "compose"})
w.settleAll()
if g := w.h.groups["feat/z"]; len(g.SaidGate) != 0 {
t.Fatalf("a standing verdict left a gate pending: %v", g.SaidGate)
}
// A member's push puts it back to pending.
w.h.PullUpdated(pr("novox/a", 1, "a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9", "feat/z"))
w.settleAll()
if s := w.forge.statuses["novox/b@"+hb+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") {
t.Fatalf("the sibling's gate after a push is %q", s)
}
}
// Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that.
@@ -215,9 +215,13 @@ func TestAGroupsComposedCheckGoesOutOfDateOnAMergeThatIsNotItsMember(t *testing.
t.Fatalf("mesh/delivery-group on %s says %q, not pending with the reason", head, s)
}
}
if len(w.ctl.checks) != asked {
t.Fatalf("the trunk's move asked a check: %v", w.ctl.checks[asked:])
// The group's Approve stays; each member whose verdict went out of date has its check asked at once, against the
// trunk's head (ADR 0421 §3, issue 1047): only the member whose trunk moved, and the composed check is asked
// after it.
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+idc {
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
}
asked++
// The controller's member judged again against the trunk's head: the composed check runs again.
w.later(time.Minute)
w.h.Checked(judged(ctl, 1, hc, "pass", merge1))
@@ -251,15 +255,13 @@ func TestARebasedMemberIsComposedAgainNotHeld(t *testing.T) {
const hc, hk, hc2 = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1", "c2c2c2c2c2c2c2c2"
idc, idk, idc2 := IDOf(ctl, hc), IDOf(cat, hk), IDOf(ctl, hc2)
w := newWorld(t)
w.op.auto = false // the merges are heard as events below, as when the forge refused one
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
w.settleAll()
g := w.h.groups["feat/g"]
if len(g.Order) != 2 || g.Order[0] != idk {
t.Fatalf("the group's order is %v: the catalogue's member first is what this replays", g.Order)
}
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"})
w.settleAll()
@@ -279,8 +281,8 @@ func TestARebasedMemberIsComposedAgainNotHeld(t *testing.T) {
if d := w.delivery(idc2); d.Group != "feat/g" {
t.Fatalf("the rebased head did not take its place in its closed group: %q", d.Group)
}
if containsString(g.Members, idc) || !containsString(g.Members, idc2) || len(g.Order) != 2 || g.Order[1] != idc2 {
t.Fatalf("the group's members are %v in the order %v", g.Members, g.Order)
if containsString(g.Members, idc) || !containsString(g.Members, idc2) || len(g.Members) != 2 {
t.Fatalf("the group's members are %v", g.Members)
}
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
t.Fatalf("the group reads %s (%s), not checking", state, why)
@@ -386,59 +388,6 @@ func TestAnOutOfDateDeliveryAskedAgainIsBoundFromTheAsk(t *testing.T) {
}
}
// The same, with the first member already delivered when the second is rebased: a delivered member counts for the
// composed check of the new heads, which is asked, and the rebased member is never held (review of ADR 0323 §6).
func TestARebasedMemberAfterAnotherWasDeliveredIsComposedAgain(t *testing.T) {
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
const hc, hk, hc2 = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1", "c2c2c2c2c2c2c2c2"
idk, idc2 := IDOf(cat, hk), IDOf(ctl, hc2)
w := newWorld(t)
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
w.settleAll()
g := w.h.groups["feat/g"]
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"})
w.settleAll()
// The catalogue's member merges and its walk is done: delivered.
mk := merged(cat, 2, hk, merge1)
mk.Head = "feat/g"
w.h.PullMerged(mk)
walk := w.ctl.put(aWalk("plan-cat", cat, merge1, true))
w.h.WalkMoved(walk)
w.settleAll()
walk = w.ctl.walk("plan-cat")
walk.State = walkDone
w.h.WalkMoved(w.ctl.put(walk))
w.settleAll()
if s := w.state(idk); s != Delivered {
t.Fatalf("the catalogue's member is %s, not delivered", s)
}
// The controller's member is rebased, and judged.
w.h.PullUpdated(pr(ctl, 1, hc2, "feat/g"))
w.h.Checked(judged(ctl, 1, hc2, "pass", trunk0))
w.settleAll()
asked := w.ctl.checks[len(w.ctl.checks)-1]
if !strings.HasPrefix(asked, "feat/g:") || !strings.Contains(asked, idc2) {
t.Fatalf("the composed check was not asked again for the new heads: %v", w.ctl.checks)
}
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
t.Fatalf("the group reads %s (%s), not checking", state, why)
}
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
mc := merged(ctl, 1, hc2, "e0e0e0e0e0e0e0e0")
mc.Head = "feat/g"
w.h.PullMerged(mc)
w.h.WalkMoved(w.ctl.put(aWalk("plan-ctl", ctl, "e0e0e0e0e0e0e0e0", true)))
w.settleAll()
if d := w.delivery(idc2); d.State == Held {
t.Fatalf("the rebased member is held: %s", d.HeldWhy)
}
}
// A check this owner asks — a recheck — sends the base head the forge last announced the head with, so the
// controller's verdict for a head no build judges names its trunk commit (ADR 0323 §3, review of mesh-controller#271).
func TestARecheckSendsTheAnnouncedBaseHead(t *testing.T) {
@@ -50,8 +50,7 @@ func TestAGroupWhoseMembersEndedEnds(t *testing.T) {
return d
}
group := func(id string, order ...string) *Group {
return &Group{ID: id, Members: append([]string(nil), order...), Closed: true, Order: order, Said: "delivering",
OrderOf: map[string]string{}}
return &Group{ID: id, Members: append([]string(nil), order...), Closed: true, Said: "delivering"}
}
const checks, assign = "feat/delivery-checks-verb", "fix/assign-says-why-a-module-is-not-there"
@@ -1,446 +0,0 @@
package main
import (
"strings"
"testing"
"time"
)
// novox/hq issue 445: in a delivery group ordered [A, B], A's walk was superseded by the walk of a later merge C
// of A's repository that does not answer A's merge. groupTurns stopped B at once, although C's walk could still
// carry A's modules and A then counts as reached (ADR 0270 decision 2, issue 442). While a later merge that can
// carry A is open, B waits, saying for which merge and until when; once that merge's walk is delivered and covers
// A, B takes its turn; when C's walk is B's own (ADR 0276 folds both into one successor), B takes its turn on it;
// when no later merge can carry A, or the one that could is past its own bound, B is stopped, saying why.
//
// The shape is the live group fix/assign-says-why-a-module-is-not-there as mesh-delivery showed it on
// 2026-10-11 (issue 442's test): the controller's member superseded first in the order, the lab's after it.
const (
g445 = "fix/assign-says-why-a-module-is-not-there"
ctl445, lab445 = "novox/mesh-controller", "novox/mesh-lab"
mergedA, mergedB, mergedC = "a5f53ef9ebdf3650", "77777777aaaaaaaa", "3f68a49500000000"
mergedD = "4d4d4d4d00000000"
headA, headB, headC, headD = "8adb7f1a051941e9", "33e63ee5b47a7552", "3f68a495c0c0c0c0", "4d4d4d4dc0c0c0c0"
)
var (
at445 = time.Date(2026, 10, 7, 19, 4, 10, 0, time.UTC)
modules445 = []string{"build-agent", "mesh-controller", "route-proxy"}
aID, bID, cID, dID445 = IDOf(ctl445, headA), IDOf(lab445, headB), IDOf(ctl445, headC), IDOf(ctl445, headD)
)
// shape445 is A superseded, B published on its walk, and C, the later merge of A's repository, as each case sets it.
type shape445 struct {
a, b, c *Delivery
extra []*Delivery
walks []Walk
forge map[string]bool // what the forge says of containment from the start
}
func newShape445() *shape445 {
at := at445
s := &shape445{}
s.a = &Delivery{ID: aID, Repository: ctl445, Commit: headA, Number: 109, Base: "main", Branch: g445, Group: g445,
State: Superseded, Since: at.Add(4 * time.Minute), Created: at.Add(-time.Hour), MergedAs: mergedA, MergedAt: at,
Plan: planMoving(ctl445, modules445...), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-a5f53e", Commit: mergedA, State: walkSuperseded, Branch: "main",
Note: "superseded at tier 0 by plan-later (" + ctl445 + " at 3f68a495); planned there again"},
Transitions: []Transition{{At: at.Add(4 * time.Minute), From: Published, To: Superseded, Event: EvSuperseded,
Why: "a newer delivery to the same trunk took over its walk"}}}
s.b = &Delivery{ID: bID, Repository: lab445, Commit: headB, Number: 117, Base: "main", Branch: g445, Group: g445,
State: Published, Since: at.Add(3 * time.Minute), Created: at.Add(-2 * time.Hour), MergedAs: mergedB,
MergedAt: at.Add(2 * time.Minute), Plan: planMoving(lab445, "mesh-lab"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-lab", Commit: mergedB, State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery",
Branch: "main", Created: at.Add(3 * time.Minute)}}
s.c = &Delivery{ID: cID, Repository: ctl445, Commit: headC, Number: 110, Base: "main", Branch: "fix/later",
State: Delivering, Since: at.Add(5 * time.Minute), Created: at.Add(-time.Hour), MergedAs: mergedC,
MergedAt: at.Add(time.Minute), Plan: planMoving(ctl445, modules445...), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-later", Commit: mergedC, State: walkRolling, WaitedFor: "mesh-delivery", GoBy: byOwner,
Branch: "main", Created: at.Add(4 * time.Minute)}}
went := at.Add(5 * time.Minute)
s.walks = []Walk{
{ID: "plan-a5f53e", Repository: ctl445, Branch: "main", Commit: mergedA, Created: at.Add(30 * time.Second),
State: walkSuperseded, Note: s.a.Walk.Note, Modules: map[string]*WalkModule{}},
{ID: "plan-lab", Repository: lab445, Branch: "main", Commit: mergedB, Created: at.Add(3 * time.Minute),
State: walkBuilding, Tiers: [][]string{{"mesh-lab"}}, Modules: map[string]*WalkModule{"mesh-lab": {}},
Delivery: &WalkDelivery{Awaits: "mesh-delivery"}},
ctlWalk("plan-later", mergedC, at.Add(4*time.Minute), walkRolling, &WalkDelivery{Awaits: "mesh-delivery",
Go: &went, By: byOwner}),
}
return s
}
// ctlWalk is a walk of the controller's repository at a merge commit, moving A's modules.
func ctlWalk(id, commit string, created time.Time, state string, wd *WalkDelivery) Walk {
w := Walk{ID: id, Repository: ctl445, Branch: "main", Commit: commit, Created: created, State: state,
Tiers: [][]string{modules445}, Modules: map[string]*WalkModule{}, Delivery: wd}
for _, m := range modules445 {
w.Modules[m] = &WalkModule{}
}
return w
}
// open is the world over the shape, read back and ticked ten minutes after A's merge.
func (s *shape445) open(t *testing.T) *world {
t.Helper()
store := newMemStore()
for _, d := range append([]*Delivery{s.a, s.b, s.c}, s.extra...) {
if d == nil {
continue
}
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
g := &Group{ID: g445, Members: []string{aID, bID}, Closed: true, Order: []string{aID, bID}, Said: "delivering",
OrderOf: map[string]string{}, Check: &GroupCheck{Asked: "check-1", At: at445.Add(-time.Hour), Verdict: "pass",
Summary: "both compose", Commits: commitsOf([]*Delivery{s.a, s.b})}}
if err := store.PutGroup(g); err != nil {
t.Fatal(err)
}
ctlr := newFakeController()
for _, w := range s.walks {
ctlr.put(w)
}
forge := newFakeForge()
for k, v := range s.forge {
forge.contains[k] = v
}
w := newWorldOver(t, store, ctlr, forge, at445.Add(10*time.Minute))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
return w
}
// end ends a walk in the state given, every module sent to every machine running it.
func end445(w *world, walk, state string) {
r := w.ctl.walk(walk)
sent := w.now
for m := range r.Modules {
r.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
}
r.State = state
w.h.WalkMoved(w.ctl.put(r))
w.settleAll()
}
func waits445(t *testing.T, w *world, on string) {
t.Helper()
d := w.delivery(bID)
if d.State != Published || containsString(w.ctl.delivers, "plan-lab") {
t.Fatalf("B is %s, let go %v; want it published and waiting while %s can still carry A", d.State, w.ctl.delivers, on)
}
if !strings.Contains(d.TurnAwaits, on) || !strings.Contains(d.TurnAwaits, aID) || d.TurnAwaitsUntil == nil {
t.Fatalf("B says it waits for %q until %v; want it to name A and %s", d.TurnAwaits, d.TurnAwaitsUntil, on)
}
g := w.h.groups[g445]
if state, why := GroupState(g, w.h.membersOf(g)); state != "delivering" {
t.Fatalf("the group reads %s (%s), want delivering", state, why)
}
}
func stopped445(t *testing.T, w *world, says string) {
t.Helper()
d := w.delivery(bID)
if d.State != Stopped || len(d.Transitions) == 0 {
t.Fatalf("B is %s, want stopped", d.State)
}
why := d.Transitions[len(d.Transitions)-1].Why
if !strings.Contains(why, aID) || !strings.Contains(why, says) {
t.Fatalf("B was stopped saying %q, want it to name %s and say %q", why, aID, says)
}
if containsString(w.ctl.delivers, "plan-lab") || d.TurnAwaits != "" {
t.Fatalf("B's walk was let go %v, or B still says it waits: %q", w.ctl.delivers, d.TurnAwaits)
}
}
func letGo445(t *testing.T, w *world, walk string) {
t.Helper()
if d := w.delivery(bID); d.State != Delivering || !containsString(w.ctl.delivers, walk) || d.TurnAwaits != "" {
t.Fatalf("B is %s, let go %v, waiting for %q; want its turn taken on %s", d.State, w.ctl.delivers, d.TurnAwaits, walk)
}
}
// C's walk runs on its own: B waits for it, says so, and goes on or stops by what comes of it.
func TestAMemberAfterACarriableSupersededOneWaitsForItsCarrier(t *testing.T) {
t.Run("a later walk that can carry A is open: B waits, naming it", func(t *testing.T) {
w := newShape445().open(t)
waits445(t, w, cID)
// Past a published delivery's own 30 minutes, B is not stalled for "its turn or its word": its bound is
// C's, and what `stalled` would say names C.
w.later(Bounds[Published].For + 5*time.Minute)
w.settleAll()
waits445(t, w, cID)
for _, l := range w.h.Stalled() {
if l.ID == bID {
t.Fatalf("B is listed stalled while C's walk is within its bound: %+v", l)
}
}
if b, _ := boundOf(w.delivery(bID)); !strings.Contains(b.Says, cID) || !strings.Contains(b.Says, "until") {
t.Fatalf("B's bound says %q", b.Says)
}
})
t.Run("that walk delivered and covers A: B takes its turn", func(t *testing.T) {
w := newShape445().open(t)
w.forge.contains[mergedA+" in "+mergedC] = true
end445(w, "plan-later", walkDone)
if s := w.state(cID); s != Delivered {
t.Fatalf("C is %s", s)
}
if d := w.delivery(aID); d.State != Superseded || d.CoveredBy == "" {
t.Fatalf("A is %s, covered by %q", d.State, d.CoveredBy)
}
letGo445(t, w, "plan-lab")
})
t.Run("the forge says the later merge does not contain A: B is stopped", func(t *testing.T) {
w := newShape445().open(t)
w.forge.contains[mergedA+" in "+mergedC] = false
end445(w, "plan-later", walkDone)
stopped445(t, w, "can carry")
})
t.Run("the later walk failed: B is stopped", func(t *testing.T) {
w := newShape445().open(t)
end445(w, "plan-later", walkFailed)
if s := w.state(cID); s != Failed {
t.Fatalf("C is %s", s)
}
stopped445(t, w, "can carry")
})
t.Run("the later walk stays open past its own bound: B is stopped", func(t *testing.T) {
w := newShape445().open(t)
w.later(Bounds[Delivering].For - 10*time.Minute)
w.settleAll()
waits445(t, w, cID)
w.later(10 * time.Minute)
w.settleAll()
stopped445(t, w, "past its bound")
})
t.Run("the forge never says whether the delivered walk contains A: B is stopped after the grace", func(t *testing.T) {
w := newShape445().open(t)
end445(w, "plan-later", walkDone)
waits445(t, w, cID)
w.later(containsGrace + time.Minute)
w.settleAll()
stopped445(t, w, "did not say")
})
}
// The review's shape of #203 (ADR 0276): A's waiting walk and B's were folded into one successor, plan-2, that
// answers C's merge and B's and not A's. The carrier is in B's own walk, which waits for B's turn: B takes its
// turn there, and the one walk delivers C and B; A is covered once it is done.
func TestAMemberWhoseWalkCarriesTheSupersededOneBeforeItTakesItsTurn(t *testing.T) {
s := foldShape()
s.forge = map[string]bool{mergedA + " in " + mergedC: true}
w := s.open(t)
letGo445(t, w, "plan-2")
if s := w.state(cID); s != Delivering {
t.Fatalf("C is %s", s)
}
end445(w, "plan-2", walkDone)
g := w.h.groups[g445]
if state, why := GroupState(g, w.h.membersOf(g)); state != "delivered" {
t.Fatalf("the group reads %s (%s), want delivered", state, why)
}
}
// A carrier superseded in turn hands the wait to the later merge that took it over: C's waiting walk is folded
// into D's, which answers only D; B waits for D, and goes once D's walk covers A.
func TestTheWaitFollowsACarrierSupersededInTurn(t *testing.T) {
s := newShape445()
s.c.State, s.c.Walk = Published, &WalkSeen{ID: "plan-2", Commit: mergedC, State: walkBuilding, Waits: true,
WaitedFor: "mesh-delivery", Branch: "main", Created: at445.Add(4 * time.Minute)}
s.walks[2] = ctlWalk("plan-2", mergedC, at445.Add(4*time.Minute), walkBuilding, &WalkDelivery{Awaits: "mesh-delivery",
Merges: []WalkMerge{{Repository: ctl445, Commit: mergedC}}})
s.extra = []*Delivery{{ID: dID445, Repository: ctl445, Commit: headD, Number: 111, Base: "main", Branch: "fix/later-still",
State: Ready, Since: at445.Add(6 * time.Minute), Created: at445.Add(-time.Hour), MergedAs: mergedD,
MergedAt: at445.Add(6 * time.Minute), Plan: planMoving(ctl445, modules445...), Check: &Verdict{Gate: "pass"}}}
w := s.open(t)
// C is a lone delivery at its turn; the fake controller lets plan-2 go. It is folded all the same, as the
// review's shape has it, before it started.
two := w.ctl.walk("plan-2")
two.Delivery.Go = nil
two.State, two.Note = walkSuperseded, "superseded at tier 0 by plan-3 ("+ctl445+" at 4d4d4d4d); planned there again"
w.h.WalkMoved(w.ctl.put(ctlWalk("plan-3", mergedD, at445.Add(7*time.Minute), walkBuilding,
&WalkDelivery{Awaits: "mesh-delivery", Merges: []WalkMerge{{Repository: ctl445, Commit: mergedD}}})))
w.h.WalkMoved(w.ctl.put(two))
w.settleAll()
if s := w.state(cID); s != Superseded {
t.Fatalf("C is %s", s)
}
waits445(t, w, dID445)
if strings.Contains(w.delivery(bID).TurnAwaits, cID) {
t.Fatalf("B still waits for C: %q", w.delivery(bID).TurnAwaits)
}
w.forge.contains[mergedA+" in "+mergedD] = true
end445(w, "plan-3", walkDone)
w.later(2 * time.Minute) // the forge, asked while it could not say, is asked again a minute on
w.settleAll()
if d := w.delivery(aID); d.CoveredBy == "" || !strings.Contains(d.CoveredBy, dID445) {
t.Fatalf("A is covered by %q", d.CoveredBy)
}
letGo445(t, w, "plan-lab")
}
// A merged carrier still in an assembling batch has no state bound of its own: it is awaited for
// readyCarrierBound from its merge, and then B is stopped.
func TestACarrierInABatchIsAwaitedWithinTheWindowAndOneWalk(t *testing.T) {
s := newShape445()
s.c.State, s.c.Walk = Ready, nil
closes := at445.Add(3 * time.Minute)
s.walks[2] = Walk{ID: "plan-batch", Repository: ctl445, Branch: "main", Commit: mergedC, State: walkQueued,
Created: at445.Add(2 * time.Minute), Delivery: &WalkDelivery{Awaits: "mesh-delivery", Batch: &WalkBatch{
ClosesAt: &closes, Behind: "plan-other"}, Merges: []WalkMerge{{Repository: ctl445, Commit: mergedC}}}}
w := s.open(t)
waits445(t, w, cID)
// The merge window at its longest, then the one open walk the batch may queue behind (ADR 0276 decisions 1,
// 4 and 9).
if readyCarrierBound != 10*time.Minute+Bounds[Delivering].For {
t.Fatalf("a ready carrier is awaited %s, want the window's 10 minutes and one walk's %s", readyCarrierBound,
Bounds[Delivering].For)
}
until := s.c.MergedAt.Add(readyCarrierBound)
if got := w.delivery(bID).TurnAwaitsUntil; got == nil || !got.Equal(until) {
t.Fatalf("B waits until %v, want %v", got, until)
}
// Within the bound, past every state bound a ready delivery could be read against, B still waits.
w.later(until.Add(-time.Minute).Sub(w.now))
w.settleAll()
waits445(t, w, cID)
w.later(2 * time.Minute)
w.settleAll()
stopped445(t, w, "no walk carried it within "+readyCarrierBound.String())
}
// A member whose every module a later delivered merge carried, the forge saying it contains the member's merge,
// is reached even before CoveredBy is written on it: never a stop with an empty reason.
func TestACarriedMemberNotYetMarkedCoveredIsReached(t *testing.T) {
s := newShape445()
s.c.State, s.c.Since = Delivered, at445.Add(9*time.Minute)
s.c.Walk.State = walkDone
for _, m := range modules445 {
s.c.Steps = append(s.c.Steps, Step{Machine: "novox", Module: m, State: StepPassed},
Step{Machine: stepTheRest, Module: m, State: StepSent})
}
s.a.ContainedIn = map[string]bool{mergedC: true}
s.walks = s.walks[:2]
store := newMemStore()
for _, d := range []*Delivery{s.a, s.b, s.c} {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
g := &Group{ID: g445, Members: []string{aID, bID}, Closed: true, Order: []string{aID, bID}, Said: "delivering",
Check: &GroupCheck{Verdict: "pass", Commits: commitsOf([]*Delivery{s.a, s.b})}}
if err := store.PutGroup(g); err != nil {
t.Fatal(err)
}
w := newWorldOver(t, store, newFakeController(), newFakeForge(), at445.Add(10*time.Minute))
w.h.mu.Lock()
gg := w.h.groups[g445]
if w.h.deliveries[aID].CoveredBy != "" {
t.Fatal("A is marked covered already: the case is not the one under test")
}
w.h.turning = map[string]*turnWant{}
w.h.groupTurns(gg, w.h.membersOf(gg))
b := w.h.deliveries[bID]
turned := w.h.turning["plan-lab"] != nil && containsString(w.h.turning["plan-lab"].ids, bID)
w.h.mu.Unlock()
if b.State != Published || !turned {
t.Fatalf("B is %s, at its turn %v; want its turn taken", b.State, turned)
}
}
// foldShape is the review's shape: B and C on one walk, plan-2, not started, waiting for mesh-delivery's word.
func foldShape() *shape445 {
s := newShape445()
s.c.State, s.c.Walk = Published, &WalkSeen{ID: "plan-2", Commit: mergedC, State: walkBuilding, Waits: true,
WaitedFor: "mesh-delivery", Branch: "main", Created: at445.Add(4 * time.Minute)}
s.b.Walk = &WalkSeen{ID: "plan-2", Commit: mergedC, State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery",
Branch: "main", Created: at445.Add(4 * time.Minute)}
two := ctlWalk("plan-2", mergedC, at445.Add(4*time.Minute), walkBuilding, &WalkDelivery{Awaits: "mesh-delivery",
Merges: []WalkMerge{{Repository: ctl445, Commit: mergedC}, {Repository: lab445, Commit: mergedB}}})
two.Tiers = [][]string{modules445, {"mesh-lab"}}
two.Modules["mesh-lab"] = &WalkModule{}
s.walks = []Walk{s.walks[0], two}
return s
}
// The turn on the carrier's walk is given only on the forge's word that the carrier contains A's merge: not yet
// said, B waits and the forge is asked; said "no", C is no carrier and B is stopped, never let go on plan-2.
func TestASharedTurnWaitsForTheForgesWord(t *testing.T) {
t.Run("not yet said: B waits and the forge is asked", func(t *testing.T) {
w := foldShape().open(t)
waits445(t, w, cID)
if containsString(w.ctl.delivers, "plan-2") {
t.Fatalf("plan-2 was let go %v before the forge said C contains A", w.ctl.delivers)
}
w.forge.mu.Lock()
asked := containsString(w.forge.containsAsks, mergedA+" in "+mergedC)
w.forge.mu.Unlock()
if !asked {
t.Fatal("the forge was not asked whether C's merge contains A's")
}
w.forge.contains[mergedA+" in "+mergedC] = true
w.later(2 * time.Minute)
w.settleAll()
letGo445(t, w, "plan-2")
})
t.Run("said no: no shared turn", func(t *testing.T) {
s := foldShape()
s.forge = map[string]bool{mergedA + " in " + mergedC: false}
w := s.open(t)
if d := w.delivery(bID); d.State == Delivering || d.LetGo != (time.Time{}) {
t.Fatalf("B was given its turn on plan-2 (%s, let go %v)", d.State, d.LetGo)
}
stopped445(t, w, "does not contain its merge")
})
}
// Two carriers for A: C on B's own walk carries one module, D on a walk of its own the other. B is not given its
// turn on C's walk, which cannot deliver A whole: it waits for D's walk, goes once D covers its module, and is
// stopped, never delivering, when D's walk fails (re-review of #203).
func TestASharedTurnNeedsEveryModuleOnTheOneWalk(t *testing.T) {
two := func() *shape445 {
s := foldShape()
s.a.Plan = planMoving(ctl445, "mesh-controller", "route-proxy")
s.c.Plan = planMoving(ctl445, "mesh-controller")
s.extra = []*Delivery{{ID: dID445, Repository: ctl445, Commit: headD, Number: 111, Base: "main",
Branch: "fix/other", State: Delivering, Since: at445.Add(5 * time.Minute), Created: at445.Add(-time.Hour),
MergedAs: mergedD, MergedAt: at445.Add(90 * time.Second), Plan: planMoving(ctl445, "route-proxy"),
Check: &Verdict{Gate: "pass"}, Walk: &WalkSeen{ID: "plan-other", Commit: mergedD, State: walkRolling,
WaitedFor: "mesh-delivery", GoBy: byOwner, Branch: "main", Created: at445.Add(4 * time.Minute)}}}
went := at445.Add(5 * time.Minute)
other := ctlWalk("plan-other", mergedD, at445.Add(4*time.Minute), walkRolling, &WalkDelivery{
Awaits: "mesh-delivery", Go: &went, By: byOwner, Merges: []WalkMerge{{Repository: ctl445, Commit: mergedD}}})
other.Modules = map[string]*WalkModule{"route-proxy": {}}
s.walks = append(s.walks, other)
s.forge = map[string]bool{mergedA + " in " + mergedC: true, mergedA + " in " + mergedD: true}
return s
}
t.Run("D's walk open: B waits for both", func(t *testing.T) {
w := two().open(t)
waits445(t, w, dID445)
if !strings.Contains(w.delivery(bID).TurnAwaits, cID) || containsString(w.ctl.delivers, "plan-2") {
t.Fatalf("B is %s, let go %v, waiting for %q", w.state(bID), w.ctl.delivers, w.delivery(bID).TurnAwaits)
}
})
t.Run("D's walk delivered: B goes on C's walk", func(t *testing.T) {
w := two().open(t)
end445(w, "plan-other", walkDone)
letGo445(t, w, "plan-2")
})
t.Run("D's walk failed: B is stopped, never delivering", func(t *testing.T) {
w := two().open(t)
end445(w, "plan-other", walkFailed)
d := w.delivery(bID)
for _, tr := range d.Transitions {
if tr.To == Delivering {
t.Fatalf("B was delivering before it was stopped: %+v", d.Transitions)
}
}
stopped445(t, w, "route-proxy")
})
}
@@ -101,78 +101,6 @@ func lastWhy(d *Delivery) string {
return d.Transitions[len(d.Transitions)-1].Why
}
// A group member stopped on a walk it shares with a lone delivery: the walk is stopped without the member's merge,
// never whole, and the lone delivery follows the walk that walks its merge again and is delivered by it.
func TestAGroupStopWalksTheOtherDeliveriesAgainWithoutIt(t *testing.T) {
s := open459(t, []string{"novox/a", "novox/b"}, []string{"novox/z"})
if containsString(s.ctl.delivers, "plan-2") {
t.Fatalf("plan-2 was let go before b's turn: %v", s.ctl.delivers)
}
s.failFirst()
if b := s.delivery(s.ids["novox/b"]); b.State != Stopped || !strings.Contains(lastWhy(b), s.ids["novox/a"]) {
t.Fatalf("b is %s (%q); want it stopped by its group, naming a", b.State, lastWhy(b))
}
if containsString(s.ctl.stops, "plan-2") {
t.Fatalf("plan-2 was ended whole %v, and with it z, which is in no group and was not stopped", s.ctl.stops)
}
if want := "plan-2 without novox/b@" + s.merges["novox/b"]; !containsString(s.ctl.withouts, want) {
t.Fatalf("the controller was asked %v; want %q", s.ctl.withouts, want)
}
s.heard("plan-2", "plan-2-again")
if containsString(s.ctl.delivers, "plan-2-again") {
t.Fatal("a batch not yet cut was given its word")
}
s.cutAgain("plan-2-again")
z := s.delivery(s.ids["novox/z"])
if z.State.Final() || z.Walk == nil || z.Walk.ID != "plan-2-again" || !containsString(s.ctl.delivers, "plan-2-again") {
t.Fatalf("z is %s on %+v, let go %v; want it following plan-2-again, let go", z.State, z.Walk, s.ctl.delivers)
}
s.done("plan-2-again")
if z, b := s.state(s.ids["novox/z"]), s.state(s.ids["novox/b"]); z != Delivered || b != Stopped {
t.Fatalf("once plan-2-again is done, z is %s and b %s; want z delivered and b stopped", z, b)
}
}
// Two members after the failed one, both on the shared walk: one ask leaves both out, so neither is walked again
// because its own stop came second.
func TestAGroupStopLeavesOutEveryMemberItStopsOnOneWalk(t *testing.T) {
s := open459(t, []string{"novox/a", "novox/b", "novox/c"}, []string{"novox/z"})
s.failFirst()
if len(s.ctl.withouts) != 1 || !strings.Contains(s.ctl.withouts[0], "novox/b@"+s.merges["novox/b"]) ||
!strings.Contains(s.ctl.withouts[0], "novox/c@"+s.merges["novox/c"]) {
t.Fatalf("the controller was asked %v; want one stop of plan-2 leaving out b and c", s.ctl.withouts)
}
s.heard("plan-2", "plan-2-again")
again := s.ctl.walk("plan-2-again")
if len(again.Delivery.Merges) != 1 || again.Delivery.Merges[0].Repository != "novox/z" {
t.Fatalf("plan-2-again walks %+v; want z's merge alone", again.Delivery.Merges)
}
for _, r := range []string{"novox/b", "novox/c"} {
if st := s.state(s.ids[r]); st != Stopped {
t.Fatalf("%s is %s", r, st)
}
}
}
// A lone delivery whose merge is a later merge of the stopped member's repository contains the member's merge: it
// is left out and ends stopped, naming the merge it contains; the other lone delivery is walked again.
func TestAGroupStopEndsAMergeThatContainsTheStoppedOne(t *testing.T) {
s := open459(t, []string{"novox/a", "novox/b"}, []string{"novox/z", "novox/b2"})
// b2 is a later merge of b's repository: the walk carries it for b.
s.retarget("novox/b2", "novox/b")
s.failFirst()
s.heard("plan-2", "plan-2-again")
s.cutAgain("plan-2-again")
b2 := s.delivery(s.ids["novox/b2"])
if b2.State != Stopped || !strings.Contains(lastWhy(b2), shortOf(s.merges["novox/b"], 8)) ||
!strings.Contains(lastWhy(b2), "contains") {
t.Fatalf("b2 is %s (%q); want it stopped, naming b's merge it contains", b2.State, lastWhy(b2))
}
if z := s.delivery(s.ids["novox/z"]); z.Walk == nil || z.Walk.ID != "plan-2-again" || z.State.Final() {
t.Fatalf("z is %s on %+v; want it following plan-2-again", z.State, z.Walk)
}
}
// retarget makes the delivery of repo `later` a later merge of repository `of`: same repository, carried by plan-2
// for the merge of `of`.
func (s *shape459) retarget(later, of string) {
@@ -279,39 +207,6 @@ func TestAPersonsStopOfTheWalkStopsEveryDelivery(t *testing.T) {
}
}
// The reviewer's case (review of #221): group [a, b]; y and z ride plan-2 with b. y is stopped `way: again`, and the
// batch that walks plan-2's others again carries b and z. Then a fails: b is stopped by its group, and its merge is
// left out of that batch — never shipped by it — and z alone is walked and delivered.
func TestAMergeWalkedAgainAndThenStoppedByItsGroupNeverShips(t *testing.T) {
s := open459(t, []string{"novox/a", "novox/b"}, []string{"novox/y", "novox/z"})
if _, err := s.h.StopWay(s.ids["novox/y"], "not wanted after all", "jochen", "again"); err != nil {
t.Fatal(err)
}
s.heard("plan-2", "plan-2-again")
s.failFirst()
if b := s.state(s.ids["novox/b"]); b != Stopped {
t.Fatalf("b is %s", b)
}
if want := "plan-2-again without novox/b@" + s.merges["novox/b"]; !containsString(s.ctl.withouts, want) {
t.Fatalf("the controller was asked %v; want %q", s.ctl.withouts, want)
}
s.heard("plan-2-again", "plan-2-again-left")
s.cutAgain("plan-2-again")
again := s.ctl.walk("plan-2-again")
for _, m := range again.Delivery.Merges {
if m.Repository == "novox/b" {
t.Fatalf("the walk %s still carries b's stopped merge: %+v", again.ID, again.Delivery.Merges)
}
}
if !containsString(s.ctl.delivers, "plan-2-again") {
t.Fatalf("z's walk was not let go: %v", s.ctl.delivers)
}
s.done("plan-2-again")
if z := s.state(s.ids["novox/z"]); z != Delivered {
t.Fatalf("z is %s", z)
}
}
// Whatever brought it there, a walk that carries a stopped delivery's merge is asked to leave it out, and is never
// given its word while it carries it.
func TestAWalkCarryingAStoppedMergeIsAskedToLeaveItOutAndNeverLetGo(t *testing.T) {
@@ -338,21 +233,6 @@ func TestAWalkCarryingAStoppedMergeIsAskedToLeaveItOutAndNeverLetGo(t *testing.T
}
}
// A group stop the controller refuses to make without the merge (an older controller) stops the whole walk: the
// stopped change is held back, and the delivery riding with it ends stopped, saying why.
func TestARefusedLeaveOutStopsTheWholeWalk(t *testing.T) {
s := open459(t, []string{"novox/a", "novox/b"}, []string{"novox/z"})
s.ctl.refuseWithout = true
s.failFirst()
if len(s.ctl.withouts) != 1 || !containsString(s.ctl.stops, "plan-2") {
t.Fatalf("asked %v, ended whole %v; want the leave-out tried, then plan-2 ended whole", s.ctl.withouts, s.ctl.stops)
}
s.heard("plan-2")
if z := s.delivery(s.ids["novox/z"]); z.State != Stopped || !strings.Contains(lastWhy(z), "could not be left out") {
t.Fatalf("z is %s (%q); want it stopped, saying the leave-out was refused", z.State, lastWhy(z))
}
}
// A person's stop the controller cannot make leaves the delivery as it was, and says how to go on.
func TestAPersonsStopThatCannotBeMadeStopsNothing(t *testing.T) {
s := shared459(t)
@@ -382,23 +262,6 @@ func TestAStopSaysWhatStaysAndWhatStillCarriesIt(t *testing.T) {
}
}
// A merge left out for moving a module the stopped merge moves, from another repository, ends stopped saying so —
// never that it contains the stopped merge (review of #221).
func TestALeftOutMergeSharingAModuleSaysSoTruly(t *testing.T) {
s := open459(t, []string{"novox/a", "novox/b"}, []string{"novox/z"})
s.ctl.mu.Lock()
s.ctl.moves[s.merges["novox/b"]], s.ctl.moves[s.merges["novox/z"]] = []string{"lab"}, []string{"lab"}
s.ctl.mu.Unlock()
s.failFirst()
s.heard("plan-2")
z := s.delivery(s.ids["novox/z"])
why := lastWhy(z)
if z.State != Stopped || !strings.Contains(why, "moves lab, as the stopped merge "+shortOf(s.merges["novox/b"], 12)) ||
strings.Contains(why, "contains the stopped merge") {
t.Fatalf("z is %s (%q); want it stopped, saying it moves lab as b's merge does", z.State, why)
}
}
// A person's first answer names the deliveries that would be left out for moving a module this merge moves, and says
// the controller may leave out more.
func TestAPersonsFirstAnswerNamesWhatSharesItsModules(t *testing.T) {
@@ -84,8 +84,8 @@ func night747Over(t *testing.T, set func(*fakeForge), built []string) *world {
t.Fatal(err)
}
}
g := &Group{ID: g747, Members: []string{id301, id336}, Closed: true, Order: []string{id336, id301}, Said: "failed",
OrderOf: commitsOf([]*Delivery{m301, m336}), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
g := &Group{ID: g747, Members: []string{id301, id336}, Closed: true, Said: "failed",
Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
Verdict: "pass", Summary: "both compose", Commits: commitsOf([]*Delivery{m301, m336})}}
if err := store.PutGroup(g); err != nil {
t.Fatal(err)
@@ -80,8 +80,8 @@ func night753Over(t *testing.T, stop Transition, merges []WalkMerge, set func(*f
for _, d := range inGroup {
ids = append(ids, d.ID)
}
g := &Group{ID: g747, Members: ids, Closed: true, Order: order, Said: "delivering",
OrderOf: commitsOf(inGroup), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
g := &Group{ID: g747, Members: ids, Closed: true, Said: "delivering",
Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
Verdict: "pass", Summary: "all compose", Commits: commitsOf(inGroup)}}
if err := store.PutGroup(g); err != nil {
t.Fatal(err)
@@ -226,26 +226,6 @@ func m299() *Delivery {
Why: "its group " + g747 + " stopped: " + id336 + " failed"}}}
}
// **A stopped member gives its word only in its group's order** (review of issue 753): #299, ordered before #301 and
// stopped for #336 too, is not delivered and this walk does not carry its merge, so #301 gives no word — it would land
// before #299 — and `stalled walks` names #299. When the walk names #299's merge too, it delivers both, in order, and
// is let go.
func TestAStoppedMemberGivesItsWordOnlyInItsGroupsOrder(t *testing.T) {
w := night753With(t, groupStop753(), m299())
if containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk was let go for #301 before #299, ordered before it: %v", w.ctl.delivers)
}
said, state := saidOf(w.h.WalksWaiting(), plan753)
if state == Unheld || !strings.Contains(said, m299().ID+" (stopped)") {
t.Fatalf("`stalled walks` reads %s %q; want #299 named", state, said)
}
w = night753Over(t, groupStop753(), []WalkMerge{{Repository: cat747, Commit: "e299aaaaaaaaaaaa", Carried: merge307}}, nil,
m299())
if !containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk carrying both members was not let go: %v", w.ctl.delivers)
}
}
// **A word the controller refuses is never silent** (review of issue 753): the controller keeps refusing it, and past
// the grace `stalled` lists the walk, with the refusal.
func TestARefusedWordIsListedByStalled(t *testing.T) {
@@ -23,7 +23,6 @@ func group902(t *testing.T) (*world, string, string, string, string) {
w := newWorld(t)
const ctlHead, catHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{cat, ctl}} }
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(verdict("novox/mesh-controller", 1, ctlHead, "pass"))
@@ -32,9 +32,8 @@ func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks.
// The forge's label and comment events say a ready mark changed, its status events that a check answered (events.go).
// The forge's status events say that a check answered (events.go).
var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed",
"gitea.forge.pull-request.labeled", "gitea.forge.pull-request.unlabeled", "gitea.forge.pull-request.commented",
"gitea.forge.status", ControllerSeat + ".checked", ControllerSeat + ".plan-moved"}
func main() {
@@ -150,20 +149,6 @@ func take(h *Holder, e stdio.Envelope) error {
default:
h.PullClosed(p)
}
case "gitea.forge.pull-request.labeled", "gitea.forge.pull-request.unlabeled", "gitea.forge.pull-request.commented":
var p ForgePullEvent
if err := json.Unmarshal(e.Body, &p); err != nil {
h.Refuse(fmt.Errorf("%s is not readable: %v", e.Key, err))
return nil
}
switch e.Key {
case "gitea.forge.pull-request.labeled":
h.PullLabeled(p)
case "gitea.forge.pull-request.unlabeled":
h.PullUnlabeled(p)
default:
h.PullCommented(p)
}
case "gitea.forge.status":
var s ForgeStatusEvent
if err := json.Unmarshal(e.Body, &s); err != nil {
@@ -274,14 +259,14 @@ func tools(h *Holder, l *listening) []stdio.Tool {
}},
{Name: seat + "show",
Description: "One delivery or group whole: its delivery plan, every transition with when and why, the " +
"machine steps of its walk, its group and its order.",
"machine steps of its walk and its group.",
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("a delivery's id, or a group's")},
"required": []string{"id"}},
Run: func(a map[string]any) (any, error) { return h.Show(strArg(a, "id")) }},
{Name: seat + "groups",
Description: "Every delivery group: its members in order, why each pair is ordered and the rules it won " +
"over, each contradiction no precedence resolves with how to declare the order, its composed check " +
"and its state, derived from its members.",
Description: "Every delivery group: its members, its composed check and its state, derived from its members. " +
"A group has no order: it is merged in one pass and delivered by one plan, whose tiers come from the module " +
"graph (novox/hq ADR 0418). composed_gate says its members' gate is the composed check's (ADR 0403).",
Input: map[string]any{"type": "object", "properties": map[string]any{
"all": map[string]any{"type": "string", "enum": []string{"true", "false"}}}},
Run: func(a map[string]any) (any, error) { return h.Groups(boolArg(a, "all")), nil }},
@@ -404,20 +389,6 @@ func tools(h *Holder, l *listening) []stdio.Tool {
"and tried again, given up) and when the last was, walks with no delivery yet, whether its events arrive, when " +
"the controller's walks were last read or why they could not be, and the last refusals.",
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
{Name: seat + "mark-ready",
Description: "A reviewer's word that a delivery's head is approved: the mesh's own forge account puts the " +
"label mesh/ready on its pull request and then writes \"ready at <head>\", the order the forge counts. " +
"Refused when the delivery's commit is no longer its pull request's head. mesh-delivery then merges it " +
"once its checks pass (novox/hq ADR 0364, issue 913).",
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id (owner/repository@commit): the head reviewed"),
"reviewed-by": str("who reviewed it, as who@node"), "why": str("the review's verdict in a line")},
"required": []string{"id", "reviewed-by", "why"}},
Run: func(a map[string]any) (any, error) {
if err := need(a, "id", "reviewed-by", "why"); err != nil {
return nil, err
}
return h.MarkReady(strArg(a, "id"), strArg(a, "reviewed-by"), strArg(a, "why"))
}},
}
// A verb that changes a delivery asks for a look at what it makes possible (react.go).
for i := range list {
@@ -8,6 +8,7 @@ import (
"sort"
"strings"
"testing"
"time"
)
// The manifest says what the code does: the seat it claims and the verbs it serves, the events it follows
@@ -60,15 +61,15 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
if !reflect.DeepEqual(m.State, []string{"deliveries", "groups", "asked", "pulls"}) {
t.Fatalf("state %v", m.State)
}
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
// It calls exactly what its ports ask: the controller's verbs and the forge's tools.
want := []string{}
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks", "forget-checks"} {
for _, v := range []string{"delivery-plan", "delivery-check", "deliver", "delivery-stop", "delivery-walks", "forget-checks"} {
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_open_pulls",
// The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off.
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment", "gitea.gitea_api")
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment")
// A merge into main asks the operator for the Approve (novox/hq ADR 0398 §3, issue 948), as the knowledge base asks.
want = append(want, "seat:operator-channel.notify")
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
@@ -128,3 +129,49 @@ func TestAnAnswerIsReadWhateverWrapsIt(t *testing.T) {
}
}
}
// **No ready mark is left in the module** (novox/hq ADR 0421 §4, issue 1022; how it is checked): no label, comment, verb
// or trusted setting of the mark stands in any of its code or its manifest. Tests are left out: they name the words to
// prove them gone.
func TestNoReadyMarkIsLeftInTheModule(t *testing.T) {
forbidden := []string{"mesh/ready", "ready at", "ready-authors", "mark-ready", "ReadyLabel", "pull-request.labeled",
"pull-request.unlabeled", "pull-request.commented"}
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
files = append(files, filepath.Join("..", "..", "module.json"))
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, word := range forbidden {
if strings.Contains(string(raw), word) {
t.Errorf("%s still holds %q: the ready mark is gone (ADR 0421 §4)", f, word)
}
}
}
}
// A row written while the ready mark stood still reads: its mark's fields are ignored, its ask and merge kept (ADR 0421
// §4 consequences, issue 1022).
func TestARowWrittenWithTheReadyMarkStillReads(t *testing.T) {
store := newMemStore()
row := `{"id":"novox/app@111111111111","repository":"novox/app","commit":"` + readyHead + `","number":7,"base":"main",
"state":"ready","since":"2026-10-17T10:00:00Z","created":"2026-10-17T10:00:00Z","let_go":"0001-01-01T00:00:00Z",
"ready":{"head":"` + readyHead + `","by":"lead@shanks","marked_at":"2026-10-17T10:01:00Z","seen":"2026-10-17T10:02:00Z",
"last_try":"0001-01-01T00:00:00Z","asked_at":"2026-10-17T10:03:00Z","ask_id":"merge-7-111111111111-1","heads":{"novox/app@111111111111":"` + readyHead + `"}}}`
store.deliveries["novox/app@111111111111"] = []byte(row)
w := newWorldOver(t, store, newFakeController(), newFakeForge(), time.Date(2026, 10, 17, 11, 0, 0, 0, time.UTC))
d := w.delivery("novox/app@111111111111")
if d.Ready == nil || d.Ready.AskID != "merge-7-111111111111-1" || d.Ready.Head != readyHead {
t.Fatalf("the old row is not read: %+v", d.Ready)
}
if !MemberOf(d).Ready {
t.Fatalf("an ask waiting is a ready member")
}
}
@@ -1,143 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"time"
)
// **A reviewer's word marks the pull request ready in the order the forge counts** (novox/hq issue 913): the label,
// then the comment "ready at <full sha>". Written the other way round the mark's head is empty (issue 873).
const fullHead = "3333333333333333333333333333333333333333"
// readyDelivery is a delivery of a pull request whose head is fullHead, its check passed, and the forge's open pull.
func readyDelivery(t *testing.T, w *world) string {
t.Helper()
w.h.PullUpdated(pr("novox/app", 7, fullHead, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, fullHead, "pass"))
w.settleAll()
w.forge.open = []OpenPull{{Owner: "novox", Repo: "app", Number: 7, Base: "main", HeadSHA: fullHead}}
return IDOf("novox/app", fullHead)
}
func TestMarkReadyLabelsThenCommentsTheFullHead(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
answer, err := w.h.MarkReady(id, "fix-913@shanks", "the change is right")
if err != nil {
t.Fatal(err)
}
if len(w.forge.calls) != 2 || w.forge.calls[0] != "label novox/app#7 mesh/ready" {
t.Fatalf("the forge was asked %q; wanted the label first", w.forge.calls)
}
c := strings.TrimPrefix(w.forge.calls[1], "comment ")
if first, _, _ := strings.Cut(c, "\n"); first != "ready at "+fullHead || !strings.Contains(c, "\n\nReviewed by fix-913@shanks: the change is right.") ||
!strings.Contains(c, "mark-ready") {
t.Fatalf("the comment is %q", c)
}
d := w.delivery(id)
if d.Ready == nil || d.Ready.Head != fullHead || d.Ready.By != "fix-913@shanks" || d.Ready.Seen.IsZero() {
t.Fatalf("the mark is not kept: %+v", d.Ready)
}
if !strings.Contains(answer, "marked") {
t.Fatalf("answer %q", answer)
}
}
func TestMarkReadyRefusesAMovedHeadAndAsksTheForgeNothing(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
w.forge.open[0].HeadSHA = pushed
_, err := w.h.MarkReady(id, "fix-913@shanks", "ok")
if err == nil || !strings.Contains(err.Error(), "the head is now "+pushed) {
t.Fatalf("a moved head: %v", err)
}
if len(w.forge.calls) != 0 {
t.Fatalf("the forge was asked %q", w.forge.calls)
}
}
func TestMarkReadyRefusesAnUnknownAndAFinalDelivery(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
if _, err := w.h.MarkReady("novox/app@nothing", "a@b", "ok"); err == nil || !strings.Contains(err.Error(), "deliveries") {
t.Fatalf("unknown id: %v", err)
}
w.h.mu.Lock()
w.h.deliveries[id].State = Stopped
w.h.mu.Unlock()
if _, err := w.h.MarkReady(id, "a@b", "ok"); err == nil {
t.Fatal("a final delivery was marked")
}
if len(w.forge.calls) != 0 {
t.Fatalf("the forge was asked %q", w.forge.calls)
}
}
func TestMarkReadyWritesNoCommentWhenTheLabelFails(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
w.forge.labelErr = errors.New("the forge refused gitea_api: no such label")
_, err := w.h.MarkReady(id, "a@b", "ok")
if err == nil || !strings.Contains(err.Error(), "no such label") {
t.Fatalf("label failure: %v", err)
}
if len(w.forge.comments) != 0 {
t.Fatalf("commented: %v", w.forge.comments)
}
if w.delivery(id).Ready != nil {
t.Fatal("marked though the label failed")
}
}
func TestClearMarkWithAnEmptyHeadSaysWhatWasWrongOnceItStayedEmpty(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, "")}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.comments) != 0 || len(w.forge.unlabels) != 0 {
t.Fatalf("an empty-head mark seen once was cleared: %v", w.forge.comments)
}
w.later(markGrace + time.Second)
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "no \"ready at\" comment by a listed account") ||
!strings.Contains(w.forge.comments[0], "the head is now "+readyHead) ||
!strings.Contains(w.forge.comments[0], "mesh-delivery.mark-ready") || strings.Contains(w.forge.comments[0], "pushed after it") {
t.Fatalf("comments: %v", w.forge.comments)
}
}
func TestAMarkReadyJustMadeIsNotClearedByAReadThatPredatesItsComment(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
if _, err := w.h.MarkReady(id, "a@b", "ok"); err != nil {
t.Fatal(err)
}
w.later(markGrace + time.Second) // the label alone was read long enough ago to count as stale...
w.forge.open = []OpenPull{markedPull("novox/app", 7, fullHead, "")}
w.h.SeeOpenPulls()
w.h.mu.Lock()
w.h.deliveries[id].Ready.Seen = w.now // ...but the mark was made just now
w.h.marks["novox/app#7"].firstEmpty = w.now.Add(-2 * markGrace)
w.h.mu.Unlock()
before := len(w.forge.comments)
w.settleAll()
if len(w.forge.unlabels) != 0 || len(w.forge.comments) != before {
t.Fatalf("a mark just made was cleared: %v %v", w.forge.unlabels, w.forge.comments)
}
}
func TestClearMarkWithAnOlderHeadKeepsTheOldWording(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "pushed after it") {
t.Fatalf("comments: %v", w.forge.comments)
}
}
@@ -33,8 +33,11 @@ type Delivery struct {
Removed []string `json:"removed,omitempty"`
ModuleDirs []string `json:"module_dirs,omitempty"`
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
// After are the repositories its pull request says it goes after (`after: <repository>` lines).
After []string `json:"after,omitempty"`
// After are the repositories its pull request's description names in `after: <repository>` lines. They are not read
// (novox/hq ADR 0419 §2): kept only to say once on the pull request that the line is not read. AfterSaid is whether
// that was said, for this pull request.
After []string `json:"after,omitempty"`
AfterSaid bool `json:"after_said,omitempty"`
// Issues are the issues its title and body name ("issue 948"), shown to the operator who is asked to approve its merge.
Issues []int `json:"issues,omitempty"`
// Group is the delivery group it is in: its head branch name. Empty for a lone delivery.
@@ -93,14 +96,9 @@ type Delivery struct {
// StoppedFor is, for a member its group stopped, the member whose end stopped it (novox/hq issue 747): once that
// member's change reached the machines, the stop has lapsed, and holds no walk of its repository back.
StoppedFor string `json:"stopped_for,omitempty"`
// TurnAwaits says, for a group member whose turn waits for a later merge to carry a superseded member before it,
// which merges and until when; TurnAwaitsUntil is that moment, which bounds the wait (ADR 0276 decision 9,
// novox/hq issue 445). `show` shows both, and `stalled` says them past that moment.
TurnAwaits string `json:"turn_awaits,omitempty"`
// HeldBack is why the walk it waits on is not let go: it carries a merge whose delivery was stopped, and is
// asked to leave it out (novox/hq ADR 0299). Said by `waits`, and by `stalled` past its bound.
HeldBack string `json:"held_back,omitempty"`
TurnAwaitsUntil *time.Time `json:"turn_awaits_until,omitempty"`
HeldBack string `json:"held_back,omitempty"`
// ContainedIn is the forge's answer, by a later merge commit, to whether that commit contains this one's
// merge (gitea_contains): asked once per pair, and kept (ADR 0270).
ContainedIn map[string]bool `json:"contained_in,omitempty"`
@@ -120,8 +118,17 @@ type Delivery struct {
LetGo time.Time `json:"let_go"`
LetGoWhy string `json:"let_go_why,omitempty"`
// Ready is what this owner did with its pull request's ready mark (novox/hq issue 722, ready.go).
// Ready is what this owner did with the operator's Approve of its merge into main, and with the merge (novox/hq ADR
// 0421, ready.go). The json key is the one the ready mark's record had, so rows written before still read.
Ready *ReadyRun `json:"ready,omitempty"`
// ReviewedBy and ReviewedAt are the review record its pull request's description holds: the line
// `Reviewed-by: <who> at <full sha>` (novox/hq ADR 0421 §5, issue 1022), as written. Read at every head; never a
// gate: the ask says first when the line is missing or names another head.
ReviewedBy string `json:"reviewed_by,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
// FreshWanted is whether a passing verdict that arrived out of date is to have its check asked against the
// trunk's head, before the operator is asked (novox/hq ADR 0421 §3, issue 1022). Cleared when it is asked.
FreshWanted bool `json:"fresh_wanted,omitempty"`
Transitions []Transition `json:"transitions,omitempty"`
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
@@ -198,13 +205,20 @@ type CheckAsk struct {
Reasked int `json:"reasked,omitempty"`
}
// GateComposed is the gate layer of a group member's own verdict when the group's composed check judges it instead
// (novox/hq ADR 0403 §1): no gate was run for the member alone.
const GateComposed = "composed"
// Passes is whether the verdict lets the delivery be ready: the gate passed or warned, and the
// repository's own check did not fail or error.
func (v *Verdict) Passes() bool {
if v == nil {
return false
}
gate := v.Gate == "pass" || v.Gate == "warning"
// A group member's own gate is the group's composed check's (novox/hq ADR 0403 §1): the controller says the layer
// "composed", and the member counts as passed here once its repository's check did not fail, as the composed
// check, asked after every member is ready, then judges them together.
gate := v.Gate == "pass" || v.Gate == "warning" || v.Gate == GateComposed
repo := v.Repo == "" || v.Repo == "pass" || v.Repo == "warning"
return gate && repo
}
@@ -418,6 +432,33 @@ func sameCommit(a, b string) bool {
return len(a) >= 7 && strings.HasPrefix(strings.ToLower(b), strings.ToLower(a))
}
// reviewedLine is the review record of a pull request's description (novox/hq ADR 0421 §5, issue 1022):
// `Reviewed-by: <who> at <full sha>`, on a line of its own, who as who@node.
var reviewedLine = regexp.MustCompile(`(?im)^\s*Reviewed-by:\s*(\S+)\s+at\s+([0-9a-f]{40}|[0-9a-f]{64})\b`)
// ReviewIn reads the review record of a pull request's description: who reviewed and the full sha named. The last such
// line counts; empty when there is none.
func ReviewIn(body string) (who, sha string) {
for _, m := range reviewedLine.FindAllStringSubmatch(body, -1) {
who, sha = m[1], strings.ToLower(m[2])
}
return who, sha
}
// reviewWords is what the delivery's review record says of its head, for the ask: reviewed by whom, or, first, that it
// is not reviewed at this head (never a gate, ADR 0421 §5). ok is whether it is reviewed at this head.
func reviewWords(d *Delivery) (words string, ok bool) {
switch {
case d.ReviewedBy == "":
return "not reviewed at this head: the description holds no Reviewed-by line", false
case !strings.EqualFold(d.ReviewedAt, d.Commit):
return fmt.Sprintf("not reviewed at this head: the Reviewed-by line names %s, the head is %s",
shortOf(d.ReviewedAt, 12), shortOf(d.Commit, 12)), false
}
// The router refuses an @ in an ask's words: who@node is said "who on node".
return "reviewed by " + strings.Replace(d.ReviewedBy, "@", " on ", 1) + " at " + shortOf(d.Commit, 12), true
}
// afterLines are the `after: <repository>` lines of a pull request's description.
var afterLines = regexp.MustCompile(`(?im)^\s*after:\s*([A-Za-z0-9_.\-/]+)\s*$`)
@@ -865,17 +906,12 @@ type Group struct {
Members []string `json:"members"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
// Order is the members in the order they are delivered, Pairs why, Cycle the members whose order
// contradicts itself; OrderOf the member commits the order was worked out for.
Order []string `json:"order,omitempty"`
Pairs []Pair `json:"pairs,omitempty"`
Cycle []string `json:"cycle,omitempty"`
// Contradictions is each two members whose rules order them both ways at one rank, said with how a
// person declares the order (novox/hq ADR 0249).
Contradictions []Contradiction `json:"contradictions,omitempty"`
OrderOf map[string]string `json:"order_of,omitempty"`
// Check is its composed check: asked of the controller for these member commits, and its verdict.
Check *GroupCheck `json:"check,omitempty"`
// SaidGate is, by member id, why the member's mesh/merge-gate was last said pending: written again only when it
// changes (novox/hq ADR 0403 §1, §6, issue 978). Cleared once a composed verdict stands at the heads, which the
// forge module then sets as every member's gate.
SaidGate map[string]string `json:"said_gate,omitempty"`
// Closed is set once a member reached the trunk: a group delivering takes no new member.
Closed bool `json:"closed,omitempty"`
// Said is the derived state last said, so a change of it is said once.
@@ -886,32 +922,6 @@ type Group struct {
Owed []Effect `json:"owed,omitempty"`
}
// Pair is one "before" among a group's members: the rule that won, and the rules that ordered it the other
// way and lost by precedence (novox/hq ADR 0249).
type Pair struct {
Before string `json:"before"`
After string `json:"after"`
Why string `json:"why"`
Over []string `json:"over,omitempty"`
}
// Contradiction is two members that rules of one rank order both ways, which no precedence resolves: the
// rules, and Said, which names them and says how to declare the order.
type Contradiction struct {
Members []string `json:"members"`
Rules []Pair `json:"rules"`
Said string `json:"said"`
}
// orderSaid is a pair as a person reads it: what won, and over what.
func (p Pair) orderSaid() string {
s := p.Before + " before " + p.After + ": " + p.Why
if len(p.Over) > 0 {
s += ", over " + strings.Join(p.Over, ", ")
}
return s
}
// GroupCheck is a group's composed check.
type GroupCheck struct {
Asked string `json:"asked"`
@@ -31,8 +31,7 @@ type Member struct {
ModuleDirs []string `json:"module_dirs,omitempty"`
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
CloneURL string `json:"clone_url,omitempty"`
After []string `json:"after,omitempty"`
// Ready is whether the pull request carries a ready mark: its check is published on the ready stream (ADR 0406 §3).
// Ready is whether the operator's Approve of its merge is asked or given: its check is published on the ready stream (ADR 0406 §3, 0421 §10).
Ready bool `json:"ready,omitempty"`
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
// the controller's verdict names when no build judges the head.
@@ -41,26 +40,15 @@ type Member struct {
// MemberOf is a delivery as a member.
func MemberOf(d *Delivery) Member {
return Member{Ready: d.Ready != nil && d.Ready.Merged == nil, ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
return Member{Ready: d.Ready != nil && d.Ready.Merged == nil && (d.Ready.AskID != "" || d.Ready.Approved != nil), ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
PathsTruncated: d.PathsTruncated, Removed: d.Removed, ModuleDirs: d.ModuleDirs, ModuleDirsSaid: d.ModuleDirsSaid,
CloneURL: d.CloneURL, After: d.After, BaseSHA: d.BaseSHA}
}
// Order is a group's order as the controller works it out: each pair with the rules it won over by
// precedence, and each contradiction no precedence resolves (novox/hq ADR 0249).
type Order struct {
Order []string `json:"order"`
Pairs []Pair `json:"pairs,omitempty"`
Cycle []string `json:"cycle,omitempty"`
Contradictions []Contradiction `json:"contradictions,omitempty"`
CloneURL: d.CloneURL, BaseSHA: d.BaseSHA}
}
// Controller is the controller's verbs this owner asks with.
type Controller interface {
// Plan is the delivery plan of a diffset, kept nowhere.
Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error)
// Order is a group's order from the graph and its members' `after:` lines.
Order(members []Member) (Order, error)
// Check asks a group's composed check, or — with no group and one member — that head's own check again.
Check(group string, members []Member) (string, error)
// Deliver lets a waiting walk start.
@@ -94,14 +82,12 @@ type Forge interface {
// 347). Reads only.
OpenPulls() ([]OpenPull, error)
// Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq
// issue 722): the one path every merge takes. Its refusal is the error, in its words.
Merge(owner, repo string, number int, head string) error
// issue 722): the one path every merge takes. approved are the heads the operator's Approve covers, each
// `owner/repo@<full sha>` (ADR 0421 §9); the tool refuses a pull request whose head differs. Its refusal is the
// error, in its words.
Merge(owner, repo string, number int, head string, approved []string) error
// Comment adds a comment to a pull request.
Comment(owner, repo string, number int, body string) error
// Label puts a label on a pull request, by the label's name (novox/hq issue 913).
Label(owner, repo string, number int, name string) error
// Unlabel takes a label off a pull request, by the label's id.
Unlabel(owner, repo string, number, label int) error
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
@@ -248,18 +234,6 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r
return a.Plan, nil
}
func (c seatController) Order(members []Member) (Order, error) {
raw, err := c.verb("delivery-order", map[string]any{"members": string(mustJSON(members))})
if err != nil {
return Order{}, err
}
var o Order
if err := json.Unmarshal(raw, &o); err != nil {
return Order{}, fmt.Errorf("the controller's delivery-order is not readable: %w", err)
}
return o, nil
}
func (c seatController) Check(group string, members []Member) (string, error) {
args := map[string]any{"members": string(mustJSON(members))}
if group != "" {
@@ -403,60 +377,15 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
return &c, nil
}
func (f toolForge) Merge(owner, repo string, number int, head string) error {
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head})
func (f toolForge) Merge(owner, repo string, number int, head string, approved []string) error {
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head,
"approved_heads": approved})
}
func (f toolForge) Comment(owner, repo string, number int, body string) error {
return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
}
// Label puts a label on a pull request. The forge's add-labels call takes label ids on every version (names only on
// the newer), so the id is read from the repository's labels by name first; a repository without the label is refused,
// not given one.
func (f toolForge) Label(owner, repo string, number int, name string) error {
id := 0
for page := 1; page <= 20 && id == 0; page++ {
raw, err := f.ask("gitea.gitea_api", map[string]any{"method": "GET",
"path": fmt.Sprintf("/repos/%s/%s/labels?limit=50&page=%d", owner, repo, page)})
if err != nil {
return err
}
answer, output, ok := answerOf(raw)
if !ok {
return fmt.Errorf("the forge refused gitea_api: %s", lastLine(output))
}
var got struct {
Result []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"result"`
}
if err := json.Unmarshal(answer, &got); err != nil {
return fmt.Errorf("the forge's labels are not readable: %s", clip(string(answer), 200))
}
for _, l := range got.Result {
if l.Name == name {
id = l.ID
}
}
if len(got.Result) < 50 {
break
}
}
if id == 0 {
return fmt.Errorf("%s/%s has no label %s", owner, repo, name)
}
return f.tool("gitea_api", map[string]any{"method": "POST",
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, number),
"body": map[string]any{"labels": []int{id}}})
}
func (f toolForge) Unlabel(owner, repo string, number, label int) error {
return f.tool("gitea_api", map[string]any{"method": "DELETE",
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)})
}
func (f toolForge) OpenPulls() ([]OpenPull, error) {
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
if err != nil {
@@ -13,7 +13,7 @@ import (
// holding its head, the trunk commit its current check is asked against and its verdict, written by compare-and-set.
// It writes a new head when the forge says one (`pull.updated`, which carries the head and the trunk commit it was
// announced against), and a new trunk commit only when the check is asked again against one — the merge tool's ask is
// that same announcement. A merge in a group's order, and a trunk move that leaves an inactive pull request unasked,
// that same announcement. A merge of a group's member, and a trunk move that leaves an inactive pull request unasked,
// are announced as nothing (the gitea module announces `pull.updated` only for a head and for the merge tool's ask), so
// they write nothing. A verdict for that head and trunk commit writes the verdict. The build seat reads the key.
@@ -120,22 +120,26 @@ func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T)
}
}
func TestAPullRequestMarkedReadyHasItsCheckAskedOnTheReadyStream(t *testing.T) {
// A check asked for a head whose Approve is asked is on the ready stream (ADR 0406 §3, ADR 0421 §10); one for a head
// with no Approve is asked as before.
func TestAPullRequestWithItsApproveAskedHasItsCheckAskedOnTheReadyStream(t *testing.T) {
w := newWorld(t)
w.op.auto = false
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.wantForge()
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.settleAll()
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
w.settleAll()
if len(w.ctl.checkReady) == 0 || !w.ctl.checkReady[0] {
t.Fatalf("the check of a marked pull request was asked with ready %v", w.ctl.checkReady)
t.Fatalf("the check of a pull request whose Approve is asked was asked with ready %v", w.ctl.checkReady)
}
// An unmarked one is asked as before.
// One with no Approve asked is asked as before.
w2 := newWorld(t)
w2.h.PullUpdated(pr("novox/app", 8, pushed, "feat/y", "src/a.go"))
w2.later(checkGrace + 1)
w2.settleAll()
if len(w2.ctl.checkReady) == 0 || w2.ctl.checkReady[0] {
t.Fatalf("the check of an unmarked pull request was asked with ready %v", w2.ctl.checkReady)
t.Fatalf("the check of a pull request with no Approve asked was asked with ready %v", w2.ctl.checkReady)
}
}
@@ -56,7 +56,7 @@ func (h *Holder) Work() {
}
// React takes every step the facts allow, after an event that can change what they are: the forge's open pull requests
// read when a ready mark may have been made, the steps that need another's word (look), what is owed (Flush), and then
// read when a check may have answered, the steps that need another's word (look), what is owed (Flush), and then
// the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
func (h *Holder) React() {
h.reactMu.Lock()
@@ -70,7 +70,7 @@ func (h *Holder) React() {
if h.takeForgeWanted() {
if err := h.SeeOpenPulls(); err != nil {
h.wantForge()
h.again("the forge's open pull requests, read when a ready mark or a check may have changed")
h.again("the forge's open pull requests, read when a check may have changed")
}
}
h.look()
+183 -236
View File
@@ -2,69 +2,52 @@ package main
import (
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Merge on ready (novox/hq issue 722).
// The Approve is asked as soon as the checks pass (novox/hq ADR 0421, issue 1022).
//
// The operator, 2026-10-14: "the reviewer task should merge the request or mark the MR as ready to merge in any case".
// Before, a reviewed pull request with green checks waited until a session called the forge's merge tool, and again
// after every check the tool asked because the trunk had moved. Now the independent reviewer marks the pull request
// ready at the head it approved — the forge label `mesh/ready` and a comment "ready at <full head sha>" (approving
// through the forge is the operator's) — and this owner merges it, through the forge's own merge tool
// (gitea_merge_pull_request), the one path every merge takes:
// Before, a reviewer marked a pull request ready (a label and a comment) and only then was the operator's Approve asked
// (issue 722, ADR 0364). The review now finishes before the push (playbook 07) and its record, a line in the pull
// request's description, is given when the pull request is opened, so the mark repeated what the push already meant and
// added a step and a wait. This owner asks the operator's Approve itself, within the event that makes it possible: the
// verdict that passes, the commit status that sets the checks. Then:
//
// - only at the head the mark names, and only while that is the pull request's head: a push after the mark clears
// it — the label taken off, and said on the pull request — and the new head needs a mark of its own;
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
// again by the merge tool, which asks the check against the trunk's head itself (novox/hq ADR 0323 §5); the merge
// is asked again once a minute, and lands on the passing verdict;
// - a group member only at its turn in the group's order, and only once the group's composed check passed for its
// members' heads as they are;
// - the Approve is the only act: nothing is merged before it, and nothing else is needed after it (no label, comment
// or second Approve); it is asked once for a head, and once for a group's heads together;
// - it merges through the forge's merge tool (gitea_merge_pull_request), the one path every merge takes, at the head
// the Approve names, handing the tool the heads the Approve covers (ADR 0421 §9);
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is asked
// again by this owner at once (askFresh), and an Approve already asked stays through a trunk move (ADR 0421 §3): the
// merge is asked again on the passing verdict, with no second Approve;
// - a group member only once the group's composed check passed for its members' heads as they are;
// - a push ends the Approve and says so once on the pull request (ADR 0421 §6); the new head is asked on its own;
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
// controller raises as the condition `delivery.<id>.stalled`. So is a mark the merge tool refused for an hour.
// controller raises as the condition `delivery.<id>.stalled`. So is a merge the tool refused for an hour.
//
// What this owner does with a mark is kept on the delivery (Ready), said in its log, and the merge's transitions name
// it as their author.
// The review record is read and put in the ask; it is never a gate (ADR 0421 §5). What this owner does is kept on the
// delivery (Ready), said in its log, and the merge's transitions name the Approve as their warrant.
// ReadyMark is a pull request's ready mark as the forge reads it (the gitea module's gitea_open_pulls): the head the
// newest "ready at" comment names, who wrote it and when, and the label's id. Head is empty when the label is on and
// no comment names a head.
type ReadyMark struct {
Head string `json:"head"`
By string `json:"by,omitempty"`
At string `json:"at,omitempty"`
LabelID int `json:"label_id"`
}
// ReadyLabel is the label that marks a pull request ready.
const ReadyLabel = "mesh/ready"
// mergeRetryAfter is how long after the merge tool refused a marked head's merge it is asked again, when nothing
// happened meanwhile that decides whether it may merge (a verdict, an Approve, a group's turn are events, and ask it at
// mergeRetryAfter is how long after the merge tool refused a head's merge it is asked again, when nothing happened
// meanwhile that decides whether it may merge (a verdict, an Approve, a group's composed check are events, and ask it at
// once): a check it asked takes minutes, and a refusal costs the forge a few reads. A retry of a bounded step (askMerge).
const mergeRetryAfter = time.Minute
// markGrace is how long a mark not complete (the label, no "ready at" comment yet), and one this owner has just
// cleared, are left before they are judged again: the comment follows the label, and a clearing takes a moment.
const markGrace = time.Minute
// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a
// readyRefusedFor is how long the merge tool may refuse an approved head before it is said as stalled: longer than a
// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said.
const readyRefusedFor = time.Hour
// ReadyRun is what this owner did with a delivery's ready mark: kept on the delivery and shown whole by `show`.
// ReadyRun is what this owner did with a delivery's Approve and merge: kept on the delivery and shown whole by `show`.
type ReadyRun struct {
// Head, By and MarkedAt are the mark: the head it names, who marked it, when.
Head string `json:"head"`
By string `json:"by,omitempty"`
MarkedAt string `json:"marked_at,omitempty"`
// Seen is when this owner first read the mark.
// Head is the head the run is for.
Head string `json:"head"`
// Seen is when this owner first made the run: the checks passed.
Seen time.Time `json:"seen"`
// Waits is what the merge waits on, when it waits: a verdict, the group's turn or its composed check.
// Waits is what the merge waits on, when it waits: a verdict, the group's composed check or the operator's Approve.
Waits string `json:"waits,omitempty"`
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
Tries int `json:"tries,omitempty"`
@@ -78,8 +61,8 @@ type ReadyRun struct {
Merged *time.Time `json:"merged,omitempty"`
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
Failed string `json:"failed,omitempty"`
// OtherBase is the branch its pull request merges into when that is not main: never merged here, said once on the
// pull request (novox/hq ADR 0398 §4, issue 948).
// OtherBase is the branch its pull request merges into when that is not main: never asked, never merged here, said
// once on the pull request (novox/hq ADR 0398 §4, issue 948).
OtherBase string `json:"other_base,omitempty"`
// The operator's Approve of the merge into main (novox/hq ADR 0398 §3, issue 948, approval.go). AskID is the ask
@@ -101,120 +84,60 @@ type ReadyRun struct {
// MergeBranch is the one branch this owner merges into (novox/hq ADR 0398 §4): a merge into any other is its author's.
const MergeBranch = "main"
// mergedBy is who a merge this owner made on a ready mark is said to be by, in its transitions.
// mergedBy is who a merge this owner made on an Approve is said to be by, in its transitions.
func (r *ReadyRun) mergedBy() string {
if r == nil || r.Merged == nil {
return ""
}
by := byOwner + ", on the ready mark at " + shortOf(r.Head, 12)
if r.By != "" {
by += " by " + r.By
}
by := byOwner + ", on the operator's Approve at " + shortOf(r.Head, 12)
if r.Warrant != "" {
by += "; the merge into main approved: " + r.Warrant
}
return by
}
// marked is a pull request read with a ready mark.
type marked struct {
pull OpenPull
// cleared is when the mark was cleared, the label taken off: not cleared again within markGrace.
cleared time.Time
// firstEmpty is when a mark with no head (a label no "ready at" comment follows yet) was first read so: it is a
// mark not yet complete, and is cleared only once it has stayed so for markGrace (novox/hq issue 873).
firstEmpty time.Time
}
// pullKey is a pull request as a key: owner/repo#number.
func pullKey(p OpenPull) string {
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
}
// seeMarks keeps the open pull requests that carry a ready mark; under the lock. A pull request no longer open or
// no longer marked drops out.
func (h *Holder) seeMarks(open []OpenPull) {
seen := map[string]*marked{}
for _, p := range open {
if p.Ready == nil || p.HeadSHA == "" {
continue
}
k := pullKey(p)
m := &marked{pull: p}
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == p.Ready.Head &&
was.pull.HeadSHA == p.HeadSHA {
m.cleared = was.cleared
}
if p.Ready.Head == "" {
m.firstEmpty = h.Now()
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == "" &&
was.pull.HeadSHA == p.HeadSHA && !was.firstEmpty.IsZero() {
m.firstEmpty = was.firstEmpty
}
}
seen[k] = m
// runOf is the delivery's run, made when it has none.
func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun {
if d.Ready == nil {
d.Ready = &ReadyRun{Head: d.Commit, Seen: now}
h.keep(d)
}
h.marks = seen
return d.Ready
}
// sameFull is whether a mark names a head: the whole sha, or — read from an older forge module — at least twelve
// characters of it.
func sameFull(mark, head string) bool {
mark, head = strings.ToLower(mark), strings.ToLower(head)
return len(mark) >= 12 && strings.HasPrefix(head, mark)
}
// readyAsks works out, under the lock, what each ready mark needs: a mark cleared, a merge asked, a failure said.
// The asking happens outside the lock.
// readyAsks works out, under the lock, what each delivery whose checks passed needs: the operator asked, a merge asked,
// a failure said, an out of date verdict's check asked again. The asking happens outside the lock.
func (h *Holder) readyAsks(now time.Time) []func() {
var asks []func()
h.endStaleAsks()
for k, m := range h.marks {
p, mark := m.pull, *m.pull.Ready
repository := strings.ToLower(p.Owner + "/" + p.Repo)
if !sameFull(mark.Head, p.HeadSHA) {
if mark.Head == "" && now.Sub(m.firstEmpty) < markGrace {
h.lookAt(m.firstEmpty.Add(markGrace), "a ready label whose comment naming the head has not followed yet")
continue // the label first, its comment not yet: a mark not complete, not an old one
}
if d := h.deliveries[IDOf(repository, p.HeadSHA)]; d != nil && d.Ready != nil &&
sameFull(d.Ready.Head, p.HeadSHA) && now.Sub(d.Ready.Seen) < markGrace {
h.lookAt(d.Ready.Seen.Add(markGrace), "a mark made through mark-ready, its comment possibly not yet read")
continue // marked through mark-ready a moment ago: this read may predate its comment
}
if !m.cleared.IsZero() && now.Sub(m.cleared) < markGrace {
h.lookAt(m.cleared.Add(markGrace), "a stale ready mark whose clearing may not have taken")
continue
}
m.cleared = now
if old := h.deliveries[IDOf(repository, mark.Head)]; old != nil && old.Ready != nil && old.Ready.AskID != "" {
asks = append(asks, h.dropAsk(old.Ready.AskID, "its head moved on to "+shortOf(p.HeadSHA, 12)))
}
asks = append(asks, h.clearMark(k, p, mark))
continue
ids := make([]string, 0, len(h.deliveries))
for id := range h.deliveries {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
d := h.deliveries[id]
if d.Number == 0 || d.State.Final() || d.MergedAs != "" {
continue // not of a pull request, or past its merge
}
d := h.deliveries[IDOf(repository, p.HeadSHA)]
if d == nil || d.State.Final() || d.MergedAs != "" {
continue // not announced yet, or past its merge
}
if d.Ready == nil || d.Ready.Head != mark.Head || (d.Ready.Declined != nil && mark.At != d.Ready.MarkedAt) {
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
if d.Ready == nil && d.State == Ready {
h.runOf(d, now)
h.Poke() // its check, if none is asked, is worth asking now (askCheck)
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
h.keep(d)
h.Logf("[mesh-delivery] %s: its checks passed; the operator's Approve of its merge into main is asked once it may merge", d.ID)
}
if a := h.sayAfterLine(d); a != nil {
asks = append(asks, a)
}
if a := h.askFresh(d, now); a != nil {
asks = append(asks, a)
}
r := d.Ready
if r.Merged != nil {
continue
}
if r.Declined != nil {
// The operator declined: nothing is merged, and the mark is taken off (said once when it was declined).
if m.cleared.IsZero() || now.Sub(m.cleared) >= markGrace {
m.cleared = now
asks = append(asks, h.unmark(k, p, mark))
}
h.lookAt(m.cleared.Add(markGrace), "a declined ready mark whose clearing may not have taken")
if r == nil || r.Merged != nil || r.Declined != nil {
continue
}
if d.State == Rejected {
@@ -227,22 +150,21 @@ func (h *Holder) readyAsks(now time.Time) []func() {
}
}
r.Waits = ""
h.Logf("[mesh-delivery] %s is marked ready and not merged: %s", d.ID, r.Failed)
h.Logf("[mesh-delivery] %s passed its checks and is not merged: %s", d.ID, r.Failed)
h.keep(d)
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked "+
"ready at %s, and %s. A new head, marked ready once reviewed, or a recheck that passes, merges it.",
mark.Head, r.Failed)))
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request's checks passed at "+
"%s, and now %s. A new head, or a recheck that passes, asks the operator again.", d.Commit, r.Failed)))
}
continue
}
if p.Base != MergeBranch {
if r.OtherBase != p.Base {
r.OtherBase = p.Base
h.Logf("[mesh-delivery] %s is marked ready and not merged: it merges into %s, not %s", d.ID, p.Base, MergeBranch)
if d.Base != MergeBranch {
if r.OtherBase != d.Base {
r.OtherBase = d.Base
h.Logf("[mesh-delivery] %s passed its checks and is not asked: it merges into %s, not %s", d.ID, d.Base, MergeBranch)
h.keep(d)
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked ready "+
"at %s, and it merges into %s. Only a merge into %s is this owner's; a merge into another branch is its "+
"author's (novox/hq ADR 0398 §4). The mark stays.", mark.Head, p.Base, MergeBranch)))
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request's checks passed at "+
"%s, and it merges into %s. Only a merge into %s is this owner's; a merge into another branch is its "+
"author's (novox/hq ADR 0398 §4).", d.Commit, d.Base, MergeBranch)))
}
continue
}
@@ -252,12 +174,12 @@ func (h *Holder) readyAsks(now time.Time) []func() {
if waits != r.Waits {
r.Waits = waits
if waits != "" {
h.Logf("[mesh-delivery] %s is marked ready and waits: %s", d.ID, waits)
h.Logf("[mesh-delivery] %s waits: %s", d.ID, waits)
}
h.keep(d)
}
var approvalAsk func()
if waits == "" && p.Base == MergeBranch {
if waits == "" {
waits, approvalAsk = h.approval(d, now)
}
if waits != r.Waits {
@@ -277,27 +199,94 @@ func (h *Holder) readyAsks(now time.Time) []func() {
r.Tries++
r.LastTry, r.merging = now, true
h.keep(d)
asks = append(asks, h.askMerge(d.ID, p, mark.Head))
asks = append(asks, h.askMerge(d.ID, r.Heads))
}
asks = append(h.cancels, asks...)
h.cancels = nil
return asks
}
// mergeAgainNow lets a marked delivery's merge be asked at once: something that decides whether it may merge happened (a
// verdict, an Approve, a group's turn), so the wait after a refusal does not hold it. Under the lock.
// sayAfterLine tells a pull request, once, that its description holds an `after: <repository>` line, which is not read
// (novox/hq ADR 0419 §2): the order of a delivery comes from the module graph alone, and no refusal asks for the line.
// Once per pull request, not per head. Under the lock; the comment is said outside it.
func (h *Holder) sayAfterLine(d *Delivery) func() {
if len(d.After) == 0 || d.AfterSaid {
return nil
}
d.AfterSaid = true
for _, o := range h.deliveries {
if o != d && o.Number == d.Number && o.Repository == d.Repository && o.AfterSaid {
h.keep(d)
return nil // said on this pull request for an earlier head
}
}
h.keep(d)
h.Logf("[mesh-delivery] %s: its description holds an after: line (%s), which is not read", d.ID, strings.Join(d.After, ", "))
return h.sayOn(d, "mesh-delivery: this description holds an `after:` line ("+strings.Join(d.After, ", ")+"). It is not read: "+
"the order of a delivery comes from the module graph alone, and nothing asks for the line (novox/hq ADR 0419).")
}
// askFresh is the ask of a head's check against its trunk's head, when its passing verdict is out of date and either
// arrived so, or has an Approve asked or given for it (novox/hq ADR 0421 §3, issue 1022; issue 1047 for a group's
// member): the Approve stays through a trunk move, and the merge is asked again on the verdict this brings, with no
// second Approve. Nil when nothing needs asking. Under the lock; the ask runs outside it.
func (h *Holder) askFresh(d *Delivery, now time.Time) func() {
if d.State != Proposed || d.outOfDate() == "" || d.AskedAgain != nil || !d.Check.Passes() || d.Base != MergeBranch ||
d.Number == 0 || d.TrunkNow == "" {
return nil
}
if !d.FreshWanted && h.covering(d) == nil {
return nil
}
if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute {
h.lookAt(tried.Add(time.Minute), "a check against the trunk's head that could not be asked")
return nil
}
h.askTried[d.ID] = now
h.lookAt(now.Add(time.Minute), "a check against the trunk's head that could not be asked")
id, m := d.ID, MemberOf(d)
m.BaseSHA = d.TrunkNow
return func() {
asked, err := h.Controller.Check("", []Member{m})
h.mu.Lock()
defer h.mu.Unlock()
if err != nil {
h.Logf("[mesh-delivery] %s's check against the trunk's head cannot be asked yet: %v", id, err)
return
}
delete(h.askTried, id)
d := h.deliveries[id]
if d == nil || d.State != Proposed || d.outOfDate() == "" {
return
}
t := h.Now()
d.AskedAgain, d.FreshWanted = &t, false
h.Logf("[mesh-delivery] %s: its verdict is out of date; its check against the trunk's head %s asked as %s", id,
shortOf(d.TrunkNow, 12), asked)
h.keep(d)
}
}
// mergeAgainNow lets an approved delivery's merge be asked at once: something that decides whether it may merge happened (a
// verdict, an Approve, a group's composed check), so the wait after a refusal does not hold it. Under the lock.
func (h *Holder) mergeAgainNow(d *Delivery) {
if d != nil && d.Ready != nil && d.Ready.Merged == nil {
d.Ready.LastTry = time.Time{}
}
}
// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is
// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it.
// readyWaits is what a delivery's merge waits on; empty when the Approve may be asked and the merge asked. A ready
// delivery is asked. So is a proposed one whose verdict went out of date, when an Approve was asked for it already (it
// stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an
// Approve waits for that check first.
func (h *Holder) readyWaits(d *Delivery) string {
switch {
case d.State == Ready:
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil:
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain != nil:
return "its check against the trunk's head is asked and has not answered"
case d.State == Proposed && d.outOfDate() != "" && d.Check.Passes() && h.covering(d) != nil:
case d.State == Proposed && d.outOfDate() != "":
return "its verdict is out of date: its check against the trunk's head is asked before the operator is"
case d.State == Proposed && d.AskedAgain != nil:
return "its check against the trunk's head is asked and has not answered"
default:
@@ -309,8 +298,9 @@ func (h *Holder) readyWaits(d *Delivery) string {
return h.groupTurnToMerge(d)
}
// groupTurnToMerge is why a group member may not merge yet; empty at its turn: the group's composed check passed for
// its members' heads as they are and is not out of date, and every member before it in the group's order merged.
// groupTurnToMerge is why a group member may not merge yet; empty when it may: the group's composed check passed for
// its members' heads as they are and is not out of date. No member waits for another's merge or delivery (novox/hq
// ADR 0418 §4): once the composed check passed and the Approve covers the heads, every member is merged in one pass.
func (h *Holder) groupTurnToMerge(d *Delivery) string {
g := h.groups[d.Group]
if g == nil {
@@ -324,27 +314,29 @@ func (h *Holder) groupTurnToMerge(d *Delivery) string {
!sameMembers(c.Commits, commitsOf(members)) {
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
}
if !containsString(g.Order, d.ID) {
return "its group " + g.ID + "'s order is not worked out for it yet"
}
for _, id := range g.Order {
if id == d.ID {
return ""
}
if o := h.deliveries[id]; o != nil && o.MergedAs == "" && !o.State.Final() {
return "its group " + g.ID + "'s order merges " + id + " first"
}
}
return ""
}
// askMerge asks the forge's merge tool to merge a marked head, and keeps its answer.
func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
// askMerge asks the forge's merge tool to merge a head the operator approved, and keeps its answer. The tool is handed
// the heads the Approve covers, each `owner/repo@<full sha>` (novox/hq ADR 0421 §9), and refuses a pull request whose
// head differs from the one approved.
func (h *Holder) askMerge(id string, heads map[string]string) func() {
return func() {
err := h.Forge.Merge(p.Owner, p.Repo, p.Number, head)
h.mu.Lock()
d := h.deliveries[id]
if d == nil || d.Ready == nil {
h.mu.Unlock()
return
}
owner, repo, number, head := d.Owner(), d.Repo(), d.Number, d.Commit
if sha := heads[id]; sha != "" {
head = sha
}
h.mu.Unlock()
err := h.Forge.Merge(owner, repo, number, head, approvedHeads(heads))
h.mu.Lock()
defer h.mu.Unlock()
d := h.deliveries[id]
d = h.deliveries[id]
if d == nil || d.Ready == nil {
return
}
@@ -353,9 +345,9 @@ func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
if err == nil {
// Issue 1025: the announcement is owed, kept with the delivery, so a death right after the merge
// still says it once.
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, p, head))), Since: now})
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, head))), Since: now})
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id,
h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", id,
shortOf(head, 12))
h.keep(d)
return
@@ -365,7 +357,7 @@ func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
r.RefusedSince = &now
}
if answer != r.Answer {
h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id,
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again in %s): %s", id,
mergeRetryAfter, answer)
}
h.lookAt(now.Add(mergeRetryAfter), "a merge the forge refused")
@@ -374,60 +366,15 @@ func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
}
}
// clearMark takes a mark off a pull request whose head moved after it, and says so on it.
func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
return func() {
err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID)
if err == nil {
err = h.Forge.Comment(p.Owner, p.Repo, p.Number, clearedSaying(mark, p))
}
if mark.Head == "" {
if err != nil {
h.Logf("[mesh-delivery] %s's ready label has no \"ready at <head>\" comment of a listed account after it, and could not be taken off yet: %v", k, err)
h.again("a ready label that could not be taken off")
return
}
h.Logf("[mesh-delivery] %s's ready label taken off: no \"ready at <head>\" comment of a listed account was written after it; mark-ready writes both in order", k)
} else {
if err != nil {
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v",
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err)
h.again("a stale ready mark that could not be cleared")
return
}
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
shortOf(p.HeadSHA, 12))
}
h.mu.Lock()
delete(h.marks, k)
h.mu.Unlock()
// approvedHeads are the heads an Approve covers as the merge tool is handed them: `owner/repo@<full sha>`, sorted.
func approvedHeads(heads map[string]string) []string {
var out []string
for id, sha := range heads {
repository, _, _ := strings.Cut(id, "@")
out = append(out, repository+"@"+sha)
}
}
// unmark takes the mark off a pull request whose merge the operator declined (already said on it).
func (h *Holder) unmark(k string, p OpenPull, mark ReadyMark) func() {
return func() {
if err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID); err != nil {
h.Logf("[mesh-delivery] %s's ready mark could not be taken off after the operator's Decline yet: %v", k, err)
h.again("a declined ready mark that could not be taken off")
return
}
h.mu.Lock()
delete(h.marks, k)
h.mu.Unlock()
}
}
// clearedSaying is what a cleared mark says on its pull request. A mark with no head is a label no "ready at <head>"
// comment of a listed account followed (novox/hq issue 873); one with a head names an older head than the pull's.
func clearedSaying(mark ReadyMark, p OpenPull) string {
if mark.Head == "" {
return "mesh-delivery: the label " + ReadyLabel + " is taken off: no \"ready at\" comment by a listed account " +
"follows both the label's last addition and the last push; the head is now " + p.HeadSHA + ". Review it and " +
"mark that head with `mesh-delivery.mark-ready`, which writes label and comment in the order the forge counts."
}
return fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+
"the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA)
sort.Strings(out)
return out
}
// sayOn comments on a delivery's pull request.
@@ -440,7 +387,7 @@ func (h *Holder) sayOn(d *Delivery, body string) func() {
}
}
// readyLines are the marked heads that will not merge: a check that failed at once, a merge refused past its bound;
// readyLines are the approved heads that will not merge: a check that failed at once, a merge refused past its bound;
// as stalled lines, which the controller raises as conditions. Under the lock.
func (h *Holder) readyLines(now time.Time) []StalledLine {
var out []StalledLine
@@ -452,19 +399,19 @@ func (h *Holder) readyLines(now time.Time) []StalledLine {
switch {
case r.Failed != "" && d.State == Rejected:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
Bound: "0s", H2: "none: a new head marked ready, or a recheck that passes — the operator's",
Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: %s", d.Repository, d.Number,
Bound: "0s", H2: "none: a new head, or a recheck that passes — the operator's",
Says: fmt.Sprintf("%s#%d passed its checks at %s and is not merged: %s", d.Repository, d.Number,
shortOf(r.Head, 12), r.Failed)})
case r.Refused != "":
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
Bound: "0s", H2: "none: a new head marked ready, which asks the operator again — the operator's",
Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: the operator's answer %s", d.Repository,
Bound: "0s", H2: "none: a new head, which asks the operator again — the operator's",
Says: fmt.Sprintf("%s#%d at %s is not merged: the operator's answer %s", d.Repository,
d.Number, shortOf(r.Head, 12), r.Refused)})
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
H2: "none: the merge is asked again after each verdict, Approve or turn, and each minute — the operator's",
Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository,
H2: "none: the merge is asked again after each verdict, Approve or composed verdict, and each minute — the operator's",
Says: fmt.Sprintf("%s#%d is approved at %s, and the merge tool has refused it since %s: %s", d.Repository,
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
}
}
@@ -474,13 +421,13 @@ func (h *Holder) readyLines(now time.Time) []StalledLine {
// mergedEvent is the module event `merged` (issue 1025): the pull request merged, its head and group, and the ask,
// option and heads the operator's Approve was bound to (the warrant the controller reads back from the router). A merge
// without an Approve names no ask and no option: no warrant is invented. Under the lock, before anything clears them.
func mergedEvent(d *Delivery, p OpenPull, head string) map[string]any {
func mergedEvent(d *Delivery, head string) map[string]any {
r := d.Ready
heads := map[string]string{}
for k, v := range r.Heads {
heads[k] = v
}
ev := map[string]any{"owner": p.Owner, "repo": p.Repo, "number": p.Number, "head_sha": head, "group": d.Group,
ev := map[string]any{"owner": d.Owner(), "repo": d.Repo(), "number": d.Number, "head_sha": head, "group": d.Group,
"ask_id": "", "option": "", "heads": heads}
if r.Approved != nil {
ev["ask_id"], ev["option"] = r.AskID, OptionApprove
@@ -2,25 +2,25 @@ package main
import (
"errors"
"sort"
"strings"
"testing"
"time"
)
// **A pull request marked ready is merged by this owner** (novox/hq issue 722): before, a reviewed pull request with
// green checks waited until a session called the merge tool, and again after every check the tool asked because the
// trunk had moved. Each test below fails on a holder without the ready mark: nothing merged it.
// **A pull request whose checks passed and whose merge the operator approved is merged by this owner** (novox/hq issues
// 722 and 1022): before, a reviewed pull request with green checks waited until a session called the merge tool, and
// again after every check the tool asked because the trunk had moved.
const (
readyHead = "1111111111111111111111111111111111111111"
pushed = "2222222222222222222222222222222222222222"
)
// markedPull is an open pull request as gitea_open_pulls reads it, labelled mesh/ready at the head named.
func markedPull(repo string, number int, head, markedAt string) OpenPull {
// openPull is an open pull request into main as gitea_open_pulls reads it.
func openPull(repo string, number int, head string) OpenPull {
owner, name, _ := strings.Cut(repo, "/")
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head, Labels: []string{ReadyLabel},
Ready: &ReadyMark{Head: markedAt, By: "reviewer", At: "2026-10-14T19:00:00Z", LabelID: 31}}
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head}
}
func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
@@ -29,26 +29,26 @@ func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
return c
}
func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.T) {
func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
w := newWorld(t)
w.op.auto = false
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("an unmarked pull request was merged: %v", w.forge.merges)
t.Fatalf("a pull request nobody approved was merged: %v", w.forge.merges)
}
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
t.Fatalf("a ready, marked pull request was merged %v; wanted once, at the marked head", w.forge.merges)
t.Fatalf("an approved pull request was merged %v; wanted once, at the approved head", w.forge.merges)
}
d := w.delivery(id)
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.By != "reviewer" || d.Ready.Tries != 1 {
t.Fatalf("what was done with the mark is not kept: %+v", d.Ready)
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.Tries != 1 {
t.Fatalf("what was done with the Approve is not kept: %+v", d.Ready)
}
// Its merge's transition names who merged it.
// Its merge's transition names the Approve as its warrant.
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
w.settleAll()
@@ -59,97 +59,20 @@ func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.
last = tr
}
}
if !strings.Contains(last.By, "ready mark at "+readyHead[:12]+" by reviewer") {
t.Fatalf("the merge's transition does not name the mark: %+v", last)
if !strings.Contains(last.By, "the operator's Approve at "+readyHead[:12]) {
t.Fatalf("the merge's transition does not name the Approve: %+v", last)
}
if len(w.forge.merges) != 1 {
t.Fatalf("merged again: %v", w.forge.merges)
}
}
func TestAPushAfterTheMarkClearsItAndNothingIsMerged(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a head pushed after the mark was merged: %v", w.forge.merges)
}
if len(w.forge.unlabels) != 1 || w.forge.unlabels[0] != "novox/app#7-31" {
t.Fatalf("the stale mark's label was not taken off: %v", w.forge.unlabels)
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "cleared") ||
!strings.Contains(w.forge.comments[0], pushed) {
t.Fatalf("the cleared mark is not said on the pull request: %v", w.forge.comments)
}
}
func TestAMovedTrunkIsJudgedAgainByTheMergeToolAndMergedOnItsVerdict(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
// Another merge moves the trunk: its verdict is out of date, and it goes back to proposed.
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
if w.state(id) != Proposed {
t.Fatalf("out of date, it is %s", w.state(id))
}
// The merge tool asks the check against the trunk's head and refuses: nothing merged.
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked")
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 1 {
t.Fatalf("the merge tool was not asked once to judge it again: %v", w.forge.merges)
}
// The tool's ask is the head announced again; it waits for that verdict, asking nothing.
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.later(5 * time.Minute)
w.settleAll()
if len(w.forge.merges) != 1 || !strings.Contains(w.delivery(id).Ready.Waits, "asked") {
t.Fatalf("asked again while its check was on its way: %v (%+v)", w.forge.merges, w.delivery(id).Ready)
}
// It passes against the trunk's head: merged, without anyone asking.
w.forge.refuse = nil
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
w.settleAll()
if w.state(id) != Ready || len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
t.Fatalf("on its passing verdict it was not merged: %s %v %+v", w.state(id), w.forge.merges, w.delivery(id).Ready)
}
}
func TestAMarkedHeadWhoseCheckFailedIsNotMergedAndIsSaid(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.later(10 * time.Minute)
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a head whose check failed was merged: %v", w.forge.merges)
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "not merged") {
t.Fatalf("the failure is not said once on the pull request: %v", w.forge.comments)
}
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Rejected || !strings.Contains(lines[0].Says, "marked ready") {
t.Fatalf("the failure is not a stalled line, which the controller raises as a condition: %+v", lines)
}
}
func TestAMarkRefusedForAnHourIsSaid(t *testing.T) {
func TestAMergeRefusedForAnHourIsSaid(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending")
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.later(30 * time.Second)
@@ -167,46 +90,51 @@ func TestAMarkRefusedForAnHourIsSaid(t *testing.T) {
}
}
func TestAGroupMergesItsMarkedMembersInOrderOnlyOnceComposed(t *testing.T) {
func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.T) {
w := newWorld(t)
w.op.auto = false
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a member merged before its group's composed check passed: %v", w.forge.merges)
if len(w.forge.merges) != 0 || w.op.count() != 0 {
t.Fatalf("a member was merged or asked before its group's composed check passed: %v, %d", w.forge.merges, w.op.count())
}
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.settleAll()
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead {
t.Fatalf("composed, the first member in the group's order was not merged alone: %v", w.forge.merges)
if w.op.count() != 1 || len(w.forge.merges) != 0 {
t.Fatalf("composed, the group is asked once and nothing merges before the Approve: asks %d, merges %v", w.op.count(), w.forge.merges)
}
if waits := w.delivery(cat).Ready.Waits; !strings.Contains(waits, "merges "+ctl+" first") {
t.Fatalf("the second member does not say it waits its turn: %q", waits)
}
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead {
t.Fatalf("after the first member merged, the next was not: %v", w.forge.merges)
if len(w.forge.merges) != 2 {
t.Fatalf("both members are merged in the same pass on the one Approve: %v", w.forge.merges)
}
sort.Strings(w.forge.merges)
if w.forge.merges[0] != "novox/mesh-catalog#2@"+catHead || w.forge.merges[1] != "novox/mesh-controller#1@"+ctlHead {
t.Fatalf("merged %v, wanted both, each at its approved head", w.forge.merges)
}
for _, got := range w.forge.approvedHeads {
if len(got) != 2 || got[0] != "novox/mesh-catalog@"+catHead || got[1] != "novox/mesh-controller@"+ctlHead {
t.Fatalf("a member's merge was handed the approved heads %v", got)
}
}
if w.op.count() != 1 {
t.Fatalf("a second Approve was asked: %d", w.op.count())
}
}
// **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a ready mark on a pull
// request whose base is not main is never merged by this owner; it says so once on the pull request and leaves the mark.
func TestAMarkedGreenPullRequestIntoAnotherBranchIsNotMergedAndSaidOnce(t *testing.T) {
// **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a green pull request
// whose base is not main is never asked or merged by this owner; it says so once on the pull request.
func TestAGreenPullRequestIntoAnotherBranchIsNotAskedOrMergedAndSaidOnce(t *testing.T) {
w := newWorld(t)
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Base = "release/x"
w.h.PullUpdated(p)
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
open := markedPull("novox/app", 7, readyHead, readyHead)
open := openPull("novox/app", 7, readyHead)
open.Base = "release/x"
w.forge.open = []OpenPull{open}
for range 3 {
@@ -214,14 +142,11 @@ func TestAMarkedGreenPullRequestIntoAnotherBranchIsNotMergedAndSaidOnce(t *testi
w.settleAll()
w.later(2 * time.Minute)
}
if len(w.forge.merges) != 0 {
t.Fatalf("a pull request into release/x was merged: %v", w.forge.merges)
if len(w.forge.merges) != 0 || w.op.count() != 0 {
t.Fatalf("a pull request into release/x was merged %v or asked %d", w.forge.merges, w.op.count())
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "another branch") ||
!strings.Contains(w.forge.comments[0], "0398") {
t.Fatalf("not said exactly once on the pull request: %v", w.forge.comments)
}
if len(w.forge.unlabels) != 0 {
t.Fatalf("the mark was taken off: %v", w.forge.unlabels)
}
}
@@ -500,7 +500,7 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
}
by := f.By
if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" {
// A merge this owner made on a ready mark names it as its author (novox/hq issue 722).
// A merge this owner made on the operator's Approve names it as its author (novox/hq issue 722).
by = d.Ready.mergedBy()
}
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
@@ -622,13 +622,6 @@ func boundOf(d *Delivery) (Bound, bool) {
b.For, b.Says = Bounds[Published].For, d.HeldBack // a walk not let go waits for a word, whatever its state
return b, true
}
if d.State == Published && d.TurnAwaits != "" && d.TurnAwaitsUntil != nil {
// Its turn waits for a later merge to carry a member before it: bounded by that carrier's own bound, and
// said with it, never the generic "waits for its turn or its word" (novox/hq issue 445).
b := Bounds[Published]
b.For, b.Says = max(b.For, d.TurnAwaitsUntil.Sub(boundSince(d))), d.TurnAwaits
return b, true
}
b, bounded := Bounds[d.State]
return b, bounded
}
@@ -52,7 +52,7 @@ func TestAMergeTheForgeRefusedIsAskedAgainAfterAMinuteByTheTimerAlone(t *testing
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: not mergeable yet")
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
w.h.wantForge()
w.settleAll()
if len(w.forge.merges) != 1 {
@@ -12,7 +12,7 @@ import (
// 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 when it starts and when an event says
// what the forge shows may have changed — a merge check's status, a new head, a ready mark (events.go), not a poll
// what the forge shows may have changed — a merge check's status, a new head (events.go), not a poll
// (novox/hq ADR 0406 §1, issue 975) — and once more at a head's bound, 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
@@ -74,9 +74,6 @@ type OpenPull struct {
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
// from an answered one.
Checks map[string]HeadCheck `json:"checks,omitempty"`
// Labels are its labels by name, and Ready its ready mark, when it carries one (novox/hq issue 722).
Labels []string `json:"labels,omitempty"`
Ready *ReadyMark `json:"ready,omitempty"`
}
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
@@ -170,7 +167,7 @@ func askedAgain(was, now map[string]string) bool {
}
// SeeOpenPulls asks the forge for every open pull request and keeps the heads with a required merge check not
// answered, and the ready marks (seeMarks). A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
// answered. A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
func (h *Holder) SeeOpenPulls() error {
open, err := h.Forge.OpenPulls()
if err != nil {
@@ -208,7 +205,6 @@ func (h *Holder) SeeOpenPulls() error {
seen[id] = u
}
h.unchecked = seen
h.seeMarks(open)
return nil
}
@@ -67,7 +67,7 @@ func TestANewlyAnnouncedHeadReadsTheForgeAndAKnownOneDoesNot(t *testing.T) {
func TestNothingReadsTheForgeWithoutAnEvent(t *testing.T) {
w := newWorld(t)
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
before := w.forge.openCalls
for range 6 {
w.later(time.Minute)
@@ -62,7 +62,7 @@ func (h *Holder) waitsFor(d *Delivery) string {
" ended without building it, and it is stopped " + strandedAfter.String() + " after that walk ended otherwise"
}
if d.Group != "" {
return "its turn in group " + d.Group
return "its group " + d.Group + ": every member merged, and the plan's word"
}
return "its walk's start"
case Held:
@@ -144,16 +144,14 @@ func (h *Holder) Show(id string) (any, error) {
// GroupLine is one group as `groups` lists it.
type GroupLine struct {
ID string `json:"id"`
State string `json:"state"`
Why string `json:"why"`
Order []string `json:"order"`
Pairs []Pair `json:"pairs,omitempty"`
Cycle []string `json:"cycle,omitempty"`
// Contradictions is each contradiction no precedence resolves, said with how to declare the order.
Contradictions []Contradiction `json:"contradictions,omitempty"`
Check string `json:"composed,omitempty"`
Members []string `json:"members"`
ID string `json:"id"`
State string `json:"state"`
Why string `json:"why"`
// ComposedGate says the group's gate is the composed check's: each member's own pull request check runs the
// repository's check alone, and the composed verdict sets its mesh/merge-gate (novox/hq ADR 0403 §1, issue 978).
ComposedGate bool `json:"composed_gate"`
Check string `json:"composed,omitempty"`
Members []string `json:"members"`
// Details are the members again, one object each, with what a reader needs to find the pull request's
// siblings: its number, full head, base branch and whether it is open (novox/hq ADR 0382 §1). Additive:
// `members` keeps its text form for older readers.
@@ -185,8 +183,7 @@ func (h *Holder) Groups(all bool) []GroupLine {
if !all && (state == "delivered" || state == "failed" || state == "stopped") {
continue
}
l := GroupLine{ID: g.ID, State: state, Why: why, Order: g.Order, Pairs: g.Pairs, Cycle: g.Cycle,
Contradictions: g.Contradictions}
l := GroupLine{ID: g.ID, State: state, Why: why, ComposedGate: true}
for _, d := range members {
l.Members = append(l.Members, fmt.Sprintf("%s (%s)", d.ID, d.State))
l.Details = append(l.Details, MemberLine{ID: d.ID, Repository: d.Repository, State: d.State,
@@ -331,65 +328,6 @@ func (h *Holder) Recheck(id, why, by string) (string, error) {
return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil
}
// MarkReady is a reviewer's word that a delivery's head is approved (novox/hq issue 913): the label mesh/ready on its
// pull request, and only after that call succeeded the comment "ready at <head>", the order the forge counts (a comment
// written before the label does not count, issue 873). Refused for an unknown, final or merged delivery, and when the
// pull request's head is no longer the delivery's commit.
func (h *Holder) MarkReady(id, reviewedBy, why string) (string, error) {
h.mu.Lock()
d := h.deliveries[id]
if d == nil {
h.mu.Unlock()
return "", fmt.Errorf("no delivery %q; `deliveries` lists them", id)
}
if d.State.Final() || d.MergedAs != "" {
h.mu.Unlock()
return "", fmt.Errorf("%s is %s: a delivery past its merge or final is not marked ready", id, d.State)
}
if d.Number == 0 {
h.mu.Unlock()
return "", fmt.Errorf("%s is not of a pull request: only a pull request is marked ready", id)
}
owner, repo, number, commit := d.Owner(), d.Repo(), d.Number, d.Commit
h.mu.Unlock()
open, err := h.Forge.OpenPulls()
if err != nil {
return "", fmt.Errorf("%s is not marked: the forge's open pull requests could not be read: %v", id, err)
}
head := ""
for _, p := range open {
if strings.EqualFold(p.Owner, owner) && strings.EqualFold(p.Repo, repo) && p.Number == number {
head = p.HeadSHA
}
}
if head == "" {
return "", fmt.Errorf("%s#%d is not an open pull request on the forge", owner+"/"+repo, number)
}
if !sameCommit(head, commit) {
return "", fmt.Errorf("the head is now %s; review and mark that one", head)
}
if err := h.Forge.Label(owner, repo, number, ReadyLabel); err != nil {
return "", fmt.Errorf("%s is not marked: the label %s could not be put on: %v", id, ReadyLabel, err)
}
body := fmt.Sprintf("ready at %s\n\nReviewed by %s: %s. Marked through mesh-delivery.mark-ready (novox/hq issue 913).",
head, reviewedBy, strings.TrimRight(strings.TrimSpace(why), "."))
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
return "", fmt.Errorf("%s has the label %s but its comment \"ready at\" could not be written: %v; mark it again", id, ReadyLabel, err)
}
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
d.Ready = &ReadyRun{Head: head, By: reviewedBy, MarkedAt: now.UTC().Format(time.RFC3339), Seen: now}
h.Logf("[mesh-delivery] %s is marked ready at %s by %s through mark-ready: %s", id, shortOf(head, 12), reviewedBy, why)
h.keep(d)
waits := h.readyWaits(d)
if waits == "" {
return fmt.Sprintf("%s is marked ready at %s; its merge is asked once the forge's label and comment events reach this owner", id, shortOf(head, 12)), nil
}
return fmt.Sprintf("%s is marked ready at %s; its merge waits: %s", id, shortOf(head, 12), waits), nil
}
// checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to
// pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every
// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none
@@ -560,7 +498,7 @@ func (h *Holder) StopWay(id, why, by, way string) (string, error) {
h.moved(d, []Transition{t})
var asks []func()
if g := h.groups[d.Group]; g != nil && g.Closed {
asks = h.groupTurns(g, h.membersOf(g))
asks = h.groupWord(g, h.membersOf(g))
} else if g != nil {
h.leaveGroup(d)
}
+1 -7
View File
@@ -19,8 +19,7 @@
"release",
"stop",
"close",
"retire-history",
"mark-ready"
"retire-history"
]
}
],
@@ -28,9 +27,6 @@
"gitea.pull.updated",
"gitea.pull.merged",
"gitea.pull.closed",
"gitea.forge.pull-request.labeled",
"gitea.forge.pull-request.unlabeled",
"gitea.forge.pull-request.commented",
"gitea.forge.status",
"mesh-controller.checked",
"mesh-controller.plan-moved"
@@ -42,7 +38,6 @@
],
"invokes": [
"seat:mesh-controller.delivery-plan",
"seat:mesh-controller.delivery-order",
"seat:mesh-controller.delivery-check",
"seat:mesh-controller.deliver",
"seat:mesh-controller.delivery-stop",
@@ -56,7 +51,6 @@
"gitea.gitea_open_pulls",
"gitea.gitea_merge_pull_request",
"gitea.gitea_add_comment",
"gitea.gitea_api",
"seat:operator-channel.notify"
],
"uses": [