// Package run stores builds as notes, so build history clones and survives a move. Chapter 16. package run import ( "context" "encoding/json" "fmt" "sort" "strings" "time" "github.com/barerepo/server/internal/gitx" ) // Ref holds every run in one ref, so `git log --show-notes=runs` prints them beside commits. const Ref = "refs/notes/runs" // inlineLimit is 64kb, so one talkative build does not make every reader of the ref pay. const inlineLimit = 64 << 10 // Record is one run of one commit. type Record struct { Runner string `json:"runner"` Labels []string `json:"labels,omitempty"` // Name is the workflow job that produced this, so a matrix of builds is readable. Chapter 15A. Name string `json:"name,omitempty"` // Ref is what was built, because a commit reaches a branch and a proposal both. Ref string `json:"ref,omitempty"` Started int64 `json:"started"` Duration int `json:"duration"` Exit int `json:"exit"` // Output holds the log when it is small enough to sit in the note. Output string `json:"output,omitempty"` // Log names a blob holding the log when Output does not. Log string `json:"log,omitempty"` } // Failed reports a run worth looking at, because chapter 19.1 says green is not news. func (r Record) Failed() bool { return r.Exit != 0 } func (r Record) StartedAt() time.Time { return time.Unix(r.Started, 0) } // recordSep separates runs of the same commit, as in chapter 13. const recordSep = "--" const maxRetries = 20 // Append records a run against a commit. func Append(ctx context.Context, dir, sha string, rec Record, log string) error { if len(log) > inlineLimit { blob, err := gitx.RunStdin(ctx, dir, log, "hash-object", "-w", "--stdin") if err != nil { return err } rec.Log = strings.TrimSpace(blob) rec.Output = "" } else { rec.Output = log rec.Log = "" } body, err := json.Marshal(rec) if err != nil { return err } for attempt := 0; attempt < maxRetries; attempt++ { old, notes, blobOf, err := read(ctx, dir) if err != nil { return err } existing := notes[sha] if strings.TrimSpace(existing) != "" { existing = strings.TrimRight(existing, "\n") + "\n" + recordSep + "\n" } else { existing = "" } notes[sha] = existing + string(body) + "\n" // This one changed, so it is the only note that has to be written. delete(blobOf, sha) tree, err := writeTree(ctx, dir, notes, blobOf) if err != nil { return err } args := []string{"commit-tree", tree, "-m", "run on " + short(sha)} if old != "" { args = append(args, "-p", old) } commit, err := gitx.RunStdin(ctx, dir, "", args...) if err != nil { return err } if _, err := gitx.Run(ctx, dir, "update-ref", Ref, strings.TrimSpace(commit), old); err == nil { return nil } } return fmt.Errorf("could not record the run after %d tries", maxRetries) } // For returns every run of one commit, newest first. func For(ctx context.Context, dir, sha string) ([]Record, error) { out, err := gitx.Run(ctx, dir, "notes", "--ref="+Ref, "show", sha) if err != nil { return nil, nil } return parse(out), nil } // Recent returns the newest runs across the repository. func Recent(ctx context.Context, dir string, limit int) (map[string][]Record, error) { _, notes, _, err := read(ctx, dir) if err != nil { return nil, err } out := map[string][]Record{} for sha, body := range notes { if recs := parse(body); len(recs) > 0 { out[sha] = recs } } if limit <= 0 { return out, nil } return newest(out, limit), nil } // newest keeps the most recent runs across every commit, because a page shows rows and not commits. func newest(byCommit map[string][]Record, limit int) map[string][]Record { type row struct { sha string rec Record } all := make([]row, 0, len(byCommit)) for sha, recs := range byCommit { for _, rec := range recs { all = append(all, row{sha, rec}) } } if len(all) <= limit { return byCommit } sort.SliceStable(all, func(i, j int) bool { return all[i].rec.Started > all[j].rec.Started }) out := map[string][]Record{} for _, r := range all[:limit] { out[r.sha] = append(out[r.sha], r.rec) } return out } func parse(note string) []Record { var out []Record for _, part := range strings.Split(note, "\n"+recordSep+"\n") { part = strings.TrimSpace(part) if part == "" { continue } var r Record if err := json.Unmarshal([]byte(part), &r); err == nil { out = append(out, r) } } sort.SliceStable(out, func(i, j int) bool { return out[i].Started > out[j].Started }) return out } // Log returns a run's output, reading the blob when it is not inline. func Log(ctx context.Context, dir string, r Record) (string, error) { if r.Log == "" { return r.Output, nil } return gitx.Run(ctx, dir, "cat-file", "blob", r.Log) } // read gets every run in two processes, where a cat-file per note cost ten times that. func read(ctx context.Context, dir string) (commit string, notes, blobOf map[string]string, err error) { notes, blobOf = map[string]string{}, map[string]string{} head, err := gitx.Batch(ctx, dir, []string{Ref, Ref + "^{tree}"}) if err != nil { return "", nil, nil, err } if head[Ref] == nil || head[Ref+"^{tree}"] == nil { return "", notes, blobOf, nil } commit = head[Ref].SHA entries := gitx.TreeEntries(head[Ref+"^{tree}"].Body) specs := make([]string, 0, len(entries)) for _, sha := range entries { specs = append(specs, sha) } blobs, err := gitx.Batch(ctx, dir, specs) if err != nil { return "", nil, nil, err } for path, sha := range entries { if obj := blobs[sha]; obj != nil { notes[path] = obj.Body blobOf[path] = sha } } return commit, notes, blobOf, nil } func writeTree(ctx context.Context, dir string, notes, blobOf map[string]string) (string, error) { paths := make([]string, 0, len(notes)) for p := range notes { paths = append(paths, p) } sort.Strings(paths) var entries strings.Builder for _, path := range paths { if strings.TrimSpace(notes[path]) == "" { continue } // A note this call did not touch already has a blob, and its content is its name. blob, ok := blobOf[path] if !ok { out, err := gitx.RunStdin(ctx, dir, notes[path], "hash-object", "-w", "--stdin") if err != nil { return "", err } blob = strings.TrimSpace(out) } fmt.Fprintf(&entries, "100644 blob %s\t%s\n", blob, path) } out, err := gitx.RunStdin(ctx, dir, entries.String(), "mktree") if err != nil { return "", err } return strings.TrimSpace(out), nil } func short(sha string) string { if len(sha) > 7 { return sha[:7] } return sha }