detached 67508cc

#4 feat(issues): native issues, owner inbox and moderated public form

state
merged · 67508cc · fast-forward
branch
feat/issue-form → main
opened
bnema ·
updated

Implements ADR 0031: native issues, the owner Inbox, and the moderated public issue form. It contains the three stacked branches feat/issues → feat/inbox → feat/issue-form, and each phase was reviewed before the next.

What it does

  • Issues:
    • Issues share the per-repo number with merge requests, so #n is one or the other and links redirect both ways.
    • New scope issue:write. Agents can create, comment, close and reopen issues without moderation.
    • Per-repo setting issues: off|owner|public, default off. A repo with issues off answers the same 404 as an unknown repo.
    • CLI ermite issue create|list|show|comment|close|reopen and repo edit --issues.
    • Web: an Issues tab, live updates, and owner forms.
  • Inbox (/-/inbox, owner only):
    • Counters at the top, then the sections Awaiting moderation, Open merge requests and Open issues.
    • Repository filter, and an owner live stream across all repositories.
    • Button at the top left of the home page, shown only when signed in.
  • Public form (mode public):
    • JavaScript required. A same-origin worker solves a proof of work signed by the server (18 to 21 bits, adaptive).
    • Honeypot field and a minimum fill time of 3 s.
    • Single-use challenge and form token.
    • Limits of 3/hour and 10/day per network, and a cap of 200 pending issues, both reserved atomically.
    • Bans last 30 days and are keyed on an HMAC of the address. No IP is written to disk.
    • Until the proof of work is valid, every refusal is the same generic 400, so nothing reveals a ban, a limit or a full queue.
    • Moderation (approve, reject, reject and ban) happens in the Inbox and over the API.
    • Approved anonymous issues render untrusted Markdown. Pending issues render as plain text.
  • Storage: schema 3 → 4. The upgrade adds issue:write to stored tokens that already hold every scope, so they keep their owner rights.

Verification

  • Phase reviews (go-reviewer) and a JS review (ts-reviewer). All findings are fixed, including:
    • comment loss after retries;
    • issue events delivered after issues were turned off;
    • the bypass of the per-network limit and of the cap under concurrency;
    • the ban oracle;
    • the emoji/NFC counters;
    • restoring the page from bfcache.
  • Final wrap-up review: every point is fixed except the deferred one listed below.
  • End-to-end test in headless Chromium at production difficulty, measured as:
    • a proof-of-work solve of 0.25 to 4.4 s;
    • moderation, then approval as #1;
    • 403 without Origin, 400 on token reuse, 429 with Retry-After;
    • the no-JS message shown instead of the form.
  • go vet, go test -race ./... (full suite), hexcheck, templ drift, buf generate, the mocks and go mod tidy all pass. All 155 JS tests pass. All commits are signed.

Before leaving draft

  • Merge MR !1 (feat/mr-notes) first. Then rebase this branch on main and extend the v3 → v4 upgrade so that it grants mr:review and issue:write to tokens holding the 9 original scopes. Also restore mr:review in client.AllScopes() and drop the temporary Unimplemented MR note handlers in rpc. Do not deploy main between the two merges: existing all-scope tokens would lose their owner rights.
  • Merge the ermite-api MR (feat/issue-form) and tag it. Replace the local replace bnema.dev/ermite-api with go get bnema.dev/ermite-api@<tag>, then run go mod tidy.
  • Deferred (GO-004, scaling): the home badge and every Inbox event compute the full Overview. This is fine on a solo instance; a lighter count-only path can come later.
  • Behind a reverse proxy, set ERMITE_TRUSTED_PROXIES. Without it, every visitor shares the proxy's per-network limit.
CommitSubjectAuthorDateSignatureStatus
67508cctest(usecase): run the issue moderation subtests in parallelbriceGPG Verified
f6f76c8build(deps): ermite-api v0.9.0briceGPG Verified
7e0e489feat(kvstore): grant mr:review and issue:write to full tokens in the schema 4 upgradebriceGPG Verified
fa37646merge: main with merge request notes into feat/issue-formbriceGPG Verified
346e37fdocs: correct issue access, limiter and comment docsbriceGPG Verified
e53302brefactor(kvstore): keep the hexcheck waiver on issue approvalbriceGPG Verified
ddab9a2fix(rpc): answer not found to non-owners before parsing moderation requestsbriceGPG Verified
f8b1e92refactor: drop the unused pending issue state, ListOpen and InboxMergeRequestbriceGPG Verified
1486f6drefactor(domain): build approved issues in the domainbriceGPG Verified
0284609perf(kvstore): page issues without decoding the whole historybriceGPG Verified
17b5f65fix(kvstore): bound the numbering locksbriceGPG Verified
b0db7adfix(usecase): refresh the inbox after a rejectionbriceGPG Verified
03a1ab8Merge branch 'feat/inbox' into feat/issue-formbriceGPG Verified
0092b37Merge branch 'feat/issues' into feat/inboxbriceGPG Verified
30f7beafix(rpc): build against the api with merge request notes and reject scope mr:reviewbriceGPG Verified
b5344bffix(usecase): verify the proof of work before any admission check and reserve quota and queue atomicallybriceGPG Verified
bc78630fix(web): count issue form limits like the server and refresh the challenge after back navigationbriceGPG Verified
e687d9cdocs: public issue form and moderationbriceGPG Verified
cce8cfafeat(web): public issue form and moderation in the inboxbriceGPG Verified
093dc62feat(web): proof of work solverbriceGPG Verified
3f9aa10feat(web): untrusted markdown for external issuesbriceGPG Verified
99db356feat(usecase): allow public issuesbriceGPG Verified
a26798bfeat(usecase): anonymous issue submission with adaptive proof of workbriceGPG Verified
ee8d39cfeat(rpc): serve pending issue moderation and wire it in the forgebriceGPG Verified
51f0135feat(usecase): add issue moderation, the form subkeys and the owner-only issue.submitted eventbriceGPG Verified
2576c64fix(clientlimit): keep the submission limits free of the domain importbriceGPG Verified
8d2ab49feat(clientlimit): add the sliding window submission limiterbriceGPG Verified
6a3e65dfeat(kvstore): store pending issues, bans and the issue form keybriceGPG Verified
dca1fe7feat(ports): add the pending issue, key, ban, submission limiter and moderation portsbriceGPG Verified
784ea9ffeat(domain): add pending issues, the issue submitted event and the form limitsbriceGPG Verified
babd8c2build(deps): use the local ermite-api issue form branchbriceGPG Verified
d66b108test(events): owner topic access matrix, token end, bearer refusal, stale indexbriceGPG Verified
063865bfix(events): deliver repo.updated and repo.deleted to owner watchers and refresh the inboxbriceGPG Verified
bc6524cfeat(web): owner inbox page and home buttonbriceGPG Verified
ab38ed2feat(events): owner topic across repositoriesbriceGPG Verified
974935dfeat(usecase): owner inbox overview across repositoriesbriceGPG Verified
e17a0e5feat(kvstore): list open merge requests and issues across repositoriesbriceGPG Verified
68460c1fix(kvstore): serialize numbering per repository so concurrent creates never exhaust retriesbriceGPG Verified
b2212f2test(app): merge the declaration with the assignment in the issue e2e test (staticcheck S1021)briceGPG Verified
2794dd4fix(kvstore): guard the repository key when updating an issue so a repo delete leaves no open-issue index keysbriceGPG Verified
02692c4fix(events): stop streaming issue events and views once issues are off or the repo is not visiblebriceGPG Verified
4f0da00fix(app): keep token preset cli working on servers without issues; return issue show write errors; correct issue help limitsbriceGPG Verified
a4f8ebdfix(usecase): report a conflict when every issue comment save conflicts instead of publishing IssueCommentedbriceGPG Verified
e3b6724merge(web): issue pages into feat/issuesbriceGPG Verified
68db1e6feat(web): annotate the issue tab and transition for hexcheckbriceGPG Verified
fd07554docs: document issuesbriceGPG Verified
582cf61feat(cli): add ermite issue commands and repo edit --issuesbriceGPG Verified
931429bfeat(web): add the issue pages, tab, redirects and live noticesbriceGPG Verified
a5d6827feat(rpc): serve IssueService and the issues setting, wire issuesbriceGPG Verified
4c8ce48feat(usecase): add the issue use cases and the issues settingbriceGPG Verified
c52c40ffeat(storage): store issues on the merge request sequence, schema 4briceGPG Verified
b41adf0feat(domain): add issues, the issues mode, text parsers and the issue:write scopebriceGPG Verified
6815c6cbuild(deps): use the local ermite-api issues branchbriceGPG Verified

Checks 8

