GIT1, GIT2, GIT3, GIT10: the forge module serves the seat git's verbs and says its events, retires its acting tools, follows the catalog half
The four share files (index.ts, module.json, guard.test.ts), so they land as one commit. GIT1: seat.ts serves pull, open-pulls, tags, trees, file, contains, statuses, status, comment, view, note, merge-pull as tools on the seat, each answering the contract's shape and an error of kind refused, not-found or failed; a comment, view or note whose act id is held is answered held (note matched by id). merge-pull merges the head the caller names (its from-G judgements are GIT6, GIT7, GIT9). GIT2: seatevents.ts says pull-updated, pull-merged, pull-closed, forge-pull, forge-status, forge-delete and repo-writable on mesh.seat.git.event.<event>; the module's emits drop them. GIT3: merge.ts, check.ts, the acting tools, the owed watch, the gate's source read and check-gates-merge are gone. GIT10: catalog.ts follows the seat mesh-catalog (revision, catalog-revision); no write is granted before the first view. Builds: design 47 G §310, §311, §312, §313, §314, §163, §317, §278, §321, §196 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -67,8 +67,8 @@ refuses the commit that would make a branch a rule guards. A rule covers a branc
|
||||
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
|
||||
(`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob
|
||||
the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the
|
||||
commit. Nothing about merging changes: a merge still needs an independent review at the head and both
|
||||
required checks green, through `gitea_merge_pull_request`.
|
||||
commit. Nothing about merging changes: a merge is the seat git's `merge-pull`, at the head the operator's
|
||||
Approve names.
|
||||
|
||||
**A patch too large to type** (novox/hq issue 852: a feature's diff is megabytes) goes as `patch_file`, with
|
||||
`patch_sha256`. The caller writes the patch into the module's spool directory `forge-patches`
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
// the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the bus stores a message it has
|
||||
// seen the id of, within its de-duplication window, once.
|
||||
//
|
||||
// The SDK's `emit` takes no id, so this publishes through the broker with the headers `emit` stamps and the id given.
|
||||
// It returns to `emit` once the SDK takes an id.
|
||||
// They are the seat git's events (design 47 *The seat git*), said through the runtime's seat publish with that id.
|
||||
//
|
||||
// The ids: a merge by its merge commit, a head by the head and the trunk commit it was announced against (the check asked
|
||||
// again against a new trunk commit is another announcement), a close by its head.
|
||||
|
||||
import { broker } from "@novox/mesh-sdk/messaging";
|
||||
import { sayOnSeat } from "./seatevents.js";
|
||||
import { state as keptState } from "@novox/mesh-sdk/state";
|
||||
|
||||
type Said = { owner?: string; repo?: string; number?: number; head_sha?: string; base_sha?: string; merge_commit_sha?: string };
|
||||
@@ -32,23 +31,11 @@ export function announcementId(type: string, body: unknown): string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Announce a merge, a head or a close: published with its message id. */
|
||||
/** Announce a merge, a head or a close: said on the seat git (seatevents.ts) with its message id. */
|
||||
export async function announce(type: string, body: unknown): Promise<void> {
|
||||
const id = announcementId(type, body);
|
||||
if (!id) throw new Error(`${type} is not an announcement with a message id`);
|
||||
const node = process.env.MESH_NODE ?? "unknown";
|
||||
await broker().publish({
|
||||
key: type,
|
||||
node,
|
||||
body,
|
||||
headers: {
|
||||
"x-event-id": id,
|
||||
"x-source": process.env.MESH_MODULE ?? "unknown",
|
||||
"x-node": node,
|
||||
"x-time": new Date().toISOString(),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!(await sayOnSeat(type, (body ?? {}) as Record<string, unknown>, id))) throw new Error(`the seat git says no ${type}`);
|
||||
}
|
||||
|
||||
// --- the watermark: the one value of the old files that is not the bus's to remember --------------------------------
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// **The forge module's view of the catalog half** (novox/hq design 47 *Every part takes a first view, then follows the
|
||||
// events*, ADR 0656 §6): which repositories build a registered module is the catalog half's word, asked of the seat
|
||||
// mesh-catalog's verb `revision` through the seat's generated TypeScript contract and then kept from its event
|
||||
// `catalog-revision`, never the holder's state nor a module named. The picture is mesh-sdk's views Follower, so the
|
||||
// module's first-view tools (statusTools) read and steer it as every part's.
|
||||
//
|
||||
// The readers' rule: a candidate's revision moves nothing; a trunk revision heard that the view does not hold yet is
|
||||
// waited for (said in the status) and asked, and past mesh-sdk's WAITS_BOUND_MS the module writes view-waits; until the
|
||||
// first view is held the module acts on nothing (writes.ts grants no write).
|
||||
|
||||
import { Follower, type Answer, type Finding, type FollowerOptions, type Status } from "@novox/mesh-sdk/views";
|
||||
import {
|
||||
seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS, states as CATALOG_STATES,
|
||||
type CatalogEntry, type CatalogRevisionEvent, type RevisionAnswer, type RevisionInput,
|
||||
} from "@novox/mesh-delivery-contracts-mesh-catalog";
|
||||
|
||||
/** The seat verb the view asks, as the manifest's `invokes` names it. */
|
||||
export const REVISION_VERB = `seat:${CATALOG}.${CATALOG_VERBS.revision}`;
|
||||
/** The seat event the view hears, as the manifest's `consumes` names it. */
|
||||
export const CATALOG_REVISION_EVENT = `${CATALOG}.${CATALOG_EVENTS.catalogRevision}`;
|
||||
/** A trunk revision; a candidate's moves no running part (ADR 0656 §6). */
|
||||
export const TRUNK = "trunk";
|
||||
|
||||
/** A revision view is never made again: one fixed creation time lets the follower tell a revision heard from one held. */
|
||||
const CREATED = new Date(0).toISOString();
|
||||
|
||||
/** Asks one seat verb over the bus and answers what the holder said. */
|
||||
export type Ask = (verb: string, input: RevisionInput) => Promise<unknown>;
|
||||
|
||||
/** The hooks a part hands the view: everything of mesh-sdk's FollowerOptions but what the view sets itself. */
|
||||
export type CatalogHooks = Omit<FollowerOptions, "owner" | "tool" | "state" | "ask">;
|
||||
|
||||
/** The revision's answer, or the holder's refusal as an error: never an empty view taken for the trunk. */
|
||||
export function revisionAnswer(raw: unknown): RevisionAnswer {
|
||||
let a: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
a = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`${REVISION_VERB} answered: ${raw.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
const r = a as Partial<RevisionAnswer> & { error?: unknown };
|
||||
if (!r || typeof r !== "object" || typeof r.revision?.revision !== "number" || r.revision.revision <= 0 || !Array.isArray(r.entries)) {
|
||||
const said = r && typeof r === "object" && typeof r.error === "string" ? r.error : JSON.stringify(a ?? null).slice(0, 200);
|
||||
throw new Error(`${REVISION_VERB} answered no revision: ${said}`);
|
||||
}
|
||||
return r as RevisionAnswer;
|
||||
}
|
||||
|
||||
export class CatalogView {
|
||||
readonly follower: Follower;
|
||||
/** The trunk revision whose pages are being read; 0 between asks. */
|
||||
private paging = 0;
|
||||
/** The trunk revision the view holds whole; 0 before the first view. */
|
||||
private heldRevision = 0;
|
||||
|
||||
private readonly ask: Ask;
|
||||
|
||||
constructor(ask: Ask, hooks: CatalogHooks) {
|
||||
this.ask = ask;
|
||||
this.follower = new Follower({ ...hooks, owner: CATALOG, tool: REVISION_VERB, state: CATALOG_STATES.catalogRevisions,
|
||||
ask: (after) => this.page(after) });
|
||||
}
|
||||
|
||||
/** The ask the last event began, not waited on by hear: a test awaits it. */
|
||||
asked: Promise<void> = Promise.resolve();
|
||||
|
||||
/** The trunk revision the view holds, 0 before the first view. */
|
||||
get held(): number {
|
||||
return this.heldRevision;
|
||||
}
|
||||
|
||||
/** One module's entry in the view, as the catalog half says it. */
|
||||
entry(module: string): CatalogEntry | undefined {
|
||||
return this.follower.get(module) as CatalogEntry | undefined;
|
||||
}
|
||||
|
||||
/** The Follower's ask: the trunk's newest revision, then the next pages of the same one. */
|
||||
private async page(after: string): Promise<Answer> {
|
||||
const input: RevisionInput = after && this.paging ? { after, revision: this.paging } : {};
|
||||
const a = revisionAnswer(await this.ask(REVISION_VERB, input));
|
||||
const rev = a.revision.revision;
|
||||
if (a.revision.kind !== TRUNK) throw new Error(`${REVISION_VERB} answered a ${a.revision.kind} revision for the trunk`);
|
||||
if (after && this.paging && rev !== this.paging) {
|
||||
const was = this.paging;
|
||||
this.paging = 0;
|
||||
throw new Error(`${REVISION_VERB} answered revision ${rev} for a page of revision ${was}`);
|
||||
}
|
||||
this.paging = rev;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const e of a.entries) values[e.module] = e;
|
||||
if (!a.next) {
|
||||
this.paging = 0;
|
||||
this.heldRevision = rev;
|
||||
const waits = Number(this.follower.status()["waits-for"] ?? 0);
|
||||
if (waits <= rev) await this.follower.holds(String(rev));
|
||||
}
|
||||
return { v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, revision: rev, created: CREATED, values,
|
||||
...(a.next ? { next: a.next } : {}), at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* One `catalog-revision` event. A candidate's moves nothing; a trunk revision the view holds, or an older one, is passed
|
||||
* over; a newer one is waited for and asked, not waited on here. Data that is no such event is an error.
|
||||
*/
|
||||
async hear(ev: CatalogRevisionEvent | undefined): Promise<void> {
|
||||
if (!ev || typeof ev.revision !== "number" || ev.revision <= 0) {
|
||||
throw new Error(`not a ${CATALOG_REVISION_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`);
|
||||
}
|
||||
if (ev.kind !== TRUNK || ev.revision <= this.heldRevision) return;
|
||||
const held = this.heldRevision;
|
||||
// The follower keeps the newest revision heard, so an ask running when it was heard is asked once more.
|
||||
await this.follower.apply({ v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, key: CATALOG_REVISION_EVENT,
|
||||
revision: ev.revision, created: CREATED, gap: true });
|
||||
await this.follower.waitFor(String(ev.revision));
|
||||
this.asked = this.follower.gap(`catalog revision ${ev.revision} said, the view holds ${held}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind of a finding of a first view unanswered past its bound (ADR 0615 §6), as mesh-sdk's Go views writes it. */
|
||||
export const UNANSWERED_KIND = "first-view-unanswered";
|
||||
|
||||
/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */
|
||||
export function findingKey(scope: string, id: string, kind: string): string {
|
||||
return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join(".");
|
||||
}
|
||||
|
||||
/** A finding as the condition keeper reads it from the module's state `findings`: its key and its value. */
|
||||
export function findingValue(f: Finding): { key: string; value: Record<string, unknown> } {
|
||||
const key = findingKey("module", f.id, f.kind);
|
||||
return { key, value: {
|
||||
v: 1, key, kind: f.kind, subject: { scope: "module", id: f.id }, severity: "warning", summary: f.summary, headline: f.headline,
|
||||
explanation: f.explanation, resolved: "", evidence: [{ at: f.at, said: f.summary }], source: `${f.observer}.${f.kind}`,
|
||||
observer: f.observer, at: f.at,
|
||||
} };
|
||||
}
|
||||
|
||||
/** A part's finding of a first view unanswered past its bound, from the follower's status. */
|
||||
export function unansweredFinding(part: string, s: Status, at: Date): Finding {
|
||||
const summary = `${part} has no first view of ${s.owner}'s ${s.state}: ${s.tool} has not answered since ${s.asked ?? "its start"} (${(s.error ?? "").trim()})`;
|
||||
return { observer: part, kind: UNANSWERED_KIND, id: `${part}.${s.owner}.${s.state}`, summary, headline: `${part} waits for ${s.owner}`,
|
||||
explanation: `${part} reads ${s.owner}'s ${s.state} only through ${s.tool}, and keeps asking it; it never reads the state itself. The answer ` +
|
||||
`clears this. Look at ${s.owner}'s holder: is it running and connected?`, at: at.toISOString() };
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
|
||||
// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone).
|
||||
//
|
||||
// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools)
|
||||
// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue
|
||||
// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the
|
||||
// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module
|
||||
// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces
|
||||
// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of
|
||||
// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is
|
||||
// written on its head, once per revision; a revision older than one already written on the key is dropped.
|
||||
//
|
||||
// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses
|
||||
// a status is asked again on a bound, until it writes or a newer revision of the key comes.
|
||||
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { bindUntilHeld, bindWait } from "./bind.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
/** The checker's state this module reads, and the act of its keys this module writes. */
|
||||
export const CHECKER_OWED = "mesh-delivery-checker.owed";
|
||||
export const CHECK_ACT = `status=${CHECK_STATUS}`;
|
||||
|
||||
const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
|
||||
/** What writing the check needs of the forge. */
|
||||
export interface CheckForge {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/** One change of the checker's owed, as the SDK's state watch hands it. */
|
||||
export interface OwedChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */
|
||||
function unescapeName(part: string): string {
|
||||
if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`);
|
||||
return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8");
|
||||
}
|
||||
|
||||
/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey):
|
||||
* `<owner>/<name>.<number>.<head>.status=mesh/check`, owner and name escaped. */
|
||||
export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } {
|
||||
const parts = String(key ?? "").split(".");
|
||||
if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, <repository>.<number>.<head>.<act>`);
|
||||
const [repository, number, sha, act] = parts;
|
||||
if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`);
|
||||
const m = /^([^/]+)\/([^/]+)$/.exec(repository);
|
||||
if (!m) throw new Error(`${JSON.stringify(key)} names no <owner>/<name>`);
|
||||
if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`);
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`);
|
||||
return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha };
|
||||
}
|
||||
|
||||
/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */
|
||||
export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } {
|
||||
if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`);
|
||||
const { owner, repo, sha } = owedKeyOf(change.key);
|
||||
const revision = change.revision;
|
||||
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) {
|
||||
throw new Error(`${JSON.stringify(revision)} is not a revision`);
|
||||
}
|
||||
const v = change.value;
|
||||
if (!v || typeof v !== "object") throw new Error("the value is no owed act");
|
||||
const o = v as Record<string, unknown>;
|
||||
if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`);
|
||||
if (o.act !== "status" || o.context !== CHECK_STATUS) {
|
||||
throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`);
|
||||
}
|
||||
const state = String(o.state ?? "");
|
||||
if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`);
|
||||
let description = String(o.text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (description.length > 140) description = description.slice(0, 139) + "…";
|
||||
const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined;
|
||||
const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description,
|
||||
...(target ? { target_url: target } : {}) };
|
||||
return { owner, repo, sha, revision, status };
|
||||
}
|
||||
|
||||
/** Whether the forge's refusal will not change by asking again: it forbids the write (403) or knows no such repository
|
||||
* or commit (404), as the client words it ("Gitea API <path>: <status> ..."). */
|
||||
export function refusedForGood(err: unknown): boolean {
|
||||
return /Gitea API \S+: (403|404) /.test(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
/** How many mesh/check writes this module has at the forge at once. */
|
||||
export const WRITES_AT_ONCE = 4;
|
||||
|
||||
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
|
||||
export class CheckStatuses {
|
||||
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
|
||||
private written = new Map<string, number>();
|
||||
private seen = new Map<string, number>();
|
||||
/** The turn each key's writes wait on, and the asks again still running. */
|
||||
private turns = new Map<string, Promise<unknown>>();
|
||||
private again = new Set<Promise<void>>();
|
||||
/** Writes at the forge at once, at most WRITES_AT_ONCE: a watch's start hands over every key together (issue 1726). */
|
||||
private writing = 0;
|
||||
private queued: (() => void)[] = [];
|
||||
private forge: CheckForge;
|
||||
private say: (line: string) => void;
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
|
||||
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l),
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
||||
this.forge = forge;
|
||||
this.say = say;
|
||||
this.sleep = sleep;
|
||||
}
|
||||
|
||||
/** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A
|
||||
* forge that refuses throws. */
|
||||
async apply(change: OwedChange): Promise<"written" | "dropped"> {
|
||||
let asked;
|
||||
try {
|
||||
asked = checkStatusOf(change);
|
||||
} catch (err) {
|
||||
// A deleted key is owed no more: its revision is seen, so a write of it still asked again ends (askAgain).
|
||||
if (change?.op === "delete" && typeof change.key === "string" && Number.isInteger(change.revision)) {
|
||||
const seen = this.seen.get(change.key);
|
||||
if (seen === undefined || change.revision > seen) this.remember(this.seen, change.key, change.revision);
|
||||
return "dropped";
|
||||
}
|
||||
this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`);
|
||||
return "dropped";
|
||||
}
|
||||
const key = change.key;
|
||||
const seen = this.seen.get(key);
|
||||
if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision);
|
||||
// One key's writes take turns, so two at once are weighed one after the other.
|
||||
const before = this.turns.get(key) ?? Promise.resolve();
|
||||
const turn = before.then(async (): Promise<"written" | "dropped"> => {
|
||||
const newest = this.written.get(key);
|
||||
if (newest !== undefined && asked.revision < newest) {
|
||||
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
||||
return "dropped";
|
||||
}
|
||||
await this.atTheForge(() => this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status));
|
||||
this.remember(this.written, key, asked.revision);
|
||||
return "written";
|
||||
});
|
||||
const settled = turn.catch(() => {});
|
||||
this.turns.set(key, settled);
|
||||
void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); });
|
||||
return turn;
|
||||
}
|
||||
|
||||
/** What the watch hands over: taken at once and written in the background, or when the forge refuses, asked again on a
|
||||
* bound until it is written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses
|
||||
* the change. It never waits on the forge (novox/hq issue 1726): the runtime hands a watch's current values one at a
|
||||
* time, waits for each to be taken and gives the whole set 10 s; a forge write per key made a large owed outlast that
|
||||
* bound at every attempt, so the watch never held and no verdict reached mesh/check. Each key's writes still take
|
||||
* turns in revision order (apply). */
|
||||
async take(change: OwedChange): Promise<void> {
|
||||
const run = this.apply(change).then(() => {}, (err) => this.askAgain(change, err));
|
||||
this.again.add(run);
|
||||
void run.finally(() => this.again.delete(run));
|
||||
}
|
||||
|
||||
/** Runs one forge write when fewer than WRITES_AT_ONCE are running, else after one ends. */
|
||||
private async atTheForge<T>(write: () => Promise<T>): Promise<T> {
|
||||
if (this.writing >= WRITES_AT_ONCE) await new Promise<void>((r) => this.queued.push(r));
|
||||
else this.writing++;
|
||||
try {
|
||||
return await write();
|
||||
} finally {
|
||||
const next = this.queued.shift();
|
||||
if (next) next();
|
||||
else this.writing--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves when no write or ask again is running. */
|
||||
async settled(): Promise<void> {
|
||||
while (this.again.size) await Promise.all([...this.again]);
|
||||
}
|
||||
|
||||
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
|
||||
let err = first;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
if (refusedForGood(err)) {
|
||||
// A repository the forge forbids or does not know answers the same at every ask (the sweep of 2026-10-30, issue
|
||||
// 774: hal and novadock, 119 asks each): said once, never asked again; a newer revision of the key asks anew.
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} is refused for good (${err instanceof Error ? err.message : err}); not asked again`);
|
||||
return;
|
||||
}
|
||||
const wait = bindWait(attempt);
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`);
|
||||
await this.sleep(wait);
|
||||
const newest = this.seen.get(change.key);
|
||||
if (newest !== undefined && newest > change.revision) return;
|
||||
try {
|
||||
await this.apply(change);
|
||||
return;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private remember(map: Map<string, number>, key: string, revision: number): void {
|
||||
map.delete(key);
|
||||
map.set(key, revision);
|
||||
if (map.size > 4000) map.delete(map.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch
|
||||
* refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects.
|
||||
*/
|
||||
export async function watchOwed(watch: (handler: (change: OwedChange) => Promise<void>) => Promise<void>, checks: CheckStatuses,
|
||||
sleep?: (ms: number) => Promise<void>, say: (line: string) => void = (l) => console.error(l)): Promise<void> {
|
||||
let refusals = 0;
|
||||
await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => {
|
||||
if (/could not be bound/.test(line) && refusals++ > 0) return;
|
||||
say(line);
|
||||
});
|
||||
}
|
||||
@@ -481,6 +481,21 @@ export class GiteaClient {
|
||||
return GiteaClient.mapPull(await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`));
|
||||
}
|
||||
|
||||
/** The pull request a commit was merged by (the forge's own lookup of a merge commit): what the seat git's `pull`
|
||||
* answers by merge commit. A commit no pull request merged is the forge's 404. */
|
||||
async pullOfCommit(owner: string, repo: string, sha: string): Promise<GiteaPull> {
|
||||
if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`${sha} is not a commit's full 40-hex sha`);
|
||||
return GiteaClient.mapPull(await this.request<any>(`${repoPath(owner, repo)}/commits/${sha}/pull`));
|
||||
}
|
||||
|
||||
/** One page of a repository's tags, newest first, by name: what the seat git's `tags` answers. */
|
||||
async tags(owner: string, repo: string, page: number, limit: number): Promise<string[]> {
|
||||
if (!Number.isInteger(page) || page < 1) throw new Error(`${page} is not a page, from 1`);
|
||||
const list = await this.request<any[]>(`${repoPath(owner, repo)}/tags?page=${page}&limit=${limit}`);
|
||||
if (!Array.isArray(list)) throw new Error(`the forge answered no list of the tags of ${owner}/${repo}`);
|
||||
return list.map((t) => String(t?.name ?? "")).filter((n) => n !== "");
|
||||
}
|
||||
|
||||
/** A commit's parents, by sha, in order: what a merge commit says it joined (novox/hq issue 381). An answer
|
||||
* that names no parents is no answer, never an empty list read as a root commit. */
|
||||
async commitParents(owner: string, repo: string, sha: string): Promise<string[]> {
|
||||
|
||||
@@ -16,8 +16,7 @@ import (
|
||||
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
|
||||
// request / approval rule without anyone pushing by hand. By the rule gitea_api already enforces for a file
|
||||
// written (novox/hq ADR 0266): never the repository's default branch, never a branch a protection rule names.
|
||||
// Nothing about merging changes — that still needs a review at the head and both required checks green
|
||||
// (gitea_merge_pull_request).
|
||||
// Nothing about merging changes — a merge is the seat git's merge-pull, at the head the operator's Approve names.
|
||||
|
||||
// FileChange is one file of a commit: its new content, as text or base64, or its deletion.
|
||||
type FileChange struct {
|
||||
|
||||
@@ -49,8 +49,8 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
|
||||
Name: "gitea_commit",
|
||||
Description: "Put one commit on a branch of its own in a forge repository, creating the branch from base " +
|
||||
"when it does not exist. Refused on the repository's default branch and on any branch a protection " +
|
||||
"rule names (novox/hq ADR 0266): a change reaches those only by a pull request whose head an " +
|
||||
"independent review approved and whose required checks passed (gitea_merge_pull_request). Carries " +
|
||||
"rule names (novox/hq ADR 0266): a change reaches those only by a pull request merged at the head the " +
|
||||
"operator's Approve names (the seat git's merge-pull). Carries " +
|
||||
"either files — each {path, content (text) | content_base64 | delete: true | copy_from {owner, repo, ref, path}: a file or directory of another repository, copied by the module}, created or updated as " +
|
||||
"the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " +
|
||||
"At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " +
|
||||
|
||||
@@ -487,8 +487,8 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) {
|
||||
if strings.Contains(string(req), `"name":"transfers","options"`) {
|
||||
t.Errorf("transfers has options (a lifetime): %s", req)
|
||||
}
|
||||
// The retention asks the builder's versions; the forge's other bundles ask the indexer's modules and record a re-read.
|
||||
if strings.Join(m.Invokes, ",") != "mesh-delivery-indexer.indexer_read_modules,seat:mesh-hand-acts.hand-act,mesh-delivery-builder.builder_versions" {
|
||||
// The retention asks the builder's versions; the forge's other bundles ask the catalog half and record a re-read.
|
||||
if strings.Join(m.Invokes, ",") != "seat:mesh-catalog.revision,seat:mesh-hand-acts.hand-act,mesh-delivery-builder.builder_versions" {
|
||||
t.Errorf("the manifest invokes %v", m.Invokes)
|
||||
}
|
||||
if len(m.Tools) != 0 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// What the forge's holder does for a delivery (novox/hq ADR 0239): the commit's note under
|
||||
// refs/notes/mesh-plan, the delivery's view on its pull request, and its statuses — asked by mesh-delivery,
|
||||
// the delivery's owner, through this module's tools. The forge is this module's; mesh-delivery never writes
|
||||
// to it itself.
|
||||
// refs/notes/mesh-plan and the delivery's view on its pull request, asked through the seat git's verbs `note` and
|
||||
// `view` (seat.ts). The forge is this module's; no delivery part writes to it itself.
|
||||
//
|
||||
// **The note is written in the forge's own repository, as the forge's own user.** The forge's API reads a
|
||||
// note and writes none, and a push needs a clone and a credential nobody else should hold; the forge's
|
||||
@@ -13,13 +12,6 @@
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
export { CHECK_STATUS };
|
||||
|
||||
/** How a command is run: docker on the forge's machine, or a test's. */
|
||||
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
|
||||
|
||||
@@ -64,12 +56,13 @@ export function noteArgs(container: string, owner: string, repo: string, commit:
|
||||
|
||||
/** Append a line to a commit's note, unless the note already holds it. Answers whether it was added. */
|
||||
export async function appendNote(runner: Runner, container: string, owner: string, repo: string, commit: string,
|
||||
notesRef: string, line: string): Promise<{ added: boolean; lines: number }> {
|
||||
notesRef: string, line: string, heldId?: string): Promise<{ added: boolean; lines: number }> {
|
||||
const wanted = noteLine(line);
|
||||
const shown = await runner("docker", noteArgs(container, owner, repo, commit, notesRef));
|
||||
// No note yet is git's exit 1 with nothing on stdout; anything else unreadable is said.
|
||||
const lines = shown.code === 0 ? shown.stdout.split("\n").filter((l) => l.trim() !== "") : [];
|
||||
if (lines.includes(wanted)) return { added: false, lines: lines.length };
|
||||
// A line that carries the act's id is the act already done, whatever else it says.
|
||||
if (lines.includes(wanted) || (heldId && lines.some((l) => l.includes(`[${heldId}]`)))) return { added: false, lines: lines.length };
|
||||
const appended = await runner("docker", noteArgs(container, owner, repo, commit, notesRef, wanted));
|
||||
if (appended.code !== 0) throw new Error(`the note on ${commit.slice(0, 8)} could not be appended (git exited ${appended.code})`);
|
||||
return { added: true, lines: lines.length + 1 };
|
||||
@@ -89,77 +82,3 @@ export function viewComment<T extends { id: number; body: string }>(comments: T[
|
||||
return comments.find((c) => c.body.includes(VIEW_MARKER));
|
||||
}
|
||||
|
||||
const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
|
||||
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
|
||||
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
|
||||
|
||||
|
||||
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
|
||||
* bounded description. */
|
||||
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
|
||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
||||
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
|
||||
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
|
||||
// state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1):
|
||||
// check.ts writes it from the checker's owed state (ADR 0521).
|
||||
if (context === CHECK_STATUS) {
|
||||
throw new Error(`${CHECK_STATUS} is the checker's status: it is written from mesh-delivery-checker.owed alone, never by a tool`);
|
||||
}
|
||||
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
|
||||
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
|
||||
// issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes,
|
||||
// and what a recheck of the same head says, so the old verdict does not let it merge while the fresh check
|
||||
// runs.
|
||||
if (mergeCheckContexts.has(context) && state !== "pending") {
|
||||
throw new Error(`${context} is the merge check's status, set by its verdict only (pending, for a check asked again, is the one state it takes by hand)`);
|
||||
}
|
||||
let d = String(description ?? "").replace(/\s+/g, " ").trim();
|
||||
if (d.length > 140) d = d.slice(0, 139) + "…";
|
||||
return { state: state as "pending" | "success" | "error" | "failure" | "warning", context, description: d,
|
||||
...(target && /^https?:\/\//.test(target) ? { target_url: target } : {}) };
|
||||
}
|
||||
|
||||
/** What the status tool writes with: the forge, and the gate writer the merge check's statuses go through. */
|
||||
export interface StatusWriters {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `gitea_commit_status` tool. **The merge check's pending goes through the module's one writer** (novox/hq issue
|
||||
* 456): a recheck's pending on mesh/merge-gate or mesh/repo-check takes its turn on the head's chain (gate.ts), so an
|
||||
* old run's verdict in flight lands before it and a redelivered one is dropped. Served from the watcher's bundle
|
||||
* (index.ts), the process that holds that writer: each bundle the runtime serves is its own process. The mesh's other
|
||||
* statuses (mesh/delivery, mesh/delivery-group) are written as they were.
|
||||
*/
|
||||
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: no tool writes it: it is written from the checker's own state. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit" },
|
||||
context: { type: "string", description: "the status's name, mesh/…" },
|
||||
state: { type: "string", description: "pending | success | error | failure | warning" },
|
||||
description: { type: "string", description: "one line" },
|
||||
target_url: { type: "string", description: "the page it links to (the pull request)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
|
||||
const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""),
|
||||
args.target_url ? String(args.target_url) : undefined);
|
||||
if (mergeCheckContexts.has(status.context)) {
|
||||
// The writer keeps a head by its whole sha, as the forge announced it: a short one would be another head.
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
|
||||
throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: the merge check's status names the head whole. Nothing was sent`);
|
||||
}
|
||||
await writers.gates.rechecked(owner, repo, sha, status);
|
||||
} else {
|
||||
await writers.setCommitStatus(owner, repo, sha, status);
|
||||
}
|
||||
return { set: status };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@ export class GateWriter {
|
||||
}
|
||||
|
||||
/**
|
||||
* A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): it takes its turn after every
|
||||
* write already asked for, so it lands over them. Any other status of the merge check takes its turn on the same
|
||||
* chain. A write that fails fails the caller.
|
||||
* A status the seat git's `status` verb sets (seat.ts), a recheck's pending among them (novox/hq issue 456): it takes
|
||||
* its turn after every write of the head already asked for, so it lands over them, and a later write lands over it. A
|
||||
* write that fails fails the caller.
|
||||
*/
|
||||
rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
|
||||
status(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
|
||||
return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
|
||||
// - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that
|
||||
// exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422);
|
||||
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed
|
||||
// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
|
||||
// that head;
|
||||
// - the seat git's `merge-pull` (seat.ts) merges only into a branch that refuses its push, and only the head the
|
||||
// operator's Approve names (ADR 0661);
|
||||
// - `gitea_delete_repo` refuses a repository any protection rule guards.
|
||||
//
|
||||
// Pure, so it is tested without a forge; the tools do the asking (tools/index.ts).
|
||||
@@ -165,7 +164,7 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap
|
||||
`writes — issues, comments, labels, pull requests (never their merge), branches, and files on a branch ` +
|
||||
`that is not the default branch and that no protection rule names. The forge's administration, a ` +
|
||||
`branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` +
|
||||
`are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` +
|
||||
`are the operator's at the forge, or have their own tool (the seat git's merge-pull and status, ` +
|
||||
`gitea_branch_protection_set, which never lets a person push). Nothing was sent`,
|
||||
};
|
||||
}
|
||||
|
||||
+48
-51
@@ -13,9 +13,8 @@
|
||||
// statuses, `mesh/merge-gate` and `mesh/repo-check`, are written for no base that main requires (issue 1301); main
|
||||
// requires `mesh/check`, the checker's, everywhere, hq included since ADR 0527 §2.
|
||||
//
|
||||
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
|
||||
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
|
||||
// over the latest value of every key whenever this module starts.
|
||||
// It reads no other module's state: mesh/check, and every act of a delivery part on the forge, arrive as verbs of the seat
|
||||
// git (seat.ts), asked by the forge bridge (novox/hq design 47 *The seat git*).
|
||||
//
|
||||
// This module is the one writer of mesh/merge-gate. It hears no build seat's event: since R4g a check's steps are asks
|
||||
// on the build seats' streams, whose backlog the delivery view shows (novox/hq ADR 0525 §9; issue 1887).
|
||||
@@ -25,9 +24,6 @@
|
||||
// request into it go back to pending with the reason. Nothing is run for it. A verdict that lands judged against a trunk
|
||||
// commit that is no longer the trunk's head is said out of date in place of its pass or failure.
|
||||
//
|
||||
// Serves gitea_commit_status and gitea_merge_pull_request from this bundle, not the tools', so a recheck's pending and
|
||||
// the merge tool's ask of a check reach that one writer (novox/hq issue 456, ADR 0323).
|
||||
//
|
||||
// Every pull request the forge holds is announced, whatever its repository: the controller holds the
|
||||
// module graph and decides what is checked — a repository is never asked to opt in.
|
||||
//
|
||||
@@ -42,14 +38,16 @@
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { statesAnnouncement } from "./kept.js";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check.js";
|
||||
import { state } from "@novox/mesh-sdk/state";
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
import { broker } from "@novox/mesh-sdk/messaging";
|
||||
import { ViewFollower, unansweredFinding, viewTools, type ViewAnswer, type ViewEvent } from "./views.js";
|
||||
import { STATUS_STATE, keepStatus, statusTools, viewWaitsFinding, type Follower, type Status } from "@novox/mesh-sdk/views";
|
||||
import type { CatalogRevisionEvent, RevisionInput } from "@novox/mesh-delivery-contracts-mesh-catalog";
|
||||
import { CATALOG_REVISION_EVENT, CatalogView, findingValue, unansweredFinding } from "./catalog.js";
|
||||
import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { SEAT_ONLY, sayOnSeat } from "./seatevents.js";
|
||||
import { FORGE_CONTAINER, seatTools } from "./seat.js";
|
||||
import { run } from "./delivery.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { keepMainRules, mainRulesStep } from "./mainrules.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, headKey, requiredOf, writesGate, type Announced } from "./pulls.js";
|
||||
@@ -60,7 +58,7 @@ import { keepSiblings, readSiblings, siblingsFile, weighSiblings } from "./sibli
|
||||
import { bindInBackground, bindUntilHeld } from "./bind.js";
|
||||
import { announcementOf, weighTrunks } from "./trunk.js";
|
||||
import { ReadAll, readAllTools } from "./readall.js";
|
||||
import { DeliveredWrites, INDEXER_MODULES_EVENTS, INDEXER_MODULES_TOOL, INDEXER_OWNER, writeTools, type ModulesChange } from "./writes.js";
|
||||
import { DeliveredWrites, writeTools } from "./writes.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
|
||||
import { PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "./packages-account.js";
|
||||
|
||||
@@ -363,14 +361,6 @@ const gates = new GateWriter({
|
||||
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
|
||||
});
|
||||
|
||||
// The one writer of mesh/check, from the checker's owed (check.ts; ADR 0521).
|
||||
const checks = new CheckStatuses({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
});
|
||||
|
||||
// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own
|
||||
// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to
|
||||
// reach, nothing is served, as the tools' bundle serves nothing then.
|
||||
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what
|
||||
* gitea_events says of them (webhook.ts). */
|
||||
const pace = new Pace();
|
||||
@@ -391,19 +381,22 @@ let readAllOnce: ReadAll | null = null;
|
||||
const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)));
|
||||
|
||||
// **The merging account writes every repository that builds a registered module** (writes.ts; the operator,
|
||||
// 2026-10-26; issue 1945): which those are is the indexer's state, watched below. Made on first use, as readAll.
|
||||
// 2026-10-26; issue 1945): which those are is the catalog half's word, followed below. Made on first use, as readAll.
|
||||
let writesOnce: DeliveredWrites | null = null;
|
||||
const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(),
|
||||
process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body)));
|
||||
process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), async (type, body) => { await sayOnSeat(type, body as Record<string, unknown>); }));
|
||||
|
||||
// The first view of the indexer's modules, once the module follows it (below); gitea_first_views says where it stands.
|
||||
let modulesView: { follower: ViewFollower; owner: string; tool: string; state: string } | undefined;
|
||||
// The first view of the catalog half (catalog.ts), made once the module follows it (below). Its status is kept in the
|
||||
// module's state first-views, which gitea_first_views reads; gitea_first_view_again asks it again by hand (mesh-sdk views).
|
||||
const firstViews = state<Status>(STATUS_STATE);
|
||||
const followers: Follower[] = [];
|
||||
|
||||
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...viewTools(() => modulesView, (act) => broker().request("seat:mesh-hand-acts.hand-act", act)), ...(gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). The seat git's verbs are
|
||||
// served here, beside the one writer of a head's statuses: the seat's status takes its turn on the head's chain, after the
|
||||
// announcement's pending and a trunk's move, so a recheck's pending never lands over a later verdict (issue 456).
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...statusTools("gitea", "gitea_first_views", "gitea_first_view_again", followers, firstViews,
|
||||
async (act) => { await broker().request("seat:mesh-hand-acts.hand-act", act); }),
|
||||
...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates }) : [])]);
|
||||
|
||||
/** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */
|
||||
const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise<void> => actOn<RepoOf, PullOf>(event, {
|
||||
@@ -434,7 +427,11 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
|
||||
const host = process.env.MESH_GITEA_HOOK_HOST ?? "0.0.0.0";
|
||||
const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery),
|
||||
refused: (reason, from) => pace.refused(reason, from),
|
||||
publish: (event) => emit(event.name, event.body),
|
||||
// The seat git's forge events are said on the seat (seatevents.ts); the forge's others stay the module's own.
|
||||
publish: async (event) => {
|
||||
if (!SEAT_ONLY.has(event.name)) await emit(event.name, event.body);
|
||||
await sayOnSeat(event.name, event.body as Record<string, unknown>);
|
||||
},
|
||||
act: async (event) => {
|
||||
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
|
||||
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
|
||||
@@ -493,7 +490,7 @@ if (gitea) {
|
||||
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
|
||||
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
|
||||
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
|
||||
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below
|
||||
// of the node starts left gitea_events served nowhere. The pass below
|
||||
// runs again once it is listening, so nothing said before is missed; listenForForge says its own failure.
|
||||
const listenOk = listenForForge(client);
|
||||
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
|
||||
@@ -513,26 +510,29 @@ if (gitea) {
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
// The indexer's modules, watched: the watch hands every key's latest value at its start and then each change, so a
|
||||
// repository that builds a registered module is made writable at the start and when its first module is registered
|
||||
// (writes.ts). A read not granted yet is said and asked again until it holds.
|
||||
// The catalog half, followed: its first view hands every registered module's entry at the start and each newer trunk
|
||||
// revision what changed, so a repository that builds a registered module is made writable at the start and when its
|
||||
// first module is registered (writes.ts); a candidate's revision moves nothing, and nothing is granted before the first view.
|
||||
try {
|
||||
const w = writes();
|
||||
// ADR 0615: asked once of the indexer's read tool, then kept by its events; the indexer's state itself is not read.
|
||||
const modules = new ViewFollower({ owner: INDEXER_OWNER, state: "modules", tool: INDEXER_MODULES_TOOL,
|
||||
ask: (after) => broker().request(INDEXER_MODULES_TOOL, after ? { values: true, after } : { values: true }) as Promise<ViewAnswer>,
|
||||
take: (change) => w.take(change as ModulesChange),
|
||||
// Unanswered past its bound is a finding of the module's own, which the condition keeper raises (ADR 0615 §6).
|
||||
raise: async (asked, error) => {
|
||||
const f = unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, asked, error, new Date());
|
||||
await state<unknown>("findings").put(f.key, f.value);
|
||||
},
|
||||
clear: async () => {
|
||||
await state<unknown>("findings").delete(unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, new Date(), "", new Date()).key);
|
||||
} });
|
||||
void bindUntilHeld(INDEXER_MODULES_EVENTS, () => on<ViewEvent>(INDEXER_MODULES_EVENTS, (event) => modules.hear(event.body)));
|
||||
modulesView = { follower: modules, owner: INDEXER_OWNER, tool: INDEXER_MODULES_TOOL, state: "modules" };
|
||||
void modules.start();
|
||||
const findings = state<unknown>("findings");
|
||||
const put = async (f: { key: string; value: unknown }) => { await findings.put(f.key, f.value); };
|
||||
const view = new CatalogView((verb, input: RevisionInput) => broker().request(verb, input), {
|
||||
said: keepStatus(firstViews),
|
||||
changed: (key, value, deleted) => w.take({ key, op: deleted ? "delete" : "put", value }),
|
||||
// Unanswered past its bound, or a revision waited for past mesh-sdk's WAITS_BOUND_MS, is a finding of the module's
|
||||
// own, which the condition keeper raises (ADR 0615 §6; design 47 *A part with no view says it waits*).
|
||||
raise: (s) => put(findingValue(unansweredFinding("gitea", s, new Date()))),
|
||||
clear: async (s) => { await findings.delete(findingValue(unansweredFinding("gitea", s, new Date())).key); },
|
||||
raiseWaits: (s) => put(findingValue(viewWaitsFinding("gitea", s, new Date()))),
|
||||
clearWaits: async (s) => { await findings.delete(findingValue(viewWaitsFinding("gitea", s, new Date())).key); },
|
||||
log: (line) => console.log(`[gitea] ${line}`),
|
||||
});
|
||||
followers.push(view.follower);
|
||||
void bindUntilHeld(CATALOG_REVISION_EVENT, () => on<CatalogRevisionEvent>(CATALOG_REVISION_EVENT, (event) =>
|
||||
// What is no catalog-revision event is said and taken: asked again, it would be no more readable.
|
||||
view.hear(event.body).catch((err) => console.error(`[gitea] ${err instanceof Error ? err.message : err}`))));
|
||||
void view.follower.start().then(() => w.viewed());
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
@@ -570,7 +570,4 @@ if (gitea) {
|
||||
// No consumer is bound since ADR 0571 clause 13 took the controller's checked out of this module; a consumer added later
|
||||
// is bound again until it holds, and not awaited (bind.ts, issue 1793).
|
||||
bindInBackground([], () => console.log("[gitea] watching for new repositories and merged pull requests"));
|
||||
// The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again,
|
||||
// apart from the binds above, so neither waits for the other.
|
||||
void watchOwed((handler) => state<OwedChange["value"]>(CHECKER_OWED).watch((change) => handler(change)), checks);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
package packageseats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
@@ -77,13 +79,91 @@ func DefaultCallers() Callers {
|
||||
VerbVersions: {stdio.AnyCaller}}
|
||||
}
|
||||
|
||||
// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of
|
||||
// the value, so a call never asks (issue 1966).
|
||||
var configured = stdio.HoldVerbCallers("callers", Verbs, DefaultCallers())
|
||||
|
||||
// ConfiguredCallers is the config value `callers` as mesh-config resolved it, held from its watch; unset is DefaultCallers.
|
||||
// ConfiguredCallers is the config value `callers` as mesh-config resolved it, its lines of the package seats' verbs; unset,
|
||||
// or with none of them, is DefaultCallers. It is held from a watch of the value and replaced on each change, so a call
|
||||
// never asks the runtime (issue 1966). The same value carries the lines of the other seat the module holds (git, served
|
||||
// by its TypeScript side), so each holder takes its own verbs' lines and neither refuses the other's.
|
||||
func ConfiguredCallers() (Callers, error) { return configured() }
|
||||
|
||||
var (
|
||||
watchMu sync.Mutex
|
||||
heldMu sync.Mutex
|
||||
heldList Callers
|
||||
heldBad error
|
||||
)
|
||||
|
||||
// configured reads the held list; the first call starts the watch, and a change that cannot be read keeps the list last
|
||||
// read, said and never widened.
|
||||
func configured() (Callers, error) {
|
||||
if l, err := read(); l != nil || err != nil {
|
||||
return l, err
|
||||
}
|
||||
watchMu.Lock()
|
||||
defer watchMu.Unlock()
|
||||
if l, err := read(); l != nil || err != nil {
|
||||
return l, err
|
||||
}
|
||||
if err := stdio.WatchConfig("callers", func(c stdio.ProvisionChange) error {
|
||||
heldMu.Lock()
|
||||
defer heldMu.Unlock()
|
||||
if c.Refused != "" {
|
||||
heldBad = fmt.Errorf("the config value callers is not answered: %s", c.Refused)
|
||||
return nil
|
||||
}
|
||||
l, err := OwnCallers(c.Value)
|
||||
if err != nil {
|
||||
heldBad = fmt.Errorf("the config value callers cannot be read: %w", err)
|
||||
return nil
|
||||
}
|
||||
heldList, heldBad = l, nil
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("the config value callers cannot be read: %w", err)
|
||||
}
|
||||
l, err := read()
|
||||
if l == nil && err == nil {
|
||||
err = errors.New("the config value callers cannot be read: the runtime handed over no value")
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
|
||||
func read() (Callers, error) {
|
||||
heldMu.Lock()
|
||||
defer heldMu.Unlock()
|
||||
if heldList != nil {
|
||||
return heldList, nil
|
||||
}
|
||||
return nil, heldBad
|
||||
}
|
||||
|
||||
// OwnCallers reads the package seats' lines of the config value as the runtime answered it: a JSON object of verb to
|
||||
// callers, or that object written as a string. A value that cannot be read is an error, never the defaults.
|
||||
func OwnCallers(raw json.RawMessage) (Callers, error) {
|
||||
text := strings.TrimSpace(string(raw))
|
||||
var quoted string
|
||||
if json.Unmarshal(raw, "ed) == nil {
|
||||
text = strings.TrimSpace(quoted)
|
||||
}
|
||||
if text == "" || text == "null" {
|
||||
return DefaultCallers(), nil
|
||||
}
|
||||
var all map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(text), &all); err != nil {
|
||||
return nil, fmt.Errorf("callers is not a JSON object of verb to callers: %w", err)
|
||||
}
|
||||
own := map[string]json.RawMessage{}
|
||||
for _, verb := range Verbs {
|
||||
if line, ok := all[verb]; ok {
|
||||
own[verb] = line
|
||||
}
|
||||
}
|
||||
if len(own) == 0 {
|
||||
return DefaultCallers(), nil
|
||||
}
|
||||
b, _ := json.Marshal(own)
|
||||
return stdio.ParseVerbCallers(string(b), Verbs, DefaultCallers())
|
||||
}
|
||||
|
||||
// Publish is a publish as the seats' contract names it (mesh-contracts npmpackageregistry and gopackageregistry share
|
||||
// one shape): what the tools bundle reads from either seat's PublishInput.
|
||||
type Publish struct {
|
||||
|
||||
@@ -303,4 +303,30 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) {
|
||||
t.Errorf("removed %v, said %+v", b.forge.removed, b.said)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the callers value: its own verbs' lines, the other seat's left to it, unset or none of its own the defaults", func(t *testing.T) {
|
||||
manifest := `"{\"publish\":[\"mesh-build-go\"],\"remove\":[\"mesh-delivery-builder\"],\"pull\":[\"mesh-delivery-gate\"]}"`
|
||||
for _, row := range []struct {
|
||||
raw string
|
||||
publish []string
|
||||
fails bool
|
||||
}{
|
||||
{raw: manifest, publish: []string{"mesh-build-go"}},
|
||||
{raw: `{"pull":["mesh-delivery-gate"]}`, publish: DefaultCallers()[VerbPublish]},
|
||||
{raw: `""`, publish: DefaultCallers()[VerbPublish]},
|
||||
{raw: `{"publish":"mesh-build-go"}`, fails: true},
|
||||
{raw: `"not json"`, fails: true},
|
||||
} {
|
||||
got, err := OwnCallers([]byte(row.raw))
|
||||
if row.fails != (err != nil) {
|
||||
t.Fatalf("%s: err %v", row.raw, err)
|
||||
}
|
||||
if !row.fails && strings.Join(got[VerbPublish], ",") != strings.Join(row.publish, ",") {
|
||||
t.Errorf("%s: publish %v, want %v", row.raw, got[VerbPublish], row.publish)
|
||||
}
|
||||
if _, theirs := got["pull"]; theirs {
|
||||
t.Errorf("%s: the seat git's line is not the package seats'", row.raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const KEPT: readonly { readonly name: string; readonly readable?: boolean
|
||||
{ name: "announced" },
|
||||
{ name: "bound", readable: true },
|
||||
{ name: "findings", readable: true },
|
||||
{ name: "first-views" },
|
||||
];
|
||||
|
||||
/** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1,
|
||||
|
||||
@@ -15,19 +15,6 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses
|
||||
* us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false
|
||||
* is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an
|
||||
* older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */
|
||||
export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const file = env.MESH_GITEA_CONFIG_FILE;
|
||||
if (!file) return true;
|
||||
try {
|
||||
const v = (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)["check-gates-merge"];
|
||||
return !(v === false || v === "false");
|
||||
} catch { return true; }
|
||||
}
|
||||
|
||||
/** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */
|
||||
export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> {
|
||||
let said = false;
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// The forge's merge tool, `gitea_merge_pull_request` (novox/hq ADR 0266, issue 381, ADR 0323).
|
||||
//
|
||||
// **Served from the watcher's bundle, beside the merge check's one writer** (index.ts, gate.ts; novox/hq issue 456):
|
||||
// when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and
|
||||
// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked.
|
||||
// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain.
|
||||
//
|
||||
// **It judges mesh/check whether or not the base requires it** (issue 1301; ADR 0447 §1, §4; ADR 0534 §4): the checker's
|
||||
// verdict, whose line names the trunk it judged ("pass <head12> on <trunk12>"), must be a pass against the trunk's head.
|
||||
// The forge requires no status to merge (ADR 0534 §1); where a base still requires one of the controller's old statuses,
|
||||
// until the forge's holder takes it off, that status judges, as before. A mesh/check of another trunk is refused and
|
||||
// nothing is asked: the checker sets it out of date on the trunk's move and checks it again. Where the base requires no
|
||||
// status, only a head the operator's Approve names (approved_heads) merges (ADR 0534 §4, ADR 0421 §9).
|
||||
|
||||
import { announce } from "./announce.js";
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import type { CommitStatus, GiteaClient } from "./client.js";
|
||||
import { mergeableUnder, newestByContext } from "./checks.js";
|
||||
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
|
||||
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
||||
import { checkGatesMerge } from "./mainrules.js";
|
||||
import { announcementOf, askedWords, outOfDateWords, sameCommit, twelve, verdictAtTrunk, waitsForComposed } 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. */
|
||||
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
|
||||
|
||||
/** What the merge tool reaches beside the forge: the merge check's one writer, how an event is said, the module's
|
||||
* state directory, and the clock. */
|
||||
export interface MergeDeps {
|
||||
gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> };
|
||||
emit?: (type: string, body: unknown) => Promise<void>;
|
||||
stateDir?: string;
|
||||
now?: () => Date;
|
||||
/** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */
|
||||
checkGates?: () => boolean;
|
||||
}
|
||||
|
||||
export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition {
|
||||
const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
|
||||
now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) };
|
||||
return {
|
||||
name: "gitea_merge_pull_request",
|
||||
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
|
||||
"its base refuses this account's push, its mesh/check passed judged against the trunk's head as it is now, and, " +
|
||||
"where the base requires no status (novox/hq ADR 0534), the operator's Approve names it in approved_heads; " +
|
||||
"optionally deleting the source branch afterwards. Refused " +
|
||||
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
|
||||
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
|
||||
"the trunk's head is asked once: ask again at the same head " +
|
||||
"once it passes — the tool never merges by itself.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 12 characters: " +
|
||||
"refused unless it is the pull request's head" },
|
||||
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);
|
||||
const repo = checkName("name", args.repo);
|
||||
const number = Number(args.number);
|
||||
const method = args.method ? String(args.method) : "merge";
|
||||
if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`);
|
||||
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
||||
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the
|
||||
// forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge
|
||||
// applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that
|
||||
// cannot be read is no rule passed.
|
||||
// **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d,
|
||||
// force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the
|
||||
// head was and nothing told it which head the review had approved. The caller names it; a head that moved
|
||||
// since is refused, and the forge is handed the same head, so a push between this read and the merge is
|
||||
// refused by the forge too.
|
||||
const named = args.head === undefined ? "" : String(args.head).trim().toLowerCase();
|
||||
if (!named) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` +
|
||||
`that commit can land (novox/hq issue 381). Nothing was merged`);
|
||||
}
|
||||
// Twelve characters, not seven: an author who force-pushes can make a commit whose first seven match the
|
||||
// reviewed one in seconds, and the review would then merge it. Twelve cannot be matched that way.
|
||||
if (!REVIEWED_HEAD.test(named)) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} does not name one commit — give its ` +
|
||||
`sha, or at least its first 12 characters. Nothing was merged`);
|
||||
}
|
||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||
if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`);
|
||||
if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`);
|
||||
if (!pull.head_sha.toLowerCase().startsWith(named)) {
|
||||
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 refuses this account's push: a pull request whose base was moved to a
|
||||
// branch nothing guards would land where anyone pushes (novox/hq ADR 0266, ADR 0534 §4).
|
||||
if (!rule || rule.enable_push !== false) {
|
||||
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule refusing ` +
|
||||
`a push guards: a tool merges only into such a branch (novox/hq ADR 0266, ADR 0534). Nothing was merged`);
|
||||
}
|
||||
// **The verdict must be judged against the trunk's head** (novox/hq ADR 0323 §5, issue 341): a check judges the
|
||||
// head merged into a named trunk commit, and a merge of another trunk is not the one that check judged. Compared
|
||||
// here at the moment the merge is asked, not left to the statuses the trunk's move resets a pass later. When they
|
||||
// differ nothing is merged, and the head's check against the trunk's head is asked once — the announcement a new
|
||||
// head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes.
|
||||
const trunkHead = await gitea.branchHead(owner, repo, pull.base);
|
||||
const checks = await gitea.headChecks(owner, repo, pull.head_sha);
|
||||
const required = requiredOf(rule) ?? [];
|
||||
// **The forge requires no status; the operator's Approve and mesh/check do** (ADR 0534 §4): where the base requires
|
||||
// no status, only a head the Approve names merges, and mesh/check judges it as where it was required.
|
||||
if (required.length === 0 && !approved) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: ${pull.base} requires no status, so a merge into it ` +
|
||||
`lands only a head the operator's Approve names: give approved_heads with ${owner}/${repo}@<full sha> ` +
|
||||
`(novox/hq ADR 0534, ADR 0421 §9). Nothing was merged`);
|
||||
}
|
||||
const kept = maintained(required);
|
||||
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
|
||||
// **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's
|
||||
// Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without
|
||||
// approved_heads the check still judges.
|
||||
if (byCheck && (deps.checkGates() || !approved)) {
|
||||
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
|
||||
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
|
||||
const check = checks[CHECK_STATUS];
|
||||
const at = verdictAtTrunk(checks, trunkHead, CHECK_STATUS);
|
||||
if (!check || check.state === "pending") {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ` +
|
||||
`${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` +
|
||||
`${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`);
|
||||
}
|
||||
if (check.state !== "success") {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ${check.state} ` +
|
||||
`(${check.description ?? ""}). A pull request merges once its check passes (novox/hq ADR 0534). Nothing was merged`);
|
||||
}
|
||||
if (!at.current) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` +
|
||||
`${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` +
|
||||
`${pull.base} is now ${twelve(trunkHead)}. The checker checks it again against the trunk's head (novox/hq ` +
|
||||
`ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`);
|
||||
}
|
||||
}
|
||||
// Where the base still requires the controller's status (mesh/merge-gate, only on a
|
||||
// revert), the first it requires judges, as before (ADR 0323 §5).
|
||||
const judge = kept[0] ?? CHECK_CONTEXT;
|
||||
const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false };
|
||||
if (!at.current) {
|
||||
// A verdict put out of date by a sibling's move says so in its own words (ADR 0382 §5); it is asked again as a
|
||||
// trunk's move is, and a failed ask puts those words back, not the trunk's.
|
||||
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
|
||||
const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})`
|
||||
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
|
||||
const head = twelve(pull.head_sha), now = twelve(trunkHead);
|
||||
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
|
||||
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
|
||||
// refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited.
|
||||
if (waitsForComposed(checks[judge])) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[judge]?.description}; the ` +
|
||||
`verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` +
|
||||
`(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`);
|
||||
}
|
||||
if (at.onItsWay) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
||||
`${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` +
|
||||
`asked again. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
|
||||
`(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`);
|
||||
}
|
||||
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
|
||||
// announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather
|
||||
// than wait for ever on a check nobody asked.
|
||||
// The statuses the base requires of the controller's (issue 1301): the one that judges, and the repository's own
|
||||
// check beside it where a revert has the base require both.
|
||||
const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT]));
|
||||
// Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new
|
||||
// check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older.
|
||||
const say = async (description: string) => {
|
||||
for (const context of contexts) {
|
||||
await deps.gates.rechecked(owner, repo, String(pull.head_sha), { state: "pending", context, description,
|
||||
...(pull.html_url ? { target_url: pull.html_url } : {}) });
|
||||
}
|
||||
};
|
||||
await say(askedWords(deps.now(), trunkHead));
|
||||
try {
|
||||
const cloneURL = (await gitea.getRepo(owner, repo)).clone_url;
|
||||
await deps.emit("pull.updated", await announcementOf(gitea, owner, repo, cloneURL, pull));
|
||||
} catch (err) {
|
||||
await say(siblingWords ?? outOfDateWords(at.judged, trunkHead)).catch(() => {});
|
||||
throw new Error(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
||||
`${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`);
|
||||
}
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
||||
`${now}. The check of head ${head} merged into trunk ${now} was asked. Ask again at ${head} once both ` +
|
||||
`statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` +
|
||||
`move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` +
|
||||
`again with mesh-delivery.recheck. Nothing was merged`);
|
||||
}
|
||||
const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses);
|
||||
const verdict = mergeableUnder(pull.base, rule, statuses);
|
||||
if (verdict.mergeable !== true) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` +
|
||||
`its checks succeed; no tool merges past them (novox/hq ADR 0266)`);
|
||||
}
|
||||
// **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6): the
|
||||
// siblings its verdict names are the holder's record (siblings.ts); a status that says it read siblings with no record
|
||||
// of them is no verdict to merge on, never read as none.
|
||||
// Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the
|
||||
// Approve's heads above; a record the controller's verdicts left before the switch judges nothing there.
|
||||
const heard = byCheck ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
|
||||
const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : [];
|
||||
if (!siblings.length && !byCheck &&
|
||||
namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` +
|
||||
`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) },
|
||||
{ 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 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);
|
||||
// The merge commit only exists now; answered so the caller can follow what is built from it.
|
||||
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
|
||||
// From here the merge has happened. Nothing below may read as if it had not: a read-back that fails or cannot
|
||||
// be done is said beside merged: true, and only a proven other head is an error — one that says the merge happened.
|
||||
const answer: Record<string, unknown> = { merged: true, number, method, head: pull.head_sha, deleted_branch: deleteBranch };
|
||||
let landed: string | undefined;
|
||||
try {
|
||||
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||
answer.merge_commit_sha = merged.merge_commit_sha;
|
||||
// What landed, read back from the commit the merge made rather than from the forge's record of the pull
|
||||
// request, which is the very thing a race would leave behind. A merge commit's second parent is the head it
|
||||
// joined; a fast-forward lands the head itself. A squash or a rebase makes new commits that name no head.
|
||||
if (!merged.merge_commit_sha) {
|
||||
answer.read_back = "the forge named no merge commit, so what landed cannot be read back";
|
||||
} else if (method === "merge") {
|
||||
const parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha);
|
||||
if (parents.length === 2) landed = parents[1];
|
||||
else answer.read_back = `the merge commit has ${parents.length} parent(s), not two: what landed cannot be read back`;
|
||||
} else if (method === "fast-forward-only") {
|
||||
// That the forge names the head itself as a fast-forward's merge commit is assumed, not seen on this forge:
|
||||
// a different sha is said, not raised as another head having landed.
|
||||
if (merged.merge_commit_sha === pull.head_sha) landed = merged.merge_commit_sha;
|
||||
else answer.read_back = `the forge named ${merged.merge_commit_sha.slice(0, 12)} as this fast-forward's commit, ` +
|
||||
`not the head: that the head landed could not be confirmed`;
|
||||
} else {
|
||||
answer.read_back = `a ${method} makes new commits that name no head: what landed cannot be read back`;
|
||||
}
|
||||
} catch (err) {
|
||||
answer.read_back = `the merge happened, but reading back what landed failed: ${(err as Error).message}`;
|
||||
}
|
||||
// Loud, not a field beside merged: true that a caller reading only that would miss. The merge cannot be taken
|
||||
// back by this tool; what must not happen is that it reads as the merge that was asked for.
|
||||
if (landed !== undefined && landed !== pull.head_sha) {
|
||||
throw new Error(`${owner}/${repo}#${number} WAS merged (${answer.merge_commit_sha}), but the forge merged ` +
|
||||
`${landed.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} it was handed and you reviewed: a commit nobody ` +
|
||||
`reviewed has landed. Read it before anything is built from it (novox/hq issue 381)`);
|
||||
}
|
||||
if (landed !== undefined) answer.landed = landed;
|
||||
return answer;
|
||||
},
|
||||
};
|
||||
}
|
||||
+23
-23
@@ -64,16 +64,10 @@
|
||||
],
|
||||
"emits": [
|
||||
"repo.created",
|
||||
"repo.writable",
|
||||
"issue.opened",
|
||||
"pull.merged",
|
||||
"pull.updated",
|
||||
"pull.closed",
|
||||
"forge.push",
|
||||
"forge.create",
|
||||
"forge.delete",
|
||||
"forge.fork",
|
||||
"forge.status",
|
||||
"forge.pull-request.opened",
|
||||
"forge.pull-request.reopened",
|
||||
"forge.pull-request.synchronized",
|
||||
@@ -173,7 +167,21 @@
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"scope": "mesh"
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"pull",
|
||||
"open-pulls",
|
||||
"tags",
|
||||
"trees",
|
||||
"file",
|
||||
"contains",
|
||||
"statuses",
|
||||
"status",
|
||||
"comment",
|
||||
"view",
|
||||
"note",
|
||||
"merge-pull"
|
||||
]
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
@@ -231,12 +239,6 @@
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"check-gates-merge": {
|
||||
"type": "boolean",
|
||||
"description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 \u00a74). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.",
|
||||
"trusted": true,
|
||||
"default": true
|
||||
},
|
||||
"main-mergers": {
|
||||
"type": "list",
|
||||
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 \u00a72): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
|
||||
@@ -249,8 +251,8 @@
|
||||
"type": "string",
|
||||
"trusted": true,
|
||||
"runtime": true,
|
||||
"default": "",
|
||||
"description": "per verb of the two package seats, the callers that may call it (JSON object of verb to callers; * every proved caller, operator the operator's session); empty is design 51's list: publish and publish-part mesh-build-go and mesh-build-node, remove mesh-delivery-builder, versions anyone"
|
||||
"default": "{\"publish\":[\"mesh-build-go\",\"mesh-build-node\"],\"publish-part\":[\"mesh-build-go\",\"mesh-build-node\"],\"remove\":[\"mesh-delivery-builder\"],\"versions\":[\"*\"],\"pull\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-gate\"],\"open-pulls\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"tags\":[\"mesh-delivery-forge-bridge\"],\"trees\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"file\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"contains\":[\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"statuses\":[\"mesh-delivery-gate\"],\"status\":[\"mesh-delivery-forge-bridge\"],\"comment\":[\"mesh-delivery-forge-bridge\"],\"view\":[\"mesh-delivery-forge-bridge\"],\"note\":[\"mesh-delivery-forge-bridge\"],\"merge-pull\":[\"mesh-delivery-forge-bridge\"]}",
|
||||
"description": "per verb of the seats the module holds, the callers that may call it (JSON object of verb to callers; * every proved caller, operator the operator's session); each holder reads the lines of its own verbs. The package seats' lines are design 51's (empty is that list for them); the seat git's are design 47's: its reads the parts that declare them in invokes, its acts (status, comment, view, note, merge-pull) the forge bridge alone, and a verb of git with no line is called by no one"
|
||||
}
|
||||
},
|
||||
"facts": {
|
||||
@@ -379,8 +381,7 @@
|
||||
"takes-config": [
|
||||
"url",
|
||||
"keep-main-rules",
|
||||
"main-mergers",
|
||||
"check-gates-merge"
|
||||
"main-mergers"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -629,7 +630,8 @@
|
||||
"state": [
|
||||
"bound",
|
||||
"announced",
|
||||
"findings"
|
||||
"findings",
|
||||
{ "name": "first-views", "history": 1 }
|
||||
],
|
||||
"reads": [
|
||||
"mesh-provisioner.plans",
|
||||
@@ -637,16 +639,14 @@
|
||||
"ssh-client.keys",
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys",
|
||||
"mesh-build-image.keys",
|
||||
"mesh-delivery-checker.owed",
|
||||
"mesh-delivery-gate.source"
|
||||
"mesh-build-image.keys"
|
||||
],
|
||||
"invokes": [
|
||||
"mesh-delivery-indexer.indexer_read_modules",
|
||||
"seat:mesh-catalog.revision",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"mesh-delivery-builder.builder_versions"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-indexer.views.modules"
|
||||
"mesh-catalog.catalog-revision"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
"@novox/mesh-delivery-contracts-git": "^0.1.0",
|
||||
"@novox/mesh-delivery-contracts-mesh-catalog": "^0.1.0",
|
||||
"@novox/mesh-sdk": "^0.1.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -206,40 +206,16 @@ function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: bo
|
||||
return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` };
|
||||
}
|
||||
|
||||
/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */
|
||||
export type SourceRead = () => Promise<Record<string, unknown>>;
|
||||
/** Where a repository's trunk is said to come from: the mesh's one trunk per repository (novox/hq design 47, *The trunk
|
||||
* is what runs*), the branch main. No other module's state is read for it (ADR 0596 §2): the catalog's trunk heads name
|
||||
* a repository and its commit, not a branch, so a second trunk would first be a branch in the seat mesh-catalog's
|
||||
* revision. */
|
||||
export const TRUNK_FROM = "the mesh's one trunk of every repository (design 47)";
|
||||
|
||||
/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each
|
||||
* repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */
|
||||
export const TRUNK_SOURCE = "mesh-delivery-gate.source";
|
||||
|
||||
/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's
|
||||
* source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and
|
||||
* `from` says which. The one place trunks are read: several trunks later change only this. */
|
||||
export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> {
|
||||
const full = `${owner}/${repo}`;
|
||||
let src: Record<string, unknown>;
|
||||
try {
|
||||
src = await read();
|
||||
} catch (err) {
|
||||
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` };
|
||||
}
|
||||
const names = Object.keys(src).filter((k) => {
|
||||
const heads = (src[k] as { heads?: Record<string, unknown> } | null)?.heads;
|
||||
return !!heads && typeof heads === "object" && full in heads;
|
||||
}).sort();
|
||||
if (names.length) return { names, from: TRUNK_SOURCE };
|
||||
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` };
|
||||
}
|
||||
|
||||
/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */
|
||||
export function sourceFromBus(get: (name: string) => { keys(): Promise<string[]>; get(k: string): Promise<{ value: unknown } | null> }): SourceRead {
|
||||
return async () => {
|
||||
const st = get(TRUNK_SOURCE);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null;
|
||||
return out;
|
||||
};
|
||||
/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): main, and `from` says
|
||||
* why. The one place trunks are named: a later trunk changes only this. */
|
||||
export function trunksOf(): { names: string[]; from: string } {
|
||||
return { names: [MAIN_BRANCH], from: TRUNK_FROM };
|
||||
}
|
||||
|
||||
/** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// The forge module holds the seat `git` (novox/hq design 47 *The seat git*, ADR 0596 §1, §2): every read and act a
|
||||
// delivery part makes of the forge is a verb of the seat, served here as a tool `git.<verb>` against the seat's generated
|
||||
// contract, and no part names this module's own tools.
|
||||
//
|
||||
// **Who may call which verb** is the holder's trusted config value `callers` (ADR 0627 §2), the one value the module's
|
||||
// package seats read too: each holder takes the lines of the verbs it serves, so neither refuses the other's. Unset, no
|
||||
// one may call a verb of `git`: the list is the manifest's, never a list in this code. The caller is the one the tool
|
||||
// runner proved in the call's `_meta` (mesh-sdk `callers.callerOf`), never an argument.
|
||||
//
|
||||
// **A failed read answers an error, never an empty answer**: the forge's 404 is the kind `not-found`, its refusal (another
|
||||
// 4xx, or a refusal of this holder) the kind `refused`, a forge that failed or did not answer the kind `failed`; an empty list or an absent file is answered
|
||||
// only when the forge said so.
|
||||
//
|
||||
// **The acts carry the caller's id** (status, comment, view, note, merge-pull): an id the pull request or the note already
|
||||
// holds is answered `held` and nothing is done again; `merge-pull` merges the head the caller names and judges no check
|
||||
// (design 47 §314): only into a base whose protection refuses a push, and only that head.
|
||||
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { callerOf, refusal, parseVerbCallers, type VerbCallers } from "@novox/mesh-sdk/callers";
|
||||
import { config } from "@novox/mesh-sdk/runtime";
|
||||
import {
|
||||
seat as GIT, verbs as GIT_VERBS,
|
||||
type ContainsAnswer, type CommentAnswer, type ErrorKind, type FileAnswer, type MergePullAnswer, type Merging, type NoteAnswer,
|
||||
type OpenPull, type OpenPullsAnswer, type Pull, type PullAnswer, type Status, type StatusAnswer, type StatusesAnswer,
|
||||
type TagsAnswer, type TreesAnswer, type ViewAnswer,
|
||||
} from "@novox/mesh-delivery-contracts-git";
|
||||
import type { CommitStatus, GiteaClient, GiteaComment, GiteaPull } from "./client.js";
|
||||
import { MAX_FILE_BYTES } from "./client.js";
|
||||
import { containedFrom, newestByContext } from "./checks.js";
|
||||
import { Refused, checkName } from "./guard.js";
|
||||
import { appendNote, viewBody, viewComment, type Runner } from "./delivery.js";
|
||||
|
||||
/** The verbs of the seat, in its definition's order. */
|
||||
export const VERBS: readonly string[] = Object.values(GIT_VERBS);
|
||||
|
||||
/** The config value whose lines per verb say who may call it. */
|
||||
export const CALLERS_KEY = "callers";
|
||||
|
||||
/** How many tags a page of `tags` holds. */
|
||||
export const TAGS_PAGE = 50;
|
||||
|
||||
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). Its name
|
||||
* is the manifest's container's; no variable names it (a MESH_* word nothing sets is refused, checks/environment). */
|
||||
export const FORGE_CONTAINER = "gitea";
|
||||
|
||||
/** The writer every status of a head takes its turn on (gate.ts): the seat's status lands after every write of the head
|
||||
* already asked for, so a recheck's pending never lands over a later verdict (novox/hq issue 456). */
|
||||
export interface HeadStatuses {
|
||||
status(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/** What the holder reaches beside the forge's API: git in the forge's container (the note), the head's one writer of its
|
||||
* statuses, and the callers' list. */
|
||||
export interface SeatDeps {
|
||||
run: Runner;
|
||||
container: string;
|
||||
statuses: HeadStatuses;
|
||||
/** The config value `callers` as the runtime answers it; the runtime's own config read by default. */
|
||||
callers?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The lines of `raw` (the config value, an object or that object as a string) for the seat's verbs; unset is none. */
|
||||
export function gitCallers(raw: unknown): VerbCallers {
|
||||
if (raw === undefined || raw === null || raw === "") return {};
|
||||
let all: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
all = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`callers is not a JSON object of verb to callers: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
if (!all || typeof all !== "object" || Array.isArray(all)) throw new Error("callers is not a JSON object of verb to callers");
|
||||
const mine = Object.fromEntries(Object.entries(all as Record<string, unknown>).filter(([verb]) => VERBS.includes(verb)));
|
||||
return parseVerbCallers(JSON.stringify(mine), VERBS, {});
|
||||
}
|
||||
|
||||
/** A failure as the contract says it: the forge's or this holder's refusal, or a forge that failed. */
|
||||
export function failure(err: unknown): { error: string; "error-kind": ErrorKind } {
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
// The forge's 404: what was asked about (a repository, a commit, a pull request, a branch, a tag) does not exist.
|
||||
if (/^Gitea API [^:]*: 404 /.test(text)) return { error: text, "error-kind": "not-found" };
|
||||
const refused = err instanceof Refused || /^Gitea API [^:]*: 4\d\d /.test(text) || /^Gitea API: /.test(text);
|
||||
return { error: text, "error-kind": refused ? "refused" : "failed" };
|
||||
}
|
||||
|
||||
/** owner/name of a `repository` argument, each checked as a name (guard.ts). */
|
||||
export function repositoryOf(raw: unknown): { owner: string; repo: string; full: string } {
|
||||
const parts = String(raw ?? "").split("/");
|
||||
if (parts.length !== 2) throw new Refused(`repository ${JSON.stringify(raw)} is not <owner>/<name>`);
|
||||
const owner = checkName("owner", parts[0]), repo = checkName("name", parts[1]);
|
||||
return { owner, repo, full: `${owner}/${repo}` };
|
||||
}
|
||||
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const STATES = new Set(["pending", "success", "failure", "error", "warning"]);
|
||||
function commitOf(name: string, raw: unknown): string {
|
||||
const sha = String(raw ?? "").trim();
|
||||
if (!SHA.test(sha)) throw new Refused(`${name} is a commit's full 40-hex sha`);
|
||||
return sha;
|
||||
}
|
||||
function numberOf(raw: unknown): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 1) throw new Refused(`${JSON.stringify(raw)} is not a pull request's number`);
|
||||
return n;
|
||||
}
|
||||
function idOf(raw: unknown): string {
|
||||
const id = String(raw ?? "").trim();
|
||||
if (!id || /[\s<>]/.test(id)) throw new Refused("id is the act's id: one word");
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The marker an act's id leaves in a comment, so an id said again is found and skipped. */
|
||||
export function actMarker(id: string): string {
|
||||
return `<!-- mesh-act:${id} -->`;
|
||||
}
|
||||
|
||||
/** A pull request as the contract says it; never its description (ADR 0659 §1). */
|
||||
export function pullOf(full: string, p: GiteaPull): Pull {
|
||||
const state = p.merged ? "merged" : p.state === "open" ? "open" : "closed";
|
||||
return {
|
||||
repository: full, number: p.number, base: String(p.base ?? ""), branch: String(p.head ?? ""), head: String(p.head_sha ?? ""),
|
||||
state, ...(p.user ? { author: p.user } : {}), ...(p.merge_commit_sha ? { "merge-commit": p.merge_commit_sha } : {}),
|
||||
...(p.merged_at ? { "merged-at": p.merged_at } : {}), ...(p.title ? { title: p.title } : {}),
|
||||
...(p.html_url ? { url: p.html_url } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function statusOf(s: { context: string; state: string; description?: string; target_url?: string; updated_at?: string; creator?: string }): Status {
|
||||
return { context: s.context, state: s.state, ...(s.description ? { description: s.description } : {}),
|
||||
...(s.target_url ? { target: s.target_url } : {}), ...(s.updated_at ? { at: s.updated_at } : {}),
|
||||
...(s.creator ? { by: s.creator } : {}) };
|
||||
}
|
||||
|
||||
/** What the forge would merge now, for an open pull request: whole, or the verb's error. */
|
||||
async function mergingOf(gitea: GiteaClient, owner: string, repo: string, p: GiteaPull): Promise<Merging> {
|
||||
if (p.mergeable === undefined) throw new Error(`the forge did not say whether ${owner}/${repo}#${p.number} merges now`);
|
||||
const rule = await gitea.appliedProtection(owner, repo, String(p.base ?? ""));
|
||||
const writes = (await gitea.getRepo(owner, repo)).writes;
|
||||
if (writes === undefined) throw new Error(`the forge did not say whether its account writes ${owner}/${repo}`);
|
||||
const required = rule?.enable_status_check ? [...(rule.status_check_contexts ?? [])] : [];
|
||||
return { mergeable: p.mergeable, protected: !!rule, required, writes };
|
||||
}
|
||||
|
||||
/** The seat's verbs as tools `git.<verb>`, each judging its caller first and answering in the seat's contract. */
|
||||
export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[] {
|
||||
const listOf = deps.callers ?? (() => config(CALLERS_KEY));
|
||||
const verb = (name: string, description: string, act: (args: Record<string, unknown>) => Promise<object>): ToolDefinition => ({
|
||||
name: `${GIT}.${name}`,
|
||||
description,
|
||||
input: {},
|
||||
run: async (args: Record<string, unknown>) => {
|
||||
try {
|
||||
const why = refusal(gitCallers(await listOf()), name, callerOf(args));
|
||||
if (why) return failure(new Refused(why.message));
|
||||
return await act(args);
|
||||
} catch (err) {
|
||||
return failure(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
verb(GIT_VERBS.pull, "The seat git's pull: one pull request by its repository and number or by its merge commit, and for an open one what the forge would merge now.",
|
||||
async (args): Promise<PullAnswer> => {
|
||||
const { owner, repo, full } = repositoryOf(args.repository);
|
||||
const p = args["merge-commit"] !== undefined
|
||||
? await gitea.pullOfCommit(owner, repo, commitOf("merge-commit", args["merge-commit"]))
|
||||
: await gitea.getPullRequest(owner, repo, numberOf(args.number));
|
||||
const pull = pullOf(full, p);
|
||||
return pull.state === "open" ? { pull, merging: await mergingOf(gitea, owner, repo, p) } : { pull };
|
||||
}),
|
||||
verb(GIT_VERBS.openPulls, "The seat git's open-pulls: every open pull request of every repository, with the statuses its base requires and those its head has.",
|
||||
async (): Promise<OpenPullsAnswer> => {
|
||||
const pulls: OpenPull[] = (await gitea.openPulls()).map((o) => ({
|
||||
pull: { repository: `${o.owner}/${o.repo}`, number: o.number, base: o.base, branch: String(o.head ?? ""), head: o.head_sha,
|
||||
state: "open", ...(o.html_url ? { url: o.html_url } : {}) },
|
||||
required: [...o.required],
|
||||
checks: Object.fromEntries(Object.entries(o.checks ?? {}).map(([context, c]) =>
|
||||
[context, statusOf({ context, state: c.state, description: c.description, updated_at: c.updated_at })])),
|
||||
}));
|
||||
return { pulls };
|
||||
}),
|
||||
verb(GIT_VERBS.tags, "The seat git's tags: a repository's tags, a page at a time, newest first; more when another page exists.",
|
||||
async (args): Promise<TagsAnswer> => {
|
||||
const { owner, repo, full } = repositoryOf(args.repository);
|
||||
const page = args.page === undefined ? 1 : Number(args.page);
|
||||
const prefix = String(args.prefix ?? "");
|
||||
// One more than the page is asked, so whether another page exists is the forge's answer, not a guess.
|
||||
const got = await gitea.tags(owner, repo, page, TAGS_PAGE + 1);
|
||||
const more = got.length > TAGS_PAGE;
|
||||
const tags = got.slice(0, TAGS_PAGE).filter((t) => t.startsWith(prefix));
|
||||
return { repository: full, page, bound: TAGS_PAGE, tags, ...(more ? { more } : {}) };
|
||||
}),
|
||||
verb(GIT_VERBS.trees, "The seat git's trees: the trees of one commit, with files and paths when asked; a listing the forge cut is refused.",
|
||||
async (args): Promise<TreesAnswer> => {
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const commit = commitOf("commit", args.commit);
|
||||
const got = await gitea.commitTrees(owner, repo, commit, args.paths as Record<string, string[]> | undefined,
|
||||
args.files as string[] | undefined);
|
||||
return { commit, ...got };
|
||||
}),
|
||||
verb(GIT_VERBS.file, "The seat git's file: one file at one commit, branch or tag, decoded; over 512 KiB refused; absent only when the forge said so.",
|
||||
async (args): Promise<FileAnswer> => {
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const path = String(args.path ?? "");
|
||||
const ref = args.ref === undefined ? undefined : String(args.ref);
|
||||
try {
|
||||
const f = await gitea.getFile(owner, repo, path, ref);
|
||||
if (f.size > MAX_FILE_BYTES) throw new Refused(`${path} is ${f.size} bytes, over ${MAX_FILE_BYTES / 1024} KiB`);
|
||||
return { path: f.path, ...(ref ? { ref } : {}), blob: f.sha, size: f.size, content: f.content };
|
||||
} catch (err) {
|
||||
if (err instanceof Error && /^Gitea API [^:]*\/contents\/[^:]*: 404 /.test(err.message)) return { path, ...(ref ? { ref } : {}), absent: true };
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
verb(GIT_VERBS.contains, "The seat git's contains: whether one commit contains another, an ancestor of it or the same.",
|
||||
async (args): Promise<ContainsAnswer> => {
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const commit = commitOf("commit", args.commit), within = commitOf("in", args.in);
|
||||
const { contains, ahead } = containedFrom(await gitea.compare(owner, repo, within, commit));
|
||||
return { commit, in: within, contains, ahead };
|
||||
}),
|
||||
verb(GIT_VERBS.statuses, "The seat git's statuses: the statuses a commit carries, the newest of each context.",
|
||||
async (args): Promise<StatusesAnswer> => {
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const commit = commitOf("commit", args.commit);
|
||||
const combined = await gitea.combinedStatus(owner, repo, commit);
|
||||
return { commit, combined: combined.state, statuses: newestByContext(combined.statuses).map(statusOf) };
|
||||
}),
|
||||
verb(GIT_VERBS.status, "The seat git's status: an act of the forge bridge, a commit status set in its turn after every write of the head already asked for; the same id said again sets it again to the same.",
|
||||
async (args): Promise<StatusAnswer> => {
|
||||
const id = idOf(args.id);
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const commit = commitOf("commit", args.commit);
|
||||
const state = String(args.state ?? "");
|
||||
if (!STATES.has(state)) throw new Refused(`state ${JSON.stringify(state)} is none of ${[...STATES].join(", ")}`);
|
||||
await deps.statuses.status(owner, repo, commit, { state: state as CommitStatus["state"], context: String(args.context ?? ""),
|
||||
description: String(args.description ?? ""), ...(args.target ? { target_url: String(args.target) } : {}) });
|
||||
return { id, performed: true };
|
||||
}),
|
||||
verb(GIT_VERBS.comment, "The seat git's comment: an act of the forge bridge, a comment on a pull request carrying the act's id; an id it already holds is held.",
|
||||
async (args): Promise<CommentAnswer> => {
|
||||
const id = idOf(args.id);
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const number = numberOf(args.number);
|
||||
if (holds(await gitea.listComments(owner, repo, number), id)) return { id, held: true, performed: true };
|
||||
await gitea.addComment(owner, repo, number, `${String(args.text ?? "")}\n\n${actMarker(id)}`);
|
||||
return { id, performed: true };
|
||||
}),
|
||||
verb(GIT_VERBS.view, "The seat git's view: an act of the forge bridge, the delivery's one view comment kept on a pull request; an id it already holds is held.",
|
||||
async (args): Promise<ViewAnswer> => {
|
||||
const id = idOf(args.id);
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const number = numberOf(args.number);
|
||||
const existing = viewComment(await gitea.listComments(owner, repo, number));
|
||||
if (existing && existing.body.includes(actMarker(id))) return { id, held: true, performed: true };
|
||||
const body = `${viewBody(String(args.text ?? ""))}\n${actMarker(id)}`;
|
||||
if (existing) await gitea.editComment(owner, repo, existing.id, body);
|
||||
else await gitea.addComment(owner, repo, number, body);
|
||||
return { id, performed: true };
|
||||
}),
|
||||
verb(GIT_VERBS.note, "The seat git's note: an act of the forge bridge, one line on a commit's note under refs/notes/mesh-plan carrying the act's id; an id it already holds is held.",
|
||||
async (args): Promise<NoteAnswer> => {
|
||||
const id = idOf(args.id);
|
||||
const { owner, repo } = repositoryOf(args.repository);
|
||||
const commit = commitOf("commit", args.commit);
|
||||
const said = await appendNote(deps.run, deps.container, owner, repo, commit, "mesh-plan", `${String(args.line ?? "")} [${id}]`, id);
|
||||
return said.added ? { id, performed: true } : { id, held: true, performed: true };
|
||||
}),
|
||||
verb(GIT_VERBS.mergePull, "The seat git's merge-pull: an act of the forge bridge, a pull request merged at the head the caller names; it judges no check.",
|
||||
async (args): Promise<MergePullAnswer> => mergePull(gitea, args)),
|
||||
];
|
||||
}
|
||||
|
||||
function holds(comments: GiteaComment[], id: string): boolean {
|
||||
return comments.some((c) => String(c.body ?? "").includes(actMarker(id)));
|
||||
}
|
||||
|
||||
/** merge-pull (design 47 §314): only the head the caller names, only into a base that takes no push, no check judged. */
|
||||
export async function mergePull(gitea: GiteaClient, args: Record<string, unknown>): Promise<MergePullAnswer> {
|
||||
const id = idOf(args.id);
|
||||
const { owner, repo, full } = repositoryOf(args.repository);
|
||||
const number = numberOf(args.number);
|
||||
const head = commitOf("head", args.head);
|
||||
const no = (why: string): MergePullAnswer => ({ id, refused: `${full}#${number} is not merged: ${why}` });
|
||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||
if (pull.merged) {
|
||||
// Already merged at that head: performed (the act said again after its answer was lost); at another, refused.
|
||||
return pull.head_sha === head ? { id, performed: true, held: true, ...(pull.merge_commit_sha ? { "merge-commit": pull.merge_commit_sha } : {}) }
|
||||
: no(`it was merged at ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`);
|
||||
}
|
||||
if (pull.state !== "open") return no("it is closed");
|
||||
if (pull.head_sha !== head) {
|
||||
return no(`its head is ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`);
|
||||
}
|
||||
const rule = await gitea.appliedProtection(owner, repo, String(pull.base ?? ""));
|
||||
if (!rule || rule.enable_push !== false) {
|
||||
return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`);
|
||||
}
|
||||
try {
|
||||
await gitea.mergePullRequest(owner, repo, number, "merge", true, head);
|
||||
} catch (err) {
|
||||
const f = failure(err);
|
||||
if (f["error-kind"] === "refused") return no(`the forge refused the merge: ${f.error}`);
|
||||
throw err;
|
||||
}
|
||||
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||
if (!merged.merge_commit_sha) throw new Error(`${full}#${number} WAS merged, but the forge named no merge commit`);
|
||||
// The merge is done once the forge named its commit: a read-back that fails is said, never answered as a failed act.
|
||||
let parents: string[];
|
||||
try {
|
||||
parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${full}#${number} was merged (${merged.merge_commit_sha}); which head it joined could not be read back — ${err instanceof Error ? err.message : err}`);
|
||||
return { id, performed: true, "merge-commit": merged.merge_commit_sha };
|
||||
}
|
||||
if (parents.length === 2 && parents[1] !== head) {
|
||||
throw new Error(`${full}#${number} WAS merged (${merged.merge_commit_sha}), but the forge merged ${parents[1].slice(0, 12)}, ` +
|
||||
`not the ${head.slice(0, 12)} the caller names (novox/hq issue 381)`);
|
||||
}
|
||||
return { id, performed: true, "merge-commit": merged.merge_commit_sha };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// The seat git's events, said by its holder (novox/hq design 47 *The seat git*): a pull request's new head, its merge and
|
||||
// its close (pull.updated, pull.merged, pull.closed), the forge's own webhook of a pull request, a status and a deleted
|
||||
// branch (forge.pull, forge.status, forge.delete), and a repository the forge can write again (repo.writable). Each is
|
||||
// said on the seat's subject through the runtime (mesh-sdk `runtime.seatPublish`, `mesh/seat.publish`), in the body the
|
||||
// seat's contract types, never as this module's own event: its hearers consume the seat, not the forge module.
|
||||
//
|
||||
// What the module's paths make (the poll, the webhook, the account's writes) is read here once and turned into the
|
||||
// contract's body; a pull request's description is never carried (ADR 0659 §1).
|
||||
|
||||
import { seatPublish } from "@novox/mesh-sdk/runtime";
|
||||
import {
|
||||
forgeDeleteSubject, forgePullSubject, forgeStatusSubject, pullClosedSubject, pullMergedSubject, pullUpdatedSubject,
|
||||
repoWritableSubject,
|
||||
type Change, type ForgeDeleteEvent, type ForgePullEvent, type ForgeStatusEvent, type PullClosedEvent, type PullMergedEvent,
|
||||
type PullUpdatedEvent, type RepoWritableEvent,
|
||||
} from "@novox/mesh-delivery-contracts-git";
|
||||
|
||||
/** The forge's pull request actions the seat's forge.pull says (the contract's word list). */
|
||||
export const PULL_ACTIONS = ["opened", "reopened", "synchronized", "edited", "closed", "merged"];
|
||||
|
||||
type Body = Record<string, any>;
|
||||
const s = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
|
||||
const opt = <K extends string>(k: K, v: unknown) => (v === undefined || v === null || v === "" ? {} : { [k]: v } as Record<K, unknown>);
|
||||
|
||||
function repositoryOf(b: Body): string {
|
||||
const full = s(b.full_name) ?? (s(b.owner) && s(b.repo) ? `${b.owner}/${b.repo}` : undefined);
|
||||
if (!full) throw new Error("the event names no repository");
|
||||
return full;
|
||||
}
|
||||
|
||||
function changeOf(b: Body): Change | undefined {
|
||||
if (!Array.isArray(b.paths)) return undefined;
|
||||
return { paths: b.paths.map(String), ...opt("paths-truncated", b.paths_truncated === true ? true : undefined),
|
||||
...opt("removed", Array.isArray(b.removed) && b.removed.length ? b.removed.map(String) : undefined),
|
||||
...opt("module-dirs", Array.isArray(b.module_dirs) ? b.module_dirs.map(String) : undefined) };
|
||||
}
|
||||
|
||||
/** The seat's subject and body for one of the module's announcements, or undefined when the seat says no such event. */
|
||||
export function seatEventOf(type: string, b: Body): { subject: string; body: unknown } | undefined {
|
||||
switch (type) {
|
||||
case "pull.updated": {
|
||||
const body: PullUpdatedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""),
|
||||
branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), ...opt("base-head", s(b.base_sha)),
|
||||
...opt("author", s(b.author)), ...opt("title", s(b.title)), ...opt("url", s(b.html_url)), ...opt("change", changeOf(b)) };
|
||||
return { subject: pullUpdatedSubject, body };
|
||||
}
|
||||
case "pull.merged": {
|
||||
const body: PullMergedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""),
|
||||
branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), "merge-commit": String(b.merge_commit_sha ?? ""),
|
||||
...opt("merged-at", s(b.merged_at)), ...opt("head-checks", b.head_checks), ...opt("author", s(b.author)),
|
||||
...opt("title", s(b.title)), ...opt("url", s(b.html_url)), ...opt("change", changeOf(b)) };
|
||||
return { subject: pullMergedSubject, body };
|
||||
}
|
||||
case "pull.closed": {
|
||||
const body: PullClosedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""),
|
||||
branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), ...opt("author", s(b.author)), ...opt("title", s(b.title)),
|
||||
...opt("url", s(b.html_url)) };
|
||||
return { subject: pullClosedSubject, body };
|
||||
}
|
||||
case "forge.status": {
|
||||
const body: ForgeStatusEvent = { repository: repositoryOf(b), commit: String(b.sha ?? ""), context: String(b.context ?? ""),
|
||||
state: String(b.state ?? ""), ...opt("description", s(b.description)), ...opt("sender", s(b.sender)) };
|
||||
return { subject: forgeStatusSubject, body };
|
||||
}
|
||||
case "forge.delete": {
|
||||
const body: ForgeDeleteEvent = { repository: repositoryOf(b), ref: String(b.ref ?? ""), ...opt("ref-type", s(b.ref_type)),
|
||||
...opt("sender", s(b.sender)) };
|
||||
return { subject: forgeDeleteSubject, body };
|
||||
}
|
||||
case "repo.writable": {
|
||||
const body: RepoWritableEvent = { repository: repositoryOf(b), account: String(b.account ?? "") };
|
||||
return { subject: repoWritableSubject, body };
|
||||
}
|
||||
}
|
||||
const action = type.startsWith("forge.pull-request.") ? type.slice("forge.pull-request.".length) : "";
|
||||
if (PULL_ACTIONS.includes(action)) {
|
||||
const body: ForgePullEvent = { repository: repositoryOf(b), number: Number(b.number), action,
|
||||
...opt("base", s(b.base)), ...opt("branch", s(b.head)), ...opt("head", s(b.head_sha)), ...opt("author", s(b.author)),
|
||||
...opt("merge-commit", s(b.merge_commit_sha)), ...(b.merged === true ? { merged: true } : {}), ...opt("sender", s(b.sender)) };
|
||||
return { subject: forgePullSubject, body };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The module's own events the seat says instead: never said as the module's (its `emits` names none of them). */
|
||||
export const SEAT_ONLY = new Set(["pull.updated", "pull.merged", "pull.closed", "forge.status", "forge.delete", "repo.writable"]);
|
||||
|
||||
export type Say = (subject: string, body: unknown, id?: string) => Promise<void>;
|
||||
|
||||
/** Say an announcement on the seat, de-duplicated by `id` (the same announcement by two paths is one message). */
|
||||
export async function sayOnSeat(type: string, body: Body, id = "", say: Say = seatPublish): Promise<boolean> {
|
||||
const e = seatEventOf(type, body);
|
||||
if (!e) return false;
|
||||
await say(e.subject, e.body, id);
|
||||
return true;
|
||||
}
|
||||
@@ -22,25 +22,37 @@ test("the same merge, head or close announced twice has the same id, and another
|
||||
assert.equal(announcementId("pull.closed", closed), "closed:novox/app#7@aaa111");
|
||||
});
|
||||
|
||||
test("an announcement is published through the broker with its id as the event id, and the provenance emit stamps", async () => {
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
const published: any[] = [];
|
||||
useBroker(() => ({ publish: async (env: any) => { published.push(env); } }) as any);
|
||||
process.env.MESH_MODULE = "gitea";
|
||||
process.env.MESH_NODE = "anchor";
|
||||
test("an announcement is said on the seat git with its id, in the contract's body, never the description", async () => {
|
||||
const { useRuntime } = await import("@novox/mesh-sdk/runtime");
|
||||
const said: any[] = [];
|
||||
useRuntime(async (method, params) => { said.push({ method, ...(params as object) }); return null; });
|
||||
const { announce } = await import("../announce.ts");
|
||||
await announce("pull.merged", merged);
|
||||
const { seatEventOf, SEAT_ONLY } = await import("../seatevents.ts");
|
||||
await announce("pull.merged", { ...merged, body: "a description", head: "feat/x", paths: ["a/b.go"], removed: [] });
|
||||
await announce("pull.merged", { ...merged });
|
||||
assert.equal(published.length, 2);
|
||||
assert.equal(published[0].key, "pull.merged");
|
||||
assert.equal(published[0].headers["x-event-id"], "merged:novox/app#7@mmm222");
|
||||
assert.equal(published[1].headers["x-event-id"], published[0].headers["x-event-id"], "one id for one announcement");
|
||||
assert.equal(published[0].headers["x-source"], "gitea");
|
||||
assert.equal(published[0].headers["x-node"], "anchor");
|
||||
assert.equal(published[0].headers["content-type"], "application/json");
|
||||
assert.ok(published[0].headers["x-time"]);
|
||||
assert.equal(published[0].node, "anchor");
|
||||
assert.deepEqual(published[0].body, merged);
|
||||
useRuntime(undefined);
|
||||
assert.equal(said.length, 2);
|
||||
assert.deepEqual([said[0].method, said[0].subject, said[0].id], ["mesh/seat.publish", "mesh.seat.git.event.pull-merged", "merged:novox/app#7@mmm222"]);
|
||||
assert.equal(said[1].id, said[0].id, "one id for one announcement");
|
||||
assert.deepEqual(said[0].body, { repository: "novox/app", number: 7, base: "main", branch: "feat/x", head: "aaa111",
|
||||
"merge-commit": "mmm222", change: { paths: ["a/b.go"] } });
|
||||
// Every event the seat says, from what the module's paths make; the others are none of the seat's.
|
||||
const rows: [string, Record<string, unknown>, string | undefined][] = [
|
||||
["pull.updated", updated, "mesh.seat.git.event.pull-updated"],
|
||||
["pull.closed", closed, "mesh.seat.git.event.pull-closed"],
|
||||
["forge.status", { full_name: "novox/app", sha: "aaa111", context: "mesh/check", state: "success" }, "mesh.seat.git.event.forge-status"],
|
||||
["forge.delete", { owner: "novox", repo: "app", ref: "feat/x", ref_type: "branch" }, "mesh.seat.git.event.forge-delete"],
|
||||
["repo.writable", { owner: "novox", repo: "app", account: "mesh-tools" }, "mesh.seat.git.event.repo-writable"],
|
||||
["forge.pull-request.synchronized", { full_name: "novox/app", number: 7, head_sha: "aaa111" }, "mesh.seat.git.event.forge-pull"],
|
||||
["forge.pull-request.labeled", { full_name: "novox/app", number: 7 }, undefined],
|
||||
["forge.push", { full_name: "novox/app" }, undefined],
|
||||
];
|
||||
for (const [type, body, subject] of rows) assert.equal(seatEventOf(type, body)?.subject, subject, type);
|
||||
assert.deepEqual(seatEventOf("pull.updated", updated)?.body, { repository: "novox/app", number: 7, base: "main", branch: "",
|
||||
head: "aaa111", "base-head": "ttt333" });
|
||||
assert.equal(seatEventOf("forge.pull-request.edited", { full_name: "novox/app", number: 7, body: "x" })?.body &&
|
||||
"body" in (seatEventOf("forge.pull-request.edited", { full_name: "novox/app", number: 7, body: "x" })!.body as object), false);
|
||||
assert.deepEqual([...SEAT_ONLY].sort(), ["forge.delete", "forge.status", "pull.closed", "pull.merged", "pull.updated", "repo.writable"]);
|
||||
});
|
||||
|
||||
test("an announcement of a kind it has no id for is refused, never published with a made-up one", async () => {
|
||||
|
||||
@@ -37,85 +37,12 @@ test("importing the bundle resolves while the broker keeps refusing the binds",
|
||||
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
|
||||
});
|
||||
|
||||
// **The bundle writes mesh/check from the checker's owed state, and serves no tool that writes it** (ADR 0521; R4e round 2
|
||||
// finding R2-1; finding K1). The bundle is imported whole, as the runtime imports it, while its read of the checker's owed
|
||||
// is refused (the grant not yet composed): the watch is asked again, and once granted it is handed the bucket as the bus
|
||||
// keeps it, the latest revision of each key, then an older revision delivered late. The forge stand-in sees the latest
|
||||
// written, and nothing after it.
|
||||
test("a bundle whose read of the checker's owed is granted late writes the latest mesh/check, and serves no gitea_check_status", async () => {
|
||||
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const script = `
|
||||
const http = await import("node:http");
|
||||
const statuses = [];
|
||||
const forge = http.createServer((req, res) => {
|
||||
let b = ""; req.on("data", (d) => (b += d)); req.on("end", () => {
|
||||
if (req.method === "POST" && /\\/statuses\\//.test(req.url)) statuses.push(req.url + " " + b);
|
||||
res.setHeader("content-type", "application/json"); res.end(req.method === "GET" ? "[]" : "{}");
|
||||
});
|
||||
});
|
||||
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
|
||||
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
|
||||
const bound = [];
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
useBroker(() => ({
|
||||
subscribe: async (pattern) => { bound.push(pattern); return () => {}; },
|
||||
publish: async () => {}, request: async () => { throw new Error("no responders"); },
|
||||
handle: async () => () => {}, close: async () => {},
|
||||
}));
|
||||
const key = "novox/mesh-sdk.12.${sha}.status=mesh/check";
|
||||
const owed = (state, text) => ({ v: 1, act: "status", context: "mesh/check", state, text, target: "https://view.example/c" });
|
||||
const watched = [];
|
||||
let granted = 0;
|
||||
const { useStateChannel } = await import("@novox/mesh-sdk/state");
|
||||
useStateChannel({
|
||||
ask: async () => null,
|
||||
watch: async (params, deliver) => {
|
||||
watched.push(params.state);
|
||||
if (watched.length === 1) throw new Error('gitea keeps and reads no state called "mesh-delivery-checker.owed"');
|
||||
granted++;
|
||||
// Two revisions were written while the read was refused; the bucket hands the latest, then a late older one.
|
||||
await deliver({ state: params.state, key, op: "put", value: owed("success", "pass at 4"), revision: 4, current: true });
|
||||
await deliver({ state: params.state, key, op: "put", value: owed("pending", "pending at 3"), revision: 3, current: false });
|
||||
},
|
||||
});
|
||||
await import(${JSON.stringify(index)});
|
||||
const { collectTools } = await import("@novox/mesh-sdk/tools");
|
||||
const tools = collectTools().flatMap((r) => r.tools.map((t) => t.name));
|
||||
for (let i = 0; i < 300 && !(granted && statuses.length); i++) await new Promise((r) => setTimeout(r, 20));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
console.log(JSON.stringify({ tools, bound, watched, statuses }));
|
||||
process.exit(0);`;
|
||||
const env = { ...process.env, GITEA_TOKEN: "x" };
|
||||
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) delete env[k];
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
const ended = new Promise<void>((r) => child.on("exit", () => r()));
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 15000);
|
||||
await ended;
|
||||
clearTimeout(timer);
|
||||
const line = out.split("\n").find((l) => l.startsWith("{"));
|
||||
assert.ok(line, `the bundle said nothing; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
|
||||
const got = JSON.parse(line!) as { tools: string[]; bound: string[]; watched: string[]; statuses: string[] };
|
||||
assert.ok(got.tools.includes("gitea_commit_status"), `the bundle's tools: ${got.tools}`);
|
||||
assert.ok(!got.tools.includes("gitea_check_status"), "no tool writes mesh/check");
|
||||
assert.ok(!got.bound.some((p) => p.includes("check.status")), `bound: ${got.bound}`);
|
||||
assert.deepEqual(got.watched, ["mesh-delivery-checker.owed", "mesh-delivery-checker.owed"], "refused, then asked again");
|
||||
assert.equal(got.statuses.length, 1, `statuses written: ${got.statuses}`);
|
||||
assert.match(got.statuses[0], new RegExp(`/repos/novox/mesh-sdk/statuses/${sha} .*"context":"mesh/check"`));
|
||||
assert.match(got.statuses[0], /"state":"success".*pass at 4/);
|
||||
assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said");
|
||||
});
|
||||
|
||||
// **The bundle's import does not wait on the forge** (novox/hq issue 1726, 2026-10-24 12:37). The webhook receiver's set-up
|
||||
// asks the forge's admin API for its system webhook; awaited at the top of index.ts, a forge slow to answer (busy while
|
||||
// every bundle of the node starts) held the import past node-tools' 10 s handshake, the bundle was killed and never
|
||||
// started again, and gitea_commit_status, gitea_merge_pull_request and gitea_events were served nowhere: the forge
|
||||
// bridge's calls found no responders and no new pull request was announced. Here the forge stand-in takes every
|
||||
// request and never answers: the import resolves at once all the same, with the bundle's tools registered.
|
||||
// started again, and gitea_events was served nowhere and no new pull request was announced. Here the forge stand-in takes
|
||||
// every request and never answers: the import resolves at once all the same, with the bundle's tools registered. The
|
||||
// bundle serves no merge and no status of its own: those are the seat git's acts (design 47 *The seat git*).
|
||||
test("importing the bundle resolves, its tools registered, while the forge never answers the webhook's set-up", async () => {
|
||||
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||
const script = `
|
||||
@@ -155,7 +82,8 @@ test("importing the bundle resolves, its tools registered, while the forge never
|
||||
const line = out.split("\n").find((l) => l.startsWith("{"));
|
||||
assert.ok(line, `the import did not resolve within 3 s while the forge did not answer; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
|
||||
const got = JSON.parse(line!) as { tools: string[] };
|
||||
for (const t of ["gitea_events", "gitea_commit_status", "gitea_merge_pull_request"]) {
|
||||
assert.ok(got.tools.includes(t), `${t} is registered: ${got.tools}`);
|
||||
assert.ok(got.tools.includes("gitea_events"), `gitea_events is registered: ${got.tools}`);
|
||||
for (const t of ["gitea_commit_status", "gitea_merge_pull_request"]) {
|
||||
assert.ok(!got.tools.includes(t), `${t} is retired: ${got.tools}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
|
||||
// before it). No tool writes mesh/check, since a tool is told no caller and node-tools invokes every tool. Nor does an event:
|
||||
// nothing in the delivery plan orders this module before the forge bridge, and a durable consumer whose filters change keeps
|
||||
// its deliver policy, so an event said before this module consumed it was lost. A watch of the checker's state hands over
|
||||
// the latest value of every key when it starts, whenever that is.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const key = (s = sha, repo = "novox/mesh-sdk", n = 12) => `${repo}.${n}.${s}.status=mesh/check`;
|
||||
const owed = (over: Record<string, unknown> = {}) => ({ v: 1, act: "status", context: "mesh/check", state: "success",
|
||||
text: "pass 0123456789ab on aaaaaaaaaaaa", target: "https://view.example/checks/g.1", ...over });
|
||||
const put = (k: string, value: unknown, revision: number, current = false) =>
|
||||
({ state: "mesh-delivery-checker.owed", key: k, op: "put" as const, value, revision, current });
|
||||
|
||||
function forge() {
|
||||
const sent: string[] = [];
|
||||
let failing = 0;
|
||||
return {
|
||||
sent,
|
||||
fail: (n: number) => { failing = n; },
|
||||
setCommitStatus: async (o: string, r: string, s: string, st: any) => {
|
||||
if (failing > 0) { failing--; throw new Error("Gitea API /repos/x/statuses: 502 bad gateway"); }
|
||||
sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
const noSleep = async () => {};
|
||||
|
||||
test("the checker's owed is what this module reads, and it consumes no check.status", async () => {
|
||||
const { CHECKER_OWED } = await import("../check.ts");
|
||||
assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed");
|
||||
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||
assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed");
|
||||
assert.ok(!(manifest.consumes ?? []).some((c: string) => c.includes("check.status")), "no check.status is consumed");
|
||||
});
|
||||
|
||||
test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
assert.equal(await checks.apply(put(key(), owed(), 7, true)), "written");
|
||||
const other = sha.replace(/0/g, "f");
|
||||
assert.equal(await checks.apply(put(key(other, "novox/my=2erepo"), owed({ state: "pending", text: "y".repeat(300), target: "javascript:x" }), 8)), "written");
|
||||
assert.equal(f.sent[0], "novox/mesh-sdk@01234567 mesh/check success pass 0123456789ab on aaaaaaaaaaaa https://view.example/checks/g.1");
|
||||
assert.ok(f.sent[1].startsWith("novox/my.repo@f1234567 mesh/check pending yyyy"), f.sent[1]);
|
||||
assert.ok(f.sent[1].length < 200, "the line is cut as every status's is");
|
||||
assert.ok(!f.sent[1].includes("javascript"), "a link that is no web page is not written");
|
||||
});
|
||||
|
||||
test("a key or value that is no owed mesh/check, and a delete, write nothing", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
const cases = [
|
||||
put(key(sha.slice(0, 12)), owed(), 1), put(`novox/mesh-sdk.12.${sha}.status=mesh/merge-gate`, owed(), 1),
|
||||
put(`novox/mesh-sdk.12.${sha}.view`, owed(), 1), put(`nokey`, owed(), 1), put(`../x/y.12.${sha}.status=mesh/check`, owed(), 1),
|
||||
put(key(), owed({ context: "mesh/delivery" }), 1), put(key(), owed({ act: "view" }), 1), put(key(), owed({ state: "green" }), 1),
|
||||
put(key(), owed({ v: 2 }), 1), put(key(), null, 1), put(key(), "text", 1), put(key(), owed(), -1),
|
||||
{ state: "mesh-delivery-checker.owed", key: key(), op: "delete" as const, revision: 2, current: false },
|
||||
];
|
||||
for (const c of cases) assert.equal(await checks.apply(c as any), "dropped", JSON.stringify(c));
|
||||
assert.deepEqual(f.sent, []);
|
||||
});
|
||||
|
||||
test("a revision older than one written on the key is dropped; the same one is written again", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
await checks.apply(put(key(), owed({ state: "success" }), 10));
|
||||
assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 9)), "dropped", "a stale pending never hides the verdict");
|
||||
assert.equal(await checks.apply(put(key(), owed({ state: "success" }), 10)), "written");
|
||||
assert.equal(await checks.apply(put(key(sha.replace(/1/g, "2")), owed({ state: "pending" }), 3)), "written", "another key is its own");
|
||||
assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 11)), "written");
|
||||
assert.deepEqual(f.sent.map((s) => s.split(" ").slice(0, 3).join(" ")), ["novox/mesh-sdk@01234567 mesh/check success",
|
||||
"novox/mesh-sdk@01234567 mesh/check success", "novox/mesh-sdk@02234567 mesh/check pending", "novox/mesh-sdk@01234567 mesh/check pending"]);
|
||||
});
|
||||
|
||||
test("a forge that refuses is asked again until it writes, unless a newer revision of the key came meanwhile", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const said: string[] = [];
|
||||
const waits: number[] = [];
|
||||
const checks = new CheckStatuses(f, (l) => said.push(l), async (ms) => { waits.push(ms); });
|
||||
f.fail(2);
|
||||
await checks.take(put(key(), owed({ state: "success" }), 5));
|
||||
await checks.settled();
|
||||
assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "written on the third ask");
|
||||
assert.deepEqual(waits, [2_000, 5_000]);
|
||||
assert.match(said[0], /502.*asked again/);
|
||||
// A refusal whose key moved on meanwhile is not asked again: the newer revision is written, the older never after it.
|
||||
const g = forge();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
const later = new CheckStatuses(g, () => {}, () => gate);
|
||||
g.fail(1);
|
||||
await later.take(put(key(), owed({ state: "pending" }), 6));
|
||||
await later.take(put(key(), owed({ state: "success" }), 7));
|
||||
release();
|
||||
await later.settled();
|
||||
assert.deepEqual(g.sent.map((s) => s.split(" ")[2]), ["success"]);
|
||||
});
|
||||
|
||||
test("a watch refused at first is asked again, said once, and once granted writes the latest revision of each key", async () => {
|
||||
const { CheckStatuses, watchOwed } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const said: string[] = [];
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
// The checker's bucket as the bus keeps it: two revisions of one key written while the read was refused.
|
||||
const kept = new Map<string, { value: unknown; revision: number }>();
|
||||
kept.set(key(), { value: owed({ state: "pending" }), revision: 3 });
|
||||
kept.set(key(), { value: owed({ state: "success" }), revision: 4 });
|
||||
let asked = 0;
|
||||
const watch = async (handler: (c: any) => Promise<void>) => {
|
||||
asked++;
|
||||
if (asked < 3) throw new Error("gitea keeps and reads no state called \"mesh-delivery-checker.owed\"");
|
||||
for (const [k, e] of kept) await handler(put(k, e.value, e.revision, true));
|
||||
};
|
||||
await watchOwed(watch, checks, noSleep, (l) => said.push(l));
|
||||
await checks.settled();
|
||||
assert.equal(asked, 3);
|
||||
assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "only the latest revision is written");
|
||||
assert.equal(said.filter((l) => /could not be bound/.test(l)).length, 1, `said once: ${said.join(" | ")}`);
|
||||
assert.ok(said.some((l) => /bound after 3 attempts/.test(l)), said.join(" | "));
|
||||
});
|
||||
|
||||
test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_status refuses it", async () => {
|
||||
const delivery = await import("../delivery.ts");
|
||||
assert.equal((delivery as Record<string, unknown>).checkStatusTool, undefined, "delivery.ts serves no gitea_check_status");
|
||||
assert.throws(() => delivery.deliveryStatus("mesh/check", "success", "x"), /mesh-delivery-checker\.owed/);
|
||||
const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||
assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status");
|
||||
});
|
||||
|
||||
test("the watch's current values are taken without waiting on the forge, so a slow forge never times the watch out (issue 1726)", async () => {
|
||||
// The tool runtime hands a watch's current values one at a time and waits for each to be taken; it gives the whole set
|
||||
// 10 s (mesh-tools StateTimeout) and otherwise ends the watch. On 2026-10-23 the owed held ~200 keys, each written to the
|
||||
// forge before the next was handed over: the set took longer than 10 s, the watch ended at every attempt, and no
|
||||
// verdict reached the forge's mesh/check again.
|
||||
const { CheckStatuses, watchOwed } = await import("../check.ts");
|
||||
const sent: string[] = [];
|
||||
let release: () => void = () => {};
|
||||
const slow = new Promise<void>((r) => { release = r; });
|
||||
const f = { setCommitStatus: async (_o: string, _r: string, s: string, st: any) => { await slow; sent.push(`${s.slice(0, 4)} ${st.state}`); } };
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
const keys = Array.from({ length: 200 }, (_, i) => key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1));
|
||||
let handedOver = 0;
|
||||
let attempts = 0;
|
||||
const watch = async (handler: (c: any) => Promise<void>) => {
|
||||
if (++attempts > 1) return; // a second attempt is not what this test waits for: the first must hold
|
||||
// The runtime's bound on the current values: the forge has answered none of them yet.
|
||||
const timedOut = new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 200));
|
||||
const delivered = (async () => { for (const [i, k] of keys.entries()) { await handler(put(k, owed(), i + 1, true)); handedOver++; } return "delivered" as const; })();
|
||||
if (await Promise.race([delivered, timedOut]) === "timeout") throw new Error("the current values of mesh-delivery-checker.owed did not arrive in 10s");
|
||||
};
|
||||
const said: string[] = [];
|
||||
await watchOwed(watch, checks, noSleep, (l) => said.push(l));
|
||||
assert.equal(handedOver, 200, "every current value was taken before the forge answered");
|
||||
assert.ok(!said.some((l) => /could not be bound/.test(l)), said.join(" | "));
|
||||
assert.equal(sent.length, 0, "nothing is written before the forge answers");
|
||||
release();
|
||||
await checks.settled();
|
||||
assert.equal(sent.length, 200, "every owed status is written once the forge answers");
|
||||
});
|
||||
|
||||
test("the writes a watch's start hands over go to the forge a few at a time, never all at once (issue 1726)", async () => {
|
||||
const { CheckStatuses, WRITES_AT_ONCE } = await import("../check.ts");
|
||||
let running = 0, most = 0, done = 0;
|
||||
const f = { setCommitStatus: async () => { running++; most = Math.max(most, running); await new Promise((r) => setTimeout(r, 1)); running--; done++; } };
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
for (let i = 0; i < 50; i++) await checks.take(put(key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1), owed(), i + 1, true));
|
||||
await checks.settled();
|
||||
assert.equal(done, 50);
|
||||
assert.equal(most, WRITES_AT_ONCE);
|
||||
});
|
||||
|
||||
// The sweep of 2026-10-30 (issue 774): a status the forge refuses with 403 or 404 was asked again every 30 s forever (hal
|
||||
// and novadock, 119 times each), and a key the checker deleted kept its loop running. Now a 403 or 404 is said once and
|
||||
// never asked again, a 5xx is still asked again, and a delete ends a loop that is running.
|
||||
test("a refusal for good is said once and not asked again; a delete ends a running ask again", async () => {
|
||||
const { CheckStatuses, refusedForGood } = await import("../check.ts");
|
||||
for (const [err, forGood] of [["Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden", true],
|
||||
["Gitea API /repos/jschoubben/novadock/statuses/x: 404 not found", true], ["Gitea API /repos/x/statuses: 502 bad gateway", false],
|
||||
["Gitea API /repos/x: no answer in 10 s, the request was cut", false]] as [string, boolean][]) {
|
||||
assert.equal(refusedForGood(new Error(err)), forGood, err);
|
||||
}
|
||||
const said: string[] = [];
|
||||
const forbidden = { setCommitStatus: async () => { throw new Error("Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden"); } };
|
||||
const checks = new CheckStatuses(forbidden, (l) => said.push(l), async () => { throw new Error("asked again"); });
|
||||
await checks.take(put(key(sha, "jschoubben/hal"), owed(), 3));
|
||||
await checks.settled();
|
||||
assert.equal(said.length, 1);
|
||||
assert.match(said[0], /refused for good.*403.*not asked again/);
|
||||
// A 502 asks again; the key's delete meanwhile ends the loop before its next ask.
|
||||
const g = forge();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
const later = new CheckStatuses(g, () => {}, () => gate);
|
||||
g.fail(5);
|
||||
await later.take(put(key(), owed(), 8));
|
||||
await later.take({ state: "mesh-delivery-checker.owed", key: key(), op: "delete", revision: 9, current: false } as any);
|
||||
release();
|
||||
await later.settled();
|
||||
assert.deepEqual(g.sent, [], "a deleted key is not written");
|
||||
});
|
||||
@@ -41,41 +41,12 @@ test("a note that cannot be written is said, never read as written", async () =>
|
||||
await assert.rejects(appendNote(runner, "gitea", "novox", "x", "abcdef1234567", "mesh-plan", "l"), /could not be appended/);
|
||||
});
|
||||
|
||||
test("one view per pull request, found by its marker; only the mesh's statuses", async () => {
|
||||
const { viewBody, viewComment, deliveryStatus, VIEW_MARKER } = await import("../delivery.ts");
|
||||
test("one view per pull request, found by its marker", async () => {
|
||||
const { viewBody, viewComment, VIEW_MARKER } = await import("../delivery.ts");
|
||||
assert.ok(viewBody("**Delivery**").startsWith(VIEW_MARKER));
|
||||
assert.equal(viewBody(`${VIEW_MARKER}\nx`), `${VIEW_MARKER}\nx`, "a marked body is kept as it is");
|
||||
const comments = [{ id: 1, body: "a review" }, { id: 2, body: `${VIEW_MARKER}\nold` }, { id: 3, body: `${VIEW_MARKER}\nlater` }];
|
||||
assert.equal(viewComment(comments)?.id, 2);
|
||||
assert.equal(viewComment([{ id: 1, body: "x" }]), undefined);
|
||||
const s = deliveryStatus("mesh/delivery", "pending", "y".repeat(300), "https://forge.invalid/novox/x/pulls/1");
|
||||
assert.ok(s.description.length <= 140 && s.target_url);
|
||||
assert.throws(() => deliveryStatus("ci/other", "success", "x"), /mesh's own/);
|
||||
assert.throws(() => deliveryStatus("mesh/delivery", "green", "x"), /not a status/);
|
||||
// The merge check's statuses are its verdict's alone: required by a branch, they are never set by hand.
|
||||
assert.throws(() => deliveryStatus("mesh/merge-gate", "success", "x"), /merge check's status/);
|
||||
assert.throws(() => deliveryStatus("mesh/repo-check", "warning", "x"), /merge check's status/);
|
||||
assert.throws(() => deliveryStatus("mesh/merge-gate", "failure", "x"), /merge check's status/);
|
||||
// Pending is no verdict: a recheck says it so the old verdict does not let the head merge meanwhile.
|
||||
assert.equal(deliveryStatus("mesh/merge-gate", "pending", "checking again: main moved on").state, "pending");
|
||||
assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check");
|
||||
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
||||
});
|
||||
|
||||
// Issue 1301-S4 (ADR 0447 §1; R4e findings K1 and R2-1): main requires mesh/check, so no hand may set it. The status tool
|
||||
// every caller may use refuses it in every state; no tool writes it at all: it is written from the checker's owed state
|
||||
// (check.ts, test/check-owed.test.ts; ADR 0521).
|
||||
test("mesh/check is refused by the status tool in every state", async () => {
|
||||
const { commitStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||
assert.equal(CHECK_STATUS, "mesh/check");
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const sent: string[] = [];
|
||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`);
|
||||
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
|
||||
for (const state of ["pending", "success", "failure", "error", "warning"]) {
|
||||
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
|
||||
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
|
||||
context: "mesh/check", state, description: "by hand" }), /mesh-delivery-checker\.owed/);
|
||||
}
|
||||
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
|
||||
});
|
||||
|
||||
@@ -42,8 +42,7 @@ function aForge() {
|
||||
};
|
||||
}
|
||||
|
||||
// **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back
|
||||
// to pending through the gitea_commit_status tool.
|
||||
// **A recheck's pending goes through the one writer** (novox/hq issue 456).
|
||||
|
||||
const recheckAt = new Date("2026-10-11T01:30:00Z");
|
||||
const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" };
|
||||
@@ -63,30 +62,8 @@ test("a recheck the forge does not take fails its caller, and the next write sti
|
||||
const gates = new GateWriter(forge, () => recheckAt);
|
||||
await gates.pending("novox", "hq", SHA);
|
||||
forge.failWrites();
|
||||
await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain));
|
||||
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||
await assert.rejects(gates.status("novox", "hq", SHA, checkingAgain));
|
||||
await gates.status("novox", "hq", SHA, checkingAgain);
|
||||
assert.equal(gateOf(forge), "pending checking again: main moved on");
|
||||
});
|
||||
|
||||
test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => {
|
||||
const { GateWriter } = await import("../gate.ts");
|
||||
const { commitStatusTool } = await import("../delivery.ts");
|
||||
const forge = aForge();
|
||||
const direct: string[] = [];
|
||||
const gates = new GateWriter(forge, () => recheckAt);
|
||||
const tool = commitStatusTool({
|
||||
setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`),
|
||||
gates,
|
||||
});
|
||||
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" });
|
||||
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" });
|
||||
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" });
|
||||
assert.equal(gateOf(forge), "pending checking again: x");
|
||||
assert.equal(forge.kept["mesh/repo-check"].state, "pending");
|
||||
assert.deepEqual(direct, ["mesh/delivery success"]);
|
||||
// A head named short is another head to the writer: refused, nothing sent.
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }),
|
||||
/whole sha/);
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }),
|
||||
/set by its verdict only/);
|
||||
});
|
||||
|
||||
+174
-450
@@ -1,22 +1,17 @@
|
||||
// What the forge's tools refuse (guard.ts; novox/hq ADR 0266): an agent reaches the forge through these tools
|
||||
// with a token minted for the forge's administrator, so the tools — not the forge — are what keeps it from
|
||||
// dropping a branch's protection, writing to main, setting the merge check's status, or merging past the
|
||||
// checks. Each route a review found is reproduced here against a fake forge, and each is refused with nothing
|
||||
// sent; the operator-approved merge of a pull request whose checks passed still goes through.
|
||||
// dropping a branch's protection, writing to main, setting the merge check's status, or merging. Each route a review
|
||||
// found is reproduced here against a fake forge, and each is refused with nothing sent; a merge is the seat git's
|
||||
// merge-pull alone, at the head the caller names (the last test's rows).
|
||||
|
||||
import { test, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
import { mergeTool } from "../dist/merge.js";
|
||||
import { commitStatusTool } from "../dist/delivery.js";
|
||||
import { GateWriter } from "../dist/gate.js";
|
||||
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
|
||||
|
||||
interface Sent { method: string; path: string; body: any }
|
||||
@@ -54,6 +49,8 @@ const state = {
|
||||
groupStatus: "",
|
||||
siblingGroup: "",
|
||||
reviews: [] as { state: string; commit_id: string; dismissed?: boolean }[],
|
||||
// The forge's own mergeable of pull request 7; unset, the forge does not say.
|
||||
mergeable: undefined as boolean | undefined,
|
||||
};
|
||||
const REVIEWED = "abc1234def5678901234567890abcdef12345678";
|
||||
const SIBLING_HEAD = "bbbbbbbbbbbb5678901234567890abcdef12345678".slice(0, 40);
|
||||
@@ -107,7 +104,7 @@ const server = createServer(async (req, res) => {
|
||||
state.asked.push(`${method} ${path}${url.search}`);
|
||||
if (method !== "GET") state.sent.push({ method, path, body });
|
||||
const repo = "/repos/novox/mesh-catalog";
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch, permissions: { push: true } });
|
||||
if (method === "PATCH" && path === repo) {
|
||||
if (body?.default_branch) state.defaultBranch = body.default_branch;
|
||||
return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
@@ -146,7 +143,7 @@ const server = createServer(async (req, res) => {
|
||||
if (method === "GET" && path === `${repo}/pulls/7`) {
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged,
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7",
|
||||
merge_commit_sha: state.merged ? state.mergeCommit : null });
|
||||
merge_commit_sha: state.merged ? state.mergeCommit : null, ...(state.mergeable === undefined ? {} : { mergeable: state.mergeable }) });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
|
||||
if (state.commitsFail) return json(res, state.commitsFail, { message: "the forge is not well" });
|
||||
@@ -165,6 +162,7 @@ const server = createServer(async (req, res) => {
|
||||
return json(res, 200, url.searchParams.get("page") === "1" ? state.reviews : []);
|
||||
}
|
||||
if (method === "GET" && path === "/repos/novox/mesh-host/issues/114/labels") return json(res, 200, []);
|
||||
if (method === "GET" && path === `${repo}/pulls/404`) return json(res, 404, { message: "pull request does not exist" });
|
||||
if (method === "POST" && path === `${repo}/pulls/7/merge`) {
|
||||
state.merged = true;
|
||||
return json(res, 200, null);
|
||||
@@ -175,20 +173,7 @@ await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
after(() => new Promise<void>((r) => server.close(() => r())));
|
||||
const { port } = server.address() as { port: number };
|
||||
const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t"));
|
||||
// gitea_commit_status is served from the watcher's bundle, beside the merge check's one writer (novox/hq issue 456).
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-"));
|
||||
// The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through.
|
||||
const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s),
|
||||
headChecks: async () => ({}) });
|
||||
const throughTheWriter: string[] = [];
|
||||
const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
|
||||
throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`);
|
||||
return writer.rechecked(o, r, sha, s);
|
||||
} };
|
||||
const tools = [...getGiteaTools(client), commitStatusTool({
|
||||
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
|
||||
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
now: () => new Date(2026, 9, 12, 15, 1) })];
|
||||
const tools = getGiteaTools(client);
|
||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
||||
|
||||
@@ -276,203 +261,6 @@ test("gitea_branch_protection_set writes no required status, and refuses a push
|
||||
assert.equal(sent.enable_push, false);
|
||||
});
|
||||
|
||||
test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => {
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED });
|
||||
state.statuses = [judgedAt(TRUNK)[0], judgedAt(TRUNK, "failure")[1]];
|
||||
await assert.rejects(merge(), /not merged: .*mesh\/repo-check \(failure\)/);
|
||||
state.statuses = [judgedAt(TRUNK)[0]];
|
||||
await assert.rejects(merge(), /mesh\/repo-check \(missing\)/);
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED, method: "manually-merged" }), /not a merge method/);
|
||||
assert.deepEqual(writes(), []);
|
||||
// The operator said yes and the checks passed: the flow that stays.
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
await merge();
|
||||
const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!;
|
||||
assert.equal(sent.body.force_merge, false);
|
||||
assert.equal(sent.body.head_commit_id, REVIEWED);
|
||||
});
|
||||
|
||||
|
||||
// **The merge tool refuses a verdict whose trunk commit is not the trunk's head, and asks the check itself** (novox/hq
|
||||
// ADR 0323 §5, issue 341): node-engine #66 merged at 00:54 on a verdict of 00:49 that judged its head alone, after #65
|
||||
// had moved the trunk. Both commits are named; one check is asked, the announcement a new head makes, and not a second
|
||||
// while it is on its way; and once the verdict judges
|
||||
// the trunk's head and passes, the merge goes through — asked again, never by itself.
|
||||
test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => {
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||
|
||||
state.trunk = MOVED; // another merge moved the trunk after the verdict
|
||||
await assert.rejects(merge(), (err: Error) => {
|
||||
assert.match(err.message, /is not merged: its verdict was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b/);
|
||||
assert.match(err.message, /check of head abc1234def56 merged into trunk 3c4d5e6f7a8b was asked/);
|
||||
assert.match(err.message, /never merges by itself/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(state.merged, false);
|
||||
assert.equal(state.emitted.length, 1, "one check asked");
|
||||
assert.equal(state.emitted[0].type, "pull.updated");
|
||||
assert.equal(state.emitted[0].body.head_sha, REVIEWED);
|
||||
assert.equal(state.emitted[0].body.base, "main");
|
||||
assert.deepEqual(state.emitted[0].body.paths, ["modules/gitea/x.ts"]);
|
||||
// The announcement names the trunk's head, for a verdict no build judges (ADR 0323 §3).
|
||||
assert.equal(state.emitted[0].body.base_sha, MOVED);
|
||||
// Its pending went through the merge check's one writer (novox/hq issue 456), not around it.
|
||||
assert.deepEqual(throughTheWriter.splice(0), ["abc1234def56 mesh/merge-gate pending", "abc1234def56 mesh/repo-check pending"]);
|
||||
const gate = state.statuses.find((s) => s.context === "mesh/merge-gate")!;
|
||||
assert.equal(gate.state, "pending");
|
||||
assert.match(gate.description!, /^waiting in the build queue since 15:01: asked by the merge tool against trunk 3c4d5e6f7a8b/);
|
||||
assert.equal(state.statuses.find((s) => s.context === "mesh/repo-check")!.state, "pending");
|
||||
|
||||
// Asked again while that check is on its way: refused, and nothing is asked a second time.
|
||||
await assert.rejects(merge(), /already asked and not yet answered .*not asked again.*mesh-delivery\.recheck/);
|
||||
assert.equal(state.emitted.length, 1, "no second check while one is on its way");
|
||||
state.statuses = [{ ...gate, description: "building on ace since 15:02" }, ...state.statuses.slice(1)];
|
||||
await assert.rejects(merge(), /not asked again/);
|
||||
assert.equal(state.emitted.length, 1);
|
||||
|
||||
// A verdict put out of date by the trunk's move is asked too, once.
|
||||
state.statuses = judgedAt(TRUNK).map((s) => ({ ...s, state: "pending",
|
||||
description: "out of date: judged against trunk 7a8b9c0d1e2f, the trunk is now 3c4d5e6f7a8b; judged again on a recheck or when it is merged" }));
|
||||
await assert.rejects(merge(), /was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b\. The check .* was asked/);
|
||||
assert.equal(state.emitted.length, 2);
|
||||
|
||||
// A verdict that names no trunk commit — a build seat from before the rule — is not taken as the trunk's head.
|
||||
state.statuses = [{ context: "mesh/merge-gate", state: "success", description: "pass: x" },
|
||||
{ context: "mesh/repo-check", state: "success", description: "pass: y" }];
|
||||
await assert.rejects(merge(), /names no trunk commit it was judged against, and main is now 3c4d5e6f7a8b/);
|
||||
assert.equal(state.emitted.length, 3);
|
||||
assert.equal(state.merged, false);
|
||||
|
||||
// An ask that cannot be announced puts the statuses back to out of date, so the next ask asks again.
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
const failing = mergeTool(client, { gates, stateDir, emit: async () => { throw new Error("the bus is away"); } });
|
||||
await assert.rejects(failing.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }),
|
||||
/its check could not be asked: the bus is away\. Nothing was merged/);
|
||||
assert.match(state.statuses.find((s) => s.context === "mesh/merge-gate")!.description!, /^out of date: /);
|
||||
await assert.rejects(merge(), /was asked/);
|
||||
assert.equal(state.emitted.length, 4, "asked again after the announcement failed");
|
||||
|
||||
// The check judged the merge into the trunk's head and passed: asked again at the reviewed head, it merges.
|
||||
state.statuses = judgedAt(MOVED);
|
||||
const done = (await merge()) as Record<string, unknown>;
|
||||
assert.equal(done.merged, true);
|
||||
assert.equal(state.emitted.length, 4, "a merge asks no check");
|
||||
});
|
||||
|
||||
// **A group member whose gate waits for its group's composed check is not announced again by the merge tool** (novox/hq issue
|
||||
// 1106): the member's own check can only say "composed" again, so each refusal's ask brought a verdict that names no trunk and
|
||||
// the next merge was refused the same way, for ever. The tool says whose verdict is awaited and asks nothing.
|
||||
test("gitea_merge_pull_request asks no check of a member that waits for its group's composed check", async () => {
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||
state.trunk = TRUNK;
|
||||
const before = state.emitted.length;
|
||||
state.statuses = [{ context: "mesh/merge-gate", state: "pending", description: "waits for its group's composed check" },
|
||||
{ context: "mesh/repo-check", state: "success", description: "pass: y" }];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await assert.rejects(merge(), (err: Error) => {
|
||||
assert.match(err.message, /waits for its group's composed check/);
|
||||
assert.match(err.message, /mesh-delivery asks it, this tool asks no check/);
|
||||
assert.match(err.message, /Nothing was merged/);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
assert.equal(state.emitted.length, before, "no check is announced again and again");
|
||||
assert.equal(state.merged, false);
|
||||
// Once the composed verdict names the member's unmoved trunk, it merges.
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
|
||||
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
|
||||
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
|
||||
const merge = (head?: string) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
...(head === undefined ? {} : { head }) });
|
||||
|
||||
// No head named: there is no review to hold the merge to.
|
||||
await assert.rejects(merge(), /name the head you reviewed/);
|
||||
await assert.rejects(merge(" "), /name the head you reviewed/);
|
||||
await assert.rejects(merge("main"), /does not name one commit/);
|
||||
// Seven characters can be matched by a commit made to match them: twelve are asked for.
|
||||
await assert.rejects(merge(REVIEWED.slice(0, 7)), /does not name one commit — give its sha, or at least its first 12/);
|
||||
await assert.rejects(merge(REVIEWED.slice(0, 11)), /does not name one commit/);
|
||||
|
||||
// The head moved after the review, and its checks passed where it is now: the case of mesh-controller#197.
|
||||
state.head = "3ced96fc6f8ad6cc7b93698912cb08a71635158a";
|
||||
await assert.rejects(merge(REVIEWED), /its head is 3ced96fc6f8a, not the abc1234def5678901234567890abcdef12345678 you named/);
|
||||
await assert.rejects(merge(REVIEWED.slice(0, 12)), /not the abc1234def56 you named\. If you reviewed an earlier head/);
|
||||
assert.deepEqual(writes(), [], "a refused merge sends nothing");
|
||||
|
||||
// Named as reviewed, by its first 12 characters, in either case: the forge is handed the whole head, and the merge
|
||||
// commit's second parent says that head landed.
|
||||
state.head = REVIEWED;
|
||||
const done = (await merge(REVIEWED.slice(0, 12).toUpperCase())) as Record<string, unknown>;
|
||||
const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!;
|
||||
assert.equal(sent.body.head_commit_id, REVIEWED);
|
||||
assert.equal(done.head, REVIEWED);
|
||||
assert.equal(done.landed, REVIEWED);
|
||||
assert.equal(done.read_back, undefined);
|
||||
});
|
||||
|
||||
// The forge is handed the head, and what landed is read from the merge commit, not from the forge's record of the
|
||||
// pull request (which here still says the head it was handed). Another head landing is an error that says the merge
|
||||
// happened, never a field beside merged: true.
|
||||
test("gitea_merge_pull_request is loud when the forge merged a head other than the one it was handed", async () => {
|
||||
state.landed = "3ced96fc6f8ad6cc7b93698912cb08a71635158a";
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED }), /WAS merged \(feedface0000.*\), but the forge merged 3ced96fc6f8a, not the abc1234def56 it was handed/);
|
||||
assert.equal(state.merged, true, "the merge happened: the error is what says so");
|
||||
});
|
||||
|
||||
// The merge has happened once the forge says so: a read-back that fails after it must not read as nothing merged.
|
||||
test("gitea_merge_pull_request says the merge happened when reading back what landed fails", async () => {
|
||||
state.commitsFail = 500;
|
||||
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED })) as Record<string, unknown>;
|
||||
assert.equal(state.merged, true);
|
||||
assert.equal(done.merged, true);
|
||||
assert.equal(done.landed, undefined);
|
||||
assert.match(String(done.read_back), /the merge happened, but reading back what landed failed/);
|
||||
|
||||
// A parent the forge names without its sha is a failed read-back, never an empty head said to have landed.
|
||||
state.merged = false;
|
||||
state.commitsFail = 0;
|
||||
state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b", ""];
|
||||
const blank = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED })) as Record<string, unknown>;
|
||||
assert.equal(blank.merged, true);
|
||||
assert.match(String(blank.read_back), /without its sha/);
|
||||
});
|
||||
|
||||
test("gitea_merge_pull_request reads back a fast-forward, and says it cannot read back a squash or a rebase", async () => {
|
||||
state.mergeCommit = REVIEWED; // a fast-forward lands the head itself
|
||||
// Its parents name another commit second, so reading a fast-forward as a merge's second parent would fail here.
|
||||
state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b"];
|
||||
const ff = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED, method: "fast-forward-only" })) as Record<string, unknown>;
|
||||
assert.equal(ff.landed, REVIEWED);
|
||||
assert.equal(ff.read_back, undefined);
|
||||
|
||||
// A fast-forward whose commit the forge names otherwise is said, not raised: that it names the head is assumed.
|
||||
state.merged = false;
|
||||
state.mergeCommit = "feedface00000000000000000000000000000000";
|
||||
const odd = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED, method: "fast-forward-only" })) as Record<string, unknown>;
|
||||
assert.equal(odd.merged, true);
|
||||
assert.equal(odd.landed, undefined);
|
||||
assert.match(String(odd.read_back), /as this fast-forward's commit, not the head/);
|
||||
|
||||
for (const method of ["squash", "rebase", "rebase-merge"]) {
|
||||
state.merged = false;
|
||||
state.mergeCommit = "feedface00000000000000000000000000000000";
|
||||
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED, method })) as Record<string, unknown>;
|
||||
assert.equal(done.landed, undefined, method);
|
||||
assert.match(String(done.read_back), new RegExp(`a ${method} makes new commits that name no head`));
|
||||
}
|
||||
});
|
||||
|
||||
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
@@ -496,65 +284,6 @@ test("gitea_api sends the path it judged, and refuses moving a pull request's ba
|
||||
assert.equal(writes().length, 2);
|
||||
});
|
||||
|
||||
test("gitea_merge_pull_request merges only into a branch whose protection refuses its push", async () => {
|
||||
const approved_heads = [`novox/mesh-catalog@${REVIEWED}`];
|
||||
state.statuses = [{ context: "mesh/check", state: "success", description: `pass ${REVIEWED.slice(0, 12)} on ${TRUNK.slice(0, 12)}` }];
|
||||
state.rules = [];
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
|
||||
approved_heads }), /no protection rule refusing/);
|
||||
state.rules = [{ rule_name: "main", enable_push: true, enable_status_check: false, status_check_contexts: [] }];
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
|
||||
approved_heads }), /no protection rule refusing/);
|
||||
assert.deepEqual(writes(), []);
|
||||
});
|
||||
|
||||
// **The forge requires no status to merge; the merge tool still judges mesh/check, and asks the operator's Approve**
|
||||
// (novox/hq ADR 0534 §4): on a main whose rule takes no push and requires no status, a head merges only with
|
||||
// approved_heads naming it and a mesh/check that passed against the trunk's head at the merge.
|
||||
test("gitea_merge_pull_request judges mesh/check on a main requiring no status, and merges only an approved head", async () => {
|
||||
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
|
||||
keepSiblings(siblingsFile(stateDir), {});
|
||||
const approved_heads = [`novox/mesh-catalog@${REVIEWED}`];
|
||||
const merge = (args: Record<string, unknown> = { approved_heads }) =>
|
||||
tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
|
||||
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [],
|
||||
block_admin_merge_override: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }];
|
||||
const line = (trunk: string, s = "success", words = "pass") =>
|
||||
[{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }];
|
||||
const before = state.emitted.length;
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
await assert.rejects(merge({}), /Approve/);
|
||||
state.statuses = [];
|
||||
await assert.rejects(merge(), /mesh\/check is not set yet/);
|
||||
state.statuses = line(TRUNK, "pending", "checking");
|
||||
await assert.rejects(merge(), /mesh\/check is pending/);
|
||||
state.statuses = line(TRUNK, "failure", "fail");
|
||||
await assert.rejects(merge(), /mesh\/check is failure/);
|
||||
state.statuses = line(MOVED);
|
||||
await assert.rejects(merge(), new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`));
|
||||
state.statuses = judgedAt(TRUNK); // the old two, current: not mesh/check
|
||||
await assert.rejects(merge(), /mesh\/check/);
|
||||
assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's");
|
||||
assert.equal(state.emitted.length, before, "no check is asked");
|
||||
assert.equal(state.merged, false);
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
|
||||
// check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says;
|
||||
// without approved_heads it is refused as before.
|
||||
const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false });
|
||||
const mergeUngated = (args: Record<string, unknown> = { approved_heads }) =>
|
||||
ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
|
||||
for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) {
|
||||
state.merged = false;
|
||||
state.statuses = statuses;
|
||||
await assert.rejects(mergeUngated({}), /Approve/);
|
||||
assert.equal(((await mergeUngated()) as Record<string, unknown>).merged, true, JSON.stringify(statuses));
|
||||
}
|
||||
});
|
||||
|
||||
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
|
||||
@@ -635,9 +364,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
|
||||
["gitea_delete_branch", { ...repo, branch: "%2e%2e" }],
|
||||
["gitea_get_file", { ...repo, path: "../../../../version" }],
|
||||
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
|
||||
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
|
||||
["gitea_commit_statuses", { ...repo, sha: ".." }],
|
||||
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
||||
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
|
||||
@@ -661,172 +387,6 @@ 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 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 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 } });
|
||||
state.statuses = [{ context: "mesh/merge-gate", state: "success", description: `${beside} — pass: x` },
|
||||
{ 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 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-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(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";
|
||||
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, 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(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.
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
state.groupStatus = "";
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, 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(((await merge(["novox/mesh-catalog@" + REVIEWED.toUpperCase()])) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// **A group approved together merges in one pass, on the real merge tool** (novox/hq ADR 0418, issue 1049): the member's
|
||||
// own verdict named its sibling at an old head; the sibling got a new head, the group's composed verdict passed for both,
|
||||
// the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its
|
||||
// base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does.
|
||||
test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => {
|
||||
const { keepSiblings, siblingsFile, weighSiblings } = await import("../dist/siblings.js");
|
||||
const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD];
|
||||
const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40);
|
||||
const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`;
|
||||
state.statuses = [{ context: "mesh/merge-gate", state: "success",
|
||||
description: `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} \u2014 pass: group g composed: ok` },
|
||||
{ context: "mesh/repo-check", state: "success", description: `${beside} \u2014 pass: y` }];
|
||||
state.groupStatus = "success";
|
||||
state.siblingGroup = "success";
|
||||
// The member's own verdict named the sibling at its old head.
|
||||
const record: Record<string, any> = { "novox/mesh-catalog#7": { head: REVIEWED,
|
||||
siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } };
|
||||
// The composed verdict then judged both new heads.
|
||||
Object.assign(record, {
|
||||
"novox/mesh-catalog#7": { head: REVIEWED, siblings: [{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: HOST_BASE.slice(0, 12) }] },
|
||||
"novox/mesh-host#114": { head: SIBLING_HEAD, siblings: [{ repository: "novox/mesh-catalog", number: 7, head: REVIEWED, base: "main", base_commit: TRUNK.slice(0, 12) }] } });
|
||||
// The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what
|
||||
// that weighing decides, through the writer.
|
||||
const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE };
|
||||
const forge = {
|
||||
async getPullRequest(_o: string, repo: string, n: number) {
|
||||
return repo === "mesh-host" ? pulled : { number: n, title: "", html_url: "", merged: false, state: "open", head_sha: REVIEWED };
|
||||
},
|
||||
async branchHead() { return HOST_MERGE; },
|
||||
async commitParents() { return [HOST_BASE, SIBLING_HEAD]; },
|
||||
};
|
||||
const wrote: string[] = [];
|
||||
const gatesOut = { async outOfDate(_o: string, _r: string, sha: string, decide: (c: any) => any[]) {
|
||||
const put = decide({ "mesh/merge-gate": { state: "success", description: state.statuses[0].description },
|
||||
"mesh/repo-check": { state: "success", description: "x" } });
|
||||
for (const s of put) {
|
||||
wrote.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`);
|
||||
state.statuses = [{ ...s }, ...state.statuses.filter((x) => x.context !== s.context)];
|
||||
}
|
||||
return put;
|
||||
} };
|
||||
await weighSiblings(forge as any, gatesOut as any, record, "novox/mesh-host", 114);
|
||||
assert.deepEqual(wrote, [], "the member's statuses were left alone");
|
||||
keepSiblings(siblingsFile(stateDir), record);
|
||||
// The member merges on its first ask, with the one Approve's heads.
|
||||
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
|
||||
approved_heads: both })) as Record<string, unknown>;
|
||||
assert.equal(done.merged, true);
|
||||
assert.equal(state.emitted.length, 0, "no check was asked");
|
||||
});
|
||||
|
||||
// **Issue 1301 (ADR 0447 §1, §4; ADR 0503 §3, §5): the merge tool judges on the status the base requires.** A main that
|
||||
// requires mesh/check is judged by the checker's line ("pass <head12> on <trunk12> …"): it merges on a pass that names the
|
||||
// trunk's head, and refuses one that names an older trunk, naming both, writing nothing and asking nothing — a re-check on
|
||||
// a trunk's move is the checker's. No mesh/merge-gate is read or written there.
|
||||
test("gitea_merge_pull_request judges a main requiring mesh/check on mesh/check alone, and asks nothing of it", async () => {
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/check"] }];
|
||||
const line = (trunk: string, s = "success", words = "pass") =>
|
||||
[{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }];
|
||||
const before = state.emitted.length;
|
||||
|
||||
state.statuses = line(MOVED);
|
||||
await assert.rejects(merge(), (err: Error) => {
|
||||
assert.match(err.message, /mesh\/check/);
|
||||
assert.match(err.message, new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`));
|
||||
assert.match(err.message, /checker checks it again/i);
|
||||
assert.match(err.message, /Nothing was merged/);
|
||||
return true;
|
||||
});
|
||||
state.statuses = line(TRUNK, "pending", "checking");
|
||||
await assert.rejects(merge(), /mesh\/check is pending/);
|
||||
state.statuses = judgedAt(TRUNK); // the old two, current: not what this main requires
|
||||
await assert.rejects(merge(), /mesh\/check/);
|
||||
assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's");
|
||||
assert.equal(state.emitted.length, before, "no check is asked");
|
||||
assert.equal(state.merged, false);
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// hq's main requires mesh/repo-check alone (ADR 0503 §5): judged on its words, and an out-of-date one is asked again on
|
||||
// mesh/repo-check alone; no mesh/merge-gate is written on it.
|
||||
test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on it, and writes no mesh/merge-gate", async () => {
|
||||
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
|
||||
keepSiblings(siblingsFile(stateDir), {}); // no sibling record left by the tests before
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/repo-check"] }];
|
||||
state.statuses = [judgedAt(MOVED)[1]];
|
||||
const before = state.emitted.length;
|
||||
await assert.rejects(merge(), /judged against trunk/);
|
||||
assert.deepEqual(state.sent.filter((s) => s.path.includes("/statuses/")).map((s) => `${s.body.context} ${s.body.state}`),
|
||||
["mesh/repo-check pending"]);
|
||||
assert.equal(state.emitted.length, before + 1, "its check is asked once");
|
||||
await assert.rejects(merge(), /already asked/);
|
||||
state.sent = [];
|
||||
state.statuses = [judgedAt(TRUNK)[1]];
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as
|
||||
// gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards.
|
||||
test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => {
|
||||
@@ -870,3 +430,167 @@ test("the two new tools are named for what mesh_search is asked, and gitea_api s
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" }));
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" }));
|
||||
});
|
||||
|
||||
// **The seat git's verbs judge their caller, and merge-pull judges the Approve's head and no check** (design 47 *The seat
|
||||
// git*, ADR 0661 §3): a caller the trusted list does not name is refused before the forge is asked; merge-pull merges the
|
||||
// head it is named into a base that takes no push, whatever its statuses say, and an id said again after the merge is
|
||||
// answered performed.
|
||||
test("the seat git's verbs judge their caller, and merge-pull merges the Approve's head and judges no check", async () => {
|
||||
const { seatTools, gitCallers } = await import("../dist/seat.js");
|
||||
const { runAs } = await import("@novox/mesh-sdk/callers");
|
||||
const bridge = "ace.mesh-delivery-forge-bridge";
|
||||
const list = JSON.stringify({ publish: ["mesh-build-go"], "merge-pull": ["mesh-delivery-forge-bridge"],
|
||||
pull: ["mesh-delivery-forge-bridge", "mesh-delivery-gate"], status: ["mesh-delivery-forge-bridge"] });
|
||||
assert.deepEqual(Object.keys(gitCallers(list)).sort(), ["merge-pull", "pull", "status"], "only the seat git's lines are its");
|
||||
assert.deepEqual(gitCallers(undefined), {}, "unset, no one may call");
|
||||
// The head's one writer of its statuses (gate.ts), over a forge whose first write is still in flight when the next is asked.
|
||||
const { GateWriter } = await import("../dist/gate.js");
|
||||
const landed: string[] = [];
|
||||
let inFlight: Promise<void> | null = null, land = () => {};
|
||||
const slow = {
|
||||
headChecks: async () => ({}),
|
||||
setCommitStatus: async (_o: string, _r: string, _sha: string, s: { state: string; description: string }) => {
|
||||
if (inFlight) { const w = inFlight; inFlight = null; await w; }
|
||||
landed.push(`${s.state} ${s.description}`);
|
||||
},
|
||||
};
|
||||
const gates = new GateWriter(slow);
|
||||
const seat = seatTools(Object.assign(Object.create(client), { setCommitStatus: slow.setCommitStatus }),
|
||||
{ run: async () => ({ stdout: "", code: 0 }), container: "gitea", callers: async () => list, statuses: gates });
|
||||
const call = (verb: string, args: Record<string, unknown>, caller = bridge) =>
|
||||
runAs({ "mesh/caller": caller }, args, () => seat.find((t: any) => t.name === `git.${verb}`)!.run(args)) as Promise<any>;
|
||||
const merge = (extra: Record<string, unknown> = {}, caller?: string) =>
|
||||
call("merge-pull", { id: "act-1", repository: "novox/mesh-catalog", number: 7, head: REVIEWED, ...extra }, caller);
|
||||
const rows: { name: string; set: () => void; caller?: string; extra?: Record<string, unknown>; want: RegExp | "merged" | "held"; loud?: boolean }[] = [
|
||||
{ name: "a caller not on the list", set: () => {}, caller: "ace.mesh-delivery-checker", want: /refused for ace.mesh-delivery-checker/ },
|
||||
{ name: "an unproven caller", set: () => {}, caller: "", want: /no caller the tool runner proved/ },
|
||||
{ name: "a head that moved", set: () => { state.head = MOVED; }, want: /its head is 3c4d5e6f7a8b, not the abc1234def56/ },
|
||||
{ name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/ },
|
||||
{ name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/ },
|
||||
{ name: "a failed check", set: () => { state.statuses = [{ context: "mesh/check", state: "failure", description: "fail" }]; }, want: "merged" },
|
||||
{ name: "no status at all", set: () => { state.statuses = []; }, want: "merged" },
|
||||
{ name: "a check of another trunk", set: () => { state.statuses = judgedAt(MOVED); }, want: "merged" },
|
||||
{ name: "the id said again after the merge", set: () => { state.merged = true; }, want: "held" },
|
||||
{ name: "a pull request merged at another head", set: () => { state.merged = true; state.head = MOVED; }, want: /merged at 3c4d5e6f7a8b, not the abc1234def56/ },
|
||||
{ name: "a closed pull request", set: () => { state.prState = "closed"; }, want: /it is closed/ },
|
||||
{ name: "a fast-forward", set: () => { state.parents = [REVIEWED]; }, want: "merged" },
|
||||
{ name: "the merge read back fails", set: () => { state.commitsFail = 502; }, want: "merged" },
|
||||
{ name: "the forge merged another head", set: () => { state.landed = MOVED; }, want: /WAS merged .*the forge merged 3c4d5e6f7a8b, not the abc1234def56/, loud: true },
|
||||
];
|
||||
for (const r of rows) {
|
||||
state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = [];
|
||||
state.rules = [protectedMain()]; state.prState = "open"; state.parents = null; state.commitsFail = 0; state.landed = "";
|
||||
r.set();
|
||||
const got = await merge(r.extra, r.caller);
|
||||
if (r.want === "merged") {
|
||||
assert.equal(got.performed, true, `${r.name}: ${JSON.stringify(got)}`);
|
||||
assert.equal(got["merge-commit"], state.mergeCommit, r.name);
|
||||
assert.equal(state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))?.body.head_commit_id, REVIEWED, r.name);
|
||||
} else if (r.want === "held") {
|
||||
assert.deepEqual([got.performed, got.held], [true, true], r.name);
|
||||
assert.deepEqual(writes(), [], `${r.name}: nothing merged again`);
|
||||
} else {
|
||||
assert.match(String(got.refused ?? got.error), r.want, r.name);
|
||||
assert.deepEqual(writes(), r.loud ? ["POST /repos/novox/mesh-catalog/pulls/7/merge"] : [], `${r.name}: what was merged`);
|
||||
}
|
||||
}
|
||||
// A read: an open pull request answers what the forge would merge now; a forge that fails is the kind failed.
|
||||
state.merged = false; state.head = REVIEWED; state.rules = [protectedMain()];
|
||||
const pull = await call("pull", { repository: "novox/mesh-catalog", number: 7 });
|
||||
assert.equal(pull.pull, undefined, "answered whole or not at all");
|
||||
assert.equal(pull.error?.startsWith("the forge did not say whether novox/mesh-catalog#7 merges now"), true, JSON.stringify(pull));
|
||||
assert.equal(pull["error-kind"], "failed", "a read the forge could not make answers the verb's error, never a guess");
|
||||
state.mergeable = true;
|
||||
const open = await call("pull", { repository: "novox/mesh-catalog", number: 7 });
|
||||
state.mergeable = undefined;
|
||||
assert.deepEqual([open.pull?.head, open.pull?.state, open.merging?.protected, open.merging?.required],
|
||||
[REVIEWED, "open", true, ["mesh/merge-gate", "mesh/repo-check"]], JSON.stringify(open));
|
||||
assert.equal("body" in (open.pull ?? {}), false, "never its description");
|
||||
// Two writes of one head out of order (issue 456): a recheck's pending still in flight when the verdict is set through the
|
||||
// seat; the verdict takes its turn after it and is what the head carries.
|
||||
inFlight = new Promise<void>((r) => (land = r));
|
||||
const recheck = gates.pending("novox", "mesh-catalog", REVIEWED);
|
||||
const verdict = call("status", { id: "act-s", repository: "novox/mesh-catalog", commit: REVIEWED, state: "success",
|
||||
context: "mesh/merge-gate", description: "passed" });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
land();
|
||||
await recheck;
|
||||
assert.equal((await verdict).performed, true);
|
||||
assert.equal(landed.at(-1), "success passed", `the verdict lands last: ${JSON.stringify(landed)}`);
|
||||
// A pull request the forge does not have is the kind not-found, so a caller never reads it out of the words.
|
||||
const gone = await call("pull", { repository: "novox/mesh-catalog", number: 404 });
|
||||
assert.deepEqual([gone.pull, gone["error-kind"]], [undefined, "not-found"], JSON.stringify(gone));
|
||||
});
|
||||
|
||||
// Each verb of the seat git answers the contract's shape from what the forge gives, and an error naming why when the forge
|
||||
// does not (design 47 §310, §312, §317): an empty list or an absent file only when the forge said so, a forge that failed
|
||||
// the kind failed, a 404 the kind not-found, and an act whose id the pull request or the note already holds is held.
|
||||
test("every read and act of the seat git answers its contract, and an error is an error, never an empty answer", async () => {
|
||||
const { seatTools } = await import("../dist/seat.js");
|
||||
const { runAs } = await import("@novox/mesh-sdk/callers");
|
||||
const SHA = "a".repeat(40), OTHER = "b".repeat(40);
|
||||
const gone = () => { throw new Error("Gitea API GET /repos/novox/app/pulls/9: 404 Not Found"); };
|
||||
const down = () => { throw new Error("Gitea API GET /repos/novox/app/x: 502 Bad Gateway"); };
|
||||
const pr = { number: 9, title: "t", state: "open", merged: false, head: "feat/x", head_sha: SHA, base: "main", user: "jochen", html_url: "http://f/9", mergeable: true };
|
||||
let comments: { id: number; body: string }[] = [], noteShown = "", added = 0, noteWritten = 0;
|
||||
const forge: Record<string, any> = {
|
||||
getPullRequest: async (_o: string, _r: string, n: number) => (n === 9 ? pr : gone()),
|
||||
pullOfCommit: async () => ({ ...pr, state: "closed", merged: true, merge_commit_sha: OTHER, merged_at: "2026-11-11T10:00:00Z" }),
|
||||
appliedProtection: async () => ({ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/check"] }),
|
||||
getRepo: async () => ({ writes: true }),
|
||||
openPulls: async () => [{ owner: "novox", repo: "app", number: 9, base: "main", head: "feat/x", head_sha: SHA, required: ["mesh/check"], checks: {} }],
|
||||
tags: async () => ["go/v0.1.1", "go/v0.1.0", "v1"],
|
||||
commitTrees: async () => ({ root: SHA, trees: { "": SHA } }),
|
||||
getFile: async (_o: string, _r: string, path: string) => (path === "gone" ? (() => { throw new Error("Gitea API GET /repos/novox/app/contents/gone: 404 Not Found"); })() : path === "down" ? down() : { path, sha: OTHER, size: 3, content: "abc" }),
|
||||
compare: async () => ({ total_commits: 2, commits: [{ sha: OTHER }, { sha: SHA }] }),
|
||||
combinedStatus: async () => ({ sha: SHA, state: "success", statuses: [{ context: "mesh/check", status: "success", description: "ok" }] }),
|
||||
listComments: async () => comments,
|
||||
addComment: async (_o: string, _r: string, _n: number, body: string) => { added++; comments.push({ id: added, body }); },
|
||||
editComment: async () => {},
|
||||
};
|
||||
const run = async (_f: string, args: string[]) => args.includes("show") || args.includes("--ref") && !args.includes("append")
|
||||
? { stdout: noteShown, code: noteShown ? 0 : 1 } : (noteWritten++, { stdout: "", code: 0 });
|
||||
const seat = seatTools(forge as any, { run: run as any, container: "gitea", callers: async () => JSON.stringify({}), statuses: { status: async () => {} } });
|
||||
const everyone = JSON.stringify(Object.fromEntries(seat.map((t: any) => [t.name.slice(4), ["*"]])));
|
||||
const call = (verb: string, args: Record<string, unknown>, over: Record<string, unknown> = {}) => {
|
||||
const t = seat.find((x: any) => x.name === `git.${verb}`)!;
|
||||
const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", callers: async () => everyone, statuses: { status: async () => {} } });
|
||||
return runAs({ "mesh/caller": "ace.x" }, args, () => wrapped.find((x: any) => x.name === t.name)!.run(args)) as Promise<any>;
|
||||
};
|
||||
const rows: [string, string, Record<string, unknown>, Record<string, unknown>, (a: any) => unknown, unknown][] = [
|
||||
["pull", "by number", { repository: "novox/app", number: 9 }, {}, (a) => [a.pull.head, a.pull.state, a.merging.mergeable, a.merging.required, a.merging.writes], [SHA, "open", true, ["mesh/check"], true]],
|
||||
["pull", "by merge commit", { repository: "novox/app", "merge-commit": OTHER }, {}, (a) => [a.pull.state, a.pull["merge-commit"], a.merging], ["merged", OTHER, undefined]],
|
||||
["pull", "unknown", { repository: "novox/app", number: 404 }, {}, (a) => [a.pull, a["error-kind"]], [undefined, "not-found"]],
|
||||
["pull", "forge down", { repository: "novox/app", number: 9 }, { getPullRequest: down }, (a) => [a.pull, a["error-kind"]], [undefined, "failed"]],
|
||||
["open-pulls", "one", {}, {}, (a) => [a.pulls.length, a.pulls[0].required], [1, ["mesh/check"]]],
|
||||
["open-pulls", "forge down", {}, { openPulls: down }, (a) => [a.pulls, a["error-kind"]], [undefined, "failed"]],
|
||||
["tags", "a page", { repository: "novox/app", prefix: "go/" }, {}, (a) => [a.tags, a.page, a.bound, a.more], [["go/v0.1.1", "go/v0.1.0"], 1, 50, undefined]],
|
||||
["tags", "forge down", { repository: "novox/app" }, { tags: down }, (a) => [a.tags, a["error-kind"]], [undefined, "failed"]],
|
||||
["trees", "root", { repository: "novox/app", commit: SHA }, {}, (a) => [a.commit, a.root], [SHA, SHA]],
|
||||
["trees", "forge down", { repository: "novox/app", commit: SHA }, { commitTrees: down }, (a) => [a.root, a["error-kind"]], [undefined, "failed"]],
|
||||
["file", "present", { repository: "novox/app", path: "a.txt" }, {}, (a) => [a.content, a.absent], ["abc", undefined]],
|
||||
["file", "the forge said absent", { repository: "novox/app", path: "gone" }, {}, (a) => [a.absent, a.error], [true, undefined]],
|
||||
["file", "forge down is no absent file", { repository: "novox/app", path: "down" }, {}, (a) => [a.absent, a["error-kind"]], [undefined, "failed"]],
|
||||
["contains", "ahead", { repository: "novox/app", commit: SHA, in: OTHER }, {}, (a) => [a.contains, a.ahead], [false, 2]],
|
||||
["contains", "forge down", { repository: "novox/app", commit: SHA, in: OTHER }, { compare: down }, (a) => [a.contains, a["error-kind"]], [undefined, "failed"]],
|
||||
["statuses", "newest of each", { repository: "novox/app", commit: SHA }, {}, (a) => [a.combined, a.statuses.map((s: any) => s.context)], ["success", ["mesh/check"]]],
|
||||
["statuses", "forge down", { repository: "novox/app", commit: SHA }, { combinedStatus: down }, (a) => [a.statuses, a["error-kind"]], [undefined, "failed"]],
|
||||
["status", "refused state", { id: "s1", repository: "novox/app", commit: SHA, context: "c", state: "bogus" }, {}, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]],
|
||||
["comment", "first time", { id: "c1", repository: "novox/app", number: 9, text: "hi" }, {}, (a) => [a.performed, a.held, added], [true, undefined, 1]],
|
||||
["comment", "forge down", { id: "c2", repository: "novox/app", number: 9, text: "hi" }, { listComments: down }, (a) => [a.performed, a["error-kind"]], [undefined, "failed"]],
|
||||
["view", "first time", { id: "v1", repository: "novox/app", number: 9, text: "state" }, {}, (a) => [a.performed, a.held], [true, undefined]],
|
||||
["note", "a line", { id: "n1", repository: "novox/app", commit: SHA, line: "planned" }, {}, (a) => [a.performed, a.held, noteWritten], [true, undefined, 1]],
|
||||
];
|
||||
for (const [verb, name, args, over, pick, want] of rows) {
|
||||
assert.deepEqual(pick(await call(verb, args, over)), want, `${verb}: ${name}`);
|
||||
}
|
||||
// The same comment id said again is held, and nothing is written again; so is a note line whose id the note holds.
|
||||
const before = added;
|
||||
const again = await call("comment", { id: "c1", repository: "novox/app", number: 9, text: "hi" });
|
||||
assert.deepEqual([again.performed, again.held, added], [true, true, before]);
|
||||
const view = await call("view", { id: "v1", repository: "novox/app", number: 9, text: "state" });
|
||||
assert.deepEqual([view.performed, view.held], [true, true]);
|
||||
noteShown = "planned [n1]"; noteWritten = 0;
|
||||
const note = await call("note", { id: "n1", repository: "novox/app", commit: SHA, line: "worded differently" });
|
||||
assert.deepEqual([note.performed, note.held, noteWritten], [true, true, 0]);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@ test("the states the module announces are the states its manifest declares", asy
|
||||
|
||||
test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => {
|
||||
const { statesAnnouncement } = await import("../kept.ts");
|
||||
assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] });
|
||||
assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] });
|
||||
assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }, { name: "first-views" }] });
|
||||
assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }, { name: "first-views" }] });
|
||||
});
|
||||
|
||||
test("index.ts says the announcement as the event `states` before the tools are served", () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ beforeEach(() => {
|
||||
enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0,
|
||||
enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false,
|
||||
force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }];
|
||||
source = {};
|
||||
repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true };
|
||||
sent = [];
|
||||
});
|
||||
@@ -88,11 +87,7 @@ await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
after(() => new Promise<void>((r) => server.close(() => r())));
|
||||
const { port } = server.address() as { port: number };
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories
|
||||
// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel.
|
||||
let source: Record<string, { heads: Record<string, string> }> = {};
|
||||
const readSource = async () => source;
|
||||
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource);
|
||||
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"));
|
||||
const setRepo = (args: Record<string, unknown>) =>
|
||||
tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args });
|
||||
const setFull = (args: Record<string, unknown>) =>
|
||||
@@ -227,24 +222,12 @@ test("a force-push onto a rule covering main is refused, nothing sent; on anothe
|
||||
assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer");
|
||||
});
|
||||
|
||||
// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as
|
||||
// the gate's source names them; "main" only when it names none, and said.
|
||||
test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => {
|
||||
source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } };
|
||||
await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/);
|
||||
await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/);
|
||||
assert.deepEqual(sent, []);
|
||||
const main: any = await setFull({ enable_force_push: true });
|
||||
assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" });
|
||||
assert.equal(main.trunk_rule.is_trunk, false);
|
||||
const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true });
|
||||
assert.equal(beta.trunk_rule.is_trunk, true);
|
||||
assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why);
|
||||
});
|
||||
|
||||
test("a source that names no trunk of the repository falls back to main, and says so", async () => {
|
||||
// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branch the mesh installs from: main,
|
||||
// the mesh's one trunk of every repository (design 47); no other module's state is read for it (ADR 0596 §2).
|
||||
test("main is the repository's trunk, read from no other module, and another branch is none", async () => {
|
||||
const out: any = await setFull({ block_admin_merge_override: true });
|
||||
assert.equal(out.trunks.names[0], "main");
|
||||
assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/);
|
||||
assert.deepEqual(out.trunks, { names: ["main"], from: "the mesh's one trunk of every repository (design 47)" });
|
||||
assert.equal(out.trunk_rule.is_trunk, true);
|
||||
const beta: any = await setFull({ branch: "beta", enable_force_push: true });
|
||||
assert.equal(beta.trunk_rule.is_trunk, false);
|
||||
});
|
||||
|
||||
@@ -527,8 +527,7 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
withAdmin.tools.map((t) => t.name),
|
||||
[
|
||||
"gitea_list_repos", "gitea_create_repo", "gitea_delete_repo",
|
||||
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment",
|
||||
// gitea_merge_pull_request is served from the watcher's bundle, beside the merge check's one writer (ADR 0323).
|
||||
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue",
|
||||
"gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request",
|
||||
"gitea_close_pull_request",
|
||||
"gitea_reopen_pull_request",
|
||||
@@ -542,7 +541,7 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full",
|
||||
"gitea_default_branch_set", "gitea_pull_base_set",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
|
||||
"gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
],
|
||||
|
||||
@@ -638,7 +638,7 @@ test("on the forge's events: a repository made is granted read at once, a known
|
||||
// The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator,
|
||||
// 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there.
|
||||
|
||||
test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => {
|
||||
test("the merging account is given write on each repository the catalog half's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => {
|
||||
const env = await environment();
|
||||
const client = fresh(env);
|
||||
await client.listRepos(); // the account and its team exist, as after any mint
|
||||
@@ -652,13 +652,17 @@ test("the merging account is given write on each repository the indexer's module
|
||||
const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l),
|
||||
async (type: string, body: unknown) => { said.push({ type, body }); });
|
||||
const [unwritableTool, grantTool] = writeTools(() => writes);
|
||||
await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read");
|
||||
await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the catalog half's first view is held");
|
||||
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write");
|
||||
|
||||
// 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once.
|
||||
await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true });
|
||||
await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true });
|
||||
await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true });
|
||||
// 1945/AC1: the first view hands the catalog half's modules; a user's repository a module builds from is made writable,
|
||||
// once, and only once the first view is held (design 47: a part acts on nothing before its first view).
|
||||
const viewing = forge.calls.length;
|
||||
await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" } });
|
||||
await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" } });
|
||||
await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" } });
|
||||
assert.equal(forge.calls.length, viewing, "the first view's entries ask the forge nothing until it is held");
|
||||
await writes.viewed();
|
||||
const from = forge.calls.length;
|
||||
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
|
||||
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
|
||||
@@ -668,7 +672,7 @@ test("the merging account is given write on each repository the indexer's module
|
||||
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
|
||||
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
|
||||
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
|
||||
assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/);
|
||||
assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds a registered module/);
|
||||
assert.match(lines.join("\n"), /broken names no repository/);
|
||||
|
||||
// 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's
|
||||
@@ -723,7 +727,7 @@ test("the merging account is given write on each repository the indexer's module
|
||||
await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT),
|
||||
/mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/);
|
||||
userRules.delete("jschoubben/dotfiles");
|
||||
// A module gone from the indexer leaves its repository out of the delivered list.
|
||||
// A module gone from the catalog half leaves its repository out of the delivered list.
|
||||
await writes.take({ key: "snake", op: "delete" });
|
||||
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
|
||||
});
|
||||
|
||||
@@ -1,62 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { ViewFollower, findingKey, unansweredFinding, viewTools, type ViewAnswer, type ViewChange, type ViewEvent } from "../views.ts";
|
||||
import { viewWaitsFinding } from "@novox/mesh-sdk/views";
|
||||
import { CatalogView, REVISION_VERB, findingValue, revisionAnswer, unansweredFinding } from "../catalog.ts";
|
||||
|
||||
// ADR 0615: the module asks the indexer's read tool once, then keeps its picture of `modules` from the indexer's views
|
||||
// events; a gap asks once more; an owner that is down is asked again and never read instead.
|
||||
// Design 47 *Every part takes a first view, then follows the events*: the forge module asks the seat mesh-catalog's verb
|
||||
// `revision` for the catalog half (every page of one trunk revision), then hears its event `catalog-revision`. A candidate's
|
||||
// revision moves nothing; a newer trunk revision is waited for, said, and asked; waited for past the bound, view-waits is
|
||||
// written, and cleared once held; an owner that does not answer is asked again and raised past its bound.
|
||||
|
||||
const made = "2026-11-04T12:00:00Z";
|
||||
const answer = (revision: number, values: Record<string, unknown>): ViewAnswer =>
|
||||
({ v: 1, owner: "o", state: "modules", revision, created: made, values });
|
||||
const ev = (revision: number, key: string, value?: unknown, extra: Partial<ViewEvent> = {}): ViewEvent =>
|
||||
({ v: 1, owner: "o", state: "modules", key, revision, created: made, value, deleted: value === undefined, ...extra });
|
||||
const entry = (module: string, repository: string) => ({ module, repository, dir: ".", manifest: "m", fingerprint: "f", verdict: "accepted" });
|
||||
const revisionOf = (revision: number, kind = "trunk") => ({ revision, kind, source: revision, entries: [] });
|
||||
|
||||
test("a follower takes the first view, then the events, and asks again only on a gap", async () => {
|
||||
const changes: ViewChange[] = [];
|
||||
/** A holder of the seat whose trunk revisions are pages of entries, and who answers what is asked of it. */
|
||||
function aHolder() {
|
||||
const asked: Record<string, unknown>[] = [];
|
||||
const trunk = new Map<number, { module: string; repository: string }[][]>([
|
||||
[7, [[entry("a", "u/a"), entry("b", "u/b")], [entry("c", "u/c")]]],
|
||||
[8, [[entry("a", "u/a"), entry("c", "u/c2")]]],
|
||||
]);
|
||||
let newest = 7;
|
||||
let down = 0;
|
||||
return {
|
||||
asked,
|
||||
set newest(n: number) { newest = n; },
|
||||
set down(n: number) { down = n; },
|
||||
ask: async (verb: string, input: { after?: string; revision?: number }) => {
|
||||
assert.equal(verb, REVISION_VERB);
|
||||
asked.push({ ...input });
|
||||
if (down > 0) { down--; return { error: "no responders" }; }
|
||||
const rev = input.revision ?? newest;
|
||||
const pages = trunk.get(rev)!;
|
||||
const at = input.after ? pages.findIndex((p) => p[0].module > input.after!) : 0;
|
||||
return { revision: revisionOf(rev), entries: pages[at], ...(at < pages.length - 1 ? { next: pages[at].at(-1)!.module } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the catalog view takes the trunk's first view page by page, then a newer trunk revision, and a candidate's moves nothing", async () => {
|
||||
const holder = aHolder();
|
||||
const changes: string[] = [];
|
||||
const view = new CatalogView(holder.ask, { pause: async () => {}, changed: (k, v, deleted) => { changes.push(deleted ? `-${k}` : `${k}=${(v as any).repository}`); } });
|
||||
await view.follower.start();
|
||||
assert.deepEqual(holder.asked, [{}, { after: "b", revision: 7 }], "the second page names the first page's revision");
|
||||
assert.deepEqual([view.held, view.follower.status().view, changes.sort()], [7, "7", ["a=u/a", "b=u/b", "c=u/c"]]);
|
||||
const rows: { name: string; ev: Record<string, unknown>; asks: number; held: number }[] = [
|
||||
{ name: "a candidate's revision", ev: { v: 1, kind: "candidate", revision: 9, source: 8, group: "g", heads: "h" }, asks: 0, held: 7 },
|
||||
{ name: "the revision it holds", ev: { v: 1, kind: "trunk", revision: 7, source: 7 }, asks: 0, held: 7 },
|
||||
{ name: "a newer trunk revision", ev: { v: 1, kind: "trunk", revision: 8, source: 8 }, asks: 1, held: 8 },
|
||||
];
|
||||
for (const r of rows) {
|
||||
holder.newest = 8;
|
||||
holder.asked.splice(0);
|
||||
changes.splice(0);
|
||||
await view.hear(r.ev as any);
|
||||
await view.asked;
|
||||
assert.deepEqual([holder.asked.length, view.held], [r.asks, r.held], r.name);
|
||||
}
|
||||
assert.deepEqual(changes.sort(), ["-b", "c=u/c2"], "the newer revision hands what changed");
|
||||
const s = view.follower.status();
|
||||
assert.deepEqual([s.view, s["waits-for"]], ["8", undefined], "it holds what it waited for");
|
||||
await assert.rejects(view.hear({ v: 1, kind: "trunk" } as any), /not a mesh-catalog.catalog-revision event/);
|
||||
assert.throws(() => revisionAnswer({ error: "refused for gitea" }), /answered no revision: refused for gitea/);
|
||||
});
|
||||
|
||||
test("a revision waited for past the bound writes view-waits, cleared once held; an unanswered first view is raised, then cleared", async () => {
|
||||
const holder = aHolder();
|
||||
const said: string[] = [];
|
||||
let asks = 0;
|
||||
let down = 2;
|
||||
const raised: string[][] = [];
|
||||
const f = new ViewFollower({ owner: "o", state: "modules", tool: "o.read",
|
||||
ask: async () => { if (down-- > 0) throw new Error("no responders"); asks++; return answer(9, { a: { repository: "novox/a" }, b: { repository: "novox/b" } }); },
|
||||
take: (c) => { changes.push(c); }, raise: (_a, e) => { raised.push(["raised", e]); }, clear: () => { raised.push(["cleared", ""]); }, say: (l) => said.push(l), sleep: async () => {}, now: (() => { let t = 0; return () => (t += 200_000); })() });
|
||||
await f.start();
|
||||
assert.equal(f.ready, true);
|
||||
assert.equal(f.values.size, 2);
|
||||
assert.deepEqual(raised.map((r) => r.slice(0, 2)), [["raised", "no responders"], ["cleared", ""]]);
|
||||
assert.ok(said.some((l) => l.includes("no first view of o's modules") && l.includes("o.read")), said.join("|"));
|
||||
await f.hear(ev(8, "a", { repository: "old" })); // already in the picture
|
||||
await f.hear(ev(10, "c", { repository: "novox/c" }));
|
||||
await f.hear(ev(11, "a")); // deleted
|
||||
assert.deepEqual([f.revision, f.values.has("a"), f.values.has("c"), asks], [11, false, true, 1]);
|
||||
assert.deepEqual(changes.slice(-2).map((c) => [c.key, c.op]), [["c", "put"], ["a", "delete"]]);
|
||||
await f.hear(ev(13, "d", { repository: "x" })); // revision 12 missed: a gap
|
||||
assert.equal(asks, 2);
|
||||
await f.hear(ev(1, "e", { repository: "x" }, { created: "2026-11-04T13:00:00Z" })); // the state made again: a gap
|
||||
assert.equal(asks, 3);
|
||||
await f.hear(ev(10, "f", undefined, { gap: true })); // the next revision, too large to carry: a gap
|
||||
assert.equal(asks, 4);
|
||||
await f.hear({ ...ev(10, "g", { repository: "x" }), state: "other" }); // another state
|
||||
assert.equal(f.values.has("g"), false);
|
||||
});
|
||||
|
||||
test("the reader's two tools say where the first view stands and ask it again by hand", async () => {
|
||||
let asks = 0;
|
||||
const acts: Record<string, unknown>[] = [];
|
||||
const follower = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", ask: async () => { asks++; return answer(5, { a: 1 }); },
|
||||
take: () => {}, sleep: async () => {} });
|
||||
const [first, again] = viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async (act) => { acts.push(act); });
|
||||
assert.deepEqual(await first.run({}), { views: [{ owner: "o", tool: "o.read", state: "modules", revision: 0, created: "", keys: 0, answered: false, asks: 0, "by-hand": 0 }] });
|
||||
const after = (await again.run({})) as { views: { revision: number; "by-hand": number; asks: number }[] };
|
||||
assert.deepEqual([after.views[0].revision, after.views[0]["by-hand"], asks, acts.length], [5, 1, 1, 1]);
|
||||
await assert.rejects(viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async () => { throw new Error("no holder"); })[1].run({}), /not recorded/);
|
||||
assert.deepEqual(await viewTools(() => undefined, async () => {})[0].run({}), { views: [] });
|
||||
});
|
||||
|
||||
test("the finding of a first view unanswered is the one mesh-sdk writes: key, kind, subject and the words", () => {
|
||||
const f = unansweredFinding("gitea", "mesh-delivery-indexer", "modules", "mesh-delivery-indexer.indexer_read_modules", new Date("2026-11-04T12:00:00Z"), "no responders", new Date("2026-11-04T12:03:00Z"));
|
||||
assert.equal(f.key, "module.gitea.mesh-delivery-indexer.modules.first-view-unanswered");
|
||||
assert.equal(findingKey("module", "a.b", "k"), "module.a.b.k");
|
||||
assert.equal(f.value.severity, "warning");
|
||||
assert.match(String(f.value.summary), /gitea has no first view of mesh-delivery-indexer's modules: .* \(no responders\)/);
|
||||
let bound = () => {};
|
||||
let clock = 0;
|
||||
const view = new CatalogView(holder.ask, {
|
||||
pause: () => new Promise((r) => setImmediate(r)), bound: 1000, now: () => new Date(Date.parse("2026-11-09T22:00:00Z") + (clock += 600)),
|
||||
waitsBound: () => new Promise<void>((r) => (bound = r)),
|
||||
raise: (s) => { said.push(`raised ${findingValue(unansweredFinding("gitea", s, new Date())).key}`); },
|
||||
clear: () => { said.push("cleared"); },
|
||||
raiseWaits: (s) => { said.push(`waits ${findingValue(viewWaitsFinding("gitea", s, new Date())).key}`); },
|
||||
clearWaits: () => { said.push("waits cleared"); },
|
||||
});
|
||||
holder.down = 3;
|
||||
await view.follower.start();
|
||||
assert.deepEqual(said, ["raised module.gitea.mesh-catalog.catalog-revisions.first-view-unanswered", "cleared"]);
|
||||
said.splice(0);
|
||||
holder.newest = 8;
|
||||
holder.down = 100; // the holder is away when the newer revision is said
|
||||
const heard = view.hear({ v: 1, kind: "trunk", revision: 8, source: 8 } as any);
|
||||
await heard;
|
||||
assert.equal(view.follower.status()["waits-for"], "8", "it says what it waits for");
|
||||
bound();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.deepEqual(said.filter((l) => l.startsWith("waits")), ["waits module.gitea.mesh-catalog.catalog-revisions.view-waits"]);
|
||||
holder.down = 0;
|
||||
await view.follower.gap("the holder is back");
|
||||
await view.asked;
|
||||
assert.deepEqual([view.held, view.follower.status()["waits-for"], said.includes("waits cleared")], [8, undefined, true]);
|
||||
});
|
||||
|
||||
@@ -12,9 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js";
|
||||
import { readTeamRights } from "../teamrights.js";
|
||||
import { state } from "@novox/mesh-sdk/state";
|
||||
import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js";
|
||||
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
|
||||
import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, trunksOf, givenFields, trunkRule, unapplied } from "../protectfull.js";
|
||||
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
|
||||
import {
|
||||
MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, pathSegments, routedPath, sentQuery,
|
||||
@@ -24,10 +22,6 @@ import {
|
||||
/** 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. */
|
||||
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
|
||||
|
||||
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). Its name
|
||||
* is the manifest's container's; no variable names it (a MESH_* word nothing sets is refused, checks/environment). */
|
||||
const forgeContainer = "gitea";
|
||||
|
||||
/** A protection rule as a person reads it: what it guards, not every field the forge keeps. */
|
||||
export function summarised(p: BranchProtection) {
|
||||
return {
|
||||
@@ -91,13 +85,14 @@ function filesDirs(raw: unknown): string[] | undefined {
|
||||
return raw as string[];
|
||||
}
|
||||
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea, admin, source));
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
// The seat git's verbs are served by the events entrypoint (index.ts), beside the head's one writer of its statuses.
|
||||
return namesChecked(allGiteaTools(gitea, admin));
|
||||
}
|
||||
|
||||
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
|
||||
// writer (novox/hq issue 456, ADR 0323).
|
||||
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] {
|
||||
// A merge, a status, a comment, a delivery's view and its note are the seat git's acts (seat.ts), asked by the forge
|
||||
// bridge alone: no tool of the module's own does them (novox/hq design 47 *The seat git*).
|
||||
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined): ToolDefinition[] {
|
||||
return [
|
||||
// ---- Repositories ----
|
||||
{
|
||||
@@ -233,19 +228,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
|
||||
issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "closed"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_add_comment",
|
||||
description: "Add a comment to an issue or pull request.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the issue or PR number" },
|
||||
body: { type: "string", description: "the comment body (markdown)" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)),
|
||||
}),
|
||||
},
|
||||
|
||||
// ---- Pull requests ----
|
||||
{
|
||||
@@ -502,7 +484,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
|
||||
"enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " +
|
||||
"flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " +
|
||||
"forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " +
|
||||
"tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " +
|
||||
"tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunk, the branch the mesh installs from, is main, the mesh's one trunk of every repository (said in trunks.from). As the forge's " +
|
||||
"administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " +
|
||||
"this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " +
|
||||
"agent-<node>, no administrator override) and why not.",
|
||||
@@ -524,7 +506,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
|
||||
const given = givenFields(args);
|
||||
if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`);
|
||||
const body = fullProtectionBody(args, given);
|
||||
const trunks = await trunksOf(owner, repo, source);
|
||||
const trunks = trunksOf();
|
||||
const operators = forcePushToTrunk(branch, body, trunks.names);
|
||||
if (operators) throw new Refused(operators);
|
||||
const forge = admin ? admin() : gitea;
|
||||
@@ -640,40 +622,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
|
||||
},
|
||||
},
|
||||
|
||||
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
|
||||
{
|
||||
name: "gitea_note_append",
|
||||
description: "Append one line to a commit's git note under refs/notes/<ref> (mesh-plan for a delivery), written in the forge's own repository as the forge's own account; a line the note already holds is not added again. `git log --notes=mesh-plan` shows it.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit" },
|
||||
ref: { type: "string", description: "the notes ref's name (default mesh-plan)" },
|
||||
line: { type: "string", description: "the line, one line" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const said = await appendNote(run, forgeContainer, String(args.owner), String(args.repo), String(args.sha),
|
||||
String(args.ref ?? "mesh-plan") || "mesh-plan", String(args.line ?? ""));
|
||||
return { commit: String(args.sha), ...said };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_delivery_view",
|
||||
description: "Keep a delivery's view on its pull request: one comment, marked as the delivery's, created the first time and edited in place after — the page every status of the delivery links to.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the pull request's number" },
|
||||
body: { type: "string", description: "the view, markdown" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
|
||||
const body = viewBody(String(args.body ?? ""));
|
||||
const existing = viewComment(await gitea.listComments(owner, repo, number));
|
||||
if (existing) return { edited: await gitea.editComment(owner, repo, existing.id, body) };
|
||||
return { created: await gitea.addComment(owner, repo, number, body) };
|
||||
},
|
||||
},
|
||||
// ---- A commit's statuses (a delivery's note, view and statuses are the seat git's acts, seat.ts) ----
|
||||
{
|
||||
name: "gitea_commit_statuses",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out); and merge.writes, whether the mesh's merging account may write the repository at all (false: its statuses and its merge are refused until gitea_grant_write). Reads only.",
|
||||
@@ -768,8 +717,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
|
||||
return { commit, in: within, contains, ahead };
|
||||
},
|
||||
},
|
||||
// gitea_commit_status is served from the watcher's bundle (index.ts), beside the merge check's one writer
|
||||
// (novox/hq issue 456).
|
||||
|
||||
// ---- Labels ----
|
||||
{
|
||||
@@ -805,7 +752,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
|
||||
name: "gitea_api",
|
||||
description: "Make an authenticated Gitea API call for an endpoint without a dedicated tool. Path is relative to /api/v1. " +
|
||||
"Reads anything but the forge's administration; writes only what an author writes — issues, comments, labels, " +
|
||||
"pull requests (not their merge: gitea_merge_pull_request), branches, and files on a branch that is not the " +
|
||||
"pull requests (not their merge: the seat git's merge-pull), branches, and files on a branch that is not the " +
|
||||
"default branch and that no protection rule names. Administration, protection, settings, collaborators, teams, " +
|
||||
"hooks, keys, tokens and statuses are refused (novox/hq ADR 0266).",
|
||||
input: {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
// A reader's picture of one state of the indexer or the assigner (novox/hq ADR 0615 §1, §4 to §6; design 25 § 12.2): asked once
|
||||
// of the owner's read tool, then kept by the owner's views events alone. It is the TypeScript form of the Go reader
|
||||
// (mesh-delivery sdk/views Follower) and follows the same rules:
|
||||
//
|
||||
// - at its start it asks the tool until it is answered, waiting longer between asks (up to a minute), and past two minutes
|
||||
// says so naming the owner and the tool; it never reads the owner's state instead;
|
||||
// - an event of another `created` is a gap (the owner's state was made again); one at or below the revision held is already
|
||||
// in the picture; one past the next revision is a gap; one with `gap: true` (a value too large to carry) is a gap;
|
||||
// - a gap asks the tool once more, said in the log;
|
||||
// - events heard while an ask runs are taken again after its answer, so none is lost.
|
||||
|
||||
/** The answer of the owner's read tool with `values`: one page of the state, at the owner's revision. */
|
||||
export interface ViewAnswer {
|
||||
v: number;
|
||||
owner: string;
|
||||
state: string;
|
||||
revision: number;
|
||||
created: string;
|
||||
values: Record<string, unknown>;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
/** One change of one key, said on the owner's views family. */
|
||||
export interface ViewEvent {
|
||||
v: number;
|
||||
owner: string;
|
||||
state: string;
|
||||
key: string;
|
||||
revision: number;
|
||||
created: string;
|
||||
deleted?: boolean;
|
||||
value?: unknown;
|
||||
gap?: boolean;
|
||||
}
|
||||
|
||||
/** One change of the picture, in the form the module's watch handed before. */
|
||||
export interface ViewChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision?: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface ViewFollowerOptions {
|
||||
owner: string;
|
||||
state: string;
|
||||
/** The owner's read tool as the manifest invokes it, for the log. */
|
||||
tool: string;
|
||||
/** Asks the tool for the page after `after` (none: the first). */
|
||||
ask: (after?: string) => Promise<ViewAnswer>;
|
||||
take: (change: ViewChange) => void | Promise<void>;
|
||||
say?: (line: string) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
now?: () => number;
|
||||
/** How long it asks before it says so; two minutes. */
|
||||
boundMs?: number;
|
||||
/** Told once when the first view stays unanswered past the bound (the module writes its finding), and once when it is answered after. */
|
||||
raise?: (asked: Date, error: string) => void | Promise<void>;
|
||||
clear?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export class ViewFollower {
|
||||
readonly values = new Map<string, unknown>();
|
||||
revision = 0;
|
||||
created = "";
|
||||
ready = false;
|
||||
asks = 0;
|
||||
byHand = 0;
|
||||
private asking = false;
|
||||
private during: ViewEvent[] = [];
|
||||
|
||||
private readonly o: ViewFollowerOptions;
|
||||
|
||||
constructor(o: ViewFollowerOptions) {
|
||||
this.o = o;
|
||||
}
|
||||
|
||||
private say(line: string): void {
|
||||
(this.o.say ?? ((l) => console.error(`[gitea] ${l}`)))(line);
|
||||
}
|
||||
|
||||
/** Asks until answered: each failed ask waits, doubling to a minute, and past the bound says what waits. */
|
||||
async start(): Promise<void> {
|
||||
await this.askUntil("start");
|
||||
}
|
||||
|
||||
/** Asks once more by hand, until answered (a hand act, counted). */
|
||||
async again(why: string): Promise<void> {
|
||||
this.byHand++;
|
||||
await this.askUntil(`by hand: ${why}`);
|
||||
}
|
||||
|
||||
private async askUntil(why: string): Promise<void> {
|
||||
if (this.asking) return; // one ask at a time; the one running brings the picture to its newest
|
||||
this.asking = true;
|
||||
this.during = [];
|
||||
const sleep = this.o.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||
const now = this.o.now ?? Date.now;
|
||||
const first = now();
|
||||
let said = false;
|
||||
try {
|
||||
for (let wait = 5000; ; wait = Math.min(wait * 2, 60_000)) {
|
||||
try {
|
||||
await this.askOnce();
|
||||
if (said) {
|
||||
this.say(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is answered`);
|
||||
await this.o.clear?.();
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!said && now() - first >= (this.o.boundMs ?? 120_000)) {
|
||||
said = true;
|
||||
await this.o.raise?.(new Date(first), err instanceof Error ? err.message : String(err));
|
||||
this.say(`no first view of ${this.o.owner}'s ${this.o.state}: ${this.o.tool} has not answered since ${new Date(first).toISOString()} ` +
|
||||
`(${err instanceof Error ? err.message : String(err)}); it keeps asking and reads the state of nobody (${why})`);
|
||||
}
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.asking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async askOnce(): Promise<void> {
|
||||
this.asks++;
|
||||
const values = new Map<string, unknown>();
|
||||
let firstPage: ViewAnswer | undefined;
|
||||
for (let after: string | undefined; ;) {
|
||||
const a = await this.o.ask(after);
|
||||
firstPage ??= a;
|
||||
for (const [k, v] of Object.entries(a.values ?? {})) values.set(k, v);
|
||||
if (!a.next || a.next === after) break;
|
||||
after = a.next;
|
||||
}
|
||||
const old = new Map(this.values);
|
||||
this.values.clear();
|
||||
for (const [k, v] of values) this.values.set(k, v);
|
||||
this.revision = firstPage.revision;
|
||||
this.created = firstPage.created;
|
||||
this.ready = true;
|
||||
const during = this.during.sort((a, b) => a.revision - b.revision);
|
||||
this.during = [];
|
||||
for (const ev of during) {
|
||||
if (ev.created === this.created && ev.revision === this.revision + 1 && !ev.gap) {
|
||||
if (ev.deleted) this.values.delete(ev.key);
|
||||
else this.values.set(ev.key, ev.value);
|
||||
this.revision = ev.revision;
|
||||
}
|
||||
}
|
||||
for (const [k, v] of this.values) {
|
||||
if (!old.has(k) || JSON.stringify(old.get(k)) !== JSON.stringify(v)) await this.o.take({ key: k, op: "put", value: v, current: true });
|
||||
}
|
||||
for (const k of old.keys()) if (!this.values.has(k)) await this.o.take({ key: k, op: "delete", current: true });
|
||||
}
|
||||
|
||||
/** One event of the owner's views family: taken into the picture, or a gap that asks the tool once more. */
|
||||
async hear(ev: ViewEvent): Promise<void> {
|
||||
if (ev.state !== this.o.state) return;
|
||||
if (this.asking) this.during.push(ev);
|
||||
if (!this.ready) return; // the first view holds it
|
||||
let why = "";
|
||||
if (ev.created !== this.created) why = `${this.o.owner}'s ${this.o.state} was made again`;
|
||||
else if (ev.revision <= this.revision) return;
|
||||
else if (ev.revision > this.revision + 1) why = `${this.o.owner}'s ${this.o.state} at revision ${ev.revision}, the picture holds ${this.revision}`;
|
||||
else if (ev.gap) why = `${this.o.owner}'s ${this.o.state}.${ev.key} was too large to carry`;
|
||||
if (why) {
|
||||
this.say(`a gap in ${this.o.state} (${why}): its first view ${this.o.tool} is asked once more`);
|
||||
await this.askUntil(`a gap: ${why}`);
|
||||
return;
|
||||
}
|
||||
if (ev.deleted) this.values.delete(ev.key);
|
||||
else this.values.set(ev.key, ev.value);
|
||||
this.revision = ev.revision;
|
||||
await this.o.take({ key: ev.key, op: ev.deleted ? "delete" : "put", value: ev.value, revision: ev.revision, current: false });
|
||||
}
|
||||
}
|
||||
|
||||
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
|
||||
|
||||
/** Where a first view stands (design 25 § 12.2): the owner and its tool, the revision the picture holds, how many asks. (r) */
|
||||
export function status(f: ViewFollower, owner: string, tool: string, state: string): Record<string, unknown> {
|
||||
return { owner, tool, state, revision: f.revision, created: f.created, keys: f.values.size, answered: f.ready, asks: f.asks, "by-hand": f.byHand };
|
||||
}
|
||||
|
||||
/** The reader's two tools: `gitea_first_views` says where its first views stand, `gitea_first_view_again` asks one once more by hand. */
|
||||
export function viewTools(view: () => { follower: ViewFollower; owner: string; tool: string; state: string } | undefined,
|
||||
record: (act: Record<string, unknown>) => Promise<unknown>): Tool[] {
|
||||
return [
|
||||
{
|
||||
name: "gitea_first_views",
|
||||
description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, how many asks (by hand among them). (r)",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const v = view();
|
||||
return { views: v ? [status(v.follower, v.owner, v.tool, v.state)] : [] };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_first_view_again",
|
||||
description: "Ask the indexer's modules again by hand: a hand act, for a picture the operator doubts. Answers where it stands after. (a)",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const v = view();
|
||||
if (!v) throw new Error("the module follows no first view yet");
|
||||
// A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded asks nothing.
|
||||
try {
|
||||
await record({ what: "asked a first view again by hand (gitea)", why: "the operator asked", cause: "first-view-again", verb: "first-view-again" });
|
||||
} catch (err) {
|
||||
throw new Error(`the hand act was not recorded through mesh-hand-acts, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
await v.follower.again("the operator asked");
|
||||
return { views: [status(v.follower, v.owner, v.tool, v.state)] };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */
|
||||
export function findingKey(scope: string, id: string, kind: string): string {
|
||||
return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join(".");
|
||||
}
|
||||
|
||||
export const UNANSWERED_KIND = "first-view-unanswered";
|
||||
|
||||
/** A reader's finding of a first view unanswered past its bound, as mesh-sdk views.UnansweredFinding writes it (ADR 0615 §6): the
|
||||
* condition keeper raises the condition from the module's state `findings`. */
|
||||
export function unansweredFinding(reader: string, owner: string, state: string, tool: string, asked: Date, error: string, at: Date):
|
||||
{ key: string; value: Record<string, unknown> } {
|
||||
const id = `${reader}.${owner}.${state}`;
|
||||
const key = findingKey("module", id, UNANSWERED_KIND);
|
||||
const summary = `${reader} has no first view of ${owner}'s ${state}: ${tool} has not answered since ${asked.toISOString()} (${error.trim()})`;
|
||||
return { key, value: {
|
||||
v: 1, key, kind: UNANSWERED_KIND, subject: { scope: "module", id }, severity: "warning", summary,
|
||||
headline: `${reader} waits for ${owner}`,
|
||||
explanation: `${reader} reads ${owner}'s ${state} only through ${tool}, and keeps asking it; it never reads the state itself. The answer clears this. ` +
|
||||
`Look at ${owner}'s runtime of its own: is it running and connected?`,
|
||||
resolved: "", evidence: [{ at: at.toISOString(), said: summary }], source: `${reader}.${UNANSWERED_KIND}`, observer: reader, at: at.toISOString(),
|
||||
} };
|
||||
}
|
||||
+39
-30
@@ -3,15 +3,16 @@
|
||||
// 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first
|
||||
// act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts).
|
||||
//
|
||||
// **Which account.** The tools' account, `mesh-tools`: the gitea module's own tools set every commit status and merge
|
||||
// (gitea_commit_status, gitea_merge_pull_request) with its token. The build modules' `nox-builder` and the machines'
|
||||
// **Which account.** The tools' account, `mesh-tools`: the seat git's acts (seat.ts) set every commit status and merge
|
||||
// with its token. The build modules' `nox-builder` and the machines'
|
||||
// `nox-agent` only fetch, and keep read (readall.ts).
|
||||
//
|
||||
// **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its
|
||||
// state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads
|
||||
// that state through the indexer's read tool (its manifest's `invokes`) and then its views events (`consumes`; ADR 0615): the
|
||||
// first view hands the latest value of every key when the module starts, and then every change, so a repository is granted
|
||||
// at the module's start and when a module of it is first registered. Nothing polls and the state is not read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other
|
||||
// **Which repositories: the catalog half's word, read over the bus.** The seat mesh-catalog's trunk revision holds every
|
||||
// registered module's entry, each naming the repository it builds from. This module asks it of the seat's verb `revision`
|
||||
// (its manifest's `invokes`) and then hears the seat's event `catalog-revision` (`consumes`; catalog.ts, design 47): the
|
||||
// first view hands every entry when the module starts, and each newer trunk revision what changed, so a repository is
|
||||
// granted at the module's start and when a module of it is first registered. A candidate's revision moves nothing, and
|
||||
// before the first view nothing is granted. Nothing polls and no holder's state is read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other
|
||||
// repository of a user (the operator's dotfiles, say) is left as it is: read only.
|
||||
//
|
||||
// **Every grant is read back** from the forge (its permission for the account), and said on the bus as
|
||||
@@ -24,19 +25,15 @@
|
||||
// set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but
|
||||
// blocked, with why (merge_blocked).
|
||||
//
|
||||
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it;
|
||||
// Not taken away: a repository whose last module leaves the catalog keeps its collaborator until a hand removes it;
|
||||
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
|
||||
|
||||
import type { ForgeRepository } from "./client.js";
|
||||
import type { BranchProtection } from "./protection.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js";
|
||||
|
||||
/** The indexer, whose state `modules` this module follows: one key per registered module, its value naming the repository.
|
||||
* It asks the indexer's read tool once and then hears its views events (ADR 0615), never reading the state. */
|
||||
export const INDEXER_OWNER = "mesh-delivery-indexer";
|
||||
export const INDEXER_MODULES_TOOL = `${INDEXER_OWNER}.indexer_read_modules`;
|
||||
export const INDEXER_MODULES_EVENTS = `${INDEXER_OWNER}.views.modules`;
|
||||
const INDEXER_MODULES = `${INDEXER_OWNER}.modules`;
|
||||
/** What the view is called in what this module says: the catalog half, through the seat mesh-catalog. */
|
||||
const CATALOG_HALF = "the catalog half (the seat mesh-catalog's revision)";
|
||||
/** The event said once an account writes a repository it could not write before. */
|
||||
export const WRITABLE_EVENT = "repo.writable";
|
||||
|
||||
@@ -75,18 +72,16 @@ export function mergeBlocks(rule: BranchProtection | null, account: string): str
|
||||
|
||||
const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`;
|
||||
|
||||
/** One change of the indexer's modules state, as the SDK's state watch hands it. */
|
||||
/** One change of the catalog half's entries: a module, put with its entry or deleted. */
|
||||
export interface ModulesChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision?: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface Written {
|
||||
account: string;
|
||||
/** The repositories that build a registered module, as the indexer says now. */
|
||||
/** The repositories that build a registered module, as the catalog half says now. */
|
||||
delivered: string[];
|
||||
/** Made writable now, each read back from the forge. */
|
||||
granted: string[];
|
||||
@@ -113,9 +108,9 @@ function split(full: string): [string, string] {
|
||||
}
|
||||
|
||||
export class DeliveredWrites {
|
||||
/** Module to the repository it builds from, as the indexer's state says. */
|
||||
/** Module to the repository it builds from, as the catalog half says. */
|
||||
readonly modules = new Map<string, string>();
|
||||
/** Whether the watch has handed the indexer's state at all: until then, which repositories are delivered is not known. */
|
||||
/** Whether the first view of the catalog half is held: until then, which repositories are delivered is not known. */
|
||||
heard = false;
|
||||
/** Repositories seen written by the account in this process: a module registered again asks nothing more. */
|
||||
readonly writable = new Set<string>();
|
||||
@@ -129,9 +124,23 @@ export class DeliveredWrites {
|
||||
return [...new Set(this.modules.values())].sort();
|
||||
}
|
||||
|
||||
/** One change of the indexer's modules: a repository newly delivered is made writable now. Never throws. */
|
||||
async take(change: ModulesChange): Promise<void> {
|
||||
/** The first view is held, its entries taken: from now on which repositories are delivered is known, even when the
|
||||
* catalog lists none, and every one the account cannot write yet is granted now, in one pass. Never throws. */
|
||||
async viewed(): Promise<void> {
|
||||
if (this.heard) return;
|
||||
this.heard = true;
|
||||
try {
|
||||
const r = await this.grant();
|
||||
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds a registered module`);
|
||||
for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository} — ${x.why}`);
|
||||
} catch (err) {
|
||||
this.log(`which repositories ${this.account} writes could not be settled at the first view — ${said(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One change of the catalog half: a repository newly delivered is made writable now. Never throws. Before the first
|
||||
* view is held (viewed) nothing is granted: the change is kept, and the first view's changes follow it. */
|
||||
async take(change: ModulesChange): Promise<void> {
|
||||
if (change.op === "delete") {
|
||||
this.modules.delete(change.key);
|
||||
return;
|
||||
@@ -140,11 +149,11 @@ export class DeliveredWrites {
|
||||
try {
|
||||
split(repository);
|
||||
} catch {
|
||||
this.log(`${INDEXER_MODULES} ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`);
|
||||
this.log(`${CATALOG_HALF}: ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`);
|
||||
return;
|
||||
}
|
||||
this.modules.set(change.key, repository);
|
||||
if (this.writable.has(repository)) return;
|
||||
if (!this.heard || this.writable.has(repository)) return;
|
||||
try {
|
||||
const r = await this.grant([repository]);
|
||||
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`);
|
||||
@@ -158,8 +167,8 @@ export class DeliveredWrites {
|
||||
* delivered); with none named, also say which repositories it cannot write are left so, and why. */
|
||||
async grant(only?: string[]): Promise<Written> {
|
||||
if (!this.heard) {
|
||||
throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read ` +
|
||||
"(the watch is not granted or not answered); nothing is granted");
|
||||
throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view ` +
|
||||
"held (not answered, or waited for); nothing is granted");
|
||||
}
|
||||
const delivered = this.delivered();
|
||||
const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] };
|
||||
@@ -169,7 +178,7 @@ export class DeliveredWrites {
|
||||
split(full);
|
||||
if (delivered.includes(full)) targets.push(full);
|
||||
else result.left_read_only.push({ repository: full, permission: "unchanged",
|
||||
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
|
||||
why: "builds no registered module (the catalog half names none from it), so the account is given no write there" });
|
||||
}
|
||||
for (const full of targets) {
|
||||
const [owner, name] = split(full);
|
||||
@@ -248,7 +257,7 @@ export class DeliveredWrites {
|
||||
}
|
||||
if (WRITES.has(permission)) continue;
|
||||
result.left_read_only.push({ repository: repo.full_name, permission,
|
||||
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
|
||||
why: "builds no registered module (the catalog half names none from it), so the account is given no write there" });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -257,7 +266,7 @@ export class DeliveredWrites {
|
||||
/** Every delivered repository the account cannot write, as the forge says now, and why. */
|
||||
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[];
|
||||
merge_blocked: { repository: string; why: string }[] }> {
|
||||
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`);
|
||||
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view held`);
|
||||
const delivered = this.delivered();
|
||||
const out: { repository: string; permission: string; why: string }[] = [];
|
||||
const blocked: { repository: string; why: string }[] = [];
|
||||
@@ -295,7 +304,7 @@ export function writeTools(writes: () => DeliveredWrites): Tool[] {
|
||||
return [
|
||||
{
|
||||
name: "gitea_unwritable_repos",
|
||||
description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " +
|
||||
description: "Every repository that builds a registered module (the catalog half's trunk revision, read over the bus) on which " +
|
||||
"the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " +
|
||||
"permission the forge gives it there and why: such a pull request can be checked but never merged, and the " +
|
||||
"delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +
|
||||
|
||||
Reference in New Issue
Block a user