Files
mesh-catalog/modules/gitea/seat.ts
T
jschoubben bc2e5574ef GIT7, GIT8: the forge module keeps the runner's failed required steps and refuses a head by them; git_failed_steps and git_failed_steps_drop
merge-pull refuses a head at whose fingerprint a run failed a required step (policy-failed, naming run, module, step), from the
module's own state failed-steps kept from step-ended, with the seen keys of every step end and the deletion once no watched
branch's head nor open pull request's head names a commit. The two tools read it with its fill and drop a key after the hand act.

Builds: design 47 G §39, §83, §84, §85, §135, §475, §487, §548

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:01:27 +01:00

340 lines
21 KiB
TypeScript

// 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 seat definition's `callers`, which the grants part grants by: the bus refuses a caller a
// definition does not name (design 47 §68, §313, §321), so this holder judges no caller and keeps no list of its own.
//
// **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, only into a base
// whose protection refuses a push, and refuses a head at whose fingerprint a run failed a `required` step (design 47 §39, §314).
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
import {
seat as GIT, verbs as GIT_VERBS,
type BranchAnswer, 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 TagAnswer, 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";
import type { Judged } from "./failedsteps.js";
/** The verbs of the seat, in its definition's order. */
export const VERBS: readonly string[] = Object.values(GIT_VERBS);
/** 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 whether the first registrations are written. */
export interface SeatDeps {
run: Runner;
container: string;
statuses: HeadStatuses;
/** Whether the first registrations were written (registrations.ts): until then merge-pull refuses every merge (design 47 §95). */
registered: () => boolean;
/** The failed `required` step that refuses a head, judged from the module's own state `failed-steps` (failedsteps.ts), or null. */
judge: (head: string) => Promise<Judged | null>;
}
/** 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}$/;
/** The git tag of a released Go version (design 51; design 47 §370). */
const GO_TAG = /^go\/v\d+\.\d+\.\d+$/;
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 answering in the seat's contract. */
export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[] {
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 {
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.branch, "The seat git's branch: a branch's head, and whether a protection rule refuses a direct push and admits only merges.",
async (args): Promise<BranchAnswer> => {
const { owner, repo, full } = repositoryOf(args.repository);
const branch = String(args.branch ?? "");
// The head first: a branch the forge does not hold is its 404, the kind not-found, never an unprotected branch.
const head = await gitea.branchHead(owner, repo, branch);
return { repository: full, branch, head, protected: pushRefused(await gitea.appliedProtection(owner, repo, branch)) };
}),
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 refuses a head at whose fingerprint a required step failed.",
async (args): Promise<MergePullAnswer> => mergePull(gitea, args, deps.registered, deps.judge)),
verb(GIT_VERBS.tag, "The seat git's tag: the git tag go/vMAJOR.MINOR.PATCH of a released version, written at the commit its build was made from; a tag that is at that commit is performed.",
async (args): Promise<TagAnswer> => {
const { owner, repo } = repositoryOf(args.repository);
const tag = String(args.tag ?? "");
if (!GO_TAG.test(tag)) throw new Refused(`${JSON.stringify(tag)} is not go/vMAJOR.MINOR.PATCH`);
const commit = commitOf("commit", args.commit);
const at = await gitea.tagCommit(owner, repo, tag);
if (at === commit) return { tag, performed: true };
if (at !== null) throw new Refused(`${tag} is at ${at.slice(0, 12)} already, not at ${commit.slice(0, 12)}; a tag is never moved`);
await gitea.createTag(owner, repo, tag, commit);
return { tag, performed: true };
}),
];
}
/** Whether a protection rule refuses a direct push and admits only merges: the rule exists and takes no push. */
export function pushRefused(rule: { enable_push?: boolean } | null): boolean {
return !!rule && rule.enable_push === false;
}
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>, registered: () => boolean,
judge: (head: string) => Promise<Judged | null>): 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, kind: NonNullable<MergePullAnswer["refused-kind"]>): MergePullAnswer =>
({ id, refused: `${full}#${number} is not merged: ${why}`, "refused-kind": kind });
// Until the first registrations are written no merge is made: the old assigner would write a want from the trunk with no
// pipeline (design 47 §95). It names what is missing.
if (!registered()) return no("no module is registered yet: the catalog has said no registration-changed, so no merge is made until the first registrations are written", "no-registrations");
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`, "head-moved");
}
if (pull.state !== "open") return no("it is closed", "git-refused");
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`, "head-moved");
}
const rule = await gitea.appliedProtection(owner, repo, String(pull.base ?? ""));
if (!pushRefused(rule)) {
return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`, "unprotected");
}
// The head's runs, judged from the module's own state (design 47 §39): a step that failed at its fingerprint in any run refuses it,
// naming the run, the module and the step; a head no run named merges, and the runner being down changes nothing.
const failed = await judge(head);
if (failed) {
return { id, refused: `${full}#${number} is not merged: the ${failed.step} step of ${failed.module} failed in run ${failed.run} at its build-input fingerprint ${failed.fingerprint}`,
"refused-kind": "policy-failed", run: failed.run, module: failed.module, step: failed.step };
}
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}`, "git-refused");
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 };
}