package main import ( "context" "encoding/base64" "encoding/json" "fmt" "net/url" "path" "slices" "strings" "sync" "time" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // What a change says of the manifests it touches, read from the forge (novox/hq ADR 0419 clause 3, the lead's settlement // of issue 977 question 1): the controller asks the forge for each changed module.json at the commit and at what it // replaced, once per commit. The composed check's result is not used: the plan is cut after the merge. // changeSource is one change to a repository whose manifests are read: the commit after it and, when known, what it // replaced (a pull request's base commit or branch). An empty Old is the first parent of New: the merge's base. type changeSource struct { Owner, Repo string New, Old string Paths []string Truncated bool Removed []string // Since is the first commit of the repository in the change, when the change is several merges: what it replaced // is the first parent of that one, so a verb an earlier merge added is added by the change too. Since string } // manifestAt reads a repository's module.json at a ref from the forge; the bool says it exists there. A variable so a // test needs no forge. var manifestAt = func(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) { var found struct { Result struct { Content string `json:"content"` Encoding string `json:"encoding"` } `json:"result"` } missing, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/contents/"+escapedPath(file)+"?ref="+url.QueryEscape(ref), &found) if err != nil { return catalogue.Manifest{}, false, err } if missing { return catalogue.Manifest{}, false, nil } body := []byte(found.Result.Content) if found.Result.Encoding == "base64" { if body, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(found.Result.Content, "\n", "")); err != nil { return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s cannot be read: %w", owner, repo, file, ref, err) } } var m catalogue.Manifest if err := json.Unmarshal(body, &m); err != nil { return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s is not a manifest: %w", owner, repo, file, ref, err) } return m, true, nil } // parentOf is the first parent of a commit, from the forge. A variable so a test needs no forge. var parentOf = func(ctx context.Context, owner, repo, commit string) (string, error) { var found struct { Result struct { Parents []struct { SHA string `json:"sha"` } `json:"parents"` } `json:"result"` } if _, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/git/commits/"+url.PathEscape(commit), &found); err != nil { return "", err } if len(found.Result.Parents) == 0 { return "", nil } return found.Result.Parents[0].SHA, nil } func escapedPath(p string) string { parts := strings.Split(p, "/") for i := range parts { parts[i] = url.PathEscape(parts[i]) } return strings.Join(parts, "/") } // askForgeApi makes one read of the forge's API through its module's tool on the bus. It answers true when the forge // says there is no such thing (404), and an error for anything else it cannot answer. func askForgeApi(ctx context.Context, apiPath string, into any) (bool, error) { js, err := aBus() if err != nil { return false, err } defer js.Close() raw, _ := json.Marshal(map[string]any{"path": apiPath}) answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second) if err != nil { return false, err } if answer.Error != "" { if strings.Contains(answer.Error, " 404 ") { return true, nil } return false, fmt.Errorf("the forge did not answer %s: %s", apiPath, answer.Error) } if err := json.Unmarshal(answer.Result, into); err != nil { return false, fmt.Errorf("the forge's answer to %s cannot be read: %w", apiPath, err) } return false, nil } // manifestsRead keeps what the forge said of a manifest at a commit, which never changes: one ask per commit. var ( manifestsRead = map[string]manifestRead{} manifestsReadMu sync.Mutex ) type manifestRead struct { manifest catalogue.Manifest exists bool } func manifestCached(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) { key := strings.Join([]string{owner, repo, file, ref}, "\x00") if len(ref) == 40 { manifestsReadMu.Lock() got, ok := manifestsRead[key] manifestsReadMu.Unlock() if ok { return got.manifest, got.exists, nil } } m, exists, err := manifestAt(ctx, owner, repo, file, ref) if err == nil && len(ref) == 40 { manifestsReadMu.Lock() if len(manifestsRead) > 512 { clear(manifestsRead) } manifestsRead[key] = manifestRead{m, exists} manifestsReadMu.Unlock() } return m, exists, err } // verbsServed are the seat verbs a manifest serves, as ".": those of the seats it defines, those of the // seats it claims, and its own tools as ".". func verbsServed(m catalogue.Manifest) []string { var out []string for _, s := range m.DefinesSeats { for _, v := range s.Serves { out = appendNew(out, s.Name+"."+v.Name) } } for _, c := range m.Claims { for _, v := range c.ServesFor(m) { out = appendNew(out, c.Name+"."+v) } } for _, t := range m.Tools { out = appendNew(out, m.Module+"."+t) } return out } // verbsInvoked are the verbs a manifest calls, as "." or ".". func verbsInvoked(m catalogue.Manifest) []string { var out []string for _, i := range m.Invokes { if i = strings.TrimPrefix(i, "seat:"); i != "*" && i != "" { out = appendNew(out, i) } } return out } func added(now, was []string) []string { var out []string for _, v := range now { if !slices.Contains(was, v) { out = append(out, v) } } return out } // changesOf reads, for each manifest the changes touch, what they add to the verbs its module serves and invokes, by // module. A change whose paths are unsaid or truncated names no manifest: nothing is read for it. An error is the // forge's, never read as "no change": the plan is not cut on a guess. func changesOf(ctx context.Context, sources []changeSource) (map[string]moduleChange, error) { out := map[string]moduleChange{} for _, s := range sources { if s.Truncated { continue } for _, p := range s.Paths { if path.Base(p) != "module.json" || slices.Contains(s.Removed, p) { continue } now, exists, err := manifestCached(ctx, s.Owner, s.Repo, p, s.New) if err != nil { return nil, err } if !exists || now.Module == "" { continue } old := s.Old if old == "" { first := s.Since if first == "" { first = s.New } if old, err = parentOf(ctx, s.Owner, s.Repo, first); err != nil { return nil, err } } var was catalogue.Manifest if old != "" { if was, _, err = manifestCached(ctx, s.Owner, s.Repo, p, old); err != nil { return nil, err } } c := out[now.Module] c.Manifest = true for _, v := range added(verbsServed(now), verbsServed(was)) { c.Serves = appendNew(c.Serves, v) } for _, v := range added(verbsInvoked(now), verbsInvoked(was)) { c.Invokes = appendNew(c.Invokes, v) } out[now.Module] = c } } return out, nil } // truncatedPaths are the manifests a change that names only a beginning of its files is read as changing: those of the // modules it moves that are built from its repository. Said, never skipped: no edge may be lost to a long list. func truncatedPaths(entries []inventory.Entry, owner, repo string, moved []string) []string { var out []string for _, e := range entries { if slices.Contains(moved, e.Manifest.Module) && sameRepository(e.Source.Repository, link.SourceMoved{Owner: owner, Repo: repo}) { out = append(out, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile)) } } fmt.Printf(" %s/%s changed more files than were listed: the manifests of its modules (%s) are read as changed\n", owner, repo, strings.Join(out, ", ")) return out } // changeSourcesOf are a group's members as changes to read: each head over what its pull request merges into. func changeSourcesOf(members []orderMember, entries []inventory.Entry, reach map[string]orderReach) []changeSource { var out []changeSource for _, m := range members { p := m.pull() if p.PathsTruncated { p.Paths, p.PathsTruncated = truncatedPaths(entries, p.Owner, p.Repo, reach[m.ID].Moved), false } old := p.BaseSHA if old == "" { old = p.Base } out = append(out, changeSource{Owner: p.Owner, Repo: p.Repo, New: p.Commit, Old: old, Paths: p.Paths, Truncated: p.PathsTruncated, Removed: p.Removed}) } return out }