Skip to content
quarry Artifact [95360a8564]
Login

Artifact [95360a8564]

Artifact 95360a8564dc8292c2a0085a47bbfd775061d87fb7d370efdd1f59a412241ab9:


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
		},
	})
}