v0.35.1 2aca1df

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

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