gitea: give the controller what it maps a pull request onto the graph with; set both check statuses; protect a branch by tool (hq ADR 0237)
The controller now decides what a pull request's check runs from the mesh's module graph, so the announcement carries the changed directories that hold a module at the head and whether the head has a merge-check.sh. A verdict sets mesh/merge-gate (the gate, with the modules it judged) and mesh/repo-check (the repository's own tests). gitea_branch_protection_get/set let the operator's agent make those statuses required. The catalogue's merge-check.sh leaves the gate to the build seat and keeps its own layer: every manifest through module check, and the touched Go modules' tests.
This commit is contained in:
+36
-17
@@ -1,30 +1,49 @@
|
||||
#!/bin/sh
|
||||
# The merge check of the catalogue (novox/hq to-be 45 §9), run by the build seat on every pull request
|
||||
# before it merges. MESH_GATE is the controller the mesh runs — the judge a catalogue change must be
|
||||
# readable by — built beside this check; MESH_FACTS the facts snapshot it keeps.
|
||||
# mesh-check-toolchain: go
|
||||
#
|
||||
# 1. every manifest through the controller's own module check;
|
||||
# 2. the merge gate: every machine of the snapshot composed with this tree's manifests and validated
|
||||
# by the node-engine's own validator; a manifest the running controller cannot read, an identity a
|
||||
# real machine's name makes too long, a module removed while a machine runs it, all fail here; how
|
||||
# wide the merge's rebuild would be is said;
|
||||
# 3. the Go tests of every module the change touches that has them, its replays among them — and a
|
||||
# module whose dependencies cannot be fetched here is said as not tested, never passed silently.
|
||||
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
|
||||
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
|
||||
#
|
||||
# The gate — the manifests the change touches through the running controller's module check, every machine
|
||||
# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays
|
||||
# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module
|
||||
# graph builds these modules from here. It is not repeated here. This is the repository's own:
|
||||
#
|
||||
# 1. every manifest through the controller's module check, so one module's change cannot leave another
|
||||
# it shares a rule with refused (MESH_GATE is the controller the mesh runs);
|
||||
# 2. the Go tests of every module the change touches that has them, under the race detector when the
|
||||
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is
|
||||
# written in TypeScript, is said as not tested, never passed silently.
|
||||
set -eu
|
||||
gate="${MESH_GATE:?the build seat builds the controller the mesh runs as MESH_GATE}"
|
||||
|
||||
"$gate" module check modules/*/module.json > /dev/null
|
||||
"$gate" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" \
|
||||
--repository "${MESH_CHECK_REPOSITORY:-novox/mesh-catalog}" --tree . \
|
||||
--changed "${MESH_CHECK_CHANGED:-}" --json > "${MESH_CHECK_VERDICT:-/dev/null}"
|
||||
if [ -n "${MESH_GATE:-}" ]; then
|
||||
"$MESH_GATE" module check modules/*/module.json > /dev/null
|
||||
else
|
||||
echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one"
|
||||
fi
|
||||
|
||||
race=""
|
||||
if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi
|
||||
|
||||
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
|
||||
for m in $touched; do
|
||||
[ -f "modules/$m/go.mod" ] || continue
|
||||
[ -d "modules/$m" ] || continue
|
||||
if [ ! -f "modules/$m/go.mod" ]; then
|
||||
[ -f "modules/$m/package.json" ] && echo "NOT TESTED HERE: modules/$m is TypeScript; the gate judges its manifest"
|
||||
continue
|
||||
fi
|
||||
if ! (cd "modules/$m" && GOPRIVATE=git.novox.be go mod download >/dev/null 2>&1); then
|
||||
echo "NOT TESTED: modules/$m — its dependencies cannot be fetched by the build seat"
|
||||
continue
|
||||
fi
|
||||
echo "testing modules/$m"
|
||||
(cd "modules/$m" && GOPRIVATE=git.novox.be go test -count=1 ./...)
|
||||
unformatted=$(cd "modules/$m" && gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "modules/$m is not gofmt'd: $unformatted"
|
||||
exit 1
|
||||
fi
|
||||
cgo=0
|
||||
[ -n "$race" ] && cgo=1
|
||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ./... &&
|
||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test $race -count=1 ./...)
|
||||
done
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
export type { BranchProtection, ProtectionWanted } from "./protection.js";
|
||||
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
export interface GiteaRepo {
|
||||
@@ -311,6 +313,11 @@ export class GiteaClient {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||
async holdsFile(owner: string, repo: string, sha: string, file: string): Promise<boolean> {
|
||||
return this.exists(`/repos/${owner}/${repo}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
|
||||
}
|
||||
|
||||
/** Whether the forge has something at a path: true for an answer, false for a 404, thrown otherwise. */
|
||||
private async exists(path: string): Promise<boolean> {
|
||||
let token = await this.tokens.current();
|
||||
@@ -331,6 +338,34 @@ export class GiteaClient {
|
||||
await this.request(`/repos/${owner}/${repo}/statuses/${sha}`, { method: "POST", body: JSON.stringify(status) });
|
||||
}
|
||||
|
||||
// ---- Branch protection ----
|
||||
|
||||
/** A repository's branch protection rules. */
|
||||
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
return (await this.request<BranchProtection[]>(`/repos/${owner}/${repo}/branch_protections`)) ?? [];
|
||||
}
|
||||
|
||||
/** The rule named for a branch, null when it has none. */
|
||||
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
|
||||
return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null;
|
||||
}
|
||||
|
||||
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
|
||||
* branch is edited — its required statuses replaced by these, everything else it says kept unless
|
||||
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
|
||||
async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> {
|
||||
const had = await this.branchProtection(owner, repo, branch);
|
||||
const body = protectionBody(want, !had);
|
||||
if (had) {
|
||||
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(branch)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(body) });
|
||||
return { created: false, rule };
|
||||
}
|
||||
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections`,
|
||||
{ method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) });
|
||||
return { created: true, rule };
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
|
||||
+24
-4
@@ -8,7 +8,12 @@
|
||||
// before it merges (novox/hq to-be 45 §9)
|
||||
//
|
||||
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
|
||||
// commit's status, with a comment saying why when it is not a pass.
|
||||
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
||||
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
|
||||
// saying why when the gate is not a pass or the repository's own check failed.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
|
||||
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
|
||||
@@ -20,7 +25,7 @@
|
||||
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, commentFor, headsToAnnounce, statusFor, type Announced, type Checked } from "./pulls.js";
|
||||
import { CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked } from "./pulls.js";
|
||||
|
||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
|
||||
@@ -192,6 +197,19 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
if (fresh) {
|
||||
const files = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||
const head = String(pull.head_sha);
|
||||
// What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as
|
||||
// amended): which changed directories hold a module at the head — the merge's rule (issue 278) —
|
||||
// and whether the head holds the repository's own merge-check.sh. Not said when it could not be
|
||||
// read; the controller then reads the change as touching everything built from the repository.
|
||||
let moduleDirs: string[] | null = null;
|
||||
let mergeCheck: boolean | null = null;
|
||||
try {
|
||||
if (!files.truncated) moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, head, files.paths);
|
||||
mergeCheck = await client.holdsFile(repo.owner, repo.name, head, "merge-check.sh");
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${key}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
await emit("pull.updated", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
@@ -204,13 +222,15 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
html_url: pull.html_url,
|
||||
paths: files.paths,
|
||||
paths_truncated: files.truncated,
|
||||
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
||||
...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}),
|
||||
});
|
||||
// Said, so an operator knows the mesh was asked to check it.
|
||||
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing.
|
||||
await client
|
||||
.setCommitStatus(repo.owner, repo.name, String(pull.head_sha), {
|
||||
state: "pending", context: CHECK_CONTEXT, description: "the mesh is checking this head against every machine",
|
||||
state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph",
|
||||
})
|
||||
.catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`));
|
||||
}
|
||||
@@ -232,7 +252,7 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
|
||||
console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored");
|
||||
return;
|
||||
}
|
||||
await client.setCommitStatus(c.owner, c.repo, c.commit, statusFor(c));
|
||||
for (const status of statusesFor(c)) await client.setCommitStatus(c.owner, c.repo, c.commit, status);
|
||||
const comment = commentFor(c);
|
||||
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
|
||||
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check —
|
||||
// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it
|
||||
// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
|
||||
|
||||
/** A branch protection rule, as the forge keeps it — the fields the mesh reads; the forge sends more. */
|
||||
export interface BranchProtection {
|
||||
rule_name?: string;
|
||||
branch_name?: string;
|
||||
enable_push?: boolean;
|
||||
enable_status_check?: boolean;
|
||||
status_check_contexts?: string[] | null;
|
||||
required_approvals?: number;
|
||||
block_admin_merge_override?: boolean;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's
|
||||
* default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */
|
||||
export interface ProtectionWanted {
|
||||
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */
|
||||
statusChecks: string[];
|
||||
/** Whether a person may push to the branch directly. */
|
||||
push?: boolean;
|
||||
/** Whether an administrator is stopped from merging past a status that has not succeeded. */
|
||||
blockAdminOverride?: boolean;
|
||||
}
|
||||
|
||||
/** The forge's body for a wanted protection. */
|
||||
export function protectionBody(want: ProtectionWanted, creating: boolean): Record<string, unknown> {
|
||||
const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))];
|
||||
const body: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
|
||||
if (want.push !== undefined) body.enable_push = want.push;
|
||||
else if (creating) body.enable_push = false;
|
||||
if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride;
|
||||
return body;
|
||||
}
|
||||
|
||||
+52
-13
@@ -11,9 +11,19 @@
|
||||
|
||||
import type { CommitStatus, GiteaPull } from "./client.js";
|
||||
|
||||
/** The status context a merge check is kept under: one per commit, the newest replacing the last. */
|
||||
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
|
||||
export const CHECK_CONTEXT = "mesh/merge-gate";
|
||||
|
||||
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
|
||||
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
|
||||
|
||||
/** One layer of a check, judged. */
|
||||
export interface Layer {
|
||||
verdict: string;
|
||||
summary: string;
|
||||
modules?: string[];
|
||||
}
|
||||
|
||||
/** What the controller says as `checked` (mesh-controller internal/link, Checked). */
|
||||
export interface Checked {
|
||||
owner: string;
|
||||
@@ -25,6 +35,10 @@ export interface Checked {
|
||||
report?: string;
|
||||
id: string;
|
||||
on?: string;
|
||||
/** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A
|
||||
* controller from before the layers says neither, and its verdict is the gate's. */
|
||||
gate?: Layer;
|
||||
"repo-check"?: Layer;
|
||||
}
|
||||
|
||||
/** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
|
||||
@@ -36,21 +50,46 @@ export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Ann
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
|
||||
}
|
||||
|
||||
/** The forge's status for a verdict: an error is the forge's `error`, never a success. */
|
||||
export function statusFor(c: Checked): CommitStatus {
|
||||
const state: CommitStatus["state"] =
|
||||
c.verdict === "pass" ? "success" : c.verdict === "warning" ? "warning" : c.verdict === "fail" ? "failure" : "error";
|
||||
// The forge keeps a short description; the rest is the comment's.
|
||||
let description = `${c.verdict}: ${c.summary}`.replace(/\s+/g, " ").trim();
|
||||
if (description.length > 140) description = description.slice(0, 139) + "…";
|
||||
return { state, context: CHECK_CONTEXT, description };
|
||||
/** The forge's state for a verdict: an error is the forge's `error`, never a success. */
|
||||
function stateOf(verdict: string): CommitStatus["state"] {
|
||||
return verdict === "pass" ? "success" : verdict === "warning" ? "warning" : verdict === "fail" ? "failure" : "error";
|
||||
}
|
||||
|
||||
/** The comment a verdict that is not a pass leaves on its pull request: the check's own account. */
|
||||
function described(verdict: string, summary: string, modules?: string[]): string {
|
||||
// The forge keeps a short description; the rest is the comment's.
|
||||
let d = `${verdict || "error"}: ${summary}`;
|
||||
if (modules?.length) d += ` [${modules.join(", ")}]`;
|
||||
d = d.replace(/\s+/g, " ").trim();
|
||||
return d.length > 140 ? d.slice(0, 139) + "…" : d;
|
||||
}
|
||||
|
||||
/** The forge's status for the gate. */
|
||||
export function statusFor(c: Checked): CommitStatus {
|
||||
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||
return { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description: described(gate.verdict, gate.summary, gate.modules) };
|
||||
}
|
||||
|
||||
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
|
||||
export function statusesFor(c: Checked): CommitStatus[] {
|
||||
const out = [statusFor(c)];
|
||||
const repo = c["repo-check"];
|
||||
if (repo) out.push({ state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The comment a verdict leaves on its pull request, with the check's own account: when the gate is not a
|
||||
* pass, or the repository's own check failed or could not run. A repository with no merge-check.sh is
|
||||
* said by its status alone, not by a comment on every push. */
|
||||
export function commentFor(c: Checked): string | null {
|
||||
if (c.verdict === "pass") return null;
|
||||
const head = `**Merge check: ${c.verdict.toUpperCase()}** at \`${c.commit.slice(0, 8)}\` — ${c.summary}`;
|
||||
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||
const repo = c["repo-check"];
|
||||
const repoWrong = !!repo && repo.verdict !== "pass" && repo.verdict !== "warning";
|
||||
if (gate.verdict === "pass" && !repoWrong) return null;
|
||||
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
|
||||
lines.push(`- \`${CHECK_CONTEXT}\`: **${(gate.verdict || "error").toUpperCase()}** — ${gate.summary}` +
|
||||
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")})` : ""));
|
||||
if (repo) lines.push(`- \`${REPO_CHECK_CONTEXT}\`: **${(repo.verdict || "error").toUpperCase()}** — ${repo.summary}`);
|
||||
const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : "";
|
||||
const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : "";
|
||||
return head + ran + report;
|
||||
return lines.join("\n") + ran + report;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
// A branch's protection (novox/hq ADR 0237): what the operator's agent sets so a pull request waits for the
|
||||
// mesh's merge check before it merges.
|
||||
|
||||
test("the statuses asked for replace the rule's, and an empty list requires none", async () => {
|
||||
const { protectionBody } = await import("../protection.ts");
|
||||
assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false),
|
||||
{ enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] },
|
||||
"an edited rule keeps its pushes as they are, and each status once");
|
||||
assert.deepEqual(protectionBody({ statusChecks: [] }, false), { enable_status_check: false, status_check_contexts: [] });
|
||||
});
|
||||
|
||||
test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => {
|
||||
const { protectionBody } = await import("../protection.ts");
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true);
|
||||
});
|
||||
@@ -30,12 +30,38 @@ test("a verdict is the head commit's status; an error is the forge's error, neve
|
||||
assert.ok(long.description.length <= 140 && long.context === CHECK_CONTEXT);
|
||||
assert.equal(commentFor({ ...base, verdict: "pass", summary: "fine" }), null, "a pass leaves no comment");
|
||||
const said = commentFor({ ...base, verdict: "fail", summary: "lemurs refused", report: "fails:\n - ```x```" }) ?? "";
|
||||
assert.match(said, /Merge check: FAIL/);
|
||||
assert.match(said, /mesh\/merge-gate`: \*\*FAIL\*\*/);
|
||||
assert.match(said, /01234567/);
|
||||
assert.match(said, /builds --log build-1/);
|
||||
assert.ok(!said.slice(said.indexOf("```") + 3, said.lastIndexOf("```")).includes("```"), "the report cannot close its own block");
|
||||
});
|
||||
|
||||
test("each layer is its own status: the gate with the modules it judged, the repository's own check beside it", async () => {
|
||||
const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
|
||||
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" };
|
||||
const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes",
|
||||
gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"] },
|
||||
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } });
|
||||
assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]);
|
||||
assert.match(both[0].description, /gitea, keycloak/);
|
||||
assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" },
|
||||
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/);
|
||||
|
||||
// Nothing of the graph touched, no script: a pass and a warning, and no comment on every push.
|
||||
const quiet = { ...base, verdict: "pass", summary: "the change touches no module of the mesh's graph",
|
||||
gate: { verdict: "pass", summary: "the change touches no module of the mesh's graph" },
|
||||
"repo-check": { verdict: "warning", summary: "the repository declares no merge-check.sh" } };
|
||||
assert.deepEqual(statusesFor(quiet).map((s) => s.state), ["success", "warning"]);
|
||||
assert.equal(commentFor(quiet), null);
|
||||
|
||||
// A repository outside the mesh, touching nothing: the gate alone, a pass.
|
||||
assert.equal(statusesFor({ ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" } }).length, 1);
|
||||
|
||||
// A controller from before the layers: its verdict is the gate's.
|
||||
assert.deepEqual(statusesFor({ ...base, verdict: "warning", summary: "wide" }).map((s) => [s.context, s.state]),
|
||||
[[CHECK_CONTEXT, "warning"]]);
|
||||
});
|
||||
|
||||
test("a commit status is set on the commit, under the merge check's context", async () => {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
let seen: { path: string; body: any } | null = null;
|
||||
|
||||
@@ -10,7 +10,18 @@
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient } from "../client.js";
|
||||
import { GiteaClient, type BranchProtection } from "../client.js";
|
||||
|
||||
/** A protection rule as a person reads it: what it guards, not every field the forge keeps. */
|
||||
export function summarised(p: BranchProtection) {
|
||||
return {
|
||||
rule: p.rule_name ?? p.branch_name,
|
||||
push: p.enable_push ?? false,
|
||||
required_statuses: p.enable_status_check ? (p.status_check_contexts ?? []) : [],
|
||||
required_approvals: p.required_approvals ?? 0,
|
||||
admin_may_override: !(p.block_admin_merge_override ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coerce a comma-separated label string into names; empty/absent yields none. */
|
||||
function parseLabels(raw: unknown): string[] {
|
||||
@@ -361,6 +372,46 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Branch protection (novox/hq ADR 0237) ----
|
||||
{
|
||||
name: "gitea_branch_protection_get",
|
||||
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch (rule name); every rule when not given" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo);
|
||||
if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) };
|
||||
const rule = await gitea.branchProtection(owner, repo, String(args.branch));
|
||||
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_branch_protection_set",
|
||||
description: "Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch to protect, e.g. main" },
|
||||
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" },
|
||||
push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" },
|
||||
block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
|
||||
if (!branch) throw new Error("name the branch to protect");
|
||||
const before = await gitea.branchProtection(owner, repo, branch);
|
||||
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, {
|
||||
statusChecks: String(args.status_checks ?? "").split(","),
|
||||
push: args.push === undefined ? undefined : Boolean(args.push),
|
||||
blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override),
|
||||
});
|
||||
return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Labels ----
|
||||
{
|
||||
name: "gitea_list_labels",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "token.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user