C1: the gate and the forge hold no branch name; the trunk is the asked branch, a default is the repository's

Builds: design 47 G §428, §507, A-93

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 22:18:25 +01:00
parent a6ce8dcbd6
commit 0aa5d6637c
6 changed files with 47 additions and 35 deletions
@@ -17,12 +17,19 @@ const rules = [
{ branch_name: "release", enable_push: false },
];
const seen: string[] = [];
const opened: { base?: string }[] = [];
const server = createServer((req, res) => {
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
const as = req.headers.authorization === adminBasic ? "admin" : "tools";
seen.push(`${req.method} ${path} as ${as}`);
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
if (path === "/repos/novox/app") return send(200, { full_name: "novox/app", default_branch: "trunk" });
if (req.method === "POST" && path === "/repos/novox/app/pulls") {
let raw = "";
req.on("data", (c) => (raw += c));
return req.on("end", () => { opened.push(JSON.parse(raw)); send(201, { number: 7, title: "t", state: "open", html_url: "u" }); });
}
if (path.includes("/branch_protections")) {
if (as !== "admin") return send(403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (req.method === "GET") return send(200, rules);
@@ -57,3 +64,10 @@ test("gitea_branch_protection_set still gets the forge's refusal, as the tools'
);
assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n"));
});
// Design 47 G §428: no part's code holds a branch's name; a pull request opened with no base goes to the repository's default branch.
test("gitea_create_pull_request with no base opens against the repository's default branch, and a named base stays", async () => {
await tool("gitea_create_pull_request").run({ owner: "novox", repo: "app", title: "t", head: "feat/x" });
await tool("gitea_create_pull_request").run({ owner: "novox", repo: "app", title: "t", head: "feat/x", base: "release" });
assert.deepEqual(opened.map((o) => o.base), ["trunk", "release"]);
});
+10 -4
View File
@@ -93,6 +93,12 @@ export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): Too
// A merge, a status, a comment, a delivery's view and its note are the seat git's acts (seat.ts), asked by the forge
// bridge alone: no tool of the module's own does them (novox/hq design 47 *The seat git*).
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined): ToolDefinition[] {
// A branch no caller named is the repository's default branch, as the forge says it; no part's code holds one (§428).
const defaultBranchOf = async (owner: string, repo: string): Promise<string> => {
const found = String((await gitea.getRepo(owner, repo)).default_branch ?? "");
if (!found) throw new Error(`the forge names no default branch of ${owner}/${repo}: name the branch`);
return found;
};
return [
// ---- Repositories ----
{
@@ -269,14 +275,14 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
title: { type: "string", description: "the PR title" },
body: { type: "string", description: "the PR body (markdown)" },
head: { type: "string", description: "the source branch" },
base: { type: "string", description: "the target branch (default main)" },
base: { type: "string", description: "the target branch (default: the repository's default branch)" },
},
run: async (args) => ({
pull: await gitea.createPullRequest(String(args.owner), String(args.repo), {
title: String(args.title),
body: args.body ? String(args.body) : undefined,
head: String(args.head),
base: args.base ? String(args.base) : "main",
base: args.base ? String(args.base) : await defaultBranchOf(String(args.owner), String(args.repo)),
}),
}),
},
@@ -679,7 +685,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "a pull request's number: its head is read, and its base's protection" },
sha: { type: "string", description: "a commit, or the start of one, instead of a pull request" },
base: { type: "string", description: "with sha: the branch whose protection is read (default main)" },
base: { type: "string", description: "with sha: the branch whose protection is read (default: the repository's default branch)" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
@@ -702,7 +708,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
if (!ref) throw new Error("name a pull request's number or a commit");
const combined = await gitea.combinedStatus(owner, repo, ref);
const statuses = newestByContext(combined.statuses);
base = base || "main";
base = base || (await defaultBranchOf(owner, repo));
let rule: BranchProtection | null = null, unreadable: string | undefined;
try {
rule = await gitea.appliedProtection(owner, repo, base);
@@ -283,10 +283,10 @@ func tools(h *Holder, l *listening) []stdio.Tool {
{Name: "delivery_status",
Description: "Whether this owner works: how many delivery records are open and ended, what is owed to the forge and not yet " +
"answered, how many owed effects were tried since it started (done, failed and tried again, given up) and when the last " +
"was, whether its events arrive, the last refusals, and source.main as the gate's bucket source holds it.",
"was, whether its events arrive, the last refusals, and the source revision of every branch the gate's bucket source holds.",
Run: func(map[string]any) (any, error) {
out := h.Status(l.get())
out["source-main"] = h.SourceMain()
out["source"] = h.SourceAll()
return out, nil
}},
}
@@ -16,9 +16,6 @@ import (
// (switch.go, §613), never written; `source` is the gate's source revision per watched branch (§15, §291), served by the verb
// `source` below.
// MergeBranch is the trunk branch each repository's merge branch names in a source key.
const MergeBranch = "main"
// changeAttempts is how often a lost compare-and-set is read and applied again before it is said.
const changeAttempts = 10
@@ -155,7 +152,7 @@ func (kvSource) Branches() ([]string, error) {
// SourceRevision is the seat's verb source (§690): given a branch, every repository that branch's source key names, its
// trunk branch and head, the group that moved it last and its revision, or why it cannot be read; given none, `branches`,
// the branch of each source key the gate keeps. A key names each repository's merge branch only (MergeBranch). Outside the lock.
// the branch of each source key the gate keeps. The trunk of each repository is the branch asked, the key's own (§428, §507). Outside the lock.
func (h *Holder) SourceRevision(branch string) seat.SourceAnswer {
if h.Source == nil {
return seat.SourceAnswer{Error: "no source bucket"}
@@ -179,24 +176,8 @@ func (h *Holder) SourceRevision(branch string) seat.SourceAnswer {
}
out := seat.SourceAnswer{Branch: branch, Group: held.Group, Revision: int64(revision)}
for repository, head := range held.Heads {
out.Repositories = append(out.Repositories, seat.SourceHead{Repository: repository, Trunk: MergeBranch, Head: head})
out.Repositories = append(out.Repositories, seat.SourceHead{Repository: repository, Trunk: branch, Head: head})
}
sort.Slice(out.Repositories, func(i, j int) bool { return out.Repositories[i].Repository < out.Repositories[j].Repository })
return out
}
// SourceMain is source.main as the bucket source holds it, read and never written: its group and each repository's trunk
// head, or why it cannot be read. Outside the lock.
func (h *Holder) SourceMain() map[string]any {
if h.Source == nil {
return map[string]any{"error": "no source bucket"}
}
held, _, err := h.Source.Read(MergeBranch)
switch {
case err != nil:
return map[string]any{"error": err.Error()}
case held == nil:
return map[string]any{"heads": map[string]string{}, "says": "source.main is not written yet"}
}
return map[string]any{"group": held.Group, "heads": held.Heads}
}
@@ -84,3 +84,22 @@ func TestTheGateStatesItsPinsThenSaysPinsStated(t *testing.T) {
t.Fatalf("%d %v %+v", n, err, events)
}
}
// Design 47 G §428, §507: no part's code holds a branch's name; the trunk of each repository a source revision names is the
// branch whose key was read, and delivery_status says every branch's source, not one fixed branch's.
func TestTheSourceAnswerNamesTheBranchItWasAskedAndStatusSaysEveryBranch(t *testing.T) {
src := &branchSource{}
for _, b := range []string{"release", "feat"} {
_, _ = src.Set(b, func(*Source) (Source, bool) {
return Source{Group: "g-" + b, Heads: map[string]string{"novox/app": "c1"}}, true
})
}
h := &Holder{Now: time.Now, Source: src}
got := h.SourceRevision("release")
if len(got.Repositories) != 1 || got.Repositories[0].Trunk != "release" {
t.Fatalf("the trunk is the asked branch: %+v", got.Repositories)
}
if all, ok := h.SourceAll()["branches"].([]seat.SourceAnswer); !ok || len(all) != 2 {
t.Fatalf("status says every branch's source: %+v", h.SourceAll())
}
}
@@ -8,14 +8,6 @@ import (
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
)
// The holders the gate reads or hears, by the names their modules go by (the gate's own constants, one per reader: the
// manifest's reads and consumes name them).
const (
tellerModule = "mesh-delivery-teller"
viewModule = "mesh-delivery-view"
forgeBridgeModule = "mesh-delivery-forge-bridge"
)
// The values the gate keeps in the seat mesh-delivery's states. The seat's types are the layout and meshdeliveryclient
// holds their rules and codecs; Source, Owed and Answer are used as they are, and the three whose seat form differs from the
// form the gate works in (a pull request that holds its head, the router's ask as bytes, a turn's times as plain times)