feat(web): owner inbox page and home button
- commit
- bc6524ce22edf8d1d57debf44b4d4324876a00ec
- author
- brice · · 2026-10-19 06:37 UTC
- parent
- ab38ed2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +1617 -378
M docs/issues.md +6 -0
| @@ -33,3 +33,9 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br | |||
| 33 | 33 | ## Numbering | |
| 34 | 34 | ||
| 35 | 35 | Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`. | |
| 36 | + | ||
| 37 | + | ## Inbox | |
| 38 | + | ||
| 39 | + | The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues. | |
| 40 | + | ||
| 41 | + | The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows. | |
| @@ -33,3 +33,9 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br | |||||
| 33 | ## Numbering | 33 | ## Numbering | ||
| 34 | 34 | ||||
| 35 | Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`. | 35 | Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`. | ||
| 36 | + | ||||
| 37 | + | ## Inbox | |||
| 38 | + | ||||
| 39 | + | The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues. | |||
| 40 | + | ||||
| 41 | + | The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows. | |||
M internal/adapters/web/events.go +31 -2
| @@ -27,6 +27,7 @@ import ( | |||
| 27 | 27 | // the HTML fragments the page shows, sent as Server-Sent Events: | |
| 28 | 28 | // | |
| 29 | 29 | // GET /-/events?topic=home | |
| 30 | + | // GET /-/events?topic=owner&view=inbox[&repo=<name>] (the signed-in owner only) | |
| 30 | 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | |
| 31 | 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 32 | 33 | // GET /-/events?topic=repo:<name>&view=mrs | |
| @@ -84,9 +85,14 @@ const ( | |||
| 84 | 85 | retryAfter = 10 | |
| 85 | 86 | ) | |
| 86 | 87 | ||
| 87 | - | // parseTopic reads "home" or "repo:<name>"; repo is "" for home. | |
| 88 | + | // ownerTopic is the stream of the inbox: the merge requests and issues of every | |
| 89 | + | // repository, for the owner. | |
| 90 | + | const ownerTopic = "owner" | |
| 91 | + | ||
| 92 | + | // parseTopic reads "home", "owner" or "repo:<name>"; repo is "" for home and | |
| 93 | + | // owner. | |
| 88 | 94 | func parseTopic(topic string) (repo string, ok bool) { | |
| 89 | - | if topic == "home" { | |
| 95 | + | if topic == "home" || topic == ownerTopic { | |
| 90 | 96 | return "", true | |
| 91 | 97 | } | |
| 92 | 98 | if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" { | |
| @@ -95,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) { | |||
| 95 | 101 | return "", false | |
| 96 | 102 | } | |
| 97 | 103 | ||
| 104 | + | // ownerStreamOK tells whether the request may follow the owner topic: the | |
| 105 | + | // signed-in owner, with the inbox view and a valid repository filter, on a | |
| 106 | + | // server that has the inbox. | |
| 107 | + | func (s *Server) ownerStreamOK(ctx context.Context, q url.Values) bool { | |
| 108 | + | if s.cfg.Inbox == nil || !SignedIn(ctx) || q.Get("view") != "inbox" { | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | if f := q.Get("repo"); f != "" { | |
| 112 | + | if _, err := domain.ParseRepoName(f); err != nil { | |
| 113 | + | return false | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return true | |
| 117 | + | } | |
| 118 | + | ||
| 98 | 119 | // events is GET /-/events. | |
| 99 | 120 | func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |
| 100 | 121 | if s.cfg.Events == nil { | |
| @@ -104,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 104 | 125 | q := r.URL.Query() | |
| 105 | 126 | topic := q.Get("topic") | |
| 106 | 127 | repo, ok := parseTopic(topic) | |
| 128 | + | if topic == ownerTopic { | |
| 129 | + | // Anyone but the signed-in owner gets the answer of an unknown topic. | |
| 130 | + | ok = s.ownerStreamOK(r.Context(), q) | |
| 131 | + | repo = "" | |
| 132 | + | } | |
| 107 | 133 | if !ok { | |
| 108 | 134 | denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>") | |
| 109 | 135 | return | |
| @@ -227,6 +253,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 227 | 253 | } | |
| 228 | 254 | ||
| 229 | 255 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha} | |
| 256 | + | if topic == ownerTopic { | |
| 257 | + | st.filter = q.Get("repo") | |
| 258 | + | } | |
| 230 | 259 | handle := func(m recvd) bool { | |
| 231 | 260 | if m.err != nil { | |
| 232 | 261 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | |
| @@ -27,6 +27,7 @@ import ( | |||||
| 27 | // the HTML fragments the page shows, sent as Server-Sent Events: | 27 | // the HTML fragments the page shows, sent as Server-Sent Events: | ||
| 28 | // | 28 | // | ||
| 29 | // GET /-/events?topic=home | 29 | // GET /-/events?topic=home | ||
| 30 | + | // GET /-/events?topic=owner&view=inbox[&repo=<name>] (the signed-in owner only) | |||
| 30 | // GET /-/events?topic=repo:<name>&view=summary|refs | 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | ||
| 31 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | ||
| 32 | // GET /-/events?topic=repo:<name>&view=mrs | 33 | // GET /-/events?topic=repo:<name>&view=mrs | ||
| @@ -84,9 +85,14 @@ const ( | |||||
| 84 | retryAfter = 10 | 85 | retryAfter = 10 | ||
| 85 | ) | 86 | ) | ||
| 86 | 87 | ||||
| 87 | - | // parseTopic reads "home" or "repo:<name>"; repo is "" for home. | 88 | + | // ownerTopic is the stream of the inbox: the merge requests and issues of every |
| 89 | + | // repository, for the owner. | |||
| 90 | + | const ownerTopic = "owner" | |||
| 91 | + | ||||
| 92 | + | // parseTopic reads "home", "owner" or "repo:<name>"; repo is "" for home and | |||
| 93 | + | // owner. | |||
| 88 | func parseTopic(topic string) (repo string, ok bool) { | 94 | func parseTopic(topic string) (repo string, ok bool) { | ||
| 89 | - | if topic == "home" { | 95 | + | if topic == "home" || topic == ownerTopic { |
| 90 | return "", true | 96 | return "", true | ||
| 91 | } | 97 | } | ||
| 92 | if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" { | 98 | if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" { | ||
| @@ -95,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) { | |||||
| 95 | return "", false | 101 | return "", false | ||
| 96 | } | 102 | } | ||
| 97 | 103 | ||||
| 104 | + | // ownerStreamOK tells whether the request may follow the owner topic: the | |||
| 105 | + | // signed-in owner, with the inbox view and a valid repository filter, on a | |||
| 106 | + | // server that has the inbox. | |||
| 107 | + | func (s *Server) ownerStreamOK(ctx context.Context, q url.Values) bool { | |||
| 108 | + | if s.cfg.Inbox == nil || !SignedIn(ctx) || q.Get("view") != "inbox" { | |||
| 109 | + | return false | |||
| 110 | + | } | |||
| 111 | + | if f := q.Get("repo"); f != "" { | |||
| 112 | + | if _, err := domain.ParseRepoName(f); err != nil { | |||
| 113 | + | return false | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | return true | |||
| 117 | + | } | |||
| 118 | + | ||||
| 98 | // events is GET /-/events. | 119 | // events is GET /-/events. | ||
| 99 | func (s *Server) events(w http.ResponseWriter, r *http.Request) { | 120 | func (s *Server) events(w http.ResponseWriter, r *http.Request) { | ||
| 100 | if s.cfg.Events == nil { | 121 | if s.cfg.Events == nil { | ||
| @@ -104,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 104 | q := r.URL.Query() | 125 | q := r.URL.Query() | ||
| 105 | topic := q.Get("topic") | 126 | topic := q.Get("topic") | ||
| 106 | repo, ok := parseTopic(topic) | 127 | repo, ok := parseTopic(topic) | ||
| 128 | + | if topic == ownerTopic { | |||
| 129 | + | // Anyone but the signed-in owner gets the answer of an unknown topic. | |||
| 130 | + | ok = s.ownerStreamOK(r.Context(), q) | |||
| 131 | + | repo = "" | |||
| 132 | + | } | |||
| 107 | if !ok { | 133 | if !ok { | ||
| 108 | denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>") | 134 | denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>") | ||
| 109 | return | 135 | return | ||
| @@ -227,6 +253,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 227 | } | 253 | } | ||
| 228 | 254 | ||||
| 229 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha} | 255 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha} | ||
| 256 | + | if topic == ownerTopic { | |||
| 257 | + | st.filter = q.Get("repo") | |||
| 258 | + | } | |||
| 230 | handle := func(m recvd) bool { | 259 | handle := func(m recvd) bool { | ||
| 231 | if m.err != nil { | 260 | if m.err != nil { | ||
| 232 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | 261 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | ||
M internal/adapters/web/events_test.go +1 -1
| @@ -36,7 +36,7 @@ func TestFormatEvent(t *testing.T) { | |||
| 36 | 36 | ||
| 37 | 37 | func TestParseTopic(t *testing.T) { | |
| 38 | 38 | t.Parallel() | |
| 39 | - | for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | |
| 39 | + | for in, want := range map[string]string{"home": "", "owner": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | |
| 40 | 40 | repo, ok := parseTopic(in) | |
| 41 | 41 | assert.True(t, ok, in) | |
| 42 | 42 | assert.Equal(t, want, repo, in) | |
| @@ -36,7 +36,7 @@ func TestFormatEvent(t *testing.T) { | |||||
| 36 | 36 | ||||
| 37 | func TestParseTopic(t *testing.T) { | 37 | func TestParseTopic(t *testing.T) { | ||
| 38 | t.Parallel() | 38 | t.Parallel() | ||
| 39 | - | for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | 39 | + | for in, want := range map[string]string{"home": "", "owner": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { |
| 40 | repo, ok := parseTopic(in) | 40 | repo, ok := parseTopic(in) | ||
| 41 | assert.True(t, ok, in) | 41 | assert.True(t, ok, in) | ||
| 42 | assert.Equal(t, want, repo, in) | 42 | assert.Equal(t, want, repo, in) | ||
M internal/adapters/web/fragments.go +10 -5
| @@ -21,11 +21,13 @@ import ( | |||
| 21 | 21 | type liveStream struct { | |
| 22 | 22 | s *Server | |
| 23 | 23 | repo string // "" on the home page | |
| 24 | - | view string // "summary", "refs" or "commits" | |
| 25 | - | ref string // commits: the branch listed | |
| 26 | - | head string // commits: the commit listed first | |
| 27 | - | n string // mr: the number of the merge request shown; run: the run id | |
| 28 | - | job string // job: the id of the job shown | |
| 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 | |
| 29 | 31 | // sha is the commit of the run of the job shown, read once when the stream | |
| 30 | 32 | // is set up: the annotations link to files at it, and it never changes. | |
| 31 | 33 | sha string | |
| @@ -75,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen | |||
| 75 | 77 | if we.Event == nil { | |
| 76 | 78 | return nil, nil | |
| 77 | 79 | } | |
| 80 | + | if l.view == "inbox" { | |
| 81 | + | return l.inbox(ctx, we) | |
| 82 | + | } | |
| 78 | 83 | if l.repo == "" { | |
| 79 | 84 | return l.home(ctx, we) | |
| 80 | 85 | } | |
| @@ -21,11 +21,13 @@ import ( | |||||
| 21 | type liveStream struct { | 21 | type liveStream struct { | ||
| 22 | s *Server | 22 | s *Server | ||
| 23 | repo string // "" on the home page | 23 | repo string // "" on the home page | ||
| 24 | - | view string // "summary", "refs" or "commits" | 24 | + | // filter is the repository the inbox is filtered on, "" for all. |
| 25 | - | ref string // commits: the branch listed | 25 | + | filter string |
| 26 | - | head string // commits: the commit listed first | 26 | + | view string // "summary", "refs" or "commits" |
| 27 | - | n string // mr: the number of the merge request shown; run: the run id | 27 | + | ref string // commits: the branch listed |
| 28 | - | job string // job: the id of the job shown | 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 | |||
| 29 | // sha is the commit of the run of the job shown, read once when the stream | 31 | // sha is the commit of the run of the job shown, read once when the stream | ||
| 30 | // is set up: the annotations link to files at it, and it never changes. | 32 | // is set up: the annotations link to files at it, and it never changes. | ||
| 31 | sha string | 33 | sha string | ||
| @@ -75,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen | |||||
| 75 | if we.Event == nil { | 77 | if we.Event == nil { | ||
| 76 | return nil, nil | 78 | return nil, nil | ||
| 77 | } | 79 | } | ||
| 80 | + | if l.view == "inbox" { | |||
| 81 | + | return l.inbox(ctx, we) | |||
| 82 | + | } | |||
| 78 | if l.repo == "" { | 83 | if l.repo == "" { | ||
| 79 | return l.home(ctx, we) | 84 | return l.home(ctx, we) | ||
| 80 | } | 85 | } | ||
M internal/adapters/web/fragments.templ +1 -1
| @@ -102,7 +102,7 @@ templ Live(l Layout) { | |||
| 102 | 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 103 | 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 104 | 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 105 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 105 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 106 | 106 | gap -> ermiteLive.reload()" | |
| 107 | 107 | ></span> | |
| 108 | 108 | } | |
| @@ -102,7 +102,7 @@ templ Live(l Layout) { | |||||
| 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 105 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | 105 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 106 | gap -> ermiteLive.reload()" | 106 | gap -> ermiteLive.reload()" | ||
| 107 | ></span> | 107 | ></span> | ||
| 108 | } | 108 | } | ||
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/home.go +3 -0
| @@ -121,6 +121,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 121 | 121 | p.Sorts = append(p.Sorts, sl) | |
| 122 | 122 | } | |
| 123 | 123 | p.Rows = s.homeRows(ctx, repos, now) | |
| 124 | + | if n, show := s.inboxCount(ctx); show { | |
| 125 | + | p.Inbox = &InboxButton{Href: inboxPath, Count: n} | |
| 126 | + | } | |
| 124 | 127 | ||
| 125 | 128 | l := s.base() | |
| 126 | 129 | l.Title = s.instance | |
| @@ -121,6 +121,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 121 | p.Sorts = append(p.Sorts, sl) | 121 | p.Sorts = append(p.Sorts, sl) | ||
| 122 | } | 122 | } | ||
| 123 | p.Rows = s.homeRows(ctx, repos, now) | 123 | p.Rows = s.homeRows(ctx, repos, now) | ||
| 124 | + | if n, show := s.inboxCount(ctx); show { | |||
| 125 | + | p.Inbox = &InboxButton{Href: inboxPath, Count: n} | |||
| 126 | + | } | |||
| 124 | 127 | ||||
| 125 | l := s.base() | 128 | l := s.base() | ||
| 126 | l.Title = s.instance | 129 | l.Title = s.instance | ||
A internal/adapters/web/inbox.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/url" | |
| 9 | + | "strconv" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/a-h/templ" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and | |
| 19 | + | // issues of every repository, newest activity first, with a filter by | |
| 20 | + | // repository (?repo=<name>, a plain link, so it works without scripts). Only a | |
| 21 | + | // signed-in owner sees it: for anyone else the path is as missing as any other. | |
| 22 | + | // It follows the stream "owner" with view=inbox: each event of a merge request | |
| 23 | + | // or an issue sends the counters, the two lists and the filter again. | |
| 24 | + | ||
| 25 | + | // inboxPath is the page of the inbox. | |
| 26 | + | const inboxPath = "/-/inbox" | |
| 27 | + | ||
| 28 | + | // inboxEvent is the name of the live events of the inbox. | |
| 29 | + | const inboxEvent = "inbox" | |
| 30 | + | ||
| 31 | + | // InboxRow is one open item of the inbox. | |
| 32 | + | type InboxRow struct { | |
| 33 | + | Repo string | |
| 34 | + | RepoHref string | |
| 35 | + | Number string | |
| 36 | + | Href string | |
| 37 | + | Title string | |
| 38 | + | When string | |
| 39 | + | WhenAt string | |
| 40 | + | Cursor bool | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // InboxRepoLink is an entry of the repository filter. | |
| 44 | + | type InboxRepoLink struct { | |
| 45 | + | Label string | |
| 46 | + | Href string | |
| 47 | + | Current bool | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // InboxPage is the inbox: the counters, the filter and the two lists. | |
| 51 | + | type InboxPage struct { | |
| 52 | + | // MergeRequests and Issues count every open item, whatever the filter. | |
| 53 | + | MergeRequests, Issues int | |
| 54 | + | Filter string // the repository filtered on, "" for all | |
| 55 | + | Repos []InboxRepoLink | |
| 56 | + | MRs []InboxRow | |
| 57 | + | IssueRows []InboxRow | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // inboxHref is the inbox page, filtered on repo when it is not "". | |
| 61 | + | func inboxHref(repo string) string { | |
| 62 | + | if repo == "" { | |
| 63 | + | return inboxPath | |
| 64 | + | } | |
| 65 | + | return inboxPath + "?repo=" + url.QueryEscape(repo) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // inboxFilter reads ?repo=. A name that is not valid is a page that is not | |
| 69 | + | // there. | |
| 70 | + | func inboxFilter(r *http.Request) (domain.RepoName, error) { | |
| 71 | + | raw := r.URL.Query().Get("repo") | |
| 72 | + | if raw == "" { | |
| 73 | + | return domain.RepoName{}, nil | |
| 74 | + | } | |
| 75 | + | name, err := domain.ParseRepoName(raw) | |
| 76 | + | if err != nil { | |
| 77 | + | return domain.RepoName{}, errNotFound | |
| 78 | + | } | |
| 79 | + | return name, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // inboxPage builds the page from the overview. | |
| 83 | + | func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage { | |
| 84 | + | now := s.cfg.Now() | |
| 85 | + | p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, Filter: filter.String()} | |
| 86 | + | row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow { | |
| 87 | + | ts := updated | |
| 88 | + | if ts.IsZero() { | |
| 89 | + | ts = created | |
| 90 | + | } | |
| 91 | + | r := InboxRow{ | |
| 92 | + | Repo: repo.String(), RepoHref: repoHref(repo.String()), | |
| 93 | + | Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title), | |
| 94 | + | } | |
| 95 | + | r.When, r.WhenAt = whenOf(now, ts) | |
| 96 | + | return r | |
| 97 | + | } | |
| 98 | + | for _, m := range ov.MergeRequests { | |
| 99 | + | p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt)) | |
| 100 | + | } | |
| 101 | + | for _, i := range ov.Issues { | |
| 102 | + | p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt)) | |
| 103 | + | } | |
| 104 | + | switch { | |
| 105 | + | case len(p.MRs) > 0: | |
| 106 | + | p.MRs[0].Cursor = true | |
| 107 | + | case len(p.IssueRows) > 0: | |
| 108 | + | p.IssueRows[0].Cursor = true | |
| 109 | + | } | |
| 110 | + | p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()}) | |
| 111 | + | for _, r := range ov.Repos { | |
| 112 | + | p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter}) | |
| 113 | + | } | |
| 114 | + | return p | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // inbox is GET /-/inbox. | |
| 118 | + | func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 119 | + | if !SignedIn(ctx) { | |
| 120 | + | return nil, 0, errNotFound | |
| 121 | + | } | |
| 122 | + | filter, err := inboxFilter(r) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, 0, err | |
| 125 | + | } | |
| 126 | + | ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter) | |
| 127 | + | if err != nil { | |
| 128 | + | return nil, 0, err | |
| 129 | + | } | |
| 130 | + | l := s.base() | |
| 131 | + | l.Title = "inbox · " + s.instance | |
| 132 | + | l.Crumbs = []Crumb{{Label: "inbox", Current: true}} | |
| 133 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |
| 134 | + | if s.cfg.Events != nil { | |
| 135 | + | q := "view=inbox" | |
| 136 | + | if !filter.IsZero() { | |
| 137 | + | q += "&repo=" + url.QueryEscape(filter.String()) | |
| 138 | + | } | |
| 139 | + | l.Live, l.LiveURL = true, liveURL("owner", q) | |
| 140 | + | } | |
| 141 | + | return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // inboxCount is the number of open items the home button shows, and whether | |
| 145 | + | // there is a button: only for the signed-in owner, and only when the inbox is | |
| 146 | + | // there. A failed read leaves the button without a number. | |
| 147 | + | func (s *Server) inboxCount(ctx context.Context) (n int, show bool) { | |
| 148 | + | if s.cfg.Inbox == nil || !SignedIn(ctx) { | |
| 149 | + | return 0, false | |
| 150 | + | } | |
| 151 | + | ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{}) | |
| 152 | + | if err != nil { | |
| 153 | + | return -1, true | |
| 154 | + | } | |
| 155 | + | return ov.Counts.MergeRequests + ov.Counts.Issues, true | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // inbox renders the fragments an event of a merge request or an issue changes | |
| 159 | + | // on the inbox page: the counters, the filter and the two lists. | |
| 160 | + | func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 161 | + | switch we.Event.(type) { | |
| 162 | + | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, | |
| 163 | + | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented: | |
| 164 | + | default: | |
| 165 | + | return nil, nil | |
| 166 | + | } | |
| 167 | + | if l.s.cfg.Inbox == nil { | |
| 168 | + | return nil, nil | |
| 169 | + | } | |
| 170 | + | var filter domain.RepoName | |
| 171 | + | if l.filter != "" { | |
| 172 | + | filter, _ = domain.ParseRepoName(l.filter) | |
| 173 | + | } | |
| 174 | + | ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter) | |
| 175 | + | if err != nil { | |
| 176 | + | return nil, err | |
| 177 | + | } | |
| 178 | + | p := l.s.inboxPage(ov, filter) | |
| 179 | + | var out []fragment | |
| 180 | + | for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxMRs(p), InboxIssues(p)} { | |
| 181 | + | f, err := renderFragment(ctx, inboxEvent, c) | |
| 182 | + | if err != nil { | |
| 183 | + | return nil, err | |
| 184 | + | } | |
| 185 | + | out = append(out, f) | |
| 186 | + | } | |
| 187 | + | return out, nil | |
| 188 | + | } | |
| @@ -0,0 +1,188 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/url" | |||
| 9 | + | "strconv" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/a-h/templ" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and | |||
| 19 | + | // issues of every repository, newest activity first, with a filter by | |||
| 20 | + | // repository (?repo=<name>, a plain link, so it works without scripts). Only a | |||
| 21 | + | // signed-in owner sees it: for anyone else the path is as missing as any other. | |||
| 22 | + | // It follows the stream "owner" with view=inbox: each event of a merge request | |||
| 23 | + | // or an issue sends the counters, the two lists and the filter again. | |||
| 24 | + | ||||
| 25 | + | // inboxPath is the page of the inbox. | |||
| 26 | + | const inboxPath = "/-/inbox" | |||
| 27 | + | ||||
| 28 | + | // inboxEvent is the name of the live events of the inbox. | |||
| 29 | + | const inboxEvent = "inbox" | |||
| 30 | + | ||||
| 31 | + | // InboxRow is one open item of the inbox. | |||
| 32 | + | type InboxRow struct { | |||
| 33 | + | Repo string | |||
| 34 | + | RepoHref string | |||
| 35 | + | Number string | |||
| 36 | + | Href string | |||
| 37 | + | Title string | |||
| 38 | + | When string | |||
| 39 | + | WhenAt string | |||
| 40 | + | Cursor bool | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // InboxRepoLink is an entry of the repository filter. | |||
| 44 | + | type InboxRepoLink struct { | |||
| 45 | + | Label string | |||
| 46 | + | Href string | |||
| 47 | + | Current bool | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // InboxPage is the inbox: the counters, the filter and the two lists. | |||
| 51 | + | type InboxPage struct { | |||
| 52 | + | // MergeRequests and Issues count every open item, whatever the filter. | |||
| 53 | + | MergeRequests, Issues int | |||
| 54 | + | Filter string // the repository filtered on, "" for all | |||
| 55 | + | Repos []InboxRepoLink | |||
| 56 | + | MRs []InboxRow | |||
| 57 | + | IssueRows []InboxRow | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // inboxHref is the inbox page, filtered on repo when it is not "". | |||
| 61 | + | func inboxHref(repo string) string { | |||
| 62 | + | if repo == "" { | |||
| 63 | + | return inboxPath | |||
| 64 | + | } | |||
| 65 | + | return inboxPath + "?repo=" + url.QueryEscape(repo) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // inboxFilter reads ?repo=. A name that is not valid is a page that is not | |||
| 69 | + | // there. | |||
| 70 | + | func inboxFilter(r *http.Request) (domain.RepoName, error) { | |||
| 71 | + | raw := r.URL.Query().Get("repo") | |||
| 72 | + | if raw == "" { | |||
| 73 | + | return domain.RepoName{}, nil | |||
| 74 | + | } | |||
| 75 | + | name, err := domain.ParseRepoName(raw) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return domain.RepoName{}, errNotFound | |||
| 78 | + | } | |||
| 79 | + | return name, nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // inboxPage builds the page from the overview. | |||
| 83 | + | func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage { | |||
| 84 | + | now := s.cfg.Now() | |||
| 85 | + | p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, Filter: filter.String()} | |||
| 86 | + | row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow { | |||
| 87 | + | ts := updated | |||
| 88 | + | if ts.IsZero() { | |||
| 89 | + | ts = created | |||
| 90 | + | } | |||
| 91 | + | r := InboxRow{ | |||
| 92 | + | Repo: repo.String(), RepoHref: repoHref(repo.String()), | |||
| 93 | + | Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title), | |||
| 94 | + | } | |||
| 95 | + | r.When, r.WhenAt = whenOf(now, ts) | |||
| 96 | + | return r | |||
| 97 | + | } | |||
| 98 | + | for _, m := range ov.MergeRequests { | |||
| 99 | + | p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt)) | |||
| 100 | + | } | |||
| 101 | + | for _, i := range ov.Issues { | |||
| 102 | + | p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt)) | |||
| 103 | + | } | |||
| 104 | + | switch { | |||
| 105 | + | case len(p.MRs) > 0: | |||
| 106 | + | p.MRs[0].Cursor = true | |||
| 107 | + | case len(p.IssueRows) > 0: | |||
| 108 | + | p.IssueRows[0].Cursor = true | |||
| 109 | + | } | |||
| 110 | + | p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()}) | |||
| 111 | + | for _, r := range ov.Repos { | |||
| 112 | + | p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter}) | |||
| 113 | + | } | |||
| 114 | + | return p | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // inbox is GET /-/inbox. | |||
| 118 | + | func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 119 | + | if !SignedIn(ctx) { | |||
| 120 | + | return nil, 0, errNotFound | |||
| 121 | + | } | |||
| 122 | + | filter, err := inboxFilter(r) | |||
| 123 | + | if err != nil { | |||
| 124 | + | return nil, 0, err | |||
| 125 | + | } | |||
| 126 | + | ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter) | |||
| 127 | + | if err != nil { | |||
| 128 | + | return nil, 0, err | |||
| 129 | + | } | |||
| 130 | + | l := s.base() | |||
| 131 | + | l.Title = "inbox · " + s.instance | |||
| 132 | + | l.Crumbs = []Crumb{{Label: "inbox", Current: true}} | |||
| 133 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |||
| 134 | + | if s.cfg.Events != nil { | |||
| 135 | + | q := "view=inbox" | |||
| 136 | + | if !filter.IsZero() { | |||
| 137 | + | q += "&repo=" + url.QueryEscape(filter.String()) | |||
| 138 | + | } | |||
| 139 | + | l.Live, l.LiveURL = true, liveURL("owner", q) | |||
| 140 | + | } | |||
| 141 | + | return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // inboxCount is the number of open items the home button shows, and whether | |||
| 145 | + | // there is a button: only for the signed-in owner, and only when the inbox is | |||
| 146 | + | // there. A failed read leaves the button without a number. | |||
| 147 | + | func (s *Server) inboxCount(ctx context.Context) (n int, show bool) { | |||
| 148 | + | if s.cfg.Inbox == nil || !SignedIn(ctx) { | |||
| 149 | + | return 0, false | |||
| 150 | + | } | |||
| 151 | + | ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{}) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return -1, true | |||
| 154 | + | } | |||
| 155 | + | return ov.Counts.MergeRequests + ov.Counts.Issues, true | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // inbox renders the fragments an event of a merge request or an issue changes | |||
| 159 | + | // on the inbox page: the counters, the filter and the two lists. | |||
| 160 | + | func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |||
| 161 | + | switch we.Event.(type) { | |||
| 162 | + | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, | |||
| 163 | + | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented: | |||
| 164 | + | default: | |||
| 165 | + | return nil, nil | |||
| 166 | + | } | |||
| 167 | + | if l.s.cfg.Inbox == nil { | |||
| 168 | + | return nil, nil | |||
| 169 | + | } | |||
| 170 | + | var filter domain.RepoName | |||
| 171 | + | if l.filter != "" { | |||
| 172 | + | filter, _ = domain.ParseRepoName(l.filter) | |||
| 173 | + | } | |||
| 174 | + | ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter) | |||
| 175 | + | if err != nil { | |||
| 176 | + | return nil, err | |||
| 177 | + | } | |||
| 178 | + | p := l.s.inboxPage(ov, filter) | |||
| 179 | + | var out []fragment | |||
| 180 | + | for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxMRs(p), InboxIssues(p)} { | |||
| 181 | + | f, err := renderFragment(ctx, inboxEvent, c) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return nil, err | |||
| 184 | + | } | |||
| 185 | + | out = append(out, f) | |||
| 186 | + | } | |||
| 187 | + | return out, nil | |||
| 188 | + | } | |||
A internal/adapters/web/inbox.templ +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Inbox is the owner's inbox: what is open across every repository. The | |
| 6 | + | // counters, the filter and the lists are fragments with an id that the live | |
| 7 | + | // stream replaces as a whole (ermiteLive.swap). | |
| 8 | + | templ Inbox(l Layout, p InboxPage) { | |
| 9 | + | @Shell(l, nil) { | |
| 10 | + | <h1 class="sr">Inbox</h1> | |
| 11 | + | <div class="inbox"> | |
| 12 | + | @InboxCounters(p) | |
| 13 | + | @InboxRepos(p) | |
| 14 | + | @InboxMRs(p) | |
| 15 | + | @InboxIssues(p) | |
| 16 | + | </div> | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | templ InboxCounters(p InboxPage) { | |
| 21 | + | <p id="inbox-counts" class="inbox-counts"> | |
| 22 | + | <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues | |
| 23 | + | </p> | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | templ InboxRepos(p InboxPage) { | |
| 27 | + | <nav id="inbox-repos" class="since" aria-label="Repository"> | |
| 28 | + | for _, r := range p.Repos { | |
| 29 | + | <a | |
| 30 | + | href={ templ.SafeURL(r.Href) } | |
| 31 | + | if r.Current { | |
| 32 | + | aria-current="page" | |
| 33 | + | } | |
| 34 | + | >{ r.Label }</a> | |
| 35 | + | } | |
| 36 | + | </nav> | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | templ InboxMRs(p InboxPage) { | |
| 40 | + | <section id="inbox-mrs" aria-labelledby="inbox-mrs-h"> | |
| 41 | + | <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2> | |
| 42 | + | @inboxRows(p.MRs, "Open merge requests") | |
| 43 | + | if len(p.MRs) == 0 { | |
| 44 | + | <p class="lede dim">no open merge requests.</p> | |
| 45 | + | } | |
| 46 | + | </section> | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | templ InboxIssues(p InboxPage) { | |
| 50 | + | <section id="inbox-issues" aria-labelledby="inbox-issues-h"> | |
| 51 | + | <h2 id="inbox-issues-h" class="rule-h"><span>Open issues</span><span class="count">{ itoa(len(p.IssueRows)) }</span></h2> | |
| 52 | + | @inboxRows(p.IssueRows, "Open issues") | |
| 53 | + | if len(p.IssueRows) == 0 { | |
| 54 | + | <p class="lede dim">no open issues.</p> | |
| 55 | + | } | |
| 56 | + | </section> | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | templ inboxRows(rows []InboxRow, label string) { | |
| 60 | + | if len(rows) > 0 { | |
| 61 | + | <table class="list mrs" aria-label={ label }> | |
| 62 | + | <tbody data-rows> | |
| 63 | + | for _, m := range rows { | |
| 64 | + | <tr data-cursor?={ m.Cursor } data-state="open"> | |
| 65 | + | <td class="c-n mute">{ m.Number }</td> | |
| 66 | + | <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <a class="mute" href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a></td> | |
| 67 | + | <td class="c-when nowrap dim"> | |
| 68 | + | if m.WhenAt != "" { | |
| 69 | + | <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time> | |
| 70 | + | } | |
| 71 | + | </td> | |
| 72 | + | </tr> | |
| 73 | + | } | |
| 74 | + | </tbody> | |
| 75 | + | </table> | |
| 76 | + | } | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // Inbox is the owner's inbox: what is open across every repository. The | |||
| 6 | + | // counters, the filter and the lists are fragments with an id that the live | |||
| 7 | + | // stream replaces as a whole (ermiteLive.swap). | |||
| 8 | + | templ Inbox(l Layout, p InboxPage) { | |||
| 9 | + | @Shell(l, nil) { | |||
| 10 | + | <h1 class="sr">Inbox</h1> | |||
| 11 | + | <div class="inbox"> | |||
| 12 | + | @InboxCounters(p) | |||
| 13 | + | @InboxRepos(p) | |||
| 14 | + | @InboxMRs(p) | |||
| 15 | + | @InboxIssues(p) | |||
| 16 | + | </div> | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | templ InboxCounters(p InboxPage) { | |||
| 21 | + | <p id="inbox-counts" class="inbox-counts"> | |||
| 22 | + | <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues | |||
| 23 | + | </p> | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | templ InboxRepos(p InboxPage) { | |||
| 27 | + | <nav id="inbox-repos" class="since" aria-label="Repository"> | |||
| 28 | + | for _, r := range p.Repos { | |||
| 29 | + | <a | |||
| 30 | + | href={ templ.SafeURL(r.Href) } | |||
| 31 | + | if r.Current { | |||
| 32 | + | aria-current="page" | |||
| 33 | + | } | |||
| 34 | + | >{ r.Label }</a> | |||
| 35 | + | } | |||
| 36 | + | </nav> | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | templ InboxMRs(p InboxPage) { | |||
| 40 | + | <section id="inbox-mrs" aria-labelledby="inbox-mrs-h"> | |||
| 41 | + | <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2> | |||
| 42 | + | @inboxRows(p.MRs, "Open merge requests") | |||
| 43 | + | if len(p.MRs) == 0 { | |||
| 44 | + | <p class="lede dim">no open merge requests.</p> | |||
| 45 | + | } | |||
| 46 | + | </section> | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | templ InboxIssues(p InboxPage) { | |||
| 50 | + | <section id="inbox-issues" aria-labelledby="inbox-issues-h"> | |||
| 51 | + | <h2 id="inbox-issues-h" class="rule-h"><span>Open issues</span><span class="count">{ itoa(len(p.IssueRows)) }</span></h2> | |||
| 52 | + | @inboxRows(p.IssueRows, "Open issues") | |||
| 53 | + | if len(p.IssueRows) == 0 { | |||
| 54 | + | <p class="lede dim">no open issues.</p> | |||
| 55 | + | } | |||
| 56 | + | </section> | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | templ inboxRows(rows []InboxRow, label string) { | |||
| 60 | + | if len(rows) > 0 { | |||
| 61 | + | <table class="list mrs" aria-label={ label }> | |||
| 62 | + | <tbody data-rows> | |||
| 63 | + | for _, m := range rows { | |||
| 64 | + | <tr data-cursor?={ m.Cursor } data-state="open"> | |||
| 65 | + | <td class="c-n mute">{ m.Number }</td> | |||
| 66 | + | <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <a class="mute" href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a></td> | |||
| 67 | + | <td class="c-when nowrap dim"> | |||
| 68 | + | if m.WhenAt != "" { | |||
| 69 | + | <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time> | |||
| 70 | + | } | |||
| 71 | + | </td> | |||
| 72 | + | </tr> | |||
| 73 | + | } | |||
| 74 | + | </tbody> | |||
| 75 | + | </table> | |||
| 76 | + | } | |||
| 77 | + | } | |||
A internal/adapters/web/inbox_templ.go +503 -0
| @@ -0,0 +1,503 @@ | |||
| 1 | + | // Code generated by templ - DO NOT EDIT. | |
| 2 | + | ||
| 3 | + | // templ: version: v0.3.1020 | |
| 4 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 5 | + | ||
| 6 | + | package web | |
| 7 | + | ||
| 8 | + | //lint:file-ignore SA4006 This context is only used if a nested component is present. | |
| 9 | + | ||
| 10 | + | import "github.com/a-h/templ" | |
| 11 | + | import templruntime "github.com/a-h/templ/runtime" | |
| 12 | + | ||
| 13 | + | // Inbox is the owner's inbox: what is open across every repository. The | |
| 14 | + | // counters, the filter and the lists are fragments with an id that the live | |
| 15 | + | // stream replaces as a whole (ermiteLive.swap). | |
| 16 | + | func Inbox(l Layout, p InboxPage) templ.Component { | |
| 17 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 18 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 19 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 20 | + | return templ_7745c5c3_CtxErr | |
| 21 | + | } | |
| 22 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 23 | + | if !templ_7745c5c3_IsBuffer { | |
| 24 | + | defer func() { | |
| 25 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 26 | + | if templ_7745c5c3_Err == nil { | |
| 27 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 28 | + | } | |
| 29 | + | }() | |
| 30 | + | } | |
| 31 | + | ctx = templ.InitializeContext(ctx) | |
| 32 | + | templ_7745c5c3_Var1 := templ.GetChildren(ctx) | |
| 33 | + | if templ_7745c5c3_Var1 == nil { | |
| 34 | + | templ_7745c5c3_Var1 = templ.NopComponent | |
| 35 | + | } | |
| 36 | + | ctx = templ.ClearChildren(ctx) | |
| 37 | + | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 38 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 39 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 40 | + | if !templ_7745c5c3_IsBuffer { | |
| 41 | + | defer func() { | |
| 42 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 43 | + | if templ_7745c5c3_Err == nil { | |
| 44 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 45 | + | } | |
| 46 | + | }() | |
| 47 | + | } | |
| 48 | + | ctx = templ.InitializeContext(ctx) | |
| 49 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Inbox</h1><div class=\"inbox\">") | |
| 50 | + | if templ_7745c5c3_Err != nil { | |
| 51 | + | return templ_7745c5c3_Err | |
| 52 | + | } | |
| 53 | + | templ_7745c5c3_Err = InboxCounters(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 54 | + | if templ_7745c5c3_Err != nil { | |
| 55 | + | return templ_7745c5c3_Err | |
| 56 | + | } | |
| 57 | + | templ_7745c5c3_Err = InboxRepos(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 58 | + | if templ_7745c5c3_Err != nil { | |
| 59 | + | return templ_7745c5c3_Err | |
| 60 | + | } | |
| 61 | + | templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 62 | + | if templ_7745c5c3_Err != nil { | |
| 63 | + | return templ_7745c5c3_Err | |
| 64 | + | } | |
| 65 | + | templ_7745c5c3_Err = InboxIssues(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 66 | + | if templ_7745c5c3_Err != nil { | |
| 67 | + | return templ_7745c5c3_Err | |
| 68 | + | } | |
| 69 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>") | |
| 70 | + | if templ_7745c5c3_Err != nil { | |
| 71 | + | return templ_7745c5c3_Err | |
| 72 | + | } | |
| 73 | + | return nil | |
| 74 | + | }) | |
| 75 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) | |
| 76 | + | if templ_7745c5c3_Err != nil { | |
| 77 | + | return templ_7745c5c3_Err | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func InboxCounters(p InboxPage) templ.Component { | |
| 84 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 85 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 86 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 87 | + | return templ_7745c5c3_CtxErr | |
| 88 | + | } | |
| 89 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 90 | + | if !templ_7745c5c3_IsBuffer { | |
| 91 | + | defer func() { | |
| 92 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 93 | + | if templ_7745c5c3_Err == nil { | |
| 94 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 95 | + | } | |
| 96 | + | }() | |
| 97 | + | } | |
| 98 | + | ctx = templ.InitializeContext(ctx) | |
| 99 | + | templ_7745c5c3_Var3 := templ.GetChildren(ctx) | |
| 100 | + | if templ_7745c5c3_Var3 == nil { | |
| 101 | + | templ_7745c5c3_Var3 = templ.NopComponent | |
| 102 | + | } | |
| 103 | + | ctx = templ.ClearChildren(ctx) | |
| 104 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p id=\"inbox-counts\" class=\"inbox-counts\"><strong>") | |
| 105 | + | if templ_7745c5c3_Err != nil { | |
| 106 | + | return templ_7745c5c3_Err | |
| 107 | + | } | |
| 108 | + | var templ_7745c5c3_Var4 string | |
| 109 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests)) | |
| 110 | + | if templ_7745c5c3_Err != nil { | |
| 111 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 33} | |
| 112 | + | } | |
| 113 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 114 | + | if templ_7745c5c3_Err != nil { | |
| 115 | + | return templ_7745c5c3_Err | |
| 116 | + | } | |
| 117 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong> merge requests · <strong>") | |
| 118 | + | if templ_7745c5c3_Err != nil { | |
| 119 | + | return templ_7745c5c3_Err | |
| 120 | + | } | |
| 121 | + | var templ_7745c5c3_Var5 string | |
| 122 | + | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues)) | |
| 123 | + | if templ_7745c5c3_Err != nil { | |
| 124 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 87} | |
| 125 | + | } | |
| 126 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |
| 127 | + | if templ_7745c5c3_Err != nil { | |
| 128 | + | return templ_7745c5c3_Err | |
| 129 | + | } | |
| 130 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues</p>") | |
| 131 | + | if templ_7745c5c3_Err != nil { | |
| 132 | + | return templ_7745c5c3_Err | |
| 133 | + | } | |
| 134 | + | return nil | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func InboxRepos(p InboxPage) templ.Component { | |
| 139 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 140 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 141 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 142 | + | return templ_7745c5c3_CtxErr | |
| 143 | + | } | |
| 144 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 145 | + | if !templ_7745c5c3_IsBuffer { | |
| 146 | + | defer func() { | |
| 147 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 148 | + | if templ_7745c5c3_Err == nil { | |
| 149 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 150 | + | } | |
| 151 | + | }() | |
| 152 | + | } | |
| 153 | + | ctx = templ.InitializeContext(ctx) | |
| 154 | + | templ_7745c5c3_Var6 := templ.GetChildren(ctx) | |
| 155 | + | if templ_7745c5c3_Var6 == nil { | |
| 156 | + | templ_7745c5c3_Var6 = templ.NopComponent | |
| 157 | + | } | |
| 158 | + | ctx = templ.ClearChildren(ctx) | |
| 159 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">") | |
| 160 | + | if templ_7745c5c3_Err != nil { | |
| 161 | + | return templ_7745c5c3_Err | |
| 162 | + | } | |
| 163 | + | for _, r := range p.Repos { | |
| 164 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"") | |
| 165 | + | if templ_7745c5c3_Err != nil { | |
| 166 | + | return templ_7745c5c3_Err | |
| 167 | + | } | |
| 168 | + | var templ_7745c5c3_Var7 templ.SafeURL | |
| 169 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 170 | + | if templ_7745c5c3_Err != nil { | |
| 171 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 30, Col: 32} | |
| 172 | + | } | |
| 173 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |
| 174 | + | if templ_7745c5c3_Err != nil { | |
| 175 | + | return templ_7745c5c3_Err | |
| 176 | + | } | |
| 177 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"") | |
| 178 | + | if templ_7745c5c3_Err != nil { | |
| 179 | + | return templ_7745c5c3_Err | |
| 180 | + | } | |
| 181 | + | if r.Current { | |
| 182 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " aria-current=\"page\"") | |
| 183 | + | if templ_7745c5c3_Err != nil { | |
| 184 | + | return templ_7745c5c3_Err | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">") | |
| 188 | + | if templ_7745c5c3_Err != nil { | |
| 189 | + | return templ_7745c5c3_Err | |
| 190 | + | } | |
| 191 | + | var templ_7745c5c3_Var8 string | |
| 192 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label) | |
| 193 | + | if templ_7745c5c3_Err != nil { | |
| 194 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 34, Col: 13} | |
| 195 | + | } | |
| 196 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 197 | + | if templ_7745c5c3_Err != nil { | |
| 198 | + | return templ_7745c5c3_Err | |
| 199 | + | } | |
| 200 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>") | |
| 201 | + | if templ_7745c5c3_Err != nil { | |
| 202 | + | return templ_7745c5c3_Err | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</nav>") | |
| 206 | + | if templ_7745c5c3_Err != nil { | |
| 207 | + | return templ_7745c5c3_Err | |
| 208 | + | } | |
| 209 | + | return nil | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func InboxMRs(p InboxPage) templ.Component { | |
| 214 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 215 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 216 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 217 | + | return templ_7745c5c3_CtxErr | |
| 218 | + | } | |
| 219 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 220 | + | if !templ_7745c5c3_IsBuffer { | |
| 221 | + | defer func() { | |
| 222 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 223 | + | if templ_7745c5c3_Err == nil { | |
| 224 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 225 | + | } | |
| 226 | + | }() | |
| 227 | + | } | |
| 228 | + | ctx = templ.InitializeContext(ctx) | |
| 229 | + | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | |
| 230 | + | if templ_7745c5c3_Var9 == nil { | |
| 231 | + | templ_7745c5c3_Var9 = templ.NopComponent | |
| 232 | + | } | |
| 233 | + | ctx = templ.ClearChildren(ctx) | |
| 234 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">") | |
| 235 | + | if templ_7745c5c3_Err != nil { | |
| 236 | + | return templ_7745c5c3_Err | |
| 237 | + | } | |
| 238 | + | var templ_7745c5c3_Var10 string | |
| 239 | + | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs))) | |
| 240 | + | if templ_7745c5c3_Err != nil { | |
| 241 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 41, Col: 108} | |
| 242 | + | } | |
| 243 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |
| 244 | + | if templ_7745c5c3_Err != nil { | |
| 245 | + | return templ_7745c5c3_Err | |
| 246 | + | } | |
| 247 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></h2>") | |
| 248 | + | if templ_7745c5c3_Err != nil { | |
| 249 | + | return templ_7745c5c3_Err | |
| 250 | + | } | |
| 251 | + | templ_7745c5c3_Err = inboxRows(p.MRs, "Open merge requests").Render(ctx, templ_7745c5c3_Buffer) | |
| 252 | + | if templ_7745c5c3_Err != nil { | |
| 253 | + | return templ_7745c5c3_Err | |
| 254 | + | } | |
| 255 | + | if len(p.MRs) == 0 { | |
| 256 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"lede dim\">no open merge requests.</p>") | |
| 257 | + | if templ_7745c5c3_Err != nil { | |
| 258 | + | return templ_7745c5c3_Err | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>") | |
| 262 | + | if templ_7745c5c3_Err != nil { | |
| 263 | + | return templ_7745c5c3_Err | |
| 264 | + | } | |
| 265 | + | return nil | |
| 266 | + | }) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func InboxIssues(p InboxPage) templ.Component { | |
| 270 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 271 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 272 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 273 | + | return templ_7745c5c3_CtxErr | |
| 274 | + | } | |
| 275 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 276 | + | if !templ_7745c5c3_IsBuffer { | |
| 277 | + | defer func() { | |
| 278 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 279 | + | if templ_7745c5c3_Err == nil { | |
| 280 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 281 | + | } | |
| 282 | + | }() | |
| 283 | + | } | |
| 284 | + | ctx = templ.InitializeContext(ctx) | |
| 285 | + | templ_7745c5c3_Var11 := templ.GetChildren(ctx) | |
| 286 | + | if templ_7745c5c3_Var11 == nil { | |
| 287 | + | templ_7745c5c3_Var11 = templ.NopComponent | |
| 288 | + | } | |
| 289 | + | ctx = templ.ClearChildren(ctx) | |
| 290 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">") | |
| 291 | + | if templ_7745c5c3_Err != nil { | |
| 292 | + | return templ_7745c5c3_Err | |
| 293 | + | } | |
| 294 | + | var templ_7745c5c3_Var12 string | |
| 295 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows))) | |
| 296 | + | if templ_7745c5c3_Err != nil { | |
| 297 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 109} | |
| 298 | + | } | |
| 299 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 300 | + | if templ_7745c5c3_Err != nil { | |
| 301 | + | return templ_7745c5c3_Err | |
| 302 | + | } | |
| 303 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></h2>") | |
| 304 | + | if templ_7745c5c3_Err != nil { | |
| 305 | + | return templ_7745c5c3_Err | |
| 306 | + | } | |
| 307 | + | templ_7745c5c3_Err = inboxRows(p.IssueRows, "Open issues").Render(ctx, templ_7745c5c3_Buffer) | |
| 308 | + | if templ_7745c5c3_Err != nil { | |
| 309 | + | return templ_7745c5c3_Err | |
| 310 | + | } | |
| 311 | + | if len(p.IssueRows) == 0 { | |
| 312 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no open issues.</p>") | |
| 313 | + | if templ_7745c5c3_Err != nil { | |
| 314 | + | return templ_7745c5c3_Err | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>") | |
| 318 | + | if templ_7745c5c3_Err != nil { | |
| 319 | + | return templ_7745c5c3_Err | |
| 320 | + | } | |
| 321 | + | return nil | |
| 322 | + | }) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func inboxRows(rows []InboxRow, label string) templ.Component { | |
| 326 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 327 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 328 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 329 | + | return templ_7745c5c3_CtxErr | |
| 330 | + | } | |
| 331 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 332 | + | if !templ_7745c5c3_IsBuffer { | |
| 333 | + | defer func() { | |
| 334 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 335 | + | if templ_7745c5c3_Err == nil { | |
| 336 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 337 | + | } | |
| 338 | + | }() | |
| 339 | + | } | |
| 340 | + | ctx = templ.InitializeContext(ctx) | |
| 341 | + | templ_7745c5c3_Var13 := templ.GetChildren(ctx) | |
| 342 | + | if templ_7745c5c3_Var13 == nil { | |
| 343 | + | templ_7745c5c3_Var13 = templ.NopComponent | |
| 344 | + | } | |
| 345 | + | ctx = templ.ClearChildren(ctx) | |
| 346 | + | if len(rows) > 0 { | |
| 347 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<table class=\"list mrs\" aria-label=\"") | |
| 348 | + | if templ_7745c5c3_Err != nil { | |
| 349 | + | return templ_7745c5c3_Err | |
| 350 | + | } | |
| 351 | + | var templ_7745c5c3_Var14 string | |
| 352 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(label) | |
| 353 | + | if templ_7745c5c3_Err != nil { | |
| 354 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 61, Col: 44} | |
| 355 | + | } | |
| 356 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |
| 357 | + | if templ_7745c5c3_Err != nil { | |
| 358 | + | return templ_7745c5c3_Err | |
| 359 | + | } | |
| 360 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><tbody data-rows>") | |
| 361 | + | if templ_7745c5c3_Err != nil { | |
| 362 | + | return templ_7745c5c3_Err | |
| 363 | + | } | |
| 364 | + | for _, m := range rows { | |
| 365 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<tr") | |
| 366 | + | if templ_7745c5c3_Err != nil { | |
| 367 | + | return templ_7745c5c3_Err | |
| 368 | + | } | |
| 369 | + | if m.Cursor { | |
| 370 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " data-cursor") | |
| 371 | + | if templ_7745c5c3_Err != nil { | |
| 372 | + | return templ_7745c5c3_Err | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-state=\"open\"><td class=\"c-n mute\">") | |
| 376 | + | if templ_7745c5c3_Err != nil { | |
| 377 | + | return templ_7745c5c3_Err | |
| 378 | + | } | |
| 379 | + | var templ_7745c5c3_Var15 string | |
| 380 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number) | |
| 381 | + | if templ_7745c5c3_Err != nil { | |
| 382 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 37} | |
| 383 | + | } | |
| 384 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 385 | + | if templ_7745c5c3_Err != nil { | |
| 386 | + | return templ_7745c5c3_Err | |
| 387 | + | } | |
| 388 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-subject\"><a href=\"") | |
| 389 | + | if templ_7745c5c3_Err != nil { | |
| 390 | + | return templ_7745c5c3_Err | |
| 391 | + | } | |
| 392 | + | var templ_7745c5c3_Var16 templ.SafeURL | |
| 393 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href)) | |
| 394 | + | if templ_7745c5c3_Err != nil { | |
| 395 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 59} | |
| 396 | + | } | |
| 397 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |
| 398 | + | if templ_7745c5c3_Err != nil { | |
| 399 | + | return templ_7745c5c3_Err | |
| 400 | + | } | |
| 401 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">") | |
| 402 | + | if templ_7745c5c3_Err != nil { | |
| 403 | + | return templ_7745c5c3_Err | |
| 404 | + | } | |
| 405 | + | var templ_7745c5c3_Var17 string | |
| 406 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) | |
| 407 | + | if templ_7745c5c3_Err != nil { | |
| 408 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 71} | |
| 409 | + | } | |
| 410 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 411 | + | if templ_7745c5c3_Err != nil { | |
| 412 | + | return templ_7745c5c3_Err | |
| 413 | + | } | |
| 414 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a> <a class=\"mute\" href=\"") | |
| 415 | + | if templ_7745c5c3_Err != nil { | |
| 416 | + | return templ_7745c5c3_Err | |
| 417 | + | } | |
| 418 | + | var templ_7745c5c3_Var18 templ.SafeURL | |
| 419 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) | |
| 420 | + | if templ_7745c5c3_Err != nil { | |
| 421 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 126} | |
| 422 | + | } | |
| 423 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 424 | + | if templ_7745c5c3_Err != nil { | |
| 425 | + | return templ_7745c5c3_Err | |
| 426 | + | } | |
| 427 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">") | |
| 428 | + | if templ_7745c5c3_Err != nil { | |
| 429 | + | return templ_7745c5c3_Err | |
| 430 | + | } | |
| 431 | + | var templ_7745c5c3_Var19 string | |
| 432 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) | |
| 433 | + | if templ_7745c5c3_Err != nil { | |
| 434 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 137} | |
| 435 | + | } | |
| 436 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 437 | + | if templ_7745c5c3_Err != nil { | |
| 438 | + | return templ_7745c5c3_Err | |
| 439 | + | } | |
| 440 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td class=\"c-when nowrap dim\">") | |
| 441 | + | if templ_7745c5c3_Err != nil { | |
| 442 | + | return templ_7745c5c3_Err | |
| 443 | + | } | |
| 444 | + | if m.WhenAt != "" { | |
| 445 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<time datetime=\"") | |
| 446 | + | if templ_7745c5c3_Err != nil { | |
| 447 | + | return templ_7745c5c3_Err | |
| 448 | + | } | |
| 449 | + | var templ_7745c5c3_Var20 string | |
| 450 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 451 | + | if templ_7745c5c3_Err != nil { | |
| 452 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 33} | |
| 453 | + | } | |
| 454 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) | |
| 455 | + | if templ_7745c5c3_Err != nil { | |
| 456 | + | return templ_7745c5c3_Err | |
| 457 | + | } | |
| 458 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" title=\"") | |
| 459 | + | if templ_7745c5c3_Err != nil { | |
| 460 | + | return templ_7745c5c3_Err | |
| 461 | + | } | |
| 462 | + | var templ_7745c5c3_Var21 string | |
| 463 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 464 | + | if templ_7745c5c3_Err != nil { | |
| 465 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 52} | |
| 466 | + | } | |
| 467 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) | |
| 468 | + | if templ_7745c5c3_Err != nil { | |
| 469 | + | return templ_7745c5c3_Err | |
| 470 | + | } | |
| 471 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">") | |
| 472 | + | if templ_7745c5c3_Err != nil { | |
| 473 | + | return templ_7745c5c3_Err | |
| 474 | + | } | |
| 475 | + | var templ_7745c5c3_Var22 string | |
| 476 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) | |
| 477 | + | if templ_7745c5c3_Err != nil { | |
| 478 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 63} | |
| 479 | + | } | |
| 480 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 481 | + | if templ_7745c5c3_Err != nil { | |
| 482 | + | return templ_7745c5c3_Err | |
| 483 | + | } | |
| 484 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</time>") | |
| 485 | + | if templ_7745c5c3_Err != nil { | |
| 486 | + | return templ_7745c5c3_Err | |
| 487 | + | } | |
| 488 | + | } | |
| 489 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td></tr>") | |
| 490 | + | if templ_7745c5c3_Err != nil { | |
| 491 | + | return templ_7745c5c3_Err | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table>") | |
| 495 | + | if templ_7745c5c3_Err != nil { | |
| 496 | + | return templ_7745c5c3_Err | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | return nil | |
| 500 | + | }) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | var _ = templruntime.GeneratedTemplate | |
| @@ -0,0 +1,503 @@ | |||||
| 1 | + | // Code generated by templ - DO NOT EDIT. | |||
| 2 | + | ||||
| 3 | + | // templ: version: v0.3.1020 | |||
| 4 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 5 | + | ||||
| 6 | + | package web | |||
| 7 | + | ||||
| 8 | + | //lint:file-ignore SA4006 This context is only used if a nested component is present. | |||
| 9 | + | ||||
| 10 | + | import "github.com/a-h/templ" | |||
| 11 | + | import templruntime "github.com/a-h/templ/runtime" | |||
| 12 | + | ||||
| 13 | + | // Inbox is the owner's inbox: what is open across every repository. The | |||
| 14 | + | // counters, the filter and the lists are fragments with an id that the live | |||
| 15 | + | // stream replaces as a whole (ermiteLive.swap). | |||
| 16 | + | func Inbox(l Layout, p InboxPage) templ.Component { | |||
| 17 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 18 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 19 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 20 | + | return templ_7745c5c3_CtxErr | |||
| 21 | + | } | |||
| 22 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 23 | + | if !templ_7745c5c3_IsBuffer { | |||
| 24 | + | defer func() { | |||
| 25 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 26 | + | if templ_7745c5c3_Err == nil { | |||
| 27 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 28 | + | } | |||
| 29 | + | }() | |||
| 30 | + | } | |||
| 31 | + | ctx = templ.InitializeContext(ctx) | |||
| 32 | + | templ_7745c5c3_Var1 := templ.GetChildren(ctx) | |||
| 33 | + | if templ_7745c5c3_Var1 == nil { | |||
| 34 | + | templ_7745c5c3_Var1 = templ.NopComponent | |||
| 35 | + | } | |||
| 36 | + | ctx = templ.ClearChildren(ctx) | |||
| 37 | + | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 38 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 39 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 40 | + | if !templ_7745c5c3_IsBuffer { | |||
| 41 | + | defer func() { | |||
| 42 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 43 | + | if templ_7745c5c3_Err == nil { | |||
| 44 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 45 | + | } | |||
| 46 | + | }() | |||
| 47 | + | } | |||
| 48 | + | ctx = templ.InitializeContext(ctx) | |||
| 49 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Inbox</h1><div class=\"inbox\">") | |||
| 50 | + | if templ_7745c5c3_Err != nil { | |||
| 51 | + | return templ_7745c5c3_Err | |||
| 52 | + | } | |||
| 53 | + | templ_7745c5c3_Err = InboxCounters(p).Render(ctx, templ_7745c5c3_Buffer) | |||
| 54 | + | if templ_7745c5c3_Err != nil { | |||
| 55 | + | return templ_7745c5c3_Err | |||
| 56 | + | } | |||
| 57 | + | templ_7745c5c3_Err = InboxRepos(p).Render(ctx, templ_7745c5c3_Buffer) | |||
| 58 | + | if templ_7745c5c3_Err != nil { | |||
| 59 | + | return templ_7745c5c3_Err | |||
| 60 | + | } | |||
| 61 | + | templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer) | |||
| 62 | + | if templ_7745c5c3_Err != nil { | |||
| 63 | + | return templ_7745c5c3_Err | |||
| 64 | + | } | |||
| 65 | + | templ_7745c5c3_Err = InboxIssues(p).Render(ctx, templ_7745c5c3_Buffer) | |||
| 66 | + | if templ_7745c5c3_Err != nil { | |||
| 67 | + | return templ_7745c5c3_Err | |||
| 68 | + | } | |||
| 69 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>") | |||
| 70 | + | if templ_7745c5c3_Err != nil { | |||
| 71 | + | return templ_7745c5c3_Err | |||
| 72 | + | } | |||
| 73 | + | return nil | |||
| 74 | + | }) | |||
| 75 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) | |||
| 76 | + | if templ_7745c5c3_Err != nil { | |||
| 77 | + | return templ_7745c5c3_Err | |||
| 78 | + | } | |||
| 79 | + | return nil | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func InboxCounters(p InboxPage) templ.Component { | |||
| 84 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 85 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 86 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 87 | + | return templ_7745c5c3_CtxErr | |||
| 88 | + | } | |||
| 89 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 90 | + | if !templ_7745c5c3_IsBuffer { | |||
| 91 | + | defer func() { | |||
| 92 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 93 | + | if templ_7745c5c3_Err == nil { | |||
| 94 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 95 | + | } | |||
| 96 | + | }() | |||
| 97 | + | } | |||
| 98 | + | ctx = templ.InitializeContext(ctx) | |||
| 99 | + | templ_7745c5c3_Var3 := templ.GetChildren(ctx) | |||
| 100 | + | if templ_7745c5c3_Var3 == nil { | |||
| 101 | + | templ_7745c5c3_Var3 = templ.NopComponent | |||
| 102 | + | } | |||
| 103 | + | ctx = templ.ClearChildren(ctx) | |||
| 104 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p id=\"inbox-counts\" class=\"inbox-counts\"><strong>") | |||
| 105 | + | if templ_7745c5c3_Err != nil { | |||
| 106 | + | return templ_7745c5c3_Err | |||
| 107 | + | } | |||
| 108 | + | var templ_7745c5c3_Var4 string | |||
| 109 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests)) | |||
| 110 | + | if templ_7745c5c3_Err != nil { | |||
| 111 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 33} | |||
| 112 | + | } | |||
| 113 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |||
| 114 | + | if templ_7745c5c3_Err != nil { | |||
| 115 | + | return templ_7745c5c3_Err | |||
| 116 | + | } | |||
| 117 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong> merge requests · <strong>") | |||
| 118 | + | if templ_7745c5c3_Err != nil { | |||
| 119 | + | return templ_7745c5c3_Err | |||
| 120 | + | } | |||
| 121 | + | var templ_7745c5c3_Var5 string | |||
| 122 | + | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues)) | |||
| 123 | + | if templ_7745c5c3_Err != nil { | |||
| 124 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 87} | |||
| 125 | + | } | |||
| 126 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |||
| 127 | + | if templ_7745c5c3_Err != nil { | |||
| 128 | + | return templ_7745c5c3_Err | |||
| 129 | + | } | |||
| 130 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues</p>") | |||
| 131 | + | if templ_7745c5c3_Err != nil { | |||
| 132 | + | return templ_7745c5c3_Err | |||
| 133 | + | } | |||
| 134 | + | return nil | |||
| 135 | + | }) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func InboxRepos(p InboxPage) templ.Component { | |||
| 139 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 140 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 141 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 142 | + | return templ_7745c5c3_CtxErr | |||
| 143 | + | } | |||
| 144 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 145 | + | if !templ_7745c5c3_IsBuffer { | |||
| 146 | + | defer func() { | |||
| 147 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 148 | + | if templ_7745c5c3_Err == nil { | |||
| 149 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 150 | + | } | |||
| 151 | + | }() | |||
| 152 | + | } | |||
| 153 | + | ctx = templ.InitializeContext(ctx) | |||
| 154 | + | templ_7745c5c3_Var6 := templ.GetChildren(ctx) | |||
| 155 | + | if templ_7745c5c3_Var6 == nil { | |||
| 156 | + | templ_7745c5c3_Var6 = templ.NopComponent | |||
| 157 | + | } | |||
| 158 | + | ctx = templ.ClearChildren(ctx) | |||
| 159 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">") | |||
| 160 | + | if templ_7745c5c3_Err != nil { | |||
| 161 | + | return templ_7745c5c3_Err | |||
| 162 | + | } | |||
| 163 | + | for _, r := range p.Repos { | |||
| 164 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"") | |||
| 165 | + | if templ_7745c5c3_Err != nil { | |||
| 166 | + | return templ_7745c5c3_Err | |||
| 167 | + | } | |||
| 168 | + | var templ_7745c5c3_Var7 templ.SafeURL | |||
| 169 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |||
| 170 | + | if templ_7745c5c3_Err != nil { | |||
| 171 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 30, Col: 32} | |||
| 172 | + | } | |||
| 173 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |||
| 174 | + | if templ_7745c5c3_Err != nil { | |||
| 175 | + | return templ_7745c5c3_Err | |||
| 176 | + | } | |||
| 177 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"") | |||
| 178 | + | if templ_7745c5c3_Err != nil { | |||
| 179 | + | return templ_7745c5c3_Err | |||
| 180 | + | } | |||
| 181 | + | if r.Current { | |||
| 182 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " aria-current=\"page\"") | |||
| 183 | + | if templ_7745c5c3_Err != nil { | |||
| 184 | + | return templ_7745c5c3_Err | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">") | |||
| 188 | + | if templ_7745c5c3_Err != nil { | |||
| 189 | + | return templ_7745c5c3_Err | |||
| 190 | + | } | |||
| 191 | + | var templ_7745c5c3_Var8 string | |||
| 192 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label) | |||
| 193 | + | if templ_7745c5c3_Err != nil { | |||
| 194 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 34, Col: 13} | |||
| 195 | + | } | |||
| 196 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |||
| 197 | + | if templ_7745c5c3_Err != nil { | |||
| 198 | + | return templ_7745c5c3_Err | |||
| 199 | + | } | |||
| 200 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>") | |||
| 201 | + | if templ_7745c5c3_Err != nil { | |||
| 202 | + | return templ_7745c5c3_Err | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</nav>") | |||
| 206 | + | if templ_7745c5c3_Err != nil { | |||
| 207 | + | return templ_7745c5c3_Err | |||
| 208 | + | } | |||
| 209 | + | return nil | |||
| 210 | + | }) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func InboxMRs(p InboxPage) templ.Component { | |||
| 214 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 215 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 216 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 217 | + | return templ_7745c5c3_CtxErr | |||
| 218 | + | } | |||
| 219 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 220 | + | if !templ_7745c5c3_IsBuffer { | |||
| 221 | + | defer func() { | |||
| 222 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 223 | + | if templ_7745c5c3_Err == nil { | |||
| 224 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 225 | + | } | |||
| 226 | + | }() | |||
| 227 | + | } | |||
| 228 | + | ctx = templ.InitializeContext(ctx) | |||
| 229 | + | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | |||
| 230 | + | if templ_7745c5c3_Var9 == nil { | |||
| 231 | + | templ_7745c5c3_Var9 = templ.NopComponent | |||
| 232 | + | } | |||
| 233 | + | ctx = templ.ClearChildren(ctx) | |||
| 234 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">") | |||
| 235 | + | if templ_7745c5c3_Err != nil { | |||
| 236 | + | return templ_7745c5c3_Err | |||
| 237 | + | } | |||
| 238 | + | var templ_7745c5c3_Var10 string | |||
| 239 | + | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs))) | |||
| 240 | + | if templ_7745c5c3_Err != nil { | |||
| 241 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 41, Col: 108} | |||
| 242 | + | } | |||
| 243 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |||
| 244 | + | if templ_7745c5c3_Err != nil { | |||
| 245 | + | return templ_7745c5c3_Err | |||
| 246 | + | } | |||
| 247 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></h2>") | |||
| 248 | + | if templ_7745c5c3_Err != nil { | |||
| 249 | + | return templ_7745c5c3_Err | |||
| 250 | + | } | |||
| 251 | + | templ_7745c5c3_Err = inboxRows(p.MRs, "Open merge requests").Render(ctx, templ_7745c5c3_Buffer) | |||
| 252 | + | if templ_7745c5c3_Err != nil { | |||
| 253 | + | return templ_7745c5c3_Err | |||
| 254 | + | } | |||
| 255 | + | if len(p.MRs) == 0 { | |||
| 256 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"lede dim\">no open merge requests.</p>") | |||
| 257 | + | if templ_7745c5c3_Err != nil { | |||
| 258 | + | return templ_7745c5c3_Err | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>") | |||
| 262 | + | if templ_7745c5c3_Err != nil { | |||
| 263 | + | return templ_7745c5c3_Err | |||
| 264 | + | } | |||
| 265 | + | return nil | |||
| 266 | + | }) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func InboxIssues(p InboxPage) templ.Component { | |||
| 270 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 271 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 272 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 273 | + | return templ_7745c5c3_CtxErr | |||
| 274 | + | } | |||
| 275 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 276 | + | if !templ_7745c5c3_IsBuffer { | |||
| 277 | + | defer func() { | |||
| 278 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 279 | + | if templ_7745c5c3_Err == nil { | |||
| 280 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 281 | + | } | |||
| 282 | + | }() | |||
| 283 | + | } | |||
| 284 | + | ctx = templ.InitializeContext(ctx) | |||
| 285 | + | templ_7745c5c3_Var11 := templ.GetChildren(ctx) | |||
| 286 | + | if templ_7745c5c3_Var11 == nil { | |||
| 287 | + | templ_7745c5c3_Var11 = templ.NopComponent | |||
| 288 | + | } | |||
| 289 | + | ctx = templ.ClearChildren(ctx) | |||
| 290 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">") | |||
| 291 | + | if templ_7745c5c3_Err != nil { | |||
| 292 | + | return templ_7745c5c3_Err | |||
| 293 | + | } | |||
| 294 | + | var templ_7745c5c3_Var12 string | |||
| 295 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows))) | |||
| 296 | + | if templ_7745c5c3_Err != nil { | |||
| 297 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 109} | |||
| 298 | + | } | |||
| 299 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |||
| 300 | + | if templ_7745c5c3_Err != nil { | |||
| 301 | + | return templ_7745c5c3_Err | |||
| 302 | + | } | |||
| 303 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></h2>") | |||
| 304 | + | if templ_7745c5c3_Err != nil { | |||
| 305 | + | return templ_7745c5c3_Err | |||
| 306 | + | } | |||
| 307 | + | templ_7745c5c3_Err = inboxRows(p.IssueRows, "Open issues").Render(ctx, templ_7745c5c3_Buffer) | |||
| 308 | + | if templ_7745c5c3_Err != nil { | |||
| 309 | + | return templ_7745c5c3_Err | |||
| 310 | + | } | |||
| 311 | + | if len(p.IssueRows) == 0 { | |||
| 312 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no open issues.</p>") | |||
| 313 | + | if templ_7745c5c3_Err != nil { | |||
| 314 | + | return templ_7745c5c3_Err | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>") | |||
| 318 | + | if templ_7745c5c3_Err != nil { | |||
| 319 | + | return templ_7745c5c3_Err | |||
| 320 | + | } | |||
| 321 | + | return nil | |||
| 322 | + | }) | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func inboxRows(rows []InboxRow, label string) templ.Component { | |||
| 326 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 327 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 328 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 329 | + | return templ_7745c5c3_CtxErr | |||
| 330 | + | } | |||
| 331 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 332 | + | if !templ_7745c5c3_IsBuffer { | |||
| 333 | + | defer func() { | |||
| 334 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 335 | + | if templ_7745c5c3_Err == nil { | |||
| 336 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 337 | + | } | |||
| 338 | + | }() | |||
| 339 | + | } | |||
| 340 | + | ctx = templ.InitializeContext(ctx) | |||
| 341 | + | templ_7745c5c3_Var13 := templ.GetChildren(ctx) | |||
| 342 | + | if templ_7745c5c3_Var13 == nil { | |||
| 343 | + | templ_7745c5c3_Var13 = templ.NopComponent | |||
| 344 | + | } | |||
| 345 | + | ctx = templ.ClearChildren(ctx) | |||
| 346 | + | if len(rows) > 0 { | |||
| 347 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<table class=\"list mrs\" aria-label=\"") | |||
| 348 | + | if templ_7745c5c3_Err != nil { | |||
| 349 | + | return templ_7745c5c3_Err | |||
| 350 | + | } | |||
| 351 | + | var templ_7745c5c3_Var14 string | |||
| 352 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(label) | |||
| 353 | + | if templ_7745c5c3_Err != nil { | |||
| 354 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 61, Col: 44} | |||
| 355 | + | } | |||
| 356 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |||
| 357 | + | if templ_7745c5c3_Err != nil { | |||
| 358 | + | return templ_7745c5c3_Err | |||
| 359 | + | } | |||
| 360 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><tbody data-rows>") | |||
| 361 | + | if templ_7745c5c3_Err != nil { | |||
| 362 | + | return templ_7745c5c3_Err | |||
| 363 | + | } | |||
| 364 | + | for _, m := range rows { | |||
| 365 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<tr") | |||
| 366 | + | if templ_7745c5c3_Err != nil { | |||
| 367 | + | return templ_7745c5c3_Err | |||
| 368 | + | } | |||
| 369 | + | if m.Cursor { | |||
| 370 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " data-cursor") | |||
| 371 | + | if templ_7745c5c3_Err != nil { | |||
| 372 | + | return templ_7745c5c3_Err | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-state=\"open\"><td class=\"c-n mute\">") | |||
| 376 | + | if templ_7745c5c3_Err != nil { | |||
| 377 | + | return templ_7745c5c3_Err | |||
| 378 | + | } | |||
| 379 | + | var templ_7745c5c3_Var15 string | |||
| 380 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number) | |||
| 381 | + | if templ_7745c5c3_Err != nil { | |||
| 382 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 37} | |||
| 383 | + | } | |||
| 384 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |||
| 385 | + | if templ_7745c5c3_Err != nil { | |||
| 386 | + | return templ_7745c5c3_Err | |||
| 387 | + | } | |||
| 388 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-subject\"><a href=\"") | |||
| 389 | + | if templ_7745c5c3_Err != nil { | |||
| 390 | + | return templ_7745c5c3_Err | |||
| 391 | + | } | |||
| 392 | + | var templ_7745c5c3_Var16 templ.SafeURL | |||
| 393 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href)) | |||
| 394 | + | if templ_7745c5c3_Err != nil { | |||
| 395 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 59} | |||
| 396 | + | } | |||
| 397 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |||
| 398 | + | if templ_7745c5c3_Err != nil { | |||
| 399 | + | return templ_7745c5c3_Err | |||
| 400 | + | } | |||
| 401 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">") | |||
| 402 | + | if templ_7745c5c3_Err != nil { | |||
| 403 | + | return templ_7745c5c3_Err | |||
| 404 | + | } | |||
| 405 | + | var templ_7745c5c3_Var17 string | |||
| 406 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) | |||
| 407 | + | if templ_7745c5c3_Err != nil { | |||
| 408 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 71} | |||
| 409 | + | } | |||
| 410 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |||
| 411 | + | if templ_7745c5c3_Err != nil { | |||
| 412 | + | return templ_7745c5c3_Err | |||
| 413 | + | } | |||
| 414 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a> <a class=\"mute\" href=\"") | |||
| 415 | + | if templ_7745c5c3_Err != nil { | |||
| 416 | + | return templ_7745c5c3_Err | |||
| 417 | + | } | |||
| 418 | + | var templ_7745c5c3_Var18 templ.SafeURL | |||
| 419 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) | |||
| 420 | + | if templ_7745c5c3_Err != nil { | |||
| 421 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 126} | |||
| 422 | + | } | |||
| 423 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |||
| 424 | + | if templ_7745c5c3_Err != nil { | |||
| 425 | + | return templ_7745c5c3_Err | |||
| 426 | + | } | |||
| 427 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">") | |||
| 428 | + | if templ_7745c5c3_Err != nil { | |||
| 429 | + | return templ_7745c5c3_Err | |||
| 430 | + | } | |||
| 431 | + | var templ_7745c5c3_Var19 string | |||
| 432 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) | |||
| 433 | + | if templ_7745c5c3_Err != nil { | |||
| 434 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 137} | |||
| 435 | + | } | |||
| 436 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |||
| 437 | + | if templ_7745c5c3_Err != nil { | |||
| 438 | + | return templ_7745c5c3_Err | |||
| 439 | + | } | |||
| 440 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td class=\"c-when nowrap dim\">") | |||
| 441 | + | if templ_7745c5c3_Err != nil { | |||
| 442 | + | return templ_7745c5c3_Err | |||
| 443 | + | } | |||
| 444 | + | if m.WhenAt != "" { | |||
| 445 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<time datetime=\"") | |||
| 446 | + | if templ_7745c5c3_Err != nil { | |||
| 447 | + | return templ_7745c5c3_Err | |||
| 448 | + | } | |||
| 449 | + | var templ_7745c5c3_Var20 string | |||
| 450 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |||
| 451 | + | if templ_7745c5c3_Err != nil { | |||
| 452 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 33} | |||
| 453 | + | } | |||
| 454 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) | |||
| 455 | + | if templ_7745c5c3_Err != nil { | |||
| 456 | + | return templ_7745c5c3_Err | |||
| 457 | + | } | |||
| 458 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" title=\"") | |||
| 459 | + | if templ_7745c5c3_Err != nil { | |||
| 460 | + | return templ_7745c5c3_Err | |||
| 461 | + | } | |||
| 462 | + | var templ_7745c5c3_Var21 string | |||
| 463 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |||
| 464 | + | if templ_7745c5c3_Err != nil { | |||
| 465 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 52} | |||
| 466 | + | } | |||
| 467 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) | |||
| 468 | + | if templ_7745c5c3_Err != nil { | |||
| 469 | + | return templ_7745c5c3_Err | |||
| 470 | + | } | |||
| 471 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">") | |||
| 472 | + | if templ_7745c5c3_Err != nil { | |||
| 473 | + | return templ_7745c5c3_Err | |||
| 474 | + | } | |||
| 475 | + | var templ_7745c5c3_Var22 string | |||
| 476 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) | |||
| 477 | + | if templ_7745c5c3_Err != nil { | |||
| 478 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 63} | |||
| 479 | + | } | |||
| 480 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |||
| 481 | + | if templ_7745c5c3_Err != nil { | |||
| 482 | + | return templ_7745c5c3_Err | |||
| 483 | + | } | |||
| 484 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</time>") | |||
| 485 | + | if templ_7745c5c3_Err != nil { | |||
| 486 | + | return templ_7745c5c3_Err | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td></tr>") | |||
| 490 | + | if templ_7745c5c3_Err != nil { | |||
| 491 | + | return templ_7745c5c3_Err | |||
| 492 | + | } | |||
| 493 | + | } | |||
| 494 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table>") | |||
| 495 | + | if templ_7745c5c3_Err != nil { | |||
| 496 | + | return templ_7745c5c3_Err | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | return nil | |||
| 500 | + | }) | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | var _ = templruntime.GeneratedTemplate | |||
A internal/adapters/web/inbox_test.go +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func inboxOverview(t testing.TB) ports.InboxOverview { | |
| 23 | + | t.Helper() | |
| 24 | + | mrTitle, err := domain.ParseMRTitle("fix <b>search</b>") | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | isTitle, err := domain.ParseIssueTitle("crash on start") | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | at := time.Now().Add(-2 * time.Hour) | |
| 29 | + | ov := ports.InboxOverview{ | |
| 30 | + | MergeRequests: []ports.InboxMergeRequest{{MergeRequest: domain.MergeRequest{ | |
| 31 | + | Repo: repoName(t, "alpha"), Number: 4, Title: mrTitle, State: domain.MRStateOpen, UpdatedAt: at, | |
| 32 | + | }}}, | |
| 33 | + | Issues: []domain.Issue{{Repo: repoName(t, "beta"), Number: 7, Title: isTitle, State: domain.IssueStateOpen, UpdatedAt: at}}, | |
| 34 | + | Repos: []domain.RepoName{repoName(t, "alpha"), repoName(t, "beta")}, | |
| 35 | + | } | |
| 36 | + | ov.Counts = ports.InboxCounts{MergeRequests: 1, Issues: 1} | |
| 37 | + | return ov | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func inboxServer(t *testing.T, inbox ports.InboxService) *Server { | |
| 41 | + | t.Helper() | |
| 42 | + | return newTestServer(t, Config{ | |
| 43 | + | Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, | |
| 44 | + | Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func inboxGet(srv *Server, path string, owner bool) *httptest.ResponseRecorder { | |
| 49 | + | req := httptest.NewRequest(http.MethodGet, path, nil) | |
| 50 | + | if owner { | |
| 51 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |
| 52 | + | } | |
| 53 | + | rec := httptest.NewRecorder() | |
| 54 | + | srv.ServeHTTP(rec, req) | |
| 55 | + | return rec | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestInboxPageForTheOwner(t *testing.T) { | |
| 59 | + | t.Parallel() | |
| 60 | + | svc := mocks.NewInboxService(t) | |
| 61 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil) | |
| 62 | + | srv := inboxServer(t, svc) | |
| 63 | + | ||
| 64 | + | rec := inboxGet(srv, "/-/inbox", true) | |
| 65 | + | require.Equal(t, 200, rec.Code, rec.Body.String()) | |
| 66 | + | body := rec.Body.String() | |
| 67 | + | assert.Contains(t, body, "<strong>1</strong> merge requests · <strong>1</strong> issues") | |
| 68 | + | assert.Contains(t, body, "Open merge requests") | |
| 69 | + | assert.Contains(t, body, "Open issues") | |
| 70 | + | assert.Contains(t, body, `href="/alpha/merge-requests/4"`) | |
| 71 | + | assert.Contains(t, body, "fix <b>search</b>") | |
| 72 | + | assert.Contains(t, body, `href="/beta/issues/7"`) | |
| 73 | + | assert.Contains(t, body, "#7") | |
| 74 | + | assert.Contains(t, body, "2h ago") | |
| 75 | + | assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter") | |
| 76 | + | assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestInboxPageIsMissingForEveryoneElse(t *testing.T) { | |
| 80 | + | t.Parallel() | |
| 81 | + | svc := mocks.NewInboxService(t) // never asked | |
| 82 | + | srv := inboxServer(t, svc) | |
| 83 | + | missing := inboxGet(srv, "/-/nothing-here", false) | |
| 84 | + | require.Equal(t, 404, missing.Code) | |
| 85 | + | ||
| 86 | + | anon := inboxGet(srv, "/-/inbox", false) | |
| 87 | + | assert.Equal(t, 404, anon.Code) | |
| 88 | + | assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one") | |
| 89 | + | assert.NotContains(t, anon.Body.String(), "Open merge requests") | |
| 90 | + | ||
| 91 | + | // A bad cookie is anonymous as well. | |
| 92 | + | req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil) | |
| 93 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"}) | |
| 94 | + | rec := httptest.NewRecorder() | |
| 95 | + | srv.ServeHTTP(rec, req) | |
| 96 | + | assert.Equal(t, 404, rec.Code) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestInboxNotMountedWithoutTheService(t *testing.T) { | |
| 100 | + | t.Parallel() | |
| 101 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}}) | |
| 102 | + | assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestInboxFilterAndEmptyStates(t *testing.T) { | |
| 106 | + | t.Parallel() | |
| 107 | + | svc := mocks.NewInboxService(t) | |
| 108 | + | ov := inboxOverview(t) | |
| 109 | + | filtered := ov | |
| 110 | + | filtered.MergeRequests, filtered.Issues = nil, nil | |
| 111 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil) | |
| 112 | + | srv := inboxServer(t, svc) | |
| 113 | + | ||
| 114 | + | rec := inboxGet(srv, "/-/inbox?repo=gamma", true) | |
| 115 | + | require.Equal(t, 200, rec.Code) | |
| 116 | + | body := rec.Body.String() | |
| 117 | + | assert.Contains(t, body, "no open merge requests.") | |
| 118 | + | assert.Contains(t, body, "no open issues.") | |
| 119 | + | assert.Contains(t, body, `href="/-/inbox"`, "the link back to all") | |
| 120 | + | ||
| 121 | + | assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) { | |
| 125 | + | t.Parallel() | |
| 126 | + | svc := mocks.NewInboxService(t) | |
| 127 | + | ov := inboxOverview(t) | |
| 128 | + | ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2} | |
| 129 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only | |
| 130 | + | srv := inboxServer(t, svc) | |
| 131 | + | ||
| 132 | + | anon := inboxGet(srv, "/", false) | |
| 133 | + | require.Equal(t, 200, anon.Code) | |
| 134 | + | assert.NotContains(t, anon.Body.String(), "/-/inbox") | |
| 135 | + | assert.NotContains(t, anon.Body.String(), "Inbox") | |
| 136 | + | ||
| 137 | + | owner := inboxGet(srv, "/", true) | |
| 138 | + | require.Equal(t, 200, owner.Code) | |
| 139 | + | assert.Contains(t, owner.Body.String(), `href="/-/inbox"`) | |
| 140 | + | assert.Contains(t, owner.Body.String(), `<span class="count">5</span>`) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestInboxStreamIsTheOwnersAlone(t *testing.T) { | |
| 144 | + | t.Parallel() | |
| 145 | + | f := &watchProbe{release: make(chan struct{})} | |
| 146 | + | svc := mocks.NewInboxService(t) | |
| 147 | + | srv := newTestServer(t, Config{ | |
| 148 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |
| 149 | + | Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 150 | + | }) | |
| 151 | + | ts := httptest.NewServer(srv) | |
| 152 | + | t.Cleanup(ts.Close) | |
| 153 | + | ||
| 154 | + | unknown, _ := open(t, ts, "topic=nope&view=inbox", nil) | |
| 155 | + | want, _ := io.ReadAll(unknown.Body) | |
| 156 | + | for name, hdr := range map[string]map[string]string{ | |
| 157 | + | "anonymous": nil, | |
| 158 | + | "another token": {"Cookie": CookieName + "=other"}, | |
| 159 | + | } { | |
| 160 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", hdr) | |
| 161 | + | body, _ := io.ReadAll(resp.Body) | |
| 162 | + | assert.Equal(t, unknown.StatusCode, resp.StatusCode, name) | |
| 163 | + | assert.Equal(t, string(want), string(body), name) | |
| 164 | + | assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name) | |
| 165 | + | } | |
| 166 | + | assert.Zero(t, f.opened.Load(), "no watch is opened for them") | |
| 167 | + | ||
| 168 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"}) | |
| 169 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 170 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestInboxLiveFragments(t *testing.T) { | |
| 174 | + | t.Parallel() | |
| 175 | + | svc := mocks.NewInboxService(t) | |
| 176 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil) | |
| 177 | + | srv := inboxServer(t, svc) | |
| 178 | + | ls := &liveStream{s: srv, view: "inbox"} | |
| 179 | + | frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | require.Len(t, frags, 4) | |
| 182 | + | assert.Contains(t, frags[0].html, `id="inbox-counts"`) | |
| 183 | + | assert.Contains(t, frags[1].html, `id="inbox-repos"`) | |
| 184 | + | assert.Contains(t, frags[2].html, `id="inbox-mrs"`) | |
| 185 | + | assert.Contains(t, frags[3].html, `id="inbox-issues"`) | |
| 186 | + | ||
| 187 | + | none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}}) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | assert.Empty(t, none, "a push is not inbox news") | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // withOwner is a context as the session middleware leaves it for the owner. | |
| 193 | + | func withOwner(ctx context.Context) context.Context { | |
| 194 | + | return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner()) | |
| 195 | + | } | |
| @@ -0,0 +1,195 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/http/httptest" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | func inboxOverview(t testing.TB) ports.InboxOverview { | |||
| 23 | + | t.Helper() | |||
| 24 | + | mrTitle, err := domain.ParseMRTitle("fix <b>search</b>") | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | isTitle, err := domain.ParseIssueTitle("crash on start") | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | at := time.Now().Add(-2 * time.Hour) | |||
| 29 | + | ov := ports.InboxOverview{ | |||
| 30 | + | MergeRequests: []ports.InboxMergeRequest{{MergeRequest: domain.MergeRequest{ | |||
| 31 | + | Repo: repoName(t, "alpha"), Number: 4, Title: mrTitle, State: domain.MRStateOpen, UpdatedAt: at, | |||
| 32 | + | }}}, | |||
| 33 | + | Issues: []domain.Issue{{Repo: repoName(t, "beta"), Number: 7, Title: isTitle, State: domain.IssueStateOpen, UpdatedAt: at}}, | |||
| 34 | + | Repos: []domain.RepoName{repoName(t, "alpha"), repoName(t, "beta")}, | |||
| 35 | + | } | |||
| 36 | + | ov.Counts = ports.InboxCounts{MergeRequests: 1, Issues: 1} | |||
| 37 | + | return ov | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func inboxServer(t *testing.T, inbox ports.InboxService) *Server { | |||
| 41 | + | t.Helper() | |||
| 42 | + | return newTestServer(t, Config{ | |||
| 43 | + | Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, | |||
| 44 | + | Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func inboxGet(srv *Server, path string, owner bool) *httptest.ResponseRecorder { | |||
| 49 | + | req := httptest.NewRequest(http.MethodGet, path, nil) | |||
| 50 | + | if owner { | |||
| 51 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |||
| 52 | + | } | |||
| 53 | + | rec := httptest.NewRecorder() | |||
| 54 | + | srv.ServeHTTP(rec, req) | |||
| 55 | + | return rec | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestInboxPageForTheOwner(t *testing.T) { | |||
| 59 | + | t.Parallel() | |||
| 60 | + | svc := mocks.NewInboxService(t) | |||
| 61 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil) | |||
| 62 | + | srv := inboxServer(t, svc) | |||
| 63 | + | ||||
| 64 | + | rec := inboxGet(srv, "/-/inbox", true) | |||
| 65 | + | require.Equal(t, 200, rec.Code, rec.Body.String()) | |||
| 66 | + | body := rec.Body.String() | |||
| 67 | + | assert.Contains(t, body, "<strong>1</strong> merge requests · <strong>1</strong> issues") | |||
| 68 | + | assert.Contains(t, body, "Open merge requests") | |||
| 69 | + | assert.Contains(t, body, "Open issues") | |||
| 70 | + | assert.Contains(t, body, `href="/alpha/merge-requests/4"`) | |||
| 71 | + | assert.Contains(t, body, "fix <b>search</b>") | |||
| 72 | + | assert.Contains(t, body, `href="/beta/issues/7"`) | |||
| 73 | + | assert.Contains(t, body, "#7") | |||
| 74 | + | assert.Contains(t, body, "2h ago") | |||
| 75 | + | assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter") | |||
| 76 | + | assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestInboxPageIsMissingForEveryoneElse(t *testing.T) { | |||
| 80 | + | t.Parallel() | |||
| 81 | + | svc := mocks.NewInboxService(t) // never asked | |||
| 82 | + | srv := inboxServer(t, svc) | |||
| 83 | + | missing := inboxGet(srv, "/-/nothing-here", false) | |||
| 84 | + | require.Equal(t, 404, missing.Code) | |||
| 85 | + | ||||
| 86 | + | anon := inboxGet(srv, "/-/inbox", false) | |||
| 87 | + | assert.Equal(t, 404, anon.Code) | |||
| 88 | + | assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one") | |||
| 89 | + | assert.NotContains(t, anon.Body.String(), "Open merge requests") | |||
| 90 | + | ||||
| 91 | + | // A bad cookie is anonymous as well. | |||
| 92 | + | req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil) | |||
| 93 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"}) | |||
| 94 | + | rec := httptest.NewRecorder() | |||
| 95 | + | srv.ServeHTTP(rec, req) | |||
| 96 | + | assert.Equal(t, 404, rec.Code) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestInboxNotMountedWithoutTheService(t *testing.T) { | |||
| 100 | + | t.Parallel() | |||
| 101 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}}) | |||
| 102 | + | assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestInboxFilterAndEmptyStates(t *testing.T) { | |||
| 106 | + | t.Parallel() | |||
| 107 | + | svc := mocks.NewInboxService(t) | |||
| 108 | + | ov := inboxOverview(t) | |||
| 109 | + | filtered := ov | |||
| 110 | + | filtered.MergeRequests, filtered.Issues = nil, nil | |||
| 111 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil) | |||
| 112 | + | srv := inboxServer(t, svc) | |||
| 113 | + | ||||
| 114 | + | rec := inboxGet(srv, "/-/inbox?repo=gamma", true) | |||
| 115 | + | require.Equal(t, 200, rec.Code) | |||
| 116 | + | body := rec.Body.String() | |||
| 117 | + | assert.Contains(t, body, "no open merge requests.") | |||
| 118 | + | assert.Contains(t, body, "no open issues.") | |||
| 119 | + | assert.Contains(t, body, `href="/-/inbox"`, "the link back to all") | |||
| 120 | + | ||||
| 121 | + | assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) { | |||
| 125 | + | t.Parallel() | |||
| 126 | + | svc := mocks.NewInboxService(t) | |||
| 127 | + | ov := inboxOverview(t) | |||
| 128 | + | ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2} | |||
| 129 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only | |||
| 130 | + | srv := inboxServer(t, svc) | |||
| 131 | + | ||||
| 132 | + | anon := inboxGet(srv, "/", false) | |||
| 133 | + | require.Equal(t, 200, anon.Code) | |||
| 134 | + | assert.NotContains(t, anon.Body.String(), "/-/inbox") | |||
| 135 | + | assert.NotContains(t, anon.Body.String(), "Inbox") | |||
| 136 | + | ||||
| 137 | + | owner := inboxGet(srv, "/", true) | |||
| 138 | + | require.Equal(t, 200, owner.Code) | |||
| 139 | + | assert.Contains(t, owner.Body.String(), `href="/-/inbox"`) | |||
| 140 | + | assert.Contains(t, owner.Body.String(), `<span class="count">5</span>`) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestInboxStreamIsTheOwnersAlone(t *testing.T) { | |||
| 144 | + | t.Parallel() | |||
| 145 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 146 | + | svc := mocks.NewInboxService(t) | |||
| 147 | + | srv := newTestServer(t, Config{ | |||
| 148 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |||
| 149 | + | Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |||
| 150 | + | }) | |||
| 151 | + | ts := httptest.NewServer(srv) | |||
| 152 | + | t.Cleanup(ts.Close) | |||
| 153 | + | ||||
| 154 | + | unknown, _ := open(t, ts, "topic=nope&view=inbox", nil) | |||
| 155 | + | want, _ := io.ReadAll(unknown.Body) | |||
| 156 | + | for name, hdr := range map[string]map[string]string{ | |||
| 157 | + | "anonymous": nil, | |||
| 158 | + | "another token": {"Cookie": CookieName + "=other"}, | |||
| 159 | + | } { | |||
| 160 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", hdr) | |||
| 161 | + | body, _ := io.ReadAll(resp.Body) | |||
| 162 | + | assert.Equal(t, unknown.StatusCode, resp.StatusCode, name) | |||
| 163 | + | assert.Equal(t, string(want), string(body), name) | |||
| 164 | + | assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name) | |||
| 165 | + | } | |||
| 166 | + | assert.Zero(t, f.opened.Load(), "no watch is opened for them") | |||
| 167 | + | ||||
| 168 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"}) | |||
| 169 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 170 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestInboxLiveFragments(t *testing.T) { | |||
| 174 | + | t.Parallel() | |||
| 175 | + | svc := mocks.NewInboxService(t) | |||
| 176 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil) | |||
| 177 | + | srv := inboxServer(t, svc) | |||
| 178 | + | ls := &liveStream{s: srv, view: "inbox"} | |||
| 179 | + | frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | require.Len(t, frags, 4) | |||
| 182 | + | assert.Contains(t, frags[0].html, `id="inbox-counts"`) | |||
| 183 | + | assert.Contains(t, frags[1].html, `id="inbox-repos"`) | |||
| 184 | + | assert.Contains(t, frags[2].html, `id="inbox-mrs"`) | |||
| 185 | + | assert.Contains(t, frags[3].html, `id="inbox-issues"`) | |||
| 186 | + | ||||
| 187 | + | none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}}) | |||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | assert.Empty(t, none, "a push is not inbox news") | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // withOwner is a context as the session middleware leaves it for the owner. | |||
| 193 | + | func withOwner(ctx context.Context) context.Context { | |||
| 194 | + | return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner()) | |||
| 195 | + | } | |||
M internal/adapters/web/pages.templ +8 -0
| @@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) { | |||
| 9 | 9 | <div class="home" data-layout="cards"> | |
| 10 | 10 | <section class="home-repos" aria-label="Repositories"> | |
| 11 | 11 | <div class="home-bar"> | |
| 12 | + | if p.Inbox != nil { | |
| 13 | + | <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox"> | |
| 14 | + | Inbox | |
| 15 | + | if p.Inbox.Count >= 0 { | |
| 16 | + | <span class="count">{ itoa(p.Inbox.Count) }</span> | |
| 17 | + | } | |
| 18 | + | </a> | |
| 19 | + | } | |
| 12 | 20 | <div class="filter"> | |
| 13 | 21 | <label for="home-filter" class="sr">filter</label> | |
| 14 | 22 | <input | |
| @@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) { | |||||
| 9 | <div class="home" data-layout="cards"> | 9 | <div class="home" data-layout="cards"> | ||
| 10 | <section class="home-repos" aria-label="Repositories"> | 10 | <section class="home-repos" aria-label="Repositories"> | ||
| 11 | <div class="home-bar"> | 11 | <div class="home-bar"> | ||
| 12 | + | if p.Inbox != nil { | |||
| 13 | + | <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox"> | |||
| 14 | + | Inbox | |||
| 15 | + | if p.Inbox.Count >= 0 { | |||
| 16 | + | <span class="count">{ itoa(p.Inbox.Count) }</span> | |||
| 17 | + | } | |||
| 18 | + | </a> | |||
| 19 | + | } | |||
| 12 | <div class="filter"> | 20 | <div class="filter"> | ||
| 13 | <label for="home-filter" class="sr">filter</label> | 21 | <label for="home-filter" class="sr">filter</label> | ||
| 14 | <input | 22 | <input | ||
M internal/adapters/web/pages_templ.go +409 -363 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +9 -0
| @@ -186,6 +186,15 @@ | |||
| 186 | 186 | } | |
| 187 | 187 | #mr-notice:empty { display: none; } | |
| 188 | 188 | ||
| 189 | + | /* the owner's inbox: counters, repository filter, then the two lists */ | |
| 190 | + | .inbox { | |
| 191 | + | display: flex; flex-direction: column; gap: var(--s4); | |
| 192 | + | & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); } | |
| 193 | + | & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); } | |
| 194 | + | & .since { align-self: flex-start; max-width: 100%; } | |
| 195 | + | } | |
| 196 | + | .inbox-btn { gap: var(--s2); } | |
| 197 | + | ||
| 189 | 198 | @media (max-width: 700px) { | |
| 190 | 199 | .list.mrs .c-who { display: none; } | |
| 191 | 200 | } | |
| @@ -186,6 +186,15 @@ | |||||
| 186 | } | 186 | } | ||
| 187 | #mr-notice:empty { display: none; } | 187 | #mr-notice:empty { display: none; } | ||
| 188 | 188 | ||||
| 189 | + | /* the owner's inbox: counters, repository filter, then the two lists */ | |||
| 190 | + | .inbox { | |||
| 191 | + | display: flex; flex-direction: column; gap: var(--s4); | |||
| 192 | + | & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); } | |||
| 193 | + | & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); } | |||
| 194 | + | & .since { align-self: flex-start; max-width: 100%; } | |||
| 195 | + | } | |||
| 196 | + | .inbox-btn { gap: var(--s2); } | |||
| 197 | + | ||||
| 189 | @media (max-width: 700px) { | 198 | @media (max-width: 700px) { | ||
| 190 | .list.mrs .c-who { display: none; } | 199 | .list.mrs .c-who { display: none; } | ||
| 191 | } | 200 | } | ||
M internal/adapters/web/view.go +10 -0
| @@ -135,6 +135,16 @@ type SortLink struct { | |||
| 135 | 135 | type HomePage struct { | |
| 136 | 136 | Sorts []SortLink | |
| 137 | 137 | Rows []HomeRow | |
| 138 | + | // Inbox is the button to the owner's inbox; nil unless the owner is signed | |
| 139 | + | // in. | |
| 140 | + | Inbox *InboxButton | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // InboxButton is the link to the inbox with the number of open items; Count is | |
| 144 | + | // negative when it could not be read. | |
| 145 | + | type InboxButton struct { | |
| 146 | + | Href string | |
| 147 | + | Count int | |
| 138 | 148 | } | |
| 139 | 149 | ||
| 140 | 150 | // Fact is one key/value row of the facts block. | |
| @@ -135,6 +135,16 @@ type SortLink struct { | |||||
| 135 | type HomePage struct { | 135 | type HomePage struct { | ||
| 136 | Sorts []SortLink | 136 | Sorts []SortLink | ||
| 137 | Rows []HomeRow | 137 | Rows []HomeRow | ||
| 138 | + | // Inbox is the button to the owner's inbox; nil unless the owner is signed | |||
| 139 | + | // in. | |||
| 140 | + | Inbox *InboxButton | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // InboxButton is the link to the inbox with the number of open items; Count is | |||
| 144 | + | // negative when it could not be read. | |||
| 145 | + | type InboxButton struct { | |||
| 146 | + | Href string | |||
| 147 | + | Count int | |||
| 138 | } | 148 | } | ||
| 139 | 149 | ||||
| 140 | // Fact is one key/value row of the facts block. | 150 | // Fact is one key/value row of the facts block. | ||
M internal/adapters/web/web.go +7 -0
| @@ -63,6 +63,10 @@ type Config struct { | |||
| 63 | 63 | // it there is no such redirect. Leave it unset rather than assigning a | |
| 64 | 64 | // typed nil pointer. | |
| 65 | 65 | IssueNumbers ports.IssueNumberResolver | |
| 66 | + | // Inbox serves the owner's inbox page (/-/inbox) and the button that | |
| 67 | + | // leads to it. Without it there is neither. Leave it unset rather than | |
| 68 | + | // assigning a typed nil pointer. | |
| 69 | + | Inbox ports.InboxService | |
| 66 | 70 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |
| 67 | 71 | // commits and merge requests. Without it there are no such pages. Leave it | |
| 68 | 72 | // unset rather than assigning a typed nil pointer. | |
| @@ -175,6 +179,9 @@ func (s *Server) routes() *http.ServeMux { | |||
| 175 | 179 | if s.passkeysOn() { | |
| 176 | 180 | s.passkeyRoutes(mux) | |
| 177 | 181 | } | |
| 182 | + | if s.cfg.Inbox != nil { | |
| 183 | + | mux.HandleFunc("GET "+inboxPath, s.page(s.inbox)) | |
| 184 | + | } | |
| 178 | 185 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 179 | 186 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 180 | 187 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| @@ -63,6 +63,10 @@ type Config struct { | |||||
| 63 | // it there is no such redirect. Leave it unset rather than assigning a | 63 | // it there is no such redirect. Leave it unset rather than assigning a | ||
| 64 | // typed nil pointer. | 64 | // typed nil pointer. | ||
| 65 | IssueNumbers ports.IssueNumberResolver | 65 | IssueNumbers ports.IssueNumberResolver | ||
| 66 | + | // Inbox serves the owner's inbox page (/-/inbox) and the button that | |||
| 67 | + | // leads to it. Without it there is neither. Leave it unset rather than | |||
| 68 | + | // assigning a typed nil pointer. | |||
| 69 | + | Inbox ports.InboxService | |||
| 66 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | 70 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | ||
| 67 | // commits and merge requests. Without it there are no such pages. Leave it | 71 | // commits and merge requests. Without it there are no such pages. Leave it | ||
| 68 | // unset rather than assigning a typed nil pointer. | 72 | // unset rather than assigning a typed nil pointer. | ||
| @@ -175,6 +179,9 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 175 | if s.passkeysOn() { | 179 | if s.passkeysOn() { | ||
| 176 | s.passkeyRoutes(mux) | 180 | s.passkeyRoutes(mux) | ||
| 177 | } | 181 | } | ||
| 182 | + | if s.cfg.Inbox != nil { | |||
| 183 | + | mux.HandleFunc("GET "+inboxPath, s.page(s.inbox)) | |||
| 184 | + | } | |||
| 178 | mux.HandleFunc("GET /{$}", s.page(s.home)) | 185 | mux.HandleFunc("GET /{$}", s.page(s.home)) | ||
| 179 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | 186 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | ||
| 180 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | 187 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | ||
M internal/app/testdata/web/commits-root.html +1 -1
| @@ -972,7 +972,7 @@ | |||
| 972 | 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 973 | 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 974 | 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 975 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 975 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 976 | 976 | gap -> ermiteLive.reload()"> | |
| 977 | 977 | </span> | |
| 978 | 978 | </div> | |
| @@ -972,7 +972,7 @@ | |||||
| 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 975 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | 975 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 976 | gap -> ermiteLive.reload()"> | 976 | gap -> ermiteLive.reload()"> | ||
| 977 | </span> | 977 | </span> | ||
| 978 | </div> | 978 | </div> | ||
M internal/app/testdata/web/commits-small.html +1 -1
| @@ -317,7 +317,7 @@ | |||
| 317 | 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 318 | 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 319 | 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 320 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 320 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 321 | 321 | gap -> ermiteLive.reload()"> | |
| 322 | 322 | </span> | |
| 323 | 323 | </div> | |
| @@ -317,7 +317,7 @@ | |||||
| 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 320 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | 320 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 321 | gap -> ermiteLive.reload()"> | 321 | gap -> ermiteLive.reload()"> | ||
| 322 | </span> | 322 | </span> | ||
| 323 | </div> | 323 | </div> | ||
M internal/app/testdata/web/home.html +1 -1
| @@ -191,7 +191,7 @@ | |||
| 191 | 191 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 192 | 192 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 193 | 193 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 194 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 194 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 195 | 195 | gap -> ermiteLive.reload()"> | |
| 196 | 196 | </span> | |
| 197 | 197 | </div> | |
| @@ -191,7 +191,7 @@ | |||||
| 191 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 191 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 192 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 192 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 193 | htmx:sse:before:connection -> ermiteLive.gate(event); | 193 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 194 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | 194 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 195 | gap -> ermiteLive.reload()"> | 195 | gap -> ermiteLive.reload()"> | ||
| 196 | </span> | 196 | </span> | ||
| 197 | </div> | 197 | </div> | ||
M internal/app/testdata/web/refs.html +1 -1
| @@ -199,7 +199,7 @@ | |||
| 199 | 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 200 | 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 201 | 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 202 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 202 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 203 | 203 | gap -> ermiteLive.reload()"> | |
| 204 | 204 | </span> | |
| 205 | 205 | </div> | |
| @@ -199,7 +199,7 @@ | |||||
| 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 202 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | 202 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 203 | gap -> ermiteLive.reload()"> | 203 | gap -> ermiteLive.reload()"> | ||
| 204 | </span> | 204 | </span> | ||
| 205 | </div> | 205 | </div> | ||
M internal/app/testdata/web/summary.html +1 -1
| @@ -225,7 +225,7 @@ | |||
| 225 | 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 226 | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 227 | 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 228 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 228 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 229 | 229 | gap -> ermiteLive.reload()"> | |
| 230 | 230 | </span> | |
| 231 | 231 | </div> | |
| @@ -225,7 +225,7 @@ | |||||
| 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 228 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | 228 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 229 | gap -> ermiteLive.reload()"> | 229 | gap -> ermiteLive.reload()"> | ||
| 230 | </span> | 230 | </span> | ||
| 231 | </div> | 231 | </div> | ||
M internal/app/web.go +1 -0
| @@ -47,6 +47,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 47 | 47 | MergeRequests: f.MergeRequests, | |
| 48 | 48 | Issues: f.Issues, | |
| 49 | 49 | IssueNumbers: f.Issues, | |
| 50 | + | Inbox: f.Inbox, | |
| 50 | 51 | Runs: f.CI, | |
| 51 | 52 | Artifacts: artifacts, | |
| 52 | 53 | Events: f.EventLog, | |
| @@ -47,6 +47,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||||
| 47 | MergeRequests: f.MergeRequests, | 47 | MergeRequests: f.MergeRequests, | ||
| 48 | Issues: f.Issues, | 48 | Issues: f.Issues, | ||
| 49 | IssueNumbers: f.Issues, | 49 | IssueNumbers: f.Issues, | ||
| 50 | + | Inbox: f.Inbox, | |||
| 50 | Runs: f.CI, | 51 | Runs: f.CI, | ||
| 51 | Artifacts: artifacts, | 52 | Artifacts: artifacts, | ||
| 52 | Events: f.EventLog, | 53 | Events: f.EventLog, | ||
A internal/app/web_inbox_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "context" | |
| 8 | + | "net/http" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The owner's inbox end to end: two repositories with open merge requests and | |
| 20 | + | // issues, the filter, the home button, the owner stream, and a 404 for | |
| 21 | + | // everyone who is not the signed-in owner. | |
| 22 | + | func TestWebInboxEndToEnd(t *testing.T) { | |
| 23 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 24 | + | b := browser{r.harness} | |
| 25 | + | token := r.signIn(t) | |
| 26 | + | ||
| 27 | + | _, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate)) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | otherURL := r.urlAuth(adminToken, "/other.git") | |
| 30 | + | r.mustGit(r.wt, "push", "-q", otherURL, "main") | |
| 31 | + | r.mustGit(r.wt, "push", "-q", otherURL, "feat/search") | |
| 32 | + | ||
| 33 | + | owner := domain.IssuesOwner | |
| 34 | + | for _, n := range []string{"proj", "other"} { | |
| 35 | + | _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner}) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Nothing is open yet. | |
| 40 | + | empty := b.get("/-/inbox", as(token)) | |
| 41 | + | require.Equal(t, 200, empty.status, empty.body) | |
| 42 | + | assert.Contains(t, empty.body, "no open merge requests.") | |
| 43 | + | assert.Contains(t, empty.body, "no open issues.") | |
| 44 | + | ||
| 45 | + | // The owner stream sees both repositories. | |
| 46 | + | stream := ownerStream(t, r.harness, token) | |
| 47 | + | ||
| 48 | + | root := domain.Root() | |
| 49 | + | _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"}) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | ev := stream.next(t, watchWait) | |
| 52 | + | assert.Equal(t, "inbox", ev.name) | |
| 53 | + | assert.Contains(t, ev.data, `id="inbox-counts"`) | |
| 54 | + | for range 3 { // the other fragments of this event | |
| 55 | + | stream.next(t, watchWait) | |
| 56 | + | } | |
| 57 | + | _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"}) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | var seen strings.Builder | |
| 60 | + | for range 4 { // the four fragments of this event | |
| 61 | + | seen.WriteString(stream.next(t, watchWait).data) | |
| 62 | + | } | |
| 63 | + | assert.Contains(t, seen.String(), "search is slow", "an event of the second repository") | |
| 64 | + | _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"}) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | _, err = r.forge.Issues.Create(r.ctx, root, r.name("proj"), ports.CreateIssueParams{Title: "proj bug"}) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | ||
| 69 | + | all := b.get("/-/inbox", as(token)) | |
| 70 | + | require.Equal(t, 200, all.status, all.body) | |
| 71 | + | assert.Equal(t, "private, no-store", all.hdr.Get("Cache-Control")) | |
| 72 | + | assert.Contains(t, all.body, "<strong>2</strong> merge requests · <strong>2</strong> issues") | |
| 73 | + | for _, want := range []string{"add search", "other work", "search is slow", "proj bug", `href="/other/issues/`, `href="/proj/merge-requests/`} { | |
| 74 | + | assert.Contains(t, all.body, want) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | only := b.get("/-/inbox?repo=other", as(token)) | |
| 78 | + | require.Equal(t, 200, only.status) | |
| 79 | + | assert.Contains(t, only.body, "<strong>2</strong> merge requests · <strong>2</strong> issues", "the counters ignore the filter") | |
| 80 | + | assert.Contains(t, only.body, "other work") | |
| 81 | + | assert.Contains(t, only.body, "search is slow") | |
| 82 | + | assert.NotContains(t, only.body, "add search") | |
| 83 | + | assert.NotContains(t, only.body, "proj bug") | |
| 84 | + | ||
| 85 | + | // Issues off: that repository's issues leave the inbox, its merge requests stay. | |
| 86 | + | off := domain.IssuesOff | |
| 87 | + | _, err = r.forge.Repos.Update(r.ctx, root, r.name("other"), ports.UpdateRepoParams{Issues: &off}) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | hidden := b.get("/-/inbox", as(token)) | |
| 90 | + | assert.Contains(t, hidden.body, "<strong>2</strong> merge requests · <strong>1</strong> issues") | |
| 91 | + | assert.NotContains(t, hidden.body, "search is slow") | |
| 92 | + | ||
| 93 | + | // The home button, for the owner only. | |
| 94 | + | assert.Contains(t, b.get("/", as(token)).body, `href="/-/inbox"`) | |
| 95 | + | anonHome := b.get("/", nil) | |
| 96 | + | assert.NotContains(t, anonHome.body, "/-/inbox") | |
| 97 | + | assert.NotContains(t, anonHome.body, "Inbox") | |
| 98 | + | ||
| 99 | + | // Anyone else: the 404 of an unknown page, and no stream. | |
| 100 | + | missing := b.get("/-/no-such-page", nil) | |
| 101 | + | anon := b.get("/-/inbox", nil) | |
| 102 | + | assert.Equal(t, 404, anon.status) | |
| 103 | + | assert.Equal(t, missing.body, anon.body) | |
| 104 | + | assert.Equal(t, 404, b.get("/-/inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status, "a token is not a session") | |
| 105 | + | unknown := b.get("/-/events?topic=nope&view=inbox", nil) | |
| 106 | + | ownerAnon := b.get("/-/events?topic=owner&view=inbox", nil) | |
| 107 | + | assert.Equal(t, unknown.status, ownerAnon.status, "the answer to an unknown topic") | |
| 108 | + | assert.Equal(t, strings.ReplaceAll(unknown.body, "nope", "owner"), ownerAnon.body) | |
| 109 | + | assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // ownerStream opens the owner topic with the session cookie. | |
| 113 | + | func ownerStream(t *testing.T, h *harness, token string) *sseStream { | |
| 114 | + | t.Helper() | |
| 115 | + | ctx, cancel := context.WithCancel(h.ctx) | |
| 116 | + | t.Cleanup(cancel) | |
| 117 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | req.Header.Set("Cookie", sessionCookie+"="+token) | |
| 120 | + | resp, err := http.DefaultClient.Do(req) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | t.Cleanup(func() { _ = resp.Body.Close() }) | |
| 123 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 124 | + | s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel} | |
| 125 | + | go func() { | |
| 126 | + | defer close(s.events) | |
| 127 | + | sc := bufio.NewScanner(resp.Body) | |
| 128 | + | sc.Buffer(make([]byte, 0, 64<<10), 1<<20) | |
| 129 | + | var cur sseEvent | |
| 130 | + | var data []string | |
| 131 | + | for sc.Scan() { | |
| 132 | + | line := sc.Text() | |
| 133 | + | switch { | |
| 134 | + | case line == "": | |
| 135 | + | if cur.id != "" || cur.name != "" || len(data) > 0 { | |
| 136 | + | cur.data = strings.Join(data, "\n") | |
| 137 | + | s.events <- cur | |
| 138 | + | } | |
| 139 | + | cur, data = sseEvent{}, nil | |
| 140 | + | case strings.HasPrefix(line, ":"): | |
| 141 | + | cur.comment = true | |
| 142 | + | case strings.HasPrefix(line, "event: "): | |
| 143 | + | cur.name = strings.TrimPrefix(line, "event: ") | |
| 144 | + | case strings.HasPrefix(line, "id: "): | |
| 145 | + | cur.id = strings.TrimPrefix(line, "id: ") | |
| 146 | + | case strings.HasPrefix(line, "data: "): | |
| 147 | + | data = append(data, strings.TrimPrefix(line, "data: ")) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | }() | |
| 151 | + | return s | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "context" | |||
| 8 | + | "net/http" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // The owner's inbox end to end: two repositories with open merge requests and | |||
| 20 | + | // issues, the filter, the home button, the owner stream, and a 404 for | |||
| 21 | + | // everyone who is not the signed-in owner. | |||
| 22 | + | func TestWebInboxEndToEnd(t *testing.T) { | |||
| 23 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 24 | + | b := browser{r.harness} | |||
| 25 | + | token := r.signIn(t) | |||
| 26 | + | ||||
| 27 | + | _, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate)) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | otherURL := r.urlAuth(adminToken, "/other.git") | |||
| 30 | + | r.mustGit(r.wt, "push", "-q", otherURL, "main") | |||
| 31 | + | r.mustGit(r.wt, "push", "-q", otherURL, "feat/search") | |||
| 32 | + | ||||
| 33 | + | owner := domain.IssuesOwner | |||
| 34 | + | for _, n := range []string{"proj", "other"} { | |||
| 35 | + | _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner}) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Nothing is open yet. | |||
| 40 | + | empty := b.get("/-/inbox", as(token)) | |||
| 41 | + | require.Equal(t, 200, empty.status, empty.body) | |||
| 42 | + | assert.Contains(t, empty.body, "no open merge requests.") | |||
| 43 | + | assert.Contains(t, empty.body, "no open issues.") | |||
| 44 | + | ||||
| 45 | + | // The owner stream sees both repositories. | |||
| 46 | + | stream := ownerStream(t, r.harness, token) | |||
| 47 | + | ||||
| 48 | + | root := domain.Root() | |||
| 49 | + | _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"}) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | ev := stream.next(t, watchWait) | |||
| 52 | + | assert.Equal(t, "inbox", ev.name) | |||
| 53 | + | assert.Contains(t, ev.data, `id="inbox-counts"`) | |||
| 54 | + | for range 3 { // the other fragments of this event | |||
| 55 | + | stream.next(t, watchWait) | |||
| 56 | + | } | |||
| 57 | + | _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"}) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | var seen strings.Builder | |||
| 60 | + | for range 4 { // the four fragments of this event | |||
| 61 | + | seen.WriteString(stream.next(t, watchWait).data) | |||
| 62 | + | } | |||
| 63 | + | assert.Contains(t, seen.String(), "search is slow", "an event of the second repository") | |||
| 64 | + | _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"}) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | _, err = r.forge.Issues.Create(r.ctx, root, r.name("proj"), ports.CreateIssueParams{Title: "proj bug"}) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | ||||
| 69 | + | all := b.get("/-/inbox", as(token)) | |||
| 70 | + | require.Equal(t, 200, all.status, all.body) | |||
| 71 | + | assert.Equal(t, "private, no-store", all.hdr.Get("Cache-Control")) | |||
| 72 | + | assert.Contains(t, all.body, "<strong>2</strong> merge requests · <strong>2</strong> issues") | |||
| 73 | + | for _, want := range []string{"add search", "other work", "search is slow", "proj bug", `href="/other/issues/`, `href="/proj/merge-requests/`} { | |||
| 74 | + | assert.Contains(t, all.body, want) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | only := b.get("/-/inbox?repo=other", as(token)) | |||
| 78 | + | require.Equal(t, 200, only.status) | |||
| 79 | + | assert.Contains(t, only.body, "<strong>2</strong> merge requests · <strong>2</strong> issues", "the counters ignore the filter") | |||
| 80 | + | assert.Contains(t, only.body, "other work") | |||
| 81 | + | assert.Contains(t, only.body, "search is slow") | |||
| 82 | + | assert.NotContains(t, only.body, "add search") | |||
| 83 | + | assert.NotContains(t, only.body, "proj bug") | |||
| 84 | + | ||||
| 85 | + | // Issues off: that repository's issues leave the inbox, its merge requests stay. | |||
| 86 | + | off := domain.IssuesOff | |||
| 87 | + | _, err = r.forge.Repos.Update(r.ctx, root, r.name("other"), ports.UpdateRepoParams{Issues: &off}) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | hidden := b.get("/-/inbox", as(token)) | |||
| 90 | + | assert.Contains(t, hidden.body, "<strong>2</strong> merge requests · <strong>1</strong> issues") | |||
| 91 | + | assert.NotContains(t, hidden.body, "search is slow") | |||
| 92 | + | ||||
| 93 | + | // The home button, for the owner only. | |||
| 94 | + | assert.Contains(t, b.get("/", as(token)).body, `href="/-/inbox"`) | |||
| 95 | + | anonHome := b.get("/", nil) | |||
| 96 | + | assert.NotContains(t, anonHome.body, "/-/inbox") | |||
| 97 | + | assert.NotContains(t, anonHome.body, "Inbox") | |||
| 98 | + | ||||
| 99 | + | // Anyone else: the 404 of an unknown page, and no stream. | |||
| 100 | + | missing := b.get("/-/no-such-page", nil) | |||
| 101 | + | anon := b.get("/-/inbox", nil) | |||
| 102 | + | assert.Equal(t, 404, anon.status) | |||
| 103 | + | assert.Equal(t, missing.body, anon.body) | |||
| 104 | + | assert.Equal(t, 404, b.get("/-/inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status, "a token is not a session") | |||
| 105 | + | unknown := b.get("/-/events?topic=nope&view=inbox", nil) | |||
| 106 | + | ownerAnon := b.get("/-/events?topic=owner&view=inbox", nil) | |||
| 107 | + | assert.Equal(t, unknown.status, ownerAnon.status, "the answer to an unknown topic") | |||
| 108 | + | assert.Equal(t, strings.ReplaceAll(unknown.body, "nope", "owner"), ownerAnon.body) | |||
| 109 | + | assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // ownerStream opens the owner topic with the session cookie. | |||
| 113 | + | func ownerStream(t *testing.T, h *harness, token string) *sseStream { | |||
| 114 | + | t.Helper() | |||
| 115 | + | ctx, cancel := context.WithCancel(h.ctx) | |||
| 116 | + | t.Cleanup(cancel) | |||
| 117 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil) | |||
| 118 | + | require.NoError(t, err) | |||
| 119 | + | req.Header.Set("Cookie", sessionCookie+"="+token) | |||
| 120 | + | resp, err := http.DefaultClient.Do(req) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | t.Cleanup(func() { _ = resp.Body.Close() }) | |||
| 123 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 124 | + | s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel} | |||
| 125 | + | go func() { | |||
| 126 | + | defer close(s.events) | |||
| 127 | + | sc := bufio.NewScanner(resp.Body) | |||
| 128 | + | sc.Buffer(make([]byte, 0, 64<<10), 1<<20) | |||
| 129 | + | var cur sseEvent | |||
| 130 | + | var data []string | |||
| 131 | + | for sc.Scan() { | |||
| 132 | + | line := sc.Text() | |||
| 133 | + | switch { | |||
| 134 | + | case line == "": | |||
| 135 | + | if cur.id != "" || cur.name != "" || len(data) > 0 { | |||
| 136 | + | cur.data = strings.Join(data, "\n") | |||
| 137 | + | s.events <- cur | |||
| 138 | + | } | |||
| 139 | + | cur, data = sseEvent{}, nil | |||
| 140 | + | case strings.HasPrefix(line, ":"): | |||
| 141 | + | cur.comment = true | |||
| 142 | + | case strings.HasPrefix(line, "event: "): | |||
| 143 | + | cur.name = strings.TrimPrefix(line, "event: ") | |||
| 144 | + | case strings.HasPrefix(line, "id: "): | |||
| 145 | + | cur.id = strings.TrimPrefix(line, "id: ") | |||
| 146 | + | case strings.HasPrefix(line, "data: "): | |||
| 147 | + | data = append(data, strings.TrimPrefix(line, "data: ")) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | }() | |||
| 151 | + | return s | |||
| 152 | + | } | |||