detached 081981c

refactor: split web/actions.go and move the runner commands (pure move)

actions.go becomes actions_pages.go (pages and the owner's actions),
actions_log.go (the log lines, the raw log and the live feed) and checks.go
(the check marks and the checks section); cli_run.go keeps the run commands and
cli_runner.go gets the runner commands. No line changes besides the imports.
commit
081981c22cca26384987c332aeb2f9b7e2b8f55d
author
brice · · 2026-10-12 19:24 UTC
parent
f1b7a40
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +519 -481
A internal/adapters/web/actions_log.go +266 -0
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// LogLineView is a line of a job log, as shown.
21+type LogLineView struct {
22+ N int64
23+ Step int32
24+ Text string
25+ Time string
26+ // Group marks "##[group]" and "##[endgroup]" lines and Error the
27+ // "##[error]" ones; the text is shown without the marker.
28+ Group, End, Error bool
29+ // Continued marks a piece of a line whose beginning is in an earlier batch
30+ // of the live feed: the page appends its text to the line before it.
31+ Continued bool
32+}
33+
34+// JobPage is one job with its steps and its log.
35+type JobPage struct {
36+ JobRow
37+ RunHref string
38+ RunName string
39+ Run string
40+ Steps []StepView
41+ Lines []LogLineView
42+ // From is the first line shown; Next is the link to the lines after the
43+ // last shown, "" when it is the end; Follow says the log is still being
44+ // written, so the page follows it live.
45+ From int64
46+ Next string
47+ Prev string
48+ Total int64
49+ Follow bool
50+ RawHref string
51+ // StreamHref is the live feed of the lines (logStream).
52+ StreamHref string
53+ Note string
54+}
55+
56+// appendLogLine adds the line n of a job log to the lines shown. The markers
57+// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
58+// are not shown as text. A piece that continues a line too long to send whole
59+// is joined to the line before it; one that opens the list (its line is in an
60+// earlier batch) keeps Continued, and the page appends it to that line.
61+func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
62+ text := l.Text
63+ v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
64+ switch {
65+ case strings.HasPrefix(text, "##[group]"):
66+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
67+ case strings.HasPrefix(text, "##[endgroup]"):
68+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
69+ case strings.HasPrefix(text, "##[error]"):
70+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
71+ }
72+ v.Text = visibleText(text)
73+ if l.Continued && len(out) > 0 {
74+ out[len(out)-1].Text += v.Text
75+ return out
76+ }
77+ return append(out, v)
78+}
79+
80+// logClient is the key of the visitor for the limit of log streams per client.
81+func (s *Server) logClient(r *http.Request) string {
82+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
83+}
84+
85+// readLog reads up to max lines from a line number, through the log stream of
86+// the use case, and returns the line after the last read.
87+func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
88+ if from > j.Lines || j.Lines == 0 {
89+ return nil, from, nil
90+ }
91+ var out []LogLineView
92+ next := from
93+ stop := errors.New("enough")
94+ err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
95+ for i, l := range b.Lines {
96+ if len(out) >= max && !l.Continued {
97+ return stop // the rest of a cut line still belongs to this page
98+ }
99+ out = appendLogLine(out, b.Offset+int64(i), l)
100+ next = b.Offset + int64(i) + 1
101+ }
102+ return nil
103+ })
104+ if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
105+ return nil, from, err
106+ }
107+ return out, next, nil
108+}
109+
110+// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
111+// one line per line with its time, joined where the runner cut a long line.
112+func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
113+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
114+ defer cancel()
115+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
116+ if err != nil {
117+ s.fail(ctx, w, r, err)
118+ return
119+ }
120+ id, err := parseRunPath(r)
121+ jobID := r.PathValue("job")
122+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
123+ s.fail(ctx, w, r, errNotFound)
124+ return
125+ }
126+ var attempt int32
127+ if a := r.URL.Query().Get("attempt"); a != "" {
128+ n, perr := strconv.ParseInt(a, 10, 32)
129+ if perr != nil || n < 1 {
130+ s.fail(ctx, w, r, errNotFound)
131+ return
132+ }
133+ attempt = int32(n)
134+ }
135+ // The job is read first: a job that is not there, or not readable, is a 404
136+ // before any header of the log is sent.
137+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
138+ s.fail(ctx, w, r, err)
139+ return
140+ }
141+ h := w.Header()
142+ h.Set("Content-Type", "text/plain; charset=utf-8")
143+ h.Set("X-Content-Type-Options", "nosniff")
144+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
145+ h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
146+ cacheHeaders(ctx, h, "no-cache", sessionCache)
147+ written := 0
148+ open := false
149+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt, Client: s.logClient(r)}, func(b ports.LogBatch) error {
150+ for _, l := range b.Lines {
151+ if written > rawLogMaxBytes {
152+ return errors.New("log too large")
153+ }
154+ var line string
155+ switch {
156+ case l.Continued && open:
157+ line = l.Text
158+ case open:
159+ line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
160+ default:
161+ line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
162+ }
163+ open = true
164+ n, werr := fmt.Fprint(w, line)
165+ written += n
166+ if werr != nil {
167+ return werr
168+ }
169+ }
170+ return nil
171+ })
172+ if open {
173+ _, _ = fmt.Fprintln(w)
174+ }
175+ if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
176+ w.WriteHeader(http.StatusNotFound)
177+ }
178+}
179+
180+// logClass is the class of a line of the log.
181+func logClass(ln LogLineView) string {
182+ switch {
183+ case ln.Group:
184+ return "lg group"
185+ case ln.End:
186+ return "lg endgroup"
187+ case ln.Error:
188+ return "lg error"
189+ }
190+ return "lg"
191+}
192+
193+// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
194+// lines of a job log from line N as Server-Sent Events, following the job while
195+// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
196+// the number of the next line as its id, so that a reconnection (Last-Event-ID)
197+// goes on where it stopped. "ci-end" tells the job is over and every line was
198+// sent. The job events of the repository stream carry no line (see events.go).
199+func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
200+ ctx, cancel := context.WithCancel(r.Context())
201+ defer cancel()
202+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
203+ if err != nil {
204+ s.denyEvents(ctx, w, err)
205+ return
206+ }
207+ id, err := parseRunPath(r)
208+ jobID := r.PathValue("job")
209+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
210+ s.denyEvents(ctx, w, errNotFound)
211+ return
212+ }
213+ from := int64(0)
214+ if v := r.Header.Get("Last-Event-ID"); v != "" {
215+ from, err = strconv.ParseInt(v, 10, 64)
216+ } else if v := r.URL.Query().Get("from"); v != "" {
217+ from, err = strconv.ParseInt(v, 10, 64)
218+ }
219+ if err != nil || from < 0 {
220+ s.denyEvents(ctx, w, errNotFound)
221+ return
222+ }
223+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
224+ s.denyEvents(ctx, w, err)
225+ return
226+ }
227+
228+ rc := http.NewResponseController(w)
229+ hdr := w.Header()
230+ hdr.Set("Content-Type", "text/event-stream")
231+ cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
232+ hdr.Set("X-Accel-Buffering", "no")
233+ w.WriteHeader(http.StatusOK)
234+ out := &sseWriter{w: w, rc: rc}
235+ if out.raw(": connected\n\n") != nil {
236+ return
237+ }
238+ n := from
239+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
240+ JobID: jobID, Offset: from, Follow: true,
241+ Client: s.logClient(r),
242+ }, func(b ports.LogBatch) error {
243+ var views []LogLineView
244+ for i, l := range b.Lines {
245+ views = appendLogLine(views, b.Offset+int64(i), l)
246+ }
247+ var html strings.Builder
248+ for _, v := range views {
249+ var b2 strings.Builder
250+ if err := LogLine(v).Render(ctx, &b2); err != nil {
251+ return err
252+ }
253+ html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
254+ }
255+ n = b.Offset + int64(len(b.Lines))
256+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
257+ _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
258+ if werr != nil {
259+ return werr
260+ }
261+ return rc.Flush()
262+ })
263+ if err == nil || errors.Is(err, domain.ErrNotFound) {
264+ _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
265+ }
266+}
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// LogLineView is a line of a job log, as shown.
21+type LogLineView struct {
22+ N int64
23+ Step int32
24+ Text string
25+ Time string
26+ // Group marks "##[group]" and "##[endgroup]" lines and Error the
27+ // "##[error]" ones; the text is shown without the marker.
28+ Group, End, Error bool
29+ // Continued marks a piece of a line whose beginning is in an earlier batch
30+ // of the live feed: the page appends its text to the line before it.
31+ Continued bool
32+}
33+
34+// JobPage is one job with its steps and its log.
35+type JobPage struct {
36+ JobRow
37+ RunHref string
38+ RunName string
39+ Run string
40+ Steps []StepView
41+ Lines []LogLineView
42+ // From is the first line shown; Next is the link to the lines after the
43+ // last shown, "" when it is the end; Follow says the log is still being
44+ // written, so the page follows it live.
45+ From int64
46+ Next string
47+ Prev string
48+ Total int64
49+ Follow bool
50+ RawHref string
51+ // StreamHref is the live feed of the lines (logStream).
52+ StreamHref string
53+ Note string
54+}
55+
56+// appendLogLine adds the line n of a job log to the lines shown. The markers
57+// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
58+// are not shown as text. A piece that continues a line too long to send whole
59+// is joined to the line before it; one that opens the list (its line is in an
60+// earlier batch) keeps Continued, and the page appends it to that line.
61+func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
62+ text := l.Text
63+ v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
64+ switch {
65+ case strings.HasPrefix(text, "##[group]"):
66+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
67+ case strings.HasPrefix(text, "##[endgroup]"):
68+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
69+ case strings.HasPrefix(text, "##[error]"):
70+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
71+ }
72+ v.Text = visibleText(text)
73+ if l.Continued && len(out) > 0 {
74+ out[len(out)-1].Text += v.Text
75+ return out
76+ }
77+ return append(out, v)
78+}
79+
80+// logClient is the key of the visitor for the limit of log streams per client.
81+func (s *Server) logClient(r *http.Request) string {
82+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
83+}
84+
85+// readLog reads up to max lines from a line number, through the log stream of
86+// the use case, and returns the line after the last read.
87+func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
88+ if from > j.Lines || j.Lines == 0 {
89+ return nil, from, nil
90+ }
91+ var out []LogLineView
92+ next := from
93+ stop := errors.New("enough")
94+ err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
95+ for i, l := range b.Lines {
96+ if len(out) >= max && !l.Continued {
97+ return stop // the rest of a cut line still belongs to this page
98+ }
99+ out = appendLogLine(out, b.Offset+int64(i), l)
100+ next = b.Offset + int64(i) + 1
101+ }
102+ return nil
103+ })
104+ if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
105+ return nil, from, err
106+ }
107+ return out, next, nil
108+}
109+
110+// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
111+// one line per line with its time, joined where the runner cut a long line.
112+func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
113+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
114+ defer cancel()
115+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
116+ if err != nil {
117+ s.fail(ctx, w, r, err)
118+ return
119+ }
120+ id, err := parseRunPath(r)
121+ jobID := r.PathValue("job")
122+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
123+ s.fail(ctx, w, r, errNotFound)
124+ return
125+ }
126+ var attempt int32
127+ if a := r.URL.Query().Get("attempt"); a != "" {
128+ n, perr := strconv.ParseInt(a, 10, 32)
129+ if perr != nil || n < 1 {
130+ s.fail(ctx, w, r, errNotFound)
131+ return
132+ }
133+ attempt = int32(n)
134+ }
135+ // The job is read first: a job that is not there, or not readable, is a 404
136+ // before any header of the log is sent.
137+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
138+ s.fail(ctx, w, r, err)
139+ return
140+ }
141+ h := w.Header()
142+ h.Set("Content-Type", "text/plain; charset=utf-8")
143+ h.Set("X-Content-Type-Options", "nosniff")
144+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
145+ h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
146+ cacheHeaders(ctx, h, "no-cache", sessionCache)
147+ written := 0
148+ open := false
149+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt, Client: s.logClient(r)}, func(b ports.LogBatch) error {
150+ for _, l := range b.Lines {
151+ if written > rawLogMaxBytes {
152+ return errors.New("log too large")
153+ }
154+ var line string
155+ switch {
156+ case l.Continued && open:
157+ line = l.Text
158+ case open:
159+ line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
160+ default:
161+ line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
162+ }
163+ open = true
164+ n, werr := fmt.Fprint(w, line)
165+ written += n
166+ if werr != nil {
167+ return werr
168+ }
169+ }
170+ return nil
171+ })
172+ if open {
173+ _, _ = fmt.Fprintln(w)
174+ }
175+ if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
176+ w.WriteHeader(http.StatusNotFound)
177+ }
178+}
179+
180+// logClass is the class of a line of the log.
181+func logClass(ln LogLineView) string {
182+ switch {
183+ case ln.Group:
184+ return "lg group"
185+ case ln.End:
186+ return "lg endgroup"
187+ case ln.Error:
188+ return "lg error"
189+ }
190+ return "lg"
191+}
192+
193+// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
194+// lines of a job log from line N as Server-Sent Events, following the job while
195+// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
196+// the number of the next line as its id, so that a reconnection (Last-Event-ID)
197+// goes on where it stopped. "ci-end" tells the job is over and every line was
198+// sent. The job events of the repository stream carry no line (see events.go).
199+func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
200+ ctx, cancel := context.WithCancel(r.Context())
201+ defer cancel()
202+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
203+ if err != nil {
204+ s.denyEvents(ctx, w, err)
205+ return
206+ }
207+ id, err := parseRunPath(r)
208+ jobID := r.PathValue("job")
209+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
210+ s.denyEvents(ctx, w, errNotFound)
211+ return
212+ }
213+ from := int64(0)
214+ if v := r.Header.Get("Last-Event-ID"); v != "" {
215+ from, err = strconv.ParseInt(v, 10, 64)
216+ } else if v := r.URL.Query().Get("from"); v != "" {
217+ from, err = strconv.ParseInt(v, 10, 64)
218+ }
219+ if err != nil || from < 0 {
220+ s.denyEvents(ctx, w, errNotFound)
221+ return
222+ }
223+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
224+ s.denyEvents(ctx, w, err)
225+ return
226+ }
227+
228+ rc := http.NewResponseController(w)
229+ hdr := w.Header()
230+ hdr.Set("Content-Type", "text/event-stream")
231+ cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
232+ hdr.Set("X-Accel-Buffering", "no")
233+ w.WriteHeader(http.StatusOK)
234+ out := &sseWriter{w: w, rc: rc}
235+ if out.raw(": connected\n\n") != nil {
236+ return
237+ }
238+ n := from
239+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
240+ JobID: jobID, Offset: from, Follow: true,
241+ Client: s.logClient(r),
242+ }, func(b ports.LogBatch) error {
243+ var views []LogLineView
244+ for i, l := range b.Lines {
245+ views = appendLogLine(views, b.Offset+int64(i), l)
246+ }
247+ var html strings.Builder
248+ for _, v := range views {
249+ var b2 strings.Builder
250+ if err := LogLine(v).Render(ctx, &b2); err != nil {
251+ return err
252+ }
253+ html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
254+ }
255+ n = b.Offset + int64(len(b.Lines))
256+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
257+ _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
258+ if werr != nil {
259+ return werr
260+ }
261+ return rc.Flush()
262+ })
263+ if err == nil || errors.Is(err, domain.ErrNotFound) {
264+ _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
265+ }
266+}
R internal/adapters/web/actions.go → internal/adapters/web/actions_pages.go +0 -348 renamed, 56% similar
@@ -4,18 +4,14 @@ package web
44
55 import (
66 "context"
7- "errors"
87 "fmt"
9- "io"
108 "net/http"
119 "strconv"
1210 "strings"
1311 "time"
1412
1513 "github.com/a-h/templ"
16- "github.com/bnema/zerowrap"
1714
18- "bnema.dev/ermite/internal/clientlimit"
1915 "bnema.dev/ermite/internal/domain"
2016 "bnema.dev/ermite/internal/ports"
2117 )
@@ -189,44 +185,6 @@ type AttemptLink struct {
189185 Current bool
190186 }
191187
192-// LogLineView is a line of a job log, as shown.
193-type LogLineView struct {
194- N int64
195- Step int32
196- Text string
197- Time string
198- // Group marks "##[group]" and "##[endgroup]" lines and Error the
199- // "##[error]" ones; the text is shown without the marker.
200- Group, End, Error bool
201- // Continued marks a piece of a line whose beginning is in an earlier batch
202- // of the live feed: the page appends its text to the line before it.
203- Continued bool
204-}
205-
206-// JobPage is one job with its steps and its log.
207-type JobPage struct {
208- JobRow
209- RunHref string
210- RunName string
211- Run string
212- Steps []StepView
213- Lines []LogLineView
214- // From is the first line shown; Next is the link to the lines after the
215- // last shown, "" when it is the end; Follow says the log is still being
216- // written, so the page follows it live.
217- From int64
218- Next string
219- Prev string
220- Total int64
221- Follow bool
222- RawHref string
223- // StreamHref is the live feed of the lines (logStream).
224- StreamHref string
225- Note string
226-}
227-
228-// --- handlers ---
229-
230188 // actions is GET /<repo>/actions.
231189 func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
232190 d, err := s.loadRepo(ctx, r.PathValue("repo"))
@@ -455,132 +413,6 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int
455413 return Job(l, p), http.StatusOK, nil
456414 }
457415
458-// appendLogLine adds the line n of a job log to the lines shown. The markers
459-// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
460-// are not shown as text. A piece that continues a line too long to send whole
461-// is joined to the line before it; one that opens the list (its line is in an
462-// earlier batch) keeps Continued, and the page appends it to that line.
463-func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
464- text := l.Text
465- v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
466- switch {
467- case strings.HasPrefix(text, "##[group]"):
468- v.Group, text = true, strings.TrimPrefix(text, "##[group]")
469- case strings.HasPrefix(text, "##[endgroup]"):
470- v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
471- case strings.HasPrefix(text, "##[error]"):
472- v.Error, text = true, strings.TrimPrefix(text, "##[error]")
473- }
474- v.Text = visibleText(text)
475- if l.Continued && len(out) > 0 {
476- out[len(out)-1].Text += v.Text
477- return out
478- }
479- return append(out, v)
480-}
481-
482-// logClient is the key of the visitor for the limit of log streams per client.
483-func (s *Server) logClient(r *http.Request) string {
484- return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
485-}
486-
487-// readLog reads up to max lines from a line number, through the log stream of
488-// the use case, and returns the line after the last read.
489-func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
490- if from > j.Lines || j.Lines == 0 {
491- return nil, from, nil
492- }
493- var out []LogLineView
494- next := from
495- stop := errors.New("enough")
496- err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
497- for i, l := range b.Lines {
498- if len(out) >= max && !l.Continued {
499- return stop // the rest of a cut line still belongs to this page
500- }
501- out = appendLogLine(out, b.Offset+int64(i), l)
502- next = b.Offset + int64(i) + 1
503- }
504- return nil
505- })
506- if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
507- return nil, from, err
508- }
509- return out, next, nil
510-}
511-
512-// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
513-// one line per line with its time, joined where the runner cut a long line.
514-func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
515- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
516- defer cancel()
517- d, err := s.loadRepo(ctx, r.PathValue("repo"))
518- if err != nil {
519- s.fail(ctx, w, r, err)
520- return
521- }
522- id, err := parseRunPath(r)
523- jobID := r.PathValue("job")
524- if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
525- s.fail(ctx, w, r, errNotFound)
526- return
527- }
528- var attempt int32
529- if a := r.URL.Query().Get("attempt"); a != "" {
530- n, perr := strconv.ParseInt(a, 10, 32)
531- if perr != nil || n < 1 {
532- s.fail(ctx, w, r, errNotFound)
533- return
534- }
535- attempt = int32(n)
536- }
537- // The job is read first: a job that is not there, or not readable, is a 404
538- // before any header of the log is sent.
539- if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
540- s.fail(ctx, w, r, err)
541- return
542- }
543- h := w.Header()
544- h.Set("Content-Type", "text/plain; charset=utf-8")
545- h.Set("X-Content-Type-Options", "nosniff")
546- h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
547- h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
548- cacheHeaders(ctx, h, "no-cache", sessionCache)
549- written := 0
550- open := false
551- err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt, Client: s.logClient(r)}, func(b ports.LogBatch) error {
552- for _, l := range b.Lines {
553- if written > rawLogMaxBytes {
554- return errors.New("log too large")
555- }
556- var line string
557- switch {
558- case l.Continued && open:
559- line = l.Text
560- case open:
561- line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
562- default:
563- line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
564- }
565- open = true
566- n, werr := fmt.Fprint(w, line)
567- written += n
568- if werr != nil {
569- return werr
570- }
571- }
572- return nil
573- })
574- if open {
575- _, _ = fmt.Fprintln(w)
576- }
577- if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
578- w.WriteHeader(http.StatusNotFound)
579- }
580-}
581-
582-// --- the owner's actions ---
583-
584416 // ciAction wraps a POST of the actions pages: the form gate, the bounded
585417 // context and the mapping of a refusal to a page.
586418 func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
@@ -634,183 +466,3 @@ func runnerText(j JobRow) string {
634466 }
635467
636468 func joinWords(ws []string) string { return strings.Join(ws, ", ") }
637-
638-// logClass is the class of a line of the log.
639-func logClass(ln LogLineView) string {
640- switch {
641- case ln.Group:
642- return "lg group"
643- case ln.End:
644- return "lg endgroup"
645- case ln.Error:
646- return "lg error"
647- }
648- return "lg"
649-}
650-
651-// ChecksSummary is the combined state of the checks of a commit.
652-type ChecksSummary struct {
653- Total int
654- Kind, State string
655- Label, Title string
656- Href string
657-}
658-
659-// CheckRow is a check of a commit or a merge request.
660-type CheckRow struct {
661- Name, Kind, State, Href string
662- Waiting bool
663-}
664-
665-// ChecksSection is the checks of the head of a merge request.
666-type ChecksSection struct {
667- Rows []CheckRow
668- // Note explains the rule that applies to them.
669- Note string
670-}
671-
672-// checksFor reads the checks of a commit as the caller of the request. A page
673-// shows them when it can: a failure of the read is logged and the page goes on
674-// without, since the code and the review do not depend on CI.
675-func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView {
676- if s.cfg.Runs == nil || sha == "" {
677- return nil
678- }
679- res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha)
680- if err != nil {
681- if !errors.Is(err, domain.ErrNotFound) {
682- log := zerowrap.FromCtx(ctx)
683- log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit")
684- }
685- return nil
686- }
687- return res.Checks
688-}
689-
690-// checksSummary reduces checks to the mark a list shows next to a commit.
691-func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
692- if len(checks) == 0 {
693- return ChecksSummary{}
694- }
695- plain := make([]domain.CICheck, 0, len(checks))
696- passed := 0
697- for _, c := range checks {
698- plain = append(plain, c.CICheck)
699- if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() {
700- passed++
701- }
702- }
703- sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))}
704- switch domain.CombineChecks(plain) {
705- case domain.CheckStateSuccess:
706- sum.Kind, sum.State = "ok", "checks passed"
707- case domain.CheckStateFailure:
708- sum.Kind, sum.State = "bad", "checks failed"
709- default:
710- sum.Kind, sum.State = "running", "checks pending"
711- }
712- sum.Title = sum.State + ": " + sum.Label
713- return sum
714-}
715-
716-// checksSection is the checks of the rail of a merge request, with the rule of
717-// the repository that applies to them.
718-func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
719- sec := ChecksSection{}
720- for _, c := range checks {
721- row := CheckRow{
722- Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
723- Waiting: c.WaitingForRunner,
724- }
725- if c.JobID != "" {
726- row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
727- } else {
728- row.Href = runHref(repo.Name.String(), c.RunID)
729- }
730- sec.Rows = append(sec.Rows, row)
731- }
732- switch {
733- case len(checks) == 0:
734- sec.Note = "no check on this commit: nothing blocks the merge."
735- case repo.Settings.FailingChecksBlock:
736- sec.Note = "a failing or unfinished check blocks the merge."
737- default:
738- sec.Note = "checks do not block the merge in this repository."
739- }
740- return sec
741-}
742-
743-// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
744-// lines of a job log from line N as Server-Sent Events, following the job while
745-// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
746-// the number of the next line as its id, so that a reconnection (Last-Event-ID)
747-// goes on where it stopped. "ci-end" tells the job is over and every line was
748-// sent. The job events of the repository stream carry no line (see events.go).
749-func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
750- ctx, cancel := context.WithCancel(r.Context())
751- defer cancel()
752- d, err := s.loadRepo(ctx, r.PathValue("repo"))
753- if err != nil {
754- s.denyEvents(ctx, w, err)
755- return
756- }
757- id, err := parseRunPath(r)
758- jobID := r.PathValue("job")
759- if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
760- s.denyEvents(ctx, w, errNotFound)
761- return
762- }
763- from := int64(0)
764- if v := r.Header.Get("Last-Event-ID"); v != "" {
765- from, err = strconv.ParseInt(v, 10, 64)
766- } else if v := r.URL.Query().Get("from"); v != "" {
767- from, err = strconv.ParseInt(v, 10, 64)
768- }
769- if err != nil || from < 0 {
770- s.denyEvents(ctx, w, errNotFound)
771- return
772- }
773- if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
774- s.denyEvents(ctx, w, err)
775- return
776- }
777-
778- rc := http.NewResponseController(w)
779- hdr := w.Header()
780- hdr.Set("Content-Type", "text/event-stream")
781- cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
782- hdr.Set("X-Accel-Buffering", "no")
783- w.WriteHeader(http.StatusOK)
784- out := &sseWriter{w: w, rc: rc}
785- if out.raw(": connected\n\n") != nil {
786- return
787- }
788- n := from
789- err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
790- JobID: jobID, Offset: from, Follow: true,
791- Client: s.logClient(r),
792- }, func(b ports.LogBatch) error {
793- var views []LogLineView
794- for i, l := range b.Lines {
795- views = appendLogLine(views, b.Offset+int64(i), l)
796- }
797- var html strings.Builder
798- for _, v := range views {
799- var b2 strings.Builder
800- if err := LogLine(v).Render(ctx, &b2); err != nil {
801- return err
802- }
803- html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
804- }
805- n = b.Offset + int64(len(b.Lines))
806- _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
807- _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
808- if werr != nil {
809- return werr
810- }
811- return rc.Flush()
812- })
813- if err == nil || errors.Is(err, domain.ErrNotFound) {
814- _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
815- }
816-}
@@ -4,18 +4,14 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7- "errors"
8 "fmt"7 "fmt"
9- "io"
10 "net/http"8 "net/http"
11 "strconv"9 "strconv"
12 "strings"10 "strings"
13 "time"11 "time"
14 12
15 "github.com/a-h/templ"13 "github.com/a-h/templ"
16- "github.com/bnema/zerowrap"
17 14
18- "bnema.dev/ermite/internal/clientlimit"
19 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"16 "bnema.dev/ermite/internal/ports"
21 )17 )
@@ -189,44 +185,6 @@ type AttemptLink struct {
189 Current bool185 Current bool
190 }186 }
191 187
192-// LogLineView is a line of a job log, as shown.
193-type LogLineView struct {
194- N int64
195- Step int32
196- Text string
197- Time string
198- // Group marks "##[group]" and "##[endgroup]" lines and Error the
199- // "##[error]" ones; the text is shown without the marker.
200- Group, End, Error bool
201- // Continued marks a piece of a line whose beginning is in an earlier batch
202- // of the live feed: the page appends its text to the line before it.
203- Continued bool
204-}
205-
206-// JobPage is one job with its steps and its log.
207-type JobPage struct {
208- JobRow
209- RunHref string
210- RunName string
211- Run string
212- Steps []StepView
213- Lines []LogLineView
214- // From is the first line shown; Next is the link to the lines after the
215- // last shown, "" when it is the end; Follow says the log is still being
216- // written, so the page follows it live.
217- From int64
218- Next string
219- Prev string
220- Total int64
221- Follow bool
222- RawHref string
223- // StreamHref is the live feed of the lines (logStream).
224- StreamHref string
225- Note string
226-}
227-
228-// --- handlers ---
229-
230 // actions is GET /<repo>/actions.188 // actions is GET /<repo>/actions.
231 func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {189 func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
232 d, err := s.loadRepo(ctx, r.PathValue("repo"))190 d, err := s.loadRepo(ctx, r.PathValue("repo"))
@@ -455,132 +413,6 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int
455 return Job(l, p), http.StatusOK, nil413 return Job(l, p), http.StatusOK, nil
456 }414 }
457 415
458-// appendLogLine adds the line n of a job log to the lines shown. The markers
459-// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
460-// are not shown as text. A piece that continues a line too long to send whole
461-// is joined to the line before it; one that opens the list (its line is in an
462-// earlier batch) keeps Continued, and the page appends it to that line.
463-func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
464- text := l.Text
465- v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
466- switch {
467- case strings.HasPrefix(text, "##[group]"):
468- v.Group, text = true, strings.TrimPrefix(text, "##[group]")
469- case strings.HasPrefix(text, "##[endgroup]"):
470- v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
471- case strings.HasPrefix(text, "##[error]"):
472- v.Error, text = true, strings.TrimPrefix(text, "##[error]")
473- }
474- v.Text = visibleText(text)
475- if l.Continued && len(out) > 0 {
476- out[len(out)-1].Text += v.Text
477- return out
478- }
479- return append(out, v)
480-}
481-
482-// logClient is the key of the visitor for the limit of log streams per client.
483-func (s *Server) logClient(r *http.Request) string {
484- return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
485-}
486-
487-// readLog reads up to max lines from a line number, through the log stream of
488-// the use case, and returns the line after the last read.
489-func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
490- if from > j.Lines || j.Lines == 0 {
491- return nil, from, nil
492- }
493- var out []LogLineView
494- next := from
495- stop := errors.New("enough")
496- err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
497- for i, l := range b.Lines {
498- if len(out) >= max && !l.Continued {
499- return stop // the rest of a cut line still belongs to this page
500- }
501- out = appendLogLine(out, b.Offset+int64(i), l)
502- next = b.Offset + int64(i) + 1
503- }
504- return nil
505- })
506- if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
507- return nil, from, err
508- }
509- return out, next, nil
510-}
511-
512-// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
513-// one line per line with its time, joined where the runner cut a long line.
514-func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
515- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
516- defer cancel()
517- d, err := s.loadRepo(ctx, r.PathValue("repo"))
518- if err != nil {
519- s.fail(ctx, w, r, err)
520- return
521- }
522- id, err := parseRunPath(r)
523- jobID := r.PathValue("job")
524- if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
525- s.fail(ctx, w, r, errNotFound)
526- return
527- }
528- var attempt int32
529- if a := r.URL.Query().Get("attempt"); a != "" {
530- n, perr := strconv.ParseInt(a, 10, 32)
531- if perr != nil || n < 1 {
532- s.fail(ctx, w, r, errNotFound)
533- return
534- }
535- attempt = int32(n)
536- }
537- // The job is read first: a job that is not there, or not readable, is a 404
538- // before any header of the log is sent.
539- if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
540- s.fail(ctx, w, r, err)
541- return
542- }
543- h := w.Header()
544- h.Set("Content-Type", "text/plain; charset=utf-8")
545- h.Set("X-Content-Type-Options", "nosniff")
546- h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
547- h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
548- cacheHeaders(ctx, h, "no-cache", sessionCache)
549- written := 0
550- open := false
551- err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt, Client: s.logClient(r)}, func(b ports.LogBatch) error {
552- for _, l := range b.Lines {
553- if written > rawLogMaxBytes {
554- return errors.New("log too large")
555- }
556- var line string
557- switch {
558- case l.Continued && open:
559- line = l.Text
560- case open:
561- line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
562- default:
563- line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
564- }
565- open = true
566- n, werr := fmt.Fprint(w, line)
567- written += n
568- if werr != nil {
569- return werr
570- }
571- }
572- return nil
573- })
574- if open {
575- _, _ = fmt.Fprintln(w)
576- }
577- if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
578- w.WriteHeader(http.StatusNotFound)
579- }
580-}
581-
582-// --- the owner's actions ---
583-
584 // ciAction wraps a POST of the actions pages: the form gate, the bounded416 // ciAction wraps a POST of the actions pages: the form gate, the bounded
585 // context and the mapping of a refusal to a page.417 // context and the mapping of a refusal to a page.
586 func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {418 func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
@@ -634,183 +466,3 @@ func runnerText(j JobRow) string {
634 }466 }
635 467
636 func joinWords(ws []string) string { return strings.Join(ws, ", ") }468 func joinWords(ws []string) string { return strings.Join(ws, ", ") }
637-
638-// logClass is the class of a line of the log.
639-func logClass(ln LogLineView) string {
640- switch {
641- case ln.Group:
642- return "lg group"
643- case ln.End:
644- return "lg endgroup"
645- case ln.Error:
646- return "lg error"
647- }
648- return "lg"
649-}
650-
651-// ChecksSummary is the combined state of the checks of a commit.
652-type ChecksSummary struct {
653- Total int
654- Kind, State string
655- Label, Title string
656- Href string
657-}
658-
659-// CheckRow is a check of a commit or a merge request.
660-type CheckRow struct {
661- Name, Kind, State, Href string
662- Waiting bool
663-}
664-
665-// ChecksSection is the checks of the head of a merge request.
666-type ChecksSection struct {
667- Rows []CheckRow
668- // Note explains the rule that applies to them.
669- Note string
670-}
671-
672-// checksFor reads the checks of a commit as the caller of the request. A page
673-// shows them when it can: a failure of the read is logged and the page goes on
674-// without, since the code and the review do not depend on CI.
675-func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView {
676- if s.cfg.Runs == nil || sha == "" {
677- return nil
678- }
679- res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha)
680- if err != nil {
681- if !errors.Is(err, domain.ErrNotFound) {
682- log := zerowrap.FromCtx(ctx)
683- log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit")
684- }
685- return nil
686- }
687- return res.Checks
688-}
689-
690-// checksSummary reduces checks to the mark a list shows next to a commit.
691-func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
692- if len(checks) == 0 {
693- return ChecksSummary{}
694- }
695- plain := make([]domain.CICheck, 0, len(checks))
696- passed := 0
697- for _, c := range checks {
698- plain = append(plain, c.CICheck)
699- if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() {
700- passed++
701- }
702- }
703- sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))}
704- switch domain.CombineChecks(plain) {
705- case domain.CheckStateSuccess:
706- sum.Kind, sum.State = "ok", "checks passed"
707- case domain.CheckStateFailure:
708- sum.Kind, sum.State = "bad", "checks failed"
709- default:
710- sum.Kind, sum.State = "running", "checks pending"
711- }
712- sum.Title = sum.State + ": " + sum.Label
713- return sum
714-}
715-
716-// checksSection is the checks of the rail of a merge request, with the rule of
717-// the repository that applies to them.
718-func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
719- sec := ChecksSection{}
720- for _, c := range checks {
721- row := CheckRow{
722- Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
723- Waiting: c.WaitingForRunner,
724- }
725- if c.JobID != "" {
726- row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
727- } else {
728- row.Href = runHref(repo.Name.String(), c.RunID)
729- }
730- sec.Rows = append(sec.Rows, row)
731- }
732- switch {
733- case len(checks) == 0:
734- sec.Note = "no check on this commit: nothing blocks the merge."
735- case repo.Settings.FailingChecksBlock:
736- sec.Note = "a failing or unfinished check blocks the merge."
737- default:
738- sec.Note = "checks do not block the merge in this repository."
739- }
740- return sec
741-}
742-
743-// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
744-// lines of a job log from line N as Server-Sent Events, following the job while
745-// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
746-// the number of the next line as its id, so that a reconnection (Last-Event-ID)
747-// goes on where it stopped. "ci-end" tells the job is over and every line was
748-// sent. The job events of the repository stream carry no line (see events.go).
749-func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
750- ctx, cancel := context.WithCancel(r.Context())
751- defer cancel()
752- d, err := s.loadRepo(ctx, r.PathValue("repo"))
753- if err != nil {
754- s.denyEvents(ctx, w, err)
755- return
756- }
757- id, err := parseRunPath(r)
758- jobID := r.PathValue("job")
759- if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
760- s.denyEvents(ctx, w, errNotFound)
761- return
762- }
763- from := int64(0)
764- if v := r.Header.Get("Last-Event-ID"); v != "" {
765- from, err = strconv.ParseInt(v, 10, 64)
766- } else if v := r.URL.Query().Get("from"); v != "" {
767- from, err = strconv.ParseInt(v, 10, 64)
768- }
769- if err != nil || from < 0 {
770- s.denyEvents(ctx, w, errNotFound)
771- return
772- }
773- if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
774- s.denyEvents(ctx, w, err)
775- return
776- }
777-
778- rc := http.NewResponseController(w)
779- hdr := w.Header()
780- hdr.Set("Content-Type", "text/event-stream")
781- cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
782- hdr.Set("X-Accel-Buffering", "no")
783- w.WriteHeader(http.StatusOK)
784- out := &sseWriter{w: w, rc: rc}
785- if out.raw(": connected\n\n") != nil {
786- return
787- }
788- n := from
789- err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
790- JobID: jobID, Offset: from, Follow: true,
791- Client: s.logClient(r),
792- }, func(b ports.LogBatch) error {
793- var views []LogLineView
794- for i, l := range b.Lines {
795- views = appendLogLine(views, b.Offset+int64(i), l)
796- }
797- var html strings.Builder
798- for _, v := range views {
799- var b2 strings.Builder
800- if err := LogLine(v).Render(ctx, &b2); err != nil {
801- return err
802- }
803- html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
804- }
805- n = b.Offset + int64(len(b.Lines))
806- _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
807- _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
808- if werr != nil {
809- return werr
810- }
811- return rc.Flush()
812- })
813- if err == nil || errors.Is(err, domain.ErrNotFound) {
814- _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
815- }
816-}
A internal/adapters/web/checks.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// ChecksSummary is the combined state of the checks of a commit.
17+type ChecksSummary struct {
18+ Total int
19+ Kind, State string
20+ Label, Title string
21+ Href string
22+}
23+
24+// CheckRow is a check of a commit or a merge request.
25+type CheckRow struct {
26+ Name, Kind, State, Href string
27+ Waiting bool
28+}
29+
30+// ChecksSection is the checks of the head of a merge request.
31+type ChecksSection struct {
32+ Rows []CheckRow
33+ // Note explains the rule that applies to them.
34+ Note string
35+}
36+
37+// checksFor reads the checks of a commit as the caller of the request. A page
38+// shows them when it can: a failure of the read is logged and the page goes on
39+// without, since the code and the review do not depend on CI.
40+func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView {
41+ if s.cfg.Runs == nil || sha == "" {
42+ return nil
43+ }
44+ res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha)
45+ if err != nil {
46+ if !errors.Is(err, domain.ErrNotFound) {
47+ log := zerowrap.FromCtx(ctx)
48+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit")
49+ }
50+ return nil
51+ }
52+ return res.Checks
53+}
54+
55+// checksSummary reduces checks to the mark a list shows next to a commit.
56+func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
57+ if len(checks) == 0 {
58+ return ChecksSummary{}
59+ }
60+ plain := make([]domain.CICheck, 0, len(checks))
61+ passed := 0
62+ for _, c := range checks {
63+ plain = append(plain, c.CICheck)
64+ if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() {
65+ passed++
66+ }
67+ }
68+ sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))}
69+ switch domain.CombineChecks(plain) {
70+ case domain.CheckStateSuccess:
71+ sum.Kind, sum.State = "ok", "checks passed"
72+ case domain.CheckStateFailure:
73+ sum.Kind, sum.State = "bad", "checks failed"
74+ default:
75+ sum.Kind, sum.State = "running", "checks pending"
76+ }
77+ sum.Title = sum.State + ": " + sum.Label
78+ return sum
79+}
80+
81+// checksSection is the checks of the rail of a merge request, with the rule of
82+// the repository that applies to them.
83+func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
84+ sec := ChecksSection{}
85+ for _, c := range checks {
86+ row := CheckRow{
87+ Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
88+ Waiting: c.WaitingForRunner,
89+ }
90+ if c.JobID != "" {
91+ row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
92+ } else {
93+ row.Href = runHref(repo.Name.String(), c.RunID)
94+ }
95+ sec.Rows = append(sec.Rows, row)
96+ }
97+ switch {
98+ case len(checks) == 0:
99+ sec.Note = "no check on this commit: nothing blocks the merge."
100+ case repo.Settings.FailingChecksBlock:
101+ sec.Note = "a failing or unfinished check blocks the merge."
102+ default:
103+ sec.Note = "checks do not block the merge in this repository."
104+ }
105+ return sec
106+}
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// ChecksSummary is the combined state of the checks of a commit.
17+type ChecksSummary struct {
18+ Total int
19+ Kind, State string
20+ Label, Title string
21+ Href string
22+}
23+
24+// CheckRow is a check of a commit or a merge request.
25+type CheckRow struct {
26+ Name, Kind, State, Href string
27+ Waiting bool
28+}
29+
30+// ChecksSection is the checks of the head of a merge request.
31+type ChecksSection struct {
32+ Rows []CheckRow
33+ // Note explains the rule that applies to them.
34+ Note string
35+}
36+
37+// checksFor reads the checks of a commit as the caller of the request. A page
38+// shows them when it can: a failure of the read is logged and the page goes on
39+// without, since the code and the review do not depend on CI.
40+func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView {
41+ if s.cfg.Runs == nil || sha == "" {
42+ return nil
43+ }
44+ res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha)
45+ if err != nil {
46+ if !errors.Is(err, domain.ErrNotFound) {
47+ log := zerowrap.FromCtx(ctx)
48+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit")
49+ }
50+ return nil
51+ }
52+ return res.Checks
53+}
54+
55+// checksSummary reduces checks to the mark a list shows next to a commit.
56+func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
57+ if len(checks) == 0 {
58+ return ChecksSummary{}
59+ }
60+ plain := make([]domain.CICheck, 0, len(checks))
61+ passed := 0
62+ for _, c := range checks {
63+ plain = append(plain, c.CICheck)
64+ if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() {
65+ passed++
66+ }
67+ }
68+ sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))}
69+ switch domain.CombineChecks(plain) {
70+ case domain.CheckStateSuccess:
71+ sum.Kind, sum.State = "ok", "checks passed"
72+ case domain.CheckStateFailure:
73+ sum.Kind, sum.State = "bad", "checks failed"
74+ default:
75+ sum.Kind, sum.State = "running", "checks pending"
76+ }
77+ sum.Title = sum.State + ": " + sum.Label
78+ return sum
79+}
80+
81+// checksSection is the checks of the rail of a merge request, with the rule of
82+// the repository that applies to them.
83+func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
84+ sec := ChecksSection{}
85+ for _, c := range checks {
86+ row := CheckRow{
87+ Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
88+ Waiting: c.WaitingForRunner,
89+ }
90+ if c.JobID != "" {
91+ row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
92+ } else {
93+ row.Href = runHref(repo.Name.String(), c.RunID)
94+ }
95+ sec.Rows = append(sec.Rows, row)
96+ }
97+ switch {
98+ case len(checks) == 0:
99+ sec.Note = "no check on this commit: nothing blocks the merge."
100+ case repo.Settings.FailingChecksBlock:
101+ sec.Note = "a failing or unfinished check blocks the merge."
102+ default:
103+ sec.Note = "checks do not block the merge in this repository."
104+ }
105+ return sec
106+}
M internal/app/cli_run.go +0 -133
@@ -4,7 +4,6 @@ package app
44
55 import (
66 "context"
7- "errors"
87 "fmt"
98 "io"
109 "strconv"
@@ -544,135 +543,3 @@ func newRunRerunCmd(env *cliEnv) *cobra.Command {
544543 }
545544 return cmd
546545 }
547-
548-// --- runners ---
549-
550-type jsonRunner struct {
551- ID string `json:"id"`
552- Name string `json:"name"`
553- Labels []string `json:"labels"`
554- Isolation string `json:"isolation"`
555- Version string `json:"version"`
556- Arch string `json:"arch"`
557- Os string `json:"os"`
558- RegisteredAt *string `json:"registered_at"`
559- LastSeen *string `json:"last_seen"`
560- Busy bool `json:"busy"`
561-}
562-
563-const jsonRunnerHelp = `
564-JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
565-container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
566-seen) and busy. A runner not seen for more than about 2 minutes is offline.`
567-
568-func newRunnerCmd(env *cliEnv) *cobra.Command {
569- runner := &cobra.Command{
570- Use: "runner",
571- Short: "List and remove CI runners",
572- Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
573- "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
574- "not limited to some repositories.",
575- }
576- runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
577- return runner
578-}
579-
580-func newRunnerListCmd(env *cliEnv) *cobra.Command {
581- cmd := &cobra.Command{
582- Use: "list",
583- Short: "List the registered runners",
584- Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
585- Args: cobra.NoArgs,
586- }
587- asJSON := addJSONFlag(cmd)
588- cmd.RunE = func(cmd *cobra.Command, _ []string) error {
589- c, err := env.client(cmd, true)
590- if err != nil {
591- return err
592- }
593- ctx := env.ctx(cmd)
594- runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
595- res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
596- if err != nil {
597- return nil, "", err
598- }
599- return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
600- })
601- if err != nil {
602- return err
603- }
604- if *asJSON {
605- out := make([]jsonRunner, 0, len(runners))
606- for _, r := range runners {
607- labels := r.GetLabels()
608- if labels == nil {
609- labels = []string{}
610- }
611- out = append(out, jsonRunner{
612- ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
613- Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
614- })
615- }
616- return writeJSON(cmd.OutOrStdout(), out)
617- }
618- if len(runners) == 0 {
619- noticef(cmd, "no runners: register one with `ermite-runner register`")
620- return nil
621- }
622- rows := make([][]string, 0, len(runners))
623- for _, r := range runners {
624- state := "offline"
625- if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
626- state = "online"
627- if r.GetBusy() {
628- state = "busy"
629- }
630- }
631- rows = append(rows, []string{
632- r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
633- strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
634- })
635- }
636- return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
637- }
638- return cmd
639-}
640-
641-func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
642- cmd := &cobra.Command{
643- Use: "remove <runner id>",
644- Short: "Remove a runner",
645- Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
646- "affected: it ends, or fails as a lost runner when its heartbeats stop.",
647- Args: cobra.ExactArgs(1),
648- ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
649- if len(args) > 0 {
650- return nil, cobra.ShellCompDirectiveNoFileComp
651- }
652- out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
653- res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
654- if err != nil {
655- return nil, err
656- }
657- return res.Msg.GetRunners(), nil
658- }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
659- return out, cobra.ShellCompDirectiveNoFileComp
660- },
661- }
662- cmd.RunE = func(cmd *cobra.Command, args []string) error {
663- c, err := env.client(cmd, true)
664- if err != nil {
665- return err
666- }
667- if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
668- var ce *connect.Error
669- if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
670- return fmt.Errorf("no runner has the id %q", args[0])
671- }
672- return err
673- }
674- noticef(cmd, "runner %s removed", args[0])
675- return nil
676- }
677- return cmd
678-}
@@ -4,7 +4,6 @@ package app
4 4
5 import (5 import (
6 "context"6 "context"
7- "errors"
8 "fmt"7 "fmt"
9 "io"8 "io"
10 "strconv"9 "strconv"
@@ -544,135 +543,3 @@ func newRunRerunCmd(env *cliEnv) *cobra.Command {
544 }543 }
545 return cmd544 return cmd
546 }545 }
547-
548-// --- runners ---
549-
550-type jsonRunner struct {
551- ID string `json:"id"`
552- Name string `json:"name"`
553- Labels []string `json:"labels"`
554- Isolation string `json:"isolation"`
555- Version string `json:"version"`
556- Arch string `json:"arch"`
557- Os string `json:"os"`
558- RegisteredAt *string `json:"registered_at"`
559- LastSeen *string `json:"last_seen"`
560- Busy bool `json:"busy"`
561-}
562-
563-const jsonRunnerHelp = `
564-JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
565-container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
566-seen) and busy. A runner not seen for more than about 2 minutes is offline.`
567-
568-func newRunnerCmd(env *cliEnv) *cobra.Command {
569- runner := &cobra.Command{
570- Use: "runner",
571- Short: "List and remove CI runners",
572- Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
573- "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
574- "not limited to some repositories.",
575- }
576- runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
577- return runner
578-}
579-
580-func newRunnerListCmd(env *cliEnv) *cobra.Command {
581- cmd := &cobra.Command{
582- Use: "list",
583- Short: "List the registered runners",
584- Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
585- Args: cobra.NoArgs,
586- }
587- asJSON := addJSONFlag(cmd)
588- cmd.RunE = func(cmd *cobra.Command, _ []string) error {
589- c, err := env.client(cmd, true)
590- if err != nil {
591- return err
592- }
593- ctx := env.ctx(cmd)
594- runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
595- res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
596- if err != nil {
597- return nil, "", err
598- }
599- return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
600- })
601- if err != nil {
602- return err
603- }
604- if *asJSON {
605- out := make([]jsonRunner, 0, len(runners))
606- for _, r := range runners {
607- labels := r.GetLabels()
608- if labels == nil {
609- labels = []string{}
610- }
611- out = append(out, jsonRunner{
612- ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
613- Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
614- })
615- }
616- return writeJSON(cmd.OutOrStdout(), out)
617- }
618- if len(runners) == 0 {
619- noticef(cmd, "no runners: register one with `ermite-runner register`")
620- return nil
621- }
622- rows := make([][]string, 0, len(runners))
623- for _, r := range runners {
624- state := "offline"
625- if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
626- state = "online"
627- if r.GetBusy() {
628- state = "busy"
629- }
630- }
631- rows = append(rows, []string{
632- r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
633- strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
634- })
635- }
636- return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
637- }
638- return cmd
639-}
640-
641-func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
642- cmd := &cobra.Command{
643- Use: "remove <runner id>",
644- Short: "Remove a runner",
645- Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
646- "affected: it ends, or fails as a lost runner when its heartbeats stop.",
647- Args: cobra.ExactArgs(1),
648- ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
649- if len(args) > 0 {
650- return nil, cobra.ShellCompDirectiveNoFileComp
651- }
652- out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
653- res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
654- if err != nil {
655- return nil, err
656- }
657- return res.Msg.GetRunners(), nil
658- }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
659- return out, cobra.ShellCompDirectiveNoFileComp
660- },
661- }
662- cmd.RunE = func(cmd *cobra.Command, args []string) error {
663- c, err := env.client(cmd, true)
664- if err != nil {
665- return err
666- }
667- if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
668- var ce *connect.Error
669- if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
670- return fmt.Errorf("no runner has the id %q", args[0])
671- }
672- return err
673- }
674- noticef(cmd, "runner %s removed", args[0])
675- return nil
676- }
677- return cmd
678-}
A internal/app/cli_runner.go +147 -0
@@ -0,0 +1,147 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/spf13/cobra"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/adapters/client"
17+)
18+
19+type jsonRunner struct {
20+ ID string `json:"id"`
21+ Name string `json:"name"`
22+ Labels []string `json:"labels"`
23+ Isolation string `json:"isolation"`
24+ Version string `json:"version"`
25+ Arch string `json:"arch"`
26+ Os string `json:"os"`
27+ RegisteredAt *string `json:"registered_at"`
28+ LastSeen *string `json:"last_seen"`
29+ Busy bool `json:"busy"`
30+}
31+
32+const jsonRunnerHelp = `
33+JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
34+container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
35+seen) and busy. A runner not seen for more than about 2 minutes is offline.`
36+
37+func newRunnerCmd(env *cliEnv) *cobra.Command {
38+ runner := &cobra.Command{
39+ Use: "runner",
40+ Short: "List and remove CI runners",
41+ Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
42+ "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
43+ "not limited to some repositories.",
44+ }
45+ runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
46+ return runner
47+}
48+
49+func newRunnerListCmd(env *cliEnv) *cobra.Command {
50+ cmd := &cobra.Command{
51+ Use: "list",
52+ Short: "List the registered runners",
53+ Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
54+ Args: cobra.NoArgs,
55+ }
56+ asJSON := addJSONFlag(cmd)
57+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58+ c, err := env.client(cmd, true)
59+ if err != nil {
60+ return err
61+ }
62+ ctx := env.ctx(cmd)
63+ runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
64+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
65+ if err != nil {
66+ return nil, "", err
67+ }
68+ return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
69+ })
70+ if err != nil {
71+ return err
72+ }
73+ if *asJSON {
74+ out := make([]jsonRunner, 0, len(runners))
75+ for _, r := range runners {
76+ labels := r.GetLabels()
77+ if labels == nil {
78+ labels = []string{}
79+ }
80+ out = append(out, jsonRunner{
81+ ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
82+ Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
83+ })
84+ }
85+ return writeJSON(cmd.OutOrStdout(), out)
86+ }
87+ if len(runners) == 0 {
88+ noticef(cmd, "no runners: register one with `ermite-runner register`")
89+ return nil
90+ }
91+ rows := make([][]string, 0, len(runners))
92+ for _, r := range runners {
93+ state := "offline"
94+ if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
95+ state = "online"
96+ if r.GetBusy() {
97+ state = "busy"
98+ }
99+ }
100+ rows = append(rows, []string{
101+ r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
102+ strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
103+ })
104+ }
105+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
106+ }
107+ return cmd
108+}
109+
110+func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
111+ cmd := &cobra.Command{
112+ Use: "remove <runner id>",
113+ Short: "Remove a runner",
114+ Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
115+ "affected: it ends, or fails as a lost runner when its heartbeats stop.",
116+ Args: cobra.ExactArgs(1),
117+ ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
118+ if len(args) > 0 {
119+ return nil, cobra.ShellCompDirectiveNoFileComp
120+ }
121+ out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
122+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
123+ if err != nil {
124+ return nil, err
125+ }
126+ return res.Msg.GetRunners(), nil
127+ }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
128+ return out, cobra.ShellCompDirectiveNoFileComp
129+ },
130+ }
131+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
132+ c, err := env.client(cmd, true)
133+ if err != nil {
134+ return err
135+ }
136+ if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
137+ var ce *connect.Error
138+ if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
139+ return fmt.Errorf("no runner has the id %q", args[0])
140+ }
141+ return err
142+ }
143+ noticef(cmd, "runner %s removed", args[0])
144+ return nil
145+ }
146+ return cmd
147+}
@@ -0,0 +1,147 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/spf13/cobra"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/adapters/client"
17+)
18+
19+type jsonRunner struct {
20+ ID string `json:"id"`
21+ Name string `json:"name"`
22+ Labels []string `json:"labels"`
23+ Isolation string `json:"isolation"`
24+ Version string `json:"version"`
25+ Arch string `json:"arch"`
26+ Os string `json:"os"`
27+ RegisteredAt *string `json:"registered_at"`
28+ LastSeen *string `json:"last_seen"`
29+ Busy bool `json:"busy"`
30+}
31+
32+const jsonRunnerHelp = `
33+JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
34+container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
35+seen) and busy. A runner not seen for more than about 2 minutes is offline.`
36+
37+func newRunnerCmd(env *cliEnv) *cobra.Command {
38+ runner := &cobra.Command{
39+ Use: "runner",
40+ Short: "List and remove CI runners",
41+ Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
42+ "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
43+ "not limited to some repositories.",
44+ }
45+ runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
46+ return runner
47+}
48+
49+func newRunnerListCmd(env *cliEnv) *cobra.Command {
50+ cmd := &cobra.Command{
51+ Use: "list",
52+ Short: "List the registered runners",
53+ Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
54+ Args: cobra.NoArgs,
55+ }
56+ asJSON := addJSONFlag(cmd)
57+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58+ c, err := env.client(cmd, true)
59+ if err != nil {
60+ return err
61+ }
62+ ctx := env.ctx(cmd)
63+ runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
64+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
65+ if err != nil {
66+ return nil, "", err
67+ }
68+ return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
69+ })
70+ if err != nil {
71+ return err
72+ }
73+ if *asJSON {
74+ out := make([]jsonRunner, 0, len(runners))
75+ for _, r := range runners {
76+ labels := r.GetLabels()
77+ if labels == nil {
78+ labels = []string{}
79+ }
80+ out = append(out, jsonRunner{
81+ ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
82+ Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
83+ })
84+ }
85+ return writeJSON(cmd.OutOrStdout(), out)
86+ }
87+ if len(runners) == 0 {
88+ noticef(cmd, "no runners: register one with `ermite-runner register`")
89+ return nil
90+ }
91+ rows := make([][]string, 0, len(runners))
92+ for _, r := range runners {
93+ state := "offline"
94+ if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
95+ state = "online"
96+ if r.GetBusy() {
97+ state = "busy"
98+ }
99+ }
100+ rows = append(rows, []string{
101+ r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
102+ strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
103+ })
104+ }
105+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
106+ }
107+ return cmd
108+}
109+
110+func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
111+ cmd := &cobra.Command{
112+ Use: "remove <runner id>",
113+ Short: "Remove a runner",
114+ Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
115+ "affected: it ends, or fails as a lost runner when its heartbeats stop.",
116+ Args: cobra.ExactArgs(1),
117+ ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
118+ if len(args) > 0 {
119+ return nil, cobra.ShellCompDirectiveNoFileComp
120+ }
121+ out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
122+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
123+ if err != nil {
124+ return nil, err
125+ }
126+ return res.Msg.GetRunners(), nil
127+ }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
128+ return out, cobra.ShellCompDirectiveNoFileComp
129+ },
130+ }
131+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
132+ c, err := env.client(cmd, true)
133+ if err != nil {
134+ return err
135+ }
136+ if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
137+ var ce *connect.Error
138+ if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
139+ return fmt.Errorf("no runner has the id %q", args[0])
140+ }
141+ return err
142+ }
143+ noticef(cmd, "runner %s removed", args[0])
144+ return nil
145+ }
146+ return cmd
147+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close