176 files · +18416 -1025
7/8
M README.md +1 -0
M docs/cli.md +7 -2
M docs/configuration.md +1 -1
A docs/issues.md +55 -0
M docs/tokens.md +2 -1
A gen/ermite/storage/issue.pb.go +541 -0
M gen/ermite/storage/merge_request.pb.go +109 -38
M go.mod +2 -2
M go.sum +2 -2
M internal/adapters/client/client.go +5 -0
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +4 -1
M internal/adapters/client/scopes_test.go +2 -0
M internal/adapters/kvstore/export_test.go +41 -1
A internal/adapters/kvstore/issues.go +437 -0
A internal/adapters/kvstore/issues_pending.go +408 -0
A internal/adapters/kvstore/issues_pending_test.go +271 -0
A internal/adapters/kvstore/issues_test.go +561 -0
M internal/adapters/kvstore/mergerequests.go +123 -26
M internal/adapters/kvstore/mergerequests_test.go +1 -1
M internal/adapters/kvstore/repos.go +3 -1
M internal/adapters/kvstore/store.go +75 -4
M internal/adapters/kvstore/store_test.go +4 -4
M internal/adapters/rpc/api.go +8 -3
M internal/adapters/rpc/convert.go +19 -1
M internal/adapters/rpc/convert_test.go +40 -0
@@ -336,3 +336,43 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
336336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337337 assert.EqualValues(t, 3, added.GetComment().GetLine())
338338 }
339+
340+func TestIssueMappers(t *testing.T) {
341+ t.Parallel()
342+ for _, m := range domain.IssuesModes() {
343+ got, err := issuesModeFromPB(issuesModeToPB(m))
344+ require.NoError(t, err)
345+ assert.Equal(t, m, got)
346+ }
347+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348+ _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349+ assert.ErrorIs(t, err, domain.ErrInvalid)
350+
351+ s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352+ require.NoError(t, err)
353+ assert.Empty(t, s)
354+ _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355+ assert.ErrorIs(t, err, domain.ErrInvalid)
356+
357+ n, err := domain.ParseRepoName("proj")
358+ require.NoError(t, err)
359+ ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360+ require.NotNil(t, ev.GetIssueClosed())
361+ assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363+ ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364+ assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367+}
368+
369+func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) {
370+ t.Parallel()
371+ assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}}))
372+}
373+
374+func TestIssueStateToPBNeverPending(t *testing.T) {
375+ t.Parallel()
376+ assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378+}
@@ -336,3 +336,43 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337 assert.EqualValues(t, 3, added.GetComment().GetLine())337 assert.EqualValues(t, 3, added.GetComment().GetLine())
338 }338 }
339+
340+func TestIssueMappers(t *testing.T) {
341+ t.Parallel()
342+ for _, m := range domain.IssuesModes() {
343+ got, err := issuesModeFromPB(issuesModeToPB(m))
344+ require.NoError(t, err)
345+ assert.Equal(t, m, got)
346+ }
347+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348+ _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349+ assert.ErrorIs(t, err, domain.ErrInvalid)
350+
351+ s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352+ require.NoError(t, err)
353+ assert.Empty(t, s)
354+ _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355+ assert.ErrorIs(t, err, domain.ErrInvalid)
356+
357+ n, err := domain.ParseRepoName("proj")
358+ require.NoError(t, err)
359+ ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360+ require.NotNil(t, ev.GetIssueClosed())
361+ assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363+ ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364+ assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367+}
368+
369+func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) {
370+ t.Parallel()
371+ assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}}))
372+}
373+
374+func TestIssueStateToPBNeverPending(t *testing.T) {
375+ t.Parallel()
376+ assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378+}
A internal/adapters/rpc/issue.go +262 -0
@@ -0,0 +1,262 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+
10+ "connectrpc.com/connect"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// issueService implements ermiteconnect.IssueServiceHandler.
18+type issueService struct {
19+ issues ports.IssueService
20+ moderation ports.IssueModerationService
21+}
22+
23+func issueStateToPB(s domain.IssueState) pb.IssueState {
24+ switch s {
25+ case domain.IssueStateOpen:
26+ return pb.IssueState_ISSUE_STATE_OPEN
27+ case domain.IssueStateClosed:
28+ return pb.IssueState_ISSUE_STATE_CLOSED
29+ }
30+ return pb.IssueState_ISSUE_STATE_UNSPECIFIED
31+}
32+
33+// issueStateFromPB maps the state filter of a listing; unspecified is the
34+// empty state, which means every state. Pending is not listable (InvalidArgument): a domain Issue is never pending.
35+func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
36+ switch s {
37+ case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
38+ return "", nil
39+ case pb.IssueState_ISSUE_STATE_OPEN:
40+ return domain.IssueStateOpen, nil
41+ case pb.IssueState_ISSUE_STATE_CLOSED:
42+ return domain.IssueStateClosed, nil
43+ }
44+ return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
45+}
46+
47+func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
48+ switch m.OrDefault() {
49+ case domain.IssuesOff:
50+ return pb.IssuesMode_ISSUES_MODE_OFF
51+ case domain.IssuesOwner:
52+ return pb.IssuesMode_ISSUES_MODE_OWNER
53+ case domain.IssuesPublic:
54+ return pb.IssuesMode_ISSUES_MODE_PUBLIC
55+ }
56+ return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
57+}
58+
59+func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
60+ switch m {
61+ case pb.IssuesMode_ISSUES_MODE_OFF:
62+ return domain.IssuesOff, nil
63+ case pb.IssuesMode_ISSUES_MODE_OWNER:
64+ return domain.IssuesOwner, nil
65+ case pb.IssuesMode_ISSUES_MODE_PUBLIC:
66+ return domain.IssuesPublic, nil
67+ }
68+ return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
69+}
70+
71+func issueToPB(i domain.Issue) *pb.Issue {
72+ return &pb.Issue{
73+ Repo: i.Repo.String(),
74+ Number: i.Number,
75+ Title: i.Title.String(),
76+ Body: i.Body.String(),
77+ State: issueStateToPB(i.State),
78+ Author: i.Author,
79+ CreatedAt: timestamp(i.CreatedAt),
80+ UpdatedAt: timestamp(i.UpdatedAt),
81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ }
83+}
84+
85+func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
86+ return &pb.IssueComment{
87+ Id: strconv.FormatInt(c.ID, 10),
88+ Author: c.Author,
89+ Text: c.Text.String(),
90+ CreatedAt: timestamp(c.CreatedAt),
91+ }
92+}
93+
94+func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
95+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
96+ if err != nil {
97+ return nil, connectError(ctx, err)
98+ }
99+ issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
100+ if err != nil {
101+ return nil, connectError(ctx, err)
102+ }
103+ return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
104+}
105+
106+func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
107+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
108+ if err != nil {
109+ return nil, connectError(ctx, err)
110+ }
111+ state, err := issueStateFromPB(req.Msg.GetState())
112+ if err != nil {
113+ return nil, connectError(ctx, err)
114+ }
115+ page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
116+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
117+ })
118+ if err != nil {
119+ return nil, connectError(ctx, err)
120+ }
121+ out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
122+ for _, i := range page.Issues {
123+ out.Issues = append(out.Issues, issueToPB(i))
124+ }
125+ return connect.NewResponse(out), nil
126+}
127+
128+func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
129+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
130+ if err != nil {
131+ return nil, connectError(ctx, err)
132+ }
133+ d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
134+ if err != nil {
135+ return nil, connectError(ctx, err)
136+ }
137+ out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
138+ for _, c := range d.Comments {
139+ out.Comments = append(out.Comments, issueCommentToPB(c))
140+ }
141+ return connect.NewResponse(out), nil
142+}
143+
144+func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
145+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
146+ if err != nil {
147+ return nil, connectError(ctx, err)
148+ }
149+ c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
150+ if err != nil {
151+ return nil, connectError(ctx, err)
152+ }
153+ return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
154+}
155+
156+func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
157+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
158+ if err != nil {
159+ return nil, connectError(ctx, err)
160+ }
161+ issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
162+ if err != nil {
163+ return nil, connectError(ctx, err)
164+ }
165+ return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
166+}
167+
168+func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
169+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
170+ if err != nil {
171+ return nil, connectError(ctx, err)
172+ }
173+ issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
174+ if err != nil {
175+ return nil, connectError(ctx, err)
176+ }
177+ return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
178+}
179+
180+func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
181+ return &pb.PendingIssue{
182+ Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
183+ Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
184+ }
185+}
186+
187+// pendingRef parses the repository and the id of a moderation request. A
188+// malformed id is reported as not found, like an unknown one.
189+func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
190+ name, err := domain.ParseRepoName(repo)
191+ if err != nil {
192+ return domain.RepoName{}, "", err
193+ }
194+ pid, err := domain.ParsePendingIssueID(id)
195+ if err != nil {
196+ return domain.RepoName{}, "", domain.ErrNotFound
197+ }
198+ return name, pid, nil
199+}
200+
201+// requireOwner answers NotFound to anyone without owner rights, before the
202+// request is parsed: a malformed repository must not tell a non-owner more
203+// than a well-formed one does.
204+func requireOwner(ctx context.Context) error {
205+ if !caller(ctx).HasOwnerRights() {
206+ return connectError(ctx, domain.ErrNotFound)
207+ }
208+ return nil
209+}
210+
211+func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
212+ if err := requireOwner(ctx); err != nil {
213+ return nil, err
214+ }
215+ var name domain.RepoName
216+ if req.Msg.GetRepo() != "" {
217+ var err error
218+ if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
219+ return nil, connectError(ctx, err)
220+ }
221+ }
222+ page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
223+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
224+ })
225+ if err != nil {
226+ return nil, connectError(ctx, err)
227+ }
228+ out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
229+ for _, p := range page.Issues {
230+ out.Issues = append(out.Issues, pendingIssueToPB(p))
231+ }
232+ return connect.NewResponse(out), nil
233+}
234+
235+func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
236+ if err := requireOwner(ctx); err != nil {
237+ return nil, err
238+ }
239+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
240+ if err != nil {
241+ return nil, connectError(ctx, err)
242+ }
243+ issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
244+ if err != nil {
245+ return nil, connectError(ctx, err)
246+ }
247+ return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
248+}
249+
250+func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
251+ if err := requireOwner(ctx); err != nil {
252+ return nil, err
253+ }
254+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
255+ if err != nil {
256+ return nil, connectError(ctx, err)
257+ }
258+ if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
259+ return nil, connectError(ctx, err)
260+ }
261+ return connect.NewResponse(&pb.RejectIssueResponse{}), nil
262+}
@@ -0,0 +1,262 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+
10+ "connectrpc.com/connect"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// issueService implements ermiteconnect.IssueServiceHandler.
18+type issueService struct {
19+ issues ports.IssueService
20+ moderation ports.IssueModerationService
21+}
22+
23+func issueStateToPB(s domain.IssueState) pb.IssueState {
24+ switch s {
25+ case domain.IssueStateOpen:
26+ return pb.IssueState_ISSUE_STATE_OPEN
27+ case domain.IssueStateClosed:
28+ return pb.IssueState_ISSUE_STATE_CLOSED
29+ }
30+ return pb.IssueState_ISSUE_STATE_UNSPECIFIED
31+}
32+
33+// issueStateFromPB maps the state filter of a listing; unspecified is the
34+// empty state, which means every state. Pending is not listable (InvalidArgument): a domain Issue is never pending.
35+func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
36+ switch s {
37+ case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
38+ return "", nil
39+ case pb.IssueState_ISSUE_STATE_OPEN:
40+ return domain.IssueStateOpen, nil
41+ case pb.IssueState_ISSUE_STATE_CLOSED:
42+ return domain.IssueStateClosed, nil
43+ }
44+ return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
45+}
46+
47+func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
48+ switch m.OrDefault() {
49+ case domain.IssuesOff:
50+ return pb.IssuesMode_ISSUES_MODE_OFF
51+ case domain.IssuesOwner:
52+ return pb.IssuesMode_ISSUES_MODE_OWNER
53+ case domain.IssuesPublic:
54+ return pb.IssuesMode_ISSUES_MODE_PUBLIC
55+ }
56+ return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
57+}
58+
59+func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
60+ switch m {
61+ case pb.IssuesMode_ISSUES_MODE_OFF:
62+ return domain.IssuesOff, nil
63+ case pb.IssuesMode_ISSUES_MODE_OWNER:
64+ return domain.IssuesOwner, nil
65+ case pb.IssuesMode_ISSUES_MODE_PUBLIC:
66+ return domain.IssuesPublic, nil
67+ }
68+ return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
69+}
70+
71+func issueToPB(i domain.Issue) *pb.Issue {
72+ return &pb.Issue{
73+ Repo: i.Repo.String(),
74+ Number: i.Number,
75+ Title: i.Title.String(),
76+ Body: i.Body.String(),
77+ State: issueStateToPB(i.State),
78+ Author: i.Author,
79+ CreatedAt: timestamp(i.CreatedAt),
80+ UpdatedAt: timestamp(i.UpdatedAt),
81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ }
83+}
84+
85+func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
86+ return &pb.IssueComment{
87+ Id: strconv.FormatInt(c.ID, 10),
88+ Author: c.Author,
89+ Text: c.Text.String(),
90+ CreatedAt: timestamp(c.CreatedAt),
91+ }
92+}
93+
94+func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
95+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
96+ if err != nil {
97+ return nil, connectError(ctx, err)
98+ }
99+ issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
100+ if err != nil {
101+ return nil, connectError(ctx, err)
102+ }
103+ return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
104+}
105+
106+func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
107+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
108+ if err != nil {
109+ return nil, connectError(ctx, err)
110+ }
111+ state, err := issueStateFromPB(req.Msg.GetState())
112+ if err != nil {
113+ return nil, connectError(ctx, err)
114+ }
115+ page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
116+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
117+ })
118+ if err != nil {
119+ return nil, connectError(ctx, err)
120+ }
121+ out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
122+ for _, i := range page.Issues {
123+ out.Issues = append(out.Issues, issueToPB(i))
124+ }
125+ return connect.NewResponse(out), nil
126+}
127+
128+func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
129+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
130+ if err != nil {
131+ return nil, connectError(ctx, err)
132+ }
133+ d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
134+ if err != nil {
135+ return nil, connectError(ctx, err)
136+ }
137+ out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
138+ for _, c := range d.Comments {
139+ out.Comments = append(out.Comments, issueCommentToPB(c))
140+ }
141+ return connect.NewResponse(out), nil
142+}
143+
144+func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
145+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
146+ if err != nil {
147+ return nil, connectError(ctx, err)
148+ }
149+ c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
150+ if err != nil {
151+ return nil, connectError(ctx, err)
152+ }
153+ return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
154+}
155+
156+func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
157+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
158+ if err != nil {
159+ return nil, connectError(ctx, err)
160+ }
161+ issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
162+ if err != nil {
163+ return nil, connectError(ctx, err)
164+ }
165+ return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
166+}
167+
168+func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
169+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
170+ if err != nil {
171+ return nil, connectError(ctx, err)
172+ }
173+ issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
174+ if err != nil {
175+ return nil, connectError(ctx, err)
176+ }
177+ return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
178+}
179+
180+func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
181+ return &pb.PendingIssue{
182+ Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
183+ Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
184+ }
185+}
186+
187+// pendingRef parses the repository and the id of a moderation request. A
188+// malformed id is reported as not found, like an unknown one.
189+func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
190+ name, err := domain.ParseRepoName(repo)
191+ if err != nil {
192+ return domain.RepoName{}, "", err
193+ }
194+ pid, err := domain.ParsePendingIssueID(id)
195+ if err != nil {
196+ return domain.RepoName{}, "", domain.ErrNotFound
197+ }
198+ return name, pid, nil
199+}
200+
201+// requireOwner answers NotFound to anyone without owner rights, before the
202+// request is parsed: a malformed repository must not tell a non-owner more
203+// than a well-formed one does.
204+func requireOwner(ctx context.Context) error {
205+ if !caller(ctx).HasOwnerRights() {
206+ return connectError(ctx, domain.ErrNotFound)
207+ }
208+ return nil
209+}
210+
211+func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
212+ if err := requireOwner(ctx); err != nil {
213+ return nil, err
214+ }
215+ var name domain.RepoName
216+ if req.Msg.GetRepo() != "" {
217+ var err error
218+ if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
219+ return nil, connectError(ctx, err)
220+ }
221+ }
222+ page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
223+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
224+ })
225+ if err != nil {
226+ return nil, connectError(ctx, err)
227+ }
228+ out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
229+ for _, p := range page.Issues {
230+ out.Issues = append(out.Issues, pendingIssueToPB(p))
231+ }
232+ return connect.NewResponse(out), nil
233+}
234+
235+func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
236+ if err := requireOwner(ctx); err != nil {
237+ return nil, err
238+ }
239+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
240+ if err != nil {
241+ return nil, connectError(ctx, err)
242+ }
243+ issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
244+ if err != nil {
245+ return nil, connectError(ctx, err)
246+ }
247+ return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
248+}
249+
250+func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
251+ if err := requireOwner(ctx); err != nil {
252+ return nil, err
253+ }
254+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
255+ if err != nil {
256+ return nil, connectError(ctx, err)
257+ }
258+ if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
259+ return nil, connectError(ctx, err)
260+ }
261+ return connect.NewResponse(&pb.RejectIssueResponse{}), nil
262+}
M internal/adapters/rpc/mergerequest.go +3 -1
@@ -15,7 +15,9 @@ import (
1515 )
1616
1717 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18-type mergeRequestService struct{ mrs ports.MergeRequestService }
18+type mergeRequestService struct {
19+ mrs ports.MergeRequestService
20+}
1921
2022 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
2123 name, err := domain.ParseRepoName(req.Msg.GetRepo())
@@ -15,7 +15,9 @@ import (
15 )15 )
16 16
17 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.17 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18-type mergeRequestService struct{ mrs ports.MergeRequestService }18+type mergeRequestService struct {
19+ mrs ports.MergeRequestService
20+}
19 21
20 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {22 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
21 name, err := domain.ParseRepoName(req.Msg.GetRepo())23 name, err := domain.ParseRepoName(req.Msg.GetRepo())
M internal/adapters/rpc/mergerequest_test.go +11 -11
@@ -41,7 +41,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
4141 Title: "t", Description: "d", Source: "feat", Target: "main",
4242 }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()
4343 }
44- resp, err := (&mergeRequestService{mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
44+ resp, err := (&mergeRequestService{mrs: mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
4545 Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",
4646 }))
4747 if tt.wantCode != 0 {
@@ -57,7 +57,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
5757
5858 func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {
5959 t.Parallel()
60- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
60+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
6161 connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))
6262 requireCode(t, connect.CodeInvalidArgument, err)
6363 }
@@ -68,7 +68,7 @@ func TestMergeRequestServiceListFiltersByState(t *testing.T) {
6868 mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{
6969 State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",
7070 }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()
71- resp, err := (&mergeRequestService{mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
71+ resp, err := (&mergeRequestService{mrs: mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
7272 Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",
7373 }))
7474 require.NoError(t, err)
@@ -99,7 +99,7 @@ func TestMergeRequestServiceAddComment(t *testing.T) {
9999 mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).
100100 Return(domain.Comment{ID: 5}, tt.usecase).Once()
101101 }
102- resp, err := (&mergeRequestService{mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
102+ resp, err := (&mergeRequestService{mrs: mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
103103 Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",
104104 }))
105105 if tt.wantCode != 0 {
@@ -120,7 +120,7 @@ func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) {
120120 mrs := mocks.NewMergeRequestService(t)
121121 mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).
122122 Return(domain.Comment{}, domain.ErrNotFound).Once()
123- _, err := (&mergeRequestService{mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
123+ _, err := (&mergeRequestService{mrs: mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
124124 Repo: "demo", Number: 1, CommentId: id, Resolved: true,
125125 }))
126126 requireCode(t, connect.CodeNotFound, err)
@@ -154,7 +154,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
154154 return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
155155 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
156156 }
157- resp, err := (&mergeRequestService{mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
157+ resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
158158 if tt.wantCode != 0 {
159159 requireCode(t, tt.wantCode, err)
160160 return
@@ -171,20 +171,20 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
171171 t.Parallel()
172172 mrs := mocks.NewMergeRequestService(t)
173173 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
174- _, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
174+ _, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
175175 requireCode(t, connect.CodeNotFound, err)
176176 })
177177 t.Run("close ok", func(t *testing.T) {
178178 t.Parallel()
179179 mrs := mocks.NewMergeRequestService(t)
180180 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
181- resp, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
181+ resp, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
182182 require.NoError(t, err)
183183 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
184184 })
185185 t.Run("mark read rejects a short head", func(t *testing.T) {
186186 t.Parallel()
187- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
187+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
188188 connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
189189 requireCode(t, connect.CodeInvalidArgument, err)
190190 })
@@ -192,7 +192,7 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
192192 t.Parallel()
193193 mrs := mocks.NewMergeRequestService(t)
194194 mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
195- _, err := (&mergeRequestService{mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
195+ _, err := (&mergeRequestService{mrs: mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
196196 requireCode(t, connect.CodeUnauthenticated, err)
197197 })
198198 }
@@ -214,7 +214,7 @@ func TestMergeRequestServiceGet(t *testing.T) {
214214 mrs := mocks.NewMergeRequestService(t)
215215 mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
216216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217- resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
217+ resp, err := (&mergeRequestService{mrs: mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218218 if tt.wantCode != 0 {
219219 requireCode(t, tt.wantCode, err)
220220 return
@@ -41,7 +41,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
41 Title: "t", Description: "d", Source: "feat", Target: "main",41 Title: "t", Description: "d", Source: "feat", Target: "main",
42 }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()42 }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()
43 }43 }
44- resp, err := (&mergeRequestService{mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{44+ resp, err := (&mergeRequestService{mrs: mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
45 Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",45 Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",
46 }))46 }))
47 if tt.wantCode != 0 {47 if tt.wantCode != 0 {
@@ -57,7 +57,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
57 57
58 func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {58 func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {
59 t.Parallel()59 t.Parallel()
60- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),60+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
61 connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))61 connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))
62 requireCode(t, connect.CodeInvalidArgument, err)62 requireCode(t, connect.CodeInvalidArgument, err)
63 }63 }
@@ -68,7 +68,7 @@ func TestMergeRequestServiceListFiltersByState(t *testing.T) {
68 mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{68 mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{
69 State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",69 State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",
70 }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()70 }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()
71- resp, err := (&mergeRequestService{mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{71+ resp, err := (&mergeRequestService{mrs: mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
72 Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",72 Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",
73 }))73 }))
74 require.NoError(t, err)74 require.NoError(t, err)
@@ -99,7 +99,7 @@ func TestMergeRequestServiceAddComment(t *testing.T) {
99 mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).99 mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).
100 Return(domain.Comment{ID: 5}, tt.usecase).Once()100 Return(domain.Comment{ID: 5}, tt.usecase).Once()
101 }101 }
102- resp, err := (&mergeRequestService{mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{102+ resp, err := (&mergeRequestService{mrs: mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
103 Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",103 Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",
104 }))104 }))
105 if tt.wantCode != 0 {105 if tt.wantCode != 0 {
@@ -120,7 +120,7 @@ func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) {
120 mrs := mocks.NewMergeRequestService(t)120 mrs := mocks.NewMergeRequestService(t)
121 mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).121 mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).
122 Return(domain.Comment{}, domain.ErrNotFound).Once()122 Return(domain.Comment{}, domain.ErrNotFound).Once()
123- _, err := (&mergeRequestService{mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{123+ _, err := (&mergeRequestService{mrs: mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
124 Repo: "demo", Number: 1, CommentId: id, Resolved: true,124 Repo: "demo", Number: 1, CommentId: id, Resolved: true,
125 }))125 }))
126 requireCode(t, connect.CodeNotFound, err)126 requireCode(t, connect.CodeNotFound, err)
@@ -154,7 +154,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
154 return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()154 return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
155 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()155 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
156 }156 }
157- resp, err := (&mergeRequestService{mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))157+ resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
158 if tt.wantCode != 0 {158 if tt.wantCode != 0 {
159 requireCode(t, tt.wantCode, err)159 requireCode(t, tt.wantCode, err)
160 return160 return
@@ -171,20 +171,20 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
171 t.Parallel()171 t.Parallel()
172 mrs := mocks.NewMergeRequestService(t)172 mrs := mocks.NewMergeRequestService(t)
173 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()173 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
174- _, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))174+ _, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
175 requireCode(t, connect.CodeNotFound, err)175 requireCode(t, connect.CodeNotFound, err)
176 })176 })
177 t.Run("close ok", func(t *testing.T) {177 t.Run("close ok", func(t *testing.T) {
178 t.Parallel()178 t.Parallel()
179 mrs := mocks.NewMergeRequestService(t)179 mrs := mocks.NewMergeRequestService(t)
180 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()180 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
181- resp, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))181+ resp, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
182 require.NoError(t, err)182 require.NoError(t, err)
183 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())183 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
184 })184 })
185 t.Run("mark read rejects a short head", func(t *testing.T) {185 t.Run("mark read rejects a short head", func(t *testing.T) {
186 t.Parallel()186 t.Parallel()
187- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),187+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
188 connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))188 connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
189 requireCode(t, connect.CodeInvalidArgument, err)189 requireCode(t, connect.CodeInvalidArgument, err)
190 })190 })
@@ -192,7 +192,7 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
192 t.Parallel()192 t.Parallel()
193 mrs := mocks.NewMergeRequestService(t)193 mrs := mocks.NewMergeRequestService(t)
194 mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()194 mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
195- _, err := (&mergeRequestService{mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))195+ _, err := (&mergeRequestService{mrs: mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
196 requireCode(t, connect.CodeUnauthenticated, err)196 requireCode(t, connect.CodeUnauthenticated, err)
197 })197 })
198 }198 }
@@ -214,7 +214,7 @@ func TestMergeRequestServiceGet(t *testing.T) {
214 mrs := mocks.NewMergeRequestService(t)214 mrs := mocks.NewMergeRequestService(t)
215 mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).215 mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217- resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))217+ resp, err := (&mergeRequestService{mrs: mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218 if tt.wantCode != 0 {218 if tt.wantCode != 0 {
219 requireCode(t, tt.wantCode, err)219 requireCode(t, tt.wantCode, err)
220 return220 return
M internal/adapters/rpc/repo.go +10 -2
@@ -81,15 +81,23 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8181 // updateParams maps the field mask of an update. The mask must name at least
8282 // one of "description", "topics", "visibility", "default_branch", "settings",
8383 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else.
84+// nothing else; the optional issues field stands outside the mask, and alone
85+// it is a valid update.
8586 //
8687 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
8788 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
8889 var params ports.UpdateRepoParams
8990 paths := msg.GetUpdateMask().GetPaths()
90- if len(paths) == 0 {
91+ if len(paths) == 0 && msg.Issues == nil {
9192 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
9293 }
94+ if msg.Issues != nil {
95+ mode, err := issuesModeFromPB(msg.GetIssues())
96+ if err != nil {
97+ return params, err
98+ }
99+ params.Issues = &mode
100+ }
93101 for _, path := range paths {
94102 switch path {
95103 case "description":
@@ -81,15 +81,23 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82 // one of "description", "topics", "visibility", "default_branch", "settings",82 // one of "description", "topics", "visibility", "default_branch", "settings",
83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else.84+// nothing else; the optional issues field stands outside the mask, and alone
85+// it is a valid update.
85 //86 //
86 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here87 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
87 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {88 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
88 var params ports.UpdateRepoParams89 var params ports.UpdateRepoParams
89 paths := msg.GetUpdateMask().GetPaths()90 paths := msg.GetUpdateMask().GetPaths()
90- if len(paths) == 0 {91+ if len(paths) == 0 && msg.Issues == nil {
91 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)92 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
92 }93 }
94+ if msg.Issues != nil {
95+ mode, err := issuesModeFromPB(msg.GetIssues())
96+ if err != nil {
97+ return params, err
98+ }
99+ params.Issues = &mode
100+ }
93 for _, path := range paths {101 for _, path := range paths {
94 switch path {102 switch path {
95 case "description":103 case "description":
M internal/adapters/web/ASSETS.md +2 -0
@@ -13,6 +13,8 @@ content-hashed names. Nothing is fetched at runtime.
1313 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1414 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
1515 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
16+| `static/js/issue-form.js` | the public issue form: starts the proof-of-work worker on load, length counters, the state of the submit button (`window.ermiteIssueForm`; core tested in Node by `jstest/issue-form.test.mjs`) | written here | |
17+| `static/js/pow-worker.js` | the dedicated Worker of the form: finds the counter `n` such that SHA-256(nonce bytes \|\| ":" \|\| decimal `n`) has the required leading zero bits, with `crypto.subtle`; same encoding as `usecase/issue_submissions.go` (shared vector tested on both sides). Loaded from the same origin by `new Worker(url)`, no `blob:` | written here | |
1618 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1719 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1820 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
@@ -13,6 +13,8 @@ content-hashed names. Nothing is fetched at runtime.
13 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |13 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
14 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |14 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
15 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |15 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
16+| `static/js/issue-form.js` | the public issue form: starts the proof-of-work worker on load, length counters, the state of the submit button (`window.ermiteIssueForm`; core tested in Node by `jstest/issue-form.test.mjs`) | written here | |
17+| `static/js/pow-worker.js` | the dedicated Worker of the form: finds the counter `n` such that SHA-256(nonce bytes \|\| ":" \|\| decimal `n`) has the required leading zero bits, with `crypto.subtle`; same encoding as `usecase/issue_submissions.go` (shared vector tested on both sides). Loaded from the same origin by `new Worker(url)`, no `blob:` | written here | |
16 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |18 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
17 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
18 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/assets.go +3 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -65,6 +65,8 @@ var scripts = []struct {
6565 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6666 {"log.js", func(a *Assets, u string) { a.Log = u }},
6767 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68+ {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69+ {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
6870 }
6971
7072 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -65,6 +65,8 @@ var scripts = []struct {
65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
66 {"log.js", func(a *Assets, u string) { a.Log = u }},66 {"log.js", func(a *Assets, u string) { a.Log = u }},
67 {"mr.js", func(a *Assets, u string) { a.MR = u }},67 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68+ {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69+ {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
68 }70 }
69 71
70 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):72 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
M internal/adapters/web/events.go +59 -3
@@ -27,10 +27,13 @@ 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
3334 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
35+// GET /-/events?topic=repo:<name>&view=issues
36+// GET /-/events?topic=repo:<name>&view=issue&n=<number>
3437 // GET /-/events?topic=repo:<name>&view=actions
3538 // GET /-/events?topic=repo:<name>&view=run&n=<run id>
3639 // GET /-/events?topic=repo:<name>&view=job&job=<job id>
@@ -51,7 +54,7 @@ import (
5154 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5255 // <dl id="refs-kv">…</dl>, the facts of the rail
5356 // commits-head data: <div id="commits-head">N new commits — reload</div>
54-// mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
57+// mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
5558 // something changed, with the link that reloads the page
5659 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
5760 // <section id="recent-actions">…</section> (the rail of the summary) or
@@ -82,9 +85,14 @@ const (
8285 retryAfter = 10
8386 )
8487
85-// 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.
8694 func parseTopic(topic string) (repo string, ok bool) {
87- if topic == "home" {
95+ if topic == "home" || topic == ownerTopic {
8896 return "", true
8997 }
9098 if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
@@ -93,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) {
93101 return "", false
94102 }
95103
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+
96119 // events is GET /-/events.
97120 func (s *Server) events(w http.ResponseWriter, r *http.Request) {
98121 if s.cfg.Events == nil {
@@ -102,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
102125 q := r.URL.Query()
103126 topic := q.Get("topic")
104127 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+ }
105133 if !ok {
106134 denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
107135 return
@@ -114,6 +142,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
114142 // A repository the visitor cannot see is a missing one (AC2).
115143 cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
116144 err := s.canSee(cctx, repo)
145+ if err == nil {
146+ if v := q.Get("view"); v == "issues" || v == "issue" {
147+ err = s.issuesOn(cctx, repo)
148+ }
149+ }
117150 ccancel()
118151 if err != nil {
119152 s.denyEvents(ctx, w, err)
@@ -220,6 +253,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
220253 }
221254
222255 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+ }
223259 handle := func(m recvd) bool {
224260 if m.err != nil {
225261 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
@@ -298,6 +334,26 @@ func (s *Server) canSee(ctx context.Context, rawName string) error {
298334 return err
299335 }
300336
337+// issuesOn is domain.ErrNotFound unless the repository has issues on and
338+// s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.
339+func (s *Server) issuesOn(ctx context.Context, rawName string) error {
340+ if s.cfg.Issues == nil {
341+ return domain.ErrNotFound
342+ }
343+ name, err := domain.ParseRepoName(rawName)
344+ if err != nil {
345+ return err
346+ }
347+ v, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
348+ if err != nil {
349+ return err
350+ }
351+ if v.Settings.Issues.OrDefault() == domain.IssuesOff {
352+ return domain.ErrNotFound
353+ }
354+ return nil
355+}
356+
301357 // denyStream answers a request that gets no stream.
302358 func denyStream(w http.ResponseWriter, status int, msg string) {
303359 w.Header().Set("Cache-Control", "no-store")
@@ -27,10 +27,13 @@ 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
33 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>34 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
35+// GET /-/events?topic=repo:<name>&view=issues
36+// GET /-/events?topic=repo:<name>&view=issue&n=<number>
34 // GET /-/events?topic=repo:<name>&view=actions37 // GET /-/events?topic=repo:<name>&view=actions
35 // GET /-/events?topic=repo:<name>&view=run&n=<run id>38 // GET /-/events?topic=repo:<name>&view=run&n=<run id>
36 // GET /-/events?topic=repo:<name>&view=job&job=<job id>39 // GET /-/events?topic=repo:<name>&view=job&job=<job id>
@@ -51,7 +54,7 @@ import (
51 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with54 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
52 // <dl id="refs-kv">…</dl>, the facts of the rail55 // <dl id="refs-kv">…</dl>, the facts of the rail
53 // commits-head data: <div id="commits-head">N new commits — reload</div>56 // commits-head data: <div id="commits-head">N new commits — reload</div>
54-// mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:57+// mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
55 // something changed, with the link that reloads the page58 // something changed, with the link that reloads the page
56 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),59 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
57 // <section id="recent-actions">…</section> (the rail of the summary) or60 // <section id="recent-actions">…</section> (the rail of the summary) or
@@ -82,9 +85,14 @@ const (
82 retryAfter = 1085 retryAfter = 10
83 )86 )
84 87
85-// 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.
86 func parseTopic(topic string) (repo string, ok bool) {94 func parseTopic(topic string) (repo string, ok bool) {
87- if topic == "home" {95+ if topic == "home" || topic == ownerTopic {
88 return "", true96 return "", true
89 }97 }
90 if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {98 if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
@@ -93,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) {
93 return "", false101 return "", false
94 }102 }
95 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+
96 // events is GET /-/events.119 // events is GET /-/events.
97 func (s *Server) events(w http.ResponseWriter, r *http.Request) {120 func (s *Server) events(w http.ResponseWriter, r *http.Request) {
98 if s.cfg.Events == nil {121 if s.cfg.Events == nil {
@@ -102,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
102 q := r.URL.Query()125 q := r.URL.Query()
103 topic := q.Get("topic")126 topic := q.Get("topic")
104 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+ }
105 if !ok {133 if !ok {
106 denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")134 denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
107 return135 return
@@ -114,6 +142,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
114 // A repository the visitor cannot see is a missing one (AC2).142 // A repository the visitor cannot see is a missing one (AC2).
115 cctx, ccancel := context.WithTimeout(ctx, requestTimeout)143 cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
116 err := s.canSee(cctx, repo)144 err := s.canSee(cctx, repo)
145+ if err == nil {
146+ if v := q.Get("view"); v == "issues" || v == "issue" {
147+ err = s.issuesOn(cctx, repo)
148+ }
149+ }
117 ccancel()150 ccancel()
118 if err != nil {151 if err != nil {
119 s.denyEvents(ctx, w, err)152 s.denyEvents(ctx, w, err)
@@ -220,6 +253,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
220 }253 }
221 254
222 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+ }
223 handle := func(m recvd) bool {259 handle := func(m recvd) bool {
224 if m.err != nil {260 if m.err != nil {
225 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {261 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
@@ -298,6 +334,26 @@ func (s *Server) canSee(ctx context.Context, rawName string) error {
298 return err334 return err
299 }335 }
300 336
337+// issuesOn is domain.ErrNotFound unless the repository has issues on and
338+// s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.
339+func (s *Server) issuesOn(ctx context.Context, rawName string) error {
340+ if s.cfg.Issues == nil {
341+ return domain.ErrNotFound
342+ }
343+ name, err := domain.ParseRepoName(rawName)
344+ if err != nil {
345+ return err
346+ }
347+ v, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
348+ if err != nil {
349+ return err
350+ }
351+ if v.Settings.Issues.OrDefault() == domain.IssuesOff {
352+ return domain.ErrNotFound
353+ }
354+ return nil
355+}
356+
301 // denyStream answers a request that gets no stream.357 // denyStream answers a request that gets no stream.
302 func denyStream(w http.ResponseWriter, status int, msg string) {358 func denyStream(w http.ResponseWriter, status int, msg string) {
303 w.Header().Set("Cache-Control", "no-store")359 w.Header().Set("Cache-Control", "no-store")
M internal/adapters/web/events_test.go +38 -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)
@@ -428,3 +428,40 @@ func TestEventsInvalidCookieStream(t *testing.T) {
428428 assert.Contains(t, resp.Header.Values("Vary"), "Cookie")
429429 assert.Contains(t, resp.Header.Get("Set-Cookie"), "Max-Age=0")
430430 }
431+
432+// An issues stream on a repository with issues off, unknown or private is
433+// refused the same way: 404 with the same body.
434+func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
435+ t.Parallel()
436+ priv := mocks.NewRepoService(t)
437+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
438+ for _, view := range []string{"issues", "issue&n=1"} {
439+ answers := map[string]string{}
440+ for name, repos := range map[string]ports.RepoService{
441+ "off": reposServing(t, issueRepoView(t, domain.IssuesOff)),
442+ "unset": reposServing(t, issueRepoView(t, "")),
443+ "unknown": reposServing(t),
444+ "private": priv,
445+ } {
446+ f := &watchProbe{release: make(chan struct{})}
447+ srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
448+ ts := httptest.NewServer(srv)
449+ t.Cleanup(ts.Close)
450+ resp, _ := open(t, ts, "topic=repo:proj&view="+view, nil)
451+ body, _ := io.ReadAll(resp.Body)
452+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, "%s %s", view, name)
453+ answers[name] = resp.Header.Get("Cache-Control") + "|" + string(body)
454+ assert.Zero(t, f.opened.Load(), "%s %s: no watch is opened", view, name)
455+ }
456+ for name, a := range answers {
457+ assert.Equal(t, answers["unknown"], a, "%s %s", view, name)
458+ }
459+ }
460+ // With issues on, the stream opens.
461+ f := &watchProbe{release: make(chan struct{})}
462+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
463+ ts := httptest.NewServer(srv)
464+ t.Cleanup(ts.Close)
465+ resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466+ assert.Equal(t, http.StatusOK, resp.StatusCode)
467+}
@@ -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)
@@ -428,3 +428,40 @@ func TestEventsInvalidCookieStream(t *testing.T) {
428 assert.Contains(t, resp.Header.Values("Vary"), "Cookie")428 assert.Contains(t, resp.Header.Values("Vary"), "Cookie")
429 assert.Contains(t, resp.Header.Get("Set-Cookie"), "Max-Age=0")429 assert.Contains(t, resp.Header.Get("Set-Cookie"), "Max-Age=0")
430 }430 }
431+
432+// An issues stream on a repository with issues off, unknown or private is
433+// refused the same way: 404 with the same body.
434+func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
435+ t.Parallel()
436+ priv := mocks.NewRepoService(t)
437+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
438+ for _, view := range []string{"issues", "issue&n=1"} {
439+ answers := map[string]string{}
440+ for name, repos := range map[string]ports.RepoService{
441+ "off": reposServing(t, issueRepoView(t, domain.IssuesOff)),
442+ "unset": reposServing(t, issueRepoView(t, "")),
443+ "unknown": reposServing(t),
444+ "private": priv,
445+ } {
446+ f := &watchProbe{release: make(chan struct{})}
447+ srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
448+ ts := httptest.NewServer(srv)
449+ t.Cleanup(ts.Close)
450+ resp, _ := open(t, ts, "topic=repo:proj&view="+view, nil)
451+ body, _ := io.ReadAll(resp.Body)
452+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, "%s %s", view, name)
453+ answers[name] = resp.Header.Get("Cache-Control") + "|" + string(body)
454+ assert.Zero(t, f.opened.Load(), "%s %s: no watch is opened", view, name)
455+ }
456+ for name, a := range answers {
457+ assert.Equal(t, answers["unknown"], a, "%s %s", view, name)
458+ }
459+ }
460+ // With issues on, the stream opens.
461+ f := &watchProbe{release: make(chan struct{})}
462+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
463+ ts := httptest.NewServer(srv)
464+ t.Cleanup(ts.Close)
465+ resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466+ assert.Equal(t, http.StatusOK, resp.StatusCode)
467+}
M internal/adapters/web/fragments.go +36 -6
@@ -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
@@ -59,7 +61,8 @@ func liveEvent(we ports.WatchEvent) bool {
5961 switch we.Event.(type) {
6062 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6163 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
62- domain.RunUpdated, domain.JobUpdated:
64+ domain.RunUpdated, domain.JobUpdated,
65+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6366 return true
6467 }
6568 return false
@@ -74,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen
7477 if we.Event == nil {
7578 return nil, nil
7679 }
80+ if l.view == "inbox" {
81+ return l.inbox(ctx, we)
82+ }
7783 if l.repo == "" {
7884 return l.home(ctx, we)
7985 }
@@ -172,6 +178,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
172178 return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
173179 case domain.CommentAdded:
174180 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
181+ case domain.IssueOpened:
182+ return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
183+ case domain.IssueClosed:
184+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
185+ case domain.IssueReopened:
186+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
187+ case domain.IssueCommented:
188+ return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
175189 case domain.PushEvent:
176190 switch l.view {
177191 case "mr":
@@ -406,6 +420,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
406420 return nil, nil
407421 }
408422
423+// issueNotice tells an issue page that something happened to the issue it
424+// shows, or the list that its content changed. Like mrNotice it sends a fixed
425+// sentence only, in the same slot.
426+func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
427+ switch l.view {
428+ case "issues":
429+ return l.notice(ctx, "issues changed — reload", issuesBase(l.repo))
430+ case "issue":
431+ if strconv.FormatInt(number, 10) != l.n {
432+ return nil, nil
433+ }
434+ return l.notice(ctx, text, issueHref(l.repo, number))
435+ }
436+ return nil, nil
437+}
438+
409439 // mrPushed announces a push to the source branch of the merge request shown.
410440 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
411441 if l.ref == "" {
@@ -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
@@ -59,7 +61,8 @@ func liveEvent(we ports.WatchEvent) bool {
59 switch we.Event.(type) {61 switch we.Event.(type) {
60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,62 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
61 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,63 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
62- domain.RunUpdated, domain.JobUpdated:64+ domain.RunUpdated, domain.JobUpdated,
65+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
63 return true66 return true
64 }67 }
65 return false68 return false
@@ -74,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen
74 if we.Event == nil {77 if we.Event == nil {
75 return nil, nil78 return nil, nil
76 }79 }
80+ if l.view == "inbox" {
81+ return l.inbox(ctx, we)
82+ }
77 if l.repo == "" {83 if l.repo == "" {
78 return l.home(ctx, we)84 return l.home(ctx, we)
79 }85 }
@@ -172,6 +178,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
172 return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")178 return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
173 case domain.CommentAdded:179 case domain.CommentAdded:
174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")180 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
181+ case domain.IssueOpened:
182+ return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
183+ case domain.IssueClosed:
184+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
185+ case domain.IssueReopened:
186+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
187+ case domain.IssueCommented:
188+ return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
175 case domain.PushEvent:189 case domain.PushEvent:
176 switch l.view {190 switch l.view {
177 case "mr":191 case "mr":
@@ -406,6 +420,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
406 return nil, nil420 return nil, nil
407 }421 }
408 422
423+// issueNotice tells an issue page that something happened to the issue it
424+// shows, or the list that its content changed. Like mrNotice it sends a fixed
425+// sentence only, in the same slot.
426+func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
427+ switch l.view {
428+ case "issues":
429+ return l.notice(ctx, "issues changed — reload", issuesBase(l.repo))
430+ case "issue":
431+ if strconv.FormatInt(number, 10) != l.n {
432+ return nil, nil
433+ }
434+ return l.notice(ctx, text, issueHref(l.repo, number))
435+ }
436+ return nil, nil
437+}
438+
409 // mrPushed announces a push to the source branch of the merge request shown.439 // mrPushed announces a push to the source branch of the merge request shown.
410 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {440 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
411 if l.ref == "" {441 if l.ref == "" {
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 +294 -0
@@ -0,0 +1,294 @@
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+ "strings"
11+ "time"
12+
13+ "github.com/a-h/templ"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20+// issues of every repository, newest activity first, with a filter by
21+// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22+// signed-in owner sees it: for anyone else the path is as missing as any other.
23+// It follows the stream "owner" with view=inbox: each event of a merge request
24+// or an issue sends the counters, the two lists and the filter again.
25+
26+// inboxPath is the page of the inbox.
27+const inboxPath = "/-/inbox"
28+
29+// inboxEvent is the name of the live events of the inbox.
30+const inboxEvent = "inbox"
31+
32+// InboxRow is one open item of the inbox.
33+type InboxRow struct {
34+ Repo string
35+ RepoHref string
36+ Number string
37+ Href string
38+ Title string
39+ When string
40+ WhenAt string
41+ Cursor bool
42+}
43+
44+// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45+// it comes from an anonymous visitor and is never rendered as Markdown.
46+type PendingRow struct {
47+ Repo string
48+ RepoHref string
49+ Author string
50+ Title string
51+ // Preview is the start of the body, at most pendingPreviewRunes characters.
52+ Preview string
53+ When string
54+ WhenAt string
55+ // Approve, Reject and Ban are the URLs the buttons post to; empty without
56+ // the moderation use case.
57+ Approve, Reject, Ban string
58+}
59+
60+// pendingPreviewRunes bounds the preview of a pending body.
61+const pendingPreviewRunes = 300
62+
63+// InboxRepoLink is an entry of the repository filter.
64+type InboxRepoLink struct {
65+ Label string
66+ Href string
67+ Current bool
68+}
69+
70+// InboxPage is the inbox: the counters, the filter and the two lists.
71+type InboxPage struct {
72+ // MergeRequests and Issues count every open item, whatever the filter.
73+ MergeRequests, Issues int
74+ // PendingCount counts every issue awaiting moderation.
75+ PendingCount int
76+ Pending []PendingRow
77+ Filter string // the repository filtered on, "" for all
78+ Repos []InboxRepoLink
79+ MRs []InboxRow
80+ IssueRows []InboxRow
81+}
82+
83+// inboxHref is the inbox page, filtered on repo when it is not "".
84+func inboxHref(repo string) string {
85+ if repo == "" {
86+ return inboxPath
87+ }
88+ return inboxPath + "?repo=" + url.QueryEscape(repo)
89+}
90+
91+// inboxFilter reads ?repo=. A name that is not valid is a page that is not
92+// there.
93+func inboxFilter(r *http.Request) (domain.RepoName, error) {
94+ raw := r.URL.Query().Get("repo")
95+ if raw == "" {
96+ return domain.RepoName{}, nil
97+ }
98+ name, err := domain.ParseRepoName(raw)
99+ if err != nil {
100+ return domain.RepoName{}, errNotFound
101+ }
102+ return name, nil
103+}
104+
105+// inboxPage builds the page from the overview.
106+func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107+ now := s.cfg.Now()
108+ p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109+ for _, pi := range ov.Pending {
110+ p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111+ }
112+ row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
113+ ts := updated
114+ if ts.IsZero() {
115+ ts = created
116+ }
117+ r := InboxRow{
118+ Repo: repo.String(), RepoHref: repoHref(repo.String()),
119+ Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
120+ }
121+ r.When, r.WhenAt = whenOf(now, ts)
122+ return r
123+ }
124+ for _, m := range ov.MergeRequests {
125+ p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
126+ }
127+ for _, i := range ov.Issues {
128+ p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
129+ }
130+ switch {
131+ case len(p.MRs) > 0:
132+ p.MRs[0].Cursor = true
133+ case len(p.IssueRows) > 0:
134+ p.IssueRows[0].Cursor = true
135+ }
136+ p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
137+ for _, r := range ov.Repos {
138+ p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
139+ }
140+ return p
141+}
142+
143+// pendingRow is the row of a pending issue: the texts as plain strings, the
144+// invisible characters marked, the body cut.
145+func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146+ repo := pi.Repo.String()
147+ row := PendingRow{
148+ Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149+ Title: visibleText(pi.Title.String()),
150+ }
151+ if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152+ row.Author = a
153+ }
154+ body := strings.TrimSpace(visibleText(pi.Body.String()))
155+ row.Preview = cutRunes(body, pendingPreviewRunes)
156+ if len(row.Preview) < len(body) {
157+ row.Preview += "…"
158+ }
159+ row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160+ if s.cfg.Moderation != nil {
161+ base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162+ var q string
163+ if !filter.IsZero() {
164+ q = "?repo=" + url.QueryEscape(filter.String())
165+ }
166+ row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167+ }
168+ return row
169+}
170+
171+// pendingBack is where a moderation action returns: the inbox, on the same
172+// repository filter.
173+func pendingBack(r *http.Request) string {
174+ if f, err := inboxFilter(r); err == nil {
175+ return inboxHref(f.String())
176+ }
177+ return inboxPath
178+}
179+
180+// pendingID reads the repository and the id of the path.
181+func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182+ name, err := domain.ParseRepoName(r.PathValue("repo"))
183+ if err != nil {
184+ return domain.RepoName{}, "", err
185+ }
186+ id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187+ if err != nil {
188+ return domain.RepoName{}, "", domain.ErrNotFound
189+ }
190+ return name, id, nil
191+}
192+
193+// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194+func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195+ back := pendingBack(r)
196+ name, id, err := pendingID(r)
197+ if err != nil {
198+ return "", back, err
199+ }
200+ if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201+ return "", back, err
202+ }
203+ return back, back, nil
204+}
205+
206+// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207+// also bans the submitter.
208+func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209+ return func(ctx context.Context, r *http.Request) (string, string, error) {
210+ back := pendingBack(r)
211+ name, id, err := pendingID(r)
212+ if err != nil {
213+ return "", back, err
214+ }
215+ if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216+ return "", back, err
217+ }
218+ return back, back, nil
219+ }
220+}
221+
222+// inbox is GET /-/inbox.
223+func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
224+ if !SignedIn(ctx) {
225+ return nil, 0, errNotFound
226+ }
227+ filter, err := inboxFilter(r)
228+ if err != nil {
229+ return nil, 0, err
230+ }
231+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
232+ if err != nil {
233+ return nil, 0, err
234+ }
235+ l := s.base()
236+ l.Title = "inbox · " + s.instance
237+ l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
238+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
239+ if s.cfg.Events != nil {
240+ q := "view=inbox"
241+ if !filter.IsZero() {
242+ q += "&repo=" + url.QueryEscape(filter.String())
243+ }
244+ l.Live, l.LiveURL = true, liveURL("owner", q)
245+ }
246+ return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
247+}
248+
249+// inboxCount is the number of open items the home button shows, and whether
250+// there is a button: only for the signed-in owner, and only when the inbox is
251+// there. A failed read leaves the button without a number.
252+func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
253+ if s.cfg.Inbox == nil || !SignedIn(ctx) {
254+ return 0, false
255+ }
256+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
257+ if err != nil {
258+ return -1, true
259+ }
260+ return ov.Counts.MergeRequests + ov.Counts.Issues, true
261+}
262+
263+// inbox renders the fragments an event of a merge request, an issue or a repository changes
264+// on the inbox page: the counters, the filter and the two lists.
265+func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266+ switch we.Event.(type) {
267+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269+ domain.RepoUpdated, domain.RepoDeleted:
270+ default:
271+ return nil, nil
272+ }
273+ if l.s.cfg.Inbox == nil {
274+ return nil, nil
275+ }
276+ var filter domain.RepoName
277+ if l.filter != "" {
278+ filter, _ = domain.ParseRepoName(l.filter)
279+ }
280+ ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
281+ if err != nil {
282+ return nil, err
283+ }
284+ p := l.s.inboxPage(ov, filter)
285+ var out []fragment
286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
287+ f, err := renderFragment(ctx, inboxEvent, c)
288+ if err != nil {
289+ return nil, err
290+ }
291+ out = append(out, f)
292+ }
293+ return out, nil
294+}
@@ -0,0 +1,294 @@
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+ "strings"
11+ "time"
12+
13+ "github.com/a-h/templ"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20+// issues of every repository, newest activity first, with a filter by
21+// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22+// signed-in owner sees it: for anyone else the path is as missing as any other.
23+// It follows the stream "owner" with view=inbox: each event of a merge request
24+// or an issue sends the counters, the two lists and the filter again.
25+
26+// inboxPath is the page of the inbox.
27+const inboxPath = "/-/inbox"
28+
29+// inboxEvent is the name of the live events of the inbox.
30+const inboxEvent = "inbox"
31+
32+// InboxRow is one open item of the inbox.
33+type InboxRow struct {
34+ Repo string
35+ RepoHref string
36+ Number string
37+ Href string
38+ Title string
39+ When string
40+ WhenAt string
41+ Cursor bool
42+}
43+
44+// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45+// it comes from an anonymous visitor and is never rendered as Markdown.
46+type PendingRow struct {
47+ Repo string
48+ RepoHref string
49+ Author string
50+ Title string
51+ // Preview is the start of the body, at most pendingPreviewRunes characters.
52+ Preview string
53+ When string
54+ WhenAt string
55+ // Approve, Reject and Ban are the URLs the buttons post to; empty without
56+ // the moderation use case.
57+ Approve, Reject, Ban string
58+}
59+
60+// pendingPreviewRunes bounds the preview of a pending body.
61+const pendingPreviewRunes = 300
62+
63+// InboxRepoLink is an entry of the repository filter.
64+type InboxRepoLink struct {
65+ Label string
66+ Href string
67+ Current bool
68+}
69+
70+// InboxPage is the inbox: the counters, the filter and the two lists.
71+type InboxPage struct {
72+ // MergeRequests and Issues count every open item, whatever the filter.
73+ MergeRequests, Issues int
74+ // PendingCount counts every issue awaiting moderation.
75+ PendingCount int
76+ Pending []PendingRow
77+ Filter string // the repository filtered on, "" for all
78+ Repos []InboxRepoLink
79+ MRs []InboxRow
80+ IssueRows []InboxRow
81+}
82+
83+// inboxHref is the inbox page, filtered on repo when it is not "".
84+func inboxHref(repo string) string {
85+ if repo == "" {
86+ return inboxPath
87+ }
88+ return inboxPath + "?repo=" + url.QueryEscape(repo)
89+}
90+
91+// inboxFilter reads ?repo=. A name that is not valid is a page that is not
92+// there.
93+func inboxFilter(r *http.Request) (domain.RepoName, error) {
94+ raw := r.URL.Query().Get("repo")
95+ if raw == "" {
96+ return domain.RepoName{}, nil
97+ }
98+ name, err := domain.ParseRepoName(raw)
99+ if err != nil {
100+ return domain.RepoName{}, errNotFound
101+ }
102+ return name, nil
103+}
104+
105+// inboxPage builds the page from the overview.
106+func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107+ now := s.cfg.Now()
108+ p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109+ for _, pi := range ov.Pending {
110+ p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111+ }
112+ row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
113+ ts := updated
114+ if ts.IsZero() {
115+ ts = created
116+ }
117+ r := InboxRow{
118+ Repo: repo.String(), RepoHref: repoHref(repo.String()),
119+ Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
120+ }
121+ r.When, r.WhenAt = whenOf(now, ts)
122+ return r
123+ }
124+ for _, m := range ov.MergeRequests {
125+ p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
126+ }
127+ for _, i := range ov.Issues {
128+ p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
129+ }
130+ switch {
131+ case len(p.MRs) > 0:
132+ p.MRs[0].Cursor = true
133+ case len(p.IssueRows) > 0:
134+ p.IssueRows[0].Cursor = true
135+ }
136+ p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
137+ for _, r := range ov.Repos {
138+ p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
139+ }
140+ return p
141+}
142+
143+// pendingRow is the row of a pending issue: the texts as plain strings, the
144+// invisible characters marked, the body cut.
145+func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146+ repo := pi.Repo.String()
147+ row := PendingRow{
148+ Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149+ Title: visibleText(pi.Title.String()),
150+ }
151+ if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152+ row.Author = a
153+ }
154+ body := strings.TrimSpace(visibleText(pi.Body.String()))
155+ row.Preview = cutRunes(body, pendingPreviewRunes)
156+ if len(row.Preview) < len(body) {
157+ row.Preview += "…"
158+ }
159+ row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160+ if s.cfg.Moderation != nil {
161+ base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162+ var q string
163+ if !filter.IsZero() {
164+ q = "?repo=" + url.QueryEscape(filter.String())
165+ }
166+ row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167+ }
168+ return row
169+}
170+
171+// pendingBack is where a moderation action returns: the inbox, on the same
172+// repository filter.
173+func pendingBack(r *http.Request) string {
174+ if f, err := inboxFilter(r); err == nil {
175+ return inboxHref(f.String())
176+ }
177+ return inboxPath
178+}
179+
180+// pendingID reads the repository and the id of the path.
181+func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182+ name, err := domain.ParseRepoName(r.PathValue("repo"))
183+ if err != nil {
184+ return domain.RepoName{}, "", err
185+ }
186+ id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187+ if err != nil {
188+ return domain.RepoName{}, "", domain.ErrNotFound
189+ }
190+ return name, id, nil
191+}
192+
193+// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194+func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195+ back := pendingBack(r)
196+ name, id, err := pendingID(r)
197+ if err != nil {
198+ return "", back, err
199+ }
200+ if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201+ return "", back, err
202+ }
203+ return back, back, nil
204+}
205+
206+// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207+// also bans the submitter.
208+func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209+ return func(ctx context.Context, r *http.Request) (string, string, error) {
210+ back := pendingBack(r)
211+ name, id, err := pendingID(r)
212+ if err != nil {
213+ return "", back, err
214+ }
215+ if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216+ return "", back, err
217+ }
218+ return back, back, nil
219+ }
220+}
221+
222+// inbox is GET /-/inbox.
223+func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
224+ if !SignedIn(ctx) {
225+ return nil, 0, errNotFound
226+ }
227+ filter, err := inboxFilter(r)
228+ if err != nil {
229+ return nil, 0, err
230+ }
231+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
232+ if err != nil {
233+ return nil, 0, err
234+ }
235+ l := s.base()
236+ l.Title = "inbox · " + s.instance
237+ l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
238+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
239+ if s.cfg.Events != nil {
240+ q := "view=inbox"
241+ if !filter.IsZero() {
242+ q += "&repo=" + url.QueryEscape(filter.String())
243+ }
244+ l.Live, l.LiveURL = true, liveURL("owner", q)
245+ }
246+ return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
247+}
248+
249+// inboxCount is the number of open items the home button shows, and whether
250+// there is a button: only for the signed-in owner, and only when the inbox is
251+// there. A failed read leaves the button without a number.
252+func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
253+ if s.cfg.Inbox == nil || !SignedIn(ctx) {
254+ return 0, false
255+ }
256+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
257+ if err != nil {
258+ return -1, true
259+ }
260+ return ov.Counts.MergeRequests + ov.Counts.Issues, true
261+}
262+
263+// inbox renders the fragments an event of a merge request, an issue or a repository changes
264+// on the inbox page: the counters, the filter and the two lists.
265+func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266+ switch we.Event.(type) {
267+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269+ domain.RepoUpdated, domain.RepoDeleted:
270+ default:
271+ return nil, nil
272+ }
273+ if l.s.cfg.Inbox == nil {
274+ return nil, nil
275+ }
276+ var filter domain.RepoName
277+ if l.filter != "" {
278+ filter, _ = domain.ParseRepoName(l.filter)
279+ }
280+ ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
281+ if err != nil {
282+ return nil, err
283+ }
284+ p := l.s.inboxPage(ov, filter)
285+ var out []fragment
286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
287+ f, err := renderFragment(ctx, inboxEvent, c)
288+ if err != nil {
289+ return nil, err
290+ }
291+ out = append(out, f)
292+ }
293+ return out, nil
294+}
A internal/adapters/web/inbox.templ +115 -0
@@ -0,0 +1,115 @@
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+ @InboxPending(p)
15+ @InboxMRs(p)
16+ @InboxIssues(p)
17+ </div>
18+ }
19+}
20+
21+templ InboxCounters(p InboxPage) {
22+ <p id="inbox-counts" class="inbox-counts">
23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
24+ </p>
25+}
26+
27+templ InboxRepos(p InboxPage) {
28+ <nav id="inbox-repos" class="since" aria-label="Repository">
29+ for _, r := range p.Repos {
30+ <a
31+ href={ templ.SafeURL(r.Href) }
32+ if r.Current {
33+ aria-current="page"
34+ }
35+ >{ r.Label }</a>
36+ }
37+ </nav>
38+}
39+
40+// InboxPending is the issues awaiting moderation, first on the page. Their
41+// texts come from anonymous visitors: they are shown as plain text, escaped,
42+// never as Markdown.
43+templ InboxPending(p InboxPage) {
44+ <section id="inbox-pending" aria-labelledby="inbox-pending-h">
45+ <h2 id="inbox-pending-h" class="rule-h"><span>Awaiting moderation</span><span class="count">{ itoa(p.PendingCount) }</span></h2>
46+ if len(p.Pending) > 0 {
47+ <ul class="pending-list">
48+ for _, m := range p.Pending {
49+ <li class="pending">
50+ <div class="p-head">
51+ <a href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a>
52+ <span>{ m.Author }</span>
53+ if m.WhenAt != "" {
54+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
55+ }
56+ </div>
57+ <p class="p-title">{ m.Title }</p>
58+ if m.Preview != "" {
59+ <p class="p-body">{ m.Preview }</p>
60+ }
61+ if m.Approve != "" {
62+ <div class="p-btns">
63+ <form method="post" action={ templ.SafeURL(m.Approve) }><button type="submit" class="btn primary">Approve</button></form>
64+ <form method="post" action={ templ.SafeURL(m.Reject) }><button type="submit" class="btn">Reject</button></form>
65+ <form method="post" action={ templ.SafeURL(m.Ban) }><button type="submit" class="btn danger">Reject and ban</button></form>
66+ </div>
67+ }
68+ </li>
69+ }
70+ </ul>
71+ } else {
72+ <p class="lede dim">nothing awaits moderation.</p>
73+ }
74+ </section>
75+}
76+
77+templ InboxMRs(p InboxPage) {
78+ <section id="inbox-mrs" aria-labelledby="inbox-mrs-h">
79+ <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2>
80+ @inboxRows(p.MRs, "Open merge requests")
81+ if len(p.MRs) == 0 {
82+ <p class="lede dim">no open merge requests.</p>
83+ }
84+ </section>
85+}
86+
87+templ InboxIssues(p InboxPage) {
88+ <section id="inbox-issues" aria-labelledby="inbox-issues-h">
89+ <h2 id="inbox-issues-h" class="rule-h"><span>Open issues</span><span class="count">{ itoa(len(p.IssueRows)) }</span></h2>
90+ @inboxRows(p.IssueRows, "Open issues")
91+ if len(p.IssueRows) == 0 {
92+ <p class="lede dim">no open issues.</p>
93+ }
94+ </section>
95+}
96+
97+templ inboxRows(rows []InboxRow, label string) {
98+ if len(rows) > 0 {
99+ <table class="list mrs" aria-label={ label }>
100+ <tbody data-rows>
101+ for _, m := range rows {
102+ <tr data-cursor?={ m.Cursor } data-state="open">
103+ <td class="c-n mute">{ m.Number }</td>
104+ <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>
105+ <td class="c-when nowrap dim">
106+ if m.WhenAt != "" {
107+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
108+ }
109+ </td>
110+ </tr>
111+ }
112+ </tbody>
113+ </table>
114+ }
115+}
@@ -0,0 +1,115 @@
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+ @InboxPending(p)
15+ @InboxMRs(p)
16+ @InboxIssues(p)
17+ </div>
18+ }
19+}
20+
21+templ InboxCounters(p InboxPage) {
22+ <p id="inbox-counts" class="inbox-counts">
23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
24+ </p>
25+}
26+
27+templ InboxRepos(p InboxPage) {
28+ <nav id="inbox-repos" class="since" aria-label="Repository">
29+ for _, r := range p.Repos {
30+ <a
31+ href={ templ.SafeURL(r.Href) }
32+ if r.Current {
33+ aria-current="page"
34+ }
35+ >{ r.Label }</a>
36+ }
37+ </nav>
38+}
39+
40+// InboxPending is the issues awaiting moderation, first on the page. Their
41+// texts come from anonymous visitors: they are shown as plain text, escaped,
42+// never as Markdown.
43+templ InboxPending(p InboxPage) {
44+ <section id="inbox-pending" aria-labelledby="inbox-pending-h">
45+ <h2 id="inbox-pending-h" class="rule-h"><span>Awaiting moderation</span><span class="count">{ itoa(p.PendingCount) }</span></h2>
46+ if len(p.Pending) > 0 {
47+ <ul class="pending-list">
48+ for _, m := range p.Pending {
49+ <li class="pending">
50+ <div class="p-head">
51+ <a href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a>
52+ <span>{ m.Author }</span>
53+ if m.WhenAt != "" {
54+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
55+ }
56+ </div>
57+ <p class="p-title">{ m.Title }</p>
58+ if m.Preview != "" {
59+ <p class="p-body">{ m.Preview }</p>
60+ }
61+ if m.Approve != "" {
62+ <div class="p-btns">
63+ <form method="post" action={ templ.SafeURL(m.Approve) }><button type="submit" class="btn primary">Approve</button></form>
64+ <form method="post" action={ templ.SafeURL(m.Reject) }><button type="submit" class="btn">Reject</button></form>
65+ <form method="post" action={ templ.SafeURL(m.Ban) }><button type="submit" class="btn danger">Reject and ban</button></form>
66+ </div>
67+ }
68+ </li>
69+ }
70+ </ul>
71+ } else {
72+ <p class="lede dim">nothing awaits moderation.</p>
73+ }
74+ </section>
75+}
76+
77+templ InboxMRs(p InboxPage) {
78+ <section id="inbox-mrs" aria-labelledby="inbox-mrs-h">
79+ <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2>
80+ @inboxRows(p.MRs, "Open merge requests")
81+ if len(p.MRs) == 0 {
82+ <p class="lede dim">no open merge requests.</p>
83+ }
84+ </section>
85+}
86+
87+templ InboxIssues(p InboxPage) {
88+ <section id="inbox-issues" aria-labelledby="inbox-issues-h">
89+ <h2 id="inbox-issues-h" class="rule-h"><span>Open issues</span><span class="count">{ itoa(len(p.IssueRows)) }</span></h2>
90+ @inboxRows(p.IssueRows, "Open issues")
91+ if len(p.IssueRows) == 0 {
92+ <p class="lede dim">no open issues.</p>
93+ }
94+ </section>
95+}
96+
97+templ inboxRows(rows []InboxRow, label string) {
98+ if len(rows) > 0 {
99+ <table class="list mrs" aria-label={ label }>
100+ <tbody data-rows>
101+ for _, m := range rows {
102+ <tr data-cursor?={ m.Cursor } data-state="open">
103+ <td class="c-n mute">{ m.Number }</td>
104+ <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>
105+ <td class="c-when nowrap dim">
106+ if m.WhenAt != "" {
107+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
108+ }
109+ </td>
110+ </tr>
111+ }
112+ </tbody>
113+ </table>
114+ }
115+}
A internal/adapters/web/inbox_moderation_test.go +148 -0
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+const testPendingID = "aaaaaaaaaaaaaaaaaaaaaaaaaa"
22+
23+func testPendingIssue(t testing.TB) domain.PendingIssue {
24+ t.Helper()
25+ id, err := domain.NewPendingIssueID()
26+ require.NoError(t, err)
27+ title, err := domain.ParseIssueTitle("<script>alert(1)</script> crash")
28+ require.NoError(t, err)
29+ body, err := domain.ParseIssueBody("**not bold** [x](javascript:alert(1)) <img src=x onerror=y>\n" + strings.Repeat("long line ", 60))
30+ require.NoError(t, err)
31+ return domain.PendingIssue{ID: id, Repo: repoName(t, "alpha"), Title: title, Body: body, Author: "Eve <i>", CreatedAt: time.Now().Add(-3 * time.Hour)}
32+}
33+
34+func moderationServer(t *testing.T, inbox ports.InboxService, mod ports.IssueModerationService) *Server {
35+ t.Helper()
36+ return newTestServer(t, Config{
37+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, Moderation: mod,
38+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
39+ })
40+}
41+
42+func TestInboxShowsPendingIssuesAsPlainTextFirst(t *testing.T) {
43+ t.Parallel()
44+ p := testPendingIssue(t)
45+ ov := inboxOverview(t)
46+ ov.Pending = []domain.PendingIssue{p, {ID: p.ID, Repo: repoName(t, "beta"), Title: p.Title, CreatedAt: time.Now()}}
47+ ov.Counts.Pending = 2
48+ svc := mocks.NewInboxService(t)
49+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil)
50+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
51+
52+ rec := inboxGet(srv, "/-/inbox", true)
53+ require.Equal(t, 200, rec.Code)
54+ body := rec.Body.String()
55+ assert.Contains(t, body, "<strong>2</strong> awaiting moderation")
56+ assert.Less(t, strings.Index(body, `id="inbox-pending"`), strings.Index(body, `id="inbox-mrs"`), "first section")
57+ assert.Contains(t, body, "&lt;script&gt;alert(1)&lt;/script&gt; crash")
58+ assert.NotContains(t, body, "<script>alert")
59+ assert.Contains(t, body, "**not bold**", "no Markdown")
60+ assert.NotContains(t, body, "<strong>not bold")
61+ assert.NotContains(t, body, `href="javascript`)
62+ assert.NotContains(t, body, "<img")
63+ assert.Contains(t, body, "Eve &lt;i&gt;")
64+ assert.Contains(t, body, ">anonymous<", "no label")
65+ assert.Contains(t, body, "…</p>", "a preview of about 300 characters")
66+ assert.Contains(t, body, `action="/-/inbox/pending/alpha/`+p.ID.String()+`/approve"`)
67+ assert.Contains(t, body, `/reject"`)
68+ assert.Contains(t, body, `/ban"`)
69+ assert.Contains(t, body, "Reject and ban")
70+}
71+
72+func TestInboxPendingKeepsTheFilterInItsActions(t *testing.T) {
73+ t.Parallel()
74+ p := testPendingIssue(t)
75+ ov := inboxOverview(t)
76+ ov.Pending = []domain.PendingIssue{p}
77+ ov.Counts.Pending = 1
78+ svc := mocks.NewInboxService(t)
79+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "alpha")).Return(ov, nil)
80+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
81+ rec := inboxGet(srv, "/-/inbox?repo=alpha", true)
82+ assert.Contains(t, rec.Body.String(), `/approve?repo=alpha"`)
83+}
84+
85+func TestInboxModerationActions(t *testing.T) {
86+ t.Parallel()
87+ post := func(srv *Server, path string, hdr map[string]string) *httptest.ResponseRecorder {
88+ req := httptest.NewRequest(http.MethodPost, path, nil)
89+ for k, v := range hdr {
90+ req.Header.Set(k, v)
91+ }
92+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
93+ rec := httptest.NewRecorder()
94+ srv.ServeHTTP(rec, req)
95+ return rec
96+ }
97+ id, err := domain.ParsePendingIssueID(testPendingID)
98+ require.NoError(t, err)
99+ alpha := repoName(t, "alpha")
100+ site := map[string]string{"Origin": testPublicURL}
101+
102+ t.Run("approve", func(t *testing.T) {
103+ t.Parallel()
104+ mod := mocks.NewIssueModerationService(t)
105+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, nil).Once()
106+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve?repo=alpha", site)
107+ assert.Equal(t, 303, rec.Code)
108+ assert.Equal(t, "/-/inbox?repo=alpha", rec.Header().Get("Location"))
109+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
110+ })
111+ t.Run("reject", func(t *testing.T) {
112+ t.Parallel()
113+ mod := mocks.NewIssueModerationService(t)
114+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, false).Return(nil).Once()
115+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/reject", site)
116+ assert.Equal(t, 303, rec.Code)
117+ assert.Equal(t, "/-/inbox", rec.Header().Get("Location"))
118+ })
119+ t.Run("ban", func(t *testing.T) {
120+ t.Parallel()
121+ mod := mocks.NewIssueModerationService(t)
122+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, true).Return(nil).Once()
123+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/ban", site)
124+ assert.Equal(t, 303, rec.Code)
125+ })
126+ t.Run("gone", func(t *testing.T) {
127+ t.Parallel()
128+ mod := mocks.NewIssueModerationService(t)
129+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, domain.ErrNotFound).Once()
130+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve", site)
131+ assert.Equal(t, 404, rec.Code)
132+ })
133+ t.Run("a malformed id is not found and calls nothing", func(t *testing.T) {
134+ t.Parallel()
135+ rec := post(moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t)), "/-/inbox/pending/alpha/nope/approve", site)
136+ assert.Equal(t, 404, rec.Code)
137+ })
138+ t.Run("the gate: no origin, no session", func(t *testing.T) {
139+ t.Parallel()
140+ srv := moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t))
141+ assert.Equal(t, 403, post(srv, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil).Code)
142+ req := httptest.NewRequest(http.MethodPost, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil)
143+ req.Header.Set("Origin", testPublicURL)
144+ rec := httptest.NewRecorder()
145+ srv.ServeHTTP(rec, req)
146+ assert.Equal(t, 403, rec.Code, "anonymous")
147+ })
148+}
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+const testPendingID = "aaaaaaaaaaaaaaaaaaaaaaaaaa"
22+
23+func testPendingIssue(t testing.TB) domain.PendingIssue {
24+ t.Helper()
25+ id, err := domain.NewPendingIssueID()
26+ require.NoError(t, err)
27+ title, err := domain.ParseIssueTitle("<script>alert(1)</script> crash")
28+ require.NoError(t, err)
29+ body, err := domain.ParseIssueBody("**not bold** [x](javascript:alert(1)) <img src=x onerror=y>\n" + strings.Repeat("long line ", 60))
30+ require.NoError(t, err)
31+ return domain.PendingIssue{ID: id, Repo: repoName(t, "alpha"), Title: title, Body: body, Author: "Eve <i>", CreatedAt: time.Now().Add(-3 * time.Hour)}
32+}
33+
34+func moderationServer(t *testing.T, inbox ports.InboxService, mod ports.IssueModerationService) *Server {
35+ t.Helper()
36+ return newTestServer(t, Config{
37+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, Moderation: mod,
38+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
39+ })
40+}
41+
42+func TestInboxShowsPendingIssuesAsPlainTextFirst(t *testing.T) {
43+ t.Parallel()
44+ p := testPendingIssue(t)
45+ ov := inboxOverview(t)
46+ ov.Pending = []domain.PendingIssue{p, {ID: p.ID, Repo: repoName(t, "beta"), Title: p.Title, CreatedAt: time.Now()}}
47+ ov.Counts.Pending = 2
48+ svc := mocks.NewInboxService(t)
49+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil)
50+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
51+
52+ rec := inboxGet(srv, "/-/inbox", true)
53+ require.Equal(t, 200, rec.Code)
54+ body := rec.Body.String()
55+ assert.Contains(t, body, "<strong>2</strong> awaiting moderation")
56+ assert.Less(t, strings.Index(body, `id="inbox-pending"`), strings.Index(body, `id="inbox-mrs"`), "first section")
57+ assert.Contains(t, body, "&lt;script&gt;alert(1)&lt;/script&gt; crash")
58+ assert.NotContains(t, body, "<script>alert")
59+ assert.Contains(t, body, "**not bold**", "no Markdown")
60+ assert.NotContains(t, body, "<strong>not bold")
61+ assert.NotContains(t, body, `href="javascript`)
62+ assert.NotContains(t, body, "<img")
63+ assert.Contains(t, body, "Eve &lt;i&gt;")
64+ assert.Contains(t, body, ">anonymous<", "no label")
65+ assert.Contains(t, body, "…</p>", "a preview of about 300 characters")
66+ assert.Contains(t, body, `action="/-/inbox/pending/alpha/`+p.ID.String()+`/approve"`)
67+ assert.Contains(t, body, `/reject"`)
68+ assert.Contains(t, body, `/ban"`)
69+ assert.Contains(t, body, "Reject and ban")
70+}
71+
72+func TestInboxPendingKeepsTheFilterInItsActions(t *testing.T) {
73+ t.Parallel()
74+ p := testPendingIssue(t)
75+ ov := inboxOverview(t)
76+ ov.Pending = []domain.PendingIssue{p}
77+ ov.Counts.Pending = 1
78+ svc := mocks.NewInboxService(t)
79+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "alpha")).Return(ov, nil)
80+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
81+ rec := inboxGet(srv, "/-/inbox?repo=alpha", true)
82+ assert.Contains(t, rec.Body.String(), `/approve?repo=alpha"`)
83+}
84+
85+func TestInboxModerationActions(t *testing.T) {
86+ t.Parallel()
87+ post := func(srv *Server, path string, hdr map[string]string) *httptest.ResponseRecorder {
88+ req := httptest.NewRequest(http.MethodPost, path, nil)
89+ for k, v := range hdr {
90+ req.Header.Set(k, v)
91+ }
92+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
93+ rec := httptest.NewRecorder()
94+ srv.ServeHTTP(rec, req)
95+ return rec
96+ }
97+ id, err := domain.ParsePendingIssueID(testPendingID)
98+ require.NoError(t, err)
99+ alpha := repoName(t, "alpha")
100+ site := map[string]string{"Origin": testPublicURL}
101+
102+ t.Run("approve", func(t *testing.T) {
103+ t.Parallel()
104+ mod := mocks.NewIssueModerationService(t)
105+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, nil).Once()
106+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve?repo=alpha", site)
107+ assert.Equal(t, 303, rec.Code)
108+ assert.Equal(t, "/-/inbox?repo=alpha", rec.Header().Get("Location"))
109+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
110+ })
111+ t.Run("reject", func(t *testing.T) {
112+ t.Parallel()
113+ mod := mocks.NewIssueModerationService(t)
114+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, false).Return(nil).Once()
115+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/reject", site)
116+ assert.Equal(t, 303, rec.Code)
117+ assert.Equal(t, "/-/inbox", rec.Header().Get("Location"))
118+ })
119+ t.Run("ban", func(t *testing.T) {
120+ t.Parallel()
121+ mod := mocks.NewIssueModerationService(t)
122+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, true).Return(nil).Once()
123+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/ban", site)
124+ assert.Equal(t, 303, rec.Code)
125+ })
126+ t.Run("gone", func(t *testing.T) {
127+ t.Parallel()
128+ mod := mocks.NewIssueModerationService(t)
129+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, domain.ErrNotFound).Once()
130+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve", site)
131+ assert.Equal(t, 404, rec.Code)
132+ })
133+ t.Run("a malformed id is not found and calls nothing", func(t *testing.T) {
134+ t.Parallel()
135+ rec := post(moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t)), "/-/inbox/pending/alpha/nope/approve", site)
136+ assert.Equal(t, 404, rec.Code)
137+ })
138+ t.Run("the gate: no origin, no session", func(t *testing.T) {
139+ t.Parallel()
140+ srv := moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t))
141+ assert.Equal(t, 403, post(srv, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil).Code)
142+ req := httptest.NewRequest(http.MethodPost, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil)
143+ req.Header.Set("Origin", testPublicURL)
144+ rec := httptest.NewRecorder()
145+ srv.ServeHTTP(rec, req)
146+ assert.Equal(t, 403, rec.Code, "anonymous")
147+ })
148+}
A internal/adapters/web/inbox_templ.go +759 -0 generated file: collapsed
A internal/adapters/web/inbox_test.go +310 -0
@@ -0,0 +1,310 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bufio"
7+ "context"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+func inboxOverview(t testing.TB) ports.InboxOverview {
25+ t.Helper()
26+ mrTitle, err := domain.ParseMRTitle("fix <b>search</b>")
27+ require.NoError(t, err)
28+ isTitle, err := domain.ParseIssueTitle("crash on start")
29+ require.NoError(t, err)
30+ at := time.Now().Add(-2 * time.Hour)
31+ ov := ports.InboxOverview{
32+ MergeRequests: []domain.MergeRequest{{
33+ Repo: repoName(t, "alpha"), Number: 4, Title: mrTitle, State: domain.MRStateOpen, UpdatedAt: at,
34+ }},
35+ Issues: []domain.Issue{{Repo: repoName(t, "beta"), Number: 7, Title: isTitle, State: domain.IssueStateOpen, UpdatedAt: at}},
36+ Repos: []domain.RepoName{repoName(t, "alpha"), repoName(t, "beta")},
37+ }
38+ ov.Counts = ports.InboxCounts{MergeRequests: 1, Issues: 1}
39+ return ov
40+}
41+
42+func inboxServer(t *testing.T, inbox ports.InboxService) *Server {
43+ t.Helper()
44+ return newTestServer(t, Config{
45+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox,
46+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
47+ })
48+}
49+
50+func inboxGet(srv *Server, path string, owner bool) *httptest.ResponseRecorder {
51+ req := httptest.NewRequest(http.MethodGet, path, nil)
52+ if owner {
53+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
54+ }
55+ rec := httptest.NewRecorder()
56+ srv.ServeHTTP(rec, req)
57+ return rec
58+}
59+
60+func TestInboxPageForTheOwner(t *testing.T) {
61+ t.Parallel()
62+ svc := mocks.NewInboxService(t)
63+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
64+ srv := inboxServer(t, svc)
65+
66+ rec := inboxGet(srv, "/-/inbox", true)
67+ require.Equal(t, 200, rec.Code, rec.Body.String())
68+ body := rec.Body.String()
69+ assert.Contains(t, body, "<strong>1</strong> merge requests · <strong>1</strong> issues")
70+ assert.Contains(t, body, "Open merge requests")
71+ assert.Contains(t, body, "Open issues")
72+ assert.Contains(t, body, `href="/alpha/merge-requests/4"`)
73+ assert.Contains(t, body, "fix &lt;b&gt;search&lt;/b&gt;")
74+ assert.Contains(t, body, `href="/beta/issues/7"`)
75+ assert.Contains(t, body, "#7")
76+ assert.Contains(t, body, "2h ago")
77+ assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter")
78+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
79+}
80+
81+func TestInboxPageIsMissingForEveryoneElse(t *testing.T) {
82+ t.Parallel()
83+ svc := mocks.NewInboxService(t) // never asked
84+ srv := inboxServer(t, svc)
85+ missing := inboxGet(srv, "/-/nothing-here", false)
86+ require.Equal(t, 404, missing.Code)
87+
88+ anon := inboxGet(srv, "/-/inbox", false)
89+ assert.Equal(t, 404, anon.Code)
90+ assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one")
91+ assert.NotContains(t, anon.Body.String(), "Open merge requests")
92+
93+ // A bad cookie is anonymous as well.
94+ req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
95+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"})
96+ rec := httptest.NewRecorder()
97+ srv.ServeHTTP(rec, req)
98+ assert.Equal(t, 404, rec.Code)
99+}
100+
101+func TestInboxNotMountedWithoutTheService(t *testing.T) {
102+ t.Parallel()
103+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}})
104+ assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code)
105+}
106+
107+func TestInboxFilterAndEmptyStates(t *testing.T) {
108+ t.Parallel()
109+ svc := mocks.NewInboxService(t)
110+ ov := inboxOverview(t)
111+ filtered := ov
112+ filtered.MergeRequests, filtered.Issues = nil, nil
113+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil)
114+ srv := inboxServer(t, svc)
115+
116+ rec := inboxGet(srv, "/-/inbox?repo=gamma", true)
117+ require.Equal(t, 200, rec.Code)
118+ body := rec.Body.String()
119+ assert.Contains(t, body, "no open merge requests.")
120+ assert.Contains(t, body, "no open issues.")
121+ assert.Contains(t, body, `href="/-/inbox"`, "the link back to all")
122+
123+ assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page")
124+}
125+
126+func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) {
127+ t.Parallel()
128+ svc := mocks.NewInboxService(t)
129+ ov := inboxOverview(t)
130+ ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2}
131+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only
132+ srv := inboxServer(t, svc)
133+
134+ anon := inboxGet(srv, "/", false)
135+ require.Equal(t, 200, anon.Code)
136+ assert.NotContains(t, anon.Body.String(), "/-/inbox")
137+ assert.NotContains(t, anon.Body.String(), "Inbox")
138+
139+ owner := inboxGet(srv, "/", true)
140+ require.Equal(t, 200, owner.Code)
141+ assert.Contains(t, owner.Body.String(), `href="/-/inbox"`)
142+ assert.Contains(t, owner.Body.String(), `<span class="count">5</span>`)
143+}
144+
145+func TestInboxStreamIsTheOwnersAlone(t *testing.T) {
146+ t.Parallel()
147+ f := &watchProbe{release: make(chan struct{})}
148+ svc := mocks.NewInboxService(t)
149+ srv := newTestServer(t, Config{
150+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
151+ Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
152+ })
153+ ts := httptest.NewServer(srv)
154+ t.Cleanup(ts.Close)
155+
156+ unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
157+ want, _ := io.ReadAll(unknown.Body)
158+ for name, hdr := range map[string]map[string]string{
159+ "anonymous": nil,
160+ "another token": {"Cookie": CookieName + "=other"},
161+ } {
162+ resp, _ := open(t, ts, "topic=owner&view=inbox", hdr)
163+ body, _ := io.ReadAll(resp.Body)
164+ assert.Equal(t, unknown.StatusCode, resp.StatusCode, name)
165+ assert.Equal(t, string(want), string(body), name)
166+ assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name)
167+ }
168+ assert.Zero(t, f.opened.Load(), "no watch is opened for them")
169+
170+ resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
171+ assert.Equal(t, http.StatusOK, resp.StatusCode)
172+ assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
173+}
174+
175+func TestInboxLiveFragments(t *testing.T) {
176+ t.Parallel()
177+ svc := mocks.NewInboxService(t)
178+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
179+ srv := inboxServer(t, svc)
180+ ls := &liveStream{s: srv, view: "inbox"}
181+ frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182+ require.NoError(t, err)
183+ require.Len(t, frags, 5)
184+ assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185+ assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186+ assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
189+
190+ none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191+ require.NoError(t, err)
192+ assert.Empty(t, none, "a push is not inbox news")
193+}
194+
195+// withOwner is a context as the session middleware leaves it for the owner.
196+func withOwner(ctx context.Context) context.Context {
197+ return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner())
198+}
199+
200+// A bearer token is not a web session: the inbox answers it as it answers
201+// anyone else, with the unknown-page and unknown-topic responses.
202+func TestInboxRefusesABearerToken(t *testing.T) {
203+ t.Parallel()
204+ f := &watchProbe{release: make(chan struct{})}
205+ svc := mocks.NewInboxService(t) // never asked
206+ srv := newTestServer(t, Config{
207+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
208+ Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
209+ })
210+ bearer := map[string]string{"Authorization": "Bearer tok"}
211+
212+ // The page.
213+ missing := inboxGet(srv, "/-/nothing-here", false)
214+ req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
215+ req.Header.Set("Authorization", "Bearer tok")
216+ rec := httptest.NewRecorder()
217+ srv.ServeHTTP(rec, req)
218+ assert.Equal(t, missing.Code, rec.Code)
219+ assert.Equal(t, http.StatusNotFound, rec.Code)
220+ assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response")
221+
222+ // The stream.
223+ ts := httptest.NewServer(srv)
224+ t.Cleanup(ts.Close)
225+ unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
226+ want, _ := io.ReadAll(unknown.Body)
227+ resp, _ := open(t, ts, "topic=owner&view=inbox", bearer)
228+ body, _ := io.ReadAll(resp.Body)
229+ assert.Equal(t, unknown.StatusCode, resp.StatusCode)
230+ assert.Equal(t, string(want), string(body), "the unknown-topic response")
231+ assert.Zero(t, f.opened.Load(), "no watch is opened")
232+}
233+
234+// The owner's inbox stream ends once the session is invalid: the next event is
235+// not sent.
236+func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) {
237+ t.Parallel()
238+ f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
239+ sess, ended := newRevocable(t, "tok")
240+ svc := mocks.NewInboxService(t)
241+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe()
242+ srv := newTestServer(t, Config{
243+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
244+ Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{},
245+ })
246+ ts := httptest.NewServer(srv)
247+ t.Cleanup(ts.Close)
248+ resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
249+ require.Equal(t, http.StatusOK, resp.StatusCode)
250+ defer time.AfterFunc(5*time.Second, cancel).Stop()
251+ rd := bufio.NewReader(resp.Body)
252+ line := func() string {
253+ s, err := rd.ReadString('\n')
254+ if err != nil {
255+ return "EOF"
256+ }
257+ return strings.TrimRight(s, "\n")
258+ }
259+ require.Equal(t, ": connected", line())
260+ line()
261+ ev := func(id string) ports.WatchEvent {
262+ return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}
263+ }
264+ f.push <- ev("b-1")
265+ var seen []string
266+ for l := line(); !strings.HasPrefix(l, "data:"); l = line() {
267+ seen = append(seen, l)
268+ require.NotEqual(t, "EOF", l)
269+ }
270+ assert.Contains(t, seen, "id: b-1")
271+
272+ ended.Store(true)
273+ select {
274+ case f.push <- ev("b-2"):
275+ case <-time.After(5 * time.Second):
276+ t.Fatal("the event was not delivered to the web")
277+ }
278+ rest, _ := io.ReadAll(rd)
279+ assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended")
280+ require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed")
281+}
282+
283+// A repository event refreshes the inbox: issues turned off or a deletion
284+// remove its items from the fragments.
285+func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286+ t.Parallel()
287+ for name, ev := range map[string]domain.Event{
288+ "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
289+ "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
290+ } {
291+ t.Run(name, func(t *testing.T) {
292+ t.Parallel()
293+ after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}}
294+ svc := mocks.NewInboxService(t)
295+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil)
296+ srv := inboxServer(t, svc)
297+ ls := &liveStream{s: srv, view: "inbox"}
298+ frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299+ require.NoError(t, err)
300+ require.Len(t, frags, 5)
301+ all := ""
302+ for _, f := range frags {
303+ all += f.html
304+ }
305+ assert.Contains(t, all, `id="inbox-issues"`)
306+ assert.NotContains(t, all, "crash on start", "the issue is gone")
307+ assert.NotContains(t, all, "/beta", "the repository is gone from the filter")
308+ })
309+ }
310+}
@@ -0,0 +1,310 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bufio"
7+ "context"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+func inboxOverview(t testing.TB) ports.InboxOverview {
25+ t.Helper()
26+ mrTitle, err := domain.ParseMRTitle("fix <b>search</b>")
27+ require.NoError(t, err)
28+ isTitle, err := domain.ParseIssueTitle("crash on start")
29+ require.NoError(t, err)
30+ at := time.Now().Add(-2 * time.Hour)
31+ ov := ports.InboxOverview{
32+ MergeRequests: []domain.MergeRequest{{
33+ Repo: repoName(t, "alpha"), Number: 4, Title: mrTitle, State: domain.MRStateOpen, UpdatedAt: at,
34+ }},
35+ Issues: []domain.Issue{{Repo: repoName(t, "beta"), Number: 7, Title: isTitle, State: domain.IssueStateOpen, UpdatedAt: at}},
36+ Repos: []domain.RepoName{repoName(t, "alpha"), repoName(t, "beta")},
37+ }
38+ ov.Counts = ports.InboxCounts{MergeRequests: 1, Issues: 1}
39+ return ov
40+}
41+
42+func inboxServer(t *testing.T, inbox ports.InboxService) *Server {
43+ t.Helper()
44+ return newTestServer(t, Config{
45+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox,
46+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
47+ })
48+}
49+
50+func inboxGet(srv *Server, path string, owner bool) *httptest.ResponseRecorder {
51+ req := httptest.NewRequest(http.MethodGet, path, nil)
52+ if owner {
53+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
54+ }
55+ rec := httptest.NewRecorder()
56+ srv.ServeHTTP(rec, req)
57+ return rec
58+}
59+
60+func TestInboxPageForTheOwner(t *testing.T) {
61+ t.Parallel()
62+ svc := mocks.NewInboxService(t)
63+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
64+ srv := inboxServer(t, svc)
65+
66+ rec := inboxGet(srv, "/-/inbox", true)
67+ require.Equal(t, 200, rec.Code, rec.Body.String())
68+ body := rec.Body.String()
69+ assert.Contains(t, body, "<strong>1</strong> merge requests · <strong>1</strong> issues")
70+ assert.Contains(t, body, "Open merge requests")
71+ assert.Contains(t, body, "Open issues")
72+ assert.Contains(t, body, `href="/alpha/merge-requests/4"`)
73+ assert.Contains(t, body, "fix &lt;b&gt;search&lt;/b&gt;")
74+ assert.Contains(t, body, `href="/beta/issues/7"`)
75+ assert.Contains(t, body, "#7")
76+ assert.Contains(t, body, "2h ago")
77+ assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter")
78+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
79+}
80+
81+func TestInboxPageIsMissingForEveryoneElse(t *testing.T) {
82+ t.Parallel()
83+ svc := mocks.NewInboxService(t) // never asked
84+ srv := inboxServer(t, svc)
85+ missing := inboxGet(srv, "/-/nothing-here", false)
86+ require.Equal(t, 404, missing.Code)
87+
88+ anon := inboxGet(srv, "/-/inbox", false)
89+ assert.Equal(t, 404, anon.Code)
90+ assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one")
91+ assert.NotContains(t, anon.Body.String(), "Open merge requests")
92+
93+ // A bad cookie is anonymous as well.
94+ req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
95+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"})
96+ rec := httptest.NewRecorder()
97+ srv.ServeHTTP(rec, req)
98+ assert.Equal(t, 404, rec.Code)
99+}
100+
101+func TestInboxNotMountedWithoutTheService(t *testing.T) {
102+ t.Parallel()
103+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}})
104+ assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code)
105+}
106+
107+func TestInboxFilterAndEmptyStates(t *testing.T) {
108+ t.Parallel()
109+ svc := mocks.NewInboxService(t)
110+ ov := inboxOverview(t)
111+ filtered := ov
112+ filtered.MergeRequests, filtered.Issues = nil, nil
113+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil)
114+ srv := inboxServer(t, svc)
115+
116+ rec := inboxGet(srv, "/-/inbox?repo=gamma", true)
117+ require.Equal(t, 200, rec.Code)
118+ body := rec.Body.String()
119+ assert.Contains(t, body, "no open merge requests.")
120+ assert.Contains(t, body, "no open issues.")
121+ assert.Contains(t, body, `href="/-/inbox"`, "the link back to all")
122+
123+ assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page")
124+}
125+
126+func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) {
127+ t.Parallel()
128+ svc := mocks.NewInboxService(t)
129+ ov := inboxOverview(t)
130+ ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2}
131+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only
132+ srv := inboxServer(t, svc)
133+
134+ anon := inboxGet(srv, "/", false)
135+ require.Equal(t, 200, anon.Code)
136+ assert.NotContains(t, anon.Body.String(), "/-/inbox")
137+ assert.NotContains(t, anon.Body.String(), "Inbox")
138+
139+ owner := inboxGet(srv, "/", true)
140+ require.Equal(t, 200, owner.Code)
141+ assert.Contains(t, owner.Body.String(), `href="/-/inbox"`)
142+ assert.Contains(t, owner.Body.String(), `<span class="count">5</span>`)
143+}
144+
145+func TestInboxStreamIsTheOwnersAlone(t *testing.T) {
146+ t.Parallel()
147+ f := &watchProbe{release: make(chan struct{})}
148+ svc := mocks.NewInboxService(t)
149+ srv := newTestServer(t, Config{
150+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
151+ Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
152+ })
153+ ts := httptest.NewServer(srv)
154+ t.Cleanup(ts.Close)
155+
156+ unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
157+ want, _ := io.ReadAll(unknown.Body)
158+ for name, hdr := range map[string]map[string]string{
159+ "anonymous": nil,
160+ "another token": {"Cookie": CookieName + "=other"},
161+ } {
162+ resp, _ := open(t, ts, "topic=owner&view=inbox", hdr)
163+ body, _ := io.ReadAll(resp.Body)
164+ assert.Equal(t, unknown.StatusCode, resp.StatusCode, name)
165+ assert.Equal(t, string(want), string(body), name)
166+ assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name)
167+ }
168+ assert.Zero(t, f.opened.Load(), "no watch is opened for them")
169+
170+ resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
171+ assert.Equal(t, http.StatusOK, resp.StatusCode)
172+ assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
173+}
174+
175+func TestInboxLiveFragments(t *testing.T) {
176+ t.Parallel()
177+ svc := mocks.NewInboxService(t)
178+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
179+ srv := inboxServer(t, svc)
180+ ls := &liveStream{s: srv, view: "inbox"}
181+ frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182+ require.NoError(t, err)
183+ require.Len(t, frags, 5)
184+ assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185+ assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186+ assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
189+
190+ none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191+ require.NoError(t, err)
192+ assert.Empty(t, none, "a push is not inbox news")
193+}
194+
195+// withOwner is a context as the session middleware leaves it for the owner.
196+func withOwner(ctx context.Context) context.Context {
197+ return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner())
198+}
199+
200+// A bearer token is not a web session: the inbox answers it as it answers
201+// anyone else, with the unknown-page and unknown-topic responses.
202+func TestInboxRefusesABearerToken(t *testing.T) {
203+ t.Parallel()
204+ f := &watchProbe{release: make(chan struct{})}
205+ svc := mocks.NewInboxService(t) // never asked
206+ srv := newTestServer(t, Config{
207+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
208+ Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
209+ })
210+ bearer := map[string]string{"Authorization": "Bearer tok"}
211+
212+ // The page.
213+ missing := inboxGet(srv, "/-/nothing-here", false)
214+ req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
215+ req.Header.Set("Authorization", "Bearer tok")
216+ rec := httptest.NewRecorder()
217+ srv.ServeHTTP(rec, req)
218+ assert.Equal(t, missing.Code, rec.Code)
219+ assert.Equal(t, http.StatusNotFound, rec.Code)
220+ assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response")
221+
222+ // The stream.
223+ ts := httptest.NewServer(srv)
224+ t.Cleanup(ts.Close)
225+ unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
226+ want, _ := io.ReadAll(unknown.Body)
227+ resp, _ := open(t, ts, "topic=owner&view=inbox", bearer)
228+ body, _ := io.ReadAll(resp.Body)
229+ assert.Equal(t, unknown.StatusCode, resp.StatusCode)
230+ assert.Equal(t, string(want), string(body), "the unknown-topic response")
231+ assert.Zero(t, f.opened.Load(), "no watch is opened")
232+}
233+
234+// The owner's inbox stream ends once the session is invalid: the next event is
235+// not sent.
236+func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) {
237+ t.Parallel()
238+ f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
239+ sess, ended := newRevocable(t, "tok")
240+ svc := mocks.NewInboxService(t)
241+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe()
242+ srv := newTestServer(t, Config{
243+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
244+ Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{},
245+ })
246+ ts := httptest.NewServer(srv)
247+ t.Cleanup(ts.Close)
248+ resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
249+ require.Equal(t, http.StatusOK, resp.StatusCode)
250+ defer time.AfterFunc(5*time.Second, cancel).Stop()
251+ rd := bufio.NewReader(resp.Body)
252+ line := func() string {
253+ s, err := rd.ReadString('\n')
254+ if err != nil {
255+ return "EOF"
256+ }
257+ return strings.TrimRight(s, "\n")
258+ }
259+ require.Equal(t, ": connected", line())
260+ line()
261+ ev := func(id string) ports.WatchEvent {
262+ return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}
263+ }
264+ f.push <- ev("b-1")
265+ var seen []string
266+ for l := line(); !strings.HasPrefix(l, "data:"); l = line() {
267+ seen = append(seen, l)
268+ require.NotEqual(t, "EOF", l)
269+ }
270+ assert.Contains(t, seen, "id: b-1")
271+
272+ ended.Store(true)
273+ select {
274+ case f.push <- ev("b-2"):
275+ case <-time.After(5 * time.Second):
276+ t.Fatal("the event was not delivered to the web")
277+ }
278+ rest, _ := io.ReadAll(rd)
279+ assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended")
280+ require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed")
281+}
282+
283+// A repository event refreshes the inbox: issues turned off or a deletion
284+// remove its items from the fragments.
285+func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286+ t.Parallel()
287+ for name, ev := range map[string]domain.Event{
288+ "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
289+ "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
290+ } {
291+ t.Run(name, func(t *testing.T) {
292+ t.Parallel()
293+ after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}}
294+ svc := mocks.NewInboxService(t)
295+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil)
296+ srv := inboxServer(t, svc)
297+ ls := &liveStream{s: srv, view: "inbox"}
298+ frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299+ require.NoError(t, err)
300+ require.Len(t, frags, 5)
301+ all := ""
302+ for _, f := range frags {
303+ all += f.html
304+ }
305+ assert.Contains(t, all, `id="inbox-issues"`)
306+ assert.NotContains(t, all, "crash on start", "the issue is gone")
307+ assert.NotContains(t, all, "/beta", "the repository is gone from the filter")
308+ })
309+ }
310+}
A internal/adapters/web/issues.go +363 -0
@@ -0,0 +1,363 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// The issue pages (ADR 0031). They follow the merge request pages: they read
21+// through IssueService as the visitor of the request, anonymous sees the
22+// issues of public repositories, and only the signed-in owner gets the forms
23+// (issues_post.go handles them). A repository whose issues mode is off, a
24+// missing one and a private one for an anonymous visitor are the same 404.
25+//
26+// Routes:
27+//
28+// GET /<repo>/issues the list, open or closed (?state=), paged (?page=)
29+// GET /<repo>/issues/new the form that opens one (owner)
30+// GET /<repo>/issues/<n> the issue with its comments
31+//
32+// Issues and merge requests share their numbers, so "#n" may name either: with
33+// an IssueNumberResolver the page of the wrong kind redirects to the right one.
34+
35+// issuesBase is the URL of the issue pages of a repository.
36+func issuesBase(repo string) string { return repoHref(repo, "issues") }
37+
38+func issueHref(repo string, n int64, sub ...string) string {
39+ return repoHref(repo, append([]string{"issues", strconv.FormatInt(n, 10)}, sub...)...)
40+}
41+
42+// IssueRow is one issue in the list.
43+type IssueRow struct {
44+ Number string
45+ Href string
46+ Title string
47+ Author string
48+ State string
49+ When string
50+ WhenAt string
51+ Cursor bool
52+}
53+
54+// IssueListPage is the list of the issues of a repository.
55+type IssueListPage struct {
56+ Rows []IssueRow
57+ // Closed is true on the tab of the closed issues.
58+ Closed bool
59+ OpenHref, ClosedHref string
60+ Older, Newest string
61+ NewHref string // the form that opens an issue; "" unless the owner
62+ Live bool
63+}
64+
65+// IssueActions are the buttons of the owner on an issue.
66+type IssueActions struct {
67+ Action string // the URL that closes or reopens
68+ Label string
69+}
70+
71+// IssuePage is an issue with its comments.
72+type IssuePage struct {
73+ Number string
74+ Title string
75+ State string
76+ Open bool
77+ Opened Person
78+ Updated string
79+ UpdatedAt string
80+ // Desc is the body rendered as Markdown, "" when it is empty.
81+ Desc template.HTML
82+ Comments []CommentView
83+ Form *CommentForm
84+ Transition *IssueActions
85+ Live bool
86+}
87+
88+// IssueNewPage is the form that opens an issue.
89+type IssueNewPage struct {
90+ Action, Cancel string
91+}
92+
93+// issueRepo reads the repository of an issue page: one with issues off is
94+// as missing as one the caller cannot see.
95+func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, error) {
96+ d, err := s.loadRepo(ctx, rawName)
97+ if err != nil {
98+ return nil, err
99+ }
100+ if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff {
101+ return nil, errNotFound
102+ }
103+ return d, nil
104+}
105+
106+// externalAuthor is the label a visitor gave, as plain text, or "anonymous".
107+func (s *Server) externalAuthor(label string) string {
108+ if a := strings.TrimSpace(visibleText(label)); a != "" {
109+ return a
110+ }
111+ return "anonymous"
112+}
113+
114+func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
115+ row := IssueRow{
116+ Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number),
117+ Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author),
118+ }
119+ if i.External {
120+ row.Author = s.externalAuthor(i.Author)
121+ }
122+ ts := i.UpdatedAt
123+ if ts.IsZero() {
124+ ts = i.CreatedAt
125+ }
126+ row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
127+ return row
128+}
129+
130+// issueList lists the issues of one state, a page at a time.
131+//
132+//hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode
133+func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
135+ if err != nil {
136+ return nil, 0, err
137+ }
138+ state := domain.IssueStateOpen
139+ switch r.URL.Query().Get("state") {
140+ case "", "open":
141+ case "closed":
142+ state = domain.IssueStateClosed
143+ default:
144+ return nil, 0, errNotFound
145+ }
146+ token := r.URL.Query().Get("page")
147+ base := issuesBase(d.name())
148+ closed := state == domain.IssueStateClosed
149+ link := func(page string) string {
150+ q := url.Values{}
151+ if closed {
152+ q.Set("state", "closed")
153+ }
154+ if page != "" {
155+ q.Set("page", page)
156+ }
157+ if len(q) == 0 {
158+ return base
159+ }
160+ return base + "?" + q.Encode()
161+ }
162+
163+ res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{
164+ State: state, PageSize: pageSize, PageToken: token,
165+ })
166+ if err != nil {
167+ return nil, 0, err
168+ }
169+ p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
170+ for _, i := range res.Issues {
171+ p.Rows = append(p.Rows, s.issueRow(d.name(), i))
172+ }
173+ if len(p.Rows) > 0 {
174+ p.Rows[0].Cursor = true
175+ }
176+ if res.NextPageToken != "" {
177+ p.Older = link(res.NextPageToken)
178+ }
179+ if token != "" {
180+ p.Newest = link("")
181+ }
182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {
183+ p.NewHref = issuesBase(d.name()) + "/new"
184+ }
185+
186+ l := s.repoLayout(ctx, d, "issues")
187+ l.Title = "issues · " + d.name() + " · " + s.instance
188+ l.Crumbs[0].Current = false
189+ l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true})
190+ if token == "" {
191+ p.Live = true
192+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues")
193+ }
194+ l.HasRail = true
195+ return IssueList(l, p), http.StatusOK, nil
196+}
197+
198+// issueNew is the form that opens an issue; it is the owner's.
199+func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) {
200+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
201+ if err != nil {
202+ return nil, 0, err
203+ }
204+ if !SignedIn(ctx) {
205+ return nil, 0, errNotFound
206+ }
207+ l := s.repoLayout(ctx, d, "issues")
208+ l.Title = "new issue · " + d.name() + " · " + s.instance
209+ l.Crumbs[0].Current = false
210+ l.Crumbs = append(l.Crumbs,
211+ Crumb{Label: "issues", Href: issuesBase(d.name())},
212+ Crumb{Label: "new", Current: true},
213+ )
214+ l.HasRail = true
215+ return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil
216+}
217+
218+// issueShow shows an issue with its comments.
219+//
220+//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
221+func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222+ n, err := mrNumber(r)
223+ if err != nil {
224+ return nil, 0, err
225+ }
226+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
227+ if err != nil {
228+ return nil, 0, err
229+ }
230+ res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
231+ if err != nil {
232+ return nil, 0, err
233+ }
234+ i := res.Issue
235+ now := s.cfg.Now()
236+ signed := SignedIn(ctx)
237+ self := issueHref(d.name(), n)
238+
239+ p := IssuePage{
240+ Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
241+ State: string(i.State), Open: i.IsOpen(),
242+ }
243+ if body := i.Body.String(); strings.TrimSpace(body) != "" {
244+ var h template.HTML
245+ var err error
246+ if i.External {
247+ // Written by an anonymous visitor: the untrusted policy.
248+ h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
249+ } else {
250+ h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
251+ }
252+ if err != nil {
253+ return nil, 0, err
254+ }
255+ p.Desc = h
256+ }
257+ p.Opened.Name = s.who(i.Author)
258+ if i.External {
259+ // The label is the visitor's, not the owner's: shown as plain text.
260+ p.Opened.Name = s.externalAuthor(i.Author)
261+ }
262+ p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
263+ if !i.CreatedAt.IsZero() {
264+ p.Opened.Abs = absolute(i.CreatedAt)
265+ }
266+ p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
267+ for _, c := range res.Comments {
268+ v := CommentView{
269+ ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
270+ }
271+ v.When, v.WhenAt = whenOf(now, c.CreatedAt)
272+ if !c.CreatedAt.IsZero() {
273+ v.Abs = absolute(c.CreatedAt)
274+ }
275+ p.Comments = append(p.Comments, v)
276+ }
277+ if signed {
278+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
279+ switch i.State {
280+ case domain.IssueStateOpen:
281+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
282+ case domain.IssueStateClosed:
283+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
284+ }
285+ }
286+
287+ l := s.repoLayout(ctx, d, "issues")
288+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
289+ l.Crumbs[0].Current = false
290+ l.Crumbs = append(l.Crumbs,
291+ Crumb{Label: "issues", Href: issuesBase(d.name())},
292+ Crumb{Label: p.Number, Href: self, Current: true},
293+ )
294+ p.Live = true
295+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
296+ l.HasRail = true
297+ return IssueShow(l, p), http.StatusOK, nil
298+}
299+
300+// pageOrRedirect is page(), except that a page that is not found may be the
301+// page of the other kind of the number: target then names where to send the
302+// visitor ("" when there is none).
303+func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
304+ plain := s.page(h)
305+ if s.cfg.IssueNumbers == nil {
306+ return plain
307+ }
308+ return func(w http.ResponseWriter, r *http.Request) {
309+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
310+ defer cancel()
311+ c, status, err := h(ctx, r)
312+ if errors.Is(err, domain.ErrNotFound) {
313+ to, terr := target(ctx, r)
314+ switch {
315+ case terr != nil && !errors.Is(terr, domain.ErrNotFound):
316+ s.fail(ctx, w, r, terr)
317+ return
318+ case terr == nil && to != "":
319+ seeOther(w, r, to)
320+ return
321+ }
322+ }
323+ if err != nil {
324+ s.fail(ctx, w, r, err)
325+ return
326+ }
327+ s.write(ctx, w, r, status, c)
328+ }
329+}
330+
331+// numberKind asks what the number of the path names.
332+func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
333+ n, err := mrNumber(r)
334+ if err != nil {
335+ return domain.NumberNone, 0, err
336+ }
337+ name, err := domain.ParseRepoName(r.PathValue("repo"))
338+ if err != nil {
339+ return domain.NumberNone, 0, err
340+ }
341+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
342+ return kind, n, err
343+}
344+
345+// issueToMR is where the number of an issue URL goes when it names a merge
346+// request.
347+func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
348+ kind, n, err := s.numberKind(ctx, r)
349+ if err != nil || kind != domain.NumberMergeRequest {
350+ return "", err
351+ }
352+ return mrHref(r.PathValue("repo"), n), nil
353+}
354+
355+// mrToIssue is where the number of a merge request URL goes when it names an
356+// issue.
357+func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
358+ kind, n, err := s.numberKind(ctx, r)
359+ if err != nil || kind != domain.NumberIssue {
360+ return "", err
361+ }
362+ return issueHref(r.PathValue("repo"), n), nil
363+}
@@ -0,0 +1,363 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// The issue pages (ADR 0031). They follow the merge request pages: they read
21+// through IssueService as the visitor of the request, anonymous sees the
22+// issues of public repositories, and only the signed-in owner gets the forms
23+// (issues_post.go handles them). A repository whose issues mode is off, a
24+// missing one and a private one for an anonymous visitor are the same 404.
25+//
26+// Routes:
27+//
28+// GET /<repo>/issues the list, open or closed (?state=), paged (?page=)
29+// GET /<repo>/issues/new the form that opens one (owner)
30+// GET /<repo>/issues/<n> the issue with its comments
31+//
32+// Issues and merge requests share their numbers, so "#n" may name either: with
33+// an IssueNumberResolver the page of the wrong kind redirects to the right one.
34+
35+// issuesBase is the URL of the issue pages of a repository.
36+func issuesBase(repo string) string { return repoHref(repo, "issues") }
37+
38+func issueHref(repo string, n int64, sub ...string) string {
39+ return repoHref(repo, append([]string{"issues", strconv.FormatInt(n, 10)}, sub...)...)
40+}
41+
42+// IssueRow is one issue in the list.
43+type IssueRow struct {
44+ Number string
45+ Href string
46+ Title string
47+ Author string
48+ State string
49+ When string
50+ WhenAt string
51+ Cursor bool
52+}
53+
54+// IssueListPage is the list of the issues of a repository.
55+type IssueListPage struct {
56+ Rows []IssueRow
57+ // Closed is true on the tab of the closed issues.
58+ Closed bool
59+ OpenHref, ClosedHref string
60+ Older, Newest string
61+ NewHref string // the form that opens an issue; "" unless the owner
62+ Live bool
63+}
64+
65+// IssueActions are the buttons of the owner on an issue.
66+type IssueActions struct {
67+ Action string // the URL that closes or reopens
68+ Label string
69+}
70+
71+// IssuePage is an issue with its comments.
72+type IssuePage struct {
73+ Number string
74+ Title string
75+ State string
76+ Open bool
77+ Opened Person
78+ Updated string
79+ UpdatedAt string
80+ // Desc is the body rendered as Markdown, "" when it is empty.
81+ Desc template.HTML
82+ Comments []CommentView
83+ Form *CommentForm
84+ Transition *IssueActions
85+ Live bool
86+}
87+
88+// IssueNewPage is the form that opens an issue.
89+type IssueNewPage struct {
90+ Action, Cancel string
91+}
92+
93+// issueRepo reads the repository of an issue page: one with issues off is
94+// as missing as one the caller cannot see.
95+func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, error) {
96+ d, err := s.loadRepo(ctx, rawName)
97+ if err != nil {
98+ return nil, err
99+ }
100+ if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff {
101+ return nil, errNotFound
102+ }
103+ return d, nil
104+}
105+
106+// externalAuthor is the label a visitor gave, as plain text, or "anonymous".
107+func (s *Server) externalAuthor(label string) string {
108+ if a := strings.TrimSpace(visibleText(label)); a != "" {
109+ return a
110+ }
111+ return "anonymous"
112+}
113+
114+func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
115+ row := IssueRow{
116+ Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number),
117+ Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author),
118+ }
119+ if i.External {
120+ row.Author = s.externalAuthor(i.Author)
121+ }
122+ ts := i.UpdatedAt
123+ if ts.IsZero() {
124+ ts = i.CreatedAt
125+ }
126+ row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
127+ return row
128+}
129+
130+// issueList lists the issues of one state, a page at a time.
131+//
132+//hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode
133+func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
135+ if err != nil {
136+ return nil, 0, err
137+ }
138+ state := domain.IssueStateOpen
139+ switch r.URL.Query().Get("state") {
140+ case "", "open":
141+ case "closed":
142+ state = domain.IssueStateClosed
143+ default:
144+ return nil, 0, errNotFound
145+ }
146+ token := r.URL.Query().Get("page")
147+ base := issuesBase(d.name())
148+ closed := state == domain.IssueStateClosed
149+ link := func(page string) string {
150+ q := url.Values{}
151+ if closed {
152+ q.Set("state", "closed")
153+ }
154+ if page != "" {
155+ q.Set("page", page)
156+ }
157+ if len(q) == 0 {
158+ return base
159+ }
160+ return base + "?" + q.Encode()
161+ }
162+
163+ res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{
164+ State: state, PageSize: pageSize, PageToken: token,
165+ })
166+ if err != nil {
167+ return nil, 0, err
168+ }
169+ p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
170+ for _, i := range res.Issues {
171+ p.Rows = append(p.Rows, s.issueRow(d.name(), i))
172+ }
173+ if len(p.Rows) > 0 {
174+ p.Rows[0].Cursor = true
175+ }
176+ if res.NextPageToken != "" {
177+ p.Older = link(res.NextPageToken)
178+ }
179+ if token != "" {
180+ p.Newest = link("")
181+ }
182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {
183+ p.NewHref = issuesBase(d.name()) + "/new"
184+ }
185+
186+ l := s.repoLayout(ctx, d, "issues")
187+ l.Title = "issues · " + d.name() + " · " + s.instance
188+ l.Crumbs[0].Current = false
189+ l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true})
190+ if token == "" {
191+ p.Live = true
192+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues")
193+ }
194+ l.HasRail = true
195+ return IssueList(l, p), http.StatusOK, nil
196+}
197+
198+// issueNew is the form that opens an issue; it is the owner's.
199+func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) {
200+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
201+ if err != nil {
202+ return nil, 0, err
203+ }
204+ if !SignedIn(ctx) {
205+ return nil, 0, errNotFound
206+ }
207+ l := s.repoLayout(ctx, d, "issues")
208+ l.Title = "new issue · " + d.name() + " · " + s.instance
209+ l.Crumbs[0].Current = false
210+ l.Crumbs = append(l.Crumbs,
211+ Crumb{Label: "issues", Href: issuesBase(d.name())},
212+ Crumb{Label: "new", Current: true},
213+ )
214+ l.HasRail = true
215+ return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil
216+}
217+
218+// issueShow shows an issue with its comments.
219+//
220+//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
221+func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222+ n, err := mrNumber(r)
223+ if err != nil {
224+ return nil, 0, err
225+ }
226+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
227+ if err != nil {
228+ return nil, 0, err
229+ }
230+ res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
231+ if err != nil {
232+ return nil, 0, err
233+ }
234+ i := res.Issue
235+ now := s.cfg.Now()
236+ signed := SignedIn(ctx)
237+ self := issueHref(d.name(), n)
238+
239+ p := IssuePage{
240+ Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
241+ State: string(i.State), Open: i.IsOpen(),
242+ }
243+ if body := i.Body.String(); strings.TrimSpace(body) != "" {
244+ var h template.HTML
245+ var err error
246+ if i.External {
247+ // Written by an anonymous visitor: the untrusted policy.
248+ h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
249+ } else {
250+ h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
251+ }
252+ if err != nil {
253+ return nil, 0, err
254+ }
255+ p.Desc = h
256+ }
257+ p.Opened.Name = s.who(i.Author)
258+ if i.External {
259+ // The label is the visitor's, not the owner's: shown as plain text.
260+ p.Opened.Name = s.externalAuthor(i.Author)
261+ }
262+ p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
263+ if !i.CreatedAt.IsZero() {
264+ p.Opened.Abs = absolute(i.CreatedAt)
265+ }
266+ p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
267+ for _, c := range res.Comments {
268+ v := CommentView{
269+ ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
270+ }
271+ v.When, v.WhenAt = whenOf(now, c.CreatedAt)
272+ if !c.CreatedAt.IsZero() {
273+ v.Abs = absolute(c.CreatedAt)
274+ }
275+ p.Comments = append(p.Comments, v)
276+ }
277+ if signed {
278+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
279+ switch i.State {
280+ case domain.IssueStateOpen:
281+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
282+ case domain.IssueStateClosed:
283+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
284+ }
285+ }
286+
287+ l := s.repoLayout(ctx, d, "issues")
288+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
289+ l.Crumbs[0].Current = false
290+ l.Crumbs = append(l.Crumbs,
291+ Crumb{Label: "issues", Href: issuesBase(d.name())},
292+ Crumb{Label: p.Number, Href: self, Current: true},
293+ )
294+ p.Live = true
295+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
296+ l.HasRail = true
297+ return IssueShow(l, p), http.StatusOK, nil
298+}
299+
300+// pageOrRedirect is page(), except that a page that is not found may be the
301+// page of the other kind of the number: target then names where to send the
302+// visitor ("" when there is none).
303+func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
304+ plain := s.page(h)
305+ if s.cfg.IssueNumbers == nil {
306+ return plain
307+ }
308+ return func(w http.ResponseWriter, r *http.Request) {
309+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
310+ defer cancel()
311+ c, status, err := h(ctx, r)
312+ if errors.Is(err, domain.ErrNotFound) {
313+ to, terr := target(ctx, r)
314+ switch {
315+ case terr != nil && !errors.Is(terr, domain.ErrNotFound):
316+ s.fail(ctx, w, r, terr)
317+ return
318+ case terr == nil && to != "":
319+ seeOther(w, r, to)
320+ return
321+ }
322+ }
323+ if err != nil {
324+ s.fail(ctx, w, r, err)
325+ return
326+ }
327+ s.write(ctx, w, r, status, c)
328+ }
329+}
330+
331+// numberKind asks what the number of the path names.
332+func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
333+ n, err := mrNumber(r)
334+ if err != nil {
335+ return domain.NumberNone, 0, err
336+ }
337+ name, err := domain.ParseRepoName(r.PathValue("repo"))
338+ if err != nil {
339+ return domain.NumberNone, 0, err
340+ }
341+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
342+ return kind, n, err
343+}
344+
345+// issueToMR is where the number of an issue URL goes when it names a merge
346+// request.
347+func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
348+ kind, n, err := s.numberKind(ctx, r)
349+ if err != nil || kind != domain.NumberMergeRequest {
350+ return "", err
351+ }
352+ return mrHref(r.PathValue("repo"), n), nil
353+}
354+
355+// mrToIssue is where the number of a merge request URL goes when it names an
356+// issue.
357+func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
358+ kind, n, err := s.numberKind(ctx, r)
359+ if err != nil || kind != domain.NumberIssue {
360+ return "", err
361+ }
362+ return issueHref(r.PathValue("repo"), n), nil
363+}
A internal/adapters/web/issues.templ +187 -0
@@ -0,0 +1,187 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueList is the list of the issues of a repository, open or closed.
6+templ IssueList(l Layout, p IssueListPage) {
7+ @Shell(l, issueListRail(p)) {
8+ if p.Live {
9+ @LiveSlot("mr-notice")
10+ }
11+ <nav class="since" aria-label="State">
12+ <a
13+ href={ templ.SafeURL(p.OpenHref) }
14+ if !p.Closed {
15+ aria-current="page"
16+ }
17+ >open</a>
18+ <a
19+ href={ templ.SafeURL(p.ClosedHref) }
20+ if p.Closed {
21+ aria-current="page"
22+ }
23+ >closed</a>
24+ </nav>
25+ <div class="filter">
26+ <label for="issue-filter" class="sr">filter</label>
27+ <input
28+ id="issue-filter"
29+ type="search"
30+ data-filter=".mrs tbody tr"
31+ placeholder="Filter by title, author or state…"
32+ autocomplete="off"
33+ hx-on:input="ermite.filter(this)"
34+ />
35+ </div>
36+ <h2 class="rule-h">
37+ if p.Closed {
38+ <span>Closed</span>
39+ } else {
40+ <span>Open</span>
41+ }
42+ <span class="count">{ itoa(len(p.Rows)) }</span>
43+ </h2>
44+ @issueRows(p.Rows)
45+ if len(p.Rows) == 0 {
46+ if p.Closed {
47+ <p class="lede dim">no closed issues.</p>
48+ } else {
49+ <p class="lede dim">no open issues.</p>
50+ }
51+ }
52+ if p.Older != "" || p.Newest != "" {
53+ <nav class="pager" aria-label="Pages">
54+ if p.Newest != "" {
55+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
56+ } else {
57+ <span class="btn" aria-disabled="true">← Newest</span>
58+ }
59+ if p.Older != "" {
60+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
61+ } else {
62+ <span class="btn" aria-disabled="true">Older →</span>
63+ }
64+ </nav>
65+ }
66+ }
67+}
68+
69+templ issueRows(rows []IssueRow) {
70+ if len(rows) > 0 {
71+ <table class="list mrs" aria-label="Issues">
72+ <tbody data-rows>
73+ for _, i := range rows {
74+ <tr data-cursor?={ i.Cursor } data-state={ i.State }>
75+ <td class="c-n mute">{ i.Number }</td>
76+ <td class="c-state">{ i.State }</td>
77+ <td class="c-subject"><a href={ templ.SafeURL(i.Href) }>{ i.Title }</a></td>
78+ <td class="c-who dim">{ i.Author }</td>
79+ <td class="c-when nowrap dim">
80+ if i.WhenAt != "" {
81+ <time datetime={ i.WhenAt } title={ i.WhenAt }>{ i.When }</time>
82+ }
83+ </td>
84+ </tr>
85+ }
86+ </tbody>
87+ </table>
88+ }
89+}
90+
91+templ issueListRail(p IssueListPage) {
92+ <section aria-labelledby="r-states">
93+ <h2 id="r-states">States</h2>
94+ <dl class="kv">
95+ <dt>open</dt>
96+ <dd>being worked on</dd>
97+ <dt>closed</dt>
98+ <dd>done with, can be reopened</dd>
99+ </dl>
100+ </section>
101+ if p.NewHref != "" {
102+ <section aria-labelledby="r-new">
103+ <h2 id="r-new">Open one</h2>
104+ <a class="btn primary" href={ templ.SafeURL(p.NewHref) }>New issue</a>
105+ </section>
106+ }
107+}
108+
109+// IssueNew is the form that opens an issue.
110+templ IssueNew(l Layout, p IssueNewPage) {
111+ @Shell(l, issueNewRail()) {
112+ <header class="show mr-h">
113+ <h1>New issue</h1>
114+ </header>
115+ <form class="cform create" method="post" action={ templ.SafeURL(p.Action) }>
116+ <label for="issue-title">title</label>
117+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" autofocus/>
118+ <label for="issue-body">description</label>
119+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional"></textarea>
120+ <div class="f-btns">
121+ <button type="submit" class="btn primary">Create issue</button>
122+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
123+ </div>
124+ </form>
125+ }
126+}
127+
128+templ issueNewRail() {
129+ <section aria-labelledby="r-about">
130+ <h2 id="r-about">Issues</h2>
131+ <p class="dim">issues share their numbers with the merge requests of the repository.</p>
132+ </section>
133+}
134+
135+// IssueShow is an issue: its facts, its body, its comments and, for the
136+// owner, the form to comment and the button that closes or reopens it.
137+templ IssueShow(l Layout, p IssuePage) {
138+ @Shell(l, issueRail(p)) {
139+ <header class="show mr-h">
140+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
141+ <dl class="kv show-kv">
142+ <dt>state</dt>
143+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
144+ <dt>opened</dt>
145+ <dd>
146+ { p.Opened.Name }
147+ if p.Opened.WhenAt != "" {
148+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
149+ }
150+ </dd>
151+ if p.UpdatedAt != "" {
152+ <dt>updated</dt>
153+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
154+ }
155+ </dl>
156+ </header>
157+ if p.Live {
158+ @LiveSlot("mr-notice")
159+ }
160+ if p.Desc != "" {
161+ <article class="mr-desc prose" aria-label="description" hx-ignore>
162+ @templ.Raw(string(p.Desc))
163+ </article>
164+ }
165+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166+ for _, c := range p.Comments {
167+ @commentView(c, "")
168+ }
169+ if len(p.Comments) == 0 {
170+ <p class="dim">no comments yet.</p>
171+ }
172+ if p.Form != nil {
173+ @commentForm(*p.Form, "")
174+ }
175+ }
176+}
177+
178+templ issueRail(p IssuePage) {
179+ if p.Transition != nil {
180+ <section aria-labelledby="r-act">
181+ <h2 id="r-act">Issue</h2>
182+ <form method="post" action={ templ.SafeURL(p.Transition.Action) }>
183+ <button type="submit" class="btn">{ p.Transition.Label }</button>
184+ </form>
185+ </section>
186+ }
187+}
@@ -0,0 +1,187 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueList is the list of the issues of a repository, open or closed.
6+templ IssueList(l Layout, p IssueListPage) {
7+ @Shell(l, issueListRail(p)) {
8+ if p.Live {
9+ @LiveSlot("mr-notice")
10+ }
11+ <nav class="since" aria-label="State">
12+ <a
13+ href={ templ.SafeURL(p.OpenHref) }
14+ if !p.Closed {
15+ aria-current="page"
16+ }
17+ >open</a>
18+ <a
19+ href={ templ.SafeURL(p.ClosedHref) }
20+ if p.Closed {
21+ aria-current="page"
22+ }
23+ >closed</a>
24+ </nav>
25+ <div class="filter">
26+ <label for="issue-filter" class="sr">filter</label>
27+ <input
28+ id="issue-filter"
29+ type="search"
30+ data-filter=".mrs tbody tr"
31+ placeholder="Filter by title, author or state…"
32+ autocomplete="off"
33+ hx-on:input="ermite.filter(this)"
34+ />
35+ </div>
36+ <h2 class="rule-h">
37+ if p.Closed {
38+ <span>Closed</span>
39+ } else {
40+ <span>Open</span>
41+ }
42+ <span class="count">{ itoa(len(p.Rows)) }</span>
43+ </h2>
44+ @issueRows(p.Rows)
45+ if len(p.Rows) == 0 {
46+ if p.Closed {
47+ <p class="lede dim">no closed issues.</p>
48+ } else {
49+ <p class="lede dim">no open issues.</p>
50+ }
51+ }
52+ if p.Older != "" || p.Newest != "" {
53+ <nav class="pager" aria-label="Pages">
54+ if p.Newest != "" {
55+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
56+ } else {
57+ <span class="btn" aria-disabled="true">← Newest</span>
58+ }
59+ if p.Older != "" {
60+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
61+ } else {
62+ <span class="btn" aria-disabled="true">Older →</span>
63+ }
64+ </nav>
65+ }
66+ }
67+}
68+
69+templ issueRows(rows []IssueRow) {
70+ if len(rows) > 0 {
71+ <table class="list mrs" aria-label="Issues">
72+ <tbody data-rows>
73+ for _, i := range rows {
74+ <tr data-cursor?={ i.Cursor } data-state={ i.State }>
75+ <td class="c-n mute">{ i.Number }</td>
76+ <td class="c-state">{ i.State }</td>
77+ <td class="c-subject"><a href={ templ.SafeURL(i.Href) }>{ i.Title }</a></td>
78+ <td class="c-who dim">{ i.Author }</td>
79+ <td class="c-when nowrap dim">
80+ if i.WhenAt != "" {
81+ <time datetime={ i.WhenAt } title={ i.WhenAt }>{ i.When }</time>
82+ }
83+ </td>
84+ </tr>
85+ }
86+ </tbody>
87+ </table>
88+ }
89+}
90+
91+templ issueListRail(p IssueListPage) {
92+ <section aria-labelledby="r-states">
93+ <h2 id="r-states">States</h2>
94+ <dl class="kv">
95+ <dt>open</dt>
96+ <dd>being worked on</dd>
97+ <dt>closed</dt>
98+ <dd>done with, can be reopened</dd>
99+ </dl>
100+ </section>
101+ if p.NewHref != "" {
102+ <section aria-labelledby="r-new">
103+ <h2 id="r-new">Open one</h2>
104+ <a class="btn primary" href={ templ.SafeURL(p.NewHref) }>New issue</a>
105+ </section>
106+ }
107+}
108+
109+// IssueNew is the form that opens an issue.
110+templ IssueNew(l Layout, p IssueNewPage) {
111+ @Shell(l, issueNewRail()) {
112+ <header class="show mr-h">
113+ <h1>New issue</h1>
114+ </header>
115+ <form class="cform create" method="post" action={ templ.SafeURL(p.Action) }>
116+ <label for="issue-title">title</label>
117+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" autofocus/>
118+ <label for="issue-body">description</label>
119+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional"></textarea>
120+ <div class="f-btns">
121+ <button type="submit" class="btn primary">Create issue</button>
122+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
123+ </div>
124+ </form>
125+ }
126+}
127+
128+templ issueNewRail() {
129+ <section aria-labelledby="r-about">
130+ <h2 id="r-about">Issues</h2>
131+ <p class="dim">issues share their numbers with the merge requests of the repository.</p>
132+ </section>
133+}
134+
135+// IssueShow is an issue: its facts, its body, its comments and, for the
136+// owner, the form to comment and the button that closes or reopens it.
137+templ IssueShow(l Layout, p IssuePage) {
138+ @Shell(l, issueRail(p)) {
139+ <header class="show mr-h">
140+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
141+ <dl class="kv show-kv">
142+ <dt>state</dt>
143+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
144+ <dt>opened</dt>
145+ <dd>
146+ { p.Opened.Name }
147+ if p.Opened.WhenAt != "" {
148+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
149+ }
150+ </dd>
151+ if p.UpdatedAt != "" {
152+ <dt>updated</dt>
153+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
154+ }
155+ </dl>
156+ </header>
157+ if p.Live {
158+ @LiveSlot("mr-notice")
159+ }
160+ if p.Desc != "" {
161+ <article class="mr-desc prose" aria-label="description" hx-ignore>
162+ @templ.Raw(string(p.Desc))
163+ </article>
164+ }
165+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166+ for _, c := range p.Comments {
167+ @commentView(c, "")
168+ }
169+ if len(p.Comments) == 0 {
170+ <p class="dim">no comments yet.</p>
171+ }
172+ if p.Form != nil {
173+ @commentForm(*p.Form, "")
174+ }
175+ }
176+}
177+
178+templ issueRail(p IssuePage) {
179+ if p.Transition != nil {
180+ <section aria-labelledby="r-act">
181+ <h2 id="r-act">Issue</h2>
182+ <form method="post" action={ templ.SafeURL(p.Transition.Action) }>
183+ <button type="submit" class="btn">{ p.Transition.Label }</button>
184+ </form>
185+ </section>
186+ }
187+}
A internal/adapters/web/issues_form.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/clientlimit"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+)
20+
21+// The public issue form (ADR 0031). A visitor of a public repository whose
22+// issues mode is public writes an issue that awaits the owner's moderation.
23+//
24+// GET /<repo>/issues/new the form, with a proof of work solved by script
25+// POST /<repo>/issues/new the submission (the one write a visitor may make)
26+// GET /<repo>/issues/submitted the thanks page
27+//
28+// Every answer is "Cache-Control: no-store". The challenge token is never
29+// echoed back, and neither the address of the client nor the text of the
30+// submission is logged. A refusal for a bot-related reason is one generic page.
31+
32+// maxIssueFormBytes bounds the body of the submission: 10,000 characters are
33+// at most 40,000 bytes, plus the title, the name and the tokens.
34+const maxIssueFormBytes = 64 << 10
35+
36+// The fields of the form, and so the only ones read.
37+const (
38+ fieldTitle = "title"
39+ fieldBody = "body"
40+ fieldAuthor = "author"
41+ fieldToken = "token"
42+ fieldFormToken = "form_token"
43+ fieldSolution = "solution"
44+ fieldHoneypot = "website"
45+)
46+
47+var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot}
48+
49+// IssueFormPage is the public form.
50+type IssueFormPage struct {
51+ Action, Cancel string
52+ Token string
53+ FormToken string
54+ Nonce string
55+ Difficulty int
56+ MinSubmitAt int64 // unix milliseconds
57+ Worker string
58+ // Title, Body and Author are what the visitor already wrote.
59+ Title, Body, Author string
60+ // ErrorField is "title", "body" or "author" and Error the message to show
61+ // next to it; both empty without an error.
62+ ErrorField, Error string
63+ // Max lengths, in characters.
64+ MaxTitle, MaxBody, MaxAuthor int
65+}
66+
67+// issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in,
68+// else the public form, else the page that is not there.
69+func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) {
70+ if SignedIn(r.Context()) || s.cfg.IssueForm == nil {
71+ s.page(s.issueNew)(w, r)
72+ return
73+ }
74+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
75+ defer cancel()
76+ name, err := domain.ParseRepoName(r.PathValue("repo"))
77+ if err != nil {
78+ s.fail(ctx, w, r, err)
79+ return
80+ }
81+ page := IssueFormPage{}
82+ status, err := s.fillIssueForm(ctx, r, name, &page)
83+ if err != nil {
84+ s.formFailed(ctx, w, r, err, page.Cancel)
85+ return
86+ }
87+ s.writeIssueForm(ctx, w, r, status, name, page)
88+}
89+
90+// fillIssueForm asks a challenge for the client and fills what the form needs
91+// from it, leaving the text of the visitor as it is.
92+func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) {
93+ base := issuesBase(name.String())
94+ p.Cancel = base
95+ ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r))
96+ if err != nil {
97+ return 0, err
98+ }
99+ p.Action = base + "/new"
100+ p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty
101+ p.MinSubmitAt = ch.MinSubmitAt.UnixMilli()
102+ p.Worker = s.assets.urls.PowWorker
103+ p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen
104+ return http.StatusOK, nil
105+}
106+
107+// formClient is the key of the client for the limits of the submissions.
108+func (s *Server) formClient(r *http.Request) string {
109+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
110+}
111+
112+// writeIssueForm renders the form in the layout of the issues of the repository.
113+func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) {
114+ d, err := s.issueRepo(ctx, name.String())
115+ if err != nil {
116+ s.fail(ctx, w, r, err)
117+ return
118+ }
119+ l := s.repoLayout(ctx, d, "issues")
120+ l.Title = "new issue · " + d.name() + " · " + s.instance
121+ l.Crumbs[0].Current = false
122+ l.Crumbs = append(l.Crumbs,
123+ Crumb{Label: "issues", Href: issuesBase(d.name())},
124+ Crumb{Label: "new", Current: true},
125+ )
126+ l.HasRail = true
127+ l.IssueForm = true
128+ w.Header().Set("Cache-Control", noStore)
129+ s.write(ctx, w, r, status, IssueForm(l, p))
130+}
131+
132+// formFailed answers an error of the challenge or of the submission.
133+func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
134+ var retry *domain.SubmissionRetryError
135+ switch {
136+ case errors.Is(err, context.Canceled):
137+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText):
138+ // The same page, with the same headers, as every other page that is
139+ // not there (a POST has set no-store already).
140+ s.fail(ctx, w, r, domain.ErrNotFound)
141+ case errors.As(err, &retry):
142+ secs := int(retry.After.Round(time.Second).Seconds())
143+ if retry.After > time.Duration(secs)*time.Second {
144+ secs++
145+ }
146+ secs = max(secs, 1)
147+ w.Header().Set("Retry-After", strconv.Itoa(secs))
148+ mins := (secs + 59) / 60
149+ unit := " minutes."
150+ if mins == 1 {
151+ unit = " minute."
152+ }
153+ s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions",
154+ "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back)
155+ case errors.Is(err, domain.ErrSubmissionsPaused):
156+ s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back)
157+ case errors.Is(err, domain.ErrSubmissionRefused):
158+ s.refusal(ctx, w, r, http.StatusBadRequest, "not verified",
159+ "The submission could not be verified. Reload the page and try again.", back)
160+ default:
161+ s.internalFailure(ctx, w, r, err)
162+ }
163+}
164+
165+// refusal writes a state page for a visitor, never cached.
166+func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
167+ if back == "" {
168+ back = "/"
169+ }
170+ l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
171+ w.Header().Set("Cache-Control", noStore)
172+ s.write(ctx, w, r, status, State(l, StatePage{
173+ Code: itoa(status), Title: title, Lines: []string{line},
174+ Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
175+ }))
176+}
177+
178+// issueFormPost is POST /<repo>/issues/new.
179+func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) {
180+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
181+ defer cancel()
182+ w.Header().Set("Cache-Control", noStore)
183+ name, err := domain.ParseRepoName(r.PathValue("repo"))
184+ if err != nil {
185+ s.formFailed(ctx, w, r, err, "")
186+ return
187+ }
188+ base := issuesBase(name.String())
189+ r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes)
190+ if err := r.ParseForm(); err != nil {
191+ var tooBig *http.MaxBytesError
192+ if errors.As(err, &tooBig) {
193+ s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new")
194+ return
195+ }
196+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
197+ return
198+ }
199+ for _, f := range issueFormFields {
200+ if len(r.PostForm[f]) > 1 {
201+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
202+ return
203+ }
204+ }
205+ in := ports.IssueSubmission{
206+ Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution),
207+ FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot),
208+ Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)),
209+ Author: r.PostForm.Get(fieldAuthor),
210+ }
211+ err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in)
212+ if err == nil {
213+ seeOther(w, r, base+"/submitted")
214+ return
215+ }
216+ var te *domain.TextError
217+ if errors.As(err, &te) {
218+ s.formAgain(ctx, w, r, name, in, err, te)
219+ return
220+ }
221+ s.formFailed(ctx, w, r, err, base+"/new")
222+}
223+
224+// formAgain shows the form again after a text the visitor can fix: a new
225+// challenge (the first one is spent), the text kept, the message at its field.
226+func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) {
227+ p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author}
228+ field, _, _ := strings.Cut(err.Error(), ": ")
229+ switch field {
230+ case fieldTitle, fieldBody, fieldAuthor:
231+ default:
232+ field = fieldBody
233+ }
234+ label := field
235+ if field == fieldAuthor {
236+ label = "name"
237+ }
238+ p.ErrorField = field
239+ p.Error = "The " + label + " " + visibleText(te.Reason) + "."
240+ if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil {
241+ s.formFailed(ctx, w, r, cerr, p.Cancel)
242+ return
243+ }
244+ s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p)
245+}
246+
247+// issueSubmittedPage is GET /<repo>/issues/submitted.
248+func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250+ defer cancel()
251+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
252+ if err != nil {
253+ s.fail(ctx, w, r, err)
254+ return
255+ }
256+ if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
257+ s.fail(ctx, w, r, errNotFound)
258+ return
259+ }
260+ w.Header().Set("Cache-Control", noStore)
261+ s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name()))
262+}
263+
264+func (s *Server) submittedPage(repo string) templ.Component {
265+ l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}})
266+ return State(l, StatePage{
267+ Code: "ok", Title: "thanks",
268+ Lines: []string{"Thanks, your issue is waiting for moderation."},
269+ Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}},
270+ })
271+}
272+
273+func i64(n int64) string { return strconv.FormatInt(n, 10) }
274+
275+// runeCount is the length of s in characters, like the limits of the form.
276+func runeCount(s string) int { return utf8.RuneCountInString(s) }
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/clientlimit"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+)
20+
21+// The public issue form (ADR 0031). A visitor of a public repository whose
22+// issues mode is public writes an issue that awaits the owner's moderation.
23+//
24+// GET /<repo>/issues/new the form, with a proof of work solved by script
25+// POST /<repo>/issues/new the submission (the one write a visitor may make)
26+// GET /<repo>/issues/submitted the thanks page
27+//
28+// Every answer is "Cache-Control: no-store". The challenge token is never
29+// echoed back, and neither the address of the client nor the text of the
30+// submission is logged. A refusal for a bot-related reason is one generic page.
31+
32+// maxIssueFormBytes bounds the body of the submission: 10,000 characters are
33+// at most 40,000 bytes, plus the title, the name and the tokens.
34+const maxIssueFormBytes = 64 << 10
35+
36+// The fields of the form, and so the only ones read.
37+const (
38+ fieldTitle = "title"
39+ fieldBody = "body"
40+ fieldAuthor = "author"
41+ fieldToken = "token"
42+ fieldFormToken = "form_token"
43+ fieldSolution = "solution"
44+ fieldHoneypot = "website"
45+)
46+
47+var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot}
48+
49+// IssueFormPage is the public form.
50+type IssueFormPage struct {
51+ Action, Cancel string
52+ Token string
53+ FormToken string
54+ Nonce string
55+ Difficulty int
56+ MinSubmitAt int64 // unix milliseconds
57+ Worker string
58+ // Title, Body and Author are what the visitor already wrote.
59+ Title, Body, Author string
60+ // ErrorField is "title", "body" or "author" and Error the message to show
61+ // next to it; both empty without an error.
62+ ErrorField, Error string
63+ // Max lengths, in characters.
64+ MaxTitle, MaxBody, MaxAuthor int
65+}
66+
67+// issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in,
68+// else the public form, else the page that is not there.
69+func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) {
70+ if SignedIn(r.Context()) || s.cfg.IssueForm == nil {
71+ s.page(s.issueNew)(w, r)
72+ return
73+ }
74+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
75+ defer cancel()
76+ name, err := domain.ParseRepoName(r.PathValue("repo"))
77+ if err != nil {
78+ s.fail(ctx, w, r, err)
79+ return
80+ }
81+ page := IssueFormPage{}
82+ status, err := s.fillIssueForm(ctx, r, name, &page)
83+ if err != nil {
84+ s.formFailed(ctx, w, r, err, page.Cancel)
85+ return
86+ }
87+ s.writeIssueForm(ctx, w, r, status, name, page)
88+}
89+
90+// fillIssueForm asks a challenge for the client and fills what the form needs
91+// from it, leaving the text of the visitor as it is.
92+func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) {
93+ base := issuesBase(name.String())
94+ p.Cancel = base
95+ ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r))
96+ if err != nil {
97+ return 0, err
98+ }
99+ p.Action = base + "/new"
100+ p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty
101+ p.MinSubmitAt = ch.MinSubmitAt.UnixMilli()
102+ p.Worker = s.assets.urls.PowWorker
103+ p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen
104+ return http.StatusOK, nil
105+}
106+
107+// formClient is the key of the client for the limits of the submissions.
108+func (s *Server) formClient(r *http.Request) string {
109+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
110+}
111+
112+// writeIssueForm renders the form in the layout of the issues of the repository.
113+func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) {
114+ d, err := s.issueRepo(ctx, name.String())
115+ if err != nil {
116+ s.fail(ctx, w, r, err)
117+ return
118+ }
119+ l := s.repoLayout(ctx, d, "issues")
120+ l.Title = "new issue · " + d.name() + " · " + s.instance
121+ l.Crumbs[0].Current = false
122+ l.Crumbs = append(l.Crumbs,
123+ Crumb{Label: "issues", Href: issuesBase(d.name())},
124+ Crumb{Label: "new", Current: true},
125+ )
126+ l.HasRail = true
127+ l.IssueForm = true
128+ w.Header().Set("Cache-Control", noStore)
129+ s.write(ctx, w, r, status, IssueForm(l, p))
130+}
131+
132+// formFailed answers an error of the challenge or of the submission.
133+func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
134+ var retry *domain.SubmissionRetryError
135+ switch {
136+ case errors.Is(err, context.Canceled):
137+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText):
138+ // The same page, with the same headers, as every other page that is
139+ // not there (a POST has set no-store already).
140+ s.fail(ctx, w, r, domain.ErrNotFound)
141+ case errors.As(err, &retry):
142+ secs := int(retry.After.Round(time.Second).Seconds())
143+ if retry.After > time.Duration(secs)*time.Second {
144+ secs++
145+ }
146+ secs = max(secs, 1)
147+ w.Header().Set("Retry-After", strconv.Itoa(secs))
148+ mins := (secs + 59) / 60
149+ unit := " minutes."
150+ if mins == 1 {
151+ unit = " minute."
152+ }
153+ s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions",
154+ "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back)
155+ case errors.Is(err, domain.ErrSubmissionsPaused):
156+ s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back)
157+ case errors.Is(err, domain.ErrSubmissionRefused):
158+ s.refusal(ctx, w, r, http.StatusBadRequest, "not verified",
159+ "The submission could not be verified. Reload the page and try again.", back)
160+ default:
161+ s.internalFailure(ctx, w, r, err)
162+ }
163+}
164+
165+// refusal writes a state page for a visitor, never cached.
166+func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
167+ if back == "" {
168+ back = "/"
169+ }
170+ l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
171+ w.Header().Set("Cache-Control", noStore)
172+ s.write(ctx, w, r, status, State(l, StatePage{
173+ Code: itoa(status), Title: title, Lines: []string{line},
174+ Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
175+ }))
176+}
177+
178+// issueFormPost is POST /<repo>/issues/new.
179+func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) {
180+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
181+ defer cancel()
182+ w.Header().Set("Cache-Control", noStore)
183+ name, err := domain.ParseRepoName(r.PathValue("repo"))
184+ if err != nil {
185+ s.formFailed(ctx, w, r, err, "")
186+ return
187+ }
188+ base := issuesBase(name.String())
189+ r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes)
190+ if err := r.ParseForm(); err != nil {
191+ var tooBig *http.MaxBytesError
192+ if errors.As(err, &tooBig) {
193+ s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new")
194+ return
195+ }
196+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
197+ return
198+ }
199+ for _, f := range issueFormFields {
200+ if len(r.PostForm[f]) > 1 {
201+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
202+ return
203+ }
204+ }
205+ in := ports.IssueSubmission{
206+ Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution),
207+ FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot),
208+ Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)),
209+ Author: r.PostForm.Get(fieldAuthor),
210+ }
211+ err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in)
212+ if err == nil {
213+ seeOther(w, r, base+"/submitted")
214+ return
215+ }
216+ var te *domain.TextError
217+ if errors.As(err, &te) {
218+ s.formAgain(ctx, w, r, name, in, err, te)
219+ return
220+ }
221+ s.formFailed(ctx, w, r, err, base+"/new")
222+}
223+
224+// formAgain shows the form again after a text the visitor can fix: a new
225+// challenge (the first one is spent), the text kept, the message at its field.
226+func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) {
227+ p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author}
228+ field, _, _ := strings.Cut(err.Error(), ": ")
229+ switch field {
230+ case fieldTitle, fieldBody, fieldAuthor:
231+ default:
232+ field = fieldBody
233+ }
234+ label := field
235+ if field == fieldAuthor {
236+ label = "name"
237+ }
238+ p.ErrorField = field
239+ p.Error = "The " + label + " " + visibleText(te.Reason) + "."
240+ if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil {
241+ s.formFailed(ctx, w, r, cerr, p.Cancel)
242+ return
243+ }
244+ s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p)
245+}
246+
247+// issueSubmittedPage is GET /<repo>/issues/submitted.
248+func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250+ defer cancel()
251+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
252+ if err != nil {
253+ s.fail(ctx, w, r, err)
254+ return
255+ }
256+ if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
257+ s.fail(ctx, w, r, errNotFound)
258+ return
259+ }
260+ w.Header().Set("Cache-Control", noStore)
261+ s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name()))
262+}
263+
264+func (s *Server) submittedPage(repo string) templ.Component {
265+ l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}})
266+ return State(l, StatePage{
267+ Code: "ok", Title: "thanks",
268+ Lines: []string{"Thanks, your issue is waiting for moderation."},
269+ Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}},
270+ })
271+}
272+
273+func i64(n int64) string { return strconv.FormatInt(n, 10) }
274+
275+// runeCount is the length of s in characters, like the limits of the form.
276+func runeCount(s string) int { return utf8.RuneCountInString(s) }
A internal/adapters/web/issues_form.templ +67 -0
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueForm is the public form that proposes an issue to the owner. It needs
6+// JavaScript: the script (issue-form.js) solves a proof of work in a worker,
7+// so the form stays hidden until it runs, and the page says why without it.
8+templ IssueForm(l Layout, p IssueFormPage) {
9+ @Shell(l, issueFormRail()) {
10+ <header class="show mr-h">
11+ <h1>New issue</h1>
12+ </header>
13+ <noscript>
14+ <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p>
15+ </noscript>
16+ <form
17+ class="cform create"
18+ method="post"
19+ action={ templ.SafeURL(p.Action) }
20+ hidden
21+ data-issue-form
22+ data-nonce={ p.Nonce }
23+ data-difficulty={ itoa(p.Difficulty) }
24+ data-min-submit-at={ i64(p.MinSubmitAt) }
25+ data-worker={ p.Worker }
26+ autocomplete="off"
27+ >
28+ <input type="hidden" name="token" value={ p.Token }/>
29+ <input type="hidden" name="form_token" value={ p.FormToken }/>
30+ <input type="hidden" name="solution" value=""/>
31+ <label for="issue-title">title</label>
32+ <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>
33+ <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34+ if p.ErrorField == "title" {
35+ <p class="f-error" role="alert">{ p.Error }</p>
36+ }
37+ <label for="issue-body">description</label>
38+ <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>
39+ <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40+ if p.ErrorField == "body" {
41+ <p class="f-error" role="alert">{ p.Error }</p>
42+ }
43+ <label for="issue-author">name (optional, not verified)</label>
44+ <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>
45+ <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46+ if p.ErrorField == "author" {
47+ <p class="f-error" role="alert">{ p.Error }</p>
48+ }
49+ <div class="hp" aria-hidden="true">
50+ <label for="issue-website">Leave this field empty</label>
51+ <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/>
52+ </div>
53+ <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p>
54+ <div class="f-btns">
55+ <button type="submit" class="btn primary" disabled>Submit for moderation</button>
56+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
57+ </div>
58+ </form>
59+ }
60+}
61+
62+templ issueFormRail() {
63+ <section aria-labelledby="r-about">
64+ <h2 id="r-about">Before you write</h2>
65+ <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
66+ </section>
67+}
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueForm is the public form that proposes an issue to the owner. It needs
6+// JavaScript: the script (issue-form.js) solves a proof of work in a worker,
7+// so the form stays hidden until it runs, and the page says why without it.
8+templ IssueForm(l Layout, p IssueFormPage) {
9+ @Shell(l, issueFormRail()) {
10+ <header class="show mr-h">
11+ <h1>New issue</h1>
12+ </header>
13+ <noscript>
14+ <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p>
15+ </noscript>
16+ <form
17+ class="cform create"
18+ method="post"
19+ action={ templ.SafeURL(p.Action) }
20+ hidden
21+ data-issue-form
22+ data-nonce={ p.Nonce }
23+ data-difficulty={ itoa(p.Difficulty) }
24+ data-min-submit-at={ i64(p.MinSubmitAt) }
25+ data-worker={ p.Worker }
26+ autocomplete="off"
27+ >
28+ <input type="hidden" name="token" value={ p.Token }/>
29+ <input type="hidden" name="form_token" value={ p.FormToken }/>
30+ <input type="hidden" name="solution" value=""/>
31+ <label for="issue-title">title</label>
32+ <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>
33+ <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34+ if p.ErrorField == "title" {
35+ <p class="f-error" role="alert">{ p.Error }</p>
36+ }
37+ <label for="issue-body">description</label>
38+ <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>
39+ <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40+ if p.ErrorField == "body" {
41+ <p class="f-error" role="alert">{ p.Error }</p>
42+ }
43+ <label for="issue-author">name (optional, not verified)</label>
44+ <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>
45+ <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46+ if p.ErrorField == "author" {
47+ <p class="f-error" role="alert">{ p.Error }</p>
48+ }
49+ <div class="hp" aria-hidden="true">
50+ <label for="issue-website">Leave this field empty</label>
51+ <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/>
52+ </div>
53+ <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p>
54+ <div class="f-btns">
55+ <button type="submit" class="btn primary" disabled>Submit for moderation</button>
56+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
57+ </div>
58+ </form>
59+ }
60+}
61+
62+templ issueFormRail() {
63+ <section aria-labelledby="r-about">
64+ <h2 id="r-about">Before you write</h2>
65+ <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
66+ </section>
67+}
A internal/adapters/web/issues_form_templ.go +421 -0 generated file: collapsed
A internal/adapters/web/issues_form_test.go +345 -0
@@ -0,0 +1,345 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+type formRig struct {
24+ *issueRig
25+ form *mocks.IssueSubmissionService
26+}
27+
28+func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig {
29+ t.Helper()
30+ r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)}
31+ browse := mocks.NewBrowseService(t)
32+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
33+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
34+ r.srv = newTestServer(t, Config{
35+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
36+ IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
37+ })
38+ return r
39+}
40+
41+func challenge(n int) ports.IssueChallenge {
42+ return ports.IssueChallenge{
43+ Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18,
44+ FormToken: fmt.Sprintf("form-%d", n),
45+ IssuedAt: time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000),
46+ ExpiresAt: time.UnixMilli(1_700_000_600_000),
47+ }
48+}
49+
50+// post sends a submission as a browser of this site would.
51+func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder {
52+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
53+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
54+ for k, v := range hdr {
55+ req.Header.Set(k, v)
56+ }
57+ rec := httptest.NewRecorder()
58+ r.srv.ServeHTTP(rec, req)
59+ return rec
60+}
61+
62+var fromSite = map[string]string{"Origin": testPublicURL}
63+
64+func goodForm() url.Values {
65+ return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}}
66+}
67+
68+func TestIssueFormGet(t *testing.T) {
69+ t.Parallel()
70+ r := newFormRig(t, domain.IssuesPublic)
71+ r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once()
72+
73+ rec := r.do("GET", "/proj/issues/new", false, nil)
74+ require.Equal(t, 200, rec.Code, rec.Body.String())
75+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
76+ body := rec.Body.String()
77+ for _, want := range []string{
78+ `<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.",
79+ `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80+ `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81+ `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82+ `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
83+ `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84+ `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85+ `/-/static/js/issue-form.`,
86+ } {
87+ assert.Contains(t, body, want)
88+ }
89+ assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units")
90+ assert.NotContains(t, body, `name="website" type="hidden"`)
91+ assert.NotContains(t, body, `type="hidden" name="website"`)
92+ noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
93+ assert.NotContains(t, noscript, "<form", "no usable form without JavaScript")
94+ assert.NotContains(t, body, "style=", "no inline style")
95+ for _, tag := range strings.Split(body, "<script")[1:] {
96+ head, _, _ := strings.Cut(tag, ">")
97+ assert.Contains(t, head, `src="`, "no inline script")
98+ }
99+}
100+
101+func TestIssueFormIsNotThereUnlessPublic(t *testing.T) {
102+ t.Parallel()
103+ want := ""
104+ for i, tc := range []struct {
105+ name string
106+ mode domain.IssuesMode
107+ err error
108+ }{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} {
109+ r := newFormRig(t, tc.mode)
110+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe()
111+ path := "/proj/issues/new"
112+ if tc.name == "unknown" {
113+ path = "/nope/issues/new"
114+ }
115+ rec := r.do("GET", path, false, nil)
116+ assert.Equal(t, 404, rec.Code, tc.name)
117+ if i == 0 {
118+ want = rec.Body.String()
119+ continue
120+ }
121+ assert.Equal(t, want, rec.Body.String(), tc.name+": the same page")
122+ }
123+}
124+
125+func TestIssueFormOwnerKeepsHisForm(t *testing.T) {
126+ t.Parallel()
127+ r := newFormRig(t, domain.IssuesPublic) // no Challenge expected
128+ rec := r.do("GET", "/proj/issues/new", true, nil)
129+ require.Equal(t, 200, rec.Code)
130+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
131+ assert.NotContains(t, rec.Body.String(), "data-issue-form")
132+}
133+
134+func TestIssueFormGetPausedAndLimited(t *testing.T) {
135+ t.Parallel()
136+ r := newFormRig(t, domain.IssuesPublic)
137+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once()
138+ rec := r.do("GET", "/proj/issues/new", false, nil)
139+ assert.Equal(t, 503, rec.Code)
140+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
141+ assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.")
142+}
143+
144+func TestIssueFormPostOutcomes(t *testing.T) {
145+ t.Parallel()
146+ generic := "The submission could not be verified. Reload the page and try again."
147+ tests := []struct {
148+ name string
149+ err error
150+ status int
151+ body string
152+ hdr map[string]string
153+ }{
154+ {"not found", domain.ErrNotFound, 404, "no such page", nil},
155+ {"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}},
156+ {"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}},
157+ {"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil},
158+ {"invalid", domain.ErrSubmissionInvalid, 400, generic, nil},
159+ {"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil},
160+ }
161+ for _, tc := range tests {
162+ t.Run(tc.name, func(t *testing.T) {
163+ t.Parallel()
164+ r := newFormRig(t, domain.IssuesPublic)
165+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once()
166+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
167+ assert.Equal(t, tc.status, rec.Code, rec.Body.String())
168+ assert.Contains(t, rec.Body.String(), tc.body)
169+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
170+ for k, v := range tc.hdr {
171+ assert.Equal(t, v, rec.Header().Get(k))
172+ }
173+ assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed")
174+ for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} {
175+ assert.NotContains(t, rec.Body.String(), leak, "no detail")
176+ }
177+ })
178+ }
179+}
180+
181+// TestIssueFormPostInvalidTokenLooksTheSame: the use case answers an invalid
182+// submission the same whatever the client's ban, limit or the queue; the page
183+// must not differ either.
184+func TestIssueFormPostInvalidTokenLooksTheSame(t *testing.T) {
185+ t.Parallel()
186+ var first *httptest.ResponseRecorder
187+ for _, state := range []string{"banned", "rate limited", "queue full"} {
188+ r := newFormRig(t, domain.IssuesPublic)
189+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(domain.ErrSubmissionInvalid).Once()
190+ f := goodForm()
191+ f.Set("token", "garbage")
192+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
193+ assert.Equal(t, 400, rec.Code, state)
194+ if first == nil {
195+ first = rec
196+ continue
197+ }
198+ assert.Equal(t, first.Body.String(), rec.Body.String(), state)
199+ assert.Equal(t, first.Header().Get("Retry-After"), rec.Header().Get("Retry-After"), state)
200+ }
201+}
202+
203+func TestIssueFormPostSuccess(t *testing.T) {
204+ t.Parallel()
205+ r := newFormRig(t, domain.IssuesPublic)
206+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{
207+ Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex",
208+ }).Return(nil).Once()
209+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
210+ assert.Equal(t, 303, rec.Code)
211+ assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location"))
212+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
213+
214+ thanks := r.do("GET", "/proj/issues/submitted", false, nil)
215+ assert.Equal(t, 200, thanks.Code)
216+ assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control"))
217+ assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.")
218+}
219+
220+func TestIssueSubmittedPageOnlyForPublic(t *testing.T) {
221+ t.Parallel()
222+ r := newFormRig(t, domain.IssuesOwner)
223+ assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code)
224+}
225+
226+func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) {
227+ t.Parallel()
228+ r := newFormRig(t, domain.IssuesPublic)
229+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
230+ Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once()
231+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once()
232+ f := goodForm()
233+ f.Set("title", "<b>mine</b>")
234+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
235+ require.Equal(t, 422, rec.Code, rec.Body.String())
236+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
237+ body := rec.Body.String()
238+ assert.Contains(t, body, "&lt;b&gt;mine&lt;/b&gt;", "the title is kept")
239+ assert.Contains(t, body, "line1\nline2", "the body is kept")
240+ assert.Contains(t, body, `value="Alex"`)
241+ assert.Contains(t, body, "The title is empty.")
242+ assert.Contains(t, body, `name="token" value="tok-2.sig"`, "a new challenge")
243+ assert.NotContains(t, body, "tok-1.sig", "the spent token is not echoed")
244+}
245+
246+func TestIssueFormPostLimits(t *testing.T) {
247+ t.Parallel()
248+ t.Run("oversize", func(t *testing.T) {
249+ t.Parallel()
250+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
251+ big := url.Values{"title": {"x"}, "body": {strings.Repeat("a", maxIssueFormBytes+10)}}
252+ rec := r.post("/proj/issues/new", fromSite, big.Encode())
253+ assert.Equal(t, 413, rec.Code)
254+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
255+ })
256+ t.Run("duplicated fields", func(t *testing.T) {
257+ t.Parallel()
258+ for _, f := range issueFormFields {
259+ r := newFormRig(t, domain.IssuesPublic)
260+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()+"&"+f+"=again")
261+ assert.Equal(t, 400, rec.Code, f)
262+ assert.Contains(t, rec.Body.String(), "could not be verified", f)
263+ }
264+ })
265+}
266+
267+// The gate still demands the Origin of this site for the anonymous POST.
268+func TestIssueFormPostNeedsTheOrigin(t *testing.T) {
269+ t.Parallel()
270+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
271+ for name, hdr := range map[string]map[string]string{
272+ "missing": nil,
273+ "cross origin": {"Origin": "https://evil.example"},
274+ "null": {"Origin": "null"},
275+ "same host, other scheme": {"Origin": "http://forge.example"},
276+ } {
277+ rec := r.post("/proj/issues/new", hdr, goodForm().Encode())
278+ assert.Equal(t, 403, rec.Code, name)
279+ }
280+}
281+
282+func TestIssueFormPostOnOwnerModeIsNotFound(t *testing.T) {
283+ t.Parallel()
284+ r := newFormRig(t, domain.IssuesOwner)
285+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once()
286+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
287+ assert.Equal(t, 404, rec.Code)
288+}
289+
290+// anonymousPost opens exactly /<repo>/issues/new and nothing else.
291+func TestAnonymousPostOpensOnlyTheIssueForm(t *testing.T) {
292+ t.Parallel()
293+ r := newFormRig(t, domain.IssuesPublic)
294+ for _, p := range []string{
295+ "/x/issues/new/extra", "/x/issues/123", "/x/issues", "/x/issues/new/", "/x/issues/123/close", "/x/issues/new/close",
296+ "/x%2Fissues/new", "/x%2Fy/issues/new", "/%2Fissues/new", "/issues/new", "//issues/new", "/X/issues/new",
297+ "/a/b/issues/new", "/x.git/issues/new", "/../issues/new", "/-/inbox/pending/x/y/approve", "/x/merge-requests",
298+ "/x/issues%2Fnew", "/x/issues/new%2Fextra", "/x/issues/%6Eew",
299+ } {
300+ u := &url.URL{Path: p}
301+ if dec, err := url.PathUnescape(p); err == nil {
302+ u = &url.URL{Path: dec, RawPath: p}
303+ }
304+ req := &http.Request{Method: http.MethodPost, URL: u}
305+ got := r.srv.anonymousPost(req)
306+ want := false
307+ assert.Equal(t, want, got, p)
308+ }
309+ for _, p := range []string{"/x/issues/new", "/proj/issues/new", "/a.b_c-d/issues/new"} {
310+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: p}}
311+ assert.True(t, r.srv.anonymousPost(req), p)
312+ req.Method = http.MethodPut
313+ assert.False(t, r.srv.anonymousPost(req), p+" PUT")
314+ }
315+ // Without the use case the path is not opened at all.
316+ plain := newIssueRig(t, domain.IssuesPublic, false)
317+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/proj/issues/new"}}
318+ assert.False(t, plain.srv.anonymousPost(req))
319+}
320+
321+func TestIssueListShowsNewIssueToVisitorsOfPublicRepos(t *testing.T) {
322+ t.Parallel()
323+ for mode, want := range map[domain.IssuesMode]bool{domain.IssuesPublic: true, domain.IssuesOwner: false} {
324+ r := newFormRig(t, mode)
325+ r.issues.EXPECT().List(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.IssuePage{}, nil)
326+ rec := r.do("GET", "/proj/issues", false, nil)
327+ require.Equal(t, 200, rec.Code)
328+ assert.Equal(t, want, strings.Contains(rec.Body.String(), `href="/proj/issues/new"`), string(mode))
329+ }
330+}
331+
332+func TestExternalIssueIsRenderedUntrusted(t *testing.T) {
333+ t.Parallel()
334+ r := newFormRig(t, domain.IssuesPublic)
335+ i := testIssue(t, 5, domain.IssueStateOpen, "bug", "[a](javascript:alert(1)) [b](https://example.org/) <script>x</script>")
336+ i.External, i.Author = true, "Eve <b>"
337+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{Issue: i}, nil)
338+ rec := r.do("GET", "/proj/issues/5", false, nil)
339+ require.Equal(t, 200, rec.Code)
340+ body := rec.Body.String()
341+ assert.NotContains(t, body, "javascript:")
342+ assert.NotContains(t, body, "<script>x")
343+ assert.Contains(t, body, `rel="nofollow noopener noreferrer ugc"`)
344+ assert.Contains(t, body, "Eve &lt;b&gt;")
345+}
@@ -0,0 +1,345 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+type formRig struct {
24+ *issueRig
25+ form *mocks.IssueSubmissionService
26+}
27+
28+func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig {
29+ t.Helper()
30+ r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)}
31+ browse := mocks.NewBrowseService(t)
32+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
33+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
34+ r.srv = newTestServer(t, Config{
35+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
36+ IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
37+ })
38+ return r
39+}
40+
41+func challenge(n int) ports.IssueChallenge {
42+ return ports.IssueChallenge{
43+ Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18,
44+ FormToken: fmt.Sprintf("form-%d", n),
45+ IssuedAt: time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000),
46+ ExpiresAt: time.UnixMilli(1_700_000_600_000),
47+ }
48+}
49+
50+// post sends a submission as a browser of this site would.
51+func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder {
52+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
53+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
54+ for k, v := range hdr {
55+ req.Header.Set(k, v)
56+ }
57+ rec := httptest.NewRecorder()
58+ r.srv.ServeHTTP(rec, req)
59+ return rec
60+}
61+
62+var fromSite = map[string]string{"Origin": testPublicURL}
63+
64+func goodForm() url.Values {
65+ return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}}
66+}
67+
68+func TestIssueFormGet(t *testing.T) {
69+ t.Parallel()
70+ r := newFormRig(t, domain.IssuesPublic)
71+ r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once()
72+
73+ rec := r.do("GET", "/proj/issues/new", false, nil)
74+ require.Equal(t, 200, rec.Code, rec.Body.String())
75+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
76+ body := rec.Body.String()
77+ for _, want := range []string{
78+ `<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.",
79+ `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80+ `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81+ `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82+ `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
83+ `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84+ `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85+ `/-/static/js/issue-form.`,
86+ } {
87+ assert.Contains(t, body, want)
88+ }
89+ assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units")
90+ assert.NotContains(t, body, `name="website" type="hidden"`)
91+ assert.NotContains(t, body, `type="hidden" name="website"`)
92+ noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
93+ assert.NotContains(t, noscript, "<form", "no usable form without JavaScript")
94+ assert.NotContains(t, body, "style=", "no inline style")
95+ for _, tag := range strings.Split(body, "<script")[1:] {
96+ head, _, _ := strings.Cut(tag, ">")
97+ assert.Contains(t, head, `src="`, "no inline script")
98+ }
99+}
100+
101+func TestIssueFormIsNotThereUnlessPublic(t *testing.T) {
102+ t.Parallel()
103+ want := ""
104+ for i, tc := range []struct {
105+ name string
106+ mode domain.IssuesMode
107+ err error
108+ }{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} {
109+ r := newFormRig(t, tc.mode)
110+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe()
111+ path := "/proj/issues/new"
112+ if tc.name == "unknown" {
113+ path = "/nope/issues/new"
114+ }
115+ rec := r.do("GET", path, false, nil)
116+ assert.Equal(t, 404, rec.Code, tc.name)
117+ if i == 0 {
118+ want = rec.Body.String()
119+ continue
120+ }
121+ assert.Equal(t, want, rec.Body.String(), tc.name+": the same page")
122+ }
123+}
124+
125+func TestIssueFormOwnerKeepsHisForm(t *testing.T) {
126+ t.Parallel()
127+ r := newFormRig(t, domain.IssuesPublic) // no Challenge expected
128+ rec := r.do("GET", "/proj/issues/new", true, nil)
129+ require.Equal(t, 200, rec.Code)
130+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
131+ assert.NotContains(t, rec.Body.String(), "data-issue-form")
132+}
133+
134+func TestIssueFormGetPausedAndLimited(t *testing.T) {
135+ t.Parallel()
136+ r := newFormRig(t, domain.IssuesPublic)
137+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once()
138+ rec := r.do("GET", "/proj/issues/new", false, nil)
139+ assert.Equal(t, 503, rec.Code)
140+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
141+ assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.")
142+}
143+
144+func TestIssueFormPostOutcomes(t *testing.T) {
145+ t.Parallel()
146+ generic := "The submission could not be verified. Reload the page and try again."
147+ tests := []struct {
148+ name string
149+ err error
150+ status int
151+ body string
152+ hdr map[string]string
153+ }{
154+ {"not found", domain.ErrNotFound, 404, "no such page", nil},
155+ {"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}},
156+ {"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}},
157+ {"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil},
158+ {"invalid", domain.ErrSubmissionInvalid, 400, generic, nil},
159+ {"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil},
160+ }
161+ for _, tc := range tests {
162+ t.Run(tc.name, func(t *testing.T) {
163+ t.Parallel()
164+ r := newFormRig(t, domain.IssuesPublic)
165+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once()
166+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
167+ assert.Equal(t, tc.status, rec.Code, rec.Body.String())
168+ assert.Contains(t, rec.Body.String(), tc.body)
169+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
170+ for k, v := range tc.hdr {
171+ assert.Equal(t, v, rec.Header().Get(k))
172+ }
173+ assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed")
174+ for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} {
175+ assert.NotContains(t, rec.Body.String(), leak, "no detail")
176+ }
177+ })
178+ }
179+}
180+
181+// TestIssueFormPostInvalidTokenLooksTheSame: the use case answers an invalid
182+// submission the same whatever the client's ban, limit or the queue; the page
183+// must not differ either.
184+func TestIssueFormPostInvalidTokenLooksTheSame(t *testing.T) {
185+ t.Parallel()
186+ var first *httptest.ResponseRecorder
187+ for _, state := range []string{"banned", "rate limited", "queue full"} {
188+ r := newFormRig(t, domain.IssuesPublic)
189+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(domain.ErrSubmissionInvalid).Once()
190+ f := goodForm()
191+ f.Set("token", "garbage")
192+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
193+ assert.Equal(t, 400, rec.Code, state)
194+ if first == nil {
195+ first = rec
196+ continue
197+ }
198+ assert.Equal(t, first.Body.String(), rec.Body.String(), state)
199+ assert.Equal(t, first.Header().Get("Retry-After"), rec.Header().Get("Retry-After"), state)
200+ }
201+}
202+
203+func TestIssueFormPostSuccess(t *testing.T) {
204+ t.Parallel()
205+ r := newFormRig(t, domain.IssuesPublic)
206+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{
207+ Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex",
208+ }).Return(nil).Once()
209+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
210+ assert.Equal(t, 303, rec.Code)
211+ assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location"))
212+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
213+
214+ thanks := r.do("GET", "/proj/issues/submitted", false, nil)
215+ assert.Equal(t, 200, thanks.Code)
216+ assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control"))
217+ assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.")
218+}
219+
220+func TestIssueSubmittedPageOnlyForPublic(t *testing.T) {
221+ t.Parallel()
222+ r := newFormRig(t, domain.IssuesOwner)
223+ assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code)
224+}
225+
226+func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) {
227+ t.Parallel()
228+ r := newFormRig(t, domain.IssuesPublic)
229+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
230+ Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once()
231+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once()
232+ f := goodForm()
233+ f.Set("title", "<b>mine</b>")
234+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
235+ require.Equal(t, 422, rec.Code, rec.Body.String())
236+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
237+ body := rec.Body.String()
238+ assert.Contains(t, body, "&lt;b&gt;mine&lt;/b&gt;", "the title is kept")
239+ assert.Contains(t, body, "line1\nline2", "the body is kept")
240+ assert.Contains(t, body, `value="Alex"`)
241+ assert.Contains(t, body, "The title is empty.")
242+ assert.Contains(t, body, `name="token" value="tok-2.sig"`, "a new challenge")
243+ assert.NotContains(t, body, "tok-1.sig", "the spent token is not echoed")
244+}
245+
246+func TestIssueFormPostLimits(t *testing.T) {
247+ t.Parallel()
248+ t.Run("oversize", func(t *testing.T) {
249+ t.Parallel()
250+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
251+ big := url.Values{"title": {"x"}, "body": {strings.Repeat("a", maxIssueFormBytes+10)}}
252+ rec := r.post("/proj/issues/new", fromSite, big.Encode())
253+ assert.Equal(t, 413, rec.Code)
254+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
255+ })
256+ t.Run("duplicated fields", func(t *testing.T) {
257+ t.Parallel()
258+ for _, f := range issueFormFields {
259+ r := newFormRig(t, domain.IssuesPublic)
260+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()+"&"+f+"=again")
261+ assert.Equal(t, 400, rec.Code, f)
262+ assert.Contains(t, rec.Body.String(), "could not be verified", f)
263+ }
264+ })
265+}
266+
267+// The gate still demands the Origin of this site for the anonymous POST.
268+func TestIssueFormPostNeedsTheOrigin(t *testing.T) {
269+ t.Parallel()
270+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
271+ for name, hdr := range map[string]map[string]string{
272+ "missing": nil,
273+ "cross origin": {"Origin": "https://evil.example"},
274+ "null": {"Origin": "null"},
275+ "same host, other scheme": {"Origin": "http://forge.example"},
276+ } {
277+ rec := r.post("/proj/issues/new", hdr, goodForm().Encode())
278+ assert.Equal(t, 403, rec.Code, name)
279+ }
280+}
281+
282+func TestIssueFormPostOnOwnerModeIsNotFound(t *testing.T) {
283+ t.Parallel()
284+ r := newFormRig(t, domain.IssuesOwner)
285+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once()
286+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
287+ assert.Equal(t, 404, rec.Code)
288+}
289+
290+// anonymousPost opens exactly /<repo>/issues/new and nothing else.
291+func TestAnonymousPostOpensOnlyTheIssueForm(t *testing.T) {
292+ t.Parallel()
293+ r := newFormRig(t, domain.IssuesPublic)
294+ for _, p := range []string{
295+ "/x/issues/new/extra", "/x/issues/123", "/x/issues", "/x/issues/new/", "/x/issues/123/close", "/x/issues/new/close",
296+ "/x%2Fissues/new", "/x%2Fy/issues/new", "/%2Fissues/new", "/issues/new", "//issues/new", "/X/issues/new",
297+ "/a/b/issues/new", "/x.git/issues/new", "/../issues/new", "/-/inbox/pending/x/y/approve", "/x/merge-requests",
298+ "/x/issues%2Fnew", "/x/issues/new%2Fextra", "/x/issues/%6Eew",
299+ } {
300+ u := &url.URL{Path: p}
301+ if dec, err := url.PathUnescape(p); err == nil {
302+ u = &url.URL{Path: dec, RawPath: p}
303+ }
304+ req := &http.Request{Method: http.MethodPost, URL: u}
305+ got := r.srv.anonymousPost(req)
306+ want := false
307+ assert.Equal(t, want, got, p)
308+ }
309+ for _, p := range []string{"/x/issues/new", "/proj/issues/new", "/a.b_c-d/issues/new"} {
310+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: p}}
311+ assert.True(t, r.srv.anonymousPost(req), p)
312+ req.Method = http.MethodPut
313+ assert.False(t, r.srv.anonymousPost(req), p+" PUT")
314+ }
315+ // Without the use case the path is not opened at all.
316+ plain := newIssueRig(t, domain.IssuesPublic, false)
317+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/proj/issues/new"}}
318+ assert.False(t, plain.srv.anonymousPost(req))
319+}
320+
321+func TestIssueListShowsNewIssueToVisitorsOfPublicRepos(t *testing.T) {
322+ t.Parallel()
323+ for mode, want := range map[domain.IssuesMode]bool{domain.IssuesPublic: true, domain.IssuesOwner: false} {
324+ r := newFormRig(t, mode)
325+ r.issues.EXPECT().List(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.IssuePage{}, nil)
326+ rec := r.do("GET", "/proj/issues", false, nil)
327+ require.Equal(t, 200, rec.Code)
328+ assert.Equal(t, want, strings.Contains(rec.Body.String(), `href="/proj/issues/new"`), string(mode))
329+ }
330+}
331+
332+func TestExternalIssueIsRenderedUntrusted(t *testing.T) {
333+ t.Parallel()
334+ r := newFormRig(t, domain.IssuesPublic)
335+ i := testIssue(t, 5, domain.IssueStateOpen, "bug", "[a](javascript:alert(1)) [b](https://example.org/) <script>x</script>")
336+ i.External, i.Author = true, "Eve <b>"
337+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{Issue: i}, nil)
338+ rec := r.do("GET", "/proj/issues/5", false, nil)
339+ require.Equal(t, 200, rec.Code)
340+ body := rec.Body.String()
341+ assert.NotContains(t, body, "javascript:")
342+ assert.NotContains(t, body, "<script>x")
343+ assert.Contains(t, body, `rel="nofollow noopener noreferrer ugc"`)
344+ assert.Contains(t, body, "Eve &lt;b&gt;")
345+}
A internal/adapters/web/issues_post.go +86 -0
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// The writes of the issue pages follow those of the merge request pages
15+// (mr_post.go): each is a POST that passed the gate (an Origin of this site
16+// and the owner's session), calls the use case as the owner and answers 303
17+// to the page that shows the result, "Cache-Control: no-store".
18+
19+// issueAction wraps a POST handler like mrAction does.
20+func (s *Server) issueAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
21+ return s.mrAction(h)
22+}
23+
24+// issueCreate is POST /<repo>/issues: it opens an issue.
25+func (s *Server) issueCreate(ctx context.Context, r *http.Request) (string, string, error) {
26+ repo := r.PathValue("repo")
27+ back := issuesBase(repo) + "/new"
28+ name, err := domain.ParseRepoName(repo)
29+ if err != nil {
30+ return "", back, err
31+ }
32+ i, err := s.cfg.Issues.Create(ctx, principal(ctx), name, ports.CreateIssueParams{
33+ Title: r.PostFormValue("title"), Body: normalizeNewlines(r.PostFormValue("body")),
34+ })
35+ if err != nil {
36+ return "", back, err
37+ }
38+ return issueHref(repo, i.Number), back, nil
39+}
40+
41+// issueComment is POST /<repo>/issues/<n>/comments.
42+func (s *Server) issueComment(ctx context.Context, r *http.Request) (string, string, error) {
43+ repo := r.PathValue("repo")
44+ n, err := number(r)
45+ if err != nil {
46+ return "", "", err
47+ }
48+ back := issueHref(repo, n)
49+ name, err := domain.ParseRepoName(repo)
50+ if err != nil {
51+ return "", back, err
52+ }
53+ c, err := s.cfg.Issues.AddComment(ctx, principal(ctx), name, n, normalizeNewlines(r.PostFormValue("text")))
54+ if err != nil {
55+ return "", back, err
56+ }
57+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
58+}
59+
60+// issueClose is POST /<repo>/issues/<n>/close.
61+func (s *Server) issueClose(ctx context.Context, r *http.Request) (string, string, error) {
62+ return s.issueTransition(ctx, r, s.cfg.Issues.Close)
63+}
64+
65+// issueReopen is POST /<repo>/issues/<n>/reopen.
66+func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, string, error) {
67+ return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
68+}
69+
70+//hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which
71+func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
72+ repo := r.PathValue("repo")
73+ n, err := number(r)
74+ if err != nil {
75+ return "", "", err
76+ }
77+ back := issueHref(repo, n)
78+ name, err := domain.ParseRepoName(repo)
79+ if err != nil {
80+ return "", back, err
81+ }
82+ if _, err := do(ctx, principal(ctx), name, n); err != nil {
83+ return "", back, err
84+ }
85+ return back, back, nil
86+}
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// The writes of the issue pages follow those of the merge request pages
15+// (mr_post.go): each is a POST that passed the gate (an Origin of this site
16+// and the owner's session), calls the use case as the owner and answers 303
17+// to the page that shows the result, "Cache-Control: no-store".
18+
19+// issueAction wraps a POST handler like mrAction does.
20+func (s *Server) issueAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
21+ return s.mrAction(h)
22+}
23+
24+// issueCreate is POST /<repo>/issues: it opens an issue.
25+func (s *Server) issueCreate(ctx context.Context, r *http.Request) (string, string, error) {
26+ repo := r.PathValue("repo")
27+ back := issuesBase(repo) + "/new"
28+ name, err := domain.ParseRepoName(repo)
29+ if err != nil {
30+ return "", back, err
31+ }
32+ i, err := s.cfg.Issues.Create(ctx, principal(ctx), name, ports.CreateIssueParams{
33+ Title: r.PostFormValue("title"), Body: normalizeNewlines(r.PostFormValue("body")),
34+ })
35+ if err != nil {
36+ return "", back, err
37+ }
38+ return issueHref(repo, i.Number), back, nil
39+}
40+
41+// issueComment is POST /<repo>/issues/<n>/comments.
42+func (s *Server) issueComment(ctx context.Context, r *http.Request) (string, string, error) {
43+ repo := r.PathValue("repo")
44+ n, err := number(r)
45+ if err != nil {
46+ return "", "", err
47+ }
48+ back := issueHref(repo, n)
49+ name, err := domain.ParseRepoName(repo)
50+ if err != nil {
51+ return "", back, err
52+ }
53+ c, err := s.cfg.Issues.AddComment(ctx, principal(ctx), name, n, normalizeNewlines(r.PostFormValue("text")))
54+ if err != nil {
55+ return "", back, err
56+ }
57+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
58+}
59+
60+// issueClose is POST /<repo>/issues/<n>/close.
61+func (s *Server) issueClose(ctx context.Context, r *http.Request) (string, string, error) {
62+ return s.issueTransition(ctx, r, s.cfg.Issues.Close)
63+}
64+
65+// issueReopen is POST /<repo>/issues/<n>/reopen.
66+func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, string, error) {
67+ return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
68+}
69+
70+//hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which
71+func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
72+ repo := r.PathValue("repo")
73+ n, err := number(r)
74+ if err != nil {
75+ return "", "", err
76+ }
77+ back := issueHref(repo, n)
78+ name, err := domain.ParseRepoName(repo)
79+ if err != nil {
80+ return "", back, err
81+ }
82+ if _, err := do(ctx, principal(ctx), name, n); err != nil {
83+ return "", back, err
84+ }
85+ return back, back, nil
86+}
A internal/adapters/web/issues_templ.go +885 -0
A internal/adapters/web/issues_test.go +332 -0
A internal/adapters/web/jstest/issue-form.test.mjs +183 -0
M internal/adapters/web/keys.templ +1 -0
M internal/adapters/web/keys_templ.go +1 -1
M internal/adapters/web/markdown.go +73 -7
M internal/adapters/web/markdown_test.go +67 -0
M internal/adapters/web/mr.go +13 -8
M internal/adapters/web/mr.templ +10 -3
M internal/adapters/web/mr_post.go +8 -1
M internal/adapters/web/mr_templ.go +289 -265
M internal/adapters/web/overlays.templ +1 -1
M internal/adapters/web/overlays_templ.go +1 -1
M internal/adapters/web/pages.templ +8 -0
M internal/adapters/web/pages_templ.go +409 -363
M internal/adapters/web/repo.go +5 -0
M internal/adapters/web/session.go +16 -1
M internal/adapters/web/shell.templ +3 -0
M internal/adapters/web/shell_templ.go +180 -161
M internal/adapters/web/static/css/60-mr.css +25 -0
M internal/adapters/web/static/js/ermite.js +2 -2
A internal/adapters/web/static/js/issue-form.js +175 -0
A internal/adapters/web/static/js/pow-worker.js +72 -0
M internal/adapters/web/view.go +12 -0
M internal/adapters/web/web.go +46 -1
M internal/app/api_helpers_test.go +2 -0
M internal/app/api_test.go +1 -1
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_fake_test.go +27 -0
A internal/app/cli_issue.go +455 -0
A internal/app/cli_issue_e2e_test.go +154 -0
A internal/app/cli_issue_test.go +264 -0
M internal/app/cli_repo.go +61 -6
M internal/app/cli_repo_test.go +6 -5
M internal/app/cli_token.go +22 -0
M internal/app/cli_token_test.go +2 -3
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch_test.go +1 -1
M internal/app/forge.go +71 -22
A internal/app/issue_api_test.go +245 -0
A internal/app/issue_moderation_api_test.go +139 -0
M internal/app/root.go +1 -1
M internal/app/testdata/web/blob-binary.html +3 -1
M internal/app/testdata/web/blob-code.html +3 -1
M internal/app/testdata/web/blob-empty.html +3 -1
M internal/app/testdata/web/blob-image.html +3 -1
M internal/app/testdata/web/blob-markdown.html +3 -1
M internal/app/testdata/web/blob-svg.html +3 -1
M internal/app/testdata/web/blob-text.html +3 -1
M internal/app/testdata/web/blob-too-large.html +3 -1
M internal/app/testdata/web/blob-trojan.html +3 -1
M internal/app/testdata/web/commit-big.html +3 -1
M internal/app/testdata/web/commit-binary.html +3 -1
M internal/app/testdata/web/commit-merge.html +3 -1
M internal/app/testdata/web/commit-modify-split.html +3 -1
M internal/app/testdata/web/commit-modify.html +3 -1
M internal/app/testdata/web/commit-rename.html +3 -1
M internal/app/testdata/web/commit-root.html +3 -1
M internal/app/testdata/web/commit-trojan.html +3 -1
M internal/app/testdata/web/commits-page2.html +3 -1
M internal/app/testdata/web/commits-path.html +3 -1
M internal/app/testdata/web/commits-root.html +4 -2
M internal/app/testdata/web/commits-small.html +4 -2
M internal/app/testdata/web/empty.html +3 -1
M internal/app/testdata/web/home.html +4 -2
M internal/app/testdata/web/not-found-page.html +3 -1
M internal/app/testdata/web/not-found.html +3 -1
M internal/app/testdata/web/refs.html +4 -2
M internal/app/testdata/web/summary.html +4 -2
M internal/app/testdata/web/tree-nested.html +3 -1
M internal/app/testdata/web/tree-root.html +3 -1
M internal/app/testdata/web/tree-slash-ref.html +3 -1
M internal/app/testdata/web/tree-subdir.html +3 -1
M internal/app/web.go +5 -0
A internal/app/web_inbox_test.go +152 -0
A internal/app/web_issue_form_test.go +231 -0
A internal/app/web_issue_test.go +139 -0
M internal/clientlimit/export_test.go +7 -0
A internal/clientlimit/submissions.go +178 -0
A internal/clientlimit/submissions_test.go +148 -0
M internal/domain/event.go +72 -0
A internal/domain/issue.go +106 -0
A internal/domain/issue_test.go +138 -0
M internal/domain/mergerequest.go +3 -1
A internal/domain/pending_issue.go +122 -0
A internal/domain/pending_issue_test.go +90 -0
A internal/domain/submission.go +42 -0
M internal/domain/text.go +102 -0
M internal/domain/token.go +5 -1
M internal/domain/token_test.go +6 -1
M internal/ports/dto.go +69 -0
A internal/ports/mocks/InboxService.go +121 -0
A internal/ports/mocks/IssueBanStore.go +183 -0
A internal/ports/mocks/IssueKeyStore.go +109 -0
A internal/ports/mocks/IssueModerationService.go +280 -0
A internal/ports/mocks/IssueNumberResolver.go +126 -0
A internal/ports/mocks/IssueService.go +523 -0
A internal/ports/mocks/IssueStore.go +515 -0
A internal/ports/mocks/IssueSubmissionService.go +190 -0
M internal/ports/mocks/MergeRequestStore.go +62 -0
A internal/ports/mocks/PendingIssueStore.go +452 -0
A internal/ports/mocks/SubmissionLimiter.go +188 -0
M internal/ports/ports.go +81 -0
M internal/ports/services.go +94 -0
M internal/usecase/events.go +97 -3
A internal/usecase/events_owner_test.go +200 -0
M internal/usecase/events_test.go +43 -0
M internal/usecase/export_test.go +34 -0
A internal/usecase/inbox.go +164 -0
A internal/usecase/inbox_test.go +110 -0
A internal/usecase/issue_keys.go +62 -0
A internal/usecase/issue_moderation.go +119 -0
A internal/usecase/issue_moderation_test.go +251 -0
A internal/usecase/issue_pow_vector_test.go +39 -0
A internal/usecase/issue_submissions.go +383 -0
A internal/usecase/issue_submissions_test.go +670 -0
A internal/usecase/issues.go +291 -0
A internal/usecase/issues_integration_test.go +87 -0
A internal/usecase/issues_test.go +276 -0
M internal/usecase/mergerequests_integration_test.go +1 -1
M internal/usecase/repos_write.go +13 -3
M internal/usecase/repos_write_test.go +42 -0
A proto/ermite/storage/issue.proto +71 -0
M proto/ermite/storage/merge_request.proto +10 -0
M skills/ermite/CLI.md +15 -0

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close