render.go

  1// Package render prints run events for a terminal: `ci run` live and
  2// `ci view` afterwards render the same events.ndjson the same way.
  3package render
  4
  5import (
  6	"fmt"
  7	"io"
  8	"os"
  9	"strings"
 10	"sync"
 11	"time"
 12
 13	"git.kilimanjaro.io/ci/internal/lex"
 14)
 15
 16// Terminal renders events to w.
 17type Terminal struct {
 18	mu     sync.Mutex
 19	w      io.Writer
 20	color  bool
 21	starts map[int]time.Time
 22}
 23
 24// New returns a renderer for w, with colour if w is a terminal and NO_COLOR
 25// is unset.
 26func New(w io.Writer) *Terminal {
 27	color := false
 28	if f, ok := w.(*os.File); ok && os.Getenv("NO_COLOR") == "" {
 29		if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
 30			color = true
 31		}
 32	}
 33	return &Terminal{w: w, color: color, starts: map[int]time.Time{}}
 34}
 35
 36func (t *Terminal) paint(code, s string) string {
 37	if !t.color {
 38		return s
 39	}
 40	return "\x1b[" + code + "m" + s + "\x1b[0m"
 41}
 42
 43func (t *Terminal) statusColor(status string) string {
 44	switch status {
 45	case lex.Success:
 46		return "32"
 47	case lex.Warning, "paused":
 48		return "33"
 49	case lex.Failure, "cancelled":
 50		return "31"
 51	}
 52	return "0"
 53}
 54
 55// Event renders one event.
 56func (t *Terminal) Event(ev lex.RunEvent) {
 57	t.mu.Lock()
 58	defer t.mu.Unlock()
 59	ts, _ := time.Parse(time.RFC3339Nano, ev.Time)
 60	switch ev.Kind {
 61	case lex.KindRun:
 62		if ev.Status == "running" {
 63			fmt.Fprintf(t.w, "%s %s\n", t.paint("1", "ci"), ev.Content)
 64			return
 65		}
 66		line := fmt.Sprintf("%s %s", t.paint("1", "ci"), t.paint(t.statusColor(ev.Status), ev.Status))
 67		if ev.Content != "" {
 68			line += ": " + ev.Content
 69		}
 70		fmt.Fprintln(t.w, line)
 71	case lex.KindCheckout:
 72		fmt.Fprintf(t.w, "  checkout %s @ %s -> %s\n", ev.Repo, short(ev.Rev), ev.Path)
 73	case lex.KindStart:
 74		t.starts[ev.Attempt] = ts
 75		fmt.Fprintf(t.w, "%s %s  %s\n", t.paint("1;36", "▶"), t.paint("1", ev.Action), t.paint("2", firstLine(ev.Command)))
 76	case lex.KindLog:
 77		prefix := t.paint("2", "│ ")
 78		if ev.Stream == "stderr" {
 79			prefix = t.paint("33", "│ ")
 80		}
 81		fmt.Fprintln(t.w, prefix+ev.Content)
 82	case lex.KindEnd:
 83		mark := "✔"
 84		switch ev.Status {
 85		case lex.Failure:
 86			mark = "✘"
 87		case lex.Warning:
 88			mark = "!"
 89		}
 90		took := ""
 91		if s, ok := t.starts[ev.Attempt]; ok && !ts.IsZero() {
 92			took = "  " + ts.Sub(s).Round(100*time.Millisecond).String()
 93		}
 94		code := ""
 95		if ev.ExitCode != nil && *ev.ExitCode != 0 {
 96			code = fmt.Sprintf("  exit %d", *ev.ExitCode)
 97		}
 98		fmt.Fprintf(t.w, "%s %s  %s%s%s\n", t.paint(t.statusColor(ev.Status), mark), ev.Action, t.paint(t.statusColor(ev.Status), ev.Status), code, t.paint("2", took))
 99	case lex.KindResult:
100		if r := ev.Result; r != nil {
101			if r.Summary != "" {
102				for _, l := range strings.Split(strings.TrimRight(r.Summary, "\n"), "\n") {
103					fmt.Fprintln(t.w, "  "+l)
104				}
105			}
106			for _, a := range r.Attachments {
107				label := a.Label
108				if label == "" {
109					label = a.Path
110				}
111				fmt.Fprintf(t.w, "  attachment: %s (%s)\n", label, a.Path)
112			}
113		}
114	case lex.KindTransition:
115		if tr := ev.Transition; tr != nil && tr.To != "" && tr.Reason != lex.ReasonDefault && tr.Reason != lex.ReasonStart {
116			fmt.Fprintf(t.w, "%s %s (%s)\n", t.paint("2", "→"), tr.To, tr.Reason)
117		}
118	case lex.KindGate:
119		if g := ev.Gate; g != nil {
120			fmt.Fprintf(t.w, "%s gate %s", t.paint("1;33", "⏸"), g.State)
121			if g.Choice != "" {
122				fmt.Fprintf(t.w, ": %s", g.Choice)
123			}
124			if g.Actor != "" {
125				fmt.Fprintf(t.w, " by %s", g.Actor)
126			}
127			fmt.Fprintln(t.w)
128		}
129	}
130}
131
132func firstLine(s string) string {
133	s = strings.TrimSpace(s)
134	if i := strings.IndexByte(s, '\n'); i >= 0 {
135		return s[:i] + " …"
136	}
137	return s
138}
139
140func short(s string) string {
141	if len(s) > 12 {
142		return s[:12]
143	}
144	return s
145}