package main import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // firstBuilds asks the first build of every module a merge adds to a repository no registered module is built from, when // the merge was announced as made on the operator's Approve of that head (novox/hq ADR 0432, issue 1146): the modules' // directories are the ones the announcement says hold a module.json at the merge commit, as touchedBy reads them. The // merge is kept, so the build's outcome is judged by the rule that registers it (mayRegisterFrom, approvedMove). // // **Complete in either order, with no timer** (ADR 0426): when mesh-delivery's word is kept first, it is found here; when // the forge's merge is heard first, the merge is kept as a first merge waiting for the word, and keepingDeliveryMerges // runs this again when the word is kept (resumeFirstBuilds). A word that says no (a Decline, another head) asks nothing and // says why. Answers whether the merge was dealt with here. func (f following) firstBuilds(ctx context.Context, m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository, now time.Time) (bool, error) { inv := f.open.inventory repository := m.Owner + "/" + m.Repo added, why, err := firstBuildsOf(ctx, inv, m, entries, read) switch { case errors.Is(err, errFirstNotApproved): fmt.Printf("%s merged into %s (%.8s) adds %s, and no build is asked: %v\n", repository, m.Base, m.Commit, strings.Join(addedDirs(m, entries, read), ", "), err) return true, nil case errors.Is(err, errNotAnnounced): // Kept, waiting for mesh-delivery's word: a restart keeps it, and the word's arrival asks the builds. if kerr := keepFirstMerge(ctx, inv, m, now, true); kerr != nil { return true, notNow(kerr) } fmt.Printf("%s merged into %s (%.8s) adds %s; it is kept, and the first build is asked when mesh-delivery "+ "announces the merge on the operator's Approve\n", repository, m.Base, m.Commit, strings.Join(addedDirs(m, entries, read), ", ")) return true, nil case err != nil: return true, notNow(err) case len(added) == 0: return false, nil } if err := askFirstBuilds(ctx, inv, m, now, added, why); err != nil { return true, notNow(err) } return true, nil } // keepFirstMerge keeps a first merge as the controller heard it, waiting (no plan answers it). func keepFirstMerge(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, now time.Time, waitingForWord bool) error { merged, err := time.Parse(time.RFC3339Nano, m.MergedAt) if err != nil { merged = now } event, err := json.Marshal(keptMerge{SourceMoved: m, FirstBuild: waitingForWord}) if err != nil { return err } _, err = inv.AddMerge(ctx, inventory.BatchedMerge{Repository: m.Owner + "/" + m.Repo, Branch: m.Base, Commit: m.Commit, Merged: merged.UTC(), Heard: now, Event: event}) return err } // askFirstBuilds keeps the merge, asks the builds, and marks the merge as no longer waiting for them. func askFirstBuilds(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, now time.Time, added []string, why string) error { repository := m.Owner + "/" + m.Repo if err := keepFirstMerge(ctx, inv, m, now, false); err != nil { return err } fmt.Printf("%s merged into %s (%.8s) on the operator's Approve (%s): the mesh builds nothing from it yet, so the "+ "first build of %s is asked\n", repository, m.Base, m.Commit, why, strings.Join(added, ", ")) // The ask carries the warrant just checked, for this repository and these module paths alone: the guard of a build // through a verb (verbMayAsk) lets exactly that through. paths := make([]string, 0, len(added)) for _, dir := range added { if dir == "." { dir = "" } paths = append(paths, dir) } askBuilds(withFirstBuildWarrant(ctx, repository, paths, why), inv, m, buildSource{Repository: repository, Seat: gitSeat}, added) // Answered: it no longer waits, so the word's arrival again asks nothing twice. return inv.AnswerMerge(ctx, repository, m.Commit, inventory.WaitingRecord, false) } // resumeFirstBuilds runs firstBuilds for the first merges waiting for the word mesh-delivery just announced for a pull // request's head: the other order of the same two events. func (f following) resumeFirstBuilds(ctx context.Context, d deliveryMerge) error { actingOnMerges.Lock() defer actingOnMerges.Unlock() inv := f.open.inventory waiting, err := inv.WaitingMerges(ctx) if err != nil { return notNow(err) } for _, w := range waiting { k := announcedOf(w) if !k.FirstBuild || !strings.EqualFold(k.Owner+"/"+k.Repo, d.Owner+"/"+d.Repo) || k.Number != d.Number || k.HeadSHA != d.HeadSHA { continue } entries, err := inv.Catalogued(ctx) if err != nil { return notNow(err) } read, err := readForPlanning(ctx, inv) if err != nil { return notNow(err) } added, why, err := firstBuildsOf(ctx, inv, k.SourceMoved, entries, read) switch { case errors.Is(err, errFirstNotApproved): fmt.Printf("%s/%s merged into %s (%.8s) was waiting for mesh-delivery's word, and no build is asked: %v\n", k.Owner, k.Repo, k.Base, k.Commit, err) if aerr := inv.AnswerMerge(ctx, w.Repository, w.Commit, inventory.WaitingRecord, false); aerr != nil { return notNow(aerr) } case err != nil: return notNow(err) case len(added) > 0: if err := askFirstBuilds(ctx, inv, k.SourceMoved, time.Now().UTC(), added, why); err != nil { return notNow(err) } } } return nil } // errFirstNotApproved says a merge into a repository the mesh builds nothing from is not one the operator approved. var errFirstNotApproved = errors.New("it is no merge made on the operator's Approve of its head, so a hand `build` " + "registers a module from a repository the mesh does not build yet") // addedDirs are the module directories the merge adds to a repository no registered module is built from, none where // the merge is not into the trunk or some module is built from the repository already. func addedDirs(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) []string { if m.Base != "main" { return nil } for _, e := range entries { if !e.Provided && sameRepository(e.Source.Repository, m) { return nil } } _, added, _ := touchedBy(nil, entries, m, read) return added } // firstBuildsOf is the directories whose first build a merge asks, and the warrant it was made on; none when the merge // is no first merge into a repository the mesh builds nothing from. errFirstNotApproved when it is and the word on record // is no Approve of its head; errNotAnnounced when mesh-delivery's word is not kept yet. func firstBuildsOf(ctx context.Context, inv catalogueReader, m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) ([]string, string, error) { added := addedDirs(m, entries, read) if len(added) == 0 { return nil, "", nil } why, err := approvedAnnounced(ctx, inv, m.Owner, m.Repo, keptMerge{SourceMoved: m}) switch { case err == nil: return added, why, nil case errors.Is(err, errWordNotAnApprove): return nil, "", fmt.Errorf("%w (%v)", errFirstNotApproved, err) } // Not announced yet, or the bus or the store could not be asked: the caller waits or tries again, and asks nothing. return nil, "", err } // probeFirstBuildsID is the probe that reads the first merges waiting for mesh-delivery's word. const probeFirstBuildsID = "D18" // kindFirstBuildUnasked is what a first merge waiting too long for mesh-delivery's word raises. const kindFirstBuildUnasked = "first-build-unasked" // firstBuildWaitBound is how long a first merge waits for mesh-delivery's announcement of the operator's Approve before // it is said: a bound in the sense of ADR 0426 ยง3, not a delay. Nothing waits on it: the word's arrival asks the builds // at once, and the merge is said, never dropped, once it is past this (issue 1146). const firstBuildWaitBound = 10 * time.Minute // firstBuildObservations are the conditions of the first merges waiting past the bound, one for each; a merge answered is // no longer listed as waiting, so its condition clears with the next look. func firstBuildObservations(waiting []inventory.BatchedMerge, now time.Time) []conditions.Observation { var out []conditions.Observation for _, w := range waiting { k := announcedOf(w) if !k.FirstBuild || now.Sub(w.Heard) <= firstBuildWaitBound { continue } repository := k.Owner + "/" + k.Repo for_ := now.Sub(w.Heard).Round(time.Minute) out = append(out, conditions.Observation{Scope: conditions.ScopeMerge, ID: fmt.Sprintf("%s_%.8s", repository, k.Commit), Kind: kindFirstBuildUnasked, Severity: conditions.Warning, Summary: fmt.Sprintf("the merge %.8s of %s adds modules the mesh builds nothing from yet, and has waited %s for "+ "mesh-delivery's announcement of the operator's Approve of its head: no build is asked", k.Commit, repository, for_), Said: fmt.Sprintf("%s at %s waits for mesh-delivery's word on pull request %d (head %.8s)", repository, k.Commit, k.Number, k.HeadSHA), Headline: fmt.Sprintf("The first build of %s is not asked: no Approve was announced", repository), Explanation: fmt.Sprintf("The merge %.8s into %s adds modules the mesh builds nothing from yet. It registers them "+ "only when mesh-delivery announces that it merged the pull request on your Approve of that head, and no such "+ "announcement has come in %s. The merge is kept, and the builds are asked the moment the announcement is kept.", k.Commit, repository, for_), Resolved: "mesh-delivery announces the merge, or a hand `build` registers the modules", }) } return out } // probeFirstBuilds is D18: no first merge waits past its bound for mesh-delivery's word unsaid. func probeFirstBuilds(ctx context.Context, d *doctor) ([]conditions.Observation, error) { waiting, err := d.open.inventory.WaitingMerges(ctx) if err != nil { return nil, err } return firstBuildObservations(waiting, time.Now().UTC()), nil }