detached bc6524c

feat(web): owner inbox page and home button

commit
bc6524ce22edf8d1d57debf44b4d4324876a00ec
author
brice · · 2026-10-19 06:37 UTC
parent
ab38ed2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
3333 ## Numbering
3434
3535 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 ## Numbering33 ## 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 (
2727 // the HTML fragments the page shows, sent as Server-Sent Events:
2828 //
2929 // GET /-/events?topic=home
30+// GET /-/events?topic=owner&view=inbox[&repo=<name>] (the signed-in owner only)
3031 // GET /-/events?topic=repo:<name>&view=summary|refs
3132 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3233 // GET /-/events?topic=repo:<name>&view=mrs
@@ -84,9 +85,14 @@ const (
8485 retryAfter = 10
8586 )
8687
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.
8894 func parseTopic(topic string) (repo string, ok bool) {
89- if topic == "home" {
95+ if topic == "home" || topic == ownerTopic {
9096 return "", true
9197 }
9298 if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
@@ -95,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) {
95101 return "", false
96102 }
97103
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+
98119 // events is GET /-/events.
99120 func (s *Server) events(w http.ResponseWriter, r *http.Request) {
100121 if s.cfg.Events == nil {
@@ -104,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
104125 q := r.URL.Query()
105126 topic := q.Get("topic")
106127 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+ }
107133 if !ok {
108134 denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
109135 return
@@ -227,6 +253,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
227253 }
228254
229255 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+ }
230259 handle := func(m recvd) bool {
231260 if m.err != nil {
232261 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=home29 // 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|refs31 // 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=mrs33 // GET /-/events?topic=repo:<name>&view=mrs
@@ -84,9 +85,14 @@ const (
84 retryAfter = 1085 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 "", true96 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 "", false101 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 return135 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) {
3636
3737 func TestParseTopic(t *testing.T) {
3838 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"} {
4040 repo, ok := parseTopic(in)
4141 assert.True(t, ok, in)
4242 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 (
2121 type liveStream struct {
2222 s *Server
2323 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
2931 // sha is the commit of the run of the job shown, read once when the stream
3032 // is set up: the annotations link to files at it, and it never changes.
3133 sha string
@@ -75,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen
7577 if we.Event == nil {
7678 return nil, nil
7779 }
80+ if l.view == "inbox" {
81+ return l.inbox(ctx, we)
82+ }
7883 if l.repo == "" {
7984 return l.home(ctx, we)
8085 }
@@ -21,11 +21,13 @@ import (
21 type liveStream struct {21 type liveStream struct {
22 s *Server22 s *Server
23 repo string // "" on the home page23 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 listed25+ filter string
26- head string // commits: the commit listed first26+ view string // "summary", "refs" or "commits"
27- n string // mr: the number of the merge request shown; run: the run id27+ ref string // commits: the branch listed
28- job string // job: the id of the job shown28+ 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 stream31 // 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 string33 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, nil78 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) {
102102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 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);
106106 gap -> ermiteLive.reload()"
107107 ></span>
108108 }
@@ -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
121121 p.Sorts = append(p.Sorts, sl)
122122 }
123123 p.Rows = s.homeRows(ctx, repos, now)
124+ if n, show := s.inboxCount(ctx); show {
125+ p.Inbox = &InboxButton{Href: inboxPath, Count: n}
126+ }
124127
125128 l := s.base()
126129 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.instance129 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 generated file: collapsed
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 &lt;b&gt;search&lt;/b&gt;")
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 &lt;b&gt;search&lt;/b&gt;")
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) {
99 <div class="home" data-layout="cards">
1010 <section class="home-repos" aria-label="Repositories">
1111 <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+ }
1220 <div class="filter">
1321 <label for="home-filter" class="sr">filter</label>
1422 <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 <input22 <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 @@
186186 }
187187 #mr-notice:empty { display: none; }
188188
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+
189198 @media (max-width: 700px) {
190199 .list.mrs .c-who { display: none; }
191200 }
@@ -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 {
135135 type HomePage struct {
136136 Sorts []SortLink
137137 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
138148 }
139149
140150 // 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 []SortLink136 Sorts []SortLink
137 Rows []HomeRow137 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 {
6363 // it there is no such redirect. Leave it unset rather than assigning a
6464 // typed nil pointer.
6565 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
6670 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
6771 // commits and merge requests. Without it there are no such pages. Leave it
6872 // unset rather than assigning a typed nil pointer.
@@ -175,6 +179,9 @@ func (s *Server) routes() *http.ServeMux {
175179 if s.passkeysOn() {
176180 s.passkeyRoutes(mux)
177181 }
182+ if s.cfg.Inbox != nil {
183+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
184+ }
178185 mux.HandleFunc("GET /{$}", s.page(s.home))
179186 mux.HandleFunc("GET /{repo}", s.page(s.summary))
180187 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 a63 // it there is no such redirect. Leave it unset rather than assigning a
64 // typed nil pointer.64 // typed nil pointer.
65 IssueNumbers ports.IssueNumberResolver65 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 to70 // 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 it71 // 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 @@
972972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 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);
976976 gap -> ermiteLive.reload()">
977977 </span>
978978 </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 @@
317317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 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);
321321 gap -> ermiteLive.reload()">
322322 </span>
323323 </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 @@
191191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
192192 htmx:sse:error -> ermiteLive.error(event.detail.status);
193193 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);
195195 gap -> ermiteLive.reload()">
196196 </span>
197197 </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 @@
199199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 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);
203203 gap -> ermiteLive.reload()">
204204 </span>
205205 </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 @@
225225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 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);
229229 gap -> ermiteLive.reload()">
230230 </span>
231231 </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
4747 MergeRequests: f.MergeRequests,
4848 Issues: f.Issues,
4949 IssueNumbers: f.Issues,
50+ Inbox: f.Inbox,
5051 Runs: f.CI,
5152 Artifacts: artifacts,
5253 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close