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}