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}