Merge pull request 'Answer what the mesh's checks said of a pull request: mesh-delivery's checks verb (hq ADR 0239)' (#109) from feat/delivery-checks-verb into main
This commit was merged in pull request #109.
This commit is contained in:
@@ -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<string, StatusRead>();
|
||||
const stamp = (s: StatusRead) => s.updated_at || s.created_at || "";
|
||||
for (const r of raw ?? []) {
|
||||
const s = r as Record<string, any>;
|
||||
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 };
|
||||
}
|
||||
@@ -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<any>(`/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<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user