package api
import (
"context"
"fmt"
"strings"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/ops"
)
// One CI run, and its log, by id. The run page had both, found by
// scanning the newest two hundred runs; an agent had neither, and read
// a failed check's log off the box. A run is named by its id or any
// prefix that names it alone, as the forge's own links shorten it.
type ciRunIn struct {
Repo string `json:"repo" validate:"required,max=64"`
RunID string `json:"runId" validate:"required,min=4,max=128"`
}
type ciRunOut struct {
Run CIRun `json:"run"`
}
type ciRunLogIn struct {
Repo string `json:"repo" validate:"required,max=64"`
RunID string `json:"runId" validate:"required,min=4,max=128"`
Step string `json:"step" validate:"max=128"`
Tail int `json:"tail" validate:"min=0,max=100000"`
}
// ciLogStep is one try of one step: what it printed, and whether it
// failed. Every try is listed, a retried step's failing first try
// included, since that is the log a reader is usually after.
type ciLogStep struct {
Step string `json:"step"`
Attempt int `json:"attempt"`
Failed bool `json:"failed"`
Lines int `json:"lines"`
Text string `json:"text"`
}
type ciRunLogOut struct {
RunID string `json:"runId"`
Status string `json:"status"`
Steps []ciLogStep `json:"steps"`
// Held is false when the forge holds no log for the run: it ran
// before logs were kept, or on an engine without the log lane.
Held bool `json:"held"`
}
// findRun resolves a run id or prefix in a repository.
func (d Deps) findRun(ctx context.Context, repo, id string) (CIRun, error) {
if _, err := d.repoFor(ctx, repo); err != nil {
return CIRun{}, err
}
lookup := d.CIRunByPrefix
if lookup == nil {
lookup = d.recentRunByPrefix
}
run, ok, ambiguous, err := lookup(repo, strings.ToLower(id))
switch {
case err != nil:
return CIRun{}, err
case ambiguous:
return CIRun{}, fmt.Errorf("%q names more than one run; give more of the id: %w", id, ops.ErrInvalid)
case !ok:
return CIRun{}, fmt.Errorf("no run %q: %w", id, ops.ErrNotFound)
}
return run, nil
}
// recentRunByPrefix is the lookup without a run table to range over:
// the newest runs the record lists, as the run page once read them.
func (d Deps) recentRunByPrefix(repo, prefix string) (CIRun, bool, bool, error) {
if d.CI == nil {
return CIRun{}, false, false, nil
}
runs, err := d.CI.CIRunsPage(repo, 200, 0)
if err != nil {
return CIRun{}, false, false, err
}
var found []CIRun
for _, r := range runs {
if strings.HasPrefix(r.RunID, prefix) {
found = append(found, r)
}
}
switch len(found) {
case 0:
return CIRun{}, false, false, nil
case 1:
return found[0], true, false, nil
}
return CIRun{}, false, true, nil
}
// logSteps groups a run's chunks into tries of steps, in the order the
// chunks hold them, the last tail lines of each when tail is set.
func logSteps(chunks []ci.LogChunk, only string, tail int) []ciLogStep {
type try struct {
step string
attempt int
}
var order []try
texts := map[try]*strings.Builder{}
failed := map[try]bool{}
for _, c := range chunks {
if only != "" && c.Step != only {
continue
}
k := try{c.Step, max(1, c.Attempt)}
if texts[k] == nil {
texts[k] = &strings.Builder{}
order = append(order, k)
}
if c.Stream == "marker" {
failed[k] = failed[k] || c.Data == "failed"
continue
}
texts[k].WriteString(c.Data)
if !strings.HasSuffix(c.Data, "\n") {
texts[k].WriteByte('\n')
}
}
out := make([]ciLogStep, 0, len(order))
for _, k := range order {
text := texts[k].String()
lines := strings.Count(text, "\n")
if tail > 0 && lines > tail {
cut := len(text)
for range tail + 1 {
cut = strings.LastIndexByte(text[:cut], '\n')
}
text = text[cut+1:]
}
out = append(out, ciLogStep{Step: k.step, Attempt: k.attempt, Failed: failed[k], Lines: lines, Text: text})
}
return out
}
func registerCIRun(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[ciRunIn, ciRunOut]{
Name: "ci.run.get",
Caps: "8",
Doc: "One CI run, by its id or a prefix that names it alone, however old.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/ci/runs/{runId}",
Run: func(ctx context.Context, in ciRunIn) (ciRunOut, error) {
run, err := d.findRun(ctx, in.Repo, in.RunID)
if err != nil {
return ciRunOut{}, err
}
// The check-in's own story, as ci.list carries it.
if repo, rerr := d.repoFor(ctx, in.Repo); rerr == nil && repo.Index != nil {
if meta, merr := repo.Index.CheckinsMeta([]string{run.Checkin}); merr == nil {
if e, ok := meta[run.Checkin]; ok {
run.Branch, run.Comment, run.User = e.Branch, e.Comment, e.User
}
}
}
return ciRunOut{Run: run}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[ciRunLogIn, ciRunLogOut]{
Name: "ci.run.log",
Caps: "8",
Doc: "A CI run's log, one entry per try of each step with whether it failed. step names one step; " +
"tail keeps the last lines of each. A run still going answers with what it has printed so far.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/ci/runs/{runId}/log",
Run: func(ctx context.Context, in ciRunLogIn) (ciRunLogOut, error) {
run, err := d.findRun(ctx, in.Repo, in.RunID)
if err != nil {
return ciRunLogOut{}, err
}
out := ciRunLogOut{RunID: run.RunID, Status: run.Status, Steps: []ciLogStep{}}
if d.CILog == nil {
return out, nil
}
chunks, held, err := d.CILog(in.Repo, run.RunID)
if err != nil {
return ciRunLogOut{}, err
}
out.Held = held
out.Steps = logSteps(chunks, in.Step, in.Tail)
return out, nil
},
})
}