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
+2
-132
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user