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
340 lines
21 KiB
TypeScript
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 };
|
|
}
|