247 lines
7.4 KiB
Go
247 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"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
|
|
}
|
|
|
|
// 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 "<seat>.<verb>": those of the seats it defines, those of the
|
|
// seats it claims, and its own tools as "<module>.<tool>".
|
|
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 "<seat>.<verb>" or "<module>.<tool>".
|
|
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 == "" {
|
|
if old, err = parentOf(ctx, s.Owner, s.Repo, s.New); 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
|
|
}
|
|
|
|
// changeSourcesOf are a group's members as changes to read: each head over what its pull request merges into.
|
|
func changeSourcesOf(members []orderMember) []changeSource {
|
|
var out []changeSource
|
|
for _, m := range members {
|
|
p := m.pull()
|
|
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
|
|
}
|