nats removes a push consumer; gitea, mesh-conditions, mesh-issues and messenger drop what named the removed controller (issue 1126) #607
@@ -672,19 +672,6 @@ export class GiteaClient {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every status of one context on a commit, newest first, as the forge keeps each one it was given: what tells
|
||||
* when the check now pending on a head was opened, after a restart (novox/hq issue 456). */
|
||||
async contextHistory(owner: string, repo: string, sha: string, context: string): Promise<HeadCheck[]> {
|
||||
const raw = await this.request<any>(`${repoPath(owner, repo)}/statuses/${commitSegment(sha)}?sort=highestindex&limit=50`);
|
||||
const out: HeadCheck[] = [];
|
||||
for (const s of Array.isArray(raw) ? raw : []) {
|
||||
if (s?.context !== context) continue;
|
||||
out.push({ state: String(s.status ?? s.state ?? ""), description: String(s.description ?? ""),
|
||||
updated_at: String(s.created_at ?? s.updated_at ?? "") });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
|
||||
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
|
||||
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
|
||||
|
||||
+12
-136
@@ -1,74 +1,21 @@
|
||||
// **One writer of a head's mesh/merge-gate, and the verdict lands last** (novox/hq issue 446).
|
||||
// **One writer of a head's mesh/merge-gate** (novox/hq issue 446).
|
||||
//
|
||||
// A pending check says where it is: "waiting in the build queue since …" when the head is announced, and the verdict when
|
||||
// it is said. The forge keeps only the newest status of a context, so a pending word
|
||||
// written after the verdict hides it: a pass reads pending and the merge waits for ever; a failure hides under
|
||||
// "building". Two writers cannot order their writes, and a writer that reads the forge before writing races the other
|
||||
// between its read and its write. So this module is the one writer of mesh/merge-gate, and every write of one head's
|
||||
// gate goes through one chain: a verdict is marked the moment it is heard, before it waits its turn, and a "building"
|
||||
// still waiting behind or taking its turn after that mark is dropped, never written.
|
||||
//
|
||||
// **A recheck's pending is written here too** (novox/hq issue 456). mesh-delivery's recheck puts the gate back to
|
||||
// pending through the gitea_commit_status tool, which this module serves from the watcher's own bundle so that it
|
||||
// reaches this writer: the pending clears the verdict mark, so the new run's "building" is said, and queues behind an
|
||||
// old verdict in flight, so that verdict lands first. A verdict is weighed by the ask it answers — the controller's
|
||||
// checked.id, `build-<unix nanoseconds>` of when the check was asked — against when the check now pending was opened:
|
||||
// an older run's verdict, redelivered after its write failed, is dropped, never written over the pending. After a
|
||||
// restart, when the check was opened is read from the forge's own history of the gate.
|
||||
// A pending check says where it is: "waiting in the build queue since …" when the head is announced. The forge keeps only
|
||||
// the newest status of a context, so two writers cannot order their writes; this module is the one writer of
|
||||
// mesh/merge-gate, and every write of one head's gate goes through one chain, in the order it was asked. A recheck's
|
||||
// pending (novox/hq issue 456) and a trunk's move (ADR 0323 §4) take their turns on the same chain. No verdict is written
|
||||
// here any more: the controller's `checked` is no event (ADR 0571 clause 13), and main requires the checker's mesh/check.
|
||||
|
||||
import { CHECK_CONTEXT, pendingWords } from "./pulls.js";
|
||||
import type { CommitStatus, HeadCheck } from "./client.js";
|
||||
|
||||
/** What the gate writer needs of the forge: a status set, and a head's gate as the forge holds it. */
|
||||
/** What the gate writer needs of the forge: a status set, and a head's statuses as the forge holds them. */
|
||||
export interface GateForge {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
/** The head's mesh/merge-gate, newest: its state and when it was set; undefined when it has none. */
|
||||
gateOf(owner: string, repo: string, sha: string): Promise<{ state: string; updated_at?: string } | undefined>;
|
||||
/** The head's mesh/merge-gate statuses, every one the forge keeps, newest first (novox/hq issue 456). */
|
||||
gateHistory(owner: string, repo: string, sha: string): Promise<GateStatus[]>;
|
||||
/** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */
|
||||
headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>>;
|
||||
}
|
||||
|
||||
/** One status of a head's gate as the forge keeps it: its state, its words, and when it was set. */
|
||||
export interface GateStatus {
|
||||
state: string;
|
||||
description?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a check was asked, from its id as mesh-controller's link.NewBuildID writes it: `build-<unix nanoseconds>`.
|
||||
* Undefined for an id of any other shape, whose ask time is not known (novox/hq issue 456).
|
||||
*/
|
||||
export function askedAt(id: string | undefined): number | undefined {
|
||||
const m = /^build-(\d{1,20})$/.exec(String(id ?? ""));
|
||||
if (!m) return undefined;
|
||||
const ms = Number(BigInt(m[1]) / 1_000_000n);
|
||||
return ms > 0 ? ms : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the check now pending on a head was opened, from the gate's history, newest first: the newest status, past the
|
||||
* "building" words this writer says only inside an open check, when it is pending; undefined when the gate holds a
|
||||
* verdict, holds nothing, or its time cannot be read (novox/hq issue 456).
|
||||
*/
|
||||
export function openedAt(history: GateStatus[]): number | undefined {
|
||||
for (const s of history) {
|
||||
if (s.state !== "pending") return undefined;
|
||||
if ((s.description ?? "").startsWith(BUILDING)) continue;
|
||||
const t = Date.parse(s.updated_at ?? "");
|
||||
return Number.isNaN(t) ? undefined : t;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** How far a build machine's clock may stand behind this one's when a start is weighed against the pending words. */
|
||||
const CLOCK_SLACK_MS = 60_000;
|
||||
|
||||
/** How the words of a check a machine was running began: a status the forge still holds from before is no opening of a check. */
|
||||
const BUILDING = "building on ";
|
||||
|
||||
/** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */
|
||||
export function clonedAs(url: string | undefined, owner: string, repo: string): boolean {
|
||||
const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, "");
|
||||
@@ -77,12 +24,6 @@ export function clonedAs(url: string | undefined, owner: string, repo: string):
|
||||
}
|
||||
|
||||
interface Head {
|
||||
/** When the pending words were last set by this writer; undefined for a head this process did not announce. */
|
||||
pendingAt?: number;
|
||||
/** A verdict was heard for the head: nothing pending is written after it. */
|
||||
verdict: boolean;
|
||||
/** Which verdict set the mark: one dropped in its turn takes the mark back only while it is still its own. */
|
||||
mark?: object;
|
||||
/** The chain the head's writes take their turns on. */
|
||||
chain: Promise<void>;
|
||||
}
|
||||
@@ -101,7 +42,7 @@ export class GateWriter {
|
||||
const key = `${owner}/${repo}@${sha}`.toLowerCase();
|
||||
let h = this.heads.get(key);
|
||||
if (!h) {
|
||||
h = { verdict: false, chain: Promise.resolve() };
|
||||
h = { chain: Promise.resolve() };
|
||||
this.heads.set(key, h);
|
||||
// Kept for the heads still in play; the oldest go first, past what any forge holds open at once.
|
||||
if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string);
|
||||
@@ -127,36 +68,17 @@ export class GateWriter {
|
||||
pending(owner: string, repo: string, sha: string): Promise<void> {
|
||||
const h = this.head(owner, repo, sha);
|
||||
const at = this.now();
|
||||
this.opened(h, at);
|
||||
return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () =>
|
||||
this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) }));
|
||||
}
|
||||
|
||||
/** A new check of the head opened now: no verdict heard for it yet, and nothing older than it is its own. */
|
||||
private opened(h: Head, at: Date): void {
|
||||
h.pendingAt = at.getTime();
|
||||
h.verdict = false;
|
||||
h.mark = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): on mesh/merge-gate it opens
|
||||
* a new check of the head — the verdict mark cleared, so the new run's "building" is said — and takes its turn
|
||||
* after every write already asked for, an old verdict in flight included, so it lands over that verdict. Any
|
||||
* other status of the merge check (mesh/repo-check, which the verdict also writes) takes its turn on the same
|
||||
* chain. A write that fails fails the caller, and the head is as it was before it.
|
||||
* 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.
|
||||
*/
|
||||
rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
|
||||
const h = this.head(owner, repo, sha);
|
||||
const write = () => this.forge.setCommitStatus(owner, repo, sha, status);
|
||||
if (status.context !== CHECK_CONTEXT || status.state !== "pending") return this.turn(h, write);
|
||||
const before = { pendingAt: h.pendingAt, verdict: h.verdict, mark: h.mark };
|
||||
const at = this.now();
|
||||
this.opened(h, at);
|
||||
return this.turn(h, write).catch((err) => {
|
||||
if (h.pendingAt === at.getTime() && !h.verdict) Object.assign(h, before);
|
||||
throw err;
|
||||
});
|
||||
return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,50 +100,4 @@ export class GateWriter {
|
||||
}
|
||||
}).then(() => written);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict: marked at once, so no pending word is written after it, then written in its turn — last. A write
|
||||
* that fails fails the caller, so the event is offered again.
|
||||
*
|
||||
* `id` is the ask it answers (checked.id): a verdict of a check asked before the check now pending was opened is
|
||||
* an older run's — redelivered after its write failed, or heard late — and is dropped, never written over the
|
||||
* pending (novox/hq issue 456). When this process opened no check of the head (it restarted since), the opening
|
||||
* is read from the forge's history of the gate in the verdict's turn; a history that cannot be read writes the
|
||||
* verdict, as one with no readable id is: a verdict is never lost to what cannot be weighed.
|
||||
*/
|
||||
verdict(owner: string, repo: string, sha: string, write: () => Promise<void>, id?: string): Promise<void> {
|
||||
const h = this.head(owner, repo, sha);
|
||||
const asked = askedAt(id);
|
||||
const head = `${owner}/${repo}@${sha.slice(0, 8)}`;
|
||||
if (asked !== undefined && h.pendingAt !== undefined && older(asked, h.pendingAt)) {
|
||||
console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check now pending; not written`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
const before = h.verdict;
|
||||
const mark = {};
|
||||
h.verdict = true;
|
||||
h.mark = mark;
|
||||
return this.turn(h, async () => {
|
||||
if (asked !== undefined && h.pendingAt === undefined) {
|
||||
let opened: number | undefined;
|
||||
try {
|
||||
opened = openedAt(await this.forge.gateHistory(owner, repo, sha));
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${head}: the gate's history could not be read, so the verdict of ${id} is written ` +
|
||||
`unweighed — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
if (opened !== undefined && older(asked, opened)) {
|
||||
if (h.mark === mark) h.verdict = before;
|
||||
console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check the forge holds pending; not written`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await write();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a check asked at `asked` was asked before a check opened at `opened`, past what the clocks may differ by. */
|
||||
function older(asked: number, opened: number): boolean {
|
||||
return asked < opened - CLOCK_SLACK_MS;
|
||||
}
|
||||
|
||||
@@ -356,8 +356,6 @@ async function siblingMoves(client: GiteaClient, repository: string, number?: nu
|
||||
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
|
||||
const gates = new GateWriter({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT],
|
||||
gateHistory: (owner, repo, sha) => (gitea as GiteaClient).contextHistory(owner, repo, sha, CHECK_CONTEXT),
|
||||
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
|
||||
});
|
||||
|
||||
|
||||
@@ -119,48 +119,6 @@ export function keepSiblings(file: string | null, record: SiblingRecord): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A member's latest verdict, kept: its siblings when it read some, else the entry removed. Answers whether it changed. */
|
||||
export function noteVerdict(record: SiblingRecord, key: string, head: string, siblings: SiblingNamed[]): boolean {
|
||||
if (siblings.length === 0) {
|
||||
if (!(key in record)) return false;
|
||||
delete record[key];
|
||||
return true;
|
||||
}
|
||||
record[key] = { head, siblings };
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One member of a composed verdict, as the group's composed check read it: its pull request, the head it judged, the
|
||||
* trunk commit that head was merged onto, and the branch the pull request merges into. */
|
||||
export interface ComposedMember {
|
||||
owner: string;
|
||||
repo: string;
|
||||
number?: number;
|
||||
commit: string;
|
||||
trunk?: string;
|
||||
base?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* **A group's composed verdict names, for each member, the other members at the heads and base commits it composed**
|
||||
* (novox/hq ADR 0403 §5, issue 1049): the composed check read every member's head together, so it is the verdict to trust
|
||||
* for the siblings. Without this, the record held what a member's OWN verdict named, which went stale when a sibling got
|
||||
* a new head; the sibling's own merge of the head the composed check judged then read as another head's merge, put the
|
||||
* member's gate back to pending, and the group merged in two passes. A member the verdict cannot name (no pull request
|
||||
* number, trunk commit or base branch), or a verdict of fewer than two such members, changes nothing. Answers whether
|
||||
* the record changed.
|
||||
*/
|
||||
export function noteComposed(record: SiblingRecord, members: ComposedMember[]): boolean {
|
||||
const named = members.filter((m) => m.owner && m.repo && m.number && m.commit && m.trunk && m.base);
|
||||
if (named.length < 2 || named.length !== members.length) return false;
|
||||
for (const m of named) {
|
||||
const siblings = named.filter((o) => o !== m).map((o): SiblingNamed => ({
|
||||
repository: `${o.owner}/${o.repo}`, number: o.number!, head: o.commit, base: o.base!, base_commit: o.trunk! }));
|
||||
record[memberKey(m.owner, m.repo, m.number!)] = { head: m.commit, siblings };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- what a sibling's move is ---------------------------------------------------------------------------------
|
||||
|
||||
/** What weighing a sibling reads of the forge. */
|
||||
|
||||
+14
-127
@@ -1,9 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
// **A pending check says where it is, and the verdict lands last** (novox/hq issue 446). This module is the one writer
|
||||
// of a head's mesh/merge-gate: the announcement's pending words and the verdict. The forge keeps the newest status of a context, so a pending word
|
||||
// written after the verdict hides it — a pass reads pending, a failure hides under "building".
|
||||
// **One writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, a recheck's pending and
|
||||
// a trunk's move, each write of a head in the order it was asked.
|
||||
|
||||
const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3";
|
||||
|
||||
@@ -11,15 +10,11 @@ const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3";
|
||||
function aForge() {
|
||||
const kept: Record<string, { state: string; description: string; updated_at: string }> = {};
|
||||
const written: string[] = [];
|
||||
/** Every mesh/merge-gate status the forge was given, newest first, as gitea keeps each one. */
|
||||
const history: { state: string; description: string; updated_at: string }[] = [];
|
||||
let failNext = 0;
|
||||
let hold: Promise<void> | null = null;
|
||||
let release = () => {};
|
||||
let readHold: Promise<void> | null = null;
|
||||
let readRelease = () => {};
|
||||
return {
|
||||
kept, written, history,
|
||||
kept, written,
|
||||
/** The next writes fail: a request the forge did not take. */
|
||||
failWrites(n = 1) {
|
||||
failNext = n;
|
||||
@@ -32,6 +27,9 @@ function aForge() {
|
||||
hold = null;
|
||||
release();
|
||||
},
|
||||
async headChecks() {
|
||||
return {};
|
||||
},
|
||||
async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) {
|
||||
if (hold) await hold;
|
||||
if (failNext > 0) {
|
||||
@@ -39,121 +37,35 @@ function aForge() {
|
||||
throw new Error("the forge answered 502");
|
||||
}
|
||||
kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() };
|
||||
if (s.context === "mesh/merge-gate") history.unshift(kept[s.context]);
|
||||
written.push(`${s.state} ${s.description}`);
|
||||
},
|
||||
/** Reads answer what the forge held when asked, as late as the reads are held: the answer of a request in flight. */
|
||||
holdReads() {
|
||||
readHold = new Promise<void>((r) => (readRelease = r));
|
||||
},
|
||||
releaseReads() {
|
||||
readHold = null;
|
||||
readRelease();
|
||||
},
|
||||
async gateOf(_o: string, _r: string, _sha: string) {
|
||||
const held = kept["mesh/merge-gate"] ? { ...kept["mesh/merge-gate"] } : undefined;
|
||||
if (readHold) await readHold;
|
||||
return held;
|
||||
},
|
||||
async gateHistory(_o: string, _r: string, _sha: string) {
|
||||
return history.map((h) => ({ ...h }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The clock of the test: the head announced at 01:08:50Z. */
|
||||
const announcedAt = () => new Date("2026-10-11T01:08:50Z");
|
||||
|
||||
// **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.
|
||||
|
||||
/** The ask of a check, as the controller's link.NewBuildID names it: when it was asked, in unix nanoseconds. */
|
||||
const askOf = (at: string) => `build-${BigInt(Date.parse(at)) * 1_000_000n}`;
|
||||
const firstRun = askOf("2026-10-11T01:09:01Z");
|
||||
const recheckAt = new Date("2026-10-11T01:30:00Z");
|
||||
const secondRun = askOf("2026-10-11T01:30:02Z");
|
||||
const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" };
|
||||
const writeOf = (forge: ReturnType<typeof aForge>, state: string, description: string) => () =>
|
||||
forge.setCommitStatus("novox", "hq", SHA, { state, context: "mesh/merge-gate", description });
|
||||
const gateOf = (forge: ReturnType<typeof aForge>) =>
|
||||
`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`;
|
||||
|
||||
test("an old run's verdict redelivered after a recheck's pending is not written over it", async () => {
|
||||
const { GateWriter } = await import("../gate.ts");
|
||||
const forge = aForge();
|
||||
let now = announcedAt();
|
||||
const gates = new GateWriter(forge, () => now);
|
||||
await gates.pending("novox", "hq", SHA);
|
||||
forge.failWrites(); // the first run's verdict is not taken: the event is offered again
|
||||
await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun));
|
||||
now = recheckAt;
|
||||
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); // redelivered
|
||||
assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | "));
|
||||
// The recheck's own verdict is written.
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail: 0 of 4 compose"), secondRun);
|
||||
assert.equal(gateOf(forge), "failure fail: 0 of 4 compose");
|
||||
test("a repository as the mesh clones it is told from another", async () => {
|
||||
const { clonedAs } = await import("../gate.ts");
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true);
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false);
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false);
|
||||
});
|
||||
|
||||
test("an old run's verdict in flight when the recheck comes lands before the recheck's pending", async () => {
|
||||
const { GateWriter } = await import("../gate.ts");
|
||||
const forge = aForge();
|
||||
let now = announcedAt();
|
||||
const gates = new GateWriter(forge, () => now);
|
||||
await gates.pending("novox", "hq", SHA);
|
||||
forge.holdWrites(); // the first run's verdict is being written when the recheck comes
|
||||
const verdict = gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
now = recheckAt;
|
||||
const pending = gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||
forge.release();
|
||||
await Promise.all([verdict, pending]);
|
||||
assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | "));
|
||||
});
|
||||
|
||||
test("the recheck's own verdict is written, even asked a little before the pending by another machine's clock", async () => {
|
||||
test("a recheck the forge does not take fails its caller, and the next write still takes its turn", async () => {
|
||||
const { GateWriter } = await import("../gate.ts");
|
||||
const forge = aForge();
|
||||
const gates = new GateWriter(forge, () => recheckAt);
|
||||
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), askOf("2026-10-11T01:29:30Z"));
|
||||
assert.equal(gateOf(forge), "success pass");
|
||||
// A verdict whose ask cannot be read is written: a verdict is never lost to what cannot be weighed.
|
||||
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), "an id of another shape");
|
||||
assert.equal(gateOf(forge), "failure fail");
|
||||
});
|
||||
|
||||
test("after a restart, an old run's verdict is weighed against the check the forge's history holds open", async () => {
|
||||
const { GateWriter } = await import("../gate.ts");
|
||||
const forge = aForge();
|
||||
// What the forge kept before this process started: the announcement, the first run's verdict never written,
|
||||
// the recheck's pending, and the second run's "building" over it.
|
||||
forge.history.unshift({ state: "pending", description: "waiting in the build queue since 03:08", updated_at: "2026-10-11T01:08:50Z" });
|
||||
forge.history.unshift({ state: "pending", description: "checking again: main moved on", updated_at: "2026-10-11T01:30:00Z" });
|
||||
forge.history.unshift({ state: "pending", description: "building on shanks since 03:30", updated_at: "2026-10-11T01:30:05Z" });
|
||||
forge.kept["mesh/merge-gate"] = forge.history[0];
|
||||
const gates = new GateWriter(forge, announcedAt);
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||
assert.equal(forge.kept["mesh/merge-gate"].description, "building on shanks since 03:30", forge.written.join(" | "));
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), secondRun);
|
||||
assert.equal(gateOf(forge), "failure fail");
|
||||
});
|
||||
|
||||
test("a recheck the forge does not take fails its caller, and the head is as it was", async () => {
|
||||
const { GateWriter } = await import("../gate.ts");
|
||||
const forge = aForge();
|
||||
let now = announcedAt();
|
||||
const gates = new GateWriter(forge, () => now);
|
||||
await gates.pending("novox", "hq", SHA);
|
||||
forge.failWrites();
|
||||
await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun));
|
||||
now = recheckAt;
|
||||
forge.failWrites();
|
||||
await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain));
|
||||
// The recheck was refused, so its check is not asked: the first run's verdict, offered again, is the head's.
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||
assert.equal(gateOf(forge), "success pass");
|
||||
await gates.rechecked("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 () => {
|
||||
@@ -166,40 +78,15 @@ test("the status tool writes the merge check's pending through the writer, and t
|
||||
setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`),
|
||||
gates,
|
||||
});
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||
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"]);
|
||||
// The redelivered first run's verdict is the writer's to drop.
|
||||
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||
assert.equal(gateOf(forge), "pending checking again: x");
|
||||
// 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/);
|
||||
});
|
||||
|
||||
test("when a check was asked is read from its id; the opening of the check now pending from the gate's history", async () => {
|
||||
const { askedAt, openedAt } = await import("../gate.ts");
|
||||
assert.equal(askedAt("build-1791680941048622697"), Date.parse("2026-10-11T01:09:01.048Z"));
|
||||
assert.equal(askedAt("build-mesh-catalog"), undefined);
|
||||
assert.equal(askedAt(undefined), undefined);
|
||||
assert.equal(openedAt([{ state: "pending", description: "building on ace since 03:31", updated_at: "2026-10-11T01:31:00Z" },
|
||||
{ state: "pending", description: "checking again: x", updated_at: "2026-10-11T01:30:00Z" }]), Date.parse("2026-10-11T01:30:00Z"));
|
||||
assert.equal(openedAt([{ state: "success", description: "pass", updated_at: "2026-10-11T01:30:00Z" }]), undefined);
|
||||
assert.equal(openedAt([]), undefined);
|
||||
});
|
||||
|
||||
test("a repository as the mesh clones it is told from another", async () => {
|
||||
const { clonedAs } = await import("../gate.ts");
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true);
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false);
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false);
|
||||
});
|
||||
|
||||
// Issue 1301: a build machine's start on a head that holds no mesh/merge-gate (its base requires none, so none was written)
|
||||
// says nothing: no "building on" status appears on a main that does not require the gate.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
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 { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -179,7 +179,7 @@ const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("
|
||||
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),
|
||||
gateOf: async () => undefined, gateHistory: async () => [], headChecks: async () => ({}) });
|
||||
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}`);
|
||||
@@ -302,8 +302,8 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su
|
||||
// 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 });
|
||||
const { keepTook, tookFile } = await import("../dist/trunk.js");
|
||||
keepTook(tookFile(stateDir), "novox/mesh-catalog", { took: "4m31s", at: "2026-10-12T14:49:00Z" });
|
||||
const { tookFile } = await import("../dist/trunk.js");
|
||||
writeFileSync(tookFile(stateDir)!, JSON.stringify({ "novox/mesh-catalog": { took: "4m31s", at: "2026-10-12T14:49:00Z" } }));
|
||||
|
||||
state.trunk = MOVED; // another merge moved the trunk after the verdict
|
||||
await assert.rejects(merge(), (err: Error) => {
|
||||
@@ -734,7 +734,7 @@ test("gitea_merge_pull_request refuses a head the Approve did not name", async (
|
||||
// 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, noteComposed, weighSiblings } = await import("../dist/siblings.js");
|
||||
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)}`;
|
||||
@@ -747,8 +747,9 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea
|
||||
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.
|
||||
noteComposed(record, [{ owner: "novox", repo: "mesh-catalog", number: 7, commit: REVIEWED, trunk: TRUNK.slice(0, 12), base: "main" },
|
||||
{ owner: "novox", repo: "mesh-host", number: 114, commit: SIBLING_HEAD, trunk: HOST_BASE.slice(0, 12), base: "main" }]);
|
||||
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 };
|
||||
|
||||
@@ -10,7 +10,7 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile,
|
||||
import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, readSiblings, siblingOutOfDate, siblingsFile,
|
||||
siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js";
|
||||
import { weighTrunks } from "../dist/trunk.js";
|
||||
|
||||
@@ -21,15 +21,11 @@ const verdict = { owner: "novox", repo: "mesh-controller", number: 346, commit:
|
||||
trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc",
|
||||
gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } };
|
||||
|
||||
test("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => {
|
||||
test("the siblings a verdict names are kept per member, and read back from where the holder keeps its state", () => {
|
||||
const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-")));
|
||||
const record: SiblingRecord = {};
|
||||
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), false);
|
||||
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, siblingsOf([sibling])), true);
|
||||
const record: SiblingRecord = { "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } };
|
||||
keepSiblings(file, record);
|
||||
assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state");
|
||||
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), true);
|
||||
assert.deepEqual(record, {});
|
||||
assert.deepEqual(readSiblings(null), {});
|
||||
});
|
||||
|
||||
@@ -241,6 +237,10 @@ const composedMembers = () => [
|
||||
{ owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, trunk: "111111111111", base: "main" },
|
||||
{ owner: "novox", repo: "mesh-host", number: 114, commit: HOST_NEW, trunk: HOST_BASE.slice(0, 12), base: "main" },
|
||||
];
|
||||
/** The record a group's composed verdict once left: for each member, the other members at the heads and base commits it composed. */
|
||||
const composedRecord = (members: ReturnType<typeof composedMembers>): SiblingRecord => Object.fromEntries(members.map((m) => [
|
||||
`${m.owner}/${m.repo}#${m.number}`.toLowerCase(), { head: m.commit, siblings: members.filter((o) => o !== m).map((o) => ({
|
||||
repository: `${o.owner}/${o.repo}`, number: o.number, head: o.commit, base: o.base, base_commit: o.trunk })) }]));
|
||||
const staleRecord = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } });
|
||||
|
||||
test("without the composed verdict's siblings, a sibling's own merge of its new head is a move (the pin of issue 1049)", async () => {
|
||||
@@ -252,9 +252,7 @@ test("without the composed verdict's siblings, a sibling's own merge of its new
|
||||
});
|
||||
|
||||
test("a composed verdict names the siblings of each member at the heads and base commits it composed, so a sibling's own merge of its new head is no move (issue 1049)", async () => {
|
||||
const { noteComposed } = await import("../dist/siblings.js");
|
||||
const record = staleRecord();
|
||||
assert.equal(noteComposed(record, composedMembers()), true);
|
||||
const record = composedRecord(composedMembers());
|
||||
assert.deepEqual(record["novox/mesh-controller#346"], { head: MEMBER_HEAD,
|
||||
siblings: [{ repository: "novox/mesh-host", number: 114, head: HOST_NEW, base: "main", base_commit: HOST_BASE.slice(0, 12) }] });
|
||||
assert.deepEqual(record["novox/mesh-host#114"], { head: HOST_NEW,
|
||||
@@ -267,21 +265,9 @@ test("a composed verdict names the siblings of each member at the heads and base
|
||||
assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true);
|
||||
// A merge of any other head, or onto any other base, is still a move.
|
||||
const other = world();
|
||||
const again: SiblingRecord = {};
|
||||
noteComposed(again, composedMembers());
|
||||
const again = composedRecord(composedMembers());
|
||||
Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
|
||||
other.parents = [HOST_MOVED, HOST_NEW];
|
||||
await weigh(other, again);
|
||||
assert.equal(other.written.length, 2);
|
||||
});
|
||||
|
||||
test("a composed verdict keeps nothing for a member it cannot name, and a group of one is none (issue 1049)", async () => {
|
||||
const { noteComposed } = await import("../dist/siblings.js");
|
||||
const record: SiblingRecord = {};
|
||||
const [a, b] = composedMembers();
|
||||
assert.equal(noteComposed(record, [a]), false);
|
||||
assert.equal(noteComposed(record, [a, { ...b, number: undefined }]), false, "a member with no pull request number");
|
||||
assert.equal(noteComposed(record, [a, { ...b, trunk: undefined }]), false, "a member with no trunk commit");
|
||||
assert.equal(noteComposed(record, [a, { ...b, base: undefined }]), false, "a member with no base branch");
|
||||
assert.deepEqual(record, {});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { checkOnItsWay, lateVerdictComment, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js";
|
||||
import { checkOnItsWay, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js";
|
||||
import { GateWriter } from "../dist/gate.js";
|
||||
|
||||
const HEAD = "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
@@ -60,8 +60,6 @@ function aForge(pulls: { number: number; base: string; head: string }[], heads:
|
||||
writes.push(`${sha} ${s.context} ${s.state}`);
|
||||
statuses.set(sha, { ...statuses.get(sha), [s.context]: { state: s.state, description: s.description } });
|
||||
},
|
||||
gateOf: async () => undefined,
|
||||
gateHistory: async () => [],
|
||||
headChecks: async (_o: string, _r: string, sha: string) => statuses.get(sha) ?? {},
|
||||
};
|
||||
return { forge, writes, statuses };
|
||||
@@ -111,24 +109,6 @@ test("a trunk that cannot be read is weighed again on the next pass, never recor
|
||||
assert.equal(trunks["novox/mesh-host:main"], TRUNK, "not recorded: the next pass weighs it again");
|
||||
});
|
||||
|
||||
test("the trunk's move takes its turn after a verdict in flight, and weighs what the forge holds then", async () => {
|
||||
const heads: Record<string, string> = { main: TRUNK };
|
||||
const { forge, statuses } = aForge([{ number: 1, base: "main", head: HEAD }], heads);
|
||||
const gates = new GateWriter(forge);
|
||||
// A verdict judged against the trunk's new head, in flight when the move is weighed: it lands first, and is left.
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((r) => (release = r));
|
||||
const verdictLanded = gates.verdict("novox", "mesh-host", HEAD, async () => {
|
||||
await held;
|
||||
statuses.set(HEAD, { "mesh/merge-gate": { state: "success", description: `head x merged into trunk ${MOVED.slice(0, 12)} — pass` } });
|
||||
});
|
||||
const moved = gates.outOfDate("novox", "mesh-host", HEAD, (checks) => outOfDateStatuses(checks, MOVED));
|
||||
release();
|
||||
await verdictLanded;
|
||||
assert.deepEqual(await moved, [], "a verdict judged against the trunk's head is not put out of date");
|
||||
assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success");
|
||||
});
|
||||
|
||||
test("a verdict judged against a trunk newer than the pass's first read is not put out of date", async () => {
|
||||
const NEWER = "ffffffffffffffffffffffffffffffffffffffff";
|
||||
const heads: Record<string, string> = { main: TRUNK };
|
||||
@@ -142,15 +122,6 @@ test("a verdict judged against a trunk newer than the pass's first read is not p
|
||||
assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success", "a fresh verdict was put out of date");
|
||||
});
|
||||
|
||||
test("a verdict that landed out of date keeps its account on the pull request", () => {
|
||||
const words = outOfDateWords(TRUNK, MOVED);
|
||||
const own = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "fail" }, "**Merge check** at `dfb1415a`\n\nthe report", words);
|
||||
assert.match(own, /the report/);
|
||||
assert.match(own, /\*\*Out of date when it landed\*\*: out of date: judged against trunk a2c2c35bbbbb/);
|
||||
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
|
||||
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
|
||||
});
|
||||
|
||||
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
|
||||
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
|
||||
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
|
||||
|
||||
+1
-24
@@ -19,8 +19,7 @@
|
||||
//
|
||||
// Pure functions and two small files of the module's own state, so this is tested without a forge.
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
|
||||
import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
|
||||
|
||||
@@ -112,14 +111,6 @@ export function askedWords(at: Date, trunkHead: string): string {
|
||||
`against trunk ${twelve(trunkHead)}`;
|
||||
}
|
||||
|
||||
/** The comment a verdict that landed out of date leaves (ADR 0323 §7: kept and shown): its own account when it has
|
||||
* one, else what it judged and said, and that it is out of date. */
|
||||
export function lateVerdictComment(c: { commit: string; trunk?: string; verdict: string }, account: string | null,
|
||||
outOfDate: string): string {
|
||||
const own = account ?? `**Merge check** at \`${c.commit.slice(0, 8)}\`: ${judgedWords(c.commit, c.trunk ?? "")}, ${c.verdict}`;
|
||||
return `${own}\n\n**Out of date when it landed**: ${outOfDate}`;
|
||||
}
|
||||
|
||||
// --- a trunk's move, weighed --------------------------------------------------------------------------------------
|
||||
|
||||
/** What weighing a trunk's move reads of the forge. */
|
||||
@@ -204,20 +195,6 @@ function readTookAll(file: string | null): Record<string, Took> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep how long a repository's check took. A record that cannot be written is said, never fatal to the verdict. */
|
||||
export function keepTook(file: string | null, repository: string, took: Took): void {
|
||||
if (!file || !took.took) return;
|
||||
try {
|
||||
const all = readTookAll(file);
|
||||
all[repository.toLowerCase()] = took;
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file + ".tmp", JSON.stringify(all));
|
||||
renameSync(file + ".tmp", file);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] how long ${repository}'s check took could not be kept — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a repository's last check took, in words for the merge tool's refusal. */
|
||||
export function tookWords(file: string | null, repository: string): string {
|
||||
const t = readTookAll(file)[repository.toLowerCase()];
|
||||
|
||||
Reference in New Issue
Block a user