detached fda3c2b

fragments.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"strconv"
10	"strings"
11	"sync"
12	"time"
13
14	"github.com/a-h/templ"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// liveStream turns the events of Watch into the fragments one page shows.
21type liveStream struct {
22	s    *Server
23	repo string // "" on the home page
24	// filter is the repository the inbox is filtered on, "" for all.
25	filter string
26	view   string // "summary", "refs" or "commits"
27	ref    string // commits: the branch listed
28	head   string // commits: the commit listed first
29	n      string // mr: the number of the merge request shown; run: the run id
30	job    string // job: the id of the job shown
31	target string // mr: the target branch of the merge request shown
32	// sha is the commit of the run of the job shown, read once when the stream
33	// is set up: the annotations link to files at it, and it never changes.
34	sha string
35	// headRuns says, on a merge request page, whether a run is one of the
36	// head shown (its checks are on that head); a job event does not carry
37	// its commit, so its run is read once. One stream reads it, no lock.
38	headRuns map[int64]bool
39	// mergeChecksHTML is the last merge-form part sent, not sent again unchanged.
40	mergeChecksHTML string
41}
42
43// maxHeadRuns bounds headRuns: past it the map starts again.
44const maxHeadRuns = 256
45
46// renderFragment renders a component to the data of an event.
47func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {
48	var b bytes.Buffer
49	if err := c.Render(ctx, &b); err != nil {
50		return fragment{}, err
51	}
52	return fragment{event: event, html: b.String()}, nil
53}
54
55var gap = []fragment{{event: "gap", html: "gap"}}
56
57// liveEvent reports whether the event of a watch is one the pages follow: a
58// gap, or one of the events of the forge. The use case may send others, which
59// no page shows and no stream carries, not even their id.
60func liveEvent(we ports.WatchEvent) bool {
61	if we.Gap {
62		return true
63	}
64	switch we.Event.(type) {
65	case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished,
66		domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
67		domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
68		domain.RunUpdated, domain.JobUpdated,
69		domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
70		return true
71	}
72	return false
73}
74
75// render returns the fragments for an event, none when it changes nothing on
76// the page.
77func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
78	if we.Gap {
79		return gap, nil
80	}
81	if we.Event == nil {
82		return nil, nil
83	}
84	if l.view == "inbox" {
85		return l.inbox(ctx, we)
86	}
87	if l.repo == "" {
88		return l.home(ctx, we)
89	}
90	return l.repoPage(ctx, we)
91}
92
93// repoByName reads a repository as the caller of the request. A name that is
94// not valid is a repository that is not there.
95func (l *liveStream) repoByName(ctx context.Context, rawName string) (ports.RepoView, error) {
96	name, err := domain.ParseRepoName(rawName)
97	if err != nil {
98		return ports.RepoView{}, domain.ErrNotFound
99	}
100	return l.s.repoView(ctx, name)
101}
102
103// home upserts the row of the repository an event is about. The row is read
104// again from the use case as the principal of the stream, so a repository
105// that was deleted or that the visitor can no longer see (public to private)
106// removes the row, and the stream never carries more than the list shows.
107func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
108	var name string
109	fresh := false
110	switch ev := we.Event.(type) {
111	case domain.RepoDeleted:
112		return l.gone(ctx, ev.Repo.String())
113	case domain.RepoCreated:
114		name = ev.Repo.String()
115	case domain.RepoUpdated:
116		name = ev.Repo.String()
117	case domain.PushEvent:
118		name, fresh = ev.Repo.String(), true
119	case domain.MergeRequestOpened:
120		name, fresh = ev.MergeRequest.Repo.String(), true
121	case domain.MergeRequestMerged:
122		name, fresh = ev.MergeRequest.Repo.String(), true
123	case domain.MergeRequestClosed:
124		name, fresh = ev.MergeRequest.Repo.String(), true
125	case domain.MergeRequestReady:
126		name, fresh = ev.MergeRequest.Repo.String(), true
127	case domain.CommentAdded:
128		name, fresh = ev.Repo.String(), true
129	case domain.IssueOpened:
130		name, fresh = ev.Issue.Repo.String(), true
131	case domain.IssueClosed:
132		name, fresh = ev.Issue.Repo.String(), true
133	case domain.IssueReopened:
134		name, fresh = ev.Issue.Repo.String(), true
135	case domain.IssueCommented:
136		name, fresh = ev.Repo.String(), true
137	case domain.RunUpdated:
138		name = ev.Run.Repo.String() // the CI state of the row
139	case domain.JobUpdated:
140		if ev.Job.Phase != domain.CIPhaseEnded {
141			return nil, nil
142		}
143		name = ev.Job.Repo.String() // a failed job turns the state to failing
144	default:
145		return nil, nil
146	}
147	view, err := l.repoByName(ctx, name)
148	switch {
149	case errors.Is(err, domain.ErrNotFound):
150		return l.gone(ctx, name)
151	case err != nil:
152		return nil, err
153	}
154	row := l.s.homeRowOf(ctx, view, l.s.cfg.Now(), false)
155	row.Fresh = fresh
156	f, err := renderFragment(ctx, "home-list", HomeRowView(row))
157	return []fragment{f}, err
158}
159
160func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) {
161	f, err := renderFragment(ctx, "home-list", RepoGone("repo-"+name))
162	return []fragment{f}, err
163}
164
165// repoPage handles the events of the topic of one repository.
166func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
167	switch ev := we.Event.(type) {
168	case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
169		if l.view == "releases" || l.view == "release" {
170			return gap, nil // notes, assets, latest or visibility changed: reload
171		}
172		return nil, nil
173	case domain.RunUpdated:
174		return l.runEvent(ctx, ev)
175	case domain.JobUpdated:
176		return l.jobEvent(ctx, ev)
177	case domain.RepoDeleted, domain.RepoCreated:
178		// Gone, or a new repository under the same name: whatever the page
179		// shows is not true any more.
180		return gap, nil
181	case domain.RepoUpdated:
182		_, err := l.repoByName(ctx, l.repo)
183		switch {
184		case errors.Is(err, domain.ErrNotFound):
185			return gap, nil // no longer visible
186		case err != nil:
187			return nil, err
188		case l.view == "summary", l.view == "refs", l.view == "releases", l.view == "release":
189			return gap, nil // the description, the default branch
190		}
191		return nil, nil
192	case domain.MergeRequestOpened:
193		return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
194	case domain.MergeRequestMerged:
195		return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
196	case domain.MergeRequestClosed:
197		return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
198	case domain.MergeRequestReady:
199		return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
200	case domain.MergeRequestAutoMergeEnabled:
201		return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was enabled — reload")
202	case domain.MergeRequestAutoMergeCancelled:
203		return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was cancelled — reload")
204	case domain.MergeRequestMergeability:
205		if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
206			return nil, nil
207		}
208		return l.mrMergeability(ctx)
209	case domain.CommentEdited:
210		return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
211	case domain.CommentDeleted:
212		return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
213	case domain.CommentResolved:
214		return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
215	case domain.CommentAdded:
216		return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
217	case domain.IssueOpened:
218		return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
219	case domain.IssueClosed:
220		return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
221	case domain.IssueReopened:
222		return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
223	case domain.IssueCommented:
224		return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
225	case domain.IssueReferenced:
226		return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload")
227	case domain.PushEvent:
228		switch l.view {
229		case "mr":
230			return l.mrPushed(ctx, ev)
231		case "mrs":
232			return nil, nil
233		}
234		switch l.view {
235		case "refs":
236			return l.refs(ctx, we.ID)
237		case "commits":
238			return l.banner(ctx, ev)
239		}
240		return l.lastPush(ctx)
241	}
242	return nil, nil
243}
244
245// lastPush is the "last push" fact of the summary.
246func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) {
247	view, err := l.repoByName(ctx, l.repo)
248	if err != nil {
249		return nil, err
250	}
251	f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(view, l.s.cfg.Now())))
252	return []fragment{f}, err
253}
254
255// refs is the table of the refs page and the facts of its rail. The fragments
256// of one event are built once for all the watchers of the repository that
257// see the same things (see fanout); eventID names the event.
258func (l *liveStream) refs(ctx context.Context, eventID string) ([]fragment, error) {
259	key := fanKey{repo: l.repo, event: eventID, owner: SignedIn(ctx)}
260	return l.s.refsFan.do(ctx, key, func() ([]fragment, error) {
261		d, err := l.s.loadRepo(ctx, l.repo)
262		if err != nil {
263			return nil, err
264		}
265		p := l.s.refsPage(ctx, d)
266		body, err := renderFragment(ctx, "refs-list", RefsBody(p))
267		if err != nil {
268			return nil, err
269		}
270		kv, err := renderFragment(ctx, "refs-list", RefsKV(p))
271		return []fragment{body, kv}, err
272	})
273}
274
275const (
276	// fanTTL is how long the fragments of an event are kept for the watchers
277	// that are told of it a little later than the first.
278	fanTTL = 2 * time.Second
279	// fanMax bounds the events kept; past it an event is built for each
280	// watcher, as without sharing.
281	fanMax = 64
282)
283
284// fanKey says which watchers may share the fragments of an event: those of
285// the same repository, for the same event, and of the same class. The class
286// is anonymous or owner (a session is the owner's alone, ADR 0018): what is
287// built as the owner can show what an anonymous visitor may not see, so the
288// two never share an entry.
289type fanKey struct {
290	repo, event string
291	owner       bool
292}
293
294type fanEntry struct {
295	done  chan struct{} // closed once frags or err is set
296	frags []fragment
297	err   error
298	at    time.Time // when it was built; zero while it is
299}
300
301// fanout builds the fragments of an event once for the many watchers that
302// receive it: a push to a repository that 256 pages watch would otherwise
303// load the refs and the commits they designate 256 times. The zero value is
304// ready. A failure is never kept: the next watcher builds it again.
305type fanout struct {
306	mu sync.Mutex
307	m  map[fanKey]*fanEntry
308}
309
310// do returns the fragments of key, built by build when nobody has or is
311// building them. build runs with the context of the watcher that calls it,
312// which is of the class of the key; a watcher whose builder failed (its
313// request was cancelled, say) does not fail the others, they build again.
314func (f *fanout) do(ctx context.Context, key fanKey, build func() ([]fragment, error)) ([]fragment, error) {
315	if key.event == "" {
316		return build() // no event to share by
317	}
318	for range 3 {
319		f.mu.Lock()
320		now := time.Now()
321		for k, e := range f.m {
322			if !e.at.IsZero() && now.Sub(e.at) > fanTTL {
323				delete(f.m, k)
324			}
325		}
326		e, shared := f.m[key]
327		if !shared {
328			if len(f.m) >= fanMax {
329				f.mu.Unlock()
330				return build()
331			}
332			if f.m == nil {
333				f.m = make(map[fanKey]*fanEntry)
334			}
335			e = &fanEntry{done: make(chan struct{})}
336			f.m[key] = e
337		}
338		f.mu.Unlock()
339
340		if !shared {
341			return f.lead(key, e, build)
342		}
343		select {
344		case <-e.done:
345		case <-ctx.Done():
346			return nil, ctx.Err()
347		}
348		if e.err == nil {
349			return e.frags, nil
350		}
351	}
352	return build()
353}
354
355// lead builds the entry that the caller registered.
356func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) (frags []fragment, err error) {
357	err = errors.New("web: fragments were not built") // a panic of build
358	defer func() {
359		f.mu.Lock()
360		e.frags, e.err, e.at = frags, err, time.Now()
361		if err != nil && f.m[key] == e {
362			delete(f.m, key)
363		}
364		f.mu.Unlock()
365		close(e.done)
366	}()
367	return build()
368}
369
370// banner tells the commits page how many commits its branch got since the
371// head it lists.
372//
373//hexcheck:ignore suspicious-business-logic-in-adapter picks the update of the listed branch from the push event; the count and rewrite come from MergeRequests.PushSummary
374func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
375	if l.ref == "" || !isFullID(l.head) {
376		return nil, nil
377	}
378	tip, touched := "", false
379	for _, u := range p.Updates {
380		if u.Ref == "refs/heads/"+l.ref {
381			tip, touched = u.New, true
382			if u.IsDelete() {
383				tip = ""
384			}
385		}
386	}
387	switch {
388	case !touched:
389		return nil, nil
390	case tip == "":
391		return gap, nil // the branch was deleted
392	}
393	text, err := l.bannerText(ctx, tip)
394	if err != nil {
395		return nil, err
396	}
397	c := LiveSlot("commits-head")
398	if text != "" {
399		c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
400	}
401	f, err := renderFragment(ctx, "commits-head", c)
402	return []fragment{f}, err
403}
404
405func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) {
406	if tip == l.head {
407		return "", nil
408	}
409	if l.s.cfg.MergeRequests == nil {
410		// Without the merge request service there is no history summary: the
411		// banner only says the branch moved.
412		return "the branch moved — reload", nil
413	}
414	name, err := domain.ParseRepoName(l.repo)
415	if err != nil {
416		return "", err
417	}
418	oldHead, err := domain.ParseObjectID(l.head)
419	if err != nil {
420		return "", err
421	}
422	newTip, err := domain.ParseObjectID(tip)
423	if err != nil {
424		return "", err
425	}
426	res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip)
427	if err != nil {
428		return "", err
429	}
430	return pushSummaryText(res), nil
431}
432
433func pushSummaryText(res ports.PushSummary) string {
434	switch {
435	case res.Rewritten:
436		return "the branch was rewritten — reload"
437	case res.More:
438		return newCommits(strconv.Itoa(res.Count)+"+", 2)
439	default:
440		return newCommits(strconv.Itoa(res.Count), res.Count)
441	}
442}
443
444// newCommits is the text of the banner: "1 new commit — reload".
445func newCommits(count string, n int) string {
446	if n == 1 {
447		return count + " new commit — reload"
448	}
449	return count + " new commits — reload"
450}
451
452// isFullID reports whether s is a full commit id.
453func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }
454
455// mrNotice tells a merge request page that something happened to the request
456// it shows, or the list that its content changed. Only a fixed sentence is sent:
457// the text of a comment or a title never leaves through the stream.
458func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
459	switch l.view {
460	case "mrs":
461		text = "merge requests changed — reload"
462		return l.notice(ctx, text, mrBase(l.repo))
463	case "mr":
464		if strconv.FormatInt(number, 10) != l.n {
465			return nil, nil
466		}
467		return l.notice(ctx, text, mrHref(l.repo, number))
468	}
469	return nil, nil
470}
471
472// issueNotice tells an issue page that something happened to the issue it
473// shows, or the list that its content changed. Like mrNotice it sends a fixed
474// sentence only, in the same slot.
475func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
476	switch l.view {
477	case "issues":
478		return l.notice(ctx, "issues changed — reload", issuesBase(l.repo))
479	case "issue":
480		if strconv.FormatInt(number, 10) != l.n {
481			return nil, nil
482		}
483		return l.notice(ctx, text, issueHref(l.repo, number))
484	}
485	return nil, nil
486}
487
488// mrPushed announces a push to the source branch of the merge request shown,
489// and has the badge follow a push to its source or its target.
490func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
491	if _, err := strconv.ParseInt(l.n, 10, 64); err != nil {
492		return nil, nil
493	}
494	var out []fragment
495	moved := false
496	for _, u := range p.Updates {
497		switch {
498		case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
499			out = append(out, fragment{event: "mr-head", html: `<div id="mr-head"></div>`})
500			moved = true
501		case l.target != "" && u.Ref == "refs/heads/"+l.target:
502			moved = true
503		}
504		if moved && len(out) > 0 {
505			break
506		}
507	}
508	if !moved {
509		return nil, nil
510	}
511	badge, err := l.mrMergeability(ctx)
512	return append(out, badge...), err
513}
514
515// mrMergeability is the badge of the merge box, read again: the result for the
516// tips as they are now, "checking" until the server has one (it then sends the
517// MergeRequestMergeability that turns the badge). Nothing for a request that
518// is not open.
519func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) {
520	if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil {
521		return nil, nil // the box is the owner's
522	}
523	name, err := domain.ParseRepoName(l.repo)
524	if err != nil {
525		return nil, nil
526	}
527	n, err := strconv.ParseInt(l.n, 10, 64)
528	if err != nil {
529		return nil, nil
530	}
531	m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n)
532	switch {
533	case errors.Is(err, domain.ErrNotFound):
534		return gap, nil
535	case err != nil:
536		return nil, err
537	case m.State == "":
538		return nil, nil
539	}
540	f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target)))
541	return []fragment{f}, err
542}
543
544func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
545	f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
546	return []fragment{f}, err
547}
548
549// runEvent updates the pages of CI that a RunUpdated changes: the row of the
550// list of runs, the recent actions of the summary, or the body of the run page
551// shown. Only a state is sent: the
552// events carry no log and no text of the workflow.
553func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
554	if l.s.cfg.Runs == nil {
555		return nil, nil
556	}
557	switch l.view {
558	case "actions":
559		f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now())))
560		if err != nil {
561			return nil, err
562		}
563		if ev.Run.Status == domain.CIQueued {
564			f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
565		}
566		return []fragment{f}, nil
567	case "summary":
568		return l.recentActions(ctx)
569	case "run":
570		if strconv.FormatInt(ev.Run.ID, 10) != l.n {
571			return nil, nil
572		}
573		return l.runBody(ctx)
574	case "mr":
575		ofHead := ev.Run.CheckSha() == l.head
576		l.rememberRun(ev.Run.ID, ofHead)
577		if !ofHead {
578			return nil, nil
579		}
580		return l.mrChecks(ctx)
581	}
582	return nil, nil
583}
584
585// rememberRun records whether a run is one of the head of the page.
586func (l *liveStream) rememberRun(id int64, ofHead bool) {
587	if l.headRuns == nil || len(l.headRuns) >= maxHeadRuns {
588		l.headRuns = make(map[int64]bool)
589	}
590	l.headRuns[id] = ofHead
591}
592
593// jobOfHead tells whether the run of a job is one of the head of the page,
594// reading the run the first time.
595func (l *liveStream) jobOfHead(ctx context.Context, job domain.CIJob) (bool, error) {
596	if ofHead, known := l.headRuns[job.RunID]; known {
597		return ofHead, nil
598	}
599	run, err := l.s.cfg.Runs.GetRun(ctx, principal(ctx), job.Repo, job.RunID, 0)
600	switch {
601	case errors.Is(err, domain.ErrNotFound):
602		return false, nil
603	case err != nil:
604		return false, err
605	}
606	ofHead := run.Run.CheckSha() == l.head
607	l.rememberRun(job.RunID, ofHead)
608	return ofHead, nil
609}
610
611// mrChecks is the checks block of the merge request page, read again for the
612// head the page shows: a run or a job of that head changed. A job event does
613// not name its commit; reading the checks of the head is one store read, and
614// a job of another commit leaves them as they were.
615func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
616	if !isFullID(l.head) {
617		return nil, nil
618	}
619	name, err := domain.ParseRepoName(l.repo)
620	if err != nil {
621		return nil, nil
622	}
623	repo, err := l.s.repoRecord(ctx, name)
624	switch {
625	case errors.Is(err, domain.ErrNotFound):
626		return gap, nil
627	case err != nil:
628		return nil, err
629	}
630	checks := l.s.checksFor(ctx, name, l.head)
631	f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, checks)))
632	if err != nil {
633		return nil, err
634	}
635	sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks)))
636	if err != nil {
637		return nil, err
638	}
639	// The merge form is the owner's: a visitor's stream never carries it.
640	if !SignedIn(ctx) {
641		return []fragment{f, sum}, nil
642	}
643	controls, err := renderFragment(ctx, "ci-run", MRMergeChecks(checksSummary(name, l.head, checks)))
644	if err != nil {
645		return nil, err
646	}
647	if controls.html == l.mergeChecksHTML {
648		return []fragment{f, sum}, nil
649	}
650	l.mergeChecksHTML = controls.html
651	return []fragment{f, sum, controls}, nil
652}
653
654// recentActions is the "Recent actions" block of the summary rail, read again
655// as the principal of the stream: the newest runs change with every event, and
656// a viewer who cannot read CI any more gets the block hidden and empty.
657func (l *liveStream) recentActions(ctx context.Context) ([]fragment, error) {
658	name, err := domain.ParseRepoName(l.repo)
659	if err != nil {
660		return nil, nil
661	}
662	a, err := l.s.listRecentActions(ctx, name, l.s.cfg.Now())
663	switch {
664	case errors.Is(err, domain.ErrNotFound):
665		a = RecentActions{AllHref: actionsBase(l.repo)}
666	case err != nil:
667		return nil, err
668	}
669	f, err := renderFragment(ctx, "ci-run", RecentActionsBlock(a))
670	return []fragment{f}, err
671}
672
673// jobEvent updates the body of the run page and the steps of the job page.
674func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
675	if l.s.cfg.Runs == nil {
676		return nil, nil
677	}
678	switch l.view {
679	case "run":
680		if strconv.FormatInt(ev.Job.RunID, 10) != l.n {
681			return nil, nil
682		}
683		return l.runBody(ctx)
684	case "job":
685		if ev.Job.ID != l.job {
686			return nil, nil
687		}
688		return l.jobState(ctx)
689	case "mr":
690		if !isFullID(l.head) {
691			return nil, nil
692		}
693		ofHead, err := l.jobOfHead(ctx, ev.Job)
694		if err != nil || !ofHead {
695			return nil, err
696		}
697		return l.mrChecks(ctx)
698	}
699	return nil, nil
700}
701
702// runBody renders again the jobs of the run page.
703func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) {
704	id, err := strconv.ParseInt(l.n, 10, 64)
705	if err != nil {
706		return nil, nil
707	}
708	d, err := l.s.loadRepo(ctx, l.repo)
709	if err != nil {
710		return nil, err
711	}
712	p, err := l.s.runPage(ctx, d, id, 0)
713	if err != nil {
714		return nil, err
715	}
716	body, err := renderFragment(ctx, "ci-run", runBodyFragment(p))
717	if err != nil {
718		return nil, err
719	}
720	// The buttons depend on the state: the page fetches itself again when the
721	// run completed, so that the owner gets the right ones.
722	if !p.Active {
723		return []fragment{body, {event: "gap", html: "gap"}}, nil
724	}
725	return []fragment{body}, nil
726}
727
728// jobState tells the job page that its state or its steps changed: the page
729// reloads the part of the log after the last line it holds (ermite.js).
730func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
731	if l.sha == "" {
732		return gap, nil // the stream was set up without the commit of the run
733	}
734	d, err := l.s.loadRepo(ctx, l.repo)
735	if err != nil {
736		return nil, err
737	}
738	view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0)
739	if err != nil {
740		return nil, err
741	}
742	now := l.s.cfg.Now()
743	row := l.s.jobRow(d.name(), view, now)
744	f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines))
745	if err != nil {
746		return nil, err
747	}
748	steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now)))
749	if err != nil {
750		return nil, err
751	}
752	anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, l.sha, view, 0)
753	if err != nil {
754		return nil, err
755	}
756	notes, err := renderFragment(ctx, "ci-job", JobAnnotations(anns, more))
757	if err != nil {
758		return nil, err
759	}
760	return []fragment{f, steps, notes}, nil
761}

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 2

Tags 45

↑↓ moveenter openesc close