1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bytes"
7 "context"
8 "strconv"
9
10 "connectrpc.com/connect"
11 "github.com/a-h/templ"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14)
15
16// liveStream turns the events of Watch into the fragments one page shows.
17type liveStream struct {
18 s *Server
19 repo string // "" on the home page
20 view string // "summary", "refs" or "commits"
21 ref string // commits: the branch listed
22 head string // commits: the commit listed first
23 n string // mr: the number of the merge request shown
24}
25
26// renderFragment renders a component to the data of an event.
27func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {
28 var b bytes.Buffer
29 if err := c.Render(ctx, &b); err != nil {
30 return fragment{}, err
31 }
32 return fragment{event: event, html: b.String()}, nil
33}
34
35var gap = []fragment{{event: "gap", html: "gap"}}
36
37// render returns the fragments for an event, none when it changes nothing on
38// the page.
39func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) {
40 if ev == nil {
41 return nil, nil
42 }
43 if ev.GetGap() != nil {
44 return gap, nil
45 }
46 if l.repo == "" {
47 return l.home(ctx, ev)
48 }
49 return l.repoPage(ctx, ev)
50}
51
52// home upserts the row of the repository an event is about. The row is read
53// again from the API as the visitor of the stream, so a repository that was
54// deleted or that the visitor can no longer see (public to private) removes
55// the row, and the stream never carries more than the list shows.
56func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) {
57 var name string
58 fresh := false
59 switch k := ev.GetKind().(type) {
60 case *pb.Event_RepoDeleted:
61 return l.gone(ctx, k.RepoDeleted.GetRepo())
62 case *pb.Event_RepoCreated:
63 name = k.RepoCreated.GetRepo().GetName()
64 case *pb.Event_RepoUpdated:
65 name = k.RepoUpdated.GetRepo().GetName()
66 case *pb.Event_Pushed:
67 name, fresh = k.Pushed.GetRepo(), true
68 default:
69 return nil, nil
70 }
71 res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
72 switch {
73 case connect.CodeOf(err) == connect.CodeNotFound:
74 return l.gone(ctx, name)
75 case err != nil:
76 return nil, err
77 }
78 row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false)
79 row.Fresh = fresh
80 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
81 return []fragment{f}, err
82}
83
84func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) {
85 f, err := renderFragment(ctx, "home-list", RepoGone("repo-"+name))
86 return []fragment{f}, err
87}
88
89// repoPage handles the events of the topic of one repository.
90func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) {
91 switch k := ev.GetKind().(type) {
92 case *pb.Event_RepoDeleted, *pb.Event_RepoCreated:
93 // Gone, or a new repository under the same name: whatever the page
94 // shows is not true any more.
95 return gap, nil
96 case *pb.Event_RepoUpdated:
97 _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
98 switch {
99 case connect.CodeOf(err) == connect.CodeNotFound:
100 return gap, nil // no longer visible
101 case err != nil:
102 return nil, err
103 case l.view == "summary", l.view == "refs":
104 return gap, nil // the description, the default branch
105 }
106 return nil, nil
107 case *pb.Event_MergeRequestOpened:
108 return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload")
109 case *pb.Event_MergeRequestMerged:
110 return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload")
111 case *pb.Event_MergeRequestClosed:
112 return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload")
113 case *pb.Event_CommentAdded:
114 return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload")
115 case *pb.Event_Pushed:
116 switch l.view {
117 case "mr":
118 return l.mrPushed(ctx, k.Pushed)
119 case "mrs":
120 return nil, nil
121 }
122 switch l.view {
123 case "refs":
124 return l.refs(ctx)
125 case "commits":
126 return l.banner(ctx, k.Pushed)
127 }
128 return l.lastPush(ctx)
129 }
130 return nil, nil
131}
132
133// lastPush is the "last push" fact of the summary.
134func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) {
135 res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
136 if err != nil {
137 return nil, err
138 }
139 f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now())))
140 return []fragment{f}, err
141}
142
143// refs is the table of the refs page and the facts of its rail.
144func (l *liveStream) refs(ctx context.Context) ([]fragment, error) {
145 d, err := l.s.loadRepo(ctx, l.repo)
146 if err != nil {
147 return nil, err
148 }
149 p := l.s.refsPage(ctx, d)
150 body, err := renderFragment(ctx, "refs-list", RefsBody(p))
151 if err != nil {
152 return nil, err
153 }
154 kv, err := renderFragment(ctx, "refs-list", RefsKV(p))
155 return []fragment{body, kv}, err
156}
157
158// banner tells the commits page how many commits its branch got since the
159// head it lists.
160func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) {
161 if l.ref == "" || !isFullID(l.head) {
162 return nil, nil
163 }
164 tip, touched := "", false
165 for _, u := range p.GetRefUpdates() {
166 if u.GetRef() == "refs/heads/"+l.ref {
167 tip, touched = u.GetNewId(), true
168 }
169 }
170 switch {
171 case !touched:
172 return nil, nil
173 case tip == "":
174 return gap, nil // the branch was deleted
175 }
176 text := ""
177 if tip != l.head {
178 res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
179 Repo: l.repo, Revision: tip, PageSize: pageSize,
180 }))
181 if err != nil {
182 return nil, err
183 }
184 // The count is approximate for a history with merges: the log is
185 // ordered by date, not as "head..tip", so commits that are not new may
186 // be counted before the head is met. And one page of the log being
187 // all that is read, "100+" cannot be told from a rewritten branch
188 // whose old head is further back: the API has no "is ancestor".
189 n, found := 0, false
190 for _, c := range res.Msg.GetCommits() {
191 if c.GetId() == l.head {
192 found = true
193 break
194 }
195 n++
196 }
197 switch {
198 case found:
199 text = newCommits(strconv.Itoa(n), n)
200 case res.Msg.GetNextPageToken() != "":
201 text = newCommits(strconv.Itoa(n)+"+", 2)
202 default:
203 text = "the branch was rewritten — reload"
204 }
205 }
206 c := CommitsSlot()
207 if text != "" {
208 c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
209 }
210 f, err := renderFragment(ctx, "commits-head", c)
211 return []fragment{f}, err
212}
213
214// newCommits is the text of the banner: "1 new commit — reload".
215func newCommits(count string, n int) string {
216 if n == 1 {
217 return count + " new commit — reload"
218 }
219 return count + " new commits — reload"
220}
221
222// isFullID reports whether s is a full commit id.
223func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }
224
225// mrNotice tells a merge request page that something happened to the request
226// it shows, or the list that its content changed. Only a fixed sentence is sent:
227// the text of a comment or a title never leaves through the stream.
228func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
229 switch l.view {
230 case "mrs":
231 text = "merge requests changed — reload"
232 return l.notice(ctx, text, mrBase(l.repo))
233 case "mr":
234 if strconv.FormatInt(number, 10) != l.n {
235 return nil, nil
236 }
237 return l.notice(ctx, text, mrHref(l.repo, number))
238 }
239 return nil, nil
240}
241
242// mrPushed announces a push to the source branch of the merge request shown.
243func (l *liveStream) mrPushed(ctx context.Context, p *pb.Pushed) ([]fragment, error) {
244 if l.ref == "" {
245 return nil, nil
246 }
247 for _, u := range p.GetRefUpdates() {
248 if u.GetRef() == "refs/heads/"+l.ref {
249 n, err := strconv.ParseInt(l.n, 10, 64)
250 if err != nil {
251 return nil, nil
252 }
253 return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
254 }
255 }
256 return nil, nil
257}
258
259func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
260 f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
261 return []fragment{f}, err
262}