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
A internal/adapters/rpc/issue.go +262 -0
M internal/adapters/rpc/mergerequest.go +3 -1
M internal/adapters/rpc/mergerequest_test.go +11 -11
M internal/adapters/rpc/repo.go +10 -2
M internal/adapters/web/ASSETS.md +2 -0
M internal/adapters/web/assets.go +3 -1
M internal/adapters/web/events.go +59 -3
M internal/adapters/web/events_test.go +38 -1
M internal/adapters/web/fragments.go +36 -6
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/home.go +3 -0
A internal/adapters/web/inbox.go +294 -0
A internal/adapters/web/inbox.templ +115 -0
A internal/adapters/web/inbox_moderation_test.go +148 -0
A internal/adapters/web/inbox_templ.go +759 -0
A internal/adapters/web/inbox_test.go +310 -0
A internal/adapters/web/issues.go +363 -0
A internal/adapters/web/issues.templ +187 -0
A internal/adapters/web/issues_form.go +276 -0
A internal/adapters/web/issues_form.templ +67 -0
A internal/adapters/web/issues_form_templ.go +421 -0
A internal/adapters/web/issues_form_test.go +345 -0
A internal/adapters/web/issues_post.go +86 -0
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
@@ -611,6 +611,68 @@ func (_c *MergeRequestStore_ListOpen_Call) RunAndReturn(run func(ctx context.Con
611 return _c611 return _c
612 }612 }
613 613
614+// ListOpenAll provides a mock function for the type MergeRequestStore
615+func (_mock *MergeRequestStore) ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error) {
616+ ret := _mock.Called(ctx)
617+
618+ if len(ret) == 0 {
619+ panic("no return value specified for ListOpenAll")
620+ }
621+
622+ var r0 []domain.MergeRequest
623+ var r1 error
624+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.MergeRequest, error)); ok {
625+ return returnFunc(ctx)
626+ }
627+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.MergeRequest); ok {
628+ r0 = returnFunc(ctx)
629+ } else {
630+ if ret.Get(0) != nil {
631+ r0 = ret.Get(0).([]domain.MergeRequest)
632+ }
633+ }
634+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
635+ r1 = returnFunc(ctx)
636+ } else {
637+ r1 = ret.Error(1)
638+ }
639+ return r0, r1
640+}
641+
642+// MergeRequestStore_ListOpenAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenAll'
643+type MergeRequestStore_ListOpenAll_Call struct {
644+ *mock.Call
645+}
646+
647+// ListOpenAll is a helper method to define mock.On call
648+// - ctx context.Context
649+func (_e *MergeRequestStore_Expecter) ListOpenAll(ctx any) *MergeRequestStore_ListOpenAll_Call {
650+ return &MergeRequestStore_ListOpenAll_Call{Call: _e.mock.On("ListOpenAll", ctx)}
651+}
652+
653+func (_c *MergeRequestStore_ListOpenAll_Call) Run(run func(ctx context.Context)) *MergeRequestStore_ListOpenAll_Call {
654+ _c.Call.Run(func(args mock.Arguments) {
655+ var arg0 context.Context
656+ if args[0] != nil {
657+ arg0 = args[0].(context.Context)
658+ }
659+ run(
660+ arg0,
661+ )
662+ })
663+ return _c
664+}
665+
666+func (_c *MergeRequestStore_ListOpenAll_Call) Return(mergeRequests []domain.MergeRequest, err error) *MergeRequestStore_ListOpenAll_Call {
667+ _c.Call.Return(mergeRequests, err)
668+ return _c
669+}
670+
671+func (_c *MergeRequestStore_ListOpenAll_Call) RunAndReturn(run func(ctx context.Context) ([]domain.MergeRequest, error)) *MergeRequestStore_ListOpenAll_Call {
672+ _c.Call.Return(run)
673+ return _c
674+}
675+
614 // OpenBySource provides a mock function for the type MergeRequestStore676 // OpenBySource provides a mock function for the type MergeRequestStore
615 func (_mock *MergeRequestStore) OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) ([]int64, bool, error) {677 func (_mock *MergeRequestStore) OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) ([]int64, bool, error) {
616 ret := _mock.Called(ctx, repo, branches, limit)678 ret := _mock.Called(ctx, repo, branches, limit)
A internal/ports/mocks/PendingIssueStore.go +452 -0
@@ -0,0 +1,452 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewPendingIssueStore creates a new instance of PendingIssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewPendingIssueStore(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *PendingIssueStore {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &PendingIssueStore{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// PendingIssueStore is an autogenerated mock type for the PendingIssueStore type
40+type PendingIssueStore struct {
41+ mock.Mock
42+}
43+
44+type PendingIssueStore_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *PendingIssueStore) EXPECT() *PendingIssueStore_Expecter {
49+ return &PendingIssueStore_Expecter{mock: &_m.Mock}
50+}
51+
52+// Approve provides a mock function for the type PendingIssueStore
53+func (_mock *PendingIssueStore) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
54+ ret := _mock.Called(ctx, repo, id, now)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for Approve")
58+ }
59+
60+ var r0 domain.Issue
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) (domain.Issue, error)); ok {
63+ return returnFunc(ctx, repo, id, now)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) domain.Issue); ok {
66+ r0 = returnFunc(ctx, repo, id, now)
67+ } else {
68+ r0 = ret.Get(0).(domain.Issue)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) error); ok {
71+ r1 = returnFunc(ctx, repo, id, now)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// PendingIssueStore_Approve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Approve'
79+type PendingIssueStore_Approve_Call struct {
80+ *mock.Call
81+}
82+
83+// Approve is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - id domain.PendingIssueID
87+// - now time.Time
88+func (_e *PendingIssueStore_Expecter) Approve(ctx any, repo any, id any, now any) *PendingIssueStore_Approve_Call {
89+ return &PendingIssueStore_Approve_Call{Call: _e.mock.On("Approve", ctx, repo, id, now)}
90+}
91+
92+func (_c *PendingIssueStore_Approve_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time)) *PendingIssueStore_Approve_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 domain.PendingIssueID
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.PendingIssueID)
105+ }
106+ var arg3 time.Time
107+ if args[3] != nil {
108+ arg3 = args[3].(time.Time)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *PendingIssueStore_Approve_Call) Return(issue domain.Issue, err error) *PendingIssueStore_Approve_Call {
121+ _c.Call.Return(issue, err)
122+ return _c
123+}
124+
125+func (_c *PendingIssueStore_Approve_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error)) *PendingIssueStore_Approve_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// Count provides a mock function for the type PendingIssueStore
131+func (_mock *PendingIssueStore) Count(ctx context.Context) (int, error) {
132+ ret := _mock.Called(ctx)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for Count")
136+ }
137+
138+ var r0 int
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context) (int, error)); ok {
141+ return returnFunc(ctx)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context) int); ok {
144+ r0 = returnFunc(ctx)
145+ } else {
146+ r0 = ret.Get(0).(int)
147+ }
148+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
149+ r1 = returnFunc(ctx)
150+ } else {
151+ r1 = ret.Error(1)
152+ }
153+ return r0, r1
154+}
155+
156+// PendingIssueStore_Count_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Count'
157+type PendingIssueStore_Count_Call struct {
158+ *mock.Call
159+}
160+
161+// Count is a helper method to define mock.On call
162+// - ctx context.Context
163+func (_e *PendingIssueStore_Expecter) Count(ctx any) *PendingIssueStore_Count_Call {
164+ return &PendingIssueStore_Count_Call{Call: _e.mock.On("Count", ctx)}
165+}
166+
167+func (_c *PendingIssueStore_Count_Call) Run(run func(ctx context.Context)) *PendingIssueStore_Count_Call {
168+ _c.Call.Run(func(args mock.Arguments) {
169+ var arg0 context.Context
170+ if args[0] != nil {
171+ arg0 = args[0].(context.Context)
172+ }
173+ run(
174+ arg0,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *PendingIssueStore_Count_Call) Return(n int, err error) *PendingIssueStore_Count_Call {
181+ _c.Call.Return(n, err)
182+ return _c
183+}
184+
185+func (_c *PendingIssueStore_Count_Call) RunAndReturn(run func(ctx context.Context) (int, error)) *PendingIssueStore_Count_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
189+
190+// Delete provides a mock function for the type PendingIssueStore
191+func (_mock *PendingIssueStore) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
192+ ret := _mock.Called(ctx, repo, id)
193+
194+ if len(ret) == 0 {
195+ panic("no return value specified for Delete")
196+ }
197+
198+ var r0 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) error); ok {
200+ r0 = returnFunc(ctx, repo, id)
201+ } else {
202+ r0 = ret.Error(0)
203+ }
204+ return r0
205+}
206+
207+// PendingIssueStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
208+type PendingIssueStore_Delete_Call struct {
209+ *mock.Call
210+}
211+
212+// Delete is a helper method to define mock.On call
213+// - ctx context.Context
214+// - repo domain.RepoName
215+// - id domain.PendingIssueID
216+func (_e *PendingIssueStore_Expecter) Delete(ctx any, repo any, id any) *PendingIssueStore_Delete_Call {
217+ return &PendingIssueStore_Delete_Call{Call: _e.mock.On("Delete", ctx, repo, id)}
218+}
219+
220+func (_c *PendingIssueStore_Delete_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID)) *PendingIssueStore_Delete_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 context.Context
223+ if args[0] != nil {
224+ arg0 = args[0].(context.Context)
225+ }
226+ var arg1 domain.RepoName
227+ if args[1] != nil {
228+ arg1 = args[1].(domain.RepoName)
229+ }
230+ var arg2 domain.PendingIssueID
231+ if args[2] != nil {
232+ arg2 = args[2].(domain.PendingIssueID)
233+ }
234+ run(
235+ arg0,
236+ arg1,
237+ arg2,
238+ )
239+ })
240+ return _c
241+}
242+
243+func (_c *PendingIssueStore_Delete_Call) Return(err error) *PendingIssueStore_Delete_Call {
244+ _c.Call.Return(err)
245+ return _c
246+}
247+
248+func (_c *PendingIssueStore_Delete_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error) *PendingIssueStore_Delete_Call {
249+ _c.Call.Return(run)
250+ return _c
251+}
252+
253+// Get provides a mock function for the type PendingIssueStore
254+func (_mock *PendingIssueStore) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
255+ ret := _mock.Called(ctx, repo, id)
256+
257+ if len(ret) == 0 {
258+ panic("no return value specified for Get")
259+ }
260+
261+ var r0 domain.PendingIssue
262+ var r1 error
263+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) (domain.PendingIssue, error)); ok {
264+ return returnFunc(ctx, repo, id)
265+ }
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) domain.PendingIssue); ok {
267+ r0 = returnFunc(ctx, repo, id)
268+ } else {
269+ r0 = ret.Get(0).(domain.PendingIssue)
270+ }
271+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.PendingIssueID) error); ok {
272+ r1 = returnFunc(ctx, repo, id)
273+ } else {
274+ r1 = ret.Error(1)
275+ }
276+ return r0, r1
277+}
278+
279+// PendingIssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
280+type PendingIssueStore_Get_Call struct {
281+ *mock.Call
282+}
283+
284+// Get is a helper method to define mock.On call
285+// - ctx context.Context
286+// - repo domain.RepoName
287+// - id domain.PendingIssueID
288+func (_e *PendingIssueStore_Expecter) Get(ctx any, repo any, id any) *PendingIssueStore_Get_Call {
289+ return &PendingIssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, id)}
290+}
291+
292+func (_c *PendingIssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID)) *PendingIssueStore_Get_Call {
293+ _c.Call.Run(func(args mock.Arguments) {
294+ var arg0 context.Context
295+ if args[0] != nil {
296+ arg0 = args[0].(context.Context)
297+ }
298+ var arg1 domain.RepoName
299+ if args[1] != nil {
300+ arg1 = args[1].(domain.RepoName)
301+ }
302+ var arg2 domain.PendingIssueID
303+ if args[2] != nil {
304+ arg2 = args[2].(domain.PendingIssueID)
305+ }
306+ run(
307+ arg0,
308+ arg1,
309+ arg2,
310+ )
311+ })
312+ return _c
313+}
314+
315+func (_c *PendingIssueStore_Get_Call) Return(pendingIssue domain.PendingIssue, err error) *PendingIssueStore_Get_Call {
316+ _c.Call.Return(pendingIssue, err)
317+ return _c
318+}
319+
320+func (_c *PendingIssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error)) *PendingIssueStore_Get_Call {
321+ _c.Call.Return(run)
322+ return _c
323+}
324+
325+// List provides a mock function for the type PendingIssueStore
326+func (_mock *PendingIssueStore) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
327+ ret := _mock.Called(ctx, repo, page)
328+
329+ if len(ret) == 0 {
330+ panic("no return value specified for List")
331+ }
332+
333+ var r0 ports.PendingIssuePage
334+ var r1 error
335+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListPendingParams) (ports.PendingIssuePage, error)); ok {
336+ return returnFunc(ctx, repo, page)
337+ }
338+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListPendingParams) ports.PendingIssuePage); ok {
339+ r0 = returnFunc(ctx, repo, page)
340+ } else {
341+ r0 = ret.Get(0).(ports.PendingIssuePage)
342+ }
343+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListPendingParams) error); ok {
344+ r1 = returnFunc(ctx, repo, page)
345+ } else {
346+ r1 = ret.Error(1)
347+ }
348+ return r0, r1
349+}
350+
351+// PendingIssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
352+type PendingIssueStore_List_Call struct {
353+ *mock.Call
354+}
355+
356+// List is a helper method to define mock.On call
357+// - ctx context.Context
358+// - repo domain.RepoName
359+// - page ports.ListPendingParams
360+func (_e *PendingIssueStore_Expecter) List(ctx any, repo any, page any) *PendingIssueStore_List_Call {
361+ return &PendingIssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, page)}
362+}
363+
364+func (_c *PendingIssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams)) *PendingIssueStore_List_Call {
365+ _c.Call.Run(func(args mock.Arguments) {
366+ var arg0 context.Context
367+ if args[0] != nil {
368+ arg0 = args[0].(context.Context)
369+ }
370+ var arg1 domain.RepoName
371+ if args[1] != nil {
372+ arg1 = args[1].(domain.RepoName)
373+ }
374+ var arg2 ports.ListPendingParams
375+ if args[2] != nil {
376+ arg2 = args[2].(ports.ListPendingParams)
377+ }
378+ run(
379+ arg0,
380+ arg1,
381+ arg2,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *PendingIssueStore_List_Call) Return(pendingIssuePage ports.PendingIssuePage, err error) *PendingIssueStore_List_Call {
388+ _c.Call.Return(pendingIssuePage, err)
389+ return _c
390+}
391+
392+func (_c *PendingIssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error)) *PendingIssueStore_List_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// Put provides a mock function for the type PendingIssueStore
398+func (_mock *PendingIssueStore) Put(ctx context.Context, p domain.PendingIssue) error {
399+ ret := _mock.Called(ctx, p)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for Put")
403+ }
404+
405+ var r0 error
406+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.PendingIssue) error); ok {
407+ r0 = returnFunc(ctx, p)
408+ } else {
409+ r0 = ret.Error(0)
410+ }
411+ return r0
412+}
413+
414+// PendingIssueStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
415+type PendingIssueStore_Put_Call struct {
416+ *mock.Call
417+}
418+
419+// Put is a helper method to define mock.On call
420+// - ctx context.Context
421+// - p domain.PendingIssue
422+func (_e *PendingIssueStore_Expecter) Put(ctx any, p any) *PendingIssueStore_Put_Call {
423+ return &PendingIssueStore_Put_Call{Call: _e.mock.On("Put", ctx, p)}
424+}
425+
426+func (_c *PendingIssueStore_Put_Call) Run(run func(ctx context.Context, p domain.PendingIssue)) *PendingIssueStore_Put_Call {
427+ _c.Call.Run(func(args mock.Arguments) {
428+ var arg0 context.Context
429+ if args[0] != nil {
430+ arg0 = args[0].(context.Context)
431+ }
432+ var arg1 domain.PendingIssue
433+ if args[1] != nil {
434+ arg1 = args[1].(domain.PendingIssue)
435+ }
436+ run(
437+ arg0,
438+ arg1,
439+ )
440+ })
441+ return _c
442+}
443+
444+func (_c *PendingIssueStore_Put_Call) Return(err error) *PendingIssueStore_Put_Call {
445+ _c.Call.Return(err)
446+ return _c
447+}
448+
449+func (_c *PendingIssueStore_Put_Call) RunAndReturn(run func(ctx context.Context, p domain.PendingIssue) error) *PendingIssueStore_Put_Call {
450+ _c.Call.Return(run)
451+ return _c
452+}
A internal/ports/mocks/SubmissionLimiter.go +188 -0
@@ -0,0 +1,188 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewSubmissionLimiter creates a new instance of SubmissionLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewSubmissionLimiter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *SubmissionLimiter {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &SubmissionLimiter{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// SubmissionLimiter is an autogenerated mock type for the SubmissionLimiter type
38+type SubmissionLimiter struct {
39+ mock.Mock
40+}
41+
42+type SubmissionLimiter_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *SubmissionLimiter) EXPECT() *SubmissionLimiter_Expecter {
47+ return &SubmissionLimiter_Expecter{mock: &_m.Mock}
48+}
49+
50+// Recent provides a mock function for the type SubmissionLimiter
51+func (_mock *SubmissionLimiter) Recent(ctx context.Context, addr string) (int, int) {
52+ ret := _mock.Called(ctx, addr)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Recent")
56+ }
57+
58+ var r0 int
59+ var r1 int
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int, int)); ok {
61+ return returnFunc(ctx, addr)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) int); ok {
64+ r0 = returnFunc(ctx, addr)
65+ } else {
66+ r0 = ret.Get(0).(int)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) int); ok {
69+ r1 = returnFunc(ctx, addr)
70+ } else {
71+ r1 = ret.Get(1).(int)
72+ }
73+ return r0, r1
74+}
75+
76+// SubmissionLimiter_Recent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Recent'
77+type SubmissionLimiter_Recent_Call struct {
78+ *mock.Call
79+}
80+
81+// Recent is a helper method to define mock.On call
82+// - ctx context.Context
83+// - addr string
84+func (_e *SubmissionLimiter_Expecter) Recent(ctx any, addr any) *SubmissionLimiter_Recent_Call {
85+ return &SubmissionLimiter_Recent_Call{Call: _e.mock.On("Recent", ctx, addr)}
86+}
87+
88+func (_c *SubmissionLimiter_Recent_Call) Run(run func(ctx context.Context, addr string)) *SubmissionLimiter_Recent_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 string
95+ if args[1] != nil {
96+ arg1 = args[1].(string)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *SubmissionLimiter_Recent_Call) Return(perAddr int, global int) *SubmissionLimiter_Recent_Call {
107+ _c.Call.Return(perAddr, global)
108+ return _c
109+}
110+
111+func (_c *SubmissionLimiter_Recent_Call) RunAndReturn(run func(ctx context.Context, addr string) (int, int)) *SubmissionLimiter_Recent_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
115+
116+// Reserve provides a mock function for the type SubmissionLimiter
117+func (_mock *SubmissionLimiter) Reserve(ctx context.Context, addr string) (bool, time.Duration, func()) {
118+ ret := _mock.Called(ctx, addr)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for Reserve")
122+ }
123+
124+ var r0 bool
125+ var r1 time.Duration
126+ var r2 func()
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, time.Duration, func())); ok {
128+ return returnFunc(ctx, addr)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
131+ r0 = returnFunc(ctx, addr)
132+ } else {
133+ r0 = ret.Get(0).(bool)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) time.Duration); ok {
136+ r1 = returnFunc(ctx, addr)
137+ } else {
138+ r1 = ret.Get(1).(time.Duration)
139+ }
140+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) func()); ok {
141+ r2 = returnFunc(ctx, addr)
142+ } else {
143+ if ret.Get(2) != nil {
144+ r2 = ret.Get(2).(func())
145+ }
146+ }
147+ return r0, r1, r2
148+}
149+
150+// SubmissionLimiter_Reserve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reserve'
151+type SubmissionLimiter_Reserve_Call struct {
152+ *mock.Call
153+}
154+
155+// Reserve is a helper method to define mock.On call
156+// - ctx context.Context
157+// - addr string
158+func (_e *SubmissionLimiter_Expecter) Reserve(ctx any, addr any) *SubmissionLimiter_Reserve_Call {
159+ return &SubmissionLimiter_Reserve_Call{Call: _e.mock.On("Reserve", ctx, addr)}
160+}
161+
162+func (_c *SubmissionLimiter_Reserve_Call) Run(run func(ctx context.Context, addr string)) *SubmissionLimiter_Reserve_Call {
163+ _c.Call.Run(func(args mock.Arguments) {
164+ var arg0 context.Context
165+ if args[0] != nil {
166+ arg0 = args[0].(context.Context)
167+ }
168+ var arg1 string
169+ if args[1] != nil {
170+ arg1 = args[1].(string)
171+ }
172+ run(
173+ arg0,
174+ arg1,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *SubmissionLimiter_Reserve_Call) Return(ok bool, retryAfter time.Duration, release func()) *SubmissionLimiter_Reserve_Call {
181+ _c.Call.Return(ok, retryAfter, release)
182+ return _c
183+}
184+
185+func (_c *SubmissionLimiter_Reserve_Call) RunAndReturn(run func(ctx context.Context, addr string) (bool, time.Duration, func())) *SubmissionLimiter_Reserve_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
M internal/ports/ports.go +81 -0
@@ -241,6 +241,83 @@ type TokenStore interface {
241 Touch(ctx context.Context, id string, at time.Time) error241 Touch(ctx context.Context, id string, at time.Time) error
242 }242 }
243 243
244+// IssueStore keeps the issues of the repositories and their comments. Issues
245+// share the number sequence of the merge requests of their repository, so that
246+// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
247+// the repository does not exist, so that deleting a repository leaves nothing
248+// for one created again under its name. *kvstore.Issues implements it.
249+type IssueStore interface {
250+ // Create stores the issue under the next number of its repository (the
251+ // one merge requests draw from), which it sets, and returns it. A number
252+ // is never given twice, to an issue or to a merge request, even by a
253+ // concurrent Create.
254+ Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
255+ // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
256+ // which state it was read at.
257+ Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
258+ // List returns a page of the issues of the repository in the given state
259+ // (any when empty), highest number first.
260+ List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
261+ // ListOpenAll returns the open issues of every repository, reading only
262+ // the open ones (through the same index). The order is unspecified.
263+ ListOpenAll(ctx context.Context) ([]domain.Issue, error)
264+ // Update replaces the stored issue and keeps the index of the open issues
265+ // in step. It returns domain.ErrNotFound if there is none and
266+ // domain.ErrConflict if another write changed it since issue was read.
267+ Update(ctx context.Context, issue domain.Issue) error
268+ // SaveComment writes a comment and the issue it belongs to in one batch: a
269+ // new comment (Version 0) must not exist yet, an existing one must be
270+ // unchanged since it was read, and so must issue. It returns
271+ // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
272+ // comment's ID for a new comment, and issue.UpdatedAt.
273+ SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
274+ // ListComments returns the comments of the issue, oldest first.
275+ ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
276+}
277+
278+// PendingIssueStore keeps the issues submitted through the public form until
279+// the owner decides. They have no number: Approve gives one. Put and Approve
280+// return domain.ErrNotFound when the repository does not exist, so that
281+// deleting a repository leaves nothing for one created again under its name.
282+// *kvstore.PendingIssues implements it.
283+type PendingIssueStore interface {
284+ // Put stores the pending issue; its id must be new.
285+ Put(ctx context.Context, p domain.PendingIssue) error
286+ // Get returns the pending issue, or domain.ErrNotFound.
287+ Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error)
288+ // List returns a page of the pending issues of the repository, or of
289+ // every repository when repo is zero, oldest first. A PageSize of 0
290+ // means every one.
291+ List(ctx context.Context, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
292+ // Count returns how many pending issues there are in all repositories,
293+ // reading at most domain.MaxPendingIssues+1 of them.
294+ Count(ctx context.Context) (int, error)
295+ // Approve takes the next number of the repository (the one merge requests
296+ // and issues share), writes the issue domain.ApprovedIssue(pending, now)
297+ // with that number, indexes it as open and deletes the pending record,
298+ // in one batch. It returns domain.ErrNotFound when either is missing.
299+ Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error)
300+ // Delete removes the pending issue, or returns domain.ErrNotFound.
301+ Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error
302+}
303+
304+// IssueKeyStore keeps the server key of the public issue form, from which the
305+// use cases derive their subkeys.
306+type IssueKeyStore interface {
307+ // Key returns the 32-byte secret. The first call on an empty store
308+ // generates and stores it; a restart keeps it.
309+ Key(ctx context.Context) ([]byte, error)
310+}
311+
312+// IssueBanStore keeps the bans of submitters, by the hash of their client key.
313+type IssueBanStore interface {
314+ // Ban bans the hash until the given time, replacing an earlier ban.
315+ Ban(ctx context.Context, hash []byte, until time.Time) error
316+ // Banned reports whether the hash is banned at now. An expired ban counts
317+ // as not banned and is deleted.
318+ Banned(ctx context.Context, hash []byte, now time.Time) (bool, error)
319+}
320+
244 // MergeRequestStore keeps the merge requests of the repositories, their321 // MergeRequestStore keeps the merge requests of the repositories, their
245 // comments and the read position of the reviewer. A repository has no entry322 // comments and the read position of the reviewer. A repository has no entry
246 // until its first merge request. Create, SaveComment and SetRead return323 // until its first merge request. Create, SaveComment and SetRead return
@@ -260,6 +337,10 @@ type MergeRequestStore interface {
260 // ListOpen returns the open merge requests of the repository, highest337 // ListOpen returns the open merge requests of the repository, highest
261 // number first, reading only the open ones (through an index).338 // number first, reading only the open ones (through an index).
262 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)339 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
340+ // ListOpenAll returns the open merge requests of every repository,
341+ // reading only the open ones (through the same index). The order is
342+ // unspecified.
343+ ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error)
263 // CountOpen returns the number of open merge requests of the repository344 // CountOpen returns the number of open merge requests of the repository
264 // from the index of keys: no request is read.345 // from the index of keys: no request is read.
265 CountOpen(ctx context.Context, repo domain.RepoName) (int, error)346 CountOpen(ctx context.Context, repo domain.RepoName) (int, error)
M internal/ports/services.go +94 -0
@@ -126,6 +126,87 @@ type MergeRequestService interface {
126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
127 }127 }
128 128
129+// IssueService is the issue use cases. *usecase.Issues implements it.
130+type IssueService interface {
131+ Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateIssueParams) (domain.Issue, error)
132+ List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListIssuesParams) (IssuePage, error)
133+ // Get returns the issue with its comments, oldest first.
134+ Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (IssueDetail, error)
135+ AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)
136+ Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
137+ Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
138+}
139+
140+// IssueModerationService is the owner's moderation of the pending issues.
141+// Every call needs owner rights and answers domain.ErrNotFound otherwise.
142+// *usecase.IssueModeration implements it.
143+type IssueModerationService interface {
144+ // ListPending lists the pending issues of a repository, or of every
145+ // repository when repo is zero, oldest first.
146+ ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
147+ // Approve opens the pending issue under a new number.
148+ Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error)
149+ // Reject deletes the pending issue and, with ban, bans its submitter.
150+ Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error
151+}
152+
153+// IssueChallenge is what the public form needs to submit: a signed token, the
154+// proof-of-work to solve and the timing.
155+type IssueChallenge struct {
156+ // Token is the signed challenge, sent back untouched.
157+ Token string
158+ // Nonce is the base64url of the 16 random bytes the browser hashes:
159+ // SHA-256(nonce bytes || ":" || decimal counter) must have Difficulty
160+ // leading zero bits.
161+ Nonce string
162+ // Difficulty is the number of leading zero bits required.
163+ Difficulty int
164+ // FormToken is a hidden field of the form, sent back untouched.
165+ FormToken string
166+ // IssuedAt, MinSubmitAt and ExpiresAt bound when the form may be sent.
167+ IssuedAt, MinSubmitAt, ExpiresAt time.Time
168+}
169+
170+// IssueSubmission is what the browser sends back.
171+type IssueSubmission struct {
172+ Token string
173+ // Solution is the decimal counter that solves the proof of work.
174+ Solution string
175+ FormToken string
176+ // Honeypot must be empty: a field only a bot fills.
177+ Honeypot string
178+ Title, Body, Author string
179+}
180+
181+// IssueSubmissionService is the anonymous issue submission. Both calls answer
182+// domain.ErrNotFound for a repository that is unknown, not public or without
183+// public issues. Submit refuses with an error matching
184+// domain.ErrSubmissionRefused (ErrSubmissionRateLimited, ErrSubmissionsPaused,
185+// ErrSubmissionInvalid) or with a *domain.TextError the user can fix.
186+// addr is the client key (clientlimit.Key of the address).
187+// *usecase.IssueSubmissions implements it.
188+type IssueSubmissionService interface {
189+ Challenge(ctx context.Context, repo domain.RepoName, addr string) (IssueChallenge, error)
190+ Submit(ctx context.Context, repo domain.RepoName, addr string, f IssueSubmission) error
191+}
192+
193+// IssueNumberResolver tells whether a number of a repository names an issue or
194+// a merge request, for the callers that follow a "#n" reference. *usecase.Issues
195+// implements it.
196+type IssueNumberResolver interface {
197+ // Kind needs the access of reading the repository and answers
198+ // domain.ErrNotFound otherwise, also when issues are off.
199+ Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
200+}
201+
202+// InboxService is the owner's inbox: what is open across every repository.
203+// *usecase.Inbox implements it.
204+type InboxService interface {
205+ // Overview needs owner rights and answers domain.ErrNotFound to anyone
206+ // else. The filter, when not zero, restricts the lists to that repository.
207+ Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (InboxOverview, error)
208+}
209+
129 // SigningKeyService tells which key signs the commits of the forge.210 // SigningKeyService tells which key signs the commits of the forge.
130 // *usecase.SigningKey implements it.211 // *usecase.SigningKey implements it.
131 type SigningKeyService interface {212 type SigningKeyService interface {
@@ -239,6 +320,19 @@ type FailureLimiter interface {
239 Fail(ctx context.Context, addr string)320 Fail(ctx context.Context, addr string)
240 }321 }
241 322
323+// SubmissionLimiter limits the submissions of the public issue form per
324+// client. *clientlimit.SubmissionLimiter implements it.
325+type SubmissionLimiter interface {
326+ // Reserve atomically checks the limits for addr and, if it may submit,
327+ // counts a submission. When it may not, retryAfter says when to retry.
328+ // release refunds the reservation (only when the submission was not
329+ // stored); it is never nil and safe to call once.
330+ Reserve(ctx context.Context, addr string) (ok bool, retryAfter time.Duration, release func())
331+ // Recent returns the submissions of the last hour from addr and from all
332+ // clients.
333+ Recent(ctx context.Context, addr string) (perAddr, global int)
334+}
335+
242 // SecretPage is one page of secrets.336 // SecretPage is one page of secrets.
243 type SecretPage struct {337 type SecretPage struct {
244 Secrets []domain.Secret338 Secrets []domain.Secret
M internal/usecase/events.go +97 -3
@@ -38,6 +38,10 @@ const (
38 const (38 const (
39 // TopicHome follows every repository the caller may see.39 // TopicHome follows every repository the caller may see.
40 TopicHome = "home"40 TopicHome = "home"
41+ // TopicOwner follows the merge requests and issues of every repository,
42+ // for the owner only (the inbox). Anyone else is told it is an unknown
43+ // topic.
44+ TopicOwner = "owner"
41 // topicRepoPrefix starts the topic of one repository: "repo:<name>".45 // topicRepoPrefix starts the topic of one repository: "repo:<name>".
42 topicRepoPrefix = "repo:"46 topicRepoPrefix = "repo:"
43 )47 )
@@ -178,6 +182,16 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178 return repoSnapshot(ev.Visibility, ev.CreatedAt)182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179 case domain.CommentAdded:183 case domain.CommentAdded:
180 return repoSnapshot(ev.Visibility, ev.CreatedAt)184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.IssueOpened:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.IssueClosed:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReopened:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
191+ case domain.IssueCommented:
192+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
193+ case domain.IssueSubmitted, domain.IssuePendingRemoved:
194+ return bufferedEvent{known: true} // owner topic only: no repository snapshot
181 case domain.CommentEdited:195 case domain.CommentEdited:
182 return repoSnapshot(ev.Visibility, ev.CreatedAt)196 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183 case domain.CommentDeleted:197 case domain.CommentDeleted:
@@ -264,6 +278,7 @@ func (e *Events) parseID(id string) (uint64, bool) {
264 type watch struct {278 type watch struct {
265 p domain.Principal279 p domain.Principal
266 home bool280 home bool
281+ owner bool // the owner topic: merge request, issue, repo update and deletion events of every repository
267 repos map[domain.RepoName]bool282 repos map[domain.RepoName]bool
268 283
269 checked time.Time // when the token store last confirmed the principal284 checked time.Time // when the token store last confirmed the principal
@@ -271,6 +286,12 @@ type watch struct {
271 286
272 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }287 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
273 288
289+// wantsEvent is wants for an event: the owner topic also follows the events
290+// about merge requests and issues of every repository.
291+func (w *watch) wantsEvent(name domain.RepoName, ev domain.Event) bool {
292+ return w.wants(name) || (w.owner && (isItemEvent(ev) || isRepoLifecycleEvent(ev)))
293+}
294+
274 // parseTopics validates the topics of a watch. A repository topic needs a295 // parseTopics validates the topics of a watch. A repository topic needs a
275 // repository the principal may read (else domain.ErrNotFound); an unknown296 // repository the principal may read (else domain.ErrNotFound); an unknown
276 // topic, or more than MaxWatchTopics, is domain.ErrInvalid.297 // topic, or more than MaxWatchTopics, is domain.ErrInvalid.
@@ -287,8 +308,14 @@ func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []s
287 w.home = true308 w.home = true
288 continue309 continue
289 }310 }
311+ if t == TopicOwner && p.HasOwnerRights() {
312+ w.owner = true
313+ continue
314+ }
290 raw, ok := strings.CutPrefix(t, topicRepoPrefix)315 raw, ok := strings.CutPrefix(t, topicRepoPrefix)
291 if !ok {316 if !ok {
317+ // Includes "owner" for anyone but the owner: the same error as
318+ // any unknown topic.
292 return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)319 return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)
293 }320 }
294 name, err := domain.ParseRepoName(raw)321 name, err := domain.ParseRepoName(raw)
@@ -425,7 +452,10 @@ func (e *Events) idleCheck() time.Duration {
425 452
426 // stillAllowed checks that the holder of a stored token may still watch: the453 // stillAllowed checks that the holder of a stored token may still watch: the
427 // token has not expired and, at most every few seconds, still exists. Root and454 // token has not expired and, at most every few seconds, still exists. Root and
428-// anonymous callers have nothing to lose.455+// anonymous callers have nothing to lose. The owner topic needs nothing more:
456+// a stored token with owner rights loses them only by ceasing to exist, which
457+// is checked here, and the owner's web session, which this use case cannot see,
458+// is checked by the web adapter before it sends anything.
429 func (e *Events) stillAllowed(ctx context.Context, w *watch) error {459 func (e *Events) stillAllowed(ctx context.Context, w *watch) error {
430 if w.p.TokenID() == "" {460 if w.p.TokenID() == "" {
431 return nil461 return nil
@@ -473,10 +503,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
473 return send(ports.WatchEvent{Gap: true})503 return send(ports.WatchEvent{Gap: true})
474 case domain.RepoDeleted:504 case domain.RepoDeleted:
475 // The repository is gone: use the visibility it had.505 // The repository is gone: use the visibility it had.
476- if !w.wants(ev.Repo) || !canReadAs(w.p, ev.Repo, ev.Visibility) {506+ if !w.wantsEvent(ev.Repo, ev) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
477 return nil507 return nil
478 }508 }
479 return send(out)509 return send(out)
510+ case domain.IssueSubmitted:
511+ return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
512+ case domain.IssuePendingRemoved:
513+ return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
480 case domain.RepoCreated:514 case domain.RepoCreated:
481 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)515 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
482 case domain.RepoUpdated:516 case domain.RepoUpdated:
@@ -493,6 +527,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
493 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)527 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
494 case domain.CommentAdded:528 case domain.CommentAdded:
495 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)529 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
530+ case domain.IssueOpened:
531+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
532+ case domain.IssueClosed:
533+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
534+ case domain.IssueReopened:
535+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
536+ case domain.IssueCommented:
537+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
496 case domain.CommentEdited:538 case domain.CommentEdited:
497 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
498 case domain.CommentDeleted:540 case domain.CommentDeleted:
@@ -514,6 +556,25 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
514 return nil // an event this version does not stream556 return nil // an event this version does not stream
515 }557 }
516 558
559+// deliverOwnerOnly sends an owner-topic event: never home, never a repository
560+// topic, not even to an owner who watches the repository. No content travels.
561+func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.RepoName, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
562+ if !w.owner {
563+ return nil
564+ }
565+ repo, err := lookupRepo(ctx, e.repos.meta, name)
566+ switch {
567+ case errors.Is(err, domain.ErrNotFound), ctx.Err() != nil:
568+ return nil
569+ case err != nil:
570+ return fmt.Errorf("check access to %q: %w", name, err)
571+ }
572+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
573+ return nil
574+ }
575+ return send(out)
576+}
577+
517 // deliverRepo sends an event about a repository if the watcher could read the578 // deliverRepo sends an event about a repository if the watcher could read the
518 // repository when the event happened and can read it now, and the repository579 // repository when the event happened and can read it now, and the repository
519 // is still the one the event is about. A repository that no longer exists is580 // is still the one the event is about. A repository that no longer exists is
@@ -523,7 +584,7 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
523 // read it before are told once, with its name and its new visibility only,584 // read it before are told once, with its name and its new visibility only,
524 // so that what they show can be removed.585 // so that what they show can be removed.
525 func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {586 func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
526- if !w.wants(name) || !be.known {587+ if !w.wantsEvent(name, be.ev) || !be.known {
527 return nil588 return nil
528 }589 }
529 repo, err := lookupRepo(ctx, e.repos.meta, name)590 repo, err := lookupRepo(ctx, e.repos.meta, name)
@@ -548,6 +609,9 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
548 if !canRead(w.p, repo) {609 if !canRead(w.p, repo) {
549 return nil610 return nil
550 }611 }
612+ if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff {
613+ return nil // issues were turned off since: a replay must not show them
614+ }
551 if withRepo {615 if withRepo {
552 view, err := e.repos.View(ctx, repo)616 view, err := e.repos.View(ctx, repo)
553 if err != nil {617 if err != nil {
@@ -557,3 +621,33 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
557 }621 }
558 return send(out)622 return send(out)
559 }623 }
624+
625+// isIssueEvent reports whether ev is about an issue.
626+func isIssueEvent(ev domain.Event) bool {
627+ switch ev.(type) {
628+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
629+ return true
630+ }
631+ return false
632+}
633+
634+// isRepoLifecycleEvent reports whether ev is a repository update or deletion:
635+// the owner topic follows them, for they change what the inbox lists.
636+func isRepoLifecycleEvent(ev domain.Event) bool {
637+ switch ev.(type) {
638+ case domain.RepoUpdated, domain.RepoDeleted:
639+ return true
640+ }
641+ return false
642+}
643+
644+// isItemEvent reports whether ev is about a merge request or an issue.
645+func isItemEvent(ev domain.Event) bool {
646+ switch ev.(type) {
647+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
648+ domain.MergeRequestReady, domain.CommentAdded,
649+ domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
650+ return true
651+ }
652+ return isIssueEvent(ev)
653+}
A internal/usecase/events_owner_test.go +200 -0
@@ -0,0 +1,200 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "testing/synctest"
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/usecase"
19+)
20+
21+func TestWatchOwnerTopicIsUnknownToEveryoneElse(t *testing.T) {
22+ t.Parallel()
23+ r := newEventRig(t, 0)
24+ noop := func(ports.WatchEvent) error { return nil }
25+ unknown := r.events.Watch(context.Background(), domain.Anonymous(), ports.WatchParams{Topics: []string{"nope"}}, noop)
26+ require.ErrorIs(t, unknown, domain.ErrInvalid)
27+
28+ partial := tokenWith("t1", now.Add(0), "pub") // limited to a repository
29+ for _, p := range []domain.Principal{domain.Anonymous(), partial, issuePrincipal(domain.ScopeIssueWrite)} {
30+ err := r.events.Watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}}, noop)
31+ require.ErrorIs(t, err, domain.ErrInvalid)
32+ assert.Equal(t, strings.ReplaceAll(unknown.Error(), "nope", "owner"), err.Error(), "same answer as an unknown topic")
33+ }
34+}
35+
36+func TestWatchOwnerTopicSpansRepositoriesAndHonoursIssuesOff(t *testing.T) {
37+ t.Parallel()
38+ synctest.Test(t, func(t *testing.T) {
39+ r := newEventRig(t, 16)
40+ a, b := name(t, "aaa"), name(t, "bbb")
41+ var mu sync.Mutex
42+ bMode := domain.IssuesOwner
43+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil)
44+ r.meta.EXPECT().Get(mock.Anything, b).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
45+ mu.Lock()
46+ defer mu.Unlock()
47+ return domain.Repo{Name: b, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(bMode)}, nil
48+ })
49+ ctx, cancel := context.WithCancel(context.Background())
50+ defer cancel()
51+ w := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
52+ synctest.Wait()
53+
54+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
55+ _, ok := w.next(t).Event.(domain.IssueOpened)
56+ require.True(t, ok)
57+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
58+ _, ok = w.next(t).Event.(domain.MergeRequestOpened)
59+ require.True(t, ok, "a second repository")
60+
61+ // Pushes are not inbox news.
62+ r.feed <- r.pushEvent(a, sentinelOID, "refs/heads/x")
63+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 3}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
64+ _, ok = w.next(t).Event.(domain.IssueOpened)
65+ require.True(t, ok, "the push was skipped")
66+
67+ mu.Lock()
68+ bMode = domain.IssuesOff
69+ mu.Unlock()
70+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 4}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
71+ r.feed <- domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
72+ e := w.next(t)
73+ _, ok = e.Event.(domain.MergeRequestClosed)
74+ require.True(t, ok, "the issue event of the issues-off repository is not delivered, got %T", e.Event)
75+ })
76+}
77+
78+func withIssues(m domain.IssuesMode) domain.RepoSettings {
79+ st := domain.DefaultRepoSettings()
80+ st.Issues = m
81+ return st
82+}
83+
84+func allScopesToken(mutate func(*domain.Token)) domain.Principal {
85+ tok := domain.Token{ID: "tok", Scopes: domain.AllScopes()}
86+ if mutate != nil {
87+ mutate(&tok)
88+ }
89+ return domain.TokenPrincipal(tok)
90+}
91+
92+func TestWatchOwnerTopicAccessMatrix(t *testing.T) {
93+ t.Parallel()
94+ missingOne := domain.AllScopes()[1:]
95+ cases := []struct {
96+ name string
97+ p domain.Principal
98+ allowed bool
99+ }{
100+ {"root", domain.Root(), true},
101+ {"owner session", domain.Owner(), true},
102+ {"unrestricted all-scope token", allScopesToken(nil), true},
103+ {"token missing one scope", allScopesToken(func(tk *domain.Token) { tk.Scopes = missingOne }), false},
104+ {"repo-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{name(t, "aaa")} }), false},
105+ {"branch-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }), false},
106+ {"anonymous", domain.Anonymous(), false},
107+ }
108+ for _, tc := range cases {
109+ t.Run(tc.name, func(t *testing.T) {
110+ t.Parallel()
111+ synctest.Test(t, func(t *testing.T) {
112+ r := newEventRig(t, 16)
113+ a, b := name(t, "aaa"), name(t, "bbb")
114+ for _, n := range []domain.RepoName{a, b} {
115+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil).Maybe()
116+ }
117+ ctx, cancel := context.WithCancel(context.Background())
118+ defer cancel()
119+ noop := func(ports.WatchEvent) error { return nil }
120+ if !tc.allowed {
121+ err := r.events.Watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}, noop)
122+ require.ErrorIs(t, err, domain.ErrInvalid)
123+ assert.EqualError(t, err, domain.ErrInvalid.Error()+`: unknown topic "owner" (want "home" or "repo:<name>")`)
124+ return
125+ }
126+ w := r.watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}})
127+ synctest.Wait()
128+ require.Equal(t, 1, r.events.Watchers())
129+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
130+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
131+ assert.Equal(t, a, w.next(t).Event.(domain.IssueOpened).Issue.Repo)
132+ assert.Equal(t, b, w.next(t).Event.(domain.MergeRequestOpened).MergeRequest.Repo)
133+ })
134+ })
135+ }
136+}
137+
138+func TestWatchOwnerTopicFollowsRepoUpdatedAndDeleted(t *testing.T) {
139+ t.Parallel()
140+ synctest.Test(t, func(t *testing.T) {
141+ r := newEventRig(t, 16)
142+ a := r.repo("aaa", domain.VisibilityPrivate)
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
146+ synctest.Wait()
147+ r.feed <- domain.RepoUpdated{Repo: a, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
148+ r.feed <- domain.RepoDeleted{Repo: a, Visibility: domain.VisibilityPrivate, At: now}
149+ _, ok := owner.next(t).Event.(domain.RepoUpdated)
150+ require.True(t, ok)
151+ _, ok = owner.next(t).Event.(domain.RepoDeleted)
152+ require.True(t, ok)
153+ })
154+}
155+
156+func TestWatchOwnerTopicEndsWhenTheTokenIsRevoked(t *testing.T) {
157+ t.Parallel()
158+ synctest.Test(t, func(t *testing.T) {
159+ r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1})
160+ priv := r.repo("priv", domain.VisibilityPrivate)
161+ ctx, cancel := context.WithCancel(context.Background())
162+ defer cancel()
163+ w := r.watch(ctx, allScopesToken(nil), ports.WatchParams{Topics: []string{"owner"}})
164+ synctest.Wait()
165+ require.Equal(t, 1, r.events.Watchers())
166+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
167+ w.next(t)
168+
169+ r.revoked.Store("tok", true)
170+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
171+ synctest.Wait()
172+ select {
173+ case err := <-w.done:
174+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
175+ default:
176+ t.Fatal("the owner watch of a revoked token did not end")
177+ }
178+ assert.Empty(t, w.got, "nothing was sent after the revocation")
179+ })
180+}
181+
182+func TestWatchOwnerTopicEndsWhenTheTokenExpires(t *testing.T) {
183+ t.Parallel()
184+ synctest.Test(t, func(t *testing.T) {
185+ r := newEventRig(t, 0)
186+ priv := r.repo("priv", domain.VisibilityPrivate)
187+ // The rig's clock stands at now: this token expired at now.
188+ p := allScopesToken(func(tk *domain.Token) { tk.ExpiresAt = now })
189+ w := r.watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}})
190+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
191+ synctest.Wait()
192+ select {
193+ case err := <-w.done:
194+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
195+ default:
196+ t.Fatal("the owner watch of an expired token did not end")
197+ }
198+ assert.Empty(t, w.got)
199+ })
200+}
M internal/usecase/events_test.go +43 -0
@@ -206,6 +206,49 @@ func TestWatchRechecksAccessPerEvent(t *testing.T) {
206 })206 })
207 }207 }
208 208
209+func TestWatchDropsIssueEventsOnceIssuesAreOff(t *testing.T) {
210+ t.Parallel()
211+ synctest.Test(t, func(t *testing.T) {
212+ r := newEventRig(t, 16)
213+ n := name(t, "iss")
214+ var mu sync.Mutex
215+ mode := domain.IssuesOwner
216+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
217+ mu.Lock()
218+ defer mu.Unlock()
219+ st := domain.DefaultRepoSettings()
220+ st.Issues = mode
221+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: st}, nil
222+ })
223+ ctx, cancel := context.WithCancel(context.Background())
224+ defer cancel()
225+ first := r.watch(ctx, domain.Root(), ports.WatchParams{})
226+ synctest.Wait()
227+
228+ r.push(n, 1)
229+ pushID := first.next(t).ID
230+ r.feed <- domain.IssueCommented{Repo: n, Number: 1, Comment: domain.IssueComment{ID: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
231+ _, ok := first.next(t).Event.(domain.IssueCommented)
232+ require.True(t, ok, "delivered while issues are on")
233+ r.push(n, 2)
234+ first.next(t)
235+
236+ mu.Lock()
237+ mode = domain.IssuesOff
238+ mu.Unlock()
239+
240+ // The replay holds the issue event: it is dropped, the other ones stay.
241+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: pushID})
242+ e := w.next(t)
243+ _, isPush := e.Event.(domain.PushEvent)
244+ require.True(t, isPush, "got %T", e.Event)
245+ w.quiet(t, r, n)
246+
247+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: n, Number: 2}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
248+ w.quiet(t, r, n)
249+ })
250+}
251+
209 func TestWatchResumeAndGap(t *testing.T) {252 func TestWatchResumeAndGap(t *testing.T) {
210 t.Parallel()253 t.Parallel()
211 const size = 8254 const size = 8
M internal/usecase/export_test.go +34 -0
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "crypto/rsa"7 "crypto/rsa"
8 "slices"8 "slices"
9+ "strconv"
9 "time"10 "time"
10 11
11 "bnema.dev/ermite-actions/expr"12 "bnema.dev/ermite-actions/expr"
@@ -127,3 +128,36 @@ func (o *OIDC) SignForTest(claims map[string]any) (string, error) {
127 }128 }
128 return o.sign(out)129 return o.sign(out)
129 }130 }
131+
132+// DifficultyFor exposes the adaptive difficulty with the production base and
133+// cap to the tests.
134+func DifficultyFor(perAddr, global int) int {
135+ return difficultyFor(baseDifficulty, maxDifficulty, perAddr, global)
136+}
137+
138+// Production parameters of the submission, for the tests.
139+const (
140+ BaseDifficulty = baseDifficulty
141+ MaxDifficulty = maxDifficulty
142+ MinFillTime = minFillTime
143+ ChallengeTTL = challengeTTL
144+)
145+
146+// SpentLen is the number of nonces the submission remembers as spent.
147+func (s *IssueSubmissions) SpentLen() int {
148+ s.mu.Lock()
149+ defer s.mu.Unlock()
150+ return len(s.spent)
151+}
152+
153+// FillSpent fills the spent set with n unexpired nonces.
154+func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
155+ s.mu.Lock()
156+ defer s.mu.Unlock()
157+ for i := range n {
158+ s.spent["fill"+strconv.Itoa(i)] = exp
159+ }
160+}
161+
162+// MaxSpent is the bound of the spent set.
163+const MaxSpent = maxSpent
A internal/usecase/inbox.go +164 -0
@@ -0,0 +1,164 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of
18+// every repository in one place. Only the owner may read it; anyone else
19+// gets domain.ErrNotFound, as for a page that does not exist. The issues of a
20+// repository whose issues mode is off are left out.
21+type Inbox struct {
22+ meta ports.RepoMetaStore
23+ mrs ports.MergeRequestStore
24+ issues ports.IssueStore
25+ // pending is nil when the public form is not wired.
26+ pending ports.PendingIssueStore
27+}
28+
29+var _ ports.InboxService = (*Inbox)(nil)
30+
31+// InboxConfig holds what the inbox needs. Every field is required.
32+type InboxConfig struct {
33+ Meta ports.RepoMetaStore
34+ MergeRequests ports.MergeRequestStore
35+ Issues ports.IssueStore
36+ // Pending lists the issues awaiting moderation. Optional: without it the
37+ // inbox has no pending section.
38+ Pending ports.PendingIssueStore
39+}
40+
41+// NewInbox builds the inbox use case.
42+func NewInbox(cfg InboxConfig) *Inbox {
43+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
44+}
45+
46+// Overview lists what is open, through the indexes of open items. The counts
47+// and the list of repositories do not depend on the filter, the lists do.
48+func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
49+ var out ports.InboxOverview
50+ if !p.HasOwnerRights() {
51+ return out, domain.ErrNotFound
52+ }
53+ mrs, err := in.mrs.ListOpenAll(ctx)
54+ if err != nil {
55+ return out, fmt.Errorf("list open merge requests: %w", err)
56+ }
57+ issues, err := in.issues.ListOpenAll(ctx)
58+ if err != nil {
59+ return out, fmt.Errorf("list open issues: %w", err)
60+ }
61+
62+ // exists and issuesOn cache what is read of each repository.
63+ issuesOn := make(map[domain.RepoName]bool)
64+ exists := make(map[domain.RepoName]bool)
65+ look := func(name domain.RepoName) error {
66+ if _, ok := exists[name]; ok {
67+ return nil
68+ }
69+ repo, err := lookupRepo(ctx, in.meta, name)
70+ switch {
71+ case errors.Is(err, domain.ErrNotFound):
72+ exists[name] = false
73+ case err != nil:
74+ return fmt.Errorf("read repository %q: %w", name, err)
75+ default:
76+ exists[name] = true
77+ issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff
78+ }
79+ return nil
80+ }
81+
82+ repos := make(map[domain.RepoName]bool)
83+ for _, m := range mrs {
84+ if err := look(m.Repo); err != nil {
85+ return out, err
86+ }
87+ if !exists[m.Repo] {
88+ continue
89+ }
90+ repos[m.Repo] = true
91+ out.Counts.MergeRequests++
92+ if filter.IsZero() || m.Repo == filter {
93+ out.MergeRequests = append(out.MergeRequests, m)
94+ }
95+ }
96+ for _, i := range issues {
97+ if err := look(i.Repo); err != nil {
98+ return out, err
99+ }
100+ if !exists[i.Repo] || !issuesOn[i.Repo] {
101+ continue
102+ }
103+ repos[i.Repo] = true
104+ out.Counts.Issues++
105+ if filter.IsZero() || i.Repo == filter {
106+ out.Issues = append(out.Issues, i)
107+ }
108+ }
109+
110+ if in.pending != nil {
111+ page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
112+ if err != nil {
113+ return out, fmt.Errorf("list pending issues: %w", err)
114+ }
115+ for _, pi := range page.Issues {
116+ if err := look(pi.Repo); err != nil {
117+ return out, err
118+ }
119+ if !exists[pi.Repo] || !issuesOn[pi.Repo] {
120+ continue
121+ }
122+ pi.IPHash = nil // the hash is for bans, the page never needs it
123+ out.Pending = append(out.Pending, pi)
124+ repos[pi.Repo] = true
125+ }
126+ out.Counts.Pending = len(out.Pending)
127+ }
128+
129+ slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130+ return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131+ })
132+ slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int {
133+ return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
134+ })
135+ for r := range repos {
136+ out.Repos = append(out.Repos, r)
137+ }
138+ slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
139+ return out, nil
140+}
141+
142+// compareRecent orders by update time, most recent first (the creation time
143+// stands in for a missing one), then by repository and number.
144+func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int {
145+ if aUp.IsZero() {
146+ aUp = aCr
147+ }
148+ if bUp.IsZero() {
149+ bUp = bCr
150+ }
151+ if c := bUp.Compare(aUp); c != 0 {
152+ return c
153+ }
154+ if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 {
155+ return c
156+ }
157+ switch {
158+ case aN > bN:
159+ return -1
160+ case aN < bN:
161+ return 1
162+ }
163+ return 0
164+}
A internal/usecase/inbox_test.go +110 -0
@@ -0,0 +1,110 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+func inboxMR(t *testing.T, repo string, n int64, up time.Time) domain.MergeRequest {
21+ t.Helper()
22+ ti, err := domain.ParseMRTitle("mr")
23+ require.NoError(t, err)
24+ return domain.MergeRequest{Repo: name(t, repo), Number: n, Title: ti, State: domain.MRStateOpen, UpdatedAt: up}
25+}
26+
27+func inboxIssue(t *testing.T, repo string, n int64, up time.Time) domain.Issue {
28+ t.Helper()
29+ ti, err := domain.ParseIssueTitle("issue")
30+ require.NoError(t, err)
31+ return domain.Issue{Repo: name(t, repo), Number: n, Title: ti, State: domain.IssueStateOpen, UpdatedAt: up}
32+}
33+
34+func newInboxUnit(t *testing.T) (*usecase.Inbox, *mocks.MergeRequestStore, *mocks.IssueStore, *mocks.RepoMetaStore) {
35+ t.Helper()
36+ mrs, is, meta := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t)
37+ return usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is}), mrs, is, meta
38+}
39+
40+func repoWithIssues(t *testing.T, n string, mode domain.IssuesMode) domain.Repo {
41+ s := domain.DefaultRepoSettings()
42+ s.Issues = mode
43+ return domain.Repo{Name: name(t, n), Visibility: domain.VisibilityPrivate, Settings: s}
44+}
45+
46+func TestInboxIsOwnerOnly(t *testing.T) {
47+ t.Parallel()
48+ in, _, _, _ := newInboxUnit(t) // the stores are never read
49+ for _, p := range []domain.Principal{domain.Anonymous(), issuePrincipal(domain.ScopeIssueWrite)} {
50+ _, err := in.Overview(context.Background(), p, domain.RepoName{})
51+ assert.ErrorIs(t, err, domain.ErrNotFound)
52+ }
53+}
54+
55+func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) {
56+ t.Parallel()
57+ ctx := context.Background()
58+ t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
59+ in, mrs, is, meta := newInboxUnit(t)
60+ mrs.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{
61+ inboxMR(t, "alpha", 1, t0), inboxMR(t, "beta", 1, t0.Add(time.Hour)), inboxMR(t, "gone", 1, t0),
62+ }, nil)
63+ is.EXPECT().ListOpenAll(mock.Anything).Return([]domain.Issue{
64+ inboxIssue(t, "alpha", 2, t0), inboxIssue(t, "beta", 2, t0.Add(2*time.Hour)), inboxIssue(t, "quiet", 2, t0),
65+ }, nil)
66+ meta.EXPECT().Get(mock.Anything, name(t, "alpha")).Return(repoWithIssues(t, "alpha", domain.IssuesOwner), nil)
67+ meta.EXPECT().Get(mock.Anything, name(t, "beta")).Return(repoWithIssues(t, "beta", domain.IssuesPublic), nil)
68+ meta.EXPECT().Get(mock.Anything, name(t, "quiet")).Return(repoWithIssues(t, "quiet", domain.IssuesOff), nil)
69+ meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
70+
71+ got, err := in.Overview(ctx, domain.Owner(), domain.RepoName{})
72+ require.NoError(t, err)
73+ assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts)
74+ require.Len(t, got.MergeRequests, 2)
75+ assert.Equal(t, "beta", got.MergeRequests[0].Repo.String(), "most recently updated first")
76+ require.Len(t, got.Issues, 2)
77+ assert.Equal(t, "beta", got.Issues[0].Repo.String())
78+ assert.Equal(t, []domain.RepoName{name(t, "alpha"), name(t, "beta")}, got.Repos)
79+
80+ got, err = in.Overview(ctx, domain.Root(), name(t, "alpha"))
81+ require.NoError(t, err)
82+ assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts, "counts ignore the filter")
83+ require.Len(t, got.MergeRequests, 1)
84+ require.Len(t, got.Issues, 1)
85+ assert.Equal(t, "alpha", got.Issues[0].Repo.String())
86+ assert.Len(t, got.Repos, 2, "the filter list keeps every repository")
87+}
88+
89+func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
90+ t.Parallel()
91+ ctx := context.Background()
92+ mrs, is, meta, pending := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewPendingIssueStore(t)
93+ in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is, Pending: pending})
94+ mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
95+ is.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
96+ meta.EXPECT().Get(mock.Anything, name(t, "on")).Return(repoWithIssues(t, "on", domain.IssuesPublic), nil)
97+ meta.EXPECT().Get(mock.Anything, name(t, "off")).Return(repoWithIssues(t, "off", domain.IssuesOff), nil)
98+ ti, err := domain.ParseIssueTitle("hi")
99+ require.NoError(t, err)
100+ pending.EXPECT().List(mock.Anything, domain.RepoName{}, mock.Anything).Return(ports.PendingIssuePage{Issues: []domain.PendingIssue{
101+ {ID: "a", Repo: name(t, "on"), Title: ti, IPHash: []byte("secret")},
102+ {ID: "b", Repo: name(t, "off"), Title: ti},
103+ }}, nil)
104+ ov, err := in.Overview(ctx, domain.Owner(), name(t, "other"))
105+ require.NoError(t, err)
106+ assert.Equal(t, 1, ov.Counts.Pending, "issues off hides pending ones too")
107+ require.Len(t, ov.Pending, 1)
108+ assert.Nil(t, ov.Pending[0].IPHash)
109+ assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110+}
A internal/usecase/issue_keys.go +62 -0
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hkdf"
8+ "crypto/hmac"
9+ "crypto/sha256"
10+ "fmt"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// HKDF info strings of the subkeys of the issue form key. Each purpose has its
17+// own key, so that a value signed for one can never pass for another.
18+const (
19+ infoIPHash = "ermite issue ip-hash"
20+ infoPoW = "ermite issue pow"
21+ infoFormToken = "ermite issue form-token"
22+)
23+
24+// IssueKeys holds the subkeys of the public issue form, derived with
25+// HKDF-SHA256 from the server key of the IssueKeyStore.
26+type IssueKeys struct {
27+ ipHash, pow, formToken []byte
28+}
29+
30+// NewIssueKeys loads the server key (generated on the first call) and derives
31+// the subkeys.
32+func NewIssueKeys(ctx context.Context, store ports.IssueKeyStore) (*IssueKeys, error) {
33+ master, err := store.Key(ctx)
34+ if err != nil {
35+ return nil, fmt.Errorf("load issue key: %w", err)
36+ }
37+ k := &IssueKeys{}
38+ for _, d := range []struct {
39+ dst *[]byte
40+ info string
41+ }{{&k.ipHash, infoIPHash}, {&k.pow, infoPoW}, {&k.formToken, infoFormToken}} {
42+ if *d.dst, err = hkdf.Key(sha256.New, master, nil, d.info, sha256.Size); err != nil {
43+ return nil, fmt.Errorf("derive %q: %w", d.info, err)
44+ }
45+ }
46+ return k, nil
47+}
48+
49+// IPHash returns HMAC-SHA256 of the client key (clientlimit.Key of the
50+// address) under the ip-hash subkey: domain.IPHashLen bytes. It is stable
51+// across restarts and says nothing about the address without the key.
52+func (k *IssueKeys) IPHash(addrKey string) []byte {
53+ m := hmac.New(sha256.New, k.ipHash)
54+ m.Write([]byte(addrKey))
55+ return m.Sum(nil)[:domain.IPHashLen]
56+}
57+
58+// PoWKey returns a copy of the subkey that signs the proof-of-work challenges.
59+func (k *IssueKeys) PoWKey() []byte { return append([]byte(nil), k.pow...) }
60+
61+// FormTokenKey returns a copy of the subkey that signs the form tokens.
62+func (k *IssueKeys) FormTokenKey() []byte { return append([]byte(nil), k.formToken...) }
A internal/usecase/issue_moderation.go +119 -0
@@ -0,0 +1,119 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// IssueModeration is the owner's moderation of the issues submitted through
17+// the public form (ADR 0031): list, approve and reject, with an optional ban of
18+// the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as
19+// for a page that does not exist.
20+type IssueModeration struct {
21+ meta ports.RepoMetaStore
22+ pending ports.PendingIssueStore
23+ bans ports.IssueBanStore
24+ bus ports.EventBus
25+ clock ports.Clock
26+}
27+
28+var _ ports.IssueModerationService = (*IssueModeration)(nil)
29+
30+// IssueModerationConfig holds what the moderation needs. Every field is
31+// required.
32+type IssueModerationConfig struct {
33+ Meta ports.RepoMetaStore
34+ Pending ports.PendingIssueStore
35+ Bans ports.IssueBanStore
36+ Bus ports.EventBus
37+ Clock ports.Clock
38+}
39+
40+// NewIssueModeration builds the moderation use case.
41+func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration {
42+ return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock}
43+}
44+
45+// ListPending lists the pending issues of a repository, or of every repository
46+// when repo is zero, oldest first.
47+func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
48+ if !p.HasOwnerRights() {
49+ return ports.PendingIssuePage{}, domain.ErrNotFound
50+ }
51+ size, err := pageSize(page.PageSize)
52+ if err != nil {
53+ return ports.PendingIssuePage{}, err
54+ }
55+ page.PageSize = size
56+ out, err := m.pending.List(ctx, repo, page)
57+ if err != nil {
58+ return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err)
59+ }
60+ return out, nil
61+}
62+
63+// Approve gives the pending issue the next number of its repository and opens
64+// it, then publishes IssueOpened. The repository must exist with issues on
65+// (else domain.ErrNotFound), and so must the pending issue.
66+func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
67+ if !p.HasOwnerRights() {
68+ return domain.Issue{}, domain.ErrNotFound
69+ }
70+ r, err := lookupRepo(ctx, m.meta, repo)
71+ if err != nil {
72+ return domain.Issue{}, err
73+ }
74+ if r.Settings.Issues.OrDefault() == domain.IssuesOff {
75+ return domain.Issue{}, domain.ErrNotFound
76+ }
77+ now := m.clock.Now().UTC()
78+ issue, err := m.pending.Approve(ctx, repo, id, now)
79+ if err != nil {
80+ if errors.Is(err, domain.ErrNotFound) {
81+ return domain.Issue{}, err
82+ }
83+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
84+ }
85+ log := zerowrap.FromCtx(ctx)
86+ log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
87+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88+ m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89+ return issue, nil
90+}
91+
92+// Reject deletes the pending issue. With ban, its submitter is first banned
93+// for domain.BanDuration.
94+func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
95+ if !p.HasOwnerRights() {
96+ return domain.ErrNotFound
97+ }
98+ pending, err := m.pending.Get(ctx, repo, id)
99+ if err != nil {
100+ return err
101+ }
102+ if ban {
103+ until := m.clock.Now().UTC().Add(domain.BanDuration)
104+ if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
105+ return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
106+ }
107+ }
108+ if err := m.pending.Delete(ctx, repo, id); err != nil {
109+ if errors.Is(err, domain.ErrNotFound) {
110+ return err
111+ }
112+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
113+ }
114+ log := zerowrap.FromCtx(ctx)
115+ log.Info().Str("repo", repo.String()).Bool("ban", ban).
116+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
117+ m.bus.Publish(ctx, domain.IssuePendingRemoved{Repo: repo, ID: id, At: m.clock.Now().UTC()})
118+ return nil
119+}
A internal/usecase/issue_moderation_test.go +251 -0
@@ -0,0 +1,251 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "testing"
9+ "testing/synctest"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+type moderationUnit struct {
22+ meta *mocks.RepoMetaStore
23+ pending *mocks.PendingIssueStore
24+ bans *mocks.IssueBanStore
25+ bus *mocks.EventBus
26+ mod *usecase.IssueModeration
27+ repo domain.Repo
28+ id domain.PendingIssueID
29+}
30+
31+func newModerationUnit(t *testing.T, mode domain.IssuesMode) *moderationUnit {
32+ t.Helper()
33+ id, err := domain.NewPendingIssueID()
34+ require.NoError(t, err)
35+ u := &moderationUnit{
36+ meta: mocks.NewRepoMetaStore(t),
37+ pending: mocks.NewPendingIssueStore(t),
38+ bans: mocks.NewIssueBanStore(t),
39+ bus: mocks.NewEventBus(t),
40+ repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(mode)},
41+ id: id,
42+ }
43+ clock := mocks.NewClock(t)
44+ clock.EXPECT().Now().Return(now).Maybe()
45+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
46+ u.mod = usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: u.meta, Pending: u.pending, Bans: u.bans, Bus: u.bus, Clock: clock})
47+ return u
48+}
49+
50+func TestIssueModerationIsOwnerOnly(t *testing.T) {
51+ t.Parallel()
52+ u := newModerationUnit(t, domain.IssuesPublic) // no store is ever called
53+ ctx := context.Background()
54+ notOwners := map[string]domain.Principal{
55+ "anonymous": domain.Anonymous(),
56+ "issue writer": issuePrincipal(domain.ScopeIssueWrite),
57+ "one scope short": allScopesToken(func(tk *domain.Token) {
58+ tk.Scopes = tk.Scopes[1:]
59+ }),
60+ "repo limited": allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{u.repo.Name} }),
61+ "branch limited": allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }),
62+ }
63+ for who, p := range notOwners {
64+ _, err := u.mod.ListPending(ctx, p, domain.RepoName{}, ports.ListPendingParams{})
65+ assert.ErrorIs(t, err, domain.ErrNotFound, who)
66+ _, err = u.mod.Approve(ctx, p, u.repo.Name, u.id)
67+ assert.ErrorIs(t, err, domain.ErrNotFound, who)
68+ assert.ErrorIs(t, u.mod.Reject(ctx, p, u.repo.Name, u.id, true), domain.ErrNotFound, who)
69+ }
70+}
71+
72+func TestIssueModerationListAppliesPageLimits(t *testing.T) {
73+ t.Parallel()
74+ u := newModerationUnit(t, domain.IssuesPublic)
75+ u.pending.EXPECT().List(mock.Anything, domain.RepoName{}, ports.ListPendingParams{PageSize: usecase.DefaultPageSize, PageToken: "t"}).
76+ Return(ports.PendingIssuePage{NextPageToken: "n"}, nil).Once()
77+ got, err := u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageToken: "t"})
78+ require.NoError(t, err)
79+ assert.Equal(t, "n", got.NextPageToken)
80+ _, err = u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageSize: -1})
81+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
82+}
83+
84+func TestIssueModerationApprove(t *testing.T) {
85+ t.Parallel()
86+ ctx := context.Background()
87+ t.Run("publishes IssueOpened after the commit", func(t *testing.T) {
88+ t.Parallel()
89+ u := newModerationUnit(t, domain.IssuesPublic)
90+ issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
91+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
92+ u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
93+ got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
94+ require.NoError(t, err)
95+ assert.Equal(t, int64(7), got.Number)
96+ })
97+ t.Run("issues off hides the repository", func(t *testing.T) {
98+ t.Parallel()
99+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
100+ u := newModerationUnit(t, mode)
101+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
102+ assert.ErrorIs(t, err, domain.ErrNotFound)
103+ }
104+ })
105+ t.Run("no event when the store refuses", func(t *testing.T) {
106+ t.Parallel()
107+ u := newModerationUnit(t, domain.IssuesOwner)
108+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(domain.Issue{}, domain.ErrNotFound).Once()
109+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
110+ assert.ErrorIs(t, err, domain.ErrNotFound)
111+ })
112+}
113+
114+func TestIssueModerationReject(t *testing.T) {
115+ t.Parallel()
116+ ctx := context.Background()
117+ hash := bytes.Repeat([]byte{3}, domain.IPHashLen)
118+ t.Run("with a ban", func(t *testing.T) {
119+ t.Parallel()
120+ u := newModerationUnit(t, domain.IssuesPublic)
121+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, Repo: u.repo.Name, IPHash: hash}, nil).Once()
122+ u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once()
123+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
124+ u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
125+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true))
126+ })
127+ t.Run("without a ban", func(t *testing.T) {
128+ t.Parallel()
129+ u := newModerationUnit(t, domain.IssuesPublic)
130+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
131+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
132+ u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
133+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false))
134+ })
135+ t.Run("unknown id", func(t *testing.T) {
136+ t.Parallel()
137+ u := newModerationUnit(t, domain.IssuesPublic)
138+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once()
139+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound)
140+ })
141+ t.Run("a failed ban keeps the pending issue", func(t *testing.T) {
142+ t.Parallel()
143+ u := newModerationUnit(t, domain.IssuesPublic)
144+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
145+ u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once()
146+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError)
147+ })
148+}
149+
150+func TestIssueKeysSubkeys(t *testing.T) {
151+ t.Parallel()
152+ ctx := context.Background()
153+ master := bytes.Repeat([]byte{0xAB}, 32)
154+ store := mocks.NewIssueKeyStore(t)
155+ store.EXPECT().Key(mock.Anything).Return(master, nil)
156+ k1, err := usecase.NewIssueKeys(ctx, store)
157+ require.NoError(t, err)
158+ k2, err := usecase.NewIssueKeys(ctx, store)
159+ require.NoError(t, err)
160+
161+ assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable")
162+ assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey())
163+ assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4"))
164+
165+ assert.Len(t, k1.PoWKey(), 32)
166+ assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose")
167+ assert.NotEqual(t, master, k1.PoWKey())
168+ assert.NotEqual(t, master, k1.FormTokenKey())
169+
170+ h := k1.IPHash("1.2.3.4")
171+ assert.Len(t, h, domain.IPHashLen)
172+ assert.NotEqual(t, h, k1.IPHash("1.2.3.5"))
173+ assert.NotContains(t, string(h), "1.2.3.4")
174+
175+ other := mocks.NewIssueKeyStore(t)
176+ other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil)
177+ k3, err := usecase.NewIssueKeys(ctx, other)
178+ require.NoError(t, err)
179+ assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash")
180+
181+ // The returned keys are copies.
182+ pow := k1.PoWKey()
183+ pow[0] ^= 0xFF
184+ assert.NotEqual(t, pow, k1.PoWKey())
185+}
186+
187+func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) {
188+ t.Parallel()
189+ synctest.Test(t, func(t *testing.T) {
190+ r := newEventRig(t, 16)
191+ a := r.repo("aaa", domain.VisibilityPublic)
192+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
193+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
194+ ctx, cancel := context.WithCancel(context.Background())
195+ defer cancel()
196+
197+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
198+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
199+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
200+ rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
201+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
202+ synctest.Wait()
203+
204+ r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now}
205+ // A sentinel every watcher sees, to prove the first one was skipped.
206+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
207+
208+ sub, ok := owner.next(t).Event.(domain.IssueSubmitted)
209+ require.True(t, ok, "the owner topic gets it")
210+ assert.Equal(t, a, sub.Repo)
211+ _, ok = owner.next(t).Event.(domain.IssueOpened)
212+ require.True(t, ok)
213+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} {
214+ e, ok := w.next(t).Event.(domain.IssueOpened)
215+ require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e)
216+ }
217+ })
218+}
219+
220+func TestIssuePendingRemovedReachesOnlyOwnerTopics(t *testing.T) {
221+ t.Parallel()
222+ synctest.Test(t, func(t *testing.T) {
223+ r := newEventRig(t, 16)
224+ a := r.repo("aaa", domain.VisibilityPublic)
225+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
226+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
227+ ctx, cancel := context.WithCancel(context.Background())
228+ defer cancel()
229+
230+ owner1 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
231+ owner2 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
232+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
233+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
234+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
235+ synctest.Wait()
236+
237+ r.feed <- domain.IssuePendingRemoved{Repo: a, ID: "x", At: now}
238+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
239+
240+ for name, w := range map[string]*watcher{"owner 1": owner1, "owner 2": owner2} {
241+ _, ok := w.next(t).Event.(domain.IssuePendingRemoved)
242+ require.True(t, ok, "%s gets the refresh", name)
243+ _, ok = w.next(t).Event.(domain.IssueOpened)
244+ require.True(t, ok)
245+ }
246+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "anonymous": anon} {
247+ e, ok := w.next(t).Event.(domain.IssueOpened)
248+ require.True(t, ok, "%s: the first event is the sentinel (got %T)", name, e)
249+ }
250+ })
251+}
A internal/usecase/issue_pow_vector_test.go +39 -0
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "encoding/base64"
7+ "strconv"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// The proof-of-work vector shared with the browser solver: the same nonce,
15+// difficulty and smallest solution are asserted in
16+// internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both.
17+const (
18+ powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding
19+ powVectorDifficulty = 12
20+ powVectorSolution = "2491"
21+)
22+
23+func TestPowVector(t *testing.T) {
24+ nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce)
25+ require.NoError(t, err)
26+ require.Len(t, nonce, nonceLen)
27+
28+ first := ""
29+ for n := 0; n < 1<<22; n++ {
30+ s := strconv.Itoa(n)
31+ if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty {
32+ first = s
33+ break
34+ }
35+ }
36+ require.NotEmpty(t, first)
37+ assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector")
38+ assert.True(t, validSolution(first))
39+}
A internal/usecase/issue_submissions.go +383 -0
@@ -0,0 +1,383 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hmac"
8+ "crypto/rand"
9+ "crypto/sha256"
10+ "encoding/base64"
11+ "encoding/json"
12+ "errors"
13+ "fmt"
14+ "math/bits"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+)
24+
25+// Parameters of the anonymous submission (ADR 0031).
26+const (
27+ // baseDifficulty is the proof-of-work of a client that has not submitted
28+ // lately, in leading zero bits of SHA-256: 2^18 hashes on average, about
29+ // one second for a SHA-256 in JavaScript on a mid-range phone.
30+ baseDifficulty = 18
31+ // maxDifficulty caps the adaptive proof-of-work: about 8 to 10 seconds.
32+ maxDifficulty = 21
33+ // perAddrStep and globalStep: one more bit per that many submissions of the
34+ // last hour from the client, and from everyone.
35+ perAddrStep = 2
36+ globalStep = 20
37+
38+ // challengeTTL is how long a challenge may be solved and sent.
39+ challengeTTL = 10 * time.Minute
40+ // minFillTime is the least time between the challenge and the submission:
41+ // a human cannot write an issue faster.
42+ minFillTime = 3 * time.Second
43+ // maxSpent bounds the nonces remembered as spent. Past it submissions are
44+ // paused rather than a live nonce forgotten.
45+ maxSpent = 100_000
46+
47+ tokenVersion = 1
48+ nonceLen = 16
49+ maxChallengeLen = 1024
50+ maxSolutionLen = 20
51+ spentSweep = time.Minute
52+)
53+
54+// difficultyFor is the proof-of-work for a client with perAddr submissions in
55+// the last hour among global ones: base, plus a bit per perAddrStep and per
56+// globalStep, at most limit. It decays as the sliding window empties.
57+func difficultyFor(base, limit, perAddr, global int) int {
58+ return min(base+max(perAddr, 0)/perAddrStep+max(global, 0)/globalStep, limit)
59+}
60+
61+// leadingZeroBits counts the zero bits at the start of sum.
62+func leadingZeroBits(sum []byte) int {
63+ n := 0
64+ for _, b := range sum {
65+ if b == 0 {
66+ n += 8
67+ continue
68+ }
69+ return n + bits.LeadingZeros8(b)
70+ }
71+ return n
72+}
73+
74+// powHash is SHA-256(nonce || ":" || solution).
75+func powHash(nonce []byte, solution string) []byte {
76+ h := sha256.New()
77+ h.Write(nonce)
78+ h.Write([]byte(":"))
79+ h.Write([]byte(solution))
80+ return h.Sum(nil)
81+}
82+
83+// challengeClaims is the signed payload of a challenge token.
84+type challengeClaims struct {
85+ V int `json:"v"`
86+ Repo string `json:"repo"`
87+ Nonce []byte `json:"nonce"`
88+ Difficulty int `json:"d"`
89+ IssuedAt int64 `json:"iat"`
90+ ExpiresAt int64 `json:"exp"`
91+ IP []byte `json:"ip"`
92+}
93+
94+// IssueSubmissions is the anonymous submission of issues: a signed challenge
95+// with an adaptive proof of work, and every server-side check before the issue
96+// awaits moderation.
97+type IssueSubmissions struct {
98+ meta ports.RepoMetaStore
99+ pending ports.PendingIssueStore
100+ bans ports.IssueBanStore
101+ limiter ports.SubmissionLimiter
102+ keys *IssueKeys
103+ bus ports.EventBus
104+ clock ports.Clock
105+ base int
106+ limit int
107+ minFill time.Duration
108+
109+ admitMu sync.Mutex // serializes the pending cap check and the store
110+
111+ mu sync.Mutex
112+ spent map[string]time.Time // nonce -> expiry of its challenge
113+ nextSweep time.Time
114+}
115+
116+var _ ports.IssueSubmissionService = (*IssueSubmissions)(nil)
117+
118+// IssueSubmissionsConfig holds what the submission needs. Every field but the
119+// difficulties is required.
120+type IssueSubmissionsConfig struct {
121+ Meta ports.RepoMetaStore
122+ Pending ports.PendingIssueStore
123+ Bans ports.IssueBanStore
124+ Limiter ports.SubmissionLimiter
125+ Keys *IssueKeys
126+ Bus ports.EventBus
127+ Clock ports.Clock
128+ // BaseDifficulty and MaxDifficulty replace the production constants; for
129+ // tests. Zero keeps the constants.
130+ BaseDifficulty, MaxDifficulty int
131+ // MinFillTime replaces minFillTime when positive; for tests.
132+ MinFillTime time.Duration
133+}
134+
135+// NewIssueSubmissions builds the submission use case.
136+func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
137+ s := &IssueSubmissions{
138+ meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
139+ keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
140+ base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime,
141+ spent: make(map[string]time.Time),
142+ }
143+ if cfg.BaseDifficulty > 0 {
144+ s.base = cfg.BaseDifficulty
145+ }
146+ if cfg.MaxDifficulty > 0 {
147+ s.limit = cfg.MaxDifficulty
148+ }
149+ if cfg.MinFillTime > 0 {
150+ s.minFill = cfg.MinFillTime
151+ }
152+ return s
153+}
154+
155+// openRepo returns the repository if anyone may submit to it, else
156+// domain.ErrNotFound.
157+func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) error {
158+ r, err := lookupRepo(ctx, s.meta, repo)
159+ if err != nil {
160+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrInvalidName) {
161+ return domain.ErrNotFound
162+ }
163+ return err
164+ }
165+ if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {
166+ return domain.ErrNotFound
167+ }
168+ return nil
169+}
170+
171+func (s *IssueSubmissions) sign(key, msg []byte) []byte {
172+ m := hmac.New(sha256.New, key)
173+ m.Write(msg)
174+ return m.Sum(nil)
175+}
176+
177+// Challenge issues a challenge for a client of the repository.
178+func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
179+ if err := ctx.Err(); err != nil {
180+ return ports.IssueChallenge{}, err
181+ }
182+ if err := s.openRepo(ctx, repo); err != nil {
183+ return ports.IssueChallenge{}, err
184+ }
185+ nonce := make([]byte, nonceLen)
186+ if _, err := rand.Read(nonce); err != nil {
187+ return ports.IssueChallenge{}, fmt.Errorf("random nonce: %w", err)
188+ }
189+ perAddr, global := s.limiter.Recent(ctx, addr)
190+ d := difficultyFor(s.base, s.limit, perAddr, global)
191+ now := s.clock.Now().UTC()
192+ claims := challengeClaims{
193+ V: tokenVersion, Repo: repo.String(), Nonce: nonce, Difficulty: d,
194+ IssuedAt: now.Unix(), ExpiresAt: now.Add(challengeTTL).Unix(), IP: s.keys.IPHash(addr),
195+ }
196+ payload, err := json.Marshal(claims)
197+ if err != nil {
198+ return ports.IssueChallenge{}, fmt.Errorf("encode challenge: %w", err)
199+ }
200+ return ports.IssueChallenge{
201+ Token: enc64(payload) + "." + enc64(s.sign(s.keys.PoWKey(), payload)),
202+ Nonce: enc64(nonce),
203+ Difficulty: d,
204+ FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
205+ IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
206+ MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(s.minFill),
207+ ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
208+ }, nil
209+}
210+
211+// parseToken verifies the signature and returns the claims, or false.
212+func (s *IssueSubmissions) parseToken(tok string) (challengeClaims, bool) {
213+ var c challengeClaims
214+ if len(tok) > maxChallengeLen {
215+ return c, false
216+ }
217+ p, sig, ok := strings.Cut(tok, ".")
218+ if !ok {
219+ return c, false
220+ }
221+ payload, err := base64.RawURLEncoding.DecodeString(p)
222+ if err != nil {
223+ return c, false
224+ }
225+ got, err := base64.RawURLEncoding.DecodeString(sig)
226+ if err != nil || !hmac.Equal(got, s.sign(s.keys.PoWKey(), payload)) {
227+ return c, false
228+ }
229+ if json.Unmarshal(payload, &c) != nil {
230+ return c, false
231+ }
232+ return c, true
233+}
234+
235+func validSolution(sol string) bool {
236+ if len(sol) < 1 || len(sol) > maxSolutionLen {
237+ return false
238+ }
239+ for i := 0; i < len(sol); i++ {
240+ if sol[i] < '0' || sol[i] > '9' {
241+ return false
242+ }
243+ }
244+ return true
245+}
246+
247+// spend marks the nonce spent. It returns domain.ErrSubmissionInvalid for a
248+// nonce already spent and domain.ErrSubmissionsPaused when the set is full.
249+func (s *IssueSubmissions) spend(nonce []byte, exp, now time.Time) error {
250+ s.mu.Lock()
251+ defer s.mu.Unlock()
252+ if len(s.spent) >= maxSpent || !now.Before(s.nextSweep) {
253+ for k, e := range s.spent {
254+ if now.After(e) { // an expired challenge is refused anyway
255+ delete(s.spent, k)
256+ }
257+ }
258+ s.nextSweep = now.Add(spentSweep)
259+ }
260+ key := string(nonce)
261+ if _, dup := s.spent[key]; dup {
262+ return domain.ErrSubmissionInvalid
263+ }
264+ if len(s.spent) >= maxSpent {
265+ return domain.ErrSubmissionsPaused
266+ }
267+ s.spent[key] = exp
268+ return nil
269+}
270+
271+// Submit checks the submission and stores it as a pending issue. The checks
272+// run in a fixed order and stop at the first failure; every bot-related
273+// failure answers domain.ErrSubmissionInvalid, and the precise reason is only
274+// logged (without address, hash or text).
275+func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
276+ if err := ctx.Err(); err != nil {
277+ return err
278+ }
279+ log := zerowrap.FromCtx(ctx)
280+ refuse := func(reason string, err error) error {
281+ log.Info().Str("repo", repo.String()).Str("reason", reason).Msg("issue submission refused")
282+ return err
283+ }
284+ if err := s.openRepo(ctx, repo); err != nil {
285+ return err
286+ }
287+ ipHash := s.keys.IPHash(addr)
288+ now := s.clock.Now().UTC()
289+ if f.Honeypot != "" {
290+ return refuse("honeypot", domain.ErrSubmissionInvalid)
291+ }
292+ c, ok := s.parseToken(f.Token)
293+ if !ok {
294+ return refuse("bad_token", domain.ErrSubmissionInvalid)
295+ }
296+ exp := time.Unix(c.ExpiresAt, 0)
297+ switch {
298+ case c.V != tokenVersion || len(c.Nonce) != nonceLen:
299+ return refuse("bad_token", domain.ErrSubmissionInvalid)
300+ case c.Repo != repo.String():
301+ return refuse("token_other_repo", domain.ErrSubmissionInvalid)
302+ case now.After(exp):
303+ return refuse("token_expired", domain.ErrSubmissionInvalid)
304+ case !hmac.Equal(c.IP, ipHash):
305+ return refuse("token_other_client", domain.ErrSubmissionInvalid)
306+ }
307+ if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
308+ return refuse("bad_form_token", domain.ErrSubmissionInvalid)
309+ }
310+ if now.Before(time.Unix(c.IssuedAt, 0).Add(s.minFill)) {
311+ return refuse("too_fast", domain.ErrSubmissionInvalid)
312+ }
313+ if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||
314+ leadingZeroBits(powHash(c.Nonce, f.Solution)) < c.Difficulty {
315+ return refuse("bad_pow", domain.ErrSubmissionInvalid)
316+ }
317+ if err := s.spend(c.Nonce, exp, now); err != nil {
318+ if errors.Is(err, domain.ErrSubmissionsPaused) {
319+ return refuse("spent_full", err)
320+ }
321+ return refuse("replayed", err)
322+ }
323+ banned, err := s.bans.Banned(ctx, ipHash, now)
324+ if err != nil {
325+ return fmt.Errorf("check ban: %w", err)
326+ }
327+ if banned {
328+ return refuse("banned", domain.ErrSubmissionInvalid)
329+ }
330+ title, err := domain.ParseIssueTitle(f.Title)
331+ if err != nil {
332+ return refuse("text_title", fmt.Errorf("title: %w", err))
333+ }
334+ body, err := domain.ParseIssueBody(f.Body)
335+ if err != nil {
336+ return refuse("text_body", fmt.Errorf("body: %w", err))
337+ }
338+ author, err := domain.ParseAuthorLabel(f.Author)
339+ if err != nil {
340+ return refuse("text_author", fmt.Errorf("author: %w", err))
341+ }
342+ id, err := domain.NewPendingIssueID()
343+ if err != nil {
344+ return fmt.Errorf("new pending issue id: %w", err)
345+ }
346+ if err := s.admit(ctx, addr, refuse, domain.PendingIssue{
347+ ID: id, Repo: repo, Title: title, Body: body, Author: author, CreatedAt: now, IPHash: ipHash,
348+ }); err != nil {
349+ return err
350+ }
351+ log.Info().Str("repo", repo.String()).Msg("issue submitted for moderation")
352+ s.bus.Publish(ctx, domain.IssueSubmitted{Repo: repo, ID: id, At: now})
353+ return nil
354+}
355+
356+// admit reserves the client's quota, checks the global queue cap and stores
357+// the issue. Ermite is a single process (ADR 0001), so admitMu serializes the
358+// Count -> Put section: concurrent submissions cannot exceed
359+// domain.MaxPendingIssues. It would not hold with several processes.
360+func (s *IssueSubmissions) admit(ctx context.Context, addr string, refuse func(string, error) error, p domain.PendingIssue) error {
361+ s.admitMu.Lock()
362+ defer s.admitMu.Unlock()
363+ ok, after, release := s.limiter.Reserve(ctx, addr)
364+ if !ok {
365+ return refuse("rate_limited", &domain.SubmissionRetryError{After: after})
366+ }
367+ n, err := s.pending.Count(ctx)
368+ if err != nil {
369+ release()
370+ return fmt.Errorf("count pending issues: %w", err)
371+ }
372+ if n >= domain.MaxPendingIssues {
373+ release()
374+ return refuse("pending_full", domain.ErrSubmissionsPaused)
375+ }
376+ if err := s.pending.Put(ctx, p); err != nil {
377+ release()
378+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
379+ }
380+ return nil
381+}
382+
383+func enc64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
A internal/usecase/issue_submissions_test.go +670 -0
@@ -0,0 +1,670 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "errors"
10+ "math/bits"
11+ "strconv"
12+ "strings"
13+ "sync"
14+ "sync/atomic"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/ports/mocks"
25+ "bnema.dev/ermite/internal/usecase"
26+)
27+
28+const testBits = 8
29+
30+type subFixture struct {
31+ t *testing.T
32+ meta *mocks.RepoMetaStore
33+ pending *mocks.PendingIssueStore
34+ bans *mocks.IssueBanStore
35+ limiter *mocks.SubmissionLimiter
36+ bus *mocks.EventBus
37+ uc *usecase.IssueSubmissions
38+ repo domain.Repo
39+ addr string
40+
41+ mu sync.Mutex
42+ now time.Time
43+}
44+
45+func (f *subFixture) advance(d time.Duration) {
46+ f.mu.Lock()
47+ defer f.mu.Unlock()
48+ f.now = f.now.Add(d)
49+}
50+
51+func newSubFixture(t *testing.T) *subFixture {
52+ t.Helper()
53+ f := &subFixture{
54+ t: t, meta: mocks.NewRepoMetaStore(t), pending: mocks.NewPendingIssueStore(t),
55+ bans: mocks.NewIssueBanStore(t), limiter: mocks.NewSubmissionLimiter(t), bus: mocks.NewEventBus(t),
56+ addr: "ip:203.0.113.7", now: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC),
57+ }
58+ f.repo = domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: f.now, Settings: withIssues(domain.IssuesPublic)}
59+ f.meta.EXPECT().Get(mock.Anything, f.repo.Name).Return(f.repo, nil).Maybe()
60+ clock := mocks.NewClock(t)
61+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
62+ f.mu.Lock()
63+ defer f.mu.Unlock()
64+ return f.now
65+ }).Maybe()
66+ keyStore := mocks.NewIssueKeyStore(t)
67+ keyStore.EXPECT().Key(mock.Anything).Return([]byte(strings.Repeat("k", 32)), nil)
68+ keys, err := usecase.NewIssueKeys(context.Background(), keyStore)
69+ require.NoError(t, err)
70+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
71+ Meta: f.meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Keys: keys, Bus: f.bus, Clock: clock,
72+ BaseDifficulty: testBits, MaxDifficulty: testBits + 3,
73+ })
74+ return f
75+}
76+
77+// open expects the checks after the proof of work: not banned, quota
78+// available, room. It returns the number of reservations released.
79+func (f *subFixture) open(pendingCount int) *atomic.Int32 {
80+ released := new(atomic.Int32)
81+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
82+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
83+ return true, 0, func() { released.Add(1) }
84+ }).Maybe()
85+ f.pending.EXPECT().Count(mock.Anything).Return(pendingCount, nil).Maybe()
86+ return released
87+}
88+
89+func (f *subFixture) challenge(addr string) ports.IssueChallenge {
90+ f.t.Helper()
91+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
92+ ch, err := f.uc.Challenge(context.Background(), f.repo.Name, addr)
93+ require.NoError(f.t, err)
94+ return ch
95+}
96+
97+func solve(t *testing.T, ch ports.IssueChallenge) string {
98+ t.Helper()
99+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
100+ require.NoError(t, err)
101+ for i := 0; ; i++ {
102+ s := strconv.Itoa(i)
103+ sum := sha256.Sum256(append(append(append([]byte(nil), nonce...), ':'), s...))
104+ if zeroBits(sum[:]) >= ch.Difficulty {
105+ return s
106+ }
107+ }
108+}
109+
110+func zeroBits(b []byte) int {
111+ n := 0
112+ for _, x := range b {
113+ if x == 0 {
114+ n += 8
115+ continue
116+ }
117+ return n + bits.LeadingZeros8(x)
118+ }
119+ return n
120+}
121+
122+// valid is a submission that passes every check once the fill time is over.
123+func (f *subFixture) valid(ch ports.IssueChallenge) ports.IssueSubmission {
124+ return ports.IssueSubmission{
125+ Token: ch.Token, Solution: solve(f.t, ch), FormToken: ch.FormToken,
126+ Title: "A bug", Body: "It breaks.", Author: "me",
127+ }
128+}
129+
130+func TestIssueChallenge(t *testing.T) {
131+ t.Parallel()
132+ f := newSubFixture(t)
133+ ch := f.challenge(f.addr)
134+ assert.Equal(t, testBits, ch.Difficulty)
135+ assert.Equal(t, f.now, ch.IssuedAt)
136+ assert.Equal(t, f.now.Add(usecase.MinFillTime), ch.MinSubmitAt)
137+ assert.Equal(t, f.now.Add(usecase.ChallengeTTL), ch.ExpiresAt)
138+ assert.Contains(t, ch.Token, ".")
139+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
140+ require.NoError(t, err)
141+ assert.Len(t, nonce, 16)
142+ assert.NotEmpty(t, ch.FormToken)
143+
144+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(4, 40).Once() // +2 +2
145+ ch, err = f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
146+ require.NoError(t, err)
147+ assert.Equal(t, testBits+3, ch.Difficulty) // capped at base+3
148+}
149+
150+func TestIssueSubmissionRepoAccess(t *testing.T) {
151+ t.Parallel()
152+ for name_, repo := range map[string]domain.Repo{
153+ "private": {Visibility: domain.VisibilityPrivate, Settings: withIssues(domain.IssuesPublic)},
154+ "owner issues": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOwner)},
155+ "issues off": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOff)},
156+ } {
157+ t.Run(name_, func(t *testing.T) {
158+ t.Parallel()
159+ f := newSubFixture(t)
160+ meta := mocks.NewRepoMetaStore(t)
161+ repo.Name = f.repo.Name
162+ meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
163+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Bus: f.bus, Clock: mocks.NewClock(t)})
164+ _, err := f.uc.Challenge(context.Background(), repo.Name, f.addr)
165+ assert.ErrorIs(t, err, domain.ErrNotFound)
166+ err = f.uc.Submit(context.Background(), repo.Name, f.addr, ports.IssueSubmission{})
167+ assert.ErrorIs(t, err, domain.ErrNotFound)
168+ })
169+ }
170+ t.Run("unknown", func(t *testing.T) {
171+ t.Parallel()
172+ f := newSubFixture(t)
173+ other := name(t, "other")
174+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{}, domain.ErrNotFound)
175+ assert.ErrorIs(t, f.uc.Submit(context.Background(), other, f.addr, ports.IssueSubmission{}), domain.ErrNotFound)
176+ _, err := f.uc.Challenge(context.Background(), other, f.addr)
177+ assert.ErrorIs(t, err, domain.ErrNotFound)
178+ })
179+}
180+
181+func TestIssueSubmitSuccess(t *testing.T) {
182+ t.Parallel()
183+ f := newSubFixture(t)
184+ f.open(0)
185+ ch := f.challenge(f.addr)
186+ sub := f.valid(ch)
187+ f.advance(usecase.MinFillTime)
188+ var put domain.PendingIssue
189+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.PendingIssue) error {
190+ put = p
191+ return nil
192+ }).Once()
193+ f.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
194+ e, ok := ev.(domain.IssueSubmitted)
195+ return ok && e.Repo == f.repo.Name && e.ID == put.ID
196+ })).Once()
197+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
198+ assert.Equal(t, "A bug", put.Title.String())
199+ assert.Equal(t, "It breaks.", put.Body.String())
200+ assert.Equal(t, "me", put.Author)
201+ assert.Equal(t, f.repo.Name, put.Repo)
202+ assert.Len(t, put.IPHash, domain.IPHashLen)
203+ assert.False(t, strings.Contains(string(put.IPHash), f.addr))
204+ assert.Equal(t, f.now, put.CreatedAt)
205+}
206+
207+// TestIssueSubmitRefusals: each failing check returns its sentinel; the
208+// strict mocks fail the test on any Put, Record or Publish.
209+func TestIssueSubmitRefusals(t *testing.T) {
210+ t.Parallel()
211+ type tc struct {
212+ name string
213+ want error
214+ arrange func(f *subFixture, ch *ports.IssueChallenge, sub *ports.IssueSubmission) (addr string)
215+ wait time.Duration
216+ setup func(f *subFixture) // replaces f.open
217+ }
218+ wait := usecase.MinFillTime
219+ cases := []tc{
220+ {name: "banned", want: domain.ErrSubmissionInvalid, wait: wait, setup: func(f *subFixture) {
221+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
222+ }},
223+ {name: "rate limited", want: domain.ErrSubmissionRateLimited, wait: wait, setup: func(f *subFixture) {
224+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
225+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(false, 5*time.Minute, func() {})
226+ }},
227+ {name: "paused at 200", want: domain.ErrSubmissionsPaused, wait: wait, setup: func(f *subFixture) {
228+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
229+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {})
230+ f.pending.EXPECT().Count(mock.Anything).Return(domain.MaxPendingIssues, nil)
231+ }},
232+ {name: "honeypot", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
233+ s.Honeypot = "http://spam"
234+ return ""
235+ }},
236+ {name: "forged signature", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
237+ p, sig, _ := strings.Cut(s.Token, ".")
238+ b := []byte(sig)
239+ b[0] ^= 1 // still base64url? flip between 'A'/'B'
240+ if b[0] > 'z' || b[0] < '0' {
241+ b[0] = 'A'
242+ }
243+ if string(b) == sig {
244+ b[0] = 'B'
245+ }
246+ s.Token = p + "." + string(b)
247+ return ""
248+ }},
249+ {name: "forged payload", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
250+ _, sig, _ := strings.Cut(s.Token, ".")
251+ s.Token = base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"d":1}`)) + "." + sig
252+ return ""
253+ }},
254+ {name: "token of another repo", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
255+ // a valid token for "other", sent to "proj"
256+ other := name(f.t, "other")
257+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil)
258+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(0, 0).Once()
259+ c2, err := f.uc.Challenge(context.Background(), other, f.addr)
260+ require.NoError(f.t, err)
261+ *s = f.valid(c2)
262+ return ""
263+ }},
264+ {name: "expired", want: domain.ErrSubmissionInvalid, wait: usecase.ChallengeTTL + time.Second},
265+ {name: "other ip", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, _ *ports.IssueChallenge, _ *ports.IssueSubmission) string {
266+ return "ip:198.51.100.9"
267+ }},
268+ {name: "too fast", want: domain.ErrSubmissionInvalid, wait: wait - time.Second},
269+ {name: "wrong form token", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
270+ s.FormToken = "AAAA"
271+ return ""
272+ }},
273+ {name: "insufficient pow", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
274+ // a counter that does not solve it
275+ nonce, _ := base64.RawURLEncoding.DecodeString(ch.Nonce)
276+ for i := 0; ; i++ {
277+ sum := sha256.Sum256(append(append(nonce, ':'), strconv.Itoa(i)...))
278+ if zeroBits(sum[:]) < ch.Difficulty {
279+ s.Solution = strconv.Itoa(i)
280+ return ""
281+ }
282+ }
283+ }},
284+ {name: "non digit solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
285+ s.Solution = "12a"
286+ return ""
287+ }},
288+ {name: "empty solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
289+ s.Solution = ""
290+ return ""
291+ }},
292+ {name: "huge solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
293+ s.Solution = strings.Repeat("1", 21)
294+ return ""
295+ }},
296+ }
297+ for _, c := range cases {
298+ t.Run(c.name, func(t *testing.T) {
299+ t.Parallel()
300+ f := newSubFixture(t)
301+ if c.setup != nil {
302+ c.setup(f)
303+ } else {
304+ f.open(0)
305+ }
306+ ch := f.challenge(f.addr)
307+ sub := f.valid(ch)
308+ addr := f.addr
309+ if c.arrange != nil {
310+ if a := c.arrange(f, &ch, &sub); a != "" {
311+ addr = a
312+ }
313+ }
314+ f.advance(c.wait)
315+ err := f.uc.Submit(context.Background(), f.repo.Name, addr, sub)
316+ require.ErrorIs(t, err, c.want)
317+ assert.ErrorIs(t, err, domain.ErrSubmissionRefused)
318+ if c.want == domain.ErrSubmissionRateLimited {
319+ var re *domain.SubmissionRetryError
320+ require.ErrorAs(t, err, &re)
321+ assert.Equal(t, 5*time.Minute, re.After)
322+ }
323+ })
324+ }
325+}
326+
327+func TestIssueSubmitReplayedNonce(t *testing.T) {
328+ t.Parallel()
329+ f := newSubFixture(t)
330+ f.open(0)
331+ ch := f.challenge(f.addr)
332+ sub := f.valid(ch)
333+ f.advance(usecase.MinFillTime)
334+ // first: text refused, nonce is spent anyway (nothing reserved)
335+ bad := sub
336+ bad.Title = ""
337+ var te *domain.TextError
338+ require.ErrorAs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, bad), &te)
339+ assert.Equal(t, 1, f.uc.SpentLen())
340+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
341+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
342+ assert.False(t, errors.Is(err, domain.ErrInvalidText))
343+}
344+
345+func TestIssueSubmitReplayAfterSuccess(t *testing.T) {
346+ t.Parallel()
347+ f := newSubFixture(t)
348+ f.open(0)
349+ ch := f.challenge(f.addr)
350+ sub := f.valid(ch)
351+ f.advance(usecase.MinFillTime)
352+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
353+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
354+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
355+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionInvalid)
356+}
357+
358+func TestIssueSubmitTextErrors(t *testing.T) {
359+ t.Parallel()
360+ for name_, mod := range map[string]func(*ports.IssueSubmission){
361+ "title": func(s *ports.IssueSubmission) { s.Title = "two\nlines" },
362+ "body": func(s *ports.IssueSubmission) { s.Body = strings.Repeat("x", domain.MaxIssueBodyLen+1) },
363+ "author": func(s *ports.IssueSubmission) { s.Author = strings.Repeat("x", domain.MaxAuthorLabelLen+1) },
364+ } {
365+ t.Run(name_, func(t *testing.T) {
366+ t.Parallel()
367+ f := newSubFixture(t)
368+ f.open(0)
369+ ch := f.challenge(f.addr)
370+ sub := f.valid(ch)
371+ mod(&sub)
372+ f.advance(usecase.MinFillTime)
373+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
374+ var te *domain.TextError
375+ require.ErrorAs(t, err, &te)
376+ assert.NotErrorIs(t, err, domain.ErrSubmissionRefused)
377+ })
378+ }
379+}
380+
381+func TestIssueSubmitSpentSetFull(t *testing.T) {
382+ t.Parallel()
383+ f := newSubFixture(t)
384+ f.open(0)
385+ ch := f.challenge(f.addr)
386+ sub := f.valid(ch)
387+ f.advance(usecase.MinFillTime)
388+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(time.Hour))
389+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
390+ assert.Equal(t, usecase.MaxSpent, f.uc.SpentLen()) // nothing live evicted
391+}
392+
393+func TestIssueSubmitSpentSetExpiry(t *testing.T) {
394+ t.Parallel()
395+ f := newSubFixture(t)
396+ f.open(0)
397+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Maybe()
398+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
399+ ch := f.challenge(f.addr)
400+ sub := f.valid(ch)
401+ f.advance(usecase.MinFillTime)
402+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
403+ assert.Equal(t, 1, f.uc.SpentLen())
404+ f.advance(usecase.ChallengeTTL + 2*time.Minute)
405+ ch2 := f.challenge(f.addr)
406+ sub2 := f.valid(ch2)
407+ f.advance(usecase.MinFillTime)
408+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub2))
409+ assert.Equal(t, 1, f.uc.SpentLen(), "the expired nonce was swept")
410+
411+ // a full set of expired nonces is swept, not a reason to pause
412+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(-time.Second))
413+ ch3 := f.challenge(f.addr)
414+ sub3 := f.valid(ch3)
415+ f.advance(usecase.MinFillTime)
416+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub3))
417+ assert.Equal(t, 2, f.uc.SpentLen(), "expired fillers swept, live nonces kept")
418+}
419+
420+func TestIssueSubmitGarbageDoesNotPanic(t *testing.T) {
421+ t.Parallel()
422+ f := newSubFixture(t)
423+ f.open(0)
424+ ch := f.challenge(f.addr)
425+ p, sig, _ := strings.Cut(ch.Token, ".")
426+ huge := strings.Repeat("A", 1<<20)
427+ tokens := []string{
428+ "", ".", "..", "a", "a.b", "!!!.???", huge, huge + "." + huge, p, p + ".", "." + sig,
429+ p + "." + sig + "." + sig, p + "." + sig[:len(sig)-3], p[:len(p)/2] + "." + sig,
430+ base64.RawURLEncoding.EncodeToString([]byte("null")) + "." + sig,
431+ base64.StdEncoding.EncodeToString([]byte("{}")) + "==." + sig, "\x00\xff.\x00",
432+ }
433+ f.advance(usecase.MinFillTime)
434+ for _, tok := range tokens {
435+ for _, s := range []ports.IssueSubmission{
436+ {Token: tok},
437+ {Token: tok, Solution: huge, FormToken: huge, Title: huge, Body: huge, Author: huge},
438+ {Token: tok, Solution: "0", FormToken: "\xff"},
439+ } {
440+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
441+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
442+ }
443+ }
444+ // the genuine token with garbage around it
445+ for _, s := range []ports.IssueSubmission{
446+ {Token: ch.Token, FormToken: huge, Solution: "1"},
447+ {Token: ch.Token, FormToken: ch.FormToken, Solution: huge},
448+ } {
449+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, s), domain.ErrSubmissionInvalid)
450+ }
451+}
452+
453+func TestIssueSubmissionHonoursContext(t *testing.T) {
454+ t.Parallel()
455+ f := newSubFixture(t)
456+ ctx, cancel := context.WithCancel(context.Background())
457+ cancel()
458+ assert.ErrorIs(t, f.uc.Submit(ctx, f.repo.Name, f.addr, ports.IssueSubmission{}), context.Canceled)
459+ _, err := f.uc.Challenge(ctx, f.repo.Name, f.addr)
460+ assert.ErrorIs(t, err, context.Canceled)
461+}
462+
463+func TestDifficulty(t *testing.T) {
464+ t.Parallel()
465+ for _, c := range []struct{ perAddr, global, want int }{
466+ {0, 0, 18}, {1, 0, 18}, {2, 0, 19}, {3, 0, 19}, {4, 0, 20}, {6, 0, 21}, {100, 0, 21},
467+ {0, 19, 18}, {0, 20, 19}, {0, 40, 20}, {0, 60, 21}, {0, 1000, 21},
468+ {2, 20, 20}, {4, 20, 21}, {-5, -5, 18},
469+ } {
470+ assert.Equal(t, c.want, usecase.DifficultyFor(c.perAddr, c.global), "%d/%d", c.perAddr, c.global)
471+ }
472+ assert.Equal(t, 18, usecase.BaseDifficulty)
473+ assert.Equal(t, 21, usecase.MaxDifficulty)
474+}
475+
476+// submitters builds n valid, distinct submissions (one challenge each) for the
477+// addresses addrOf(i) and the repositories repoOf(i), ready to be sent.
478+type prepared struct {
479+ repo domain.RepoName
480+ addr string
481+ sub ports.IssueSubmission
482+}
483+
484+func (f *subFixture) prepare(n int, addrOf func(int) string, repoOf func(int) domain.RepoName) []prepared {
485+ out := make([]prepared, n)
486+ for i := range n {
487+ addr := addrOf(i)
488+ repo := repoOf(i)
489+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
490+ ch, err := f.uc.Challenge(context.Background(), repo, addr)
491+ require.NoError(f.t, err)
492+ out[i] = prepared{repo: repo, addr: addr, sub: f.valid(ch)}
493+ }
494+ f.advance(usecase.MinFillTime)
495+ return out
496+}
497+
498+// race sends every prepared submission at once and returns the errors.
499+func (f *subFixture) race(ps []prepared) []error {
500+ errs := make([]error, len(ps))
501+ var wg sync.WaitGroup
502+ start := make(chan struct{})
503+ for i, p := range ps {
504+ wg.Add(1)
505+ go func() {
506+ defer wg.Done()
507+ <-start
508+ errs[i] = f.uc.Submit(context.Background(), p.repo, p.addr, p.sub)
509+ }()
510+ }
511+ close(start)
512+ wg.Wait()
513+ return errs
514+}
515+
516+func TestIssueSubmitConcurrentSameAddrRespectsQuota(t *testing.T) {
517+ t.Parallel()
518+ f := newSubFixture(t)
519+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
520+ f.pending.EXPECT().Count(mock.Anything).Return(0, nil).Maybe()
521+ // a real, atomic limiter semantics: PerHour grants then refusals
522+ var granted atomic.Int32
523+ var rlMu sync.Mutex
524+ f.limiter.EXPECT().Reserve(mock.Anything, f.addr).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
525+ rlMu.Lock()
526+ defer rlMu.Unlock()
527+ if granted.Load() >= 3 {
528+ return false, time.Minute, func() {}
529+ }
530+ granted.Add(1)
531+ return true, 0, func() {}
532+ }).Maybe()
533+ var puts atomic.Int32
534+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
535+ puts.Add(1)
536+ return nil
537+ }).Maybe()
538+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
539+ ps := f.prepare(20, func(int) string { return f.addr }, func(int) domain.RepoName { return f.repo.Name })
540+ var ok, limited int
541+ for _, err := range f.race(ps) {
542+ var re *domain.SubmissionRetryError
543+ switch {
544+ case err == nil:
545+ ok++
546+ case errors.As(err, &re):
547+ limited++
548+ default:
549+ t.Fatalf("unexpected error: %v", err)
550+ }
551+ }
552+ assert.Equal(t, 3, ok)
553+ assert.Equal(t, 17, limited)
554+ assert.Equal(t, int32(3), puts.Load())
555+}
556+
557+func TestIssueSubmitConcurrentRespectsPendingCap(t *testing.T) {
558+ t.Parallel()
559+ f := newSubFixture(t)
560+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
561+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
562+ other := name(t, "other")
563+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
564+ var count atomic.Int32
565+ count.Store(domain.MaxPendingIssues - 1)
566+ f.pending.EXPECT().Count(mock.Anything).RunAndReturn(func(context.Context) (int, error) {
567+ return int(count.Load()), nil
568+ }).Maybe()
569+ var puts atomic.Int32
570+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
571+ puts.Add(1)
572+ count.Add(1)
573+ return nil
574+ }).Maybe()
575+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
576+ ps := f.prepare(20, func(i int) string { return "ip:198.51.100." + strconv.Itoa(i+1) }, func(i int) domain.RepoName {
577+ if i%2 == 0 {
578+ return f.repo.Name
579+ }
580+ return other
581+ })
582+ var ok, paused int
583+ for _, err := range f.race(ps) {
584+ switch {
585+ case err == nil:
586+ ok++
587+ case errors.Is(err, domain.ErrSubmissionsPaused):
588+ paused++
589+ default:
590+ t.Fatalf("unexpected error: %v", err)
591+ }
592+ }
593+ assert.Equal(t, 1, ok)
594+ assert.Equal(t, 19, paused)
595+ assert.Equal(t, int32(1), puts.Load())
596+}
597+
598+func TestIssueSubmitReleasesTheReservationWhenStoreFails(t *testing.T) {
599+ t.Parallel()
600+ f := newSubFixture(t)
601+ released := f.open(0)
602+ boom := errors.New("disk full")
603+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(boom).Once()
604+ ch := f.challenge(f.addr)
605+ sub := f.valid(ch)
606+ f.advance(usecase.MinFillTime)
607+ require.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), boom)
608+ assert.Equal(t, int32(1), released.Load())
609+
610+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
611+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
612+ ch = f.challenge(f.addr)
613+ sub = f.valid(ch)
614+ f.advance(usecase.MinFillTime)
615+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
616+ assert.Equal(t, int32(1), released.Load(), "a stored submission keeps its reservation")
617+}
618+
619+func TestIssueSubmitReleasesTheReservationWhenPaused(t *testing.T) {
620+ t.Parallel()
621+ f := newSubFixture(t)
622+ released := f.open(domain.MaxPendingIssues)
623+ ch := f.challenge(f.addr)
624+ sub := f.valid(ch)
625+ f.advance(usecase.MinFillTime)
626+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
627+ assert.Equal(t, int32(1), released.Load())
628+}
629+
630+// TestIssueSubmitNoStateOracleBeforeProofOfWork: a banned client, a limited
631+// one, or a full queue all get the same answer to an invalid submission, and
632+// no limiter, count or store is touched (the mocks have no expectation).
633+func TestIssueSubmitNoStateOracleBeforeProofOfWork(t *testing.T) {
634+ t.Parallel()
635+ f := newSubFixture(t)
636+ ch := f.challenge(f.addr)
637+ valid := f.valid(ch)
638+ f.advance(usecase.MinFillTime)
639+ bad := []ports.IssueSubmission{
640+ {},
641+ {Token: "garbage", Solution: "x", FormToken: "y", Title: "", Body: ""},
642+ {Token: ch.Token, Honeypot: "x"},
643+ {Token: ch.Token, FormToken: ch.FormToken, Solution: "nope"},
644+ {Token: valid.Token, FormToken: valid.FormToken, Solution: "abc", Title: "two\nlines"}, // text error + bad pow
645+ }
646+ for _, s := range bad {
647+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
648+ require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
649+ assert.NotErrorIs(t, err, domain.ErrInvalidText)
650+ }
651+ // a fully valid submission from a banned client: still invalid, not
652+ // limited, paused or a text error, even with bad text.
653+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil).Once()
654+ bannedBadText := valid
655+ bannedBadText.Title = ""
656+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, bannedBadText)
657+ require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
658+ assert.NotErrorIs(t, err, domain.ErrInvalidText)
659+ var re *domain.SubmissionRetryError
660+ assert.False(t, errors.As(err, &re))
661+ assert.NotErrorIs(t, err, domain.ErrSubmissionsPaused)
662+}
663+
664+func TestIssueChallengeRevealsNoState(t *testing.T) {
665+ t.Parallel()
666+ f := newSubFixture(t) // no Banned, Reserve or Count expectation
667+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(50, 500).Once()
668+ _, err := f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
669+ require.NoError(t, err)
670+}
A internal/usecase/issues.go +291 -0
@@ -0,0 +1,291 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// MaxCommentsPerIssue is the most comments an issue holds. Past it AddComment
17+// fails with domain.ErrResourceExhausted. Comments are never deleted, so the
18+// limit counts the ones ever added.
19+const MaxCommentsPerIssue = 1000
20+
21+// Issues are the issue use cases (ADR 0031): open, comment on, close and
22+// reopen an issue. Issues share their numbers with the merge requests of the
23+// repository. A repository whose issues mode is off answers domain.ErrNotFound
24+// to everyone, the owner included, like a repository that does not exist.
25+// Reading needs the access of reading the repository (canRead), writing needs
26+// issue:write on it, which the branch prefixes of a token do not restrict:
27+// an issue writes no ref.
28+type Issues struct {
29+ meta ports.RepoMetaStore
30+ store ports.IssueStore
31+ mrs ports.MergeRequestStore
32+ bus ports.EventBus
33+ clock ports.Clock
34+}
35+
36+var (
37+ _ ports.IssueService = (*Issues)(nil)
38+ _ ports.IssueNumberResolver = (*Issues)(nil)
39+)
40+
41+// IssuesConfig holds what the issue use cases need. Every field is required.
42+type IssuesConfig struct {
43+ Meta ports.RepoMetaStore
44+ Store ports.IssueStore
45+ // MergeRequests tells which numbers name a merge request (Kind).
46+ MergeRequests ports.MergeRequestStore
47+ Bus ports.EventBus
48+ Clock ports.Clock
49+}
50+
51+// NewIssues builds the issue use cases.
52+func NewIssues(cfg IssuesConfig) *Issues {
53+ return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54+}
55+
56+// enabled looks the repository up and hides it when issues are off.
57+func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
58+ repo, err := lookupRepo(ctx, i.meta, name)
59+ if err != nil {
60+ return domain.Repo{}, err
61+ }
62+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
63+ return domain.Repo{}, domain.ErrNotFound
64+ }
65+ return repo, nil
66+}
67+
68+// readable applies the read rule: the access of reading the repository.
69+func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
70+ repo, err := i.enabled(ctx, name)
71+ if err != nil {
72+ return domain.Repo{}, err
73+ }
74+ if !canRead(p, repo) {
75+ return domain.Repo{}, domain.ErrNotFound
76+ }
77+ return repo, nil
78+}
79+
80+// writable is the write rule, as MergeRequests.writable: a principal that can
81+// neither read the repository nor holds the scope on it gets
82+// domain.ErrNotFound, one that cannot write the error of scopeAccess.
83+func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
84+ repo, err := i.enabled(ctx, name)
85+ if err != nil {
86+ return domain.Repo{}, err
87+ }
88+ if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) {
89+ return domain.Repo{}, domain.ErrNotFound
90+ }
91+ if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil {
92+ return domain.Repo{}, err
93+ }
94+ return repo, nil
95+}
96+
97+// Create opens an issue and publishes IssueOpened. The principal needs
98+// issue:write. Invalid texts give an *domain.InvalidFieldsError.
99+func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
100+ repo, err := i.writable(ctx, p, name)
101+ if err != nil {
102+ return domain.Issue{}, err
103+ }
104+ var v domain.Violations
105+ title, err := domain.ParseIssueTitle(params.Title)
106+ if err != nil {
107+ v.AddErr("title", err)
108+ }
109+ body, err := domain.ParseIssueBody(params.Body)
110+ if err != nil {
111+ v.AddErr("body", err)
112+ }
113+ if err := v.Err(); err != nil {
114+ return domain.Issue{}, err
115+ }
116+ now := i.clock.Now().UTC()
117+ issue, err := i.store.Create(ctx, domain.Issue{
118+ Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119+ Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
120+ })
121+ if err != nil {
122+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
123+ }
124+ log := zerowrap.FromCtx(ctx)
125+ log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128+ return issue, nil
129+}
130+
131+// List returns a page of the issues of a repository, highest number first.
132+func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
133+ size, err := pageSize(params.PageSize)
134+ if err != nil {
135+ return ports.IssuePage{}, err
136+ }
137+ if _, err := i.readable(ctx, p, name); err != nil {
138+ return ports.IssuePage{}, err
139+ }
140+ params.PageSize = size
141+ page, err := i.store.List(ctx, name, params)
142+ if err != nil {
143+ return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
144+ }
145+ return page, nil
146+}
147+
148+// Get returns an issue with its comments.
149+func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
150+ if _, err := i.readable(ctx, p, name); err != nil {
151+ return ports.IssueDetail{}, err
152+ }
153+ issue, err := i.store.Get(ctx, name, number)
154+ if err != nil {
155+ return ports.IssueDetail{}, err
156+ }
157+ comments, err := i.store.ListComments(ctx, name, number)
158+ if err != nil {
159+ return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
160+ }
161+ return ports.IssueDetail{Issue: issue, Comments: comments}, nil
162+}
163+
164+// AddComment adds a comment and publishes IssueCommented. The principal needs
165+// issue:write. The text must not be blank. A comment may follow a closed
166+// issue; one that already holds MaxCommentsPerIssue comments gives
167+// domain.ErrResourceExhausted.
168+func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
169+ repo, err := i.writable(ctx, p, name)
170+ if err != nil {
171+ return domain.IssueComment{}, err
172+ }
173+ issue, err := i.store.Get(ctx, name, number)
174+ if err != nil {
175+ return domain.IssueComment{}, err
176+ }
177+ var v domain.Violations
178+ body, err := domain.ParseIssueBody(text)
179+ switch {
180+ case err != nil:
181+ v.AddErr("text", err)
182+ case body.IsBlank():
183+ v.Add("text", "is required")
184+ }
185+ if err := v.Err(); err != nil {
186+ return domain.IssueComment{}, err
187+ }
188+ var c domain.IssueComment
189+ saved := false
190+ for range maxWriteRetries {
191+ if issue.CommentSeq >= MaxCommentsPerIssue {
192+ return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
193+ }
194+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
195+ issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
196+ err = i.store.SaveComment(ctx, issue, c)
197+ if err == nil {
198+ saved = true
199+ break
200+ }
201+ if !errors.Is(err, domain.ErrConflict) {
202+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
203+ }
204+ if issue, err = i.store.Get(ctx, name, number); err != nil {
205+ return domain.IssueComment{}, err
206+ }
207+ }
208+ if !saved {
209+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict)
210+ }
211+ log := zerowrap.FromCtx(ctx)
212+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
214+ i.bus.Publish(ctx, domain.IssueCommented{
215+ Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216+ })
217+ return c, nil
218+}
219+
220+// Close closes an open issue and publishes IssueClosed. The principal needs
221+// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
222+func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
223+ return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
224+}
225+
226+// Reopen reopens a closed issue and publishes IssueReopened. The principal
227+// needs issue:write. An issue that is not closed gives
228+// domain.ErrPreconditionFailed.
229+func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
230+ return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
231+}
232+
233+// transition moves an issue from one state to the other, retrying when another
234+// write changes it in between.
235+func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
236+ repo, err := i.writable(ctx, p, name)
237+ if err != nil {
238+ return domain.Issue{}, err
239+ }
240+ verb := "close"
241+ if to == domain.IssueStateOpen {
242+ verb = "reopen"
243+ }
244+ for range maxWriteRetries {
245+ issue, err := i.store.Get(ctx, name, number)
246+ if err != nil {
247+ return domain.Issue{}, err
248+ }
249+ if issue.State != from {
250+ return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
251+ }
252+ issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
253+ err = i.store.Update(ctx, issue)
254+ if errors.Is(err, domain.ErrConflict) {
255+ continue
256+ }
257+ if err != nil {
258+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
259+ }
260+ log := zerowrap.FromCtx(ctx)
261+ log.Info().Str("repo", name.String()).Int64("number", number).
262+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
263+ if to == domain.IssueStateOpen {
264+ i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265+ } else {
266+ i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
267+ }
268+ return issue, nil
269+ }
270+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
271+}
272+
273+// Kind tells what number n of the repository names. The principal needs the
274+// access of reading the repository, and issues must be on there: otherwise it
275+// is domain.ErrNotFound.
276+func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
277+ if _, err := i.readable(ctx, p, name); err != nil {
278+ return domain.NumberNone, err
279+ }
280+ if _, err := i.store.Get(ctx, name, n); err == nil {
281+ return domain.NumberIssue, nil
282+ } else if !errors.Is(err, domain.ErrNotFound) {
283+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
284+ }
285+ if _, err := i.mrs.Get(ctx, name, n); err == nil {
286+ return domain.NumberMergeRequest, nil
287+ } else if !errors.Is(err, domain.ErrNotFound) {
288+ return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
289+ }
290+ return domain.NumberNone, nil
291+}
A internal/usecase/issues_integration_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/adapters/eventbus"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// Issues over the real store: shared numbers with merge requests, the life of
21+// an issue and its events.
22+func TestIssuesOverTheStore(t *testing.T) {
23+ ctx := context.Background()
24+ store, err := kvstore.Open(ctx, filepath.Join(t.TempDir(), "kv"))
25+ require.NoError(t, err)
26+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
27+ bus := eventbus.NewWithBuffer(64)
28+ events := bus.Subscribe(ctx)
29+ n := name(t, "proj")
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = domain.IssuesOwner
32+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}))
33+
34+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: bus, Clock: fixedClock{}})
35+ agent := domain.TokenPrincipal(domain.Token{ID: "t", Label: "bot", Scopes: []domain.Scope{domain.ScopeIssueWrite}, BranchPrefixes: []string{"x/"}})
36+
37+ mrTitle, err := domain.ParseMRTitle("Change")
38+ require.NoError(t, err)
39+ mr, err := store.MergeRequests().Create(ctx, domain.MergeRequest{
40+ Repo: n, Title: mrTitle, State: domain.MRStateOpen, Source: "feat", Target: "main", Author: "a", CreatedAt: now, UpdatedAt: now,
41+ })
42+ require.NoError(t, err)
43+ assert.EqualValues(t, 1, mr.Number)
44+
45+ issue, err := issues.Create(ctx, agent, n, ports.CreateIssueParams{Title: "Crash", Body: "details"})
46+ require.NoError(t, err)
47+ assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48+ assert.Equal(t, "bot", issue.Author)
49+
50+ kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51+ require.NoError(t, err)
52+ assert.Equal(t, domain.NumberMergeRequest, kind)
53+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 2)
54+ require.NoError(t, err)
55+ assert.Equal(t, domain.NumberIssue, kind)
56+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 3)
57+ require.NoError(t, err)
58+ assert.Equal(t, domain.NumberNone, kind)
59+
60+ _, err = issues.AddComment(ctx, agent, n, 2, "me too")
61+ require.NoError(t, err)
62+ _, err = issues.Close(ctx, agent, n, 2)
63+ require.NoError(t, err)
64+ _, err = issues.AddComment(ctx, agent, n, 2, "after close")
65+ require.NoError(t, err)
66+ _, err = issues.Close(ctx, agent, n, 2)
67+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
68+ _, err = issues.Reopen(ctx, agent, n, 2)
69+ require.NoError(t, err)
70+
71+ d, err := issues.Get(ctx, domain.Anonymous(), n, 2)
72+ require.NoError(t, err)
73+ assert.Equal(t, domain.IssueStateOpen, d.Issue.State)
74+ require.Len(t, d.Comments, 2)
75+ assert.Equal(t, "me too", d.Comments[0].Text.String())
76+ assert.EqualValues(t, 2, d.Issue.CommentSeq)
77+
78+ page, err := issues.List(ctx, domain.Anonymous(), n, ports.ListIssuesParams{State: domain.IssueStateClosed})
79+ require.NoError(t, err)
80+ assert.Empty(t, page.Issues)
81+
82+ var kinds []string
83+ for len(events) > 0 {
84+ kinds = append(kinds, (<-events).EventKind())
85+ }
86+ assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
87+}
A internal/usecase/issues_test.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+type issueUnit struct {
20+ meta *mocks.RepoMetaStore
21+ store *mocks.IssueStore
22+ mrs *mocks.MergeRequestStore
23+ bus *mocks.EventBus
24+ issues *usecase.Issues
25+ repo domain.Repo
26+}
27+
28+func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *issueUnit {
29+ t.Helper()
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = mode
32+ u := &issueUnit{
33+ meta: mocks.NewRepoMetaStore(t),
34+ store: mocks.NewIssueStore(t),
35+ mrs: mocks.NewMergeRequestStore(t),
36+ bus: mocks.NewEventBus(t),
37+ repo: domain.Repo{Name: name(t, "proj"), Visibility: vis, Settings: settings},
38+ }
39+ clock := mocks.NewClock(t)
40+ clock.EXPECT().Now().Return(now).Maybe()
41+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43+ return u
44+}
45+
46+func issuePrincipal(scopes ...domain.Scope) domain.Principal {
47+ return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes, BranchPrefixes: []string{"agent/"}})
48+}
49+
50+func TestIssuesOffHidesEverything(t *testing.T) {
51+ t.Parallel()
52+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
53+ u := newIssueUnit(t, domain.VisibilityPublic, mode)
54+ ctx, n := context.Background(), u.repo.Name
55+ _, err := u.issues.Create(ctx, domain.Root(), n, ports.CreateIssueParams{Title: "t"})
56+ assert.ErrorIs(t, err, domain.ErrNotFound)
57+ _, err = u.issues.List(ctx, domain.Root(), n, ports.ListIssuesParams{})
58+ assert.ErrorIs(t, err, domain.ErrNotFound)
59+ _, err = u.issues.Get(ctx, domain.Owner(), n, 1)
60+ assert.ErrorIs(t, err, domain.ErrNotFound)
61+ _, err = u.issues.AddComment(ctx, domain.Root(), n, 1, "x")
62+ assert.ErrorIs(t, err, domain.ErrNotFound)
63+ _, err = u.issues.Close(ctx, domain.Root(), n, 1)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
66+ assert.ErrorIs(t, err, domain.ErrNotFound)
67+ _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+ }
70+}
71+
72+func TestIssueAccessRules(t *testing.T) {
73+ t.Parallel()
74+ ctx := context.Background()
75+ t.Run("private repository hides reads", func(t *testing.T) {
76+ t.Parallel()
77+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
78+ _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{})
79+ assert.ErrorIs(t, err, domain.ErrNotFound)
80+ _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1)
81+ assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read")
82+ })
83+ t.Run("writes", func(t *testing.T) {
84+ t.Parallel()
85+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
86+ p := ports.CreateIssueParams{Title: "t"}
87+ _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p)
88+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
89+ _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p)
90+ assert.ErrorIs(t, err, domain.ErrForbidden)
91+ })
92+ t.Run("private write without the scope", func(t *testing.T) {
93+ t.Parallel()
94+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
95+ _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"})
96+ assert.ErrorIs(t, err, domain.ErrNotFound)
97+ })
98+}
99+
100+func TestIssueCreateAndTransitions(t *testing.T) {
101+ t.Parallel()
102+ ctx := context.Background()
103+ p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref
104+
105+ t.Run("create", func(t *testing.T) {
106+ t.Parallel()
107+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
108+ u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
109+ return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" &&
110+ i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now)
111+ })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil })
112+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
113+ o, ok := ev.(domain.IssueOpened)
114+ return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate
115+ })).Return()
116+ got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117+ require.NoError(t, err)
118+ assert.EqualValues(t, 7, got.Number)
119+ })
120+
121+ t.Run("invalid text stores nothing", func(t *testing.T) {
122+ t.Parallel()
123+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
124+ _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""})
125+ var fe *domain.InvalidFieldsError
126+ require.ErrorAs(t, err, &fe)
127+ })
128+
129+ open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1}
130+
131+ t.Run("close with every update conflicting is an error and publishes nothing", func(t *testing.T) {
132+ t.Parallel()
133+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
134+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
135+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict)
136+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
137+ assert.ErrorIs(t, err, domain.ErrConflict)
138+ })
139+
140+ t.Run("close retries a conflict then publishes", func(t *testing.T) {
141+ t.Parallel()
142+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
143+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice()
144+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
145+ u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).Return(nil).Once()
146+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
147+ c, ok := ev.(domain.IssueClosed)
148+ return ok && c.Issue.State == domain.IssueStateClosed
149+ })).Return()
150+ got, err := u.issues.Close(ctx, p, u.repo.Name, 3)
151+ require.NoError(t, err)
152+ assert.Equal(t, domain.IssueStateClosed, got.State)
153+ })
154+
155+ t.Run("close of a closed issue", func(t *testing.T) {
156+ t.Parallel()
157+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
158+ closed := open
159+ closed.State = domain.IssueStateClosed
160+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
161+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
162+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
163+ })
164+
165+ t.Run("reopen", func(t *testing.T) {
166+ t.Parallel()
167+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
168+ closed := open
169+ closed.State = domain.IssueStateClosed
170+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
171+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil)
172+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return()
173+ got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
174+ require.NoError(t, err)
175+ assert.Equal(t, domain.IssueStateOpen, got.State)
176+ })
177+
178+ t.Run("reopen of an open issue", func(t *testing.T) {
179+ t.Parallel()
180+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
181+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
182+ _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
183+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
184+ })
185+}
186+
187+func TestIssueAddComment(t *testing.T) {
188+ t.Parallel()
189+ ctx := context.Background()
190+ p := issuePrincipal(domain.ScopeIssueWrite)
191+ issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1}
192+
193+ t.Run("every save conflicting is an error and publishes nothing", func(t *testing.T) {
194+ t.Parallel()
195+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
196+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
197+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict)
198+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi") // the bus mock fails on any Publish
199+ assert.ErrorIs(t, err, domain.ErrConflict)
200+ })
201+
202+ t.Run("on a closed issue, with a retry", func(t *testing.T) {
203+ t.Parallel()
204+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
205+ fresh := issue
206+ fresh.CommentSeq = 5
207+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once()
208+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once()
209+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
210+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }),
211+ mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once()
212+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
213+ c, ok := ev.(domain.IssueCommented)
214+ return ok && c.Number == 3 && c.Comment.ID == 6
215+ })).Return()
216+ c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi")
217+ require.NoError(t, err)
218+ assert.EqualValues(t, 6, c.ID)
219+ })
220+
221+ t.Run("blank", func(t *testing.T) {
222+ t.Parallel()
223+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
224+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
225+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ")
226+ var fe *domain.InvalidFieldsError
227+ require.ErrorAs(t, err, &fe)
228+ })
229+
230+ t.Run("full", func(t *testing.T) {
231+ t.Parallel()
232+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
233+ full := issue
234+ full.CommentSeq = usecase.MaxCommentsPerIssue
235+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil)
236+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x")
237+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
238+ })
239+}
240+
241+func TestIssueKind(t *testing.T) {
242+ t.Parallel()
243+ ctx := context.Background()
244+ t.Run("issue", func(t *testing.T) {
245+ t.Parallel()
246+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
247+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil)
248+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
249+ require.NoError(t, err)
250+ assert.Equal(t, domain.NumberIssue, k)
251+ })
252+ t.Run("merge request", func(t *testing.T) {
253+ t.Parallel()
254+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
255+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
256+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
257+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
258+ require.NoError(t, err)
259+ assert.Equal(t, domain.NumberMergeRequest, k)
260+ })
261+ t.Run("none", func(t *testing.T) {
262+ t.Parallel()
263+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
264+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
265+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound)
266+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
267+ require.NoError(t, err)
268+ assert.Equal(t, domain.NumberNone, k)
269+ })
270+ t.Run("private repository", func(t *testing.T) {
271+ t.Parallel()
272+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
273+ _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
274+ assert.ErrorIs(t, err, domain.ErrNotFound)
275+ })
276+}
M internal/usecase/mergerequests_integration_test.go +1 -1
@@ -1202,7 +1202,7 @@ func TestUpdateRepoSettings(t *testing.T) {
1202 off := false1202 off := false
1203 got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})1203 got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1204 require.NoError(t, err)1204 require.NoError(t, err)
1205- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)1205+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)
1206 again, err := r.repos.Get(r.ctx, root, r.repo)1206 again, err := r.repos.Get(r.ctx, root, r.repo)
1207 require.NoError(t, err)1207 require.NoError(t, err)
1208 assert.Equal(t, got.Settings, again.Settings)1208 assert.Equal(t, got.Settings, again.Settings)
M internal/usecase/repos_write.go +13 -3
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
62 }62 }
63 before := repo63 before := repo
64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
68 if params.AllowedMergeModes != nil {68 if params.AllowedMergeModes != nil {
@@ -72,6 +72,13 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
72 }72 }
73 params.AllowedMergeModes = &modes73 params.AllowedMergeModes = &modes
74 }74 }
75+ if params.Issues != nil {
76+ mode, err := domain.ParseIssuesMode(params.Issues.String())
77+ if err != nil {
78+ return domain.Repo{}, err
79+ }
80+ params.Issues = &mode
81+ }
75 if params.Description != nil {82 if params.Description != nil {
76 if err := validateDescription(*params.Description); err != nil {83 if err := validateDescription(*params.Description); err != nil {
77 return domain.Repo{}, err84 return domain.Repo{}, err
@@ -109,7 +116,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
109 // branch was checked above, so the remaining causes are a vanished116 // branch was checked above, so the remaining causes are a vanished
110 // repository or an I/O failure.117 // repository or an I/O failure.
111 metaDone := false118 metaDone := false
112- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
113 if repo, err = r.updateMeta(ctx, name, params); err != nil {120 if repo, err = r.updateMeta(ctx, name, params); err != nil {
114 return domain.Repo{}, err121 return domain.Repo{}, err
115 }122 }
@@ -132,7 +139,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
132 Bool("topics", params.Topics != nil).139 Bool("topics", params.Topics != nil).
133 Bool("visibility", params.Visibility != nil).140 Bool("visibility", params.Visibility != nil).
134 Bool("default_branch", params.DefaultBranch != nil).141 Bool("default_branch", params.DefaultBranch != nil).
135- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).142+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
136 Str("principal", p.Label()).Str("token_id", p.TokenID()).143 Str("principal", p.Label()).Str("token_id", p.TokenID()).
137 Msg("repository updated")144 Msg("repository updated")
138 r.bus.Publish(ctx, domain.RepoUpdated{145 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -171,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
171 if params.FailingChecksBlock != nil {178 if params.FailingChecksBlock != nil {
172 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock179 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
173 }180 }
181+ if params.Issues != nil {
182+ repo.Settings.Issues = *params.Issues
183+ }
174 err = r.meta.Update(ctx, repo)184 err = r.meta.Update(ctx, repo)
175 if errors.Is(err, domain.ErrConflict) {185 if errors.Is(err, domain.ErrConflict) {
176 continue186 continue
M internal/usecase/repos_write_test.go +42 -0
@@ -215,6 +215,7 @@ func TestUpdate(t *testing.T) {
215 "description": {Description: ptr("x")},215 "description": {Description: ptr("x")},
216 "merge modes": {AllowedMergeModes: &modes},216 "merge modes": {AllowedMergeModes: &modes},
217 "checks rule": {FailingChecksBlock: &block},217 "checks rule": {FailingChecksBlock: &block},
218+ "issues": {Issues: ptr(domain.IssuesOwner)},
218 } {219 } {
219 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish220 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
220 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)221 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
@@ -240,6 +241,47 @@ func TestUpdate(t *testing.T) {
240 })241 })
241 }242 }
242 243
244+func TestUpdateIssuesMode(t *testing.T) {
245+ t.Parallel()
246+ ctx := context.Background()
247+ n := name(t, "tools")
248+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
249+
250+ t.Run("public is stored and published", func(t *testing.T) {
251+ t.Parallel()
252+ f := newFixture(t)
253+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
254+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
255+ return r.Settings.Issues == domain.IssuesPublic
256+ })).Return(nil)
257+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259+ require.NoError(t, err)
260+ assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues)
261+ })
262+
263+ t.Run("an unknown mode is invalid", func(t *testing.T) {
264+ t.Parallel()
265+ f := newFixture(t)
266+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
267+ _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesMode("all"))})
268+ assert.ErrorIs(t, err, domain.ErrInvalid)
269+ })
270+
271+ t.Run("owner is stored and published", func(t *testing.T) {
272+ t.Parallel()
273+ f := newFixture(t)
274+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
275+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
276+ return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
277+ })).Return(nil)
278+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
279+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
280+ require.NoError(t, err)
281+ assert.Equal(t, domain.IssuesOwner, repo.Settings.Issues)
282+ })
283+}
284+
243 func TestDelete(t *testing.T) {285 func TestDelete(t *testing.T) {
244 t.Parallel()286 t.Parallel()
245 ctx := context.Background()287 ctx := context.Background()
A proto/ermite/storage/issue.proto +71 -0
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+syntax = "proto3";
6+
7+package ermite.storage;
8+
9+import "google/protobuf/timestamp.proto";
10+
11+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
12+
13+// IssueState is where an issue is in its life.
14+enum IssueState {
15+ ISSUE_STATE_UNSPECIFIED = 0;
16+ ISSUE_STATE_OPEN = 1;
17+ ISSUE_STATE_CLOSED = 2;
18+ // Awaiting moderation. Not stored yet.
19+ ISSUE_STATE_PENDING = 3;
20+}
21+
22+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
23+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
24+// indexed under "isopen/<repo>/<number:010d>".
25+message Issue {
26+ int64 number = 1;
27+ string title = 2;
28+ string body = 3;
29+ IssueState state = 4;
30+ // Label of the token that opened it.
31+ string author = 5;
32+ google.protobuf.Timestamp created_at = 6;
33+ google.protobuf.Timestamp updated_at = 7;
34+ // Last comment id handed out for this issue.
35+ int64 comment_seq = 8;
36+ // True for an issue approved from the public form: its text comes from
37+ // an anonymous visitor and is rendered as untrusted.
38+ bool external = 9;
39+}
40+
41+// IssueComment is stored under the key
42+// "isc/<repo>/<number:010d>/<id:010d>".
43+message IssueComment {
44+ int64 id = 1;
45+ string author = 2;
46+ string text = 3;
47+ google.protobuf.Timestamp created_at = 4;
48+}
49+
50+// PendingIssue is an issue submitted through the public form, stored under
51+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
52+// It has no number until the owner approves it.
53+message PendingIssue {
54+ string title = 1;
55+ string body = 2;
56+ // Display name given by the submitter. May be empty.
57+ string author = 3;
58+ google.protobuf.Timestamp created_at = 4;
59+ // HMAC-SHA256 of the client key (32 bytes).
60+ bytes ip_hash = 5;
61+}
62+
63+// IssueBan is stored under the key "isban/<hex of the hash>".
64+message IssueBan {
65+ google.protobuf.Timestamp until = 1;
66+}
67+
68+// IssueKey is stored under the key "meta/issuekey".
69+message IssueKey {
70+ bytes key = 1;
71+}
M proto/ermite/storage/merge_request.proto +10 -0
@@ -22,6 +22,16 @@ message RepoSettings {
22 // Never empty once stored.22 // Never empty once stored.
23 repeated MergeMode allowed_merge_modes = 1;23 repeated MergeMode allowed_merge_modes = 1;
24 bool failing_checks_block = 2;24 bool failing_checks_block = 2;
25+ // Who may write issues; unspecified reads as off.
26+ IssuesMode issues = 3;
27+}
28+
29+// IssuesMode says who may write issues in a repository.
30+enum IssuesMode {
31+ ISSUES_MODE_UNSPECIFIED = 0;
32+ ISSUES_MODE_OFF = 1;
33+ ISSUES_MODE_OWNER = 2;
34+ ISSUES_MODE_PUBLIC = 3;
25 }35 }
26 36
27 // MergeRequestState is where a merge request is in its life.37 // MergeRequestState is where a merge request is in its life.
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 43

↑↓ moveenter openesc close