v0.33.0 cf26365

fragments.go RawHistoryFolder

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

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