300 lines
12 KiB
Go
300 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// A branch is tried before its pull request (novox/hq issue 686), by the checker (novox/hq ADR 0501 §5; issue 1851).
|
|
//
|
|
// A repository's store tests skip without a store, and no agent may start a database on its machine. So a builder
|
|
// pushed, a reviewer read, and only the pull request's merge check ran those tests for the first time: each failure
|
|
// there cost a round of fifteen to thirty minutes (mesh-controller #283, #310, #312).
|
|
//
|
|
// `try <owner>/<repository> <ref>` asks the checker (mesh-delivery-checker's `checker_try`) to judge the commit the ref
|
|
// names merged onto the trunk, as it judges a pull request's head: the build modules' fetch, digest and test steps,
|
|
// the unit level and then the integration level, each level's kept verdicts taken. From R4e build-agent is asked no
|
|
// try. It is a check and nothing more:
|
|
//
|
|
// - **no pull request, so no status**: the checker owes the forge nothing for a try;
|
|
// - **nothing registered, nothing built for delivery**: a try writes no verdict of a group and no preview;
|
|
// - **bounded**: one try of a head at a time — a second ask answers the first's id — and at most four open, the
|
|
// checker's own rule; a repository the checker judges no level of (hq) is refused by the checker, and said.
|
|
//
|
|
// A check outlasts a seat verb's one answer, so the ask answers its id at once and `try --id <id>` reads what came of
|
|
// it from the checker (`checker_try_read`), holding its answer up to tryWait for a verdict, like `plans wait`.
|
|
|
|
// tryWait is how long reading a try holds its answer for the verdict. A variable so a test need not wait.
|
|
var tryWait = 20 * time.Second
|
|
|
|
// A ref as the forge is asked for it — a branch or a commit, never an option — and a `go test -run` pattern.
|
|
var (
|
|
tryID = regexp.MustCompile(`^try-[0-9]+$`)
|
|
tryRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
|
tryTests = regexp.MustCompile(`^[A-Za-z0-9_^$|().*+?/\[\]-]+$`)
|
|
)
|
|
|
|
// tryAnswer is what `try` answers, asking or reading.
|
|
type tryAnswer struct {
|
|
ID string `json:"id"`
|
|
// State is asked (just now), waiting (nothing said of it yet: in the build queue), running, or the verdict:
|
|
// pass, warning (the repository declares no merge-check.sh), fail, or error (it could not run).
|
|
State string `json:"state"`
|
|
Said string `json:"said"`
|
|
Repository string `json:"repository,omitempty"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Commit string `json:"commit,omitempty"`
|
|
Base string `json:"base,omitempty"`
|
|
Trunk string `json:"trunk,omitempty"`
|
|
On string `json:"on,omitempty"`
|
|
Took string `json:"took,omitempty"`
|
|
Failed []string `json:"failed,omitempty"`
|
|
// Last is the last thing the build seat said of one still running.
|
|
Last string `json:"last,omitempty"`
|
|
}
|
|
|
|
// isTry says a check's outcome is a try's: marked so, or — echoed by a build seat from before the mark — a
|
|
// check of no pull request and no group, which is nobody's status either.
|
|
func isTry(result link.BuildResult) bool {
|
|
c := result.Checked
|
|
return c != nil && (c.Try || (c.Number == 0 && c.Group == ""))
|
|
}
|
|
|
|
// checkerModule is the module whose tools judge a try (mesh-delivery-checker).
|
|
const checkerModule = "mesh-delivery-checker"
|
|
|
|
// checkerTry is the checker's answer to a try's ask: the try's id.
|
|
type checkerTry struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// checkerTryRead is the checker's word on a try (`checker_try_read`): its state queued, running, pass, fail or error
|
|
// (could not run), what it said, the head and trunk judged, how long it took, what failed, and the machines it ran on.
|
|
type checkerTryRead struct {
|
|
ID string `json:"id"`
|
|
Repository string `json:"repository"`
|
|
Head string `json:"head"`
|
|
Trunk string `json:"trunk"`
|
|
Branch string `json:"branch,omitempty"`
|
|
State string `json:"state"`
|
|
Said string `json:"said"`
|
|
Took string `json:"took,omitempty"`
|
|
Failed []string `json:"failed,omitempty"`
|
|
Machines []string `json:"machines,omitempty"`
|
|
}
|
|
|
|
// askChecker asks one of the checker's tools and reads its result into into. A variable so a test needs no checker.
|
|
var askChecker = func(ctx context.Context, tool string, args map[string]any, into any) error {
|
|
js, err := aBus()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer js.Close()
|
|
raw, _ := json.Marshal(args)
|
|
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, checkerModule, tool, raw, 30*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("%s.%s: %w", checkerModule, tool, err)
|
|
}
|
|
if answer.Error != "" {
|
|
return fmt.Errorf("%s.%s: %s", checkerModule, tool, answer.Error)
|
|
}
|
|
var wrapped struct {
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(answer.Result, &wrapped); err == nil && len(wrapped.Result) > 0 {
|
|
return json.Unmarshal(wrapped.Result, into)
|
|
}
|
|
return json.Unmarshal(answer.Result, into)
|
|
}
|
|
|
|
// tryOfChecker is the checker's word on a try as `try` answers it, today's answer unchanged (novox/hq issue 686): queued
|
|
// is waiting, and the machines it ran on are where.
|
|
func tryOfChecker(r checkerTryRead) tryAnswer {
|
|
a := tryAnswer{ID: r.ID, State: r.State, Said: r.Said, Repository: r.Repository, Commit: r.Head, Trunk: r.Trunk,
|
|
Took: r.Took, Failed: r.Failed, On: strings.Join(r.Machines, ", "), Base: r.Branch}
|
|
switch r.State {
|
|
case "queued":
|
|
a.State = "waiting"
|
|
if a.Said == "" {
|
|
a.Said = "it waits for the checker; ask again"
|
|
}
|
|
case "running":
|
|
if a.Said == "" {
|
|
a.Said = "still running; ask again"
|
|
}
|
|
}
|
|
return a
|
|
}
|
|
|
|
// forgeCommit asks the forge, through its module's tools on the bus, which commit a branch or commit of a
|
|
// repository is, and the repository's default branch. A variable so a test needs no forge.
|
|
var forgeCommit = func(ctx context.Context, owner, repo, ref string) (commit, trunk string, err error) {
|
|
js, err := aBus()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer js.Close()
|
|
bus := link.OverNATS{Conn: js.Conn()}
|
|
ask := func(path string, into any) error {
|
|
raw, _ := json.Marshal(map[string]any{"path": path})
|
|
answer, err := link.Ask(ctx, bus, "gitea", "gitea_api", raw, 30*time.Second)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if answer.Error != "" {
|
|
return errors.New(answer.Error)
|
|
}
|
|
return json.Unmarshal(answer.Result, into)
|
|
}
|
|
var commits struct {
|
|
Result []struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"result"`
|
|
}
|
|
if err := ask("/repos/"+owner+"/"+repo+"/commits?limit=1&stat=false&verification=false&files=false&sha="+
|
|
url.QueryEscape(ref), &commits); err != nil {
|
|
return "", "", err
|
|
}
|
|
if len(commits.Result) == 0 || commits.Result[0].SHA == "" {
|
|
return "", "", errors.New("the forge named no commit for it")
|
|
}
|
|
var found struct {
|
|
Result struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
} `json:"result"`
|
|
}
|
|
if err := ask("/repos/"+owner+"/"+repo, &found); err != nil {
|
|
return "", "", err
|
|
}
|
|
return commits.Result[0].SHA, found.Result.DefaultBranch, nil
|
|
}
|
|
|
|
// tryCommand is `try <owner>/<repository> <ref> [--base <branch>] [--tests <pattern>] [--store-only]`, and
|
|
// `try --id <id>` to read what came of one.
|
|
func tryCommand(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("try", flag.ContinueOnError)
|
|
base := set.String("base", "", "the branch it is merged into (the repository's default branch when none)")
|
|
tests := set.String("tests", "", "only the tests this `go test -run` pattern names")
|
|
storeOnly := set.Bool("store-only", false, "only the repository's store tests")
|
|
id := set.String("id", "", "a try's id: what came of it")
|
|
positionals, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
usage := errors.New("try <owner>/<repository> <branch or commit> [--base <branch>] [--tests <pattern>] " +
|
|
"[--store-only], or try --id <id>")
|
|
if *id != "" {
|
|
if len(positionals) != 0 {
|
|
return usage
|
|
}
|
|
// An id, never a pattern: the checker's `try-<n>`.
|
|
if !tryID.MatchString(*id) {
|
|
return fmt.Errorf("%q is not a try's id, try-<number>", *id)
|
|
}
|
|
return tryRead(ctx, *id)
|
|
}
|
|
if len(positionals) != 2 {
|
|
return usage
|
|
}
|
|
owner, repo, isPath := strings.Cut(positionals[0], "/")
|
|
ref := positionals[1]
|
|
switch {
|
|
case !isPath || !tryRef.MatchString(owner) || !tryRef.MatchString(repo) || strings.Contains(repo, "/"):
|
|
return fmt.Errorf("%q is not a repository on the forge, owner/name; nothing was asked", positionals[0])
|
|
case !tryRef.MatchString(ref):
|
|
return fmt.Errorf("%q is not a branch or a commit; nothing was asked", ref)
|
|
case *base != "" && !tryRef.MatchString(*base):
|
|
return fmt.Errorf("%q is not a branch; nothing was asked", *base)
|
|
case *tests != "" && !tryTests.MatchString(*tests):
|
|
return fmt.Errorf("%q is not a `go test -run` pattern of test names; nothing was asked", *tests)
|
|
}
|
|
// **An unknown ref is refused here, loudly** — not sent to a machine to fail at its clone minutes later.
|
|
commit, trunk, err := forgeCommit(ctx, owner, repo, ref)
|
|
if err != nil {
|
|
return fmt.Errorf("the forge holds no branch or commit %s in %s/%s — is it pushed? — so nothing was asked: %w",
|
|
ref, owner, repo, err)
|
|
}
|
|
if *base != "" {
|
|
trunk = *base
|
|
}
|
|
if trunk == "" {
|
|
return fmt.Errorf("the forge named no default branch for %s/%s, and no --base says what %s is merged into; "+
|
|
"nothing was asked", owner, repo, ref)
|
|
}
|
|
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
entries, read, edges, err := theGraph(ctx, open.inventory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p := link.PullUpdated{Owner: owner, Repo: repo, Base: trunk, Commit: commit}
|
|
// Whose repository it is, as a pull request's check reckons it: only the mesh's is tried, the code the
|
|
// build seat runs for every pull request already.
|
|
mesh := pullScope(p, entries, read, edges)
|
|
if !mesh.Mesh {
|
|
return fmt.Errorf("%s/%s is no repository of the mesh — no module is built from it and its owner is not "+
|
|
"the core's — and only the mesh's are tried on the build seat; nothing was asked", owner, repo)
|
|
}
|
|
// **Asked of the checker, never of build-agent** (ADR 0501 §5): the head merged onto the trunk, as a pull request's.
|
|
askArgs := map[string]any{"repository": strings.ToLower(owner + "/" + repo), "head": commit, "trunk": trunk}
|
|
if *tests != "" {
|
|
askArgs["tests"] = *tests
|
|
}
|
|
if *storeOnly {
|
|
askArgs["level"] = "integration"
|
|
}
|
|
var asked checkerTry
|
|
if err := askChecker(ctx, "checker_try", askArgs, &asked); err != nil {
|
|
return fmt.Errorf("the checker did not take a try of %s/%s at %.12s; nothing was asked: %w", owner, repo, commit, err)
|
|
}
|
|
return printTry(tryAnswer{ID: asked.ID, State: "asked", Repository: owner + "/" + repo, Ref: ref, Commit: commit,
|
|
Base: trunk, On: checkerModule, Said: fmt.Sprintf("asked %s to try %s/%s at %.12s (%s) merged into %s: the head "+
|
|
"judged as a pull request's, unit level then integration level; no status is set and nothing is registered. "+
|
|
"Read it with try id=%s", checkerModule, owner, repo, commit, ref, trunk, asked.ID)})
|
|
}
|
|
|
|
// tryRead answers what came of a try, holding the answer up to tryWait for its verdict.
|
|
func tryRead(ctx context.Context, id string) error {
|
|
until := time.Now().Add(tryWait)
|
|
for {
|
|
var r checkerTryRead
|
|
if err := askChecker(ctx, "checker_try_read", map[string]any{"id": id}, &r); err != nil {
|
|
return printTry(tryAnswer{ID: id, State: "error", Said: "the checker's word on it cannot be read: " + err.Error()})
|
|
}
|
|
a := tryOfChecker(r)
|
|
if (a.State != "waiting" && a.State != "running") || !time.Now().Add(2*time.Second).Before(until) {
|
|
return printTry(a)
|
|
}
|
|
if err := sleepOrDone(ctx, 2*time.Second); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func printTry(a tryAnswer) error {
|
|
fmt.Fprintln(os.Stderr, a.Said)
|
|
for _, l := range a.Failed {
|
|
fmt.Fprintln(os.Stderr, " "+l)
|
|
}
|
|
out, err := json.MarshalIndent(a, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(string(out))
|
|
return nil
|
|
}
|