Files
mesh-catalog/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go
T
jschoubben e057670bc4 Gate: seed source.main from the latest announcement's base head, not the newest delivery's (issue 1422)
A delivery made earlier can be announced again after a newer one, with main's head moved on: its base_sha is the freshest.
The gate now keeps when each base_sha was announced and seeds from the latest.
2026-10-28 11:05:38 +01:00

424 lines
20 KiB
Go

package main
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
)
// What mesh-delivery owns (novox/hq ADR 0239): the delivery — one commit in one repository — and the
// delivery group above it. Each is kept whole, one key of the module's own state per delivery and per
// group, written only by this holder.
// Delivery is one commit in one repository, from its pull request's head to every machine.
type Delivery struct {
// ID is owner/repository@<twelve characters of its commit>.
ID string `json:"id"`
Repository string `json:"repository"`
Commit string `json:"commit"`
// The pull request, when there is one: its number, title, base, head branch and page.
Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"`
Base string `json:"base,omitempty"`
Branch string `json:"branch,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
CloneURL string `json:"clone_url,omitempty"`
// The diffset, as the forge said it: what the planner maps onto modules.
Paths []string `json:"paths,omitempty"`
PathsTruncated bool `json:"paths_truncated,omitempty"`
Removed []string `json:"removed,omitempty"`
ModuleDirs []string `json:"module_dirs,omitempty"`
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
// After are the repositories its pull request's description names in `after: <repository>` lines. They are not read
// (novox/hq ADR 0419 §2): kept only to say once on the pull request that the line is not read. AfterSaid is whether
// that was said, for this pull request.
After []string `json:"after,omitempty"`
AfterSaid bool `json:"after_said,omitempty"`
// Comments is how many comments this owner owed on the pull request at this head, which numbers the next.
Comments int `json:"comments,omitempty"`
// Issues are the issues its title and body name ("issue 948"), shown to the operator who is asked to approve its merge.
Issues []int `json:"issues,omitempty"`
// Group is the delivery group it is in: its head branch name. Empty for a lone delivery.
Group string `json:"group,omitempty"`
State State `json:"state"`
Since time.Time `json:"since"`
Created time.Time `json:"created"`
// Check is its own check's verdict.
Check *Verdict `json:"check,omitempty"`
// Required are the statuses its base requires, as the forge last said them (gitea_open_pulls): mesh/check makes it
// the checker's (issue 1302). Empty until the forge said them.
Required []string `json:"required,omitempty"`
// CheckState is its head's mesh/check as the forge last said it (pending, success, failure, error): a pending one
// after a pass is the checker judging it again, and no Approve is asked meanwhile (issue 1302).
CheckState string `json:"check_state,omitempty"`
// CheckAsk is this owner's own ask of its check, when the forge's announcement did not bring one: a head
// adopted while it already waited, or one whose announcement the controller missed.
CheckAsk *CheckAsk `json:"check_ask,omitempty"`
// TrunkNow is its trunk's head as this owner last heard it move while it was open: the commit the last merge
// into its trunk landed as (novox/hq ADR 0323 §7). A verdict that names another trunk commit is out of date.
// Empty until a merge into its trunk is heard after it was made.
TrunkNow string `json:"trunk_now,omitempty"`
// BaseSHA is the base branch's head the forge last announced its head with (ADR 0323): sent with a check this
// owner asks, for a verdict no build judges to name its trunk commit.
BaseSHA string `json:"base_sha,omitempty"`
// BaseSHAAt is when the forge last announced its head with BaseSHA: the freshest of a repository's announcements gives
// source.main its trunk head when it names none (seedTrunks, issue 1422). Zero for one kept before it was written.
BaseSHAAt time.Time `json:"base_sha_at,omitzero"`
// AskedAgain is when its head was announced again while its verdict was out of date — the merge tool's ask of
// its check against the trunk's head (ADR 0323 §5) — and no verdict has come since: its one-hour bound runs from
// then. Nil while nothing is asked, and cleared by a verdict or another move of its trunk.
AskedAgain *time.Time `json:"asked_again,omitempty"`
// The facts the table's guards read. NewerHead is the head that superseded it; ClosedUnmerged the pull
// request closed; MergedAs the commit it landed on the trunk as; HeldWhy what holds it for a person.
NewerHead string `json:"newer_head,omitempty"`
ClosedUnmerged bool `json:"closed_unmerged,omitempty"`
// Others are the other open pull requests announced at this head while this one was live (issue 1114), as they were
// announced: the record is theirs to take over when this one's pull request closes unmerged.
Others []PullEvent `json:"others,omitempty"`
MergedAs string `json:"merged_as,omitempty"`
MergedAt time.Time `json:"merged_at"`
// InSource says the merge was looked at for source.main: written there, or left as older than the trunk's head (issue 888).
// A merge the gate handed to the bridge on an Approve is moved there by finish instead (merges.go).
InSource bool `json:"in_source,omitempty"`
// LearntLate says its merge was learnt after the fact, from its absence among the forge's open pull requests
// (settleGone, issue 1275): it moved no trunk here, and it never moves source.main, not even at a catch-up.
LearntLate bool `json:"learnt_late,omitempty"`
HeldWhy string `json:"held_why,omitempty"`
// FromForge is a delivery made from the forge's word at its merge: its head was never heard by this owner.
FromForge bool `json:"from_forge,omitempty"`
// Ready is what this owner did with the operator's Approve of its merge into main, and with the merge (novox/hq ADR
// 0421, ready.go). The json key is the one the ready mark's record had, so rows written before still read.
Ready *ReadyRun `json:"ready,omitempty"`
// ReviewedBy and ReviewedAt are the review record its pull request's description holds: the line
// `Reviewed-by: <who> at <full sha>` (novox/hq ADR 0421 §5, issue 1022), as written. Read at every head; never a
// hold: no Approve is asked until it names the head (novox/hq ADR 0496, issue 1803).
ReviewedBy string `json:"reviewed_by,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
// PushedBy is the session that pushed its head, as its pull request's description names it in a line
// `Pushed-by: <who@node>` (playbook 07): named with its branch when a new head is needed (novox/hq ADR 0505 §5).
PushedBy string `json:"pushed_by,omitempty"`
// FreshWanted is whether a passing verdict that arrived out of date is to have its check asked against the
// trunk's head, before the operator is asked (novox/hq ADR 0421 §3, issue 1022). Cleared when it is asked.
FreshWanted bool `json:"fresh_wanted,omitempty"`
// Settle is the settle ask a person had written for it while it is held for a merge without a passing check (settle.go,
// novox/hq ADR 0556 clause 5): its key in the gate's asks, why, who asked and when, and the operator's answer once read.
Settle *SettleAsk `json:"settle,omitempty"`
Transitions []Transition `json:"transitions,omitempty"`
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
// delivery is kept, retried until done.
Owed []Effect `json:"owed,omitempty"`
}
// Transition is one move of the table, kept.
type Transition struct {
At time.Time `json:"at"`
From State `json:"from"`
To State `json:"to"`
Event Event `json:"event"`
Why string `json:"why"`
By string `json:"by,omitempty"`
}
// 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"`
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"`
// Head and Trunk are what it judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, twelve
// characters each. Trunk is empty for a verdict of a build seat that judged the head alone. Beside are the
// commits of the other repositories the check read, by directory; Took how long it ran.
Head string `json:"head,omitempty"`
Trunk string `json:"trunk,omitempty"`
Beside map[string]string `json:"beside,omitempty"`
Took string `json:"took,omitempty"`
// Status is the forge status it was read from when it is not the controller's verdict: mesh/check, the checker's
// (novox/hq ADR 0447, issue 1302). Empty for the controller's `checked`.
Status string `json:"status,omitempty"`
}
// Judged is what a verdict judged, in words: "head 1a2b3c4d5e6f merged into trunk 7a8b9c0d1e2f" (ADR 0323 §3);
// empty for a verdict that names no trunk commit.
func (v *Verdict) Judged() string {
if v == nil || v.Trunk == "" {
return ""
}
return fmt.Sprintf("head %s merged into trunk %s", shortOf(v.Head, 12), shortOf(v.Trunk, 12))
}
// outOfDate is why a delivery's verdict no longer counts: it names a trunk commit that is not its trunk's head
// as this owner heard it (novox/hq ADR 0323 §7). Empty when it counts — or names no trunk commit, as a verdict
// of a build seat from before the rule does, which counts as it did.
func (d *Delivery) outOfDate() string {
if d.Check == nil || d.Check.Trunk == "" || d.TrunkNow == "" || sameCommit(d.Check.Trunk, d.TrunkNow) {
return ""
}
return outOfDateWords(d.Check.Trunk, d.TrunkNow)
}
// outOfDateWords is a verdict out of date, as the forge's holder says it on the pull request (ADR 0323 §4).
func outOfDateWords(judged, now string) string {
return fmt.Sprintf("out of date: judged against trunk %s, the trunk is now %s; judged again on a recheck or when "+
"it is merged", shortOf(judged, 12), shortOf(now, 12))
}
// CheckAsk is a head's own check, asked of the controller by this owner (or re-asked by healer H2).
type CheckAsk struct {
// ID is the ask's id as the controller answered it.
ID string `json:"id"`
At time.Time `json:"at"`
By string `json:"by"`
Why string `json:"why"`
// Reasked is how many times H2 asked it again: once at most, then the operator's.
Reasked int `json:"reasked,omitempty"`
}
// GateComposed is the gate layer of a group member's own verdict when the group's composed check judges it instead
// (novox/hq ADR 0403 §1): no gate was run for the member alone.
const GateComposed = "composed"
// Passes is whether the verdict lets the delivery be ready: the gate passed or warned, and the
// repository's own check did not fail or error.
func (v *Verdict) Passes() bool {
if v == nil {
return false
}
// A group member's own gate is the group's composed check's (novox/hq ADR 0403 §1): the controller says the layer
// "composed", and the member counts as passed here once its repository's check did not fail, as the composed
// check, asked after every member is ready, then judges them together.
gate := v.Gate == "pass" || v.Gate == "warning" || v.Gate == GateComposed
repo := v.Repo == "" || v.Repo == "pass" || v.Repo == "warning"
return gate && repo
}
// Effect is one thing owed outside the state: an event said, a line appended to a commit's note, the view
// on the pull request refreshed, a status set.
type Effect struct {
Kind string `json:"kind"`
Event string `json:"event,omitempty"` // an emit's module event, when not transition or group (issue 1025)
Commit string `json:"commit,omitempty"`
Line string `json:"line,omitempty"`
Context string `json:"context,omitempty"`
State string `json:"state,omitempty"`
Tries int `json:"tries,omitempty"`
Last string `json:"last,omitempty"`
Since time.Time `json:"since"`
// Next is when a failed effect is tried again: later after every failure, up to retryCap.
Next *time.Time `json:"next,omitempty"`
}
// The kinds of effect.
const (
EffectEmit = "emit"
EffectNote = "note"
EffectView = "view"
EffectStatus = "status"
// EffectComment is a comment on the pull request; its Context is the sequence number of the comment on the head, which
// keeps two comments on one head two acts (mesh-sdk go/delivery OwedCommentAct).
EffectComment = "comment"
)
// FromForgesWord is whether the delivery was made from the forge's word at its merge: said by FromForge, or —
// for one made before FromForge was kept — by its first transition.
func (d *Delivery) FromForgesWord() bool {
if d.FromForge {
return true
}
return len(d.Transitions) > 0 && d.Transitions[0].Event == EvAnnounced &&
strings.Contains(d.Transitions[0].Why, "so it is made from the forge's word")
}
func (d *Delivery) shownOnItsPullRequest() bool {
return d.Number > 0
}
// Owner and Repo split the repository.
func (d *Delivery) Owner() string { o, _, _ := strings.Cut(d.Repository, "/"); return o }
func (d *Delivery) Repo() string { _, r, _ := strings.Cut(d.Repository, "/"); return r }
// IDOf is a delivery's id: the repository and twelve characters of its commit.
func IDOf(repository, commit string) string {
return strings.ToLower(repository) + "@" + shortOf(commit, 12)
}
func shortOf(commit string, n int) string {
if len(commit) > n {
return commit[:n]
}
return commit
}
// sameCommit is whether two commits are one, however either is abbreviated.
func sameCommit(a, b string) bool {
if a == "" || b == "" {
return false
}
if len(a) > len(b) {
a, b = b, a
}
return len(a) >= 7 && strings.HasPrefix(strings.ToLower(b), strings.ToLower(a))
}
// reviewedLine is the review record of a pull request's description (novox/hq ADR 0421 §5, issue 1022):
// `Reviewed-by: <who> at <full sha>`, on a line of its own, who as who@node.
var reviewedLine = regexp.MustCompile(`(?im)^\s*Reviewed-by:\s*(\S+)\s+at\s+([0-9a-f]{40}|[0-9a-f]{64})\b`)
// ReviewIn reads the review record of a pull request's description: who reviewed and the full sha named. The last such
// line counts; empty when there is none.
func ReviewIn(body string) (who, sha string) {
for _, m := range reviewedLine.FindAllStringSubmatch(body, -1) {
who, sha = m[1], strings.ToLower(m[2])
}
return who, sha
}
// pushedLine is the line naming the session that pushed a pull request's head: `Pushed-by: <who@node>` (ADR 0505 §5).
var pushedLine = regexp.MustCompile(`(?im)^\s*Pushed-by:\s*([A-Za-z0-9_.\-]+(?:@[A-Za-z0-9_.\-]+)?)\s*$`)
// PushedIn reads it: the last such line, or "" when there is none.
func PushedIn(body string) string {
who := ""
for _, m := range pushedLine.FindAllStringSubmatch(body, -1) {
who = m[1]
}
return who
}
// authorOf is a delivery's author as the operator reads it: "the author of <branch>", and the session that pushed it
// when its description names one ("who on node": the router refuses an @ in its words).
func authorOf(d *Delivery) string {
who := "the author of " + d.Branch
if d.Branch == "" {
who = "its author"
}
if d.PushedBy != "" {
who += " (" + strings.Replace(d.PushedBy, "@", " on ", 1) + ")"
}
return who
}
// reviewWords is what the delivery's review record says of its head, for the ask: reviewed by whom, or, first, that it
// is not reviewed at this head (the ask is held then, ADR 0496). ok is whether it is reviewed at this head.
func reviewWords(d *Delivery) (words string, ok bool) {
switch {
case d.ReviewedBy == "":
return "not reviewed at this head: the description holds no Reviewed-by line", false
case !strings.EqualFold(d.ReviewedAt, d.Commit):
return fmt.Sprintf("not reviewed at this head: the Reviewed-by line names %s, the head is %s",
shortOf(d.ReviewedAt, 12), shortOf(d.Commit, 12)), false
}
// The router refuses an @ in an ask's words: who@node is said "who on node".
return "reviewed by " + strings.Replace(d.ReviewedBy, "@", " on ", 1) + " at " + shortOf(d.Commit, 12), true
}
// afterLines are the `after: <repository>` lines of a pull request's description.
var afterLines = regexp.MustCompile(`(?im)^\s*after:\s*([A-Za-z0-9_.\-/]+)\s*$`)
// AfterIn reads them.
func AfterIn(body string) []string {
var out []string
for _, m := range afterLines.FindAllStringSubmatch(body, -1) {
out = append(out, strings.TrimSuffix(m[1], ".git"))
}
sort.Strings(out)
return out
}
// Group is a delivery group: two or more deliveries sharing a head branch name, one level only.
type Group struct {
ID string `json:"id"`
Members []string `json:"members"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
// Check is its composed check: asked of the controller for these member commits, and its verdict.
Check *GroupCheck `json:"check,omitempty"`
// SaidGate is, by member id, why the member's mesh/merge-gate was last said pending (a group the controller judges;
// a group on mesh/check is owed none, issue 1302): written again only when it
// changes (novox/hq ADR 0403 §1, §6, issue 978). Cleared once a composed verdict stands at the heads, which the
// forge module then sets as every member's gate.
SaidGate map[string]string `json:"said_gate,omitempty"`
// Closed is set once a member reached the trunk: a group delivering takes no new member.
Closed bool `json:"closed,omitempty"`
// Said is the derived state last said, so a change of it is said once.
Said string `json:"said,omitempty"`
// Hold is why the group's merge or its Approve's ask is held back by a member's required status that is not a
// success (issue 1275); nil when nothing is.
Hold *GroupHold `json:"hold,omitempty"`
// SaidStatus is the mesh/delivery-group status last written for it: a change of the status a state takes is
// written too, though the state did not change (novox/hq issue 902).
SaidStatus string `json:"said_status,omitempty"`
// SaidReview is the wait for a review last said of the group while it was held for one (ADR 0496 §2): a change of
// the members still waiting is said too, though the state did not change.
SaidReview string `json:"said_review,omitempty"`
Owed []Effect `json:"owed,omitempty"`
}
// GroupHold is a group held back from its Approve or its merge because a member's required status is not a success.
type GroupHold struct {
// Kind is "unasked" (the Approve is not asked) or "blocked" (the approved merge is not started: nothing is merged).
Kind string `json:"kind"`
Member string `json:"member"`
Says string `json:"says"`
Since time.Time `json:"since"`
}
// GroupCheck is a group's composed check.
type GroupCheck struct {
Asked string `json:"asked"`
At time.Time `json:"at"`
Commits map[string]string `json:"commits"`
Verdict string `json:"verdict,omitempty"`
Summary string `json:"summary,omitempty"`
// Trunks are the trunk commit each member's head was merged into, by member id (novox/hq ADR 0323 §6).
Trunks map[string]string `json:"trunks,omitempty"`
// OutOfDate is why its verdict no longer counts: a merge that is not one of the group's members moved a
// member's trunk, at OutOfDateAt. It counts as outstanding (ADR 0239 §18), and runs again once a member is
// judged again after it.
OutOfDate string `json:"out_of_date,omitempty"`
OutOfDateAt time.Time `json:"out_of_date_at"`
}
// Commits is each member's commit, by id.
func commitsOf(ds []*Delivery) map[string]string {
out := map[string]string{}
for _, d := range ds {
out[d.ID] = d.Commit
}
return out
}
// sameMembers is whether two member→commit maps are one.
func sameMembers(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func mustJSON(v any) json.RawMessage {
raw, _ := json.Marshal(v)
return raw
}