// Package render prints run events for a terminal: `ci run` live and // `ci view` afterwards render the same events.ndjson the same way. package render import ( "fmt" "io" "os" "strings" "sync" "time" "git.kilimanjaro.io/ci/internal/lex" ) // Terminal renders events to w. type Terminal struct { mu sync.Mutex w io.Writer color bool starts map[int]time.Time } // New returns a renderer for w, with colour if w is a terminal and NO_COLOR // is unset. func New(w io.Writer) *Terminal { color := false if f, ok := w.(*os.File); ok && os.Getenv("NO_COLOR") == "" { if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 { color = true } } return &Terminal{w: w, color: color, starts: map[int]time.Time{}} } func (t *Terminal) paint(code, s string) string { if !t.color { return s } return "\x1b[" + code + "m" + s + "\x1b[0m" } func (t *Terminal) statusColor(status string) string { switch status { case lex.Success: return "32" case lex.Warning, "paused": return "33" case lex.Failure, "cancelled": return "31" } return "0" } // Event renders one event. func (t *Terminal) Event(ev lex.RunEvent) { t.mu.Lock() defer t.mu.Unlock() ts, _ := time.Parse(time.RFC3339Nano, ev.Time) switch ev.Kind { case lex.KindRun: if ev.Status == "running" { fmt.Fprintf(t.w, "%s %s\n", t.paint("1", "ci"), ev.Content) return } line := fmt.Sprintf("%s %s", t.paint("1", "ci"), t.paint(t.statusColor(ev.Status), ev.Status)) if ev.Content != "" { line += ": " + ev.Content } fmt.Fprintln(t.w, line) case lex.KindCheckout: fmt.Fprintf(t.w, " checkout %s @ %s -> %s\n", ev.Repo, short(ev.Rev), ev.Path) case lex.KindStart: t.starts[ev.Attempt] = ts fmt.Fprintf(t.w, "%s %s %s\n", t.paint("1;36", "▶"), t.paint("1", ev.Action), t.paint("2", firstLine(ev.Command))) case lex.KindLog: prefix := t.paint("2", "│ ") if ev.Stream == "stderr" { prefix = t.paint("33", "│ ") } fmt.Fprintln(t.w, prefix+ev.Content) case lex.KindEnd: mark := "✔" switch ev.Status { case lex.Failure: mark = "✘" case lex.Warning: mark = "!" } took := "" if s, ok := t.starts[ev.Attempt]; ok && !ts.IsZero() { took = " " + ts.Sub(s).Round(100*time.Millisecond).String() } code := "" if ev.ExitCode != nil && *ev.ExitCode != 0 { code = fmt.Sprintf(" exit %d", *ev.ExitCode) } 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)) case lex.KindResult: if r := ev.Result; r != nil { if r.Summary != "" { for _, l := range strings.Split(strings.TrimRight(r.Summary, "\n"), "\n") { fmt.Fprintln(t.w, " "+l) } } for _, a := range r.Attachments { label := a.Label if label == "" { label = a.Path } fmt.Fprintf(t.w, " attachment: %s (%s)\n", label, a.Path) } } case lex.KindTransition: if tr := ev.Transition; tr != nil && tr.To != "" && tr.Reason != lex.ReasonDefault && tr.Reason != lex.ReasonStart { fmt.Fprintf(t.w, "%s %s (%s)\n", t.paint("2", "→"), tr.To, tr.Reason) } case lex.KindGate: if g := ev.Gate; g != nil { fmt.Fprintf(t.w, "%s gate %s", t.paint("1;33", "⏸"), g.State) if g.Choice != "" { fmt.Fprintf(t.w, ": %s", g.Choice) } if g.Actor != "" { fmt.Fprintf(t.w, " by %s", g.Actor) } fmt.Fprintln(t.w) } } } func firstLine(s string) string { s = strings.TrimSpace(s) if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] + " …" } return s } func short(s string) string { if len(s) > 12 { return s[:12] } return s }