From 13894744a701fe59545476a4beebf15ecd441446 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:36:36 +0200 Subject: [PATCH] Answer what the mesh's checks said of a pull request, as mesh-delivery's checks verb (hq ADR 0239) Reading a pull request's mesh/merge-gate and mesh/repo-check meant scraping the controller's journal: the forge clips each status to 140 characters and nothing returned the verdict whole. mesh-delivery already keeps the verdict; it now keeps the machine that ran it, the layers' modules and the report, and its checks verb joins that to the forge's statuses, read through a new gitea_commit_statuses tool that also says whether the base's protection lets the commit merge. after: novox/mesh-controller --- modules/gitea/checks.ts | 83 ++++++ modules/gitea/client.ts | 7 + modules/gitea/test/checks.test.ts | 48 +++ modules/gitea/tools/index.ts | 42 +++ .../mesh-delivery/cmd/mesh-delivery/checks.go | 274 ++++++++++++++++++ .../cmd/mesh-delivery/checks_test.go | 242 ++++++++++++++++ .../cmd/mesh-delivery/fakes_test.go | 76 ++++- .../mesh-delivery/cmd/mesh-delivery/holder.go | 11 +- .../mesh-delivery/cmd/mesh-delivery/main.go | 44 +++ .../cmd/mesh-delivery/manifest_test.go | 5 +- .../mesh-delivery/cmd/mesh-delivery/model.go | 20 +- .../mesh-delivery/cmd/mesh-delivery/ports.go | 76 +++++ .../mesh-delivery/cmd/mesh-delivery/verbs.go | 2 +- modules/mesh-delivery/module.json | 4 +- 14 files changed, 919 insertions(+), 15 deletions(-) create mode 100644 modules/gitea/checks.ts create mode 100644 modules/gitea/test/checks.test.ts create mode 100644 modules/mesh-delivery/cmd/mesh-delivery/checks.go create mode 100644 modules/mesh-delivery/cmd/mesh-delivery/checks_test.go diff --git a/modules/gitea/checks.ts b/modules/gitea/checks.ts new file mode 100644 index 0000000..672ec03 --- /dev/null +++ b/modules/gitea/checks.ts @@ -0,0 +1,83 @@ +// What the forge says of a commit's checks (novox/hq ADR 0239): every status it keeps for the commit, the +// newest of each context, and whether the branch it merges into would let it merge — every status that +// branch's protection requires being a success. Read by mesh-delivery's `checks` verb, which joins it to the +// verdict it keeps; the forge's statuses are what the merge button reads, so they are asked of the forge. +// +// Pure functions, so they are tested without a forge; the tool does the asking. + +import type { BranchProtection } from "./protection.js"; + +/** One status of a commit as the forge keeps it. */ +export interface StatusRead { + context: string; + state: string; + description: string; + target_url?: string; + created_at?: string; + updated_at?: string; + creator?: string; +} + +/** The forge's statuses of a commit, the newest of each context, in context order. The forge answers the + * newest first; a status it says without a context is not one anything can require, and is left out. */ +export function newestByContext(raw: unknown[]): StatusRead[] { + const seen = new Map(); + const stamp = (s: StatusRead) => s.updated_at || s.created_at || ""; + for (const r of raw ?? []) { + const s = r as Record; + const context = String(s?.context ?? "").trim(); + if (!context) continue; + const read: StatusRead = { + context, + state: String(s.status ?? s.state ?? ""), + description: String(s.description ?? ""), + }; + if (s.target_url) read.target_url = String(s.target_url); + if (s.created_at) read.created_at = String(s.created_at); + if (s.updated_at) read.updated_at = String(s.updated_at); + const creator = s.creator?.login ?? s.creator?.username; + if (creator) read.creator = String(creator); + const before = seen.get(context); + if (!before || stamp(read) > stamp(before)) seen.set(context, read); + } + return [...seen.values()].sort((a, b) => a.context.localeCompare(b.context)); +} + +/** Whether a commit may merge into a branch, as its protection reads the statuses. */ +export interface MergeableRead { + /** The branch the protection is read for. */ + branch: string; + /** Whether the branch has a rule at all, and whether it requires statuses. */ + protected: boolean; + required: string[]; + /** Every required status that is not a success, with its state ("missing" when the commit has none). */ + blocking: { context: string; state: string }[]; + /** True when every required status is a success; null when the protection could not be read. */ + mergeable: boolean | null; + /** In words. */ + says: string; +} + +/** Whether the statuses let the commit merge under the branch's rule. A rule that cannot be read says + * nothing, never yes. A warning blocks: the forge combines it as a failure (novox/hq issue 293). */ +export function mergeableUnder(branch: string, rule: BranchProtection | null | undefined, statuses: StatusRead[], + unreadable?: string): MergeableRead { + if (unreadable !== undefined) { + return { branch, protected: false, required: [], blocking: [], mergeable: null, + says: `the protection of ${branch} could not be read: ${unreadable}` }; + } + const required = rule && rule.enable_status_check ? [...new Set((rule.status_check_contexts ?? []).filter(Boolean))] : []; + const by = new Map(statuses.map((s) => [s.context, s.state])); + const blocking = required + .filter((c) => by.get(c) !== "success") + .map((c) => ({ context: c, state: by.get(c) ?? "missing" })); + const says = !rule + ? `${branch} has no protection: nothing is required` + : required.length === 0 + ? `${branch} requires no status` + : blocking.length === 0 + ? `every status ${branch} requires is a success: ${required.join(", ")}` + : `${branch} requires ${required.join(", ")}; not a success: ` + + blocking.map((b) => `${b.context} (${b.state})`).join(", "); + return { branch, protected: !!rule, required, blocking, mergeable: blocking.length === 0, says }; +} diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 118f73a..fe8e423 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -348,6 +348,13 @@ export class GiteaClient { 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[] }> { + const raw = await this.request(`/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}/status?limit=50`); + return { sha: String(raw?.sha ?? ""), state: String(raw?.state ?? ""), statuses: Array.isArray(raw?.statuses) ? raw.statuses : [] }; + } + /** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */ async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> { const c = await this.request(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) }); diff --git a/modules/gitea/test/checks.test.ts b/modules/gitea/test/checks.test.ts new file mode 100644 index 0000000..b29062b --- /dev/null +++ b/modules/gitea/test/checks.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// What the forge says of a commit's checks (novox/hq ADR 0239): read by mesh-delivery's `checks` verb. + +test("the newest status of each context is kept, with what it said, when and by whom", async () => { + const { newestByContext } = await import("../checks.ts"); + const read = newestByContext([ + { context: "mesh/merge-gate", status: "success", description: "pass: every machine composes", updated_at: "2026-10-07T10:00:00Z", + creator: { login: "mesh-admin" }, target_url: "https://forge.example/o/r/pulls/1" }, + { context: "mesh/merge-gate", status: "failure", description: "fail: 0 of 4 compose", updated_at: "2026-10-07T09:00:00Z" }, + { context: "mesh/repo-check", state: "error", description: "error: the toolchain was not there", created_at: "2026-10-07T10:01:00Z" }, + { status: "success", description: "a status with no context" }, + ]); + assert.deepEqual(read.map((s) => [s.context, s.state]), [["mesh/merge-gate", "success"], ["mesh/repo-check", "error"]]); + assert.equal(read[0].creator, "mesh-admin"); + assert.equal(read[0].description, "pass: every machine composes"); + assert.equal(read[1].created_at, "2026-10-07T10:01:00Z"); +}); + +test("a commit merges only when every required status is a success; a warning blocks", async () => { + const { mergeableUnder } = await import("../checks.ts"); + const rule = { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }; + const ok = mergeableUnder("main", rule, [ + { context: "mesh/merge-gate", state: "success", description: "" }, + { context: "mesh/repo-check", state: "success", description: "" }, + ]); + assert.equal(ok.mergeable, true); + assert.deepEqual(ok.blocking, []); + + const not = mergeableUnder("main", rule, [{ context: "mesh/merge-gate", state: "warning", description: "" }]); + assert.equal(not.mergeable, false); + assert.deepEqual(not.blocking, [{ context: "mesh/merge-gate", state: "warning" }, { context: "mesh/repo-check", state: "missing" }]); + assert.match(not.says, /mesh\/repo-check \(missing\)/); +}); + +test("no rule requires nothing; a rule that cannot be read says nothing, never yes", async () => { + const { mergeableUnder } = await import("../checks.ts"); + const none = mergeableUnder("main", null, []); + assert.equal(none.mergeable, true); + assert.equal(none.protected, false); + const off = mergeableUnder("main", { rule_name: "main", enable_status_check: false, status_check_contexts: ["mesh/merge-gate"] }, []); + assert.equal(off.mergeable, true); + assert.deepEqual(off.required, []); + const unread = mergeableUnder("main", undefined, [], "Gitea API: 500"); + assert.equal(unread.mergeable, null); + assert.match(unread.says, /could not be read/); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index f3ddb98..b6ba570 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,6 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools" import { emit } from "@novox/mesh-sdk/events"; import { GiteaClient, type BranchProtection } from "../client.js"; import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js"; +import { mergeableUnder, newestByContext } from "../checks.js"; /** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */ const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea"; @@ -450,6 +451,47 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { return { created: await gitea.addComment(owner, repo, number, body) }; }, }, + { + name: "gitea_commit_statuses", + description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.", + input: { + owner: { type: "string", description: "the repository owner" }, + 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)" }, + }, + run: async (args) => { + const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); + const number = args.number === undefined || args.number === "" ? 0 : Number(args.number); + let ref = String(args.sha ?? "").trim(); + let base = String(args.base ?? "").trim(); + if (!owner || !repo) throw new Error("name the repository: owner and repo"); + if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`); + let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string; + head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined; + if (number > 0) { + const p = await gitea.getPullRequest(owner, repo, number); + pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head, + head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url }; + if (!ref) ref = p.head_sha ?? ""; + if (!base) base = p.base ?? ""; + if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`); + } + 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"; + let rule: BranchProtection | null = null, unreadable: string | undefined; + try { + rule = await gitea.branchProtection(owner, repo, base); + } catch (err) { + unreadable = err instanceof Error ? err.message : String(err); + } + return { commit: combined.sha || ref, pull, combined: combined.state, statuses, + merge: mergeableUnder(base, rule, statuses, unreadable) }; + }, + }, { name: "gitea_commit_status", description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to.", diff --git a/modules/mesh-delivery/cmd/mesh-delivery/checks.go b/modules/mesh-delivery/cmd/mesh-delivery/checks.go new file mode 100644 index 0000000..d8936b2 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/checks.go @@ -0,0 +1,274 @@ +package main + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// The `checks` verb (novox/hq ADR 0239): what the mesh's checks said of a pull request's head, or of one +// commit. Two records answer it, and neither alone is enough. The forge keeps each status — the newest of +// each context, a description clipped to what it shows, and the protection that decides whether the merge +// button works. This owner keeps the verdict whole as the controller said it: each layer's summary +// unclipped ("0 of 4 compose" is not lost to a 140-character description), the build seat's ask that ran +// it, the machine it ran on, and its report. So the forge is asked for its statuses and this owner joins +// its own verdict to the two statuses that verdict set. Nothing is written: a read, by anyone. + +// Context names of the statuses this owner can say more of than the forge keeps. +const ( + ctxGate = "mesh/merge-gate" + ctxRepo = "mesh/repo-check" + ctxDelivery = "mesh/delivery" + ctxGroup = "mesh/delivery-group" +) + +// CheckStatus is one status of the commit, with what this owner knows of it beyond the forge. +type CheckStatus struct { + Name string `json:"name"` + State string `json:"state"` + Description string `json:"description,omitempty"` + SetAt string `json:"set_at,omitempty"` + SetBy string `json:"set_by,omitempty"` + Link string `json:"link,omitempty"` + // From the verdict that set it, when this owner holds it: the build seat's ask, the machine that ran it, + // when it was heard, and the layer's verdict in full. + Build string `json:"build,omitempty"` + CheckedOn string `json:"checked_on,omitempty"` + CheckedAt string `json:"checked_at,omitempty"` + Verdict string `json:"verdict,omitempty"` + // Source says where the status was read: the forge, or — the forge unreachable — this owner's verdict. + Source string `json:"source"` +} + +// ChecksAnswer is the `checks` verb's answer. +type ChecksAnswer struct { + Repository string `json:"repository"` + Commit string `json:"commit"` + Pull *ForgePull `json:"pull,omitempty"` + Delivery *ChecksOf `json:"delivery,omitempty"` + Statuses []CheckStatus `json:"statuses"` + // Verdict is the merge check's verdict whole, as this owner heard it for this commit. + Verdict *Verdict `json:"verdict,omitempty"` + // Merge is whether the branch's protection lets the commit merge; Mergeable nil when it is not known. + Merge ForgeMerge `json:"merge"` + // Forge is why the forge could not be read, when it could not. + Forge string `json:"forge,omitempty"` + Says string `json:"says"` +} + +// ChecksOf is the delivery of the commit, in brief: `show` has it whole. +type ChecksOf struct { + ID string `json:"id"` + State State `json:"state"` + Since string `json:"since"` + Waits string `json:"waits,omitempty"` + Group string `json:"group,omitempty"` +} + +// Checks is the `checks` verb: a repository and a pull request's number, or a repository and a commit. +func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAnswer, error) { + repository = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git")) + owner, repo, ok := strings.Cut(repository, "/") + if !ok || owner == "" || repo == "" || strings.Contains(repo, "/") { + return nil, fmt.Errorf("%q is not owner/repository", repository) + } + commit = strings.ToLower(strings.TrimSpace(commit)) + if number < 0 { + return nil, fmt.Errorf("%d is not a pull request's number", number) + } + if number == 0 && commit == "" { + return nil, errors.New("name a pull request's number or a commit") + } + if commit != "" && (len(commit) < 7 || strings.Trim(commit, "0123456789abcdef") != "") { + return nil, fmt.Errorf("%q is not a commit: seven or more hexadecimal characters", commit) + } + + // What this owner knows first, for the base whose protection is read; the forge then says the head. + base := "" + if d := h.deliveryOf(repository, number, commit); d != nil { + base = d.Base + } + a := &ChecksAnswer{Repository: repository, Commit: commit} + fc, err := h.Forge.Statuses(owner, repo, number, commit, base) + if err != nil { + a.Forge = err.Error() + } else { + a.Commit, a.Pull = fc.Commit, fc.Pull + } + + d := h.deliveryOf(repository, number, a.Commit) + if a.Commit == "" && d != nil { + a.Commit = d.Commit // the forge away: the newest head this owner holds for the pull request + } + if d != nil { + a.Delivery = &ChecksOf{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), Waits: d.waits, + Group: d.Group} + if d.Check != nil && sameCommit(d.Commit, a.Commit) { + a.Verdict = d.Check + } + } + + if fc != nil { + for _, s := range fc.Statuses { + a.Statuses = append(a.Statuses, CheckStatus{Name: s.Context, State: s.State, Description: s.Description, + SetAt: firstOf(s.UpdatedAt, s.CreatedAt), SetBy: s.Creator, Link: s.TargetURL, Source: "the forge"}) + } + a.Merge = fc.Merge + } else { + // The forge away: the statuses the verdict set, as the forge holder sets them from it, said as such. + if v := a.Verdict; v != nil { + a.Statuses = append(a.Statuses, CheckStatus{Name: ctxGate, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"}) + if v.Repo != "" { + a.Statuses = append(a.Statuses, CheckStatus{Name: ctxRepo, State: forgeStateOf(v.Repo), Source: "mesh-delivery's verdict; the forge could not be read"}) + } + } + a.Merge = ForgeMerge{Branch: firstOf(base, "main"), Says: "not known: the forge could not be read, and only it holds the protection"} + } + for i := range a.Statuses { + h.addWhatIsKnown(&a.Statuses[i], a.Verdict, d) + } + sort.SliceStable(a.Statuses, func(i, j int) bool { return a.Statuses[i].Name < a.Statuses[j].Name }) + a.Says = a.says() + return a, nil +} + +// checksDelivery is the part of a delivery `checks` reads, copied under the lock. +type checksDelivery struct { + Delivery + waits string + groupSaid string +} + +// deliveryOf is the delivery of a commit — its head, or the commit it merged as — else, for a pull request, +// its newest head that was not superseded, else its newest. +func (h *Holder) deliveryOf(repository string, number int, commit string) *checksDelivery { + h.mu.Lock() + defer h.mu.Unlock() + var found *Delivery + if commit != "" { + for _, d := range h.deliveries { + if strings.EqualFold(d.Repository, repository) && (sameCommit(d.Commit, commit) || sameCommit(d.MergedAs, commit)) { + found = d + break + } + } + } else if number > 0 { + for _, d := range h.deliveries { + if !strings.EqualFold(d.Repository, repository) || d.Number != number { + continue + } + if found == nil || newerHead(d, found) { + found = d + } + } + } + if found == nil { + return nil + } + c := &checksDelivery{Delivery: *found, waits: h.waitsFor(found)} + if found.Check != nil { + v := *found.Check + c.Check = &v + } + if g := h.groups[found.Group]; g != nil { + state, why := GroupState(g, h.membersOf(g)) + c.groupSaid = state + ": " + why + if g.Check != nil && g.Check.Verdict != "" { + c.groupSaid += "; composed check " + g.Check.Verdict + " — " + g.Check.Summary + } + } + return c +} + +// newerHead is whether d is a pull request's head to read before e: one not superseded first, then the newest. +func newerHead(d, e *Delivery) bool { + if (d.State == Superseded) != (e.State == Superseded) { + return e.State == Superseded + } + return d.Created.After(e.Created) +} + +// addWhatIsKnown joins this owner's verdict to the status it set, and the delivery to its own statuses. +func (h *Holder) addWhatIsKnown(s *CheckStatus, v *Verdict, d *checksDelivery) { + switch s.Name { + case ctxGate, ctxRepo: + if v == nil { + return + } + s.Build, s.CheckedOn = v.ID, v.On + if !v.At.IsZero() { + s.CheckedAt = v.At.UTC().Format(time.RFC3339) + } + if s.Name == ctxGate { + s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary + if len(v.Modules) > 0 { + s.Verdict += " (modules: " + strings.Join(v.Modules, ", ") + if len(v.Dependents) > 0 { + s.Verdict += "; built after them: " + strings.Join(v.Dependents, ", ") + } + s.Verdict += ")" + } + } else if v.Repo != "" { + s.Verdict = strings.ToUpper(v.Repo) + " — " + v.RepoSaid + } + case ctxDelivery: + if d != nil { + s.Verdict = string(d.State) + if d.waits != "" { + s.Verdict += ": waits for " + d.waits + } + } + case ctxGroup: + if d != nil && d.groupSaid != "" { + s.Verdict = d.groupSaid + } + } +} + +// forgeStateOf is the forge state the forge's holder sets for a verdict (the gitea module's pulls.ts): a +// warning is a pass with a note, an error never a success. +func forgeStateOf(verdict string) string { + switch verdict { + case "pass", "warning": + return "success" + case "fail": + return "failure" + } + return "error" +} + +func (a *ChecksAnswer) says() string { + what := a.Repository + "@" + shortOf(a.Commit, 8) + if a.Pull != nil { + what = fmt.Sprintf("%s#%d at %s", a.Repository, a.Pull.Number, shortOf(a.Commit, 8)) + } + var parts []string + for _, s := range a.Statuses { + parts = append(parts, s.Name+" "+s.State) + } + statuses := "no status" + if len(parts) > 0 { + statuses = strings.Join(parts, ", ") + } + merge := a.Merge.Says + switch { + case a.Merge.Mergeable == nil: + merge = "mergeable: not known — " + merge + case *a.Merge.Mergeable: + merge = "mergeable — " + merge + default: + merge = "NOT mergeable — " + merge + } + return what + ": " + statuses + "; " + merge +} + +func firstOf(s ...string) string { + for _, x := range s { + if x != "" { + return x + } + } + return "" +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/checks_test.go b/modules/mesh-delivery/cmd/mesh-delivery/checks_test.go new file mode 100644 index 0000000..b2565ef --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/checks_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// The `checks` verb (novox/hq ADR 0239): what the mesh's checks said of a pull request's head or a commit — +// the forge's statuses, the verdict whole as the controller said it, and whether the base's protection lets +// it merge. + +// checkedAs is the controller's `checked` as it comes off the bus, both layers said. +func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid, repoVerdict, repoSaid string) CheckedEvent { + t.Helper() + owner, name, _ := strings.Cut(repo, "/") + raw := map[string]any{"owner": owner, "repo": name, "number": number, "commit": sha, "verdict": gate, + "summary": gateSaid, "id": "build-" + sha[:8], "on": "the-build-node", "report": "== compose\n" + gateSaid, + "gate": map[string]any{"verdict": gate, "summary": gateSaid, "modules": []string{"app"}, "dependents": []string{"web"}}, + "repo-check": map[string]any{"verdict": repoVerdict, "summary": repoSaid}} + var c CheckedEvent + if err := json.Unmarshal(mustJSON(raw), &c); err != nil { + t.Fatal(err) + } + return c +} + +// statusesAsTheForgeSets puts the two statuses the forge's holder sets from a verdict: clipped, as it keeps them. +func statusesAsTheForgeSets(w *world, repo, sha string, c CheckedEvent) { + owner, name, _ := strings.Cut(repo, "/") + _ = w.forge.Status(owner, name, sha, ctxGate, forgeStateOf(c.Gate.Verdict), clip(c.Gate.Verdict+": "+c.Gate.Summary, 40), "https://forge.invalid/pr") + _ = w.forge.Status(owner, name, sha, ctxRepo, forgeStateOf(c.RepoCheck.Verdict), clip(c.RepoCheck.Verdict+": "+c.RepoCheck.Summary, 40), "https://forge.invalid/pr") +} + +func statusNamed(a *ChecksAnswer, name string) *CheckStatus { + for i := range a.Statuses { + if a.Statuses[i].Name == name { + return &a.Statuses[i] + } + } + return nil +} + +func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *testing.T) { + w := newWorld(t) + w.forge.required = []string{ctxGate, ctxRepo} + w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Title: "a change", State: "open", Base: "main", Head: "feat/x", HeadSHA: head} + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) + said := "0 of 4 compose: anchor refuses app's manifest — a provision nothing provides, and three more machines alike" + c := checkedAs(t, "novox/app", 7, head, "fail", said, "pass", "its merge-check.sh passed") + w.h.Checked(c) + w.settleAll() + statusesAsTheForgeSets(w, "novox/app", head, c) + + a, err := w.h.Checks("novox/app", 7, "") + if err != nil { + t.Fatal(err) + } + if a.Commit != head || a.Pull == nil || a.Pull.Number != 7 || a.Forge != "" { + t.Fatalf("the head read is %q, pull %+v, forge %q", a.Commit, a.Pull, a.Forge) + } + gate := statusNamed(a, ctxGate) + if gate == nil || gate.State != "failure" || gate.Source != "the forge" || gate.SetBy != "mesh-admin" { + t.Fatalf("the gate's status is %+v", gate) + } + // The forge clipped it; the verdict says it whole, with the machine, the build and the modules. + if strings.Contains(gate.Description, "three more machines") { + t.Fatalf("the fake forge did not clip: %q", gate.Description) + } + if !strings.Contains(gate.Verdict, "FAIL — 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") || + !strings.Contains(gate.Verdict, "modules: app; built after them: web") { + t.Fatalf("the gate's verdict is %q", gate.Verdict) + } + if gate.Build != "build-"+head[:8] || gate.CheckedOn != "the-build-node" || gate.CheckedAt == "" { + t.Fatalf("the gate was checked as %q on %q at %q", gate.Build, gate.CheckedOn, gate.CheckedAt) + } + repo := statusNamed(a, ctxRepo) + if repo == nil || repo.State != "success" || repo.Verdict != "PASS — its merge-check.sh passed" || repo.CheckedOn != "the-build-node" { + t.Fatalf("the repository check's status is %+v", repo) + } + if d := statusNamed(a, ctxDelivery); d == nil || !strings.HasPrefix(d.Verdict, "rejected: waits for a new head") { + t.Fatalf("the delivery's status is %+v", d) + } + if a.Verdict == nil || !strings.Contains(a.Verdict.Report, "== compose") { + t.Fatalf("the verdict whole is %+v", a.Verdict) + } + if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) || a.Delivery.State != Rejected { + t.Fatalf("the delivery is %+v", a.Delivery) + } + if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxGate { + t.Fatalf("the merge is %+v", a.Merge) + } + if !strings.Contains(a.Says, "novox/app#7 at aaaaaaaa") || !strings.Contains(a.Says, "NOT mergeable") { + t.Fatalf("it says %q", a.Says) + } + // The forge was asked for the pull request, with the base this owner knew. + if len(w.forge.asked) != 1 || w.forge.asked[0] != "novox/app#7@ base=main" { + t.Fatalf("the forge was asked %v", w.forge.asked) + } +} + +func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) { + w := newWorld(t) + w.forge.required = []string{ctxGate, ctxRepo} + newer := "bbbbbbbbbbbb2222" + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x")) + w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose", "pass", "passed")) + w.h.PullUpdated(pr("novox/app", 7, newer, "feat/x")) + w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, State: "open", Base: "main", HeadSHA: newer} + + a, err := w.h.Checks("novox/app", 7, "") + if err != nil { + t.Fatal(err) + } + if a.Commit != newer || a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", newer) || a.Delivery.State != Proposed { + t.Fatalf("the new head reads as %q, %+v", a.Commit, a.Delivery) + } + if a.Verdict != nil { + t.Fatalf("the older head's verdict was given for the newer: %+v", a.Verdict) + } + if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 2 || a.Merge.Blocking[0].State != "missing" { + t.Fatalf("a head not checked yet merges as %+v", a.Merge) + } + + // Passed, it merges. + c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes", "pass", "its merge-check.sh passed") + w.h.Checked(c) + statusesAsTheForgeSets(w, "novox/app", newer, c) + a, _ = w.h.Checks("novox/app", 7, "") + if a.Merge.Mergeable == nil || !*a.Merge.Mergeable || !strings.Contains(a.Says, "mergeable") || strings.Contains(a.Says, "NOT") { + t.Fatalf("a head that passed: %+v, says %q", a.Merge, a.Says) + } + if g := statusNamed(a, ctxGate); g == nil || g.Verdict != "PASS — every machine composes (modules: app; built after them: web)" { + t.Fatalf("its gate: %+v", g) + } +} + +func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) { + w := newWorld(t) + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x")) + c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes", "pass", "passed") + w.h.Checked(c) + statusesAsTheForgeSets(w, "novox/app", head, c) + + a, err := w.h.Checks("Novox/App.git", 0, head[:8]) + if err != nil { + t.Fatal(err) + } + if a.Commit != head || a.Verdict == nil || a.Delivery == nil || a.Pull != nil { + t.Fatalf("by the start of its sha: %q, %+v, %+v", a.Commit, a.Verdict, a.Delivery) + } + // No protection: nothing is required, so it merges. + if a.Merge.Mergeable == nil || !*a.Merge.Mergeable { + t.Fatalf("with no protection: %+v", a.Merge) + } + + mergedAs := "dddddddddddd9999" + w.h.PullMerged(merged("novox/app", 7, head, mergedAs)) + _ = w.forge.Status("novox", "app", mergedAs, ctxDelivery, "pending", "published", "") + a, err = w.h.Checks("novox/app", 0, mergedAs) + if err != nil { + t.Fatal(err) + } + if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) { + t.Fatalf("the merge commit's delivery is %+v", a.Delivery) + } + if a.Verdict != nil { + t.Fatalf("the head's verdict was given for the merge commit: %+v", a.Verdict) + } + if s := statusNamed(a, ctxDelivery); s == nil || s.State != "pending" || s.Verdict == "" { + t.Fatalf("the merge's delivery status: %+v", s) + } +} + +func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing.T) { + w := newWorld(t) + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x")) + w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note", "error", "the toolchain was not there")) + w.forge.down = true + + a, err := w.h.Checks("novox/app", 7, "") + if err != nil { + t.Fatal(err) + } + if a.Forge == "" || a.Commit != head || a.Verdict == nil { + t.Fatalf("with the forge away: forge %q, commit %q, verdict %+v", a.Forge, a.Commit, a.Verdict) + } + gate, repo := statusNamed(a, ctxGate), statusNamed(a, ctxRepo) + if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") || + repo == nil || repo.State != "error" || repo.Verdict != "ERROR — the toolchain was not there" { + t.Fatalf("the statuses from the verdict: %+v %+v", gate, repo) + } + if a.Merge.Mergeable != nil || !strings.Contains(a.Says, "mergeable: not known") { + t.Fatalf("whether it merges, with the forge away: %+v, %q", a.Merge, a.Says) + } + + // The protection unreadable: the statuses are said, and whether it merges is not known. + w.forge.down, w.forge.protectionDown = false, true + w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Base: "main", HeadSHA: head} + a, _ = w.h.Checks("novox/app", 7, "") + if a.Merge.Mergeable != nil || a.Forge != "" { + t.Fatalf("with the protection unreadable: %+v", a.Merge) + } +} + +func TestChecksRefuseWhatNamesNoCommit(t *testing.T) { + w := newWorld(t) + for _, c := range []struct { + repo string + number int + commit string + says string + }{ + {"novox/app", 0, "", "number or a commit"}, + {"app", 7, "", "owner/repository"}, + {"novox/app/x", 7, "", "owner/repository"}, + {"novox/app", 0, "abc", "not a commit"}, + {"novox/app", 0, "zzzzzzzzzz", "not a commit"}, + {"novox/app", -1, "", "not a pull request's number"}, + } { + if _, err := w.h.Checks(c.repo, c.number, c.commit); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s #%d %q: %v", c.repo, c.number, c.commit, err) + } + } + if len(w.forge.asked) != 0 { + t.Fatalf("the forge was asked for what names nothing: %v", w.forge.asked) + } +} + +func TestANumberIsTakenAsANumberOrAsText(t *testing.T) { + for _, c := range []struct { + in any + want int + ok bool + }{{float64(168), 168, true}, {"168", 168, true}, {"#168", 168, true}, {nil, 0, true}, {"", 0, true}, + {"x", 0, false}, {1.5, 0, false}} { + got, err := intArg(map[string]any{"number": c.in}, "number") + if (err == nil) != c.ok || got != c.want { + t.Errorf("%v → %d %v", c.in, got, err) + } + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go b/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go index 0c76c28..3ed3865 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go @@ -215,10 +215,17 @@ type fakeForge struct { views map[string]string statuses map[string]string down bool + // What Statuses reads: the pull requests by owner/repo#number, the base's required statuses, whether the + // protection cannot be read, and what it was asked. + pulls map[string]ForgePull + required []string + protectionDown bool + asked []string } func newFakeForge() *fakeForge { - return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{}} + return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{}, + pulls: map[string]ForgePull{}} } func (f *fakeForge) Note(owner, repo, commit, line string) error { @@ -255,6 +262,73 @@ func (f *fakeForge) Status(owner, repo, commit, context, state, description, tar return nil } +// Statuses answers what the fake was told: a pull request's head from pulls, the statuses set on the commit +// (those Status set too, as the forge would keep them), and the base's required statuses from required. +func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.down { + return nil, errors.New("the forge is away") + } + f.asked = append(f.asked, fmt.Sprintf("%s/%s#%d@%s base=%s", owner, repo, number, commit, base)) + out := &ForgeChecks{} + if number > 0 { + p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)] + if !ok { + return nil, fmt.Errorf("no pull request %d", number) + } + out.Pull = &p + if commit == "" { + commit = p.HeadSHA + } + if base == "" { + base = p.Base + } + } + for key, said := range f.statuses { + at, context, _ := strings.Cut(key, " ") + if !strings.HasPrefix(at, owner+"/"+repo+"@") || !sameCommit(strings.TrimPrefix(at, owner+"/"+repo+"@"), commit) { + continue + } + if len(strings.TrimPrefix(at, owner+"/"+repo+"@")) > len(commit) { + commit = strings.TrimPrefix(at, owner+"/"+repo+"@") + } + state, rest, _ := strings.Cut(said, " ") + description, link, _ := strings.Cut(rest, " → ") + out.Statuses = append(out.Statuses, ForgeStatus{Context: context, State: state, Description: description, + TargetURL: link, UpdatedAt: "2026-10-06T12:00:00Z", Creator: "mesh-admin"}) + } + sort.Slice(out.Statuses, func(i, j int) bool { return out.Statuses[i].Context < out.Statuses[j].Context }) + out.Commit = commit + if base == "" { + base = "main" + } + out.Merge = ForgeMerge{Branch: base, Protected: f.required != nil, Required: f.required} + if f.protectionDown { + out.Merge.Says = "the protection of " + base + " could not be read" + return out, nil + } + ok := true + for _, r := range f.required { + state := "missing" + for _, st := range out.Statuses { + if st.Context == r { + state = st.State + } + } + if state != "success" { + ok = false + out.Merge.Blocking = append(out.Merge.Blocking, struct { + Context string `json:"context"` + State string `json:"state"` + }{r, state}) + } + } + out.Merge.Mergeable = &ok + out.Merge.Says = fmt.Sprintf("%s requires %v", base, f.required) + return out, nil +} + func (f *fakeForge) notesOn(commit string) []string { f.mu.Lock() defer f.mu.Unlock() diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder.go b/modules/mesh-delivery/cmd/mesh-delivery/holder.go index 8139eb7..4b6d0a8 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/holder.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/holder.go @@ -456,9 +456,13 @@ type CheckedEvent struct { Verdict string `json:"verdict"` Summary string `json:"summary"` ID string `json:"id"` + On string `json:"on,omitempty"` + Report string `json:"report,omitempty"` Gate *struct { - Verdict string `json:"verdict"` - Summary string `json:"summary"` + Verdict string `json:"verdict"` + Summary string `json:"summary"` + Modules []string `json:"modules,omitempty"` + Dependents []string `json:"dependents,omitempty"` } `json:"gate,omitempty"` RepoCheck *struct { Verdict string `json:"verdict"` @@ -509,9 +513,10 @@ func (h *Holder) Checked(c CheckedEvent) { h.Logf("[mesh-delivery] %s is %s: a verdict for it now is history (%s)", d.ID, d.State, c.Verdict) return } - v := &Verdict{ID: c.ID, At: now, Gate: c.Verdict, Summary: c.Summary} + v := &Verdict{ID: c.ID, On: c.On, At: now, Gate: c.Verdict, Summary: c.Summary, Report: c.Report} if c.Gate != nil { v.Gate, v.Summary = c.Gate.Verdict, c.Gate.Summary + v.Modules, v.Dependents = c.Gate.Modules, c.Gate.Dependents } if c.RepoCheck != nil { v.Repo, v.RepoSaid = c.RepoCheck.Verdict, c.RepoCheck.Summary diff --git a/modules/mesh-delivery/cmd/mesh-delivery/main.go b/modules/mesh-delivery/cmd/mesh-delivery/main.go index ed381cd..a718196 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/main.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/main.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "sync" "time" @@ -137,6 +138,29 @@ func str(description string) map[string]any { func strArg(a map[string]any, k string) string { s, _ := a[k].(string); return strings.TrimSpace(s) } +// intArg is a whole number given as a number or as text; absent is zero. +func intArg(a map[string]any, k string) (int, error) { + switch v := a[k].(type) { + case nil: + return 0, nil + case float64: + if v == float64(int(v)) { + return int(v), nil + } + case int: + return v, nil + case string: + v = strings.TrimPrefix(strings.TrimSpace(v), "#") + if v == "" { + return 0, nil + } + if n, err := strconv.Atoi(v); err == nil { + return n, nil + } + } + return 0, fmt.Errorf("%q is not a whole number: %v", k, a[k]) +} + func boolArg(a map[string]any, k string) bool { switch v := a[k].(type) { case bool: @@ -195,6 +219,26 @@ func tools(h *Holder, l *listening) []stdio.Tool { } return h.WhatIf(strArg(a, "repository"), strArg(a, "base"), strings.Split(strArg(a, "paths"), ",")) }}, + {Name: seat + "checks", + Description: "What the mesh's checks said of a pull request's head or of one commit: each of the commit's " + + "statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description and when it " + + "was set; the merge check's full verdict as the controller said it — each layer's summary, the machine " + + "that ran it, when, its build id and its report; and whether the branch's protection would let it " + + "merge, every required status being success.", + Input: map[string]any{"type": "object", "properties": map[string]any{"repository": str("owner/repository"), + "number": str("a pull request's number: its head is read"), + "commit": str("a commit's sha, or the start of one, instead of a pull request")}, + "required": []string{"repository"}}, + Run: func(a map[string]any) (any, error) { + if err := need(a, "repository"); err != nil { + return nil, err + } + number, err := intArg(a, "number") + if err != nil { + return nil, err + } + return h.Checks(strArg(a, "repository"), number, strArg(a, "commit")) + }}, {Name: seat + "table", Description: "The state table every delivery runs by: each transition with its guard, each state's bound " + "and what healer H2 may do once it has passed; and the machine steps' table.", diff --git a/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go b/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go index 7f14389..05989ba 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go @@ -59,12 +59,13 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { if !reflect.DeepEqual(m.State, []string{"deliveries", "groups"}) { t.Fatalf("state %v", m.State) } - // It calls exactly what its ports ask: the controller's six verbs and the forge's three tools. + // It calls exactly what its ports ask: the controller's six verbs and the forge's four tools. want := []string{} for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks"} { want = append(want, "seat:"+ControllerSeat+"."+v) } - want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status") + want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", + "gitea.gitea_commit_statuses") if !reflect.DeepEqual(m.Invokes, want) { t.Fatalf("invokes %v, wanted %v", m.Invokes, want) } diff --git a/modules/mesh-delivery/cmd/mesh-delivery/model.go b/modules/mesh-delivery/cmd/mesh-delivery/model.go index e812530..6d51cba 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/model.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/model.go @@ -80,14 +80,20 @@ type Transition struct { By string `json:"by,omitempty"` } -// Verdict is a check's verdict: the gate's and the repository's own. +// Verdict is a check's verdict: the gate's and the repository's own. ID is the build seat's ask that ran +// it, On the machine it ran on, At when this owner heard it; Report is the check's own account, as bounded +// as the controller says it. All of it is what `checks` answers, whole, beside the forge's short statuses. type Verdict struct { - ID string `json:"id,omitempty"` - At time.Time `json:"at"` - Gate string `json:"gate"` - Summary string `json:"summary"` - Repo string `json:"repo,omitempty"` - RepoSaid string `json:"repo_summary,omitempty"` + ID string `json:"id,omitempty"` + On string `json:"on,omitempty"` + At time.Time `json:"at"` + Gate string `json:"gate"` + Summary string `json:"summary"` + Modules []string `json:"modules,omitempty"` + Dependents []string `json:"dependents,omitempty"` + Repo string `json:"repo,omitempty"` + RepoSaid string `json:"repo_summary,omitempty"` + Report string `json:"report,omitempty"` } // CheckAsk is a head's own check, asked of the controller by this owner (or re-asked by healer H2). diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ports.go b/modules/mesh-delivery/cmd/mesh-delivery/ports.go index e39727e..1de9f85 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/ports.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/ports.go @@ -71,6 +71,56 @@ type Forge interface { View(owner, repo string, number int, body string) error // Status sets one status of a commit, linking to the view. Status(owner, repo, commit, context, state, description, target string) error + // Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch + // it merges into would let it merge. Reads only. + Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) +} + +// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses). +type ForgeChecks struct { + Commit string `json:"commit"` + Pull *ForgePull `json:"pull,omitempty"` + Combined string `json:"combined,omitempty"` + Statuses []ForgeStatus `json:"statuses"` + Merge ForgeMerge `json:"merge"` +} + +// ForgePull is a pull request as the forge says it. +type ForgePull struct { + Number int `json:"number"` + Title string `json:"title,omitempty"` + State string `json:"state,omitempty"` + Merged bool `json:"merged,omitempty"` + Base string `json:"base,omitempty"` + Head string `json:"head,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + MergeCommit string `json:"merge_commit_sha,omitempty"` + HTMLURL string `json:"html_url,omitempty"` +} + +// ForgeStatus is one status of a commit, the newest of its context. +type ForgeStatus struct { + Context string `json:"context"` + State string `json:"state"` + Description string `json:"description"` + TargetURL string `json:"target_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Creator string `json:"creator,omitempty"` +} + +// ForgeMerge is whether the branch's protection lets the commit merge: every required status a success. +// Mergeable is nil when the protection could not be read. +type ForgeMerge struct { + Branch string `json:"branch"` + Protected bool `json:"protected"` + Required []string `json:"required"` + Blocking []struct { + Context string `json:"context"` + State string `json:"state"` + } `json:"blocking"` + Mergeable *bool `json:"mergeable"` + Says string `json:"says"` } // Store keeps deliveries and groups: one key each, in the module's own state (ADR 0201). @@ -259,6 +309,32 @@ func (f toolForge) Status(owner, repo, commit, context, state, description, targ "context": context, "state": state, "description": description, "target_url": target}) } +func (f toolForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) { + args := map[string]any{"owner": owner, "repo": repo} + if number > 0 { + args["number"] = number + } + if commit != "" { + args["sha"] = commit + } + if base != "" { + args["base"] = base + } + raw, err := f.ask("gitea.gitea_commit_statuses", args) + if err != nil { + return nil, err + } + answer, output, ok := answerOf(raw) + if !ok { + return nil, fmt.Errorf("the forge refused gitea_commit_statuses: %s", lastLine(output)) + } + var c ForgeChecks + if err := json.Unmarshal(answer, &c); err != nil || c.Commit == "" { + return nil, fmt.Errorf("the forge's gitea_commit_statuses is not readable: %s", clip(string(answer), 200)) + } + return &c, nil +} + // kvStore is the module's own state: `deliveries` and `groups`, one key each. type kvStore struct{} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/verbs.go b/modules/mesh-delivery/cmd/mesh-delivery/verbs.go index 15d823d..f3edf4c 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/verbs.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/verbs.go @@ -8,7 +8,7 @@ import ( "time" ) -// The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): five that read, and +// The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): six that read, and // the acts — a person's recheck, release and stop, and healer H2's close, each only by a transition the table // holds. diff --git a/modules/mesh-delivery/module.json b/modules/mesh-delivery/module.json index f2fee26..b247fa5 100644 --- a/modules/mesh-delivery/module.json +++ b/modules/mesh-delivery/module.json @@ -11,6 +11,7 @@ "show", "groups", "what-if", + "checks", "table", "stalled", "recheck", @@ -40,7 +41,8 @@ "seat:mesh-controller.delivery-walks", "gitea.gitea_note_append", "gitea.gitea_delivery_view", - "gitea.gitea_commit_status" + "gitea.gitea_commit_status", + "gitea.gitea_commit_statuses" ], "state": [ "deliveries",