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}