1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "net/url"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/a-h/templ"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20// issues of every repository, newest activity first, with a filter by
21// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22// signed-in owner sees it: for anyone else the path is as missing as any other.
23// It follows the stream "owner" with view=inbox: each event of a merge request
24// or an issue sends the counters, the two lists and the filter again.
25
26// inboxPath is the page of the inbox.
27const inboxPath = "/-/inbox"
28
29// inboxEvent is the name of the live events of the inbox.
30const inboxEvent = "inbox"
31
32// InboxRow is one open item of the inbox.
33type InboxRow struct {
34 Repo string
35 RepoHref string
36 Number string
37 Href string
38 Title string
39 When string
40 WhenAt string
41 Cursor bool
42}
43
44// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45// it comes from an anonymous visitor and is never rendered as Markdown.
46type PendingRow struct {
47 Repo string
48 RepoHref string
49 Author string
50 Title string
51 // Preview is the start of the body, at most pendingPreviewRunes characters.
52 Preview string
53 When string
54 WhenAt string
55 // Approve, Reject and Ban are the URLs the buttons post to; empty without
56 // the moderation use case.
57 Approve, Reject, Ban string
58}
59
60// pendingPreviewRunes bounds the preview of a pending body.
61const pendingPreviewRunes = 300
62
63// InboxRepoLink is an entry of the repository filter.
64type InboxRepoLink struct {
65 Label string
66 Href string
67 Current bool
68}
69
70// InboxRunRow adds repository and start time to the Actions run display.
71type InboxRunRow struct {
72 RunRow
73 Repo, RepoHref, When, WhenAt string
74}
75
76// InboxPage is the inbox: counters, repository filter and item lists.
77type InboxPage struct {
78 Running int
79 RunsEnabled bool
80 Runs []InboxRunRow
81 // MergeRequests and Issues count every open item, whatever the filter.
82 MergeRequests, Issues int
83 // PendingCount counts every issue awaiting moderation.
84 PendingCount int
85 Pending []PendingRow
86 Filter string // the repository filtered on, "" for all
87 Repos []InboxRepoLink
88 MRs []InboxRow
89 IssueRows []InboxRow
90}
91
92// inboxHref is the inbox page, filtered on repo when it is not "".
93func inboxHref(repo string) string {
94 if repo == "" {
95 return inboxPath
96 }
97 return inboxPath + "?repo=" + url.QueryEscape(repo)
98}
99
100// inboxFilter reads ?repo=. A name that is not valid is a page that is not
101// there.
102func inboxFilter(r *http.Request) (domain.RepoName, error) {
103 raw := r.URL.Query().Get("repo")
104 if raw == "" {
105 return domain.RepoName{}, nil
106 }
107 name, err := domain.ParseRepoName(raw)
108 if err != nil {
109 return domain.RepoName{}, errNotFound
110 }
111 return name, nil
112}
113
114// inboxPage builds the page from the overview.
115func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
116 now := s.cfg.Now()
117 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118 p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119 for _, run := range ov.Runs {
120 r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121 if run.Status == domain.CIInProgress {
122 r.State = "in progress"
123 }
124 ts := run.StartedAt
125 if ts.IsZero() {
126 ts = run.CreatedAt
127 }
128 r.When, r.WhenAt = whenOf(now, ts)
129 p.Runs = append(p.Runs, r)
130 }
131 for _, pi := range ov.Pending {
132 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
133 }
134 row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
135 ts := updated
136 if ts.IsZero() {
137 ts = created
138 }
139 r := InboxRow{
140 Repo: repo.String(), RepoHref: repoHref(repo.String()),
141 Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
142 }
143 r.When, r.WhenAt = whenOf(now, ts)
144 return r
145 }
146 for _, m := range ov.MergeRequests {
147 p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
148 }
149 for _, i := range ov.Issues {
150 p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
151 }
152 switch {
153 case len(p.MRs) > 0:
154 p.MRs[0].Cursor = true
155 case len(p.IssueRows) > 0:
156 p.IssueRows[0].Cursor = true
157 }
158 p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
159 for _, r := range ov.Repos {
160 p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
161 }
162 return p
163}
164
165// pendingRow is the row of a pending issue: the texts as plain strings, the
166// invisible characters marked, the body cut.
167func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
168 repo := pi.Repo.String()
169 row := PendingRow{
170 Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
171 Title: visibleText(pi.Title.String()),
172 }
173 if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
174 row.Author = a
175 }
176 body := strings.TrimSpace(visibleText(pi.Body.String()))
177 row.Preview = cutRunes(body, pendingPreviewRunes)
178 if len(row.Preview) < len(body) {
179 row.Preview += "…"
180 }
181 row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
182 if s.cfg.Moderation != nil {
183 base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
184 var q string
185 if !filter.IsZero() {
186 q = "?repo=" + url.QueryEscape(filter.String())
187 }
188 row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
189 }
190 return row
191}
192
193// pendingBack is where a moderation action returns: the inbox, on the same
194// repository filter.
195func pendingBack(r *http.Request) string {
196 if f, err := inboxFilter(r); err == nil {
197 return inboxHref(f.String())
198 }
199 return inboxPath
200}
201
202// pendingID reads the repository and the id of the path.
203func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
204 name, err := domain.ParseRepoName(r.PathValue("repo"))
205 if err != nil {
206 return domain.RepoName{}, "", err
207 }
208 id, err := domain.ParsePendingIssueID(r.PathValue("id"))
209 if err != nil {
210 return domain.RepoName{}, "", domain.ErrNotFound
211 }
212 return name, id, nil
213}
214
215// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
216func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
217 back := pendingBack(r)
218 name, id, err := pendingID(r)
219 if err != nil {
220 return "", back, err
221 }
222 if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
223 return "", back, err
224 }
225 return back, back, nil
226}
227
228// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
229// also bans the submitter.
230func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
231 return func(ctx context.Context, r *http.Request) (string, string, error) {
232 back := pendingBack(r)
233 name, id, err := pendingID(r)
234 if err != nil {
235 return "", back, err
236 }
237 if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
238 return "", back, err
239 }
240 return back, back, nil
241 }
242}
243
244// inbox is GET /-/inbox.
245func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
246 if !SignedIn(ctx) {
247 return nil, 0, errNotFound
248 }
249 filter, err := inboxFilter(r)
250 if err != nil {
251 return nil, 0, err
252 }
253 ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
254 if err != nil {
255 return nil, 0, err
256 }
257 l := s.base()
258 l.Title = "inbox · " + s.instance
259 l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
260 l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
261 if s.cfg.Events != nil {
262 q := "view=inbox"
263 if !filter.IsZero() {
264 q += "&repo=" + url.QueryEscape(filter.String())
265 }
266 l.Live, l.LiveURL = true, liveURL("owner", q)
267 }
268 return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
269}
270
271// inboxCount is the number of open items the home button shows, and whether
272// there is a button: only for the signed-in owner, and only when the inbox is
273// there. A failed read leaves the button without a number.
274func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
275 if s.cfg.Inbox == nil || !SignedIn(ctx) {
276 return 0, false
277 }
278 ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
279 if err != nil {
280 return -1, true
281 }
282 return ov.Counts.MergeRequests + ov.Counts.Issues, true
283}
284
285// inbox renders the fragments an event of a merge request, an issue or a repository changes
286// on the inbox page: the counters, the filter and the two lists.
287func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
288 switch we.Event.(type) {
289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291 domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
292 default:
293 return nil, nil
294 }
295 if l.s.cfg.Inbox == nil {
296 return nil, nil
297 }
298 var filter domain.RepoName
299 if l.filter != "" {
300 filter, _ = domain.ParseRepoName(l.filter)
301 }
302 ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
303 if err != nil {
304 return nil, err
305 }
306 p := l.s.inboxPage(ov, filter)
307 var out []fragment
308 for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
309 f, err := renderFragment(ctx, inboxEvent, c)
310 if err != nil {
311 return nil, err
312 }
313 out = append(out, f)
314 }
315 return out, nil
316}