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}