144 lines
4.9 KiB
Go
144 lines
4.9 KiB
Go
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 the forge says of a change's manifests (novox/hq ADR 0502): read through the forge's module on the bus, once per
|
|
// commit. Kept by release 6 for the pull request's declared config; the delivery plan's reads of it are gone.
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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
|
|
}
|