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}