v0.31.0 d5e0505

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

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