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
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bufio"
7+ "context"
8+ "net/http"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The owner's inbox end to end: two repositories with open merge requests and
20+// issues, the filter, the home button, the owner stream, and a 404 for
21+// everyone who is not the signed-in owner.
22+func TestWebInboxEndToEnd(t *testing.T) {
23+ r := newMRRepo(t, domain.VisibilityPublic)
24+ b := browser{r.harness}
25+ token := r.signIn(t)
26+
27+ _, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate))
28+ require.NoError(t, err)
29+ otherURL := r.urlAuth(adminToken, "/other.git")
30+ r.mustGit(r.wt, "push", "-q", otherURL, "main")
31+ r.mustGit(r.wt, "push", "-q", otherURL, "feat/search")
32+
33+ owner := domain.IssuesOwner
34+ for _, n := range []string{"proj", "other"} {
35+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner})
36+ require.NoError(t, err)
37+ }
38+
39+ // Nothing is open yet.
40+ empty := b.get("/-/inbox", as(token))
41+ require.Equal(t, 200, empty.status, empty.body)
42+ assert.Contains(t, empty.body, "no open merge requests.")
43+ assert.Contains(t, empty.body, "no open issues.")
44+
45+ // The owner stream sees both repositories.
46+ stream := ownerStream(t, r.harness, token)
47+
48+ root := domain.Root()
49+ _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
50+ require.NoError(t, err)
51+ ev := stream.next(t, watchWait)
52+ assert.Equal(t, "inbox", ev.name)
53+ assert.Contains(t, ev.data, `id="inbox-counts"`)
54+ for range 4 { // the other fragments of this event
55+ stream.next(t, watchWait)
56+ }
57+ _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
58+ require.NoError(t, err)
59+ var seen strings.Builder
60+ for range 5 { // the five fragments of this event
61+ seen.WriteString(stream.next(t, watchWait).data)
62+ }
63+ assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
64+ _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})
65+ require.NoError(t, err)
66+ _, err = r.forge.Issues.Create(r.ctx, root, r.name("proj"), ports.CreateIssueParams{Title: "proj bug"})
67+ require.NoError(t, err)
68+
69+ all := b.get("/-/inbox", as(token))
70+ require.Equal(t, 200, all.status, all.body)
71+ assert.Equal(t, "private, no-store", all.hdr.Get("Cache-Control"))
72+ assert.Contains(t, all.body, "<strong>2</strong> merge requests · <strong>2</strong> issues")
73+ for _, want := range []string{"add search", "other work", "search is slow", "proj bug", `href="/other/issues/`, `href="/proj/merge-requests/`} {
74+ assert.Contains(t, all.body, want)
75+ }
76+
77+ only := b.get("/-/inbox?repo=other", as(token))
78+ require.Equal(t, 200, only.status)
79+ assert.Contains(t, only.body, "<strong>2</strong> merge requests · <strong>2</strong> issues", "the counters ignore the filter")
80+ assert.Contains(t, only.body, "other work")
81+ assert.Contains(t, only.body, "search is slow")
82+ assert.NotContains(t, only.body, "add search")
83+ assert.NotContains(t, only.body, "proj bug")
84+
85+ // Issues off: that repository's issues leave the inbox, its merge requests stay.
86+ off := domain.IssuesOff
87+ _, err = r.forge.Repos.Update(r.ctx, root, r.name("other"), ports.UpdateRepoParams{Issues: &off})
88+ require.NoError(t, err)
89+ hidden := b.get("/-/inbox", as(token))
90+ assert.Contains(t, hidden.body, "<strong>2</strong> merge requests · <strong>1</strong> issues")
91+ assert.NotContains(t, hidden.body, "search is slow")
92+
93+ // The home button, for the owner only.
94+ assert.Contains(t, b.get("/", as(token)).body, `href="/-/inbox"`)
95+ anonHome := b.get("/", nil)
96+ assert.NotContains(t, anonHome.body, "/-/inbox")
97+ assert.NotContains(t, anonHome.body, "Inbox")
98+
99+ // Anyone else: the 404 of an unknown page, and no stream.
100+ missing := b.get("/-/no-such-page", nil)
101+ anon := b.get("/-/inbox", nil)
102+ assert.Equal(t, 404, anon.status)
103+ assert.Equal(t, missing.body, anon.body)
104+ assert.Equal(t, 404, b.get("/-/inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status, "a token is not a session")
105+ unknown := b.get("/-/events?topic=nope&view=inbox", nil)
106+ ownerAnon := b.get("/-/events?topic=owner&view=inbox", nil)
107+ assert.Equal(t, unknown.status, ownerAnon.status, "the answer to an unknown topic")
108+ assert.Equal(t, strings.ReplaceAll(unknown.body, "nope", "owner"), ownerAnon.body)
109+ assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)
110+}
111+
112+// ownerStream opens the owner topic with the session cookie.
113+func ownerStream(t *testing.T, h *harness, token string) *sseStream {
114+ t.Helper()
115+ ctx, cancel := context.WithCancel(h.ctx)
116+ t.Cleanup(cancel)
117+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil)
118+ require.NoError(t, err)
119+ req.Header.Set("Cookie", sessionCookie+"="+token)
120+ resp, err := http.DefaultClient.Do(req)
121+ require.NoError(t, err)
122+ t.Cleanup(func() { _ = resp.Body.Close() })
123+ require.Equal(t, http.StatusOK, resp.StatusCode)
124+ s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
125+ go func() {
126+ defer close(s.events)
127+ sc := bufio.NewScanner(resp.Body)
128+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
129+ var cur sseEvent
130+ var data []string
131+ for sc.Scan() {
132+ line := sc.Text()
133+ switch {
134+ case line == "":
135+ if cur.id != "" || cur.name != "" || len(data) > 0 {
136+ cur.data = strings.Join(data, "\n")
137+ s.events <- cur
138+ }
139+ cur, data = sseEvent{}, nil
140+ case strings.HasPrefix(line, ":"):
141+ cur.comment = true
142+ case strings.HasPrefix(line, "event: "):
143+ cur.name = strings.TrimPrefix(line, "event: ")
144+ case strings.HasPrefix(line, "id: "):
145+ cur.id = strings.TrimPrefix(line, "id: ")
146+ case strings.HasPrefix(line, "data: "):
147+ data = append(data, strings.TrimPrefix(line, "data: "))
148+ }
149+ }
150+ }()
151+ return s
152+}
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bufio"
7+ "context"
8+ "net/http"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The owner's inbox end to end: two repositories with open merge requests and
20+// issues, the filter, the home button, the owner stream, and a 404 for
21+// everyone who is not the signed-in owner.
22+func TestWebInboxEndToEnd(t *testing.T) {
23+ r := newMRRepo(t, domain.VisibilityPublic)
24+ b := browser{r.harness}
25+ token := r.signIn(t)
26+
27+ _, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate))
28+ require.NoError(t, err)
29+ otherURL := r.urlAuth(adminToken, "/other.git")
30+ r.mustGit(r.wt, "push", "-q", otherURL, "main")
31+ r.mustGit(r.wt, "push", "-q", otherURL, "feat/search")
32+
33+ owner := domain.IssuesOwner
34+ for _, n := range []string{"proj", "other"} {
35+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner})
36+ require.NoError(t, err)
37+ }
38+
39+ // Nothing is open yet.
40+ empty := b.get("/-/inbox", as(token))
41+ require.Equal(t, 200, empty.status, empty.body)
42+ assert.Contains(t, empty.body, "no open merge requests.")
43+ assert.Contains(t, empty.body, "no open issues.")
44+
45+ // The owner stream sees both repositories.
46+ stream := ownerStream(t, r.harness, token)
47+
48+ root := domain.Root()
49+ _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
50+ require.NoError(t, err)
51+ ev := stream.next(t, watchWait)
52+ assert.Equal(t, "inbox", ev.name)
53+ assert.Contains(t, ev.data, `id="inbox-counts"`)
54+ for range 4 { // the other fragments of this event
55+ stream.next(t, watchWait)
56+ }
57+ _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
58+ require.NoError(t, err)
59+ var seen strings.Builder
60+ for range 5 { // the five fragments of this event
61+ seen.WriteString(stream.next(t, watchWait).data)
62+ }
63+ assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
64+ _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})
65+ require.NoError(t, err)
66+ _, err = r.forge.Issues.Create(r.ctx, root, r.name("proj"), ports.CreateIssueParams{Title: "proj bug"})
67+ require.NoError(t, err)
68+
69+ all := b.get("/-/inbox", as(token))
70+ require.Equal(t, 200, all.status, all.body)
71+ assert.Equal(t, "private, no-store", all.hdr.Get("Cache-Control"))
72+ assert.Contains(t, all.body, "<strong>2</strong> merge requests · <strong>2</strong> issues")
73+ for _, want := range []string{"add search", "other work", "search is slow", "proj bug", `href="/other/issues/`, `href="/proj/merge-requests/`} {
74+ assert.Contains(t, all.body, want)
75+ }
76+
77+ only := b.get("/-/inbox?repo=other", as(token))
78+ require.Equal(t, 200, only.status)
79+ assert.Contains(t, only.body, "<strong>2</strong> merge requests · <strong>2</strong> issues", "the counters ignore the filter")
80+ assert.Contains(t, only.body, "other work")
81+ assert.Contains(t, only.body, "search is slow")
82+ assert.NotContains(t, only.body, "add search")
83+ assert.NotContains(t, only.body, "proj bug")
84+
85+ // Issues off: that repository's issues leave the inbox, its merge requests stay.
86+ off := domain.IssuesOff
87+ _, err = r.forge.Repos.Update(r.ctx, root, r.name("other"), ports.UpdateRepoParams{Issues: &off})
88+ require.NoError(t, err)
89+ hidden := b.get("/-/inbox", as(token))
90+ assert.Contains(t, hidden.body, "<strong>2</strong> merge requests · <strong>1</strong> issues")
91+ assert.NotContains(t, hidden.body, "search is slow")
92+
93+ // The home button, for the owner only.
94+ assert.Contains(t, b.get("/", as(token)).body, `href="/-/inbox"`)
95+ anonHome := b.get("/", nil)
96+ assert.NotContains(t, anonHome.body, "/-/inbox")
97+ assert.NotContains(t, anonHome.body, "Inbox")
98+
99+ // Anyone else: the 404 of an unknown page, and no stream.
100+ missing := b.get("/-/no-such-page", nil)
101+ anon := b.get("/-/inbox", nil)
102+ assert.Equal(t, 404, anon.status)
103+ assert.Equal(t, missing.body, anon.body)
104+ assert.Equal(t, 404, b.get("/-/inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status, "a token is not a session")
105+ unknown := b.get("/-/events?topic=nope&view=inbox", nil)
106+ ownerAnon := b.get("/-/events?topic=owner&view=inbox", nil)
107+ assert.Equal(t, unknown.status, ownerAnon.status, "the answer to an unknown topic")
108+ assert.Equal(t, strings.ReplaceAll(unknown.body, "nope", "owner"), ownerAnon.body)
109+ assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)
110+}
111+
112+// ownerStream opens the owner topic with the session cookie.
113+func ownerStream(t *testing.T, h *harness, token string) *sseStream {
114+ t.Helper()
115+ ctx, cancel := context.WithCancel(h.ctx)
116+ t.Cleanup(cancel)
117+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil)
118+ require.NoError(t, err)
119+ req.Header.Set("Cookie", sessionCookie+"="+token)
120+ resp, err := http.DefaultClient.Do(req)
121+ require.NoError(t, err)
122+ t.Cleanup(func() { _ = resp.Body.Close() })
123+ require.Equal(t, http.StatusOK, resp.StatusCode)
124+ s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
125+ go func() {
126+ defer close(s.events)
127+ sc := bufio.NewScanner(resp.Body)
128+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
129+ var cur sseEvent
130+ var data []string
131+ for sc.Scan() {
132+ line := sc.Text()
133+ switch {
134+ case line == "":
135+ if cur.id != "" || cur.name != "" || len(data) > 0 {
136+ cur.data = strings.Join(data, "\n")
137+ s.events <- cur
138+ }
139+ cur, data = sseEvent{}, nil
140+ case strings.HasPrefix(line, ":"):
141+ cur.comment = true
142+ case strings.HasPrefix(line, "event: "):
143+ cur.name = strings.TrimPrefix(line, "event: ")
144+ case strings.HasPrefix(line, "id: "):
145+ cur.id = strings.TrimPrefix(line, "id: ")
146+ case strings.HasPrefix(line, "data: "):
147+ data = append(data, strings.TrimPrefix(line, "data: "))
148+ }
149+ }
150+ }()
151+ return s
152+}
A internal/app/web_issue_form_test.go +231 -0
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "html"
9+ "math/bits"
10+ "net/url"
11+ "regexp"
12+ "strconv"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+var (
23+ reFormAttr = func(name string) *regexp.Regexp { return regexp.MustCompile(`data-` + name + `="([^"]*)"`) }
24+ reFormField = func(name string) *regexp.Regexp {
25+ return regexp.MustCompile(`<input type="hidden" name="` + name + `" value="([^"]*)"`)
26+ }
27+ rePendingActions = regexp.MustCompile(`action="(/-/inbox/pending/[^"]+/(approve|reject|ban))"`)
28+)
29+
30+// testPoW is the difficulty the tests lower the proof of work to.
31+const testPoW = 8
32+
33+// solvePoW finds the smallest counter n with SHA-256(nonce || ":" || n) having
34+// bits leading zero bits, as the browser does.
35+func solvePoW(t *testing.T, nonce string, difficulty int) string {
36+ t.Helper()
37+ raw, err := url.PathUnescape(nonce)
38+ require.NoError(t, err)
39+ b, err := decodeB64URL(raw)
40+ require.NoError(t, err)
41+ for n := 0; n < 1<<26; n++ {
42+ sum := sha256.Sum256(append(append(append([]byte{}, b...), ':'), strconv.Itoa(n)...))
43+ z := 0
44+ for _, c := range sum {
45+ if c == 0 {
46+ z += 8
47+ continue
48+ }
49+ z += bits.LeadingZeros8(c)
50+ break
51+ }
52+ if z >= difficulty {
53+ return strconv.Itoa(n)
54+ }
55+ }
56+ t.Fatal("no solution")
57+ return ""
58+}
59+
60+// formValues reads the challenge out of the HTML of the public form and
61+// solves it.
62+func formValues(t *testing.T, body string) url.Values {
63+ t.Helper()
64+ get := func(re *regexp.Regexp) string {
65+ m := re.FindStringSubmatch(body)
66+ require.Len(t, m, 2, "the form carries %s", re)
67+ return html.UnescapeString(m[1])
68+ }
69+ diff, err := strconv.Atoi(get(reFormAttr("difficulty")))
70+ require.NoError(t, err)
71+ require.Equal(t, testPoW, diff, "the test difficulty")
72+ return url.Values{
73+ "token": {get(reFormField("token"))}, "form_token": {get(reFormField("form_token"))},
74+ "solution": {solvePoW(t, get(reFormAttr("nonce")), diff)},
75+ }
76+}
77+
78+// The public issue form end to end: a visitor solves the proof of work and
79+// submits, the owner sees the issue awaiting moderation in the inbox and
80+// approves it, and a rejection with a ban shuts the same address out.
81+func TestWebPublicIssueFormEndToEnd(t *testing.T) {
82+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
83+ cli := &cliHarness{harness: h, url: h.srv.URL}
84+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
85+ require.NoError(t, err)
86+ b := browser{h}
87+ token := cli.signIn(t)
88+
89+ pub := domain.IssuesPublic
90+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
91+ require.NoError(t, err)
92+ site := map[string]string{"Origin": publicOrigin}
93+
94+ // The visitor reads the list and finds the button.
95+ list := b.get("/proj/issues", nil)
96+ require.Equal(t, 200, list.status, list.body)
97+ assert.Contains(t, list.body, `href="/proj/issues/new"`)
98+
99+ // The form, with a challenge, never stored.
100+ form := b.get("/proj/issues/new", nil)
101+ require.Equal(t, 200, form.status, form.body)
102+ assert.Equal(t, "no-store", form.hdr.Get("Cache-Control"))
103+ assert.Contains(t, form.body, "<noscript>")
104+ assert.Contains(t, form.body, `name="website"`)
105+ v := formValues(t, form.body)
106+ v.Set("title", "Search is <b>slow</b>")
107+ v.Set("body", "see [docs](https://example.org/d) and [x](javascript:alert(1))\r\n**bold**")
108+ v.Set("author", "Alex")
109+
110+ // A bad cross-origin POST is refused before anything is checked.
111+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", nil, v.Encode()).status)
112+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", map[string]string{"Origin": "https://evil.example"}, v.Encode()).status)
113+
114+ // The submission passes.
115+ sent := b.do("POST", "/proj/issues/new", site, v.Encode())
116+ require.Equal(t, 303, sent.status, sent.body)
117+ assert.Equal(t, "/proj/issues/submitted", sent.hdr.Get("Location"))
118+ assert.Equal(t, "no-store", sent.hdr.Get("Cache-Control"))
119+ thanks := b.get("/proj/issues/submitted", nil)
120+ assert.Equal(t, 200, thanks.status)
121+ assert.Contains(t, thanks.body, "Thanks, your issue is waiting for moderation.")
122+
123+ // The same challenge cannot be used twice.
124+ replay := b.do("POST", "/proj/issues/new", site, v.Encode())
125+ assert.Equal(t, 400, replay.status)
126+ assert.Contains(t, replay.body, "could not be verified")
127+
128+ // Nothing is public yet.
129+ assert.NotContains(t, b.get("/proj/issues", nil).body, "slow")
130+
131+ // A text error shows the form again with a new challenge and the text.
132+ form2 := b.get("/proj/issues/new", nil)
133+ v2 := formValues(t, form2.body)
134+ v2.Set("title", "")
135+ v2.Set("body", "kept text")
136+ again := b.do("POST", "/proj/issues/new", site, v2.Encode())
137+ require.Equal(t, 422, again.status, again.body)
138+ assert.Contains(t, again.body, "kept text")
139+ assert.Contains(t, again.body, "The title is empty.")
140+ v3 := formValues(t, again.body)
141+ assert.NotEqual(t, v2.Get("token"), v3.Get("token"))
142+
143+ // The owner finds it awaiting moderation, as plain text.
144+ inbox := b.get("/-/inbox", as(token))
145+ require.Equal(t, 200, inbox.status, inbox.body)
146+ assert.Contains(t, inbox.body, "<strong>1</strong> awaiting moderation")
147+ assert.Contains(t, inbox.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
148+ assert.Contains(t, inbox.body, "**bold**")
149+ assert.NotContains(t, inbox.body, "<strong>bold")
150+ acts := rePendingActions.FindAllStringSubmatch(inbox.body, -1)
151+ require.Len(t, acts, 3)
152+ actions := map[string]string{}
153+ for _, a := range acts {
154+ actions[a[2]] = a[1]
155+ }
156+
157+ // An anonymous POST to a moderation path is refused.
158+ assert.Equal(t, 403, b.do("POST", actions["approve"], site, "").status)
159+
160+ // The owner shares the numbers with merge requests and issues: approve.
161+ ok := b.form(actions["approve"], token, nil)
162+ require.Equal(t, 303, ok.status, ok.body)
163+ assert.Equal(t, "/-/inbox", ok.hdr.Get("Location"))
164+ assert.Equal(t, 404, b.form(actions["approve"], token, nil).status, "approved once")
165+ shown := b.get("/proj/issues/1", nil)
166+ require.Equal(t, 200, shown.status, shown.body)
167+ assert.Contains(t, shown.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
168+ assert.Contains(t, shown.body, `href="https://example.org/d"`)
169+ assert.Contains(t, shown.body, `rel="nofollow noopener noreferrer ugc"`)
170+ assert.NotContains(t, shown.body, "javascript:")
171+ assert.Contains(t, shown.body, "Alex")
172+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="/proj/issues/1"`)
173+ empty := b.get("/-/inbox", as(token))
174+ assert.Contains(t, empty.body, "<strong>0</strong> awaiting moderation")
175+
176+ // Reject and ban: the same address is then refused with the generic page.
177+ form4 := b.get("/proj/issues/new", nil)
178+ v4 := formValues(t, form4.body)
179+ v4.Set("title", "spam")
180+ require.Equal(t, 303, b.do("POST", "/proj/issues/new", site, v4.Encode()).status)
181+ inbox2 := b.get("/-/inbox", as(token))
182+ var ban string
183+ for _, a := range rePendingActions.FindAllStringSubmatch(inbox2.body, -1) {
184+ if a[2] == "ban" {
185+ ban = a[1]
186+ }
187+ }
188+ require.NotEmpty(t, ban)
189+ require.Equal(t, 303, b.form(ban, token, nil).status)
190+
191+ form5 := b.get("/proj/issues/new", nil)
192+ require.Equal(t, 200, form5.status, "the form is still served; the ban bites at submission")
193+ v5 := formValues(t, form5.body)
194+ v5.Set("title", "more spam")
195+ banned := b.do("POST", "/proj/issues/new", site, v5.Encode())
196+ assert.Equal(t, 400, banned.status)
197+ assert.Contains(t, banned.body, "The submission could not be verified. Reload the page and try again.")
198+ assert.NotContains(t, banned.body, "ban")
199+ assert.Contains(t, b.get("/-/inbox", as(token)).body, "<strong>0</strong> awaiting moderation")
200+}
201+
202+// A repository that is not public with public issues has no form.
203+func TestWebPublicIssueFormIsNotThere(t *testing.T) {
204+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
205+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
206+ require.NoError(t, err)
207+ b := browser{h}
208+ site := map[string]string{"Origin": publicOrigin}
209+ body := url.Values{"title": {"x"}}.Encode()
210+
211+ missing := b.get("/nope/issues/new", nil)
212+ for name, mode := range map[string]domain.IssuesMode{"off": domain.IssuesOff, "owner": domain.IssuesOwner} {
213+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &mode})
214+ require.NoError(t, err)
215+ got := b.get("/proj/issues/new", nil)
216+ assert.Equal(t, 404, got.status, name)
217+ assert.Equal(t, missing.body, got.body, name+": the same page as a missing repository")
218+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, name)
219+ }
220+
221+ pub := domain.IssuesPublic
222+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
223+ require.NoError(t, err)
224+ priv := domain.VisibilityPrivate
225+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
226+ require.NoError(t, err)
227+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status, "private")
228+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, "private")
229+}
230+
231+func decodeB64URL(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "html"
9+ "math/bits"
10+ "net/url"
11+ "regexp"
12+ "strconv"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+var (
23+ reFormAttr = func(name string) *regexp.Regexp { return regexp.MustCompile(`data-` + name + `="([^"]*)"`) }
24+ reFormField = func(name string) *regexp.Regexp {
25+ return regexp.MustCompile(`<input type="hidden" name="` + name + `" value="([^"]*)"`)
26+ }
27+ rePendingActions = regexp.MustCompile(`action="(/-/inbox/pending/[^"]+/(approve|reject|ban))"`)
28+)
29+
30+// testPoW is the difficulty the tests lower the proof of work to.
31+const testPoW = 8
32+
33+// solvePoW finds the smallest counter n with SHA-256(nonce || ":" || n) having
34+// bits leading zero bits, as the browser does.
35+func solvePoW(t *testing.T, nonce string, difficulty int) string {
36+ t.Helper()
37+ raw, err := url.PathUnescape(nonce)
38+ require.NoError(t, err)
39+ b, err := decodeB64URL(raw)
40+ require.NoError(t, err)
41+ for n := 0; n < 1<<26; n++ {
42+ sum := sha256.Sum256(append(append(append([]byte{}, b...), ':'), strconv.Itoa(n)...))
43+ z := 0
44+ for _, c := range sum {
45+ if c == 0 {
46+ z += 8
47+ continue
48+ }
49+ z += bits.LeadingZeros8(c)
50+ break
51+ }
52+ if z >= difficulty {
53+ return strconv.Itoa(n)
54+ }
55+ }
56+ t.Fatal("no solution")
57+ return ""
58+}
59+
60+// formValues reads the challenge out of the HTML of the public form and
61+// solves it.
62+func formValues(t *testing.T, body string) url.Values {
63+ t.Helper()
64+ get := func(re *regexp.Regexp) string {
65+ m := re.FindStringSubmatch(body)
66+ require.Len(t, m, 2, "the form carries %s", re)
67+ return html.UnescapeString(m[1])
68+ }
69+ diff, err := strconv.Atoi(get(reFormAttr("difficulty")))
70+ require.NoError(t, err)
71+ require.Equal(t, testPoW, diff, "the test difficulty")
72+ return url.Values{
73+ "token": {get(reFormField("token"))}, "form_token": {get(reFormField("form_token"))},
74+ "solution": {solvePoW(t, get(reFormAttr("nonce")), diff)},
75+ }
76+}
77+
78+// The public issue form end to end: a visitor solves the proof of work and
79+// submits, the owner sees the issue awaiting moderation in the inbox and
80+// approves it, and a rejection with a ban shuts the same address out.
81+func TestWebPublicIssueFormEndToEnd(t *testing.T) {
82+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
83+ cli := &cliHarness{harness: h, url: h.srv.URL}
84+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
85+ require.NoError(t, err)
86+ b := browser{h}
87+ token := cli.signIn(t)
88+
89+ pub := domain.IssuesPublic
90+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
91+ require.NoError(t, err)
92+ site := map[string]string{"Origin": publicOrigin}
93+
94+ // The visitor reads the list and finds the button.
95+ list := b.get("/proj/issues", nil)
96+ require.Equal(t, 200, list.status, list.body)
97+ assert.Contains(t, list.body, `href="/proj/issues/new"`)
98+
99+ // The form, with a challenge, never stored.
100+ form := b.get("/proj/issues/new", nil)
101+ require.Equal(t, 200, form.status, form.body)
102+ assert.Equal(t, "no-store", form.hdr.Get("Cache-Control"))
103+ assert.Contains(t, form.body, "<noscript>")
104+ assert.Contains(t, form.body, `name="website"`)
105+ v := formValues(t, form.body)
106+ v.Set("title", "Search is <b>slow</b>")
107+ v.Set("body", "see [docs](https://example.org/d) and [x](javascript:alert(1))\r\n**bold**")
108+ v.Set("author", "Alex")
109+
110+ // A bad cross-origin POST is refused before anything is checked.
111+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", nil, v.Encode()).status)
112+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", map[string]string{"Origin": "https://evil.example"}, v.Encode()).status)
113+
114+ // The submission passes.
115+ sent := b.do("POST", "/proj/issues/new", site, v.Encode())
116+ require.Equal(t, 303, sent.status, sent.body)
117+ assert.Equal(t, "/proj/issues/submitted", sent.hdr.Get("Location"))
118+ assert.Equal(t, "no-store", sent.hdr.Get("Cache-Control"))
119+ thanks := b.get("/proj/issues/submitted", nil)
120+ assert.Equal(t, 200, thanks.status)
121+ assert.Contains(t, thanks.body, "Thanks, your issue is waiting for moderation.")
122+
123+ // The same challenge cannot be used twice.
124+ replay := b.do("POST", "/proj/issues/new", site, v.Encode())
125+ assert.Equal(t, 400, replay.status)
126+ assert.Contains(t, replay.body, "could not be verified")
127+
128+ // Nothing is public yet.
129+ assert.NotContains(t, b.get("/proj/issues", nil).body, "slow")
130+
131+ // A text error shows the form again with a new challenge and the text.
132+ form2 := b.get("/proj/issues/new", nil)
133+ v2 := formValues(t, form2.body)
134+ v2.Set("title", "")
135+ v2.Set("body", "kept text")
136+ again := b.do("POST", "/proj/issues/new", site, v2.Encode())
137+ require.Equal(t, 422, again.status, again.body)
138+ assert.Contains(t, again.body, "kept text")
139+ assert.Contains(t, again.body, "The title is empty.")
140+ v3 := formValues(t, again.body)
141+ assert.NotEqual(t, v2.Get("token"), v3.Get("token"))
142+
143+ // The owner finds it awaiting moderation, as plain text.
144+ inbox := b.get("/-/inbox", as(token))
145+ require.Equal(t, 200, inbox.status, inbox.body)
146+ assert.Contains(t, inbox.body, "<strong>1</strong> awaiting moderation")
147+ assert.Contains(t, inbox.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
148+ assert.Contains(t, inbox.body, "**bold**")
149+ assert.NotContains(t, inbox.body, "<strong>bold")
150+ acts := rePendingActions.FindAllStringSubmatch(inbox.body, -1)
151+ require.Len(t, acts, 3)
152+ actions := map[string]string{}
153+ for _, a := range acts {
154+ actions[a[2]] = a[1]
155+ }
156+
157+ // An anonymous POST to a moderation path is refused.
158+ assert.Equal(t, 403, b.do("POST", actions["approve"], site, "").status)
159+
160+ // The owner shares the numbers with merge requests and issues: approve.
161+ ok := b.form(actions["approve"], token, nil)
162+ require.Equal(t, 303, ok.status, ok.body)
163+ assert.Equal(t, "/-/inbox", ok.hdr.Get("Location"))
164+ assert.Equal(t, 404, b.form(actions["approve"], token, nil).status, "approved once")
165+ shown := b.get("/proj/issues/1", nil)
166+ require.Equal(t, 200, shown.status, shown.body)
167+ assert.Contains(t, shown.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
168+ assert.Contains(t, shown.body, `href="https://example.org/d"`)
169+ assert.Contains(t, shown.body, `rel="nofollow noopener noreferrer ugc"`)
170+ assert.NotContains(t, shown.body, "javascript:")
171+ assert.Contains(t, shown.body, "Alex")
172+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="/proj/issues/1"`)
173+ empty := b.get("/-/inbox", as(token))
174+ assert.Contains(t, empty.body, "<strong>0</strong> awaiting moderation")
175+
176+ // Reject and ban: the same address is then refused with the generic page.
177+ form4 := b.get("/proj/issues/new", nil)
178+ v4 := formValues(t, form4.body)
179+ v4.Set("title", "spam")
180+ require.Equal(t, 303, b.do("POST", "/proj/issues/new", site, v4.Encode()).status)
181+ inbox2 := b.get("/-/inbox", as(token))
182+ var ban string
183+ for _, a := range rePendingActions.FindAllStringSubmatch(inbox2.body, -1) {
184+ if a[2] == "ban" {
185+ ban = a[1]
186+ }
187+ }
188+ require.NotEmpty(t, ban)
189+ require.Equal(t, 303, b.form(ban, token, nil).status)
190+
191+ form5 := b.get("/proj/issues/new", nil)
192+ require.Equal(t, 200, form5.status, "the form is still served; the ban bites at submission")
193+ v5 := formValues(t, form5.body)
194+ v5.Set("title", "more spam")
195+ banned := b.do("POST", "/proj/issues/new", site, v5.Encode())
196+ assert.Equal(t, 400, banned.status)
197+ assert.Contains(t, banned.body, "The submission could not be verified. Reload the page and try again.")
198+ assert.NotContains(t, banned.body, "ban")
199+ assert.Contains(t, b.get("/-/inbox", as(token)).body, "<strong>0</strong> awaiting moderation")
200+}
201+
202+// A repository that is not public with public issues has no form.
203+func TestWebPublicIssueFormIsNotThere(t *testing.T) {
204+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
205+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
206+ require.NoError(t, err)
207+ b := browser{h}
208+ site := map[string]string{"Origin": publicOrigin}
209+ body := url.Values{"title": {"x"}}.Encode()
210+
211+ missing := b.get("/nope/issues/new", nil)
212+ for name, mode := range map[string]domain.IssuesMode{"off": domain.IssuesOff, "owner": domain.IssuesOwner} {
213+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &mode})
214+ require.NoError(t, err)
215+ got := b.get("/proj/issues/new", nil)
216+ assert.Equal(t, 404, got.status, name)
217+ assert.Equal(t, missing.body, got.body, name+": the same page as a missing repository")
218+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, name)
219+ }
220+
221+ pub := domain.IssuesPublic
222+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
223+ require.NoError(t, err)
224+ priv := domain.VisibilityPrivate
225+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
226+ require.NoError(t, err)
227+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status, "private")
228+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, "private")
229+}
230+
231+func decodeB64URL(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
A internal/app/web_issue_test.go +139 -0
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/url"
7+ "regexp"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+var reIssueLocation = regexp.MustCompile(`^/proj/issues/(\d+)$`)
19+
20+// The issue pages end to end: the owner opens, comments on, closes and
21+// reopens an issue through the forms; an anonymous visitor reads them on a
22+// public repository and cannot write; with issues off every page is a 404.
23+func TestWebIssuePagesEndToEnd(t *testing.T) {
24+ r := newMRRepo(t, domain.VisibilityPublic)
25+ b := browser{r.harness}
26+ token := r.signIn(t)
27+
28+ // Off by default: no page, no tab, for the owner as well.
29+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
30+ assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31+ assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)
32+
33+ mode := domain.IssuesOwner
34+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
35+ require.NoError(t, err)
36+
37+ home := b.get("/proj", nil)
38+ require.Equal(t, 200, home.status)
39+ assert.Contains(t, home.body, `href="/proj/issues"`, "the Issues tab")
40+
41+ empty := b.get("/proj/issues", nil)
42+ require.Equal(t, 200, empty.status, empty.body)
43+ assert.Contains(t, empty.body, "no open issues")
44+ assert.NotContains(t, empty.body, "New issue")
45+
46+ // The form is the owner's.
47+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status)
48+ form := b.get("/proj/issues/new", as(token))
49+ require.Equal(t, 200, form.status)
50+ assert.Contains(t, form.body, `action="/proj/issues"`)
51+
52+ // A POST without the session is refused.
53+ anon := b.do("POST", "/proj/issues", map[string]string{"Origin": publicOrigin}, url.Values{"title": {"x"}}.Encode())
54+ assert.Equal(t, 403, anon.status)
55+
56+ // Create.
57+ created := b.form("/proj/issues", token, url.Values{"title": {"search is slow"}, "body": {"## Steps\n\nrun **find**"}})
58+ require.Equal(t, 303, created.status, created.body)
59+ assert.Equal(t, "no-store", created.hdr.Get("Cache-Control"))
60+ m := reIssueLocation.FindStringSubmatch(created.hdr.Get("Location"))
61+ require.Len(t, m, 2, created.hdr.Get("Location"))
62+ path := "/proj/issues/" + m[1]
63+
64+ owner := b.get(path, as(token))
65+ require.Equal(t, 200, owner.status, owner.body)
66+ assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
67+ assert.Contains(t, owner.body, "search is slow")
68+ assert.Contains(t, owner.body, "<strong>find</strong>")
69+ assert.Contains(t, owner.body, `action="`+path+`/comments"`)
70+ assert.Contains(t, owner.body, `action="`+path+`/close"`)
71+
72+ // An invalid title is a state page, nothing is created.
73+ bad := b.form("/proj/issues", token, url.Values{"title": {""}})
74+ assert.Equal(t, 400, bad.status)
75+
76+ // Comment.
77+ com := b.form(path+"/comments", token, url.Values{"text": {"reproduced\r\non main"}})
78+ require.Equal(t, 303, com.status, com.body)
79+ assert.Equal(t, path+"#c1", com.hdr.Get("Location"))
80+ assert.Equal(t, 400, b.form(path+"/comments", token, url.Values{"text": {" "}}).status, "a blank comment")
81+
82+ // Anonymous reads a public repository: no forms, public cache headers.
83+ pub := b.get(path, nil)
84+ require.Equal(t, 200, pub.status)
85+ assert.Equal(t, "no-cache", pub.hdr.Get("Cache-Control"))
86+ assert.Contains(t, pub.body, "reproduced")
87+ assert.NotContains(t, pub.body, `class="cform"`)
88+ assert.NotContains(t, pub.body, path+"/close")
89+ list := b.get("/proj/issues", nil)
90+ assert.Contains(t, list.body, `href="`+path+`"`)
91+ assert.Contains(t, list.body, "search is slow")
92+ assert.Equal(t, 403, b.do("POST", path+"/close", map[string]string{"Origin": publicOrigin}, "").status)
93+
94+ // Close, then reopen.
95+ closed := b.form(path+"/close", token, nil)
96+ require.Equal(t, 303, closed.status, closed.body)
97+ assert.Equal(t, path, closed.hdr.Get("Location"))
98+ assert.Contains(t, b.get("/proj/issues?state=closed", nil).body, `href="`+path+`"`)
99+ assert.NotContains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
100+ again := b.get(path, as(token))
101+ assert.Contains(t, again.body, `action="`+path+`/reopen"`)
102+ assert.Equal(t, 409, b.form(path+"/close", token, nil).status, "closing a closed issue")
103+
104+ reopened := b.form(path+"/reopen", token, nil)
105+ require.Equal(t, 303, reopened.status, reopened.body)
106+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
107+
108+ // The number is shared with the merge requests: #n goes to the right page.
109+ mr, err := r.forge.MergeRequests.Create(r.ctx, domain.Root(), r.name("proj"), ports.CreateMergeRequestParams{
110+ Title: "add search", Source: "feat/search", Target: "main",
111+ })
112+ require.NoError(t, err)
113+ to := b.get("/proj/issues/"+itoa64(mr.Number), nil)
114+ assert.Equal(t, 303, to.status)
115+ assert.Equal(t, "/proj/merge-requests/"+itoa64(mr.Number), to.hdr.Get("Location"))
116+ back := b.get("/proj/merge-requests/"+m[1], nil)
117+ assert.Equal(t, 303, back.status)
118+ assert.Equal(t, path, back.hdr.Get("Location"))
119+ assert.Equal(t, 404, b.get("/proj/issues/999", nil).status)
120+ assert.Equal(t, 404, b.get("/proj/merge-requests/999", nil).status)
121+
122+ // Private repository: invisible to an anonymous visitor.
123+ priv := domain.VisibilityPrivate
124+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
125+ require.NoError(t, err)
126+ assert.Equal(t, 404, b.get("/proj/issues", nil).status)
127+ assert.Equal(t, 404, b.get(path, nil).status)
128+ assert.Equal(t, 200, b.get(path, as(token)).status)
129+
130+ // Off again: 404 for everyone.
131+ off := domain.IssuesOff
132+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &off})
133+ require.NoError(t, err)
134+ assert.Equal(t, 404, b.get(path, as(token)).status)
135+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
136+ assert.Equal(t, 404, b.form(path+"/comments", token, url.Values{"text": {"x"}}).status)
137+}
138+
139+func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/url"
7+ "regexp"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+var reIssueLocation = regexp.MustCompile(`^/proj/issues/(\d+)$`)
19+
20+// The issue pages end to end: the owner opens, comments on, closes and
21+// reopens an issue through the forms; an anonymous visitor reads them on a
22+// public repository and cannot write; with issues off every page is a 404.
23+func TestWebIssuePagesEndToEnd(t *testing.T) {
24+ r := newMRRepo(t, domain.VisibilityPublic)
25+ b := browser{r.harness}
26+ token := r.signIn(t)
27+
28+ // Off by default: no page, no tab, for the owner as well.
29+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
30+ assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31+ assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)
32+
33+ mode := domain.IssuesOwner
34+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
35+ require.NoError(t, err)
36+
37+ home := b.get("/proj", nil)
38+ require.Equal(t, 200, home.status)
39+ assert.Contains(t, home.body, `href="/proj/issues"`, "the Issues tab")
40+
41+ empty := b.get("/proj/issues", nil)
42+ require.Equal(t, 200, empty.status, empty.body)
43+ assert.Contains(t, empty.body, "no open issues")
44+ assert.NotContains(t, empty.body, "New issue")
45+
46+ // The form is the owner's.
47+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status)
48+ form := b.get("/proj/issues/new", as(token))
49+ require.Equal(t, 200, form.status)
50+ assert.Contains(t, form.body, `action="/proj/issues"`)
51+
52+ // A POST without the session is refused.
53+ anon := b.do("POST", "/proj/issues", map[string]string{"Origin": publicOrigin}, url.Values{"title": {"x"}}.Encode())
54+ assert.Equal(t, 403, anon.status)
55+
56+ // Create.
57+ created := b.form("/proj/issues", token, url.Values{"title": {"search is slow"}, "body": {"## Steps\n\nrun **find**"}})
58+ require.Equal(t, 303, created.status, created.body)
59+ assert.Equal(t, "no-store", created.hdr.Get("Cache-Control"))
60+ m := reIssueLocation.FindStringSubmatch(created.hdr.Get("Location"))
61+ require.Len(t, m, 2, created.hdr.Get("Location"))
62+ path := "/proj/issues/" + m[1]
63+
64+ owner := b.get(path, as(token))
65+ require.Equal(t, 200, owner.status, owner.body)
66+ assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
67+ assert.Contains(t, owner.body, "search is slow")
68+ assert.Contains(t, owner.body, "<strong>find</strong>")
69+ assert.Contains(t, owner.body, `action="`+path+`/comments"`)
70+ assert.Contains(t, owner.body, `action="`+path+`/close"`)
71+
72+ // An invalid title is a state page, nothing is created.
73+ bad := b.form("/proj/issues", token, url.Values{"title": {""}})
74+ assert.Equal(t, 400, bad.status)
75+
76+ // Comment.
77+ com := b.form(path+"/comments", token, url.Values{"text": {"reproduced\r\non main"}})
78+ require.Equal(t, 303, com.status, com.body)
79+ assert.Equal(t, path+"#c1", com.hdr.Get("Location"))
80+ assert.Equal(t, 400, b.form(path+"/comments", token, url.Values{"text": {" "}}).status, "a blank comment")
81+
82+ // Anonymous reads a public repository: no forms, public cache headers.
83+ pub := b.get(path, nil)
84+ require.Equal(t, 200, pub.status)
85+ assert.Equal(t, "no-cache", pub.hdr.Get("Cache-Control"))
86+ assert.Contains(t, pub.body, "reproduced")
87+ assert.NotContains(t, pub.body, `class="cform"`)
88+ assert.NotContains(t, pub.body, path+"/close")
89+ list := b.get("/proj/issues", nil)
90+ assert.Contains(t, list.body, `href="`+path+`"`)
91+ assert.Contains(t, list.body, "search is slow")
92+ assert.Equal(t, 403, b.do("POST", path+"/close", map[string]string{"Origin": publicOrigin}, "").status)
93+
94+ // Close, then reopen.
95+ closed := b.form(path+"/close", token, nil)
96+ require.Equal(t, 303, closed.status, closed.body)
97+ assert.Equal(t, path, closed.hdr.Get("Location"))
98+ assert.Contains(t, b.get("/proj/issues?state=closed", nil).body, `href="`+path+`"`)
99+ assert.NotContains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
100+ again := b.get(path, as(token))
101+ assert.Contains(t, again.body, `action="`+path+`/reopen"`)
102+ assert.Equal(t, 409, b.form(path+"/close", token, nil).status, "closing a closed issue")
103+
104+ reopened := b.form(path+"/reopen", token, nil)
105+ require.Equal(t, 303, reopened.status, reopened.body)
106+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
107+
108+ // The number is shared with the merge requests: #n goes to the right page.
109+ mr, err := r.forge.MergeRequests.Create(r.ctx, domain.Root(), r.name("proj"), ports.CreateMergeRequestParams{
110+ Title: "add search", Source: "feat/search", Target: "main",
111+ })
112+ require.NoError(t, err)
113+ to := b.get("/proj/issues/"+itoa64(mr.Number), nil)
114+ assert.Equal(t, 303, to.status)
115+ assert.Equal(t, "/proj/merge-requests/"+itoa64(mr.Number), to.hdr.Get("Location"))
116+ back := b.get("/proj/merge-requests/"+m[1], nil)
117+ assert.Equal(t, 303, back.status)
118+ assert.Equal(t, path, back.hdr.Get("Location"))
119+ assert.Equal(t, 404, b.get("/proj/issues/999", nil).status)
120+ assert.Equal(t, 404, b.get("/proj/merge-requests/999", nil).status)
121+
122+ // Private repository: invisible to an anonymous visitor.
123+ priv := domain.VisibilityPrivate
124+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
125+ require.NoError(t, err)
126+ assert.Equal(t, 404, b.get("/proj/issues", nil).status)
127+ assert.Equal(t, 404, b.get(path, nil).status)
128+ assert.Equal(t, 200, b.get(path, as(token)).status)
129+
130+ // Off again: 404 for everyone.
131+ off := domain.IssuesOff
132+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &off})
133+ require.NoError(t, err)
134+ assert.Equal(t, 404, b.get(path, as(token)).status)
135+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
136+ assert.Equal(t, 404, b.form(path+"/comments", token, url.Values{"text": {"x"}}).status)
137+}
138+
139+func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
M internal/clientlimit/export_test.go +7 -0
@@ -11,3 +11,10 @@ func (l *Limiter) Tracked() int {
1111 defer l.mu.Unlock()
1212 return len(l.failures)
1313 }
14+
15+// Tracked returns how many clients have submissions on record.
16+func (l *SubmissionLimiter) Tracked() int {
17+ l.mu.Lock()
18+ defer l.mu.Unlock()
19+ return len(l.subs)
20+}
@@ -11,3 +11,10 @@ func (l *Limiter) Tracked() int {
11 defer l.mu.Unlock()11 defer l.mu.Unlock()
12 return len(l.failures)12 return len(l.failures)
13 }13 }
14+
15+// Tracked returns how many clients have submissions on record.
16+func (l *SubmissionLimiter) Tracked() int {
17+ l.mu.Lock()
18+ defer l.mu.Unlock()
19+ return len(l.subs)
20+}
A internal/clientlimit/submissions.go +178 -0
@@ -0,0 +1,178 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit
4+
5+import (
6+ "context"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// Submission windows of the public issue form.
15+const (
16+ // HourWindow and DayWindow are the sliding windows of the limits
17+ // PerHour and PerDay.
18+ HourWindow = time.Hour
19+ DayWindow = 24 * time.Hour
20+
21+ // PerHour and PerDay are the submissions one client may make in each
22+ // window. They are the limits of the form (PerHour and
23+ // PerDay), repeated here because this package may not import
24+ // the domain; a test keeps them equal.
25+ PerHour = 3
26+ PerDay = 10
27+
28+ submissionSweepInterval = HourWindow / 4
29+)
30+
31+// SubmissionLimiter limits the submissions of the public issue form per client
32+// (an IPv4 address or an IPv6 /64, see Key): PerHour in a sliding
33+// hour and PerDay in a sliding day. It also counts the
34+// submissions of all clients in the last hour. It lives in memory, is bounded
35+// like Limiter, and is safe for concurrent use.
36+type SubmissionLimiter struct {
37+ now func() time.Time
38+
39+ mu sync.Mutex
40+ subs map[string][]time.Time // oldest first, all within DayWindow
41+ global []time.Time // oldest first, all within HourWindow
42+ lastSweep time.Time
43+}
44+
45+var _ ports.SubmissionLimiter = (*SubmissionLimiter)(nil)
46+
47+// NewSubmissionLimiter builds a limiter. now defaults to time.Now.
48+func NewSubmissionLimiter(now func() time.Time) *SubmissionLimiter {
49+ if now == nil {
50+ now = time.Now
51+ }
52+ return &SubmissionLimiter{now: now, subs: make(map[string][]time.Time)}
53+}
54+
55+// Reserve checks the limits for addr and, if it may submit now, counts a
56+// submission, atomically: concurrent callers cannot all pass the check. When
57+// addr may not submit, retryAfter is how long until the oldest submission that
58+// counts leaves its window and release is a no-op. Otherwise release refunds
59+// the reservation; call it only if the submission was not stored.
60+func (l *SubmissionLimiter) Reserve(_ context.Context, addr string) (ok bool, retryAfter time.Duration, release func()) {
61+ key := limiterKey(addr)
62+ now := l.now()
63+ l.mu.Lock()
64+ defer l.mu.Unlock()
65+ l.maintain(now)
66+ ts := l.prune(key, now)
67+ var wait time.Duration
68+ if h := withinWindow(ts, now, HourWindow); len(h) >= PerHour {
69+ wait = h[len(h)-PerHour].Add(HourWindow).Sub(now)
70+ }
71+ if len(ts) >= PerDay {
72+ wait = max(wait, ts[len(ts)-PerDay].Add(DayWindow).Sub(now))
73+ }
74+ if wait > 0 {
75+ return false, max(wait, time.Second), func() {}
76+ }
77+ ts = append(ts, now)
78+ if len(ts) > PerDay*2 {
79+ ts = ts[len(ts)-PerDay*2:] // enough to compute the wait
80+ }
81+ l.subs[key] = ts
82+ l.global = append(l.pruneGlobal(now), now)
83+ if len(l.global) > maxTracked {
84+ l.global = l.global[len(l.global)-maxTracked:] // bound the memory
85+ }
86+ var once sync.Once
87+ return true, 0, func() { once.Do(func() { l.refund(key, now) }) }
88+}
89+
90+// refund removes one reservation made at t for key.
91+func (l *SubmissionLimiter) refund(key string, t time.Time) {
92+ l.mu.Lock()
93+ defer l.mu.Unlock()
94+ if ts := l.subs[key]; ts != nil {
95+ l.subs[key] = removeLast(ts, t)
96+ if len(l.subs[key]) == 0 {
97+ delete(l.subs, key)
98+ }
99+ }
100+ l.global = removeLast(l.global, t)
101+}
102+
103+// removeLast removes the last element of ts equal to t, if any.
104+func removeLast(ts []time.Time, t time.Time) []time.Time {
105+ for i := len(ts) - 1; i >= 0; i-- {
106+ if ts[i].Equal(t) {
107+ return slices.Delete(ts, i, i+1)
108+ }
109+ }
110+ return ts
111+}
112+
113+// Recent returns the submissions of addr and of all clients in the last hour.
114+func (l *SubmissionLimiter) Recent(_ context.Context, addr string) (perAddr, global int) {
115+ key := limiterKey(addr)
116+ now := l.now()
117+ l.mu.Lock()
118+ defer l.mu.Unlock()
119+ return len(withinWindow(l.prune(key, now), now, HourWindow)), len(l.pruneGlobal(now))
120+}
121+
122+// withinWindow returns the tail of ts (oldest first) that is within window.
123+func withinWindow(ts []time.Time, now time.Time, window time.Duration) []time.Time {
124+ cut := now.Add(-window)
125+ i, _ := slices.BinarySearchFunc(ts, cut, func(t, c time.Time) int {
126+ if t.After(c) {
127+ return 1
128+ }
129+ return -1
130+ })
131+ return ts[i:]
132+}
133+
134+// prune drops the submissions of key older than DayWindow. The caller holds mu.
135+func (l *SubmissionLimiter) prune(key string, now time.Time) []time.Time {
136+ ts := withinWindow(l.subs[key], now, DayWindow)
137+ if len(ts) == 0 {
138+ delete(l.subs, key)
139+ return nil
140+ }
141+ l.subs[key] = ts
142+ return ts
143+}
144+
145+// pruneGlobal drops the global submissions older than HourWindow. The caller
146+// holds mu.
147+func (l *SubmissionLimiter) pruneGlobal(now time.Time) []time.Time {
148+ l.global = withinWindow(l.global, now, HourWindow)
149+ return l.global
150+}
151+
152+// maintain keeps the table small: expired entries go at most once per
153+// submissionSweepInterval, and a table at its bound loses its oldest tenth.
154+// The caller holds mu.
155+func (l *SubmissionLimiter) maintain(now time.Time) {
156+ if now.Sub(l.lastSweep) >= submissionSweepInterval {
157+ l.lastSweep = now
158+ for key := range l.subs {
159+ l.prune(key, now)
160+ }
161+ }
162+ if len(l.subs) < maxTracked {
163+ return
164+ }
165+ drop := len(l.subs) - (maxTracked - maxTracked/evictFraction)
166+ type entry struct {
167+ key string
168+ last time.Time
169+ }
170+ all := make([]entry, 0, len(l.subs))
171+ for key, ts := range l.subs {
172+ all = append(all, entry{key, ts[len(ts)-1]})
173+ }
174+ slices.SortFunc(all, func(a, b entry) int { return a.last.Compare(b.last) })
175+ for _, e := range all[:drop] {
176+ delete(l.subs, e.key)
177+ }
178+}
@@ -0,0 +1,178 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit
4+
5+import (
6+ "context"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// Submission windows of the public issue form.
15+const (
16+ // HourWindow and DayWindow are the sliding windows of the limits
17+ // PerHour and PerDay.
18+ HourWindow = time.Hour
19+ DayWindow = 24 * time.Hour
20+
21+ // PerHour and PerDay are the submissions one client may make in each
22+ // window. They are the limits of the form (PerHour and
23+ // PerDay), repeated here because this package may not import
24+ // the domain; a test keeps them equal.
25+ PerHour = 3
26+ PerDay = 10
27+
28+ submissionSweepInterval = HourWindow / 4
29+)
30+
31+// SubmissionLimiter limits the submissions of the public issue form per client
32+// (an IPv4 address or an IPv6 /64, see Key): PerHour in a sliding
33+// hour and PerDay in a sliding day. It also counts the
34+// submissions of all clients in the last hour. It lives in memory, is bounded
35+// like Limiter, and is safe for concurrent use.
36+type SubmissionLimiter struct {
37+ now func() time.Time
38+
39+ mu sync.Mutex
40+ subs map[string][]time.Time // oldest first, all within DayWindow
41+ global []time.Time // oldest first, all within HourWindow
42+ lastSweep time.Time
43+}
44+
45+var _ ports.SubmissionLimiter = (*SubmissionLimiter)(nil)
46+
47+// NewSubmissionLimiter builds a limiter. now defaults to time.Now.
48+func NewSubmissionLimiter(now func() time.Time) *SubmissionLimiter {
49+ if now == nil {
50+ now = time.Now
51+ }
52+ return &SubmissionLimiter{now: now, subs: make(map[string][]time.Time)}
53+}
54+
55+// Reserve checks the limits for addr and, if it may submit now, counts a
56+// submission, atomically: concurrent callers cannot all pass the check. When
57+// addr may not submit, retryAfter is how long until the oldest submission that
58+// counts leaves its window and release is a no-op. Otherwise release refunds
59+// the reservation; call it only if the submission was not stored.
60+func (l *SubmissionLimiter) Reserve(_ context.Context, addr string) (ok bool, retryAfter time.Duration, release func()) {
61+ key := limiterKey(addr)
62+ now := l.now()
63+ l.mu.Lock()
64+ defer l.mu.Unlock()
65+ l.maintain(now)
66+ ts := l.prune(key, now)
67+ var wait time.Duration
68+ if h := withinWindow(ts, now, HourWindow); len(h) >= PerHour {
69+ wait = h[len(h)-PerHour].Add(HourWindow).Sub(now)
70+ }
71+ if len(ts) >= PerDay {
72+ wait = max(wait, ts[len(ts)-PerDay].Add(DayWindow).Sub(now))
73+ }
74+ if wait > 0 {
75+ return false, max(wait, time.Second), func() {}
76+ }
77+ ts = append(ts, now)
78+ if len(ts) > PerDay*2 {
79+ ts = ts[len(ts)-PerDay*2:] // enough to compute the wait
80+ }
81+ l.subs[key] = ts
82+ l.global = append(l.pruneGlobal(now), now)
83+ if len(l.global) > maxTracked {
84+ l.global = l.global[len(l.global)-maxTracked:] // bound the memory
85+ }
86+ var once sync.Once
87+ return true, 0, func() { once.Do(func() { l.refund(key, now) }) }
88+}
89+
90+// refund removes one reservation made at t for key.
91+func (l *SubmissionLimiter) refund(key string, t time.Time) {
92+ l.mu.Lock()
93+ defer l.mu.Unlock()
94+ if ts := l.subs[key]; ts != nil {
95+ l.subs[key] = removeLast(ts, t)
96+ if len(l.subs[key]) == 0 {
97+ delete(l.subs, key)
98+ }
99+ }
100+ l.global = removeLast(l.global, t)
101+}
102+
103+// removeLast removes the last element of ts equal to t, if any.
104+func removeLast(ts []time.Time, t time.Time) []time.Time {
105+ for i := len(ts) - 1; i >= 0; i-- {
106+ if ts[i].Equal(t) {
107+ return slices.Delete(ts, i, i+1)
108+ }
109+ }
110+ return ts
111+}
112+
113+// Recent returns the submissions of addr and of all clients in the last hour.
114+func (l *SubmissionLimiter) Recent(_ context.Context, addr string) (perAddr, global int) {
115+ key := limiterKey(addr)
116+ now := l.now()
117+ l.mu.Lock()
118+ defer l.mu.Unlock()
119+ return len(withinWindow(l.prune(key, now), now, HourWindow)), len(l.pruneGlobal(now))
120+}
121+
122+// withinWindow returns the tail of ts (oldest first) that is within window.
123+func withinWindow(ts []time.Time, now time.Time, window time.Duration) []time.Time {
124+ cut := now.Add(-window)
125+ i, _ := slices.BinarySearchFunc(ts, cut, func(t, c time.Time) int {
126+ if t.After(c) {
127+ return 1
128+ }
129+ return -1
130+ })
131+ return ts[i:]
132+}
133+
134+// prune drops the submissions of key older than DayWindow. The caller holds mu.
135+func (l *SubmissionLimiter) prune(key string, now time.Time) []time.Time {
136+ ts := withinWindow(l.subs[key], now, DayWindow)
137+ if len(ts) == 0 {
138+ delete(l.subs, key)
139+ return nil
140+ }
141+ l.subs[key] = ts
142+ return ts
143+}
144+
145+// pruneGlobal drops the global submissions older than HourWindow. The caller
146+// holds mu.
147+func (l *SubmissionLimiter) pruneGlobal(now time.Time) []time.Time {
148+ l.global = withinWindow(l.global, now, HourWindow)
149+ return l.global
150+}
151+
152+// maintain keeps the table small: expired entries go at most once per
153+// submissionSweepInterval, and a table at its bound loses its oldest tenth.
154+// The caller holds mu.
155+func (l *SubmissionLimiter) maintain(now time.Time) {
156+ if now.Sub(l.lastSweep) >= submissionSweepInterval {
157+ l.lastSweep = now
158+ for key := range l.subs {
159+ l.prune(key, now)
160+ }
161+ }
162+ if len(l.subs) < maxTracked {
163+ return
164+ }
165+ drop := len(l.subs) - (maxTracked - maxTracked/evictFraction)
166+ type entry struct {
167+ key string
168+ last time.Time
169+ }
170+ all := make([]entry, 0, len(l.subs))
171+ for key, ts := range l.subs {
172+ all = append(all, entry{key, ts[len(ts)-1]})
173+ }
174+ slices.SortFunc(all, func(a, b entry) int { return a.last.Compare(b.last) })
175+ for _, e := range all[:drop] {
176+ delete(l.subs, e.key)
177+ }
178+}
A internal/clientlimit/submissions_test.go +148 -0
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit_test
4+
5+import (
6+ "fmt"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/clientlimit"
16+)
17+
18+func newSubs() (*clientlimit.SubmissionLimiter, *fakeClock) {
19+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
20+ return clientlimit.NewSubmissionLimiter(clk.now), clk
21+}
22+
23+func TestSubmissionHourWindow(t *testing.T) {
24+ t.Parallel()
25+ l, clk := newSubs()
26+ for i := range clientlimit.PerHour {
27+ ok, _, _ := l.Reserve(bg, "1.2.3.4")
28+ require.True(t, ok, "submission %d", i)
29+ clk.t = clk.t.Add(time.Minute)
30+ }
31+ ok, wait, _ := l.Reserve(bg, "1.2.3.4")
32+ assert.False(t, ok)
33+ assert.Equal(t, time.Hour-3*time.Minute, wait, "until the first one leaves the hour")
34+
35+ other, _, _ := l.Reserve(bg, "5.6.7.8")
36+ assert.True(t, other, "another client is not affected")
37+
38+ clk.t = clk.t.Add(wait)
39+ ok, _, _ = l.Reserve(bg, "1.2.3.4")
40+ assert.True(t, ok, "the window slides")
41+}
42+
43+func TestSubmissionDayWindow(t *testing.T) {
44+ t.Parallel()
45+ l, clk := newSubs()
46+ start := clk.t
47+ // 3 per hour, spaced so the hourly limit never bites: one every 2 hours.
48+ for range clientlimit.PerDay {
49+ ok, _, _ := l.Reserve(bg, "1.2.3.4")
50+ require.True(t, ok)
51+ clk.t = clk.t.Add(2 * time.Hour)
52+ }
53+ // 20h after start: hourly window empty, day window full.
54+ ok, wait, _ := l.Reserve(bg, "1.2.3.4")
55+ assert.False(t, ok)
56+ assert.Equal(t, start.Add(clientlimit.DayWindow).Sub(clk.t), wait)
57+
58+ clk.t = start.Add(clientlimit.DayWindow)
59+ ok, _, _ = l.Reserve(bg, "1.2.3.4")
60+ assert.True(t, ok)
61+}
62+
63+func TestSubmissionIPv6GroupsBy64(t *testing.T) {
64+ t.Parallel()
65+ l, _ := newSubs()
66+ for i := range clientlimit.PerHour {
67+ l.Reserve(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) // same /64
68+ }
69+ ok, _, _ := l.Reserve(bg, "2001:db8:1:2:ffff::1")
70+ assert.False(t, ok, "the whole /64 shares the budget")
71+ ok, _, _ = l.Reserve(bg, "2001:db8:1:3::1")
72+ assert.True(t, ok, "another /64 does not")
73+ ok, _, _ = l.Reserve(bg, "::ffff:1.2.3.4")
74+ assert.True(t, ok)
75+ perAddr, _ := l.Recent(bg, "1.2.3.4")
76+ assert.Equal(t, 1, perAddr, "a mapped address counts as its IPv4")
77+}
78+
79+func TestSubmissionRecent(t *testing.T) {
80+ t.Parallel()
81+ l, clk := newSubs()
82+ l.Reserve(bg, "1.1.1.1")
83+ l.Reserve(bg, "1.1.1.1")
84+ l.Reserve(bg, "2.2.2.2")
85+ perAddr, global := l.Recent(bg, "1.1.1.1")
86+ assert.Equal(t, 2, perAddr)
87+ assert.Equal(t, 3, global)
88+
89+ clk.t = clk.t.Add(time.Hour)
90+ perAddr, global = l.Recent(bg, "1.1.1.1")
91+ assert.Zero(t, perAddr, "Recent looks at the last hour")
92+ assert.Zero(t, global)
93+}
94+
95+func TestSubmissionEvictsOldest(t *testing.T) {
96+ t.Parallel()
97+ l, clk := newSubs()
98+ for i := range clientlimit.MaxTracked + 10 {
99+ clk.t = clk.t.Add(time.Millisecond)
100+ l.Reserve(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
101+ }
102+ assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked, "bounded")
103+ ok, _, _ := l.Reserve(bg, "10.0.0.0")
104+ assert.True(t, ok, "the oldest were evicted")
105+}
106+
107+func TestSubmissionReserveIsAtomic(t *testing.T) {
108+ t.Parallel()
109+ l, _ := newSubs()
110+ const n = 50
111+ var granted atomic.Int32
112+ var wg sync.WaitGroup
113+ start := make(chan struct{})
114+ for range n {
115+ wg.Add(1)
116+ go func() {
117+ defer wg.Done()
118+ <-start
119+ if ok, _, _ := l.Reserve(bg, "1.2.3.4"); ok {
120+ granted.Add(1)
121+ }
122+ }()
123+ }
124+ close(start)
125+ wg.Wait()
126+ assert.Equal(t, int32(clientlimit.PerHour), granted.Load())
127+}
128+
129+func TestSubmissionReleaseRefunds(t *testing.T) {
130+ t.Parallel()
131+ l, _ := newSubs()
132+ var releases []func()
133+ for range clientlimit.PerHour {
134+ ok, _, rel := l.Reserve(bg, "1.2.3.4")
135+ require.True(t, ok)
136+ releases = append(releases, rel)
137+ }
138+ ok, _, rel := l.Reserve(bg, "1.2.3.4")
139+ require.False(t, ok)
140+ rel() // no-op on a refusal
141+ releases[0]()
142+ releases[0]() // idempotent
143+ perAddr, global := l.Recent(bg, "1.2.3.4")
144+ assert.Equal(t, clientlimit.PerHour-1, perAddr)
145+ assert.Equal(t, clientlimit.PerHour-1, global)
146+ ok, _, _ = l.Reserve(bg, "1.2.3.4")
147+ assert.True(t, ok, "the refunded slot is usable again")
148+}
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit_test
4+
5+import (
6+ "fmt"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/clientlimit"
16+)
17+
18+func newSubs() (*clientlimit.SubmissionLimiter, *fakeClock) {
19+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
20+ return clientlimit.NewSubmissionLimiter(clk.now), clk
21+}
22+
23+func TestSubmissionHourWindow(t *testing.T) {
24+ t.Parallel()
25+ l, clk := newSubs()
26+ for i := range clientlimit.PerHour {
27+ ok, _, _ := l.Reserve(bg, "1.2.3.4")
28+ require.True(t, ok, "submission %d", i)
29+ clk.t = clk.t.Add(time.Minute)
30+ }
31+ ok, wait, _ := l.Reserve(bg, "1.2.3.4")
32+ assert.False(t, ok)
33+ assert.Equal(t, time.Hour-3*time.Minute, wait, "until the first one leaves the hour")
34+
35+ other, _, _ := l.Reserve(bg, "5.6.7.8")
36+ assert.True(t, other, "another client is not affected")
37+
38+ clk.t = clk.t.Add(wait)
39+ ok, _, _ = l.Reserve(bg, "1.2.3.4")
40+ assert.True(t, ok, "the window slides")
41+}
42+
43+func TestSubmissionDayWindow(t *testing.T) {
44+ t.Parallel()
45+ l, clk := newSubs()
46+ start := clk.t
47+ // 3 per hour, spaced so the hourly limit never bites: one every 2 hours.
48+ for range clientlimit.PerDay {
49+ ok, _, _ := l.Reserve(bg, "1.2.3.4")
50+ require.True(t, ok)
51+ clk.t = clk.t.Add(2 * time.Hour)
52+ }
53+ // 20h after start: hourly window empty, day window full.
54+ ok, wait, _ := l.Reserve(bg, "1.2.3.4")
55+ assert.False(t, ok)
56+ assert.Equal(t, start.Add(clientlimit.DayWindow).Sub(clk.t), wait)
57+
58+ clk.t = start.Add(clientlimit.DayWindow)
59+ ok, _, _ = l.Reserve(bg, "1.2.3.4")
60+ assert.True(t, ok)
61+}
62+
63+func TestSubmissionIPv6GroupsBy64(t *testing.T) {
64+ t.Parallel()
65+ l, _ := newSubs()
66+ for i := range clientlimit.PerHour {
67+ l.Reserve(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) // same /64
68+ }
69+ ok, _, _ := l.Reserve(bg, "2001:db8:1:2:ffff::1")
70+ assert.False(t, ok, "the whole /64 shares the budget")
71+ ok, _, _ = l.Reserve(bg, "2001:db8:1:3::1")
72+ assert.True(t, ok, "another /64 does not")
73+ ok, _, _ = l.Reserve(bg, "::ffff:1.2.3.4")
74+ assert.True(t, ok)
75+ perAddr, _ := l.Recent(bg, "1.2.3.4")
76+ assert.Equal(t, 1, perAddr, "a mapped address counts as its IPv4")
77+}
78+
79+func TestSubmissionRecent(t *testing.T) {
80+ t.Parallel()
81+ l, clk := newSubs()
82+ l.Reserve(bg, "1.1.1.1")
83+ l.Reserve(bg, "1.1.1.1")
84+ l.Reserve(bg, "2.2.2.2")
85+ perAddr, global := l.Recent(bg, "1.1.1.1")
86+ assert.Equal(t, 2, perAddr)
87+ assert.Equal(t, 3, global)
88+
89+ clk.t = clk.t.Add(time.Hour)
90+ perAddr, global = l.Recent(bg, "1.1.1.1")
91+ assert.Zero(t, perAddr, "Recent looks at the last hour")
92+ assert.Zero(t, global)
93+}
94+
95+func TestSubmissionEvictsOldest(t *testing.T) {
96+ t.Parallel()
97+ l, clk := newSubs()
98+ for i := range clientlimit.MaxTracked + 10 {
99+ clk.t = clk.t.Add(time.Millisecond)
100+ l.Reserve(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
101+ }
102+ assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked, "bounded")
103+ ok, _, _ := l.Reserve(bg, "10.0.0.0")
104+ assert.True(t, ok, "the oldest were evicted")
105+}
106+
107+func TestSubmissionReserveIsAtomic(t *testing.T) {
108+ t.Parallel()
109+ l, _ := newSubs()
110+ const n = 50
111+ var granted atomic.Int32
112+ var wg sync.WaitGroup
113+ start := make(chan struct{})
114+ for range n {
115+ wg.Add(1)
116+ go func() {
117+ defer wg.Done()
118+ <-start
119+ if ok, _, _ := l.Reserve(bg, "1.2.3.4"); ok {
120+ granted.Add(1)
121+ }
122+ }()
123+ }
124+ close(start)
125+ wg.Wait()
126+ assert.Equal(t, int32(clientlimit.PerHour), granted.Load())
127+}
128+
129+func TestSubmissionReleaseRefunds(t *testing.T) {
130+ t.Parallel()
131+ l, _ := newSubs()
132+ var releases []func()
133+ for range clientlimit.PerHour {
134+ ok, _, rel := l.Reserve(bg, "1.2.3.4")
135+ require.True(t, ok)
136+ releases = append(releases, rel)
137+ }
138+ ok, _, rel := l.Reserve(bg, "1.2.3.4")
139+ require.False(t, ok)
140+ rel() // no-op on a refusal
141+ releases[0]()
142+ releases[0]() // idempotent
143+ perAddr, global := l.Recent(bg, "1.2.3.4")
144+ assert.Equal(t, clientlimit.PerHour-1, perAddr)
145+ assert.Equal(t, clientlimit.PerHour-1, global)
146+ ok, _, _ = l.Reserve(bg, "1.2.3.4")
147+ assert.True(t, ok, "the refunded slot is usable again")
148+}
M internal/domain/event.go +72 -0
@@ -173,6 +173,78 @@ type CommentAdded struct {
173173 // EventKind implements Event.
174174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175175
176+// IssueOpened is published when an issue is opened.
177+type IssueOpened struct {
178+ Issue Issue
179+ At time.Time
180+ // Visibility and CreatedAt are those of the repository when the event
181+ // was published, like PushEvent's.
182+ Visibility Visibility
183+ CreatedAt time.Time
184+}
185+
186+// EventKind implements Event.
187+func (IssueOpened) EventKind() string { return "issue.opened" }
188+
189+// IssueClosed is published when an issue was closed.
190+type IssueClosed struct {
191+ Issue Issue
192+ At time.Time
193+ Visibility Visibility
194+ CreatedAt time.Time
195+}
196+
197+// EventKind implements Event.
198+func (IssueClosed) EventKind() string { return "issue.closed" }
199+
200+// IssueReopened is published when a closed issue was reopened.
201+type IssueReopened struct {
202+ Issue Issue
203+ At time.Time
204+ Visibility Visibility
205+ CreatedAt time.Time
206+}
207+
208+// EventKind implements Event.
209+func (IssueReopened) EventKind() string { return "issue.reopened" }
210+
211+// IssueCommented is published when a comment was added to an issue.
212+type IssueCommented struct {
213+ Repo RepoName
214+ Number int64
215+ Comment IssueComment
216+ At time.Time
217+ Visibility Visibility
218+ CreatedAt time.Time
219+}
220+
221+// EventKind implements Event.
222+func (IssueCommented) EventKind() string { return "issue.commented" }
223+
224+// IssueSubmitted is published when an anonymous submission awaits moderation.
225+// It carries no content, and is delivered to the owner topic only: never to
226+// home or to a repository topic.
227+type IssueSubmitted struct {
228+ Repo RepoName
229+ ID PendingIssueID
230+ At time.Time
231+}
232+
233+// EventKind implements Event.
234+func (IssueSubmitted) EventKind() string { return "issue.submitted" }
235+
236+// IssuePendingRemoved is published when a pending submission is rejected, so
237+// the other open inboxes drop it. Like IssueSubmitted it carries no content
238+// and is delivered to the owner topic only.
239+type IssuePendingRemoved struct {
240+ Repo RepoName
241+ ID PendingIssueID
242+ At time.Time
243+}
244+
245+// EventKind implements Event.
246+func (IssuePendingRemoved) EventKind() string { return "issue.pending_removed" }
247+
176248 // CommentEdited is published when the text of a note was edited.
177249 type CommentEdited struct {
178250 Repo RepoName
@@ -173,6 +173,78 @@ type CommentAdded struct {
173 // EventKind implements Event.173 // EventKind implements Event.
174 func (CommentAdded) EventKind() string { return "mr.comment_added" }174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175 175
176+// IssueOpened is published when an issue is opened.
177+type IssueOpened struct {
178+ Issue Issue
179+ At time.Time
180+ // Visibility and CreatedAt are those of the repository when the event
181+ // was published, like PushEvent's.
182+ Visibility Visibility
183+ CreatedAt time.Time
184+}
185+
186+// EventKind implements Event.
187+func (IssueOpened) EventKind() string { return "issue.opened" }
188+
189+// IssueClosed is published when an issue was closed.
190+type IssueClosed struct {
191+ Issue Issue
192+ At time.Time
193+ Visibility Visibility
194+ CreatedAt time.Time
195+}
196+
197+// EventKind implements Event.
198+func (IssueClosed) EventKind() string { return "issue.closed" }
199+
200+// IssueReopened is published when a closed issue was reopened.
201+type IssueReopened struct {
202+ Issue Issue
203+ At time.Time
204+ Visibility Visibility
205+ CreatedAt time.Time
206+}
207+
208+// EventKind implements Event.
209+func (IssueReopened) EventKind() string { return "issue.reopened" }
210+
211+// IssueCommented is published when a comment was added to an issue.
212+type IssueCommented struct {
213+ Repo RepoName
214+ Number int64
215+ Comment IssueComment
216+ At time.Time
217+ Visibility Visibility
218+ CreatedAt time.Time
219+}
220+
221+// EventKind implements Event.
222+func (IssueCommented) EventKind() string { return "issue.commented" }
223+
224+// IssueSubmitted is published when an anonymous submission awaits moderation.
225+// It carries no content, and is delivered to the owner topic only: never to
226+// home or to a repository topic.
227+type IssueSubmitted struct {
228+ Repo RepoName
229+ ID PendingIssueID
230+ At time.Time
231+}
232+
233+// EventKind implements Event.
234+func (IssueSubmitted) EventKind() string { return "issue.submitted" }
235+
236+// IssuePendingRemoved is published when a pending submission is rejected, so
237+// the other open inboxes drop it. Like IssueSubmitted it carries no content
238+// and is delivered to the owner topic only.
239+type IssuePendingRemoved struct {
240+ Repo RepoName
241+ ID PendingIssueID
242+ At time.Time
243+}
244+
245+// EventKind implements Event.
246+func (IssuePendingRemoved) EventKind() string { return "issue.pending_removed" }
247+
176 // CommentEdited is published when the text of a note was edited.248 // CommentEdited is published when the text of a note was edited.
177 type CommentEdited struct {249 type CommentEdited struct {
178 Repo RepoName250 Repo RepoName
A internal/domain/issue.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+// IssuesMode says who may write issues in a repository.
11+type IssuesMode string
12+
13+// Issues modes.
14+const (
15+ // IssuesOff turns issues off: the default.
16+ IssuesOff IssuesMode = "off"
17+ // IssuesOwner lets the owner and tokens with ScopeIssueWrite write issues.
18+ IssuesOwner IssuesMode = "owner"
19+ // IssuesPublic also opens the repository to submissions from anyone.
20+ IssuesPublic IssuesMode = "public"
21+)
22+
23+// IssuesModes returns the three modes in their canonical order.
24+func IssuesModes() []IssuesMode { return []IssuesMode{IssuesOff, IssuesOwner, IssuesPublic} }
25+
26+// ParseIssuesMode parses "off", "owner" or "public".
27+func ParseIssuesMode(s string) (IssuesMode, error) {
28+ for _, m := range IssuesModes() {
29+ if string(m) == s {
30+ return m, nil
31+ }
32+ }
33+ return "", fmt.Errorf("%w: issues mode %q (want off, owner or public)", ErrInvalid, s)
34+}
35+
36+// String returns the mode as text.
37+func (m IssuesMode) String() string { return string(m) }
38+
39+// OrDefault returns IssuesOff for the empty mode, else m.
40+func (m IssuesMode) OrDefault() IssuesMode {
41+ if m == "" {
42+ return IssuesOff
43+ }
44+ return m
45+}
46+
47+// IssueState is where an issue is in its life.
48+type IssueState string
49+
50+// Issue states.
51+const (
52+ // IssueStateOpen is being worked on.
53+ IssueStateOpen IssueState = "open"
54+ // IssueStateClosed is done with; it can be reopened.
55+ IssueStateClosed IssueState = "closed"
56+)
57+
58+// Issue is a problem or a request about a repository. It shares its number
59+// sequence with the merge requests of the repository.
60+type Issue struct {
61+ Repo RepoName
62+ Number int64
63+ Title IssueTitle
64+ // Body may be empty.
65+ Body IssueBody
66+ State IssueState
67+ // Author is the label of the token that opened it, "root" or "owner".
68+ Author string
69+ CreatedAt time.Time
70+ UpdatedAt time.Time
71+ // CommentSeq is the id of the last comment of the issue.
72+ CommentSeq int64
73+ // External is true for an issue approved from the public form: its text
74+ // comes from an anonymous visitor and is rendered as untrusted.
75+ External bool
76+ // Version is set by the store on read: Update fails with ErrConflict when
77+ // the stored issue changed since. Opaque to everyone else.
78+ Version uint64
79+}
80+
81+// IsOpen reports whether the issue is open.
82+func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
83+
84+// IssueComment is a comment on an issue.
85+type IssueComment struct {
86+ ID int64
87+ Author string
88+ Text IssueBody
89+ CreatedAt time.Time
90+ // Version is set by the store on read, like Issue's.
91+ Version uint64
92+}
93+
94+// NumberKind says what a number of a repository names. Issues and merge
95+// requests share one sequence, so a number names at most one of them.
96+type NumberKind int
97+
98+// Number kinds.
99+const (
100+ // NumberNone: no issue or merge request has this number.
101+ NumberNone NumberKind = iota
102+ // NumberIssue: the number names an issue.
103+ NumberIssue
104+ // NumberMergeRequest: the number names a merge request.
105+ NumberMergeRequest
106+)
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+// IssuesMode says who may write issues in a repository.
11+type IssuesMode string
12+
13+// Issues modes.
14+const (
15+ // IssuesOff turns issues off: the default.
16+ IssuesOff IssuesMode = "off"
17+ // IssuesOwner lets the owner and tokens with ScopeIssueWrite write issues.
18+ IssuesOwner IssuesMode = "owner"
19+ // IssuesPublic also opens the repository to submissions from anyone.
20+ IssuesPublic IssuesMode = "public"
21+)
22+
23+// IssuesModes returns the three modes in their canonical order.
24+func IssuesModes() []IssuesMode { return []IssuesMode{IssuesOff, IssuesOwner, IssuesPublic} }
25+
26+// ParseIssuesMode parses "off", "owner" or "public".
27+func ParseIssuesMode(s string) (IssuesMode, error) {
28+ for _, m := range IssuesModes() {
29+ if string(m) == s {
30+ return m, nil
31+ }
32+ }
33+ return "", fmt.Errorf("%w: issues mode %q (want off, owner or public)", ErrInvalid, s)
34+}
35+
36+// String returns the mode as text.
37+func (m IssuesMode) String() string { return string(m) }
38+
39+// OrDefault returns IssuesOff for the empty mode, else m.
40+func (m IssuesMode) OrDefault() IssuesMode {
41+ if m == "" {
42+ return IssuesOff
43+ }
44+ return m
45+}
46+
47+// IssueState is where an issue is in its life.
48+type IssueState string
49+
50+// Issue states.
51+const (
52+ // IssueStateOpen is being worked on.
53+ IssueStateOpen IssueState = "open"
54+ // IssueStateClosed is done with; it can be reopened.
55+ IssueStateClosed IssueState = "closed"
56+)
57+
58+// Issue is a problem or a request about a repository. It shares its number
59+// sequence with the merge requests of the repository.
60+type Issue struct {
61+ Repo RepoName
62+ Number int64
63+ Title IssueTitle
64+ // Body may be empty.
65+ Body IssueBody
66+ State IssueState
67+ // Author is the label of the token that opened it, "root" or "owner".
68+ Author string
69+ CreatedAt time.Time
70+ UpdatedAt time.Time
71+ // CommentSeq is the id of the last comment of the issue.
72+ CommentSeq int64
73+ // External is true for an issue approved from the public form: its text
74+ // comes from an anonymous visitor and is rendered as untrusted.
75+ External bool
76+ // Version is set by the store on read: Update fails with ErrConflict when
77+ // the stored issue changed since. Opaque to everyone else.
78+ Version uint64
79+}
80+
81+// IsOpen reports whether the issue is open.
82+func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
83+
84+// IssueComment is a comment on an issue.
85+type IssueComment struct {
86+ ID int64
87+ Author string
88+ Text IssueBody
89+ CreatedAt time.Time
90+ // Version is set by the store on read, like Issue's.
91+ Version uint64
92+}
93+
94+// NumberKind says what a number of a repository names. Issues and merge
95+// requests share one sequence, so a number names at most one of them.
96+type NumberKind int
97+
98+// Number kinds.
99+const (
100+ // NumberNone: no issue or merge request has this number.
101+ NumberNone NumberKind = iota
102+ // NumberIssue: the number names an issue.
103+ NumberIssue
104+ // NumberMergeRequest: the number names a merge request.
105+ NumberMergeRequest
106+)
A internal/domain/issue_test.go +138 -0
@@ -0,0 +1,138 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "errors"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+func TestParseIssueTitle(t *testing.T) {
17+ t.Parallel()
18+ tests := []struct {
19+ name, in, want, reason string // reason "" when valid
20+ }{
21+ {"plain", "Crash on start", "Crash on start", ""},
22+ {"max length in code points", strings.Repeat("é", 120), strings.Repeat("é", 120), ""},
23+ {"too long", strings.Repeat("a", 121), "", "longer than 120"},
24+ {"emoji count as one", strings.Repeat("🎉", 120), strings.Repeat("🎉", 120), ""},
25+ {"empty", "", "", "is empty"},
26+ {"blank", " \t ", "", "is empty"},
27+ {"only controls", "\x01\x02", "", "is empty"},
28+ {"newline", "a\nb", "", "one line"},
29+ {"carriage return", "a\rb", "", "one line"},
30+ {"nfc", "e\u0301", "\u00e9", ""},
31+ {"c0 stripped", "a\x00b\x1bc\tz", "abcz", ""},
32+ {"c1 stripped", "a\u0085b\u009fc", "abc", ""},
33+ {"del stripped", "a\x7fb", "ab", ""},
34+ {"bidi stripped", "a\u202ab\u202ec\u2066d\u2069e\u200ef\u200fg\u061ch", "abcdefgh", ""},
35+ {"not utf-8", "a\xffb", "", "UTF-8"},
36+ }
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ t.Parallel()
40+ got, err := domain.ParseIssueTitle(tt.in)
41+ if tt.reason == "" {
42+ require.NoError(t, err)
43+ assert.Equal(t, tt.want, got.String())
44+ return
45+ }
46+ var te *domain.TextError
47+ require.True(t, errors.As(err, &te), "%v", err)
48+ assert.ErrorIs(t, err, domain.ErrInvalidText)
49+ assert.Contains(t, te.Reason, tt.reason)
50+ })
51+ }
52+}
53+
54+func TestParseIssueBody(t *testing.T) {
55+ t.Parallel()
56+ tests := []struct {
57+ name, in, want, reason string
58+ }{
59+ {"empty allowed", "", "", ""},
60+ {"multiline and tabs kept", "a\n\tb\n", "a\n\tb\n", ""},
61+ {"crlf becomes lf", "a\r\nb", "a\nb", ""},
62+ {"max length", strings.Repeat("é", 10000), strings.Repeat("é", 10000), ""},
63+ {"too long", strings.Repeat("a", 10001), "", "longer than 10000"},
64+ {"nfc", "e\u0301\n", "\u00e9\n", ""},
65+ {"controls stripped", "a\x00\x08b\u0085c\u009fd\x7f", "abcd", ""},
66+ {"bidi stripped", "\u202etxt\u202c \u2067x\u2069", "txt x", ""},
67+ {"separators stripped", "a\u2028b\u2029c", "abc", ""},
68+ {"not utf-8", "\xc3\x28", "", "UTF-8"},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.name, func(t *testing.T) {
72+ t.Parallel()
73+ got, err := domain.ParseIssueBody(tt.in)
74+ if tt.reason == "" {
75+ require.NoError(t, err)
76+ assert.Equal(t, tt.want, got.String())
77+ return
78+ }
79+ var te *domain.TextError
80+ require.True(t, errors.As(err, &te), "%v", err)
81+ assert.Contains(t, te.Reason, tt.reason)
82+ })
83+ }
84+}
85+
86+func TestParseAuthorLabel(t *testing.T) {
87+ t.Parallel()
88+ tests := []struct {
89+ name, in, want, reason string
90+ }{
91+ {"plain", "Ada", "Ada", ""},
92+ {"empty allowed", "", "", ""},
93+ {"max length", strings.Repeat("é", 40), strings.Repeat("é", 40), ""},
94+ {"too long", strings.Repeat("a", 41), "", "longer than 40"},
95+ {"newline", "a\nb", "", "one line"},
96+ {"nfc", "e\u0301", "\u00e9", ""},
97+ {"stripped", "A\u202ed\x00a", "Ada", ""},
98+ {"trimmed", " Ada ", "Ada", ""},
99+ {"not utf-8", "\xff", "", "UTF-8"},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ t.Parallel()
104+ got, err := domain.ParseAuthorLabel(tt.in)
105+ if tt.reason == "" {
106+ require.NoError(t, err)
107+ assert.Equal(t, tt.want, got)
108+ return
109+ }
110+ var te *domain.TextError
111+ require.True(t, errors.As(err, &te), "%v", err)
112+ assert.Contains(t, te.Reason, tt.reason)
113+ })
114+ }
115+}
116+
117+func TestIssuesMode(t *testing.T) {
118+ t.Parallel()
119+ for _, m := range domain.IssuesModes() {
120+ got, err := domain.ParseIssuesMode(m.String())
121+ require.NoError(t, err)
122+ assert.Equal(t, m, got)
123+ }
124+ _, err := domain.ParseIssuesMode("all")
125+ assert.ErrorIs(t, err, domain.ErrInvalid)
126+ assert.Equal(t, domain.IssuesOff, domain.DefaultRepoSettings().Issues)
127+ assert.False(t, domain.DefaultRepoSettings().IsZero())
128+ assert.True(t, domain.RepoSettings{}.IsZero())
129+ assert.Equal(t, domain.IssuesOff, domain.IssuesMode("").OrDefault())
130+}
131+
132+func TestIssueEventKinds(t *testing.T) {
133+ t.Parallel()
134+ assert.Equal(t, "issue.opened", domain.IssueOpened{}.EventKind())
135+ assert.Equal(t, "issue.closed", domain.IssueClosed{}.EventKind())
136+ assert.Equal(t, "issue.reopened", domain.IssueReopened{}.EventKind())
137+ assert.Equal(t, "issue.commented", domain.IssueCommented{}.EventKind())
138+}
@@ -0,0 +1,138 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "errors"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+func TestParseIssueTitle(t *testing.T) {
17+ t.Parallel()
18+ tests := []struct {
19+ name, in, want, reason string // reason "" when valid
20+ }{
21+ {"plain", "Crash on start", "Crash on start", ""},
22+ {"max length in code points", strings.Repeat("é", 120), strings.Repeat("é", 120), ""},
23+ {"too long", strings.Repeat("a", 121), "", "longer than 120"},
24+ {"emoji count as one", strings.Repeat("🎉", 120), strings.Repeat("🎉", 120), ""},
25+ {"empty", "", "", "is empty"},
26+ {"blank", " \t ", "", "is empty"},
27+ {"only controls", "\x01\x02", "", "is empty"},
28+ {"newline", "a\nb", "", "one line"},
29+ {"carriage return", "a\rb", "", "one line"},
30+ {"nfc", "e\u0301", "\u00e9", ""},
31+ {"c0 stripped", "a\x00b\x1bc\tz", "abcz", ""},
32+ {"c1 stripped", "a\u0085b\u009fc", "abc", ""},
33+ {"del stripped", "a\x7fb", "ab", ""},
34+ {"bidi stripped", "a\u202ab\u202ec\u2066d\u2069e\u200ef\u200fg\u061ch", "abcdefgh", ""},
35+ {"not utf-8", "a\xffb", "", "UTF-8"},
36+ }
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ t.Parallel()
40+ got, err := domain.ParseIssueTitle(tt.in)
41+ if tt.reason == "" {
42+ require.NoError(t, err)
43+ assert.Equal(t, tt.want, got.String())
44+ return
45+ }
46+ var te *domain.TextError
47+ require.True(t, errors.As(err, &te), "%v", err)
48+ assert.ErrorIs(t, err, domain.ErrInvalidText)
49+ assert.Contains(t, te.Reason, tt.reason)
50+ })
51+ }
52+}
53+
54+func TestParseIssueBody(t *testing.T) {
55+ t.Parallel()
56+ tests := []struct {
57+ name, in, want, reason string
58+ }{
59+ {"empty allowed", "", "", ""},
60+ {"multiline and tabs kept", "a\n\tb\n", "a\n\tb\n", ""},
61+ {"crlf becomes lf", "a\r\nb", "a\nb", ""},
62+ {"max length", strings.Repeat("é", 10000), strings.Repeat("é", 10000), ""},
63+ {"too long", strings.Repeat("a", 10001), "", "longer than 10000"},
64+ {"nfc", "e\u0301\n", "\u00e9\n", ""},
65+ {"controls stripped", "a\x00\x08b\u0085c\u009fd\x7f", "abcd", ""},
66+ {"bidi stripped", "\u202etxt\u202c \u2067x\u2069", "txt x", ""},
67+ {"separators stripped", "a\u2028b\u2029c", "abc", ""},
68+ {"not utf-8", "\xc3\x28", "", "UTF-8"},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.name, func(t *testing.T) {
72+ t.Parallel()
73+ got, err := domain.ParseIssueBody(tt.in)
74+ if tt.reason == "" {
75+ require.NoError(t, err)
76+ assert.Equal(t, tt.want, got.String())
77+ return
78+ }
79+ var te *domain.TextError
80+ require.True(t, errors.As(err, &te), "%v", err)
81+ assert.Contains(t, te.Reason, tt.reason)
82+ })
83+ }
84+}
85+
86+func TestParseAuthorLabel(t *testing.T) {
87+ t.Parallel()
88+ tests := []struct {
89+ name, in, want, reason string
90+ }{
91+ {"plain", "Ada", "Ada", ""},
92+ {"empty allowed", "", "", ""},
93+ {"max length", strings.Repeat("é", 40), strings.Repeat("é", 40), ""},
94+ {"too long", strings.Repeat("a", 41), "", "longer than 40"},
95+ {"newline", "a\nb", "", "one line"},
96+ {"nfc", "e\u0301", "\u00e9", ""},
97+ {"stripped", "A\u202ed\x00a", "Ada", ""},
98+ {"trimmed", " Ada ", "Ada", ""},
99+ {"not utf-8", "\xff", "", "UTF-8"},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ t.Parallel()
104+ got, err := domain.ParseAuthorLabel(tt.in)
105+ if tt.reason == "" {
106+ require.NoError(t, err)
107+ assert.Equal(t, tt.want, got)
108+ return
109+ }
110+ var te *domain.TextError
111+ require.True(t, errors.As(err, &te), "%v", err)
112+ assert.Contains(t, te.Reason, tt.reason)
113+ })
114+ }
115+}
116+
117+func TestIssuesMode(t *testing.T) {
118+ t.Parallel()
119+ for _, m := range domain.IssuesModes() {
120+ got, err := domain.ParseIssuesMode(m.String())
121+ require.NoError(t, err)
122+ assert.Equal(t, m, got)
123+ }
124+ _, err := domain.ParseIssuesMode("all")
125+ assert.ErrorIs(t, err, domain.ErrInvalid)
126+ assert.Equal(t, domain.IssuesOff, domain.DefaultRepoSettings().Issues)
127+ assert.False(t, domain.DefaultRepoSettings().IsZero())
128+ assert.True(t, domain.RepoSettings{}.IsZero())
129+ assert.Equal(t, domain.IssuesOff, domain.IssuesMode("").OrDefault())
130+}
131+
132+func TestIssueEventKinds(t *testing.T) {
133+ t.Parallel()
134+ assert.Equal(t, "issue.opened", domain.IssueOpened{}.EventKind())
135+ assert.Equal(t, "issue.closed", domain.IssueClosed{}.EventKind())
136+ assert.Equal(t, "issue.reopened", domain.IssueReopened{}.EventKind())
137+ assert.Equal(t, "issue.commented", domain.IssueCommented{}.EventKind())
138+}
M internal/domain/mergerequest.go +3 -1
@@ -43,12 +43,14 @@ type RepoSettings struct {
4343 AllowedMergeModes []MergeMode
4444 // FailingChecksBlock says that a failing check prevents the merge.
4545 FailingChecksBlock bool
46+ // Issues says who may write issues; the empty mode means IssuesOff.
47+ Issues IssuesMode
4648 }
4749
4850 // DefaultRepoSettings are the settings of a new repository: both modes, and
4951 // failing checks block the merge.
5052 func DefaultRepoSettings() RepoSettings {
51- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true}
53+ return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
5254 }
5355
5456 // IsZero reports whether s holds no mode, which only the zero value does.
@@ -43,12 +43,14 @@ type RepoSettings struct {
43 AllowedMergeModes []MergeMode43 AllowedMergeModes []MergeMode
44 // FailingChecksBlock says that a failing check prevents the merge.44 // FailingChecksBlock says that a failing check prevents the merge.
45 FailingChecksBlock bool45 FailingChecksBlock bool
46+ // Issues says who may write issues; the empty mode means IssuesOff.
47+ Issues IssuesMode
46 }48 }
47 49
48 // DefaultRepoSettings are the settings of a new repository: both modes, and50 // DefaultRepoSettings are the settings of a new repository: both modes, and
49 // failing checks block the merge.51 // failing checks block the merge.
50 func DefaultRepoSettings() RepoSettings {52 func DefaultRepoSettings() RepoSettings {
51- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true}53+ return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
52 }54 }
53 55
54 // IsZero reports whether s holds no mode, which only the zero value does.56 // IsZero reports whether s holds no mode, which only the zero value does.
A internal/domain/pending_issue.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/rand"
7+ "encoding/base32"
8+ "fmt"
9+ "strings"
10+ "time"
11+ "unicode/utf8"
12+)
13+
14+// Limits of the public issue form (ADR 0031).
15+const (
16+ // MaxPendingIssues is the most issues awaiting moderation, over every
17+ // repository. Past it submissions are refused.
18+ MaxPendingIssues = 200
19+ // SubmitPerHour and SubmitPerDay are the submissions one client may make
20+ // in a sliding hour and a sliding day.
21+ SubmitPerHour = 3
22+ SubmitPerDay = 10
23+ // BanDuration is how long a rejected submitter stays banned.
24+ BanDuration = 30 * 24 * time.Hour
25+ // MaxPendingAuthorLen is the longest display name of a submitter, in
26+ // code points.
27+ MaxPendingAuthorLen = 40
28+ // PendingIssueIDLen is the length of a pending issue id: 16 random bytes
29+ // in unpadded lowercase base32.
30+ PendingIssueIDLen = 26
31+ // IPHashLen is the size of the HMAC of a client key.
32+ IPHashLen = 32
33+)
34+
35+var pendingIDEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
36+
37+// PendingIssueID identifies an issue awaiting moderation. It is random and
38+// opaque, not a number: a pending issue has none yet.
39+type PendingIssueID string
40+
41+// NewPendingIssueID returns a fresh random id.
42+func NewPendingIssueID() (PendingIssueID, error) {
43+ var b [16]byte
44+ if _, err := rand.Read(b[:]); err != nil {
45+ return "", fmt.Errorf("generate pending issue id: %w", err)
46+ }
47+ return PendingIssueID(strings.ToLower(pendingIDEncoding.EncodeToString(b[:]))), nil
48+}
49+
50+// ParsePendingIssueID accepts exactly what NewPendingIssueID produces: 26
51+// characters of a-z and 2-7. Anything else gives an error wrapping ErrInvalid.
52+func ParsePendingIssueID(s string) (PendingIssueID, error) {
53+ if len(s) != PendingIssueIDLen {
54+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
55+ }
56+ for i := range len(s) {
57+ c := s[i]
58+ if (c < 'a' || c > 'z') && (c < '2' || c > '7') {
59+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
60+ }
61+ }
62+ // 26 base32 characters carry 130 bits; the last one must not set the two
63+ // spare bits, or the id would not round-trip.
64+ raw, err := pendingIDEncoding.DecodeString(strings.ToUpper(s))
65+ if err != nil || len(raw) != 16 || strings.ToLower(pendingIDEncoding.EncodeToString(raw)) != s {
66+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
67+ }
68+ return PendingIssueID(s), nil
69+}
70+
71+// String returns the id as text.
72+func (id PendingIssueID) String() string { return string(id) }
73+
74+// ParsePendingAuthor validates the optional display name of a submitter: valid
75+// UTF-8, one line, at most MaxPendingAuthorLen code points, normalized.
76+// The empty name is valid.
77+func ParsePendingAuthor(s string) (string, error) {
78+ if !utf8.ValidString(s) {
79+ return "", &TextError{Reason: "is not valid UTF-8"}
80+ }
81+ c := cleanText(s, false)
82+ if n := utf8.RuneCountInString(c); n > MaxPendingAuthorLen {
83+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxPendingAuthorLen, n)}
84+ }
85+ return c, nil
86+}
87+
88+// AnonymousAuthor labels an approved issue whose submitter gave no name.
89+const AnonymousAuthor = "anonymous"
90+
91+// ApprovedIssue is the open, external issue a pending submission becomes when
92+// the owner approves it at now. The store sets the number. A submitter who gave
93+// no name is AnonymousAuthor; the issue keeps the submission time as its
94+// creation time.
95+func ApprovedIssue(p PendingIssue, now time.Time) Issue {
96+ author := p.Author
97+ if author == "" {
98+ author = AnonymousAuthor
99+ }
100+ return Issue{
101+ Repo: p.Repo, Title: p.Title, Body: p.Body, State: IssueStateOpen,
102+ Author: author, CreatedAt: p.CreatedAt, UpdatedAt: now, External: true,
103+ }
104+}
105+
106+// PendingIssue is an issue submitted through the public form, awaiting the
107+// owner's decision. It has no number: approval gives it one.
108+type PendingIssue struct {
109+ ID PendingIssueID
110+ Repo RepoName
111+ Title IssueTitle
112+ Body IssueBody
113+ // Author is the display name the submitter gave; it may be empty and is
114+ // not verified.
115+ Author string
116+ CreatedAt time.Time
117+ // IPHash is the HMAC-SHA256 of the client key, IPHashLen bytes. It lets
118+ // the owner ban a submitter without the server keeping an address.
119+ IPHash []byte
120+ // Version is set by the store on read; opaque to everyone else.
121+ Version uint64
122+}
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/rand"
7+ "encoding/base32"
8+ "fmt"
9+ "strings"
10+ "time"
11+ "unicode/utf8"
12+)
13+
14+// Limits of the public issue form (ADR 0031).
15+const (
16+ // MaxPendingIssues is the most issues awaiting moderation, over every
17+ // repository. Past it submissions are refused.
18+ MaxPendingIssues = 200
19+ // SubmitPerHour and SubmitPerDay are the submissions one client may make
20+ // in a sliding hour and a sliding day.
21+ SubmitPerHour = 3
22+ SubmitPerDay = 10
23+ // BanDuration is how long a rejected submitter stays banned.
24+ BanDuration = 30 * 24 * time.Hour
25+ // MaxPendingAuthorLen is the longest display name of a submitter, in
26+ // code points.
27+ MaxPendingAuthorLen = 40
28+ // PendingIssueIDLen is the length of a pending issue id: 16 random bytes
29+ // in unpadded lowercase base32.
30+ PendingIssueIDLen = 26
31+ // IPHashLen is the size of the HMAC of a client key.
32+ IPHashLen = 32
33+)
34+
35+var pendingIDEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
36+
37+// PendingIssueID identifies an issue awaiting moderation. It is random and
38+// opaque, not a number: a pending issue has none yet.
39+type PendingIssueID string
40+
41+// NewPendingIssueID returns a fresh random id.
42+func NewPendingIssueID() (PendingIssueID, error) {
43+ var b [16]byte
44+ if _, err := rand.Read(b[:]); err != nil {
45+ return "", fmt.Errorf("generate pending issue id: %w", err)
46+ }
47+ return PendingIssueID(strings.ToLower(pendingIDEncoding.EncodeToString(b[:]))), nil
48+}
49+
50+// ParsePendingIssueID accepts exactly what NewPendingIssueID produces: 26
51+// characters of a-z and 2-7. Anything else gives an error wrapping ErrInvalid.
52+func ParsePendingIssueID(s string) (PendingIssueID, error) {
53+ if len(s) != PendingIssueIDLen {
54+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
55+ }
56+ for i := range len(s) {
57+ c := s[i]
58+ if (c < 'a' || c > 'z') && (c < '2' || c > '7') {
59+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
60+ }
61+ }
62+ // 26 base32 characters carry 130 bits; the last one must not set the two
63+ // spare bits, or the id would not round-trip.
64+ raw, err := pendingIDEncoding.DecodeString(strings.ToUpper(s))
65+ if err != nil || len(raw) != 16 || strings.ToLower(pendingIDEncoding.EncodeToString(raw)) != s {
66+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
67+ }
68+ return PendingIssueID(s), nil
69+}
70+
71+// String returns the id as text.
72+func (id PendingIssueID) String() string { return string(id) }
73+
74+// ParsePendingAuthor validates the optional display name of a submitter: valid
75+// UTF-8, one line, at most MaxPendingAuthorLen code points, normalized.
76+// The empty name is valid.
77+func ParsePendingAuthor(s string) (string, error) {
78+ if !utf8.ValidString(s) {
79+ return "", &TextError{Reason: "is not valid UTF-8"}
80+ }
81+ c := cleanText(s, false)
82+ if n := utf8.RuneCountInString(c); n > MaxPendingAuthorLen {
83+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxPendingAuthorLen, n)}
84+ }
85+ return c, nil
86+}
87+
88+// AnonymousAuthor labels an approved issue whose submitter gave no name.
89+const AnonymousAuthor = "anonymous"
90+
91+// ApprovedIssue is the open, external issue a pending submission becomes when
92+// the owner approves it at now. The store sets the number. A submitter who gave
93+// no name is AnonymousAuthor; the issue keeps the submission time as its
94+// creation time.
95+func ApprovedIssue(p PendingIssue, now time.Time) Issue {
96+ author := p.Author
97+ if author == "" {
98+ author = AnonymousAuthor
99+ }
100+ return Issue{
101+ Repo: p.Repo, Title: p.Title, Body: p.Body, State: IssueStateOpen,
102+ Author: author, CreatedAt: p.CreatedAt, UpdatedAt: now, External: true,
103+ }
104+}
105+
106+// PendingIssue is an issue submitted through the public form, awaiting the
107+// owner's decision. It has no number: approval gives it one.
108+type PendingIssue struct {
109+ ID PendingIssueID
110+ Repo RepoName
111+ Title IssueTitle
112+ Body IssueBody
113+ // Author is the display name the submitter gave; it may be empty and is
114+ // not verified.
115+ Author string
116+ CreatedAt time.Time
117+ // IPHash is the HMAC-SHA256 of the client key, IPHashLen bytes. It lets
118+ // the owner ban a submitter without the server keeping an address.
119+ IPHash []byte
120+ // Version is set by the store on read; opaque to everyone else.
121+ Version uint64
122+}
A internal/domain/pending_issue_test.go +90 -0
@@ -0,0 +1,90 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+func TestPendingIssueID(t *testing.T) {
17+ t.Parallel()
18+ id, err := domain.NewPendingIssueID()
19+ require.NoError(t, err)
20+ assert.Len(t, id.String(), domain.PendingIssueIDLen)
21+ got, err := domain.ParsePendingIssueID(id.String())
22+ require.NoError(t, err)
23+ assert.Equal(t, id, got)
24+ other, err := domain.NewPendingIssueID()
25+ require.NoError(t, err)
26+ assert.NotEqual(t, id, other)
27+
28+ for _, bad := range []string{
29+ "", "short", strings.ToUpper(id.String()), id.String() + "a", id.String()[:25] + "1",
30+ strings.Repeat("a", 25) + "b", // spare bits set
31+ strings.Repeat("a", 24) + "=a", "../" + strings.Repeat("a", 23),
32+ } {
33+ _, err := domain.ParsePendingIssueID(bad)
34+ assert.ErrorIs(t, err, domain.ErrInvalid, "%q", bad)
35+ }
36+ _, err = domain.ParsePendingIssueID(strings.Repeat("a", 26))
37+ assert.NoError(t, err)
38+}
39+
40+func TestParsePendingAuthor(t *testing.T) {
41+ t.Parallel()
42+ got, err := domain.ParsePendingAuthor("")
43+ require.NoError(t, err)
44+ assert.Empty(t, got)
45+ got, err = domain.ParsePendingAuthor("Al\u202ee\x01x")
46+ require.NoError(t, err)
47+ assert.Equal(t, "Alex", got)
48+ _, err = domain.ParsePendingAuthor(strings.Repeat("a", 41))
49+ assert.Error(t, err)
50+ _, err = domain.ParsePendingAuthor("\xff")
51+ assert.Error(t, err)
52+ got, err = domain.ParsePendingAuthor(strings.Repeat("é", 40))
53+ require.NoError(t, err)
54+ assert.Len(t, []rune(got), 40)
55+}
56+
57+func TestIssueSubmittedKind(t *testing.T) {
58+ t.Parallel()
59+ assert.Equal(t, "issue.submitted", domain.IssueSubmitted{}.EventKind())
60+}
61+
62+func TestSubmissionLimitsAreTheDocumentedOnes(t *testing.T) {
63+ t.Parallel()
64+ assert.Equal(t, 3, domain.SubmitPerHour)
65+ assert.Equal(t, 10, domain.SubmitPerDay)
66+ assert.Equal(t, 200, domain.MaxPendingIssues)
67+ assert.Equal(t, 30*24*time.Hour, domain.BanDuration)
68+}
69+
70+func TestApprovedIssue(t *testing.T) {
71+ t.Parallel()
72+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
73+ now := at.Add(time.Hour)
74+ title, err := domain.ParseIssueTitle("t")
75+ require.NoError(t, err)
76+ repo, err := domain.ParseRepoName("proj")
77+ require.NoError(t, err)
78+
79+ named := domain.ApprovedIssue(domain.PendingIssue{Repo: repo, Title: title, Author: "Alex", CreatedAt: at}, now)
80+ assert.Equal(t, domain.IssueStateOpen, named.State)
81+ assert.Equal(t, "Alex", named.Author)
82+ assert.True(t, named.External)
83+ assert.Equal(t, repo, named.Repo)
84+ assert.Equal(t, at, named.CreatedAt)
85+ assert.Equal(t, now, named.UpdatedAt)
86+ assert.Zero(t, named.Number, "the store numbers it")
87+
88+ anon := domain.ApprovedIssue(domain.PendingIssue{Repo: repo, Title: title, CreatedAt: at}, now)
89+ assert.Equal(t, domain.AnonymousAuthor, anon.Author)
90+}
@@ -0,0 +1,90 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+func TestPendingIssueID(t *testing.T) {
17+ t.Parallel()
18+ id, err := domain.NewPendingIssueID()
19+ require.NoError(t, err)
20+ assert.Len(t, id.String(), domain.PendingIssueIDLen)
21+ got, err := domain.ParsePendingIssueID(id.String())
22+ require.NoError(t, err)
23+ assert.Equal(t, id, got)
24+ other, err := domain.NewPendingIssueID()
25+ require.NoError(t, err)
26+ assert.NotEqual(t, id, other)
27+
28+ for _, bad := range []string{
29+ "", "short", strings.ToUpper(id.String()), id.String() + "a", id.String()[:25] + "1",
30+ strings.Repeat("a", 25) + "b", // spare bits set
31+ strings.Repeat("a", 24) + "=a", "../" + strings.Repeat("a", 23),
32+ } {
33+ _, err := domain.ParsePendingIssueID(bad)
34+ assert.ErrorIs(t, err, domain.ErrInvalid, "%q", bad)
35+ }
36+ _, err = domain.ParsePendingIssueID(strings.Repeat("a", 26))
37+ assert.NoError(t, err)
38+}
39+
40+func TestParsePendingAuthor(t *testing.T) {
41+ t.Parallel()
42+ got, err := domain.ParsePendingAuthor("")
43+ require.NoError(t, err)
44+ assert.Empty(t, got)
45+ got, err = domain.ParsePendingAuthor("Al\u202ee\x01x")
46+ require.NoError(t, err)
47+ assert.Equal(t, "Alex", got)
48+ _, err = domain.ParsePendingAuthor(strings.Repeat("a", 41))
49+ assert.Error(t, err)
50+ _, err = domain.ParsePendingAuthor("\xff")
51+ assert.Error(t, err)
52+ got, err = domain.ParsePendingAuthor(strings.Repeat("é", 40))
53+ require.NoError(t, err)
54+ assert.Len(t, []rune(got), 40)
55+}
56+
57+func TestIssueSubmittedKind(t *testing.T) {
58+ t.Parallel()
59+ assert.Equal(t, "issue.submitted", domain.IssueSubmitted{}.EventKind())
60+}
61+
62+func TestSubmissionLimitsAreTheDocumentedOnes(t *testing.T) {
63+ t.Parallel()
64+ assert.Equal(t, 3, domain.SubmitPerHour)
65+ assert.Equal(t, 10, domain.SubmitPerDay)
66+ assert.Equal(t, 200, domain.MaxPendingIssues)
67+ assert.Equal(t, 30*24*time.Hour, domain.BanDuration)
68+}
69+
70+func TestApprovedIssue(t *testing.T) {
71+ t.Parallel()
72+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
73+ now := at.Add(time.Hour)
74+ title, err := domain.ParseIssueTitle("t")
75+ require.NoError(t, err)
76+ repo, err := domain.ParseRepoName("proj")
77+ require.NoError(t, err)
78+
79+ named := domain.ApprovedIssue(domain.PendingIssue{Repo: repo, Title: title, Author: "Alex", CreatedAt: at}, now)
80+ assert.Equal(t, domain.IssueStateOpen, named.State)
81+ assert.Equal(t, "Alex", named.Author)
82+ assert.True(t, named.External)
83+ assert.Equal(t, repo, named.Repo)
84+ assert.Equal(t, at, named.CreatedAt)
85+ assert.Equal(t, now, named.UpdatedAt)
86+ assert.Zero(t, named.Number, "the store numbers it")
87+
88+ anon := domain.ApprovedIssue(domain.PendingIssue{Repo: repo, Title: title, CreatedAt: at}, now)
89+ assert.Equal(t, domain.AnonymousAuthor, anon.Author)
90+}
A internal/domain/submission.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "time"
9+)
10+
11+// ErrSubmissionRefused is matched by every refusal of an anonymous issue
12+// submission, whatever the class. Unknown or closed repositories answer
13+// ErrNotFound instead, and a text that breaks the rules a *TextError.
14+var ErrSubmissionRefused = errors.New("submission refused")
15+
16+var (
17+ // ErrSubmissionRateLimited means the client submitted too often; the
18+ // error is a *SubmissionRetryError that says when to retry.
19+ ErrSubmissionRateLimited = fmt.Errorf("%w: rate limited", ErrSubmissionRefused)
20+ // ErrSubmissionsPaused means too many submissions await moderation (or
21+ // the server cannot track more challenges): nobody may submit for now.
22+ ErrSubmissionsPaused = fmt.Errorf("%w: submissions paused", ErrSubmissionRefused)
23+ // ErrSubmissionInvalid covers every bot, token, proof-of-work, timing and
24+ // honeypot failure. It is deliberately one error: it must not tell a bot
25+ // which check it failed.
26+ ErrSubmissionInvalid = fmt.Errorf("%w: invalid submission", ErrSubmissionRefused)
27+)
28+
29+// SubmissionRetryError is the ErrSubmissionRateLimited of a client that may
30+// try again after the given delay.
31+type SubmissionRetryError struct {
32+ After time.Duration
33+}
34+
35+// Error implements error.
36+func (e *SubmissionRetryError) Error() string {
37+ return fmt.Sprintf("%v: retry after %s", ErrSubmissionRateLimited, e.After.Round(time.Second))
38+}
39+
40+// Unwrap makes errors.Is match ErrSubmissionRateLimited and
41+// ErrSubmissionRefused.
42+func (e *SubmissionRetryError) Unwrap() error { return ErrSubmissionRateLimited }
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "time"
9+)
10+
11+// ErrSubmissionRefused is matched by every refusal of an anonymous issue
12+// submission, whatever the class. Unknown or closed repositories answer
13+// ErrNotFound instead, and a text that breaks the rules a *TextError.
14+var ErrSubmissionRefused = errors.New("submission refused")
15+
16+var (
17+ // ErrSubmissionRateLimited means the client submitted too often; the
18+ // error is a *SubmissionRetryError that says when to retry.
19+ ErrSubmissionRateLimited = fmt.Errorf("%w: rate limited", ErrSubmissionRefused)
20+ // ErrSubmissionsPaused means too many submissions await moderation (or
21+ // the server cannot track more challenges): nobody may submit for now.
22+ ErrSubmissionsPaused = fmt.Errorf("%w: submissions paused", ErrSubmissionRefused)
23+ // ErrSubmissionInvalid covers every bot, token, proof-of-work, timing and
24+ // honeypot failure. It is deliberately one error: it must not tell a bot
25+ // which check it failed.
26+ ErrSubmissionInvalid = fmt.Errorf("%w: invalid submission", ErrSubmissionRefused)
27+)
28+
29+// SubmissionRetryError is the ErrSubmissionRateLimited of a client that may
30+// try again after the given delay.
31+type SubmissionRetryError struct {
32+ After time.Duration
33+}
34+
35+// Error implements error.
36+func (e *SubmissionRetryError) Error() string {
37+ return fmt.Sprintf("%v: retry after %s", ErrSubmissionRateLimited, e.After.Round(time.Second))
38+}
39+
40+// Unwrap makes errors.Is match ErrSubmissionRateLimited and
41+// ErrSubmissionRefused.
42+func (e *SubmissionRetryError) Unwrap() error { return ErrSubmissionRateLimited }
M internal/domain/text.go +102 -0
@@ -5,8 +5,11 @@ package domain
55 import (
66 "errors"
77 "fmt"
8+ "strings"
89 "unicode"
910 "unicode/utf8"
11+
12+ "golang.org/x/text/unicode/norm"
1013 )
1114
1215 // Limits of the plain text of a merge request (ADR 0017), in characters.
@@ -178,3 +181,102 @@ func (v *Violations) Err() error {
178181 }
179182 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
180183 }
184+
185+// Limits of the text of an issue, in code points.
186+const (
187+ // MaxIssueTitleLen is the longest issue title.
188+ MaxIssueTitleLen = 120
189+ // MaxIssueBodyLen is the longest issue body or issue comment.
190+ MaxIssueBodyLen = 10000
191+ // MaxAuthorLabelLen is the longest author label.
192+ MaxAuthorLabelLen = 40
193+)
194+
195+// IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code
196+// points, normalized. Only ParseIssueTitle builds a non-zero value.
197+type IssueTitle struct{ s string }
198+
199+// String returns the title.
200+func (t IssueTitle) String() string { return t.s }
201+
202+// IsZero reports whether t is the zero value.
203+func (t IssueTitle) IsZero() bool { return t.s == "" }
204+
205+// IssueBody is a validated issue body or issue comment: at most
206+// MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The
207+// zero value is the empty text.
208+type IssueBody struct{ s string }
209+
210+// String returns the text.
211+func (b IssueBody) String() string { return b.s }
212+
213+// IsBlank reports whether the text is empty or only white space.
214+func (b IssueBody) IsBlank() bool { return isBlank(b.s) }
215+
216+// ParseIssueTitle cleans s (see cleanText) and validates it: one line, not
217+// blank, at most MaxIssueTitleLen code points. A line break is refused, not
218+// removed, so that two lines are never glued into one.
219+func ParseIssueTitle(s string) (IssueTitle, error) {
220+ if !utf8.ValidString(s) {
221+ return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"}
222+ }
223+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
224+ return IssueTitle{}, &TextError{Reason: "must be one line"}
225+ }
226+ c := strings.TrimSpace(cleanText(s, false))
227+ if c == "" {
228+ return IssueTitle{}, &TextError{Reason: "is empty"}
229+ }
230+ if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen {
231+ return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)}
232+ }
233+ return IssueTitle{s: c}, nil
234+}
235+
236+// ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and
237+// validates it: at most MaxIssueBodyLen code points. The empty text is valid.
238+func ParseIssueBody(s string) (IssueBody, error) {
239+ if !utf8.ValidString(s) {
240+ return IssueBody{}, &TextError{Reason: "is not valid UTF-8"}
241+ }
242+ c := cleanText(s, true)
243+ if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen {
244+ return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)}
245+ }
246+ return IssueBody{s: c}, nil
247+}
248+
249+// ParseAuthorLabel cleans s like a title (one line) and validates it: at most
250+// MaxAuthorLabelLen code points. The empty label is valid.
251+func ParseAuthorLabel(s string) (string, error) {
252+ if !utf8.ValidString(s) {
253+ return "", &TextError{Reason: "is not valid UTF-8"}
254+ }
255+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
256+ return "", &TextError{Reason: "must be one line"}
257+ }
258+ c := strings.TrimSpace(cleanText(s, false))
259+ if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen {
260+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)}
261+ }
262+ return c, nil
263+}
264+
265+// cleanText normalizes s to NFC and removes the control characters (C0, C1 and
266+// DEL; with multiline, line feeds and tabs stay) and the bidirectional marks,
267+// embeddings, overrides and isolates. s must be valid UTF-8.
268+func cleanText(s string, multiline bool) string {
269+ s = norm.NFC.String(s)
270+ if multiline {
271+ s = strings.ReplaceAll(s, "\r\n", "\n")
272+ }
273+ return strings.Map(func(r rune) rune {
274+ switch {
275+ case multiline && (r == '\n' || r == '\t'):
276+ return r
277+ case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029':
278+ return -1
279+ }
280+ return r
281+ }, s)
282+}
@@ -5,8 +5,11 @@ package domain
5 import (5 import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "strings"
8 "unicode"9 "unicode"
9 "unicode/utf8"10 "unicode/utf8"
11+
12+ "golang.org/x/text/unicode/norm"
10 )13 )
11 14
12 // Limits of the plain text of a merge request (ADR 0017), in characters.15 // Limits of the plain text of a merge request (ADR 0017), in characters.
@@ -178,3 +181,102 @@ func (v *Violations) Err() error {
178 }181 }
179 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}182 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
180 }183 }
184+
185+// Limits of the text of an issue, in code points.
186+const (
187+ // MaxIssueTitleLen is the longest issue title.
188+ MaxIssueTitleLen = 120
189+ // MaxIssueBodyLen is the longest issue body or issue comment.
190+ MaxIssueBodyLen = 10000
191+ // MaxAuthorLabelLen is the longest author label.
192+ MaxAuthorLabelLen = 40
193+)
194+
195+// IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code
196+// points, normalized. Only ParseIssueTitle builds a non-zero value.
197+type IssueTitle struct{ s string }
198+
199+// String returns the title.
200+func (t IssueTitle) String() string { return t.s }
201+
202+// IsZero reports whether t is the zero value.
203+func (t IssueTitle) IsZero() bool { return t.s == "" }
204+
205+// IssueBody is a validated issue body or issue comment: at most
206+// MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The
207+// zero value is the empty text.
208+type IssueBody struct{ s string }
209+
210+// String returns the text.
211+func (b IssueBody) String() string { return b.s }
212+
213+// IsBlank reports whether the text is empty or only white space.
214+func (b IssueBody) IsBlank() bool { return isBlank(b.s) }
215+
216+// ParseIssueTitle cleans s (see cleanText) and validates it: one line, not
217+// blank, at most MaxIssueTitleLen code points. A line break is refused, not
218+// removed, so that two lines are never glued into one.
219+func ParseIssueTitle(s string) (IssueTitle, error) {
220+ if !utf8.ValidString(s) {
221+ return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"}
222+ }
223+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
224+ return IssueTitle{}, &TextError{Reason: "must be one line"}
225+ }
226+ c := strings.TrimSpace(cleanText(s, false))
227+ if c == "" {
228+ return IssueTitle{}, &TextError{Reason: "is empty"}
229+ }
230+ if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen {
231+ return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)}
232+ }
233+ return IssueTitle{s: c}, nil
234+}
235+
236+// ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and
237+// validates it: at most MaxIssueBodyLen code points. The empty text is valid.
238+func ParseIssueBody(s string) (IssueBody, error) {
239+ if !utf8.ValidString(s) {
240+ return IssueBody{}, &TextError{Reason: "is not valid UTF-8"}
241+ }
242+ c := cleanText(s, true)
243+ if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen {
244+ return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)}
245+ }
246+ return IssueBody{s: c}, nil
247+}
248+
249+// ParseAuthorLabel cleans s like a title (one line) and validates it: at most
250+// MaxAuthorLabelLen code points. The empty label is valid.
251+func ParseAuthorLabel(s string) (string, error) {
252+ if !utf8.ValidString(s) {
253+ return "", &TextError{Reason: "is not valid UTF-8"}
254+ }
255+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
256+ return "", &TextError{Reason: "must be one line"}
257+ }
258+ c := strings.TrimSpace(cleanText(s, false))
259+ if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen {
260+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)}
261+ }
262+ return c, nil
263+}
264+
265+// cleanText normalizes s to NFC and removes the control characters (C0, C1 and
266+// DEL; with multiline, line feeds and tabs stay) and the bidirectional marks,
267+// embeddings, overrides and isolates. s must be valid UTF-8.
268+func cleanText(s string, multiline bool) string {
269+ s = norm.NFC.String(s)
270+ if multiline {
271+ s = strings.ReplaceAll(s, "\r\n", "\n")
272+ }
273+ return strings.Map(func(r rune) rune {
274+ switch {
275+ case multiline && (r == '\n' || r == '\t'):
276+ return r
277+ case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029':
278+ return -1
279+ }
280+ return r
281+ }, s)
282+}
M internal/domain/token.go +5 -1
@@ -40,15 +40,19 @@ const (
4040 ScopeRunnerRegister Scope = "runner:register"
4141 // ScopeTokenAdmin creates, lists and revokes stored tokens.
4242 ScopeTokenAdmin Scope = "token:admin"
43+ // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation
44+ // is not part of it: it stays with the owner.
45+ ScopeIssueWrite Scope = "issue:write"
4346 )
4447
45-// AllScopes returns the ten scopes in a stable order.
48+// AllScopes returns the eleven scopes in a stable order.
4649 func AllScopes() []Scope {
4750 return []Scope{
4851 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
4952 ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
5053 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
5154 ScopeTokenAdmin,
55+ ScopeIssueWrite,
5256 }
5357 }
5458
@@ -40,15 +40,19 @@ const (
40 ScopeRunnerRegister Scope = "runner:register"40 ScopeRunnerRegister Scope = "runner:register"
41 // ScopeTokenAdmin creates, lists and revokes stored tokens.41 // ScopeTokenAdmin creates, lists and revokes stored tokens.
42 ScopeTokenAdmin Scope = "token:admin"42 ScopeTokenAdmin Scope = "token:admin"
43+ // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation
44+ // is not part of it: it stays with the owner.
45+ ScopeIssueWrite Scope = "issue:write"
43 )46 )
44 47
45-// AllScopes returns the ten scopes in a stable order.48+// AllScopes returns the eleven scopes in a stable order.
46 func AllScopes() []Scope {49 func AllScopes() []Scope {
47 return []Scope{50 return []Scope{
48 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,51 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
49 ScopeMRWrite, ScopeMRMerge, ScopeMRReview,52 ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
50 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,53 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
51 ScopeTokenAdmin,54 ScopeTokenAdmin,
55+ ScopeIssueWrite,
52 }56 }
53 }57 }
54 58
M internal/domain/token_test.go +6 -1
@@ -16,7 +16,11 @@ import (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 10)
19+ assert.Len(t, all, 11)
20+ assert.Contains(t, all, domain.ScopeIssueWrite)
21+ got, err := domain.ParseScope("issue:write")
22+ require.NoError(t, err)
23+ assert.Equal(t, domain.ScopeIssueWrite, got)
2024 for _, s := range all {
2125 got, err := domain.ParseScope(s.String())
2226 require.NoError(t, err)
@@ -110,6 +114,7 @@ func TestHasOwnerRights(t *testing.T) {
110114 assert.False(t, domain.Anonymous().HasOwnerRights())
111115 assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights())
112116 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
117+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:9]}).HasOwnerRights(), "without issue:write")
113118 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
114119 }
115120
@@ -16,7 +16,11 @@ import (
16 func TestScopes(t *testing.T) {16 func TestScopes(t *testing.T) {
17 t.Parallel()17 t.Parallel()
18 all := domain.AllScopes()18 all := domain.AllScopes()
19- assert.Len(t, all, 10)19+ assert.Len(t, all, 11)
20+ assert.Contains(t, all, domain.ScopeIssueWrite)
21+ got, err := domain.ParseScope("issue:write")
22+ require.NoError(t, err)
23+ assert.Equal(t, domain.ScopeIssueWrite, got)
20 for _, s := range all {24 for _, s := range all {
21 got, err := domain.ParseScope(s.String())25 got, err := domain.ParseScope(s.String())
22 require.NoError(t, err)26 require.NoError(t, err)
@@ -110,6 +114,7 @@ func TestHasOwnerRights(t *testing.T) {
110 assert.False(t, domain.Anonymous().HasOwnerRights())114 assert.False(t, domain.Anonymous().HasOwnerRights())
111 assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights())115 assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights())
112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")116 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
117+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:9]}).HasOwnerRights(), "without issue:write")
113 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")118 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
114 }119 }
115 120
M internal/ports/dto.go +69 -0
@@ -49,6 +49,8 @@ type UpdateRepoParams struct {
4949 AllowedMergeModes *[]domain.MergeMode
5050 // FailingChecksBlock replaces the setting of that name.
5151 FailingChecksBlock *bool
52+ // Issues replaces the issues mode.
53+ Issues *domain.IssuesMode
5254 }
5355
5456 // TokenPage is a page of stored tokens, newest first.
@@ -198,6 +200,52 @@ type MergeRequestPage struct {
198200 NextPageToken string
199201 }
200202
203+// ListIssuesParams selects a page of issues from an IssueStore.
204+type ListIssuesParams struct {
205+ // State restricts the result; empty means every state.
206+ State domain.IssueState
207+ // PageSize is the most issues returned; 0 or less means no limit. The
208+ // use case chooses it.
209+ PageSize int
210+ // PageToken is the NextPageToken of the previous page, or empty.
211+ PageToken string
212+}
213+
214+// IssuePage is a page of issues, highest number first.
215+type IssuePage struct {
216+ Issues []domain.Issue
217+ // NextPageToken is empty on the last page.
218+ NextPageToken string
219+}
220+
221+// ListPendingParams selects a page of pending issues.
222+type ListPendingParams struct {
223+ // PageSize is the page size; the use case applies the default and the
224+ // maximum, a store takes it as is (0: every one).
225+ PageSize int
226+ // PageToken is the NextPageToken of the previous page.
227+ PageToken string
228+}
229+
230+// PendingIssuePage is a page of pending issues, oldest first.
231+type PendingIssuePage struct {
232+ Issues []domain.PendingIssue
233+ NextPageToken string
234+}
235+
236+// CreateIssueParams describes the issue to open.
237+type CreateIssueParams struct {
238+ Title string
239+ Body string
240+}
241+
242+// IssueDetail is an issue with its comments.
243+type IssueDetail struct {
244+ Issue domain.Issue
245+ // Comments are oldest first.
246+ Comments []domain.IssueComment
247+}
248+
201249 // MergeRequestDetail is a merge request with its review data.
202250 type MergeRequestDetail struct {
203251 MergeRequest domain.MergeRequest
@@ -282,3 +330,24 @@ type LoginLink struct {
282330 URL string
283331 ExpiresAt time.Time
284332 }
333+
334+// InboxCounts are the numbers of open items across every repository, whatever
335+// the filter.
336+type InboxCounts struct{ MergeRequests, Issues, Pending int }
337+
338+// InboxOverview is what the owner has to look at across every repository: the
339+// open merge requests and the open issues, most recently updated first. A
340+// section for the items awaiting moderation can be added next to them.
341+type InboxOverview struct {
342+ Counts InboxCounts
343+ // MergeRequests and Issues are those of the filtered repository, or of
344+ // every repository when there is no filter. Counts always counts all.
345+ MergeRequests []domain.MergeRequest
346+ Issues []domain.Issue
347+ // Pending are the issues awaiting moderation, in every repository
348+ // whatever the filter, oldest first. At most domain.MaxPendingIssues.
349+ Pending []domain.PendingIssue
350+ // Repos are the repositories that have at least one open item, by name,
351+ // whatever the filter: they feed the filter itself.
352+ Repos []domain.RepoName
353+}
@@ -49,6 +49,8 @@ type UpdateRepoParams struct {
49 AllowedMergeModes *[]domain.MergeMode49 AllowedMergeModes *[]domain.MergeMode
50 // FailingChecksBlock replaces the setting of that name.50 // FailingChecksBlock replaces the setting of that name.
51 FailingChecksBlock *bool51 FailingChecksBlock *bool
52+ // Issues replaces the issues mode.
53+ Issues *domain.IssuesMode
52 }54 }
53 55
54 // TokenPage is a page of stored tokens, newest first.56 // TokenPage is a page of stored tokens, newest first.
@@ -198,6 +200,52 @@ type MergeRequestPage struct {
198 NextPageToken string200 NextPageToken string
199 }201 }
200 202
203+// ListIssuesParams selects a page of issues from an IssueStore.
204+type ListIssuesParams struct {
205+ // State restricts the result; empty means every state.
206+ State domain.IssueState
207+ // PageSize is the most issues returned; 0 or less means no limit. The
208+ // use case chooses it.
209+ PageSize int
210+ // PageToken is the NextPageToken of the previous page, or empty.
211+ PageToken string
212+}
213+
214+// IssuePage is a page of issues, highest number first.
215+type IssuePage struct {
216+ Issues []domain.Issue
217+ // NextPageToken is empty on the last page.
218+ NextPageToken string
219+}
220+
221+// ListPendingParams selects a page of pending issues.
222+type ListPendingParams struct {
223+ // PageSize is the page size; the use case applies the default and the
224+ // maximum, a store takes it as is (0: every one).
225+ PageSize int
226+ // PageToken is the NextPageToken of the previous page.
227+ PageToken string
228+}
229+
230+// PendingIssuePage is a page of pending issues, oldest first.
231+type PendingIssuePage struct {
232+ Issues []domain.PendingIssue
233+ NextPageToken string
234+}
235+
236+// CreateIssueParams describes the issue to open.
237+type CreateIssueParams struct {
238+ Title string
239+ Body string
240+}
241+
242+// IssueDetail is an issue with its comments.
243+type IssueDetail struct {
244+ Issue domain.Issue
245+ // Comments are oldest first.
246+ Comments []domain.IssueComment
247+}
248+
201 // MergeRequestDetail is a merge request with its review data.249 // MergeRequestDetail is a merge request with its review data.
202 type MergeRequestDetail struct {250 type MergeRequestDetail struct {
203 MergeRequest domain.MergeRequest251 MergeRequest domain.MergeRequest
@@ -282,3 +330,24 @@ type LoginLink struct {
282 URL string330 URL string
283 ExpiresAt time.Time331 ExpiresAt time.Time
284 }332 }
333+
334+// InboxCounts are the numbers of open items across every repository, whatever
335+// the filter.
336+type InboxCounts struct{ MergeRequests, Issues, Pending int }
337+
338+// InboxOverview is what the owner has to look at across every repository: the
339+// open merge requests and the open issues, most recently updated first. A
340+// section for the items awaiting moderation can be added next to them.
341+type InboxOverview struct {
342+ Counts InboxCounts
343+ // MergeRequests and Issues are those of the filtered repository, or of
344+ // every repository when there is no filter. Counts always counts all.
345+ MergeRequests []domain.MergeRequest
346+ Issues []domain.Issue
347+ // Pending are the issues awaiting moderation, in every repository
348+ // whatever the filter, oldest first. At most domain.MaxPendingIssues.
349+ Pending []domain.PendingIssue
350+ // Repos are the repositories that have at least one open item, by name,
351+ // whatever the filter: they feed the filter itself.
352+ Repos []domain.RepoName
353+}
A internal/ports/mocks/InboxService.go +121 -0
@@ -0,0 +1,121 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewInboxService creates a new instance of InboxService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewInboxService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *InboxService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &InboxService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// InboxService is an autogenerated mock type for the InboxService type
39+type InboxService struct {
40+ mock.Mock
41+}
42+
43+type InboxService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *InboxService) EXPECT() *InboxService_Expecter {
48+ return &InboxService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Overview provides a mock function for the type InboxService
52+func (_mock *InboxService) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
53+ ret := _mock.Called(ctx, p, filter)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Overview")
57+ }
58+
59+ var r0 ports.InboxOverview
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.InboxOverview, error)); ok {
62+ return returnFunc(ctx, p, filter)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.InboxOverview); ok {
65+ r0 = returnFunc(ctx, p, filter)
66+ } else {
67+ r0 = ret.Get(0).(ports.InboxOverview)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
70+ r1 = returnFunc(ctx, p, filter)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// InboxService_Overview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Overview'
78+type InboxService_Overview_Call struct {
79+ *mock.Call
80+}
81+
82+// Overview is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - filter domain.RepoName
86+func (_e *InboxService_Expecter) Overview(ctx any, p any, filter any) *InboxService_Overview_Call {
87+ return &InboxService_Overview_Call{Call: _e.mock.On("Overview", ctx, p, filter)}
88+}
89+
90+func (_c *InboxService_Overview_Call) Run(run func(ctx context.Context, p domain.Principal, filter domain.RepoName)) *InboxService_Overview_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ var arg2 domain.RepoName
101+ if args[2] != nil {
102+ arg2 = args[2].(domain.RepoName)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *InboxService_Overview_Call) Return(inboxOverview ports.InboxOverview, err error) *InboxService_Overview_Call {
114+ _c.Call.Return(inboxOverview, err)
115+ return _c
116+}
117+
118+func (_c *InboxService_Overview_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error)) *InboxService_Overview_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
@@ -0,0 +1,121 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewInboxService creates a new instance of InboxService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewInboxService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *InboxService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &InboxService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// InboxService is an autogenerated mock type for the InboxService type
39+type InboxService struct {
40+ mock.Mock
41+}
42+
43+type InboxService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *InboxService) EXPECT() *InboxService_Expecter {
48+ return &InboxService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Overview provides a mock function for the type InboxService
52+func (_mock *InboxService) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
53+ ret := _mock.Called(ctx, p, filter)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Overview")
57+ }
58+
59+ var r0 ports.InboxOverview
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.InboxOverview, error)); ok {
62+ return returnFunc(ctx, p, filter)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.InboxOverview); ok {
65+ r0 = returnFunc(ctx, p, filter)
66+ } else {
67+ r0 = ret.Get(0).(ports.InboxOverview)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
70+ r1 = returnFunc(ctx, p, filter)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// InboxService_Overview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Overview'
78+type InboxService_Overview_Call struct {
79+ *mock.Call
80+}
81+
82+// Overview is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - filter domain.RepoName
86+func (_e *InboxService_Expecter) Overview(ctx any, p any, filter any) *InboxService_Overview_Call {
87+ return &InboxService_Overview_Call{Call: _e.mock.On("Overview", ctx, p, filter)}
88+}
89+
90+func (_c *InboxService_Overview_Call) Run(run func(ctx context.Context, p domain.Principal, filter domain.RepoName)) *InboxService_Overview_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ var arg2 domain.RepoName
101+ if args[2] != nil {
102+ arg2 = args[2].(domain.RepoName)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *InboxService_Overview_Call) Return(inboxOverview ports.InboxOverview, err error) *InboxService_Overview_Call {
114+ _c.Call.Return(inboxOverview, err)
115+ return _c
116+}
117+
118+func (_c *InboxService_Overview_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error)) *InboxService_Overview_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
A internal/ports/mocks/IssueBanStore.go +183 -0
@@ -0,0 +1,183 @@
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+// NewIssueBanStore creates a new instance of IssueBanStore. 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 NewIssueBanStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueBanStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueBanStore{}
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+// IssueBanStore is an autogenerated mock type for the IssueBanStore type
38+type IssueBanStore struct {
39+ mock.Mock
40+}
41+
42+type IssueBanStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueBanStore) EXPECT() *IssueBanStore_Expecter {
47+ return &IssueBanStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// Ban provides a mock function for the type IssueBanStore
51+func (_mock *IssueBanStore) Ban(ctx context.Context, hash []byte, until time.Time) error {
52+ ret := _mock.Called(ctx, hash, until)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Ban")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) error); ok {
60+ r0 = returnFunc(ctx, hash, until)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// IssueBanStore_Ban_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ban'
68+type IssueBanStore_Ban_Call struct {
69+ *mock.Call
70+}
71+
72+// Ban is a helper method to define mock.On call
73+// - ctx context.Context
74+// - hash []byte
75+// - until time.Time
76+func (_e *IssueBanStore_Expecter) Ban(ctx any, hash any, until any) *IssueBanStore_Ban_Call {
77+ return &IssueBanStore_Ban_Call{Call: _e.mock.On("Ban", ctx, hash, until)}
78+}
79+
80+func (_c *IssueBanStore_Ban_Call) Run(run func(ctx context.Context, hash []byte, until time.Time)) *IssueBanStore_Ban_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 []byte
87+ if args[1] != nil {
88+ arg1 = args[1].([]byte)
89+ }
90+ var arg2 time.Time
91+ if args[2] != nil {
92+ arg2 = args[2].(time.Time)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *IssueBanStore_Ban_Call) Return(err error) *IssueBanStore_Ban_Call {
104+ _c.Call.Return(err)
105+ return _c
106+}
107+
108+func (_c *IssueBanStore_Ban_Call) RunAndReturn(run func(ctx context.Context, hash []byte, until time.Time) error) *IssueBanStore_Ban_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
112+
113+// Banned provides a mock function for the type IssueBanStore
114+func (_mock *IssueBanStore) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) {
115+ ret := _mock.Called(ctx, hash, now)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for Banned")
119+ }
120+
121+ var r0 bool
122+ var r1 error
123+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) (bool, error)); ok {
124+ return returnFunc(ctx, hash, now)
125+ }
126+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) bool); ok {
127+ r0 = returnFunc(ctx, hash, now)
128+ } else {
129+ r0 = ret.Get(0).(bool)
130+ }
131+ if returnFunc, ok := ret.Get(1).(func(context.Context, []byte, time.Time) error); ok {
132+ r1 = returnFunc(ctx, hash, now)
133+ } else {
134+ r1 = ret.Error(1)
135+ }
136+ return r0, r1
137+}
138+
139+// IssueBanStore_Banned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Banned'
140+type IssueBanStore_Banned_Call struct {
141+ *mock.Call
142+}
143+
144+// Banned is a helper method to define mock.On call
145+// - ctx context.Context
146+// - hash []byte
147+// - now time.Time
148+func (_e *IssueBanStore_Expecter) Banned(ctx any, hash any, now any) *IssueBanStore_Banned_Call {
149+ return &IssueBanStore_Banned_Call{Call: _e.mock.On("Banned", ctx, hash, now)}
150+}
151+
152+func (_c *IssueBanStore_Banned_Call) Run(run func(ctx context.Context, hash []byte, now time.Time)) *IssueBanStore_Banned_Call {
153+ _c.Call.Run(func(args mock.Arguments) {
154+ var arg0 context.Context
155+ if args[0] != nil {
156+ arg0 = args[0].(context.Context)
157+ }
158+ var arg1 []byte
159+ if args[1] != nil {
160+ arg1 = args[1].([]byte)
161+ }
162+ var arg2 time.Time
163+ if args[2] != nil {
164+ arg2 = args[2].(time.Time)
165+ }
166+ run(
167+ arg0,
168+ arg1,
169+ arg2,
170+ )
171+ })
172+ return _c
173+}
174+
175+func (_c *IssueBanStore_Banned_Call) Return(b bool, err error) *IssueBanStore_Banned_Call {
176+ _c.Call.Return(b, err)
177+ return _c
178+}
179+
180+func (_c *IssueBanStore_Banned_Call) RunAndReturn(run func(ctx context.Context, hash []byte, now time.Time) (bool, error)) *IssueBanStore_Banned_Call {
181+ _c.Call.Return(run)
182+ return _c
183+}
@@ -0,0 +1,183 @@
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+// NewIssueBanStore creates a new instance of IssueBanStore. 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 NewIssueBanStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueBanStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueBanStore{}
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+// IssueBanStore is an autogenerated mock type for the IssueBanStore type
38+type IssueBanStore struct {
39+ mock.Mock
40+}
41+
42+type IssueBanStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueBanStore) EXPECT() *IssueBanStore_Expecter {
47+ return &IssueBanStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// Ban provides a mock function for the type IssueBanStore
51+func (_mock *IssueBanStore) Ban(ctx context.Context, hash []byte, until time.Time) error {
52+ ret := _mock.Called(ctx, hash, until)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Ban")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) error); ok {
60+ r0 = returnFunc(ctx, hash, until)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// IssueBanStore_Ban_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ban'
68+type IssueBanStore_Ban_Call struct {
69+ *mock.Call
70+}
71+
72+// Ban is a helper method to define mock.On call
73+// - ctx context.Context
74+// - hash []byte
75+// - until time.Time
76+func (_e *IssueBanStore_Expecter) Ban(ctx any, hash any, until any) *IssueBanStore_Ban_Call {
77+ return &IssueBanStore_Ban_Call{Call: _e.mock.On("Ban", ctx, hash, until)}
78+}
79+
80+func (_c *IssueBanStore_Ban_Call) Run(run func(ctx context.Context, hash []byte, until time.Time)) *IssueBanStore_Ban_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 []byte
87+ if args[1] != nil {
88+ arg1 = args[1].([]byte)
89+ }
90+ var arg2 time.Time
91+ if args[2] != nil {
92+ arg2 = args[2].(time.Time)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *IssueBanStore_Ban_Call) Return(err error) *IssueBanStore_Ban_Call {
104+ _c.Call.Return(err)
105+ return _c
106+}
107+
108+func (_c *IssueBanStore_Ban_Call) RunAndReturn(run func(ctx context.Context, hash []byte, until time.Time) error) *IssueBanStore_Ban_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
112+
113+// Banned provides a mock function for the type IssueBanStore
114+func (_mock *IssueBanStore) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) {
115+ ret := _mock.Called(ctx, hash, now)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for Banned")
119+ }
120+
121+ var r0 bool
122+ var r1 error
123+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) (bool, error)); ok {
124+ return returnFunc(ctx, hash, now)
125+ }
126+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) bool); ok {
127+ r0 = returnFunc(ctx, hash, now)
128+ } else {
129+ r0 = ret.Get(0).(bool)
130+ }
131+ if returnFunc, ok := ret.Get(1).(func(context.Context, []byte, time.Time) error); ok {
132+ r1 = returnFunc(ctx, hash, now)
133+ } else {
134+ r1 = ret.Error(1)
135+ }
136+ return r0, r1
137+}
138+
139+// IssueBanStore_Banned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Banned'
140+type IssueBanStore_Banned_Call struct {
141+ *mock.Call
142+}
143+
144+// Banned is a helper method to define mock.On call
145+// - ctx context.Context
146+// - hash []byte
147+// - now time.Time
148+func (_e *IssueBanStore_Expecter) Banned(ctx any, hash any, now any) *IssueBanStore_Banned_Call {
149+ return &IssueBanStore_Banned_Call{Call: _e.mock.On("Banned", ctx, hash, now)}
150+}
151+
152+func (_c *IssueBanStore_Banned_Call) Run(run func(ctx context.Context, hash []byte, now time.Time)) *IssueBanStore_Banned_Call {
153+ _c.Call.Run(func(args mock.Arguments) {
154+ var arg0 context.Context
155+ if args[0] != nil {
156+ arg0 = args[0].(context.Context)
157+ }
158+ var arg1 []byte
159+ if args[1] != nil {
160+ arg1 = args[1].([]byte)
161+ }
162+ var arg2 time.Time
163+ if args[2] != nil {
164+ arg2 = args[2].(time.Time)
165+ }
166+ run(
167+ arg0,
168+ arg1,
169+ arg2,
170+ )
171+ })
172+ return _c
173+}
174+
175+func (_c *IssueBanStore_Banned_Call) Return(b bool, err error) *IssueBanStore_Banned_Call {
176+ _c.Call.Return(b, err)
177+ return _c
178+}
179+
180+func (_c *IssueBanStore_Banned_Call) RunAndReturn(run func(ctx context.Context, hash []byte, now time.Time) (bool, error)) *IssueBanStore_Banned_Call {
181+ _c.Call.Return(run)
182+ return _c
183+}
A internal/ports/mocks/IssueKeyStore.go +109 -0
@@ -0,0 +1,109 @@
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+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewIssueKeyStore creates a new instance of IssueKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewIssueKeyStore(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *IssueKeyStore {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &IssueKeyStore{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// IssueKeyStore is an autogenerated mock type for the IssueKeyStore type
37+type IssueKeyStore struct {
38+ mock.Mock
39+}
40+
41+type IssueKeyStore_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *IssueKeyStore) EXPECT() *IssueKeyStore_Expecter {
46+ return &IssueKeyStore_Expecter{mock: &_m.Mock}
47+}
48+
49+// Key provides a mock function for the type IssueKeyStore
50+func (_mock *IssueKeyStore) Key(ctx context.Context) ([]byte, error) {
51+ ret := _mock.Called(ctx)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Key")
55+ }
56+
57+ var r0 []byte
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok {
60+ return returnFunc(ctx)
61+ }
62+ if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok {
63+ r0 = returnFunc(ctx)
64+ } else {
65+ if ret.Get(0) != nil {
66+ r0 = ret.Get(0).([]byte)
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
70+ r1 = returnFunc(ctx)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueKeyStore_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key'
78+type IssueKeyStore_Key_Call struct {
79+ *mock.Call
80+}
81+
82+// Key is a helper method to define mock.On call
83+// - ctx context.Context
84+func (_e *IssueKeyStore_Expecter) Key(ctx any) *IssueKeyStore_Key_Call {
85+ return &IssueKeyStore_Key_Call{Call: _e.mock.On("Key", ctx)}
86+}
87+
88+func (_c *IssueKeyStore_Key_Call) Run(run func(ctx context.Context)) *IssueKeyStore_Key_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+ run(
95+ arg0,
96+ )
97+ })
98+ return _c
99+}
100+
101+func (_c *IssueKeyStore_Key_Call) Return(bytes []byte, err error) *IssueKeyStore_Key_Call {
102+ _c.Call.Return(bytes, err)
103+ return _c
104+}
105+
106+func (_c *IssueKeyStore_Key_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *IssueKeyStore_Key_Call {
107+ _c.Call.Return(run)
108+ return _c
109+}
@@ -0,0 +1,109 @@
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+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewIssueKeyStore creates a new instance of IssueKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewIssueKeyStore(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *IssueKeyStore {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &IssueKeyStore{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// IssueKeyStore is an autogenerated mock type for the IssueKeyStore type
37+type IssueKeyStore struct {
38+ mock.Mock
39+}
40+
41+type IssueKeyStore_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *IssueKeyStore) EXPECT() *IssueKeyStore_Expecter {
46+ return &IssueKeyStore_Expecter{mock: &_m.Mock}
47+}
48+
49+// Key provides a mock function for the type IssueKeyStore
50+func (_mock *IssueKeyStore) Key(ctx context.Context) ([]byte, error) {
51+ ret := _mock.Called(ctx)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Key")
55+ }
56+
57+ var r0 []byte
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok {
60+ return returnFunc(ctx)
61+ }
62+ if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok {
63+ r0 = returnFunc(ctx)
64+ } else {
65+ if ret.Get(0) != nil {
66+ r0 = ret.Get(0).([]byte)
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
70+ r1 = returnFunc(ctx)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueKeyStore_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key'
78+type IssueKeyStore_Key_Call struct {
79+ *mock.Call
80+}
81+
82+// Key is a helper method to define mock.On call
83+// - ctx context.Context
84+func (_e *IssueKeyStore_Expecter) Key(ctx any) *IssueKeyStore_Key_Call {
85+ return &IssueKeyStore_Key_Call{Call: _e.mock.On("Key", ctx)}
86+}
87+
88+func (_c *IssueKeyStore_Key_Call) Run(run func(ctx context.Context)) *IssueKeyStore_Key_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+ run(
95+ arg0,
96+ )
97+ })
98+ return _c
99+}
100+
101+func (_c *IssueKeyStore_Key_Call) Return(bytes []byte, err error) *IssueKeyStore_Key_Call {
102+ _c.Call.Return(bytes, err)
103+ return _c
104+}
105+
106+func (_c *IssueKeyStore_Key_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *IssueKeyStore_Key_Call {
107+ _c.Call.Return(run)
108+ return _c
109+}
A internal/ports/mocks/IssueModerationService.go +280 -0
@@ -0,0 +1,280 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueModerationService creates a new instance of IssueModerationService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueModerationService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueModerationService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueModerationService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueModerationService is an autogenerated mock type for the IssueModerationService type
39+type IssueModerationService struct {
40+ mock.Mock
41+}
42+
43+type IssueModerationService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueModerationService) EXPECT() *IssueModerationService_Expecter {
48+ return &IssueModerationService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Approve provides a mock function for the type IssueModerationService
52+func (_mock *IssueModerationService) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, p, repo, id)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Approve")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, p, repo, id)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) domain.Issue); ok {
65+ r0 = returnFunc(ctx, p, repo, id)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) error); ok {
70+ r1 = returnFunc(ctx, p, repo, id)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueModerationService_Approve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Approve'
78+type IssueModerationService_Approve_Call struct {
79+ *mock.Call
80+}
81+
82+// Approve is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - repo domain.RepoName
86+// - id domain.PendingIssueID
87+func (_e *IssueModerationService_Expecter) Approve(ctx any, p any, repo any, id any) *IssueModerationService_Approve_Call {
88+ return &IssueModerationService_Approve_Call{Call: _e.mock.On("Approve", ctx, p, repo, id)}
89+}
90+
91+func (_c *IssueModerationService_Approve_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID)) *IssueModerationService_Approve_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.Principal
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.Principal)
100+ }
101+ var arg2 domain.RepoName
102+ if args[2] != nil {
103+ arg2 = args[2].(domain.RepoName)
104+ }
105+ var arg3 domain.PendingIssueID
106+ if args[3] != nil {
107+ arg3 = args[3].(domain.PendingIssueID)
108+ }
109+ run(
110+ arg0,
111+ arg1,
112+ arg2,
113+ arg3,
114+ )
115+ })
116+ return _c
117+}
118+
119+func (_c *IssueModerationService_Approve_Call) Return(issue domain.Issue, err error) *IssueModerationService_Approve_Call {
120+ _c.Call.Return(issue, err)
121+ return _c
122+}
123+
124+func (_c *IssueModerationService_Approve_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error)) *IssueModerationService_Approve_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
128+
129+// ListPending provides a mock function for the type IssueModerationService
130+func (_mock *IssueModerationService) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
131+ ret := _mock.Called(ctx, p, repo, page)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for ListPending")
135+ }
136+
137+ var r0 ports.PendingIssuePage
138+ var r1 error
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) (ports.PendingIssuePage, error)); ok {
140+ return returnFunc(ctx, p, repo, page)
141+ }
142+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) ports.PendingIssuePage); ok {
143+ r0 = returnFunc(ctx, p, repo, page)
144+ } else {
145+ r0 = ret.Get(0).(ports.PendingIssuePage)
146+ }
147+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) error); ok {
148+ r1 = returnFunc(ctx, p, repo, page)
149+ } else {
150+ r1 = ret.Error(1)
151+ }
152+ return r0, r1
153+}
154+
155+// IssueModerationService_ListPending_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPending'
156+type IssueModerationService_ListPending_Call struct {
157+ *mock.Call
158+}
159+
160+// ListPending is a helper method to define mock.On call
161+// - ctx context.Context
162+// - p domain.Principal
163+// - repo domain.RepoName
164+// - page ports.ListPendingParams
165+func (_e *IssueModerationService_Expecter) ListPending(ctx any, p any, repo any, page any) *IssueModerationService_ListPending_Call {
166+ return &IssueModerationService_ListPending_Call{Call: _e.mock.On("ListPending", ctx, p, repo, page)}
167+}
168+
169+func (_c *IssueModerationService_ListPending_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams)) *IssueModerationService_ListPending_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ var arg0 context.Context
172+ if args[0] != nil {
173+ arg0 = args[0].(context.Context)
174+ }
175+ var arg1 domain.Principal
176+ if args[1] != nil {
177+ arg1 = args[1].(domain.Principal)
178+ }
179+ var arg2 domain.RepoName
180+ if args[2] != nil {
181+ arg2 = args[2].(domain.RepoName)
182+ }
183+ var arg3 ports.ListPendingParams
184+ if args[3] != nil {
185+ arg3 = args[3].(ports.ListPendingParams)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ arg3,
192+ )
193+ })
194+ return _c
195+}
196+
197+func (_c *IssueModerationService_ListPending_Call) Return(pendingIssuePage ports.PendingIssuePage, err error) *IssueModerationService_ListPending_Call {
198+ _c.Call.Return(pendingIssuePage, err)
199+ return _c
200+}
201+
202+func (_c *IssueModerationService_ListPending_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error)) *IssueModerationService_ListPending_Call {
203+ _c.Call.Return(run)
204+ return _c
205+}
206+
207+// Reject provides a mock function for the type IssueModerationService
208+func (_mock *IssueModerationService) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
209+ ret := _mock.Called(ctx, p, repo, id, ban)
210+
211+ if len(ret) == 0 {
212+ panic("no return value specified for Reject")
213+ }
214+
215+ var r0 error
216+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID, bool) error); ok {
217+ r0 = returnFunc(ctx, p, repo, id, ban)
218+ } else {
219+ r0 = ret.Error(0)
220+ }
221+ return r0
222+}
223+
224+// IssueModerationService_Reject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reject'
225+type IssueModerationService_Reject_Call struct {
226+ *mock.Call
227+}
228+
229+// Reject is a helper method to define mock.On call
230+// - ctx context.Context
231+// - p domain.Principal
232+// - repo domain.RepoName
233+// - id domain.PendingIssueID
234+// - ban bool
235+func (_e *IssueModerationService_Expecter) Reject(ctx any, p any, repo any, id any, ban any) *IssueModerationService_Reject_Call {
236+ return &IssueModerationService_Reject_Call{Call: _e.mock.On("Reject", ctx, p, repo, id, ban)}
237+}
238+
239+func (_c *IssueModerationService_Reject_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool)) *IssueModerationService_Reject_Call {
240+ _c.Call.Run(func(args mock.Arguments) {
241+ var arg0 context.Context
242+ if args[0] != nil {
243+ arg0 = args[0].(context.Context)
244+ }
245+ var arg1 domain.Principal
246+ if args[1] != nil {
247+ arg1 = args[1].(domain.Principal)
248+ }
249+ var arg2 domain.RepoName
250+ if args[2] != nil {
251+ arg2 = args[2].(domain.RepoName)
252+ }
253+ var arg3 domain.PendingIssueID
254+ if args[3] != nil {
255+ arg3 = args[3].(domain.PendingIssueID)
256+ }
257+ var arg4 bool
258+ if args[4] != nil {
259+ arg4 = args[4].(bool)
260+ }
261+ run(
262+ arg0,
263+ arg1,
264+ arg2,
265+ arg3,
266+ arg4,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *IssueModerationService_Reject_Call) Return(err error) *IssueModerationService_Reject_Call {
273+ _c.Call.Return(err)
274+ return _c
275+}
276+
277+func (_c *IssueModerationService_Reject_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error) *IssueModerationService_Reject_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
@@ -0,0 +1,280 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueModerationService creates a new instance of IssueModerationService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueModerationService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueModerationService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueModerationService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueModerationService is an autogenerated mock type for the IssueModerationService type
39+type IssueModerationService struct {
40+ mock.Mock
41+}
42+
43+type IssueModerationService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueModerationService) EXPECT() *IssueModerationService_Expecter {
48+ return &IssueModerationService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Approve provides a mock function for the type IssueModerationService
52+func (_mock *IssueModerationService) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, p, repo, id)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Approve")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, p, repo, id)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) domain.Issue); ok {
65+ r0 = returnFunc(ctx, p, repo, id)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) error); ok {
70+ r1 = returnFunc(ctx, p, repo, id)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueModerationService_Approve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Approve'
78+type IssueModerationService_Approve_Call struct {
79+ *mock.Call
80+}
81+
82+// Approve is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - repo domain.RepoName
86+// - id domain.PendingIssueID
87+func (_e *IssueModerationService_Expecter) Approve(ctx any, p any, repo any, id any) *IssueModerationService_Approve_Call {
88+ return &IssueModerationService_Approve_Call{Call: _e.mock.On("Approve", ctx, p, repo, id)}
89+}
90+
91+func (_c *IssueModerationService_Approve_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID)) *IssueModerationService_Approve_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.Principal
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.Principal)
100+ }
101+ var arg2 domain.RepoName
102+ if args[2] != nil {
103+ arg2 = args[2].(domain.RepoName)
104+ }
105+ var arg3 domain.PendingIssueID
106+ if args[3] != nil {
107+ arg3 = args[3].(domain.PendingIssueID)
108+ }
109+ run(
110+ arg0,
111+ arg1,
112+ arg2,
113+ arg3,
114+ )
115+ })
116+ return _c
117+}
118+
119+func (_c *IssueModerationService_Approve_Call) Return(issue domain.Issue, err error) *IssueModerationService_Approve_Call {
120+ _c.Call.Return(issue, err)
121+ return _c
122+}
123+
124+func (_c *IssueModerationService_Approve_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error)) *IssueModerationService_Approve_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
128+
129+// ListPending provides a mock function for the type IssueModerationService
130+func (_mock *IssueModerationService) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
131+ ret := _mock.Called(ctx, p, repo, page)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for ListPending")
135+ }
136+
137+ var r0 ports.PendingIssuePage
138+ var r1 error
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) (ports.PendingIssuePage, error)); ok {
140+ return returnFunc(ctx, p, repo, page)
141+ }
142+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) ports.PendingIssuePage); ok {
143+ r0 = returnFunc(ctx, p, repo, page)
144+ } else {
145+ r0 = ret.Get(0).(ports.PendingIssuePage)
146+ }
147+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) error); ok {
148+ r1 = returnFunc(ctx, p, repo, page)
149+ } else {
150+ r1 = ret.Error(1)
151+ }
152+ return r0, r1
153+}
154+
155+// IssueModerationService_ListPending_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPending'
156+type IssueModerationService_ListPending_Call struct {
157+ *mock.Call
158+}
159+
160+// ListPending is a helper method to define mock.On call
161+// - ctx context.Context
162+// - p domain.Principal
163+// - repo domain.RepoName
164+// - page ports.ListPendingParams
165+func (_e *IssueModerationService_Expecter) ListPending(ctx any, p any, repo any, page any) *IssueModerationService_ListPending_Call {
166+ return &IssueModerationService_ListPending_Call{Call: _e.mock.On("ListPending", ctx, p, repo, page)}
167+}
168+
169+func (_c *IssueModerationService_ListPending_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams)) *IssueModerationService_ListPending_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ var arg0 context.Context
172+ if args[0] != nil {
173+ arg0 = args[0].(context.Context)
174+ }
175+ var arg1 domain.Principal
176+ if args[1] != nil {
177+ arg1 = args[1].(domain.Principal)
178+ }
179+ var arg2 domain.RepoName
180+ if args[2] != nil {
181+ arg2 = args[2].(domain.RepoName)
182+ }
183+ var arg3 ports.ListPendingParams
184+ if args[3] != nil {
185+ arg3 = args[3].(ports.ListPendingParams)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ arg3,
192+ )
193+ })
194+ return _c
195+}
196+
197+func (_c *IssueModerationService_ListPending_Call) Return(pendingIssuePage ports.PendingIssuePage, err error) *IssueModerationService_ListPending_Call {
198+ _c.Call.Return(pendingIssuePage, err)
199+ return _c
200+}
201+
202+func (_c *IssueModerationService_ListPending_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error)) *IssueModerationService_ListPending_Call {
203+ _c.Call.Return(run)
204+ return _c
205+}
206+
207+// Reject provides a mock function for the type IssueModerationService
208+func (_mock *IssueModerationService) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
209+ ret := _mock.Called(ctx, p, repo, id, ban)
210+
211+ if len(ret) == 0 {
212+ panic("no return value specified for Reject")
213+ }
214+
215+ var r0 error
216+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID, bool) error); ok {
217+ r0 = returnFunc(ctx, p, repo, id, ban)
218+ } else {
219+ r0 = ret.Error(0)
220+ }
221+ return r0
222+}
223+
224+// IssueModerationService_Reject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reject'
225+type IssueModerationService_Reject_Call struct {
226+ *mock.Call
227+}
228+
229+// Reject is a helper method to define mock.On call
230+// - ctx context.Context
231+// - p domain.Principal
232+// - repo domain.RepoName
233+// - id domain.PendingIssueID
234+// - ban bool
235+func (_e *IssueModerationService_Expecter) Reject(ctx any, p any, repo any, id any, ban any) *IssueModerationService_Reject_Call {
236+ return &IssueModerationService_Reject_Call{Call: _e.mock.On("Reject", ctx, p, repo, id, ban)}
237+}
238+
239+func (_c *IssueModerationService_Reject_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool)) *IssueModerationService_Reject_Call {
240+ _c.Call.Run(func(args mock.Arguments) {
241+ var arg0 context.Context
242+ if args[0] != nil {
243+ arg0 = args[0].(context.Context)
244+ }
245+ var arg1 domain.Principal
246+ if args[1] != nil {
247+ arg1 = args[1].(domain.Principal)
248+ }
249+ var arg2 domain.RepoName
250+ if args[2] != nil {
251+ arg2 = args[2].(domain.RepoName)
252+ }
253+ var arg3 domain.PendingIssueID
254+ if args[3] != nil {
255+ arg3 = args[3].(domain.PendingIssueID)
256+ }
257+ var arg4 bool
258+ if args[4] != nil {
259+ arg4 = args[4].(bool)
260+ }
261+ run(
262+ arg0,
263+ arg1,
264+ arg2,
265+ arg3,
266+ arg4,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *IssueModerationService_Reject_Call) Return(err error) *IssueModerationService_Reject_Call {
273+ _c.Call.Return(err)
274+ return _c
275+}
276+
277+func (_c *IssueModerationService_Reject_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error) *IssueModerationService_Reject_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
A internal/ports/mocks/IssueNumberResolver.go +126 -0
@@ -0,0 +1,126 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueNumberResolver creates a new instance of IssueNumberResolver. 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 NewIssueNumberResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueNumberResolver {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueNumberResolver{}
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+// IssueNumberResolver is an autogenerated mock type for the IssueNumberResolver type
38+type IssueNumberResolver struct {
39+ mock.Mock
40+}
41+
42+type IssueNumberResolver_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueNumberResolver) EXPECT() *IssueNumberResolver_Expecter {
47+ return &IssueNumberResolver_Expecter{mock: &_m.Mock}
48+}
49+
50+// Kind provides a mock function for the type IssueNumberResolver
51+func (_mock *IssueNumberResolver) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
52+ ret := _mock.Called(ctx, p, name, n)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Kind")
56+ }
57+
58+ var r0 domain.NumberKind
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.NumberKind, error)); ok {
61+ return returnFunc(ctx, p, name, n)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.NumberKind); ok {
64+ r0 = returnFunc(ctx, p, name, n)
65+ } else {
66+ r0 = ret.Get(0).(domain.NumberKind)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
69+ r1 = returnFunc(ctx, p, name, n)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// IssueNumberResolver_Kind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Kind'
77+type IssueNumberResolver_Kind_Call struct {
78+ *mock.Call
79+}
80+
81+// Kind is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - name domain.RepoName
85+// - n int64
86+func (_e *IssueNumberResolver_Expecter) Kind(ctx any, p any, name any, n any) *IssueNumberResolver_Kind_Call {
87+ return &IssueNumberResolver_Kind_Call{Call: _e.mock.On("Kind", ctx, p, name, n)}
88+}
89+
90+func (_c *IssueNumberResolver_Kind_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64)) *IssueNumberResolver_Kind_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ var arg2 domain.RepoName
101+ if args[2] != nil {
102+ arg2 = args[2].(domain.RepoName)
103+ }
104+ var arg3 int64
105+ if args[3] != nil {
106+ arg3 = args[3].(int64)
107+ }
108+ run(
109+ arg0,
110+ arg1,
111+ arg2,
112+ arg3,
113+ )
114+ })
115+ return _c
116+}
117+
118+func (_c *IssueNumberResolver_Kind_Call) Return(numberKind domain.NumberKind, err error) *IssueNumberResolver_Kind_Call {
119+ _c.Call.Return(numberKind, err)
120+ return _c
121+}
122+
123+func (_c *IssueNumberResolver_Kind_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)) *IssueNumberResolver_Kind_Call {
124+ _c.Call.Return(run)
125+ return _c
126+}
@@ -0,0 +1,126 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueNumberResolver creates a new instance of IssueNumberResolver. 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 NewIssueNumberResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueNumberResolver {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueNumberResolver{}
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+// IssueNumberResolver is an autogenerated mock type for the IssueNumberResolver type
38+type IssueNumberResolver struct {
39+ mock.Mock
40+}
41+
42+type IssueNumberResolver_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueNumberResolver) EXPECT() *IssueNumberResolver_Expecter {
47+ return &IssueNumberResolver_Expecter{mock: &_m.Mock}
48+}
49+
50+// Kind provides a mock function for the type IssueNumberResolver
51+func (_mock *IssueNumberResolver) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
52+ ret := _mock.Called(ctx, p, name, n)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Kind")
56+ }
57+
58+ var r0 domain.NumberKind
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.NumberKind, error)); ok {
61+ return returnFunc(ctx, p, name, n)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.NumberKind); ok {
64+ r0 = returnFunc(ctx, p, name, n)
65+ } else {
66+ r0 = ret.Get(0).(domain.NumberKind)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
69+ r1 = returnFunc(ctx, p, name, n)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// IssueNumberResolver_Kind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Kind'
77+type IssueNumberResolver_Kind_Call struct {
78+ *mock.Call
79+}
80+
81+// Kind is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - name domain.RepoName
85+// - n int64
86+func (_e *IssueNumberResolver_Expecter) Kind(ctx any, p any, name any, n any) *IssueNumberResolver_Kind_Call {
87+ return &IssueNumberResolver_Kind_Call{Call: _e.mock.On("Kind", ctx, p, name, n)}
88+}
89+
90+func (_c *IssueNumberResolver_Kind_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64)) *IssueNumberResolver_Kind_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ var arg2 domain.RepoName
101+ if args[2] != nil {
102+ arg2 = args[2].(domain.RepoName)
103+ }
104+ var arg3 int64
105+ if args[3] != nil {
106+ arg3 = args[3].(int64)
107+ }
108+ run(
109+ arg0,
110+ arg1,
111+ arg2,
112+ arg3,
113+ )
114+ })
115+ return _c
116+}
117+
118+func (_c *IssueNumberResolver_Kind_Call) Return(numberKind domain.NumberKind, err error) *IssueNumberResolver_Kind_Call {
119+ _c.Call.Return(numberKind, err)
120+ return _c
121+}
122+
123+func (_c *IssueNumberResolver_Kind_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)) *IssueNumberResolver_Kind_Call {
124+ _c.Call.Return(run)
125+ return _c
126+}
A internal/ports/mocks/IssueService.go +523 -0
@@ -0,0 +1,523 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueService creates a new instance of IssueService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueService is an autogenerated mock type for the IssueService type
39+type IssueService struct {
40+ mock.Mock
41+}
42+
43+type IssueService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueService) EXPECT() *IssueService_Expecter {
48+ return &IssueService_Expecter{mock: &_m.Mock}
49+}
50+
51+// AddComment provides a mock function for the type IssueService
52+func (_mock *IssueService) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
53+ ret := _mock.Called(ctx, p, name, number, text)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AddComment")
57+ }
58+
59+ var r0 domain.IssueComment
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) (domain.IssueComment, error)); ok {
62+ return returnFunc(ctx, p, name, number, text)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) domain.IssueComment); ok {
65+ r0 = returnFunc(ctx, p, name, number, text)
66+ } else {
67+ r0 = ret.Get(0).(domain.IssueComment)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, string) error); ok {
70+ r1 = returnFunc(ctx, p, name, number, text)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueService_AddComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddComment'
78+type IssueService_AddComment_Call struct {
79+ *mock.Call
80+}
81+
82+// AddComment is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - name domain.RepoName
86+// - number int64
87+// - text string
88+func (_e *IssueService_Expecter) AddComment(ctx any, p any, name any, number any, text any) *IssueService_AddComment_Call {
89+ return &IssueService_AddComment_Call{Call: _e.mock.On("AddComment", ctx, p, name, number, text)}
90+}
91+
92+func (_c *IssueService_AddComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string)) *IssueService_AddComment_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.Principal
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.Principal)
101+ }
102+ var arg2 domain.RepoName
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.RepoName)
105+ }
106+ var arg3 int64
107+ if args[3] != nil {
108+ arg3 = args[3].(int64)
109+ }
110+ var arg4 string
111+ if args[4] != nil {
112+ arg4 = args[4].(string)
113+ }
114+ run(
115+ arg0,
116+ arg1,
117+ arg2,
118+ arg3,
119+ arg4,
120+ )
121+ })
122+ return _c
123+}
124+
125+func (_c *IssueService_AddComment_Call) Return(issueComment domain.IssueComment, err error) *IssueService_AddComment_Call {
126+ _c.Call.Return(issueComment, err)
127+ return _c
128+}
129+
130+func (_c *IssueService_AddComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)) *IssueService_AddComment_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
135+// Close provides a mock function for the type IssueService
136+func (_mock *IssueService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
137+ ret := _mock.Called(ctx, p, name, number)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for Close")
141+ }
142+
143+ var r0 domain.Issue
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
146+ return returnFunc(ctx, p, name, number)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
149+ r0 = returnFunc(ctx, p, name, number)
150+ } else {
151+ r0 = ret.Get(0).(domain.Issue)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
154+ r1 = returnFunc(ctx, p, name, number)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// IssueService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
162+type IssueService_Close_Call struct {
163+ *mock.Call
164+}
165+
166+// Close is a helper method to define mock.On call
167+// - ctx context.Context
168+// - p domain.Principal
169+// - name domain.RepoName
170+// - number int64
171+func (_e *IssueService_Expecter) Close(ctx any, p any, name any, number any) *IssueService_Close_Call {
172+ return &IssueService_Close_Call{Call: _e.mock.On("Close", ctx, p, name, number)}
173+}
174+
175+func (_c *IssueService_Close_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Close_Call {
176+ _c.Call.Run(func(args mock.Arguments) {
177+ var arg0 context.Context
178+ if args[0] != nil {
179+ arg0 = args[0].(context.Context)
180+ }
181+ var arg1 domain.Principal
182+ if args[1] != nil {
183+ arg1 = args[1].(domain.Principal)
184+ }
185+ var arg2 domain.RepoName
186+ if args[2] != nil {
187+ arg2 = args[2].(domain.RepoName)
188+ }
189+ var arg3 int64
190+ if args[3] != nil {
191+ arg3 = args[3].(int64)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *IssueService_Close_Call) Return(issue domain.Issue, err error) *IssueService_Close_Call {
204+ _c.Call.Return(issue, err)
205+ return _c
206+}
207+
208+func (_c *IssueService_Close_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Close_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
213+// Create provides a mock function for the type IssueService
214+func (_mock *IssueService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
215+ ret := _mock.Called(ctx, p, name, params)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Create")
219+ }
220+
221+ var r0 domain.Issue
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) (domain.Issue, error)); ok {
224+ return returnFunc(ctx, p, name, params)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) domain.Issue); ok {
227+ r0 = returnFunc(ctx, p, name, params)
228+ } else {
229+ r0 = ret.Get(0).(domain.Issue)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) error); ok {
232+ r1 = returnFunc(ctx, p, name, params)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// IssueService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
240+type IssueService_Create_Call struct {
241+ *mock.Call
242+}
243+
244+// Create is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - name domain.RepoName
248+// - params ports.CreateIssueParams
249+func (_e *IssueService_Expecter) Create(ctx any, p any, name any, params any) *IssueService_Create_Call {
250+ return &IssueService_Create_Call{Call: _e.mock.On("Create", ctx, p, name, params)}
251+}
252+
253+func (_c *IssueService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams)) *IssueService_Create_Call {
254+ _c.Call.Run(func(args mock.Arguments) {
255+ var arg0 context.Context
256+ if args[0] != nil {
257+ arg0 = args[0].(context.Context)
258+ }
259+ var arg1 domain.Principal
260+ if args[1] != nil {
261+ arg1 = args[1].(domain.Principal)
262+ }
263+ var arg2 domain.RepoName
264+ if args[2] != nil {
265+ arg2 = args[2].(domain.RepoName)
266+ }
267+ var arg3 ports.CreateIssueParams
268+ if args[3] != nil {
269+ arg3 = args[3].(ports.CreateIssueParams)
270+ }
271+ run(
272+ arg0,
273+ arg1,
274+ arg2,
275+ arg3,
276+ )
277+ })
278+ return _c
279+}
280+
281+func (_c *IssueService_Create_Call) Return(issue domain.Issue, err error) *IssueService_Create_Call {
282+ _c.Call.Return(issue, err)
283+ return _c
284+}
285+
286+func (_c *IssueService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error)) *IssueService_Create_Call {
287+ _c.Call.Return(run)
288+ return _c
289+}
290+
291+// Get provides a mock function for the type IssueService
292+func (_mock *IssueService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
293+ ret := _mock.Called(ctx, p, name, number)
294+
295+ if len(ret) == 0 {
296+ panic("no return value specified for Get")
297+ }
298+
299+ var r0 ports.IssueDetail
300+ var r1 error
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (ports.IssueDetail, error)); ok {
302+ return returnFunc(ctx, p, name, number)
303+ }
304+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) ports.IssueDetail); ok {
305+ r0 = returnFunc(ctx, p, name, number)
306+ } else {
307+ r0 = ret.Get(0).(ports.IssueDetail)
308+ }
309+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
310+ r1 = returnFunc(ctx, p, name, number)
311+ } else {
312+ r1 = ret.Error(1)
313+ }
314+ return r0, r1
315+}
316+
317+// IssueService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
318+type IssueService_Get_Call struct {
319+ *mock.Call
320+}
321+
322+// Get is a helper method to define mock.On call
323+// - ctx context.Context
324+// - p domain.Principal
325+// - name domain.RepoName
326+// - number int64
327+func (_e *IssueService_Expecter) Get(ctx any, p any, name any, number any) *IssueService_Get_Call {
328+ return &IssueService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number)}
329+}
330+
331+func (_c *IssueService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Get_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 context.Context
334+ if args[0] != nil {
335+ arg0 = args[0].(context.Context)
336+ }
337+ var arg1 domain.Principal
338+ if args[1] != nil {
339+ arg1 = args[1].(domain.Principal)
340+ }
341+ var arg2 domain.RepoName
342+ if args[2] != nil {
343+ arg2 = args[2].(domain.RepoName)
344+ }
345+ var arg3 int64
346+ if args[3] != nil {
347+ arg3 = args[3].(int64)
348+ }
349+ run(
350+ arg0,
351+ arg1,
352+ arg2,
353+ arg3,
354+ )
355+ })
356+ return _c
357+}
358+
359+func (_c *IssueService_Get_Call) Return(issueDetail ports.IssueDetail, err error) *IssueService_Get_Call {
360+ _c.Call.Return(issueDetail, err)
361+ return _c
362+}
363+
364+func (_c *IssueService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error)) *IssueService_Get_Call {
365+ _c.Call.Return(run)
366+ return _c
367+}
368+
369+// List provides a mock function for the type IssueService
370+func (_mock *IssueService) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
371+ ret := _mock.Called(ctx, p, name, params)
372+
373+ if len(ret) == 0 {
374+ panic("no return value specified for List")
375+ }
376+
377+ var r0 ports.IssuePage
378+ var r1 error
379+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
380+ return returnFunc(ctx, p, name, params)
381+ }
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
383+ r0 = returnFunc(ctx, p, name, params)
384+ } else {
385+ r0 = ret.Get(0).(ports.IssuePage)
386+ }
387+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) error); ok {
388+ r1 = returnFunc(ctx, p, name, params)
389+ } else {
390+ r1 = ret.Error(1)
391+ }
392+ return r0, r1
393+}
394+
395+// IssueService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
396+type IssueService_List_Call struct {
397+ *mock.Call
398+}
399+
400+// List is a helper method to define mock.On call
401+// - ctx context.Context
402+// - p domain.Principal
403+// - name domain.RepoName
404+// - params ports.ListIssuesParams
405+func (_e *IssueService_Expecter) List(ctx any, p any, name any, params any) *IssueService_List_Call {
406+ return &IssueService_List_Call{Call: _e.mock.On("List", ctx, p, name, params)}
407+}
408+
409+func (_c *IssueService_List_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams)) *IssueService_List_Call {
410+ _c.Call.Run(func(args mock.Arguments) {
411+ var arg0 context.Context
412+ if args[0] != nil {
413+ arg0 = args[0].(context.Context)
414+ }
415+ var arg1 domain.Principal
416+ if args[1] != nil {
417+ arg1 = args[1].(domain.Principal)
418+ }
419+ var arg2 domain.RepoName
420+ if args[2] != nil {
421+ arg2 = args[2].(domain.RepoName)
422+ }
423+ var arg3 ports.ListIssuesParams
424+ if args[3] != nil {
425+ arg3 = args[3].(ports.ListIssuesParams)
426+ }
427+ run(
428+ arg0,
429+ arg1,
430+ arg2,
431+ arg3,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *IssueService_List_Call) Return(issuePage ports.IssuePage, err error) *IssueService_List_Call {
438+ _c.Call.Return(issuePage, err)
439+ return _c
440+}
441+
442+func (_c *IssueService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueService_List_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
447+// Reopen provides a mock function for the type IssueService
448+func (_mock *IssueService) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
449+ ret := _mock.Called(ctx, p, name, number)
450+
451+ if len(ret) == 0 {
452+ panic("no return value specified for Reopen")
453+ }
454+
455+ var r0 domain.Issue
456+ var r1 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
458+ return returnFunc(ctx, p, name, number)
459+ }
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
461+ r0 = returnFunc(ctx, p, name, number)
462+ } else {
463+ r0 = ret.Get(0).(domain.Issue)
464+ }
465+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
466+ r1 = returnFunc(ctx, p, name, number)
467+ } else {
468+ r1 = ret.Error(1)
469+ }
470+ return r0, r1
471+}
472+
473+// IssueService_Reopen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reopen'
474+type IssueService_Reopen_Call struct {
475+ *mock.Call
476+}
477+
478+// Reopen is a helper method to define mock.On call
479+// - ctx context.Context
480+// - p domain.Principal
481+// - name domain.RepoName
482+// - number int64
483+func (_e *IssueService_Expecter) Reopen(ctx any, p any, name any, number any) *IssueService_Reopen_Call {
484+ return &IssueService_Reopen_Call{Call: _e.mock.On("Reopen", ctx, p, name, number)}
485+}
486+
487+func (_c *IssueService_Reopen_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Reopen_Call {
488+ _c.Call.Run(func(args mock.Arguments) {
489+ var arg0 context.Context
490+ if args[0] != nil {
491+ arg0 = args[0].(context.Context)
492+ }
493+ var arg1 domain.Principal
494+ if args[1] != nil {
495+ arg1 = args[1].(domain.Principal)
496+ }
497+ var arg2 domain.RepoName
498+ if args[2] != nil {
499+ arg2 = args[2].(domain.RepoName)
500+ }
501+ var arg3 int64
502+ if args[3] != nil {
503+ arg3 = args[3].(int64)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ arg2,
509+ arg3,
510+ )
511+ })
512+ return _c
513+}
514+
515+func (_c *IssueService_Reopen_Call) Return(issue domain.Issue, err error) *IssueService_Reopen_Call {
516+ _c.Call.Return(issue, err)
517+ return _c
518+}
519+
520+func (_c *IssueService_Reopen_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Reopen_Call {
521+ _c.Call.Return(run)
522+ return _c
523+}
@@ -0,0 +1,523 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueService creates a new instance of IssueService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueService is an autogenerated mock type for the IssueService type
39+type IssueService struct {
40+ mock.Mock
41+}
42+
43+type IssueService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueService) EXPECT() *IssueService_Expecter {
48+ return &IssueService_Expecter{mock: &_m.Mock}
49+}
50+
51+// AddComment provides a mock function for the type IssueService
52+func (_mock *IssueService) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
53+ ret := _mock.Called(ctx, p, name, number, text)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AddComment")
57+ }
58+
59+ var r0 domain.IssueComment
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) (domain.IssueComment, error)); ok {
62+ return returnFunc(ctx, p, name, number, text)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) domain.IssueComment); ok {
65+ r0 = returnFunc(ctx, p, name, number, text)
66+ } else {
67+ r0 = ret.Get(0).(domain.IssueComment)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, string) error); ok {
70+ r1 = returnFunc(ctx, p, name, number, text)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueService_AddComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddComment'
78+type IssueService_AddComment_Call struct {
79+ *mock.Call
80+}
81+
82+// AddComment is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - name domain.RepoName
86+// - number int64
87+// - text string
88+func (_e *IssueService_Expecter) AddComment(ctx any, p any, name any, number any, text any) *IssueService_AddComment_Call {
89+ return &IssueService_AddComment_Call{Call: _e.mock.On("AddComment", ctx, p, name, number, text)}
90+}
91+
92+func (_c *IssueService_AddComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string)) *IssueService_AddComment_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.Principal
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.Principal)
101+ }
102+ var arg2 domain.RepoName
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.RepoName)
105+ }
106+ var arg3 int64
107+ if args[3] != nil {
108+ arg3 = args[3].(int64)
109+ }
110+ var arg4 string
111+ if args[4] != nil {
112+ arg4 = args[4].(string)
113+ }
114+ run(
115+ arg0,
116+ arg1,
117+ arg2,
118+ arg3,
119+ arg4,
120+ )
121+ })
122+ return _c
123+}
124+
125+func (_c *IssueService_AddComment_Call) Return(issueComment domain.IssueComment, err error) *IssueService_AddComment_Call {
126+ _c.Call.Return(issueComment, err)
127+ return _c
128+}
129+
130+func (_c *IssueService_AddComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)) *IssueService_AddComment_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
135+// Close provides a mock function for the type IssueService
136+func (_mock *IssueService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
137+ ret := _mock.Called(ctx, p, name, number)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for Close")
141+ }
142+
143+ var r0 domain.Issue
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
146+ return returnFunc(ctx, p, name, number)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
149+ r0 = returnFunc(ctx, p, name, number)
150+ } else {
151+ r0 = ret.Get(0).(domain.Issue)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
154+ r1 = returnFunc(ctx, p, name, number)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// IssueService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
162+type IssueService_Close_Call struct {
163+ *mock.Call
164+}
165+
166+// Close is a helper method to define mock.On call
167+// - ctx context.Context
168+// - p domain.Principal
169+// - name domain.RepoName
170+// - number int64
171+func (_e *IssueService_Expecter) Close(ctx any, p any, name any, number any) *IssueService_Close_Call {
172+ return &IssueService_Close_Call{Call: _e.mock.On("Close", ctx, p, name, number)}
173+}
174+
175+func (_c *IssueService_Close_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Close_Call {
176+ _c.Call.Run(func(args mock.Arguments) {
177+ var arg0 context.Context
178+ if args[0] != nil {
179+ arg0 = args[0].(context.Context)
180+ }
181+ var arg1 domain.Principal
182+ if args[1] != nil {
183+ arg1 = args[1].(domain.Principal)
184+ }
185+ var arg2 domain.RepoName
186+ if args[2] != nil {
187+ arg2 = args[2].(domain.RepoName)
188+ }
189+ var arg3 int64
190+ if args[3] != nil {
191+ arg3 = args[3].(int64)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *IssueService_Close_Call) Return(issue domain.Issue, err error) *IssueService_Close_Call {
204+ _c.Call.Return(issue, err)
205+ return _c
206+}
207+
208+func (_c *IssueService_Close_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Close_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
213+// Create provides a mock function for the type IssueService
214+func (_mock *IssueService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
215+ ret := _mock.Called(ctx, p, name, params)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Create")
219+ }
220+
221+ var r0 domain.Issue
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) (domain.Issue, error)); ok {
224+ return returnFunc(ctx, p, name, params)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) domain.Issue); ok {
227+ r0 = returnFunc(ctx, p, name, params)
228+ } else {
229+ r0 = ret.Get(0).(domain.Issue)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) error); ok {
232+ r1 = returnFunc(ctx, p, name, params)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// IssueService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
240+type IssueService_Create_Call struct {
241+ *mock.Call
242+}
243+
244+// Create is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - name domain.RepoName
248+// - params ports.CreateIssueParams
249+func (_e *IssueService_Expecter) Create(ctx any, p any, name any, params any) *IssueService_Create_Call {
250+ return &IssueService_Create_Call{Call: _e.mock.On("Create", ctx, p, name, params)}
251+}
252+
253+func (_c *IssueService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams)) *IssueService_Create_Call {
254+ _c.Call.Run(func(args mock.Arguments) {
255+ var arg0 context.Context
256+ if args[0] != nil {
257+ arg0 = args[0].(context.Context)
258+ }
259+ var arg1 domain.Principal
260+ if args[1] != nil {
261+ arg1 = args[1].(domain.Principal)
262+ }
263+ var arg2 domain.RepoName
264+ if args[2] != nil {
265+ arg2 = args[2].(domain.RepoName)
266+ }
267+ var arg3 ports.CreateIssueParams
268+ if args[3] != nil {
269+ arg3 = args[3].(ports.CreateIssueParams)
270+ }
271+ run(
272+ arg0,
273+ arg1,
274+ arg2,
275+ arg3,
276+ )
277+ })
278+ return _c
279+}
280+
281+func (_c *IssueService_Create_Call) Return(issue domain.Issue, err error) *IssueService_Create_Call {
282+ _c.Call.Return(issue, err)
283+ return _c
284+}
285+
286+func (_c *IssueService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error)) *IssueService_Create_Call {
287+ _c.Call.Return(run)
288+ return _c
289+}
290+
291+// Get provides a mock function for the type IssueService
292+func (_mock *IssueService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
293+ ret := _mock.Called(ctx, p, name, number)
294+
295+ if len(ret) == 0 {
296+ panic("no return value specified for Get")
297+ }
298+
299+ var r0 ports.IssueDetail
300+ var r1 error
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (ports.IssueDetail, error)); ok {
302+ return returnFunc(ctx, p, name, number)
303+ }
304+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) ports.IssueDetail); ok {
305+ r0 = returnFunc(ctx, p, name, number)
306+ } else {
307+ r0 = ret.Get(0).(ports.IssueDetail)
308+ }
309+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
310+ r1 = returnFunc(ctx, p, name, number)
311+ } else {
312+ r1 = ret.Error(1)
313+ }
314+ return r0, r1
315+}
316+
317+// IssueService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
318+type IssueService_Get_Call struct {
319+ *mock.Call
320+}
321+
322+// Get is a helper method to define mock.On call
323+// - ctx context.Context
324+// - p domain.Principal
325+// - name domain.RepoName
326+// - number int64
327+func (_e *IssueService_Expecter) Get(ctx any, p any, name any, number any) *IssueService_Get_Call {
328+ return &IssueService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number)}
329+}
330+
331+func (_c *IssueService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Get_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 context.Context
334+ if args[0] != nil {
335+ arg0 = args[0].(context.Context)
336+ }
337+ var arg1 domain.Principal
338+ if args[1] != nil {
339+ arg1 = args[1].(domain.Principal)
340+ }
341+ var arg2 domain.RepoName
342+ if args[2] != nil {
343+ arg2 = args[2].(domain.RepoName)
344+ }
345+ var arg3 int64
346+ if args[3] != nil {
347+ arg3 = args[3].(int64)
348+ }
349+ run(
350+ arg0,
351+ arg1,
352+ arg2,
353+ arg3,
354+ )
355+ })
356+ return _c
357+}
358+
359+func (_c *IssueService_Get_Call) Return(issueDetail ports.IssueDetail, err error) *IssueService_Get_Call {
360+ _c.Call.Return(issueDetail, err)
361+ return _c
362+}
363+
364+func (_c *IssueService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error)) *IssueService_Get_Call {
365+ _c.Call.Return(run)
366+ return _c
367+}
368+
369+// List provides a mock function for the type IssueService
370+func (_mock *IssueService) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
371+ ret := _mock.Called(ctx, p, name, params)
372+
373+ if len(ret) == 0 {
374+ panic("no return value specified for List")
375+ }
376+
377+ var r0 ports.IssuePage
378+ var r1 error
379+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
380+ return returnFunc(ctx, p, name, params)
381+ }
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
383+ r0 = returnFunc(ctx, p, name, params)
384+ } else {
385+ r0 = ret.Get(0).(ports.IssuePage)
386+ }
387+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) error); ok {
388+ r1 = returnFunc(ctx, p, name, params)
389+ } else {
390+ r1 = ret.Error(1)
391+ }
392+ return r0, r1
393+}
394+
395+// IssueService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
396+type IssueService_List_Call struct {
397+ *mock.Call
398+}
399+
400+// List is a helper method to define mock.On call
401+// - ctx context.Context
402+// - p domain.Principal
403+// - name domain.RepoName
404+// - params ports.ListIssuesParams
405+func (_e *IssueService_Expecter) List(ctx any, p any, name any, params any) *IssueService_List_Call {
406+ return &IssueService_List_Call{Call: _e.mock.On("List", ctx, p, name, params)}
407+}
408+
409+func (_c *IssueService_List_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams)) *IssueService_List_Call {
410+ _c.Call.Run(func(args mock.Arguments) {
411+ var arg0 context.Context
412+ if args[0] != nil {
413+ arg0 = args[0].(context.Context)
414+ }
415+ var arg1 domain.Principal
416+ if args[1] != nil {
417+ arg1 = args[1].(domain.Principal)
418+ }
419+ var arg2 domain.RepoName
420+ if args[2] != nil {
421+ arg2 = args[2].(domain.RepoName)
422+ }
423+ var arg3 ports.ListIssuesParams
424+ if args[3] != nil {
425+ arg3 = args[3].(ports.ListIssuesParams)
426+ }
427+ run(
428+ arg0,
429+ arg1,
430+ arg2,
431+ arg3,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *IssueService_List_Call) Return(issuePage ports.IssuePage, err error) *IssueService_List_Call {
438+ _c.Call.Return(issuePage, err)
439+ return _c
440+}
441+
442+func (_c *IssueService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueService_List_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
447+// Reopen provides a mock function for the type IssueService
448+func (_mock *IssueService) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
449+ ret := _mock.Called(ctx, p, name, number)
450+
451+ if len(ret) == 0 {
452+ panic("no return value specified for Reopen")
453+ }
454+
455+ var r0 domain.Issue
456+ var r1 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
458+ return returnFunc(ctx, p, name, number)
459+ }
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
461+ r0 = returnFunc(ctx, p, name, number)
462+ } else {
463+ r0 = ret.Get(0).(domain.Issue)
464+ }
465+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
466+ r1 = returnFunc(ctx, p, name, number)
467+ } else {
468+ r1 = ret.Error(1)
469+ }
470+ return r0, r1
471+}
472+
473+// IssueService_Reopen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reopen'
474+type IssueService_Reopen_Call struct {
475+ *mock.Call
476+}
477+
478+// Reopen is a helper method to define mock.On call
479+// - ctx context.Context
480+// - p domain.Principal
481+// - name domain.RepoName
482+// - number int64
483+func (_e *IssueService_Expecter) Reopen(ctx any, p any, name any, number any) *IssueService_Reopen_Call {
484+ return &IssueService_Reopen_Call{Call: _e.mock.On("Reopen", ctx, p, name, number)}
485+}
486+
487+func (_c *IssueService_Reopen_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Reopen_Call {
488+ _c.Call.Run(func(args mock.Arguments) {
489+ var arg0 context.Context
490+ if args[0] != nil {
491+ arg0 = args[0].(context.Context)
492+ }
493+ var arg1 domain.Principal
494+ if args[1] != nil {
495+ arg1 = args[1].(domain.Principal)
496+ }
497+ var arg2 domain.RepoName
498+ if args[2] != nil {
499+ arg2 = args[2].(domain.RepoName)
500+ }
501+ var arg3 int64
502+ if args[3] != nil {
503+ arg3 = args[3].(int64)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ arg2,
509+ arg3,
510+ )
511+ })
512+ return _c
513+}
514+
515+func (_c *IssueService_Reopen_Call) Return(issue domain.Issue, err error) *IssueService_Reopen_Call {
516+ _c.Call.Return(issue, err)
517+ return _c
518+}
519+
520+func (_c *IssueService_Reopen_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Reopen_Call {
521+ _c.Call.Return(run)
522+ return _c
523+}
A internal/ports/mocks/IssueStore.go +515 -0
@@ -0,0 +1,515 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueStore creates a new instance of IssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueStore is an autogenerated mock type for the IssueStore type
39+type IssueStore struct {
40+ mock.Mock
41+}
42+
43+type IssueStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueStore) EXPECT() *IssueStore_Expecter {
48+ return &IssueStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type IssueStore
52+func (_mock *IssueStore) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, issue)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, issue)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) domain.Issue); ok {
65+ r0 = returnFunc(ctx, issue)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Issue) error); ok {
70+ r1 = returnFunc(ctx, issue)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
78+type IssueStore_Create_Call struct {
79+ *mock.Call
80+}
81+
82+// Create is a helper method to define mock.On call
83+// - ctx context.Context
84+// - issue domain.Issue
85+func (_e *IssueStore_Expecter) Create(ctx any, issue any) *IssueStore_Create_Call {
86+ return &IssueStore_Create_Call{Call: _e.mock.On("Create", ctx, issue)}
87+}
88+
89+func (_c *IssueStore_Create_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Create_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Issue
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Issue)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *IssueStore_Create_Call) Return(issue1 domain.Issue, err error) *IssueStore_Create_Call {
108+ _c.Call.Return(issue1, err)
109+ return _c
110+}
111+
112+func (_c *IssueStore_Create_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) (domain.Issue, error)) *IssueStore_Create_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// Get provides a mock function for the type IssueStore
118+func (_mock *IssueStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
119+ ret := _mock.Called(ctx, repo, number)
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for Get")
123+ }
124+
125+ var r0 domain.Issue
126+ var r1 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Issue, error)); ok {
128+ return returnFunc(ctx, repo, number)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Issue); ok {
131+ r0 = returnFunc(ctx, repo, number)
132+ } else {
133+ r0 = ret.Get(0).(domain.Issue)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
136+ r1 = returnFunc(ctx, repo, number)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// IssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
144+type IssueStore_Get_Call struct {
145+ *mock.Call
146+}
147+
148+// Get is a helper method to define mock.On call
149+// - ctx context.Context
150+// - repo domain.RepoName
151+// - number int64
152+func (_e *IssueStore_Expecter) Get(ctx any, repo any, number any) *IssueStore_Get_Call {
153+ return &IssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, number)}
154+}
155+
156+func (_c *IssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_Get_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 context.Context
159+ if args[0] != nil {
160+ arg0 = args[0].(context.Context)
161+ }
162+ var arg1 domain.RepoName
163+ if args[1] != nil {
164+ arg1 = args[1].(domain.RepoName)
165+ }
166+ var arg2 int64
167+ if args[2] != nil {
168+ arg2 = args[2].(int64)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *IssueStore_Get_Call) Return(issue domain.Issue, err error) *IssueStore_Get_Call {
180+ _c.Call.Return(issue, err)
181+ return _c
182+}
183+
184+func (_c *IssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)) *IssueStore_Get_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// List provides a mock function for the type IssueStore
190+func (_mock *IssueStore) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
191+ ret := _mock.Called(ctx, repo, params)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for List")
195+ }
196+
197+ var r0 ports.IssuePage
198+ var r1 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
200+ return returnFunc(ctx, repo, params)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
203+ r0 = returnFunc(ctx, repo, params)
204+ } else {
205+ r0 = ret.Get(0).(ports.IssuePage)
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListIssuesParams) error); ok {
208+ r1 = returnFunc(ctx, repo, params)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// IssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
216+type IssueStore_List_Call struct {
217+ *mock.Call
218+}
219+
220+// List is a helper method to define mock.On call
221+// - ctx context.Context
222+// - repo domain.RepoName
223+// - params ports.ListIssuesParams
224+func (_e *IssueStore_Expecter) List(ctx any, repo any, params any) *IssueStore_List_Call {
225+ return &IssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, params)}
226+}
227+
228+func (_c *IssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams)) *IssueStore_List_Call {
229+ _c.Call.Run(func(args mock.Arguments) {
230+ var arg0 context.Context
231+ if args[0] != nil {
232+ arg0 = args[0].(context.Context)
233+ }
234+ var arg1 domain.RepoName
235+ if args[1] != nil {
236+ arg1 = args[1].(domain.RepoName)
237+ }
238+ var arg2 ports.ListIssuesParams
239+ if args[2] != nil {
240+ arg2 = args[2].(ports.ListIssuesParams)
241+ }
242+ run(
243+ arg0,
244+ arg1,
245+ arg2,
246+ )
247+ })
248+ return _c
249+}
250+
251+func (_c *IssueStore_List_Call) Return(issuePage ports.IssuePage, err error) *IssueStore_List_Call {
252+ _c.Call.Return(issuePage, err)
253+ return _c
254+}
255+
256+func (_c *IssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueStore_List_Call {
257+ _c.Call.Return(run)
258+ return _c
259+}
260+
261+// ListComments provides a mock function for the type IssueStore
262+func (_mock *IssueStore) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
263+ ret := _mock.Called(ctx, repo, number)
264+
265+ if len(ret) == 0 {
266+ panic("no return value specified for ListComments")
267+ }
268+
269+ var r0 []domain.IssueComment
270+ var r1 error
271+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueComment, error)); ok {
272+ return returnFunc(ctx, repo, number)
273+ }
274+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueComment); ok {
275+ r0 = returnFunc(ctx, repo, number)
276+ } else {
277+ if ret.Get(0) != nil {
278+ r0 = ret.Get(0).([]domain.IssueComment)
279+ }
280+ }
281+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
282+ r1 = returnFunc(ctx, repo, number)
283+ } else {
284+ r1 = ret.Error(1)
285+ }
286+ return r0, r1
287+}
288+
289+// IssueStore_ListComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListComments'
290+type IssueStore_ListComments_Call struct {
291+ *mock.Call
292+}
293+
294+// ListComments is a helper method to define mock.On call
295+// - ctx context.Context
296+// - repo domain.RepoName
297+// - number int64
298+func (_e *IssueStore_Expecter) ListComments(ctx any, repo any, number any) *IssueStore_ListComments_Call {
299+ return &IssueStore_ListComments_Call{Call: _e.mock.On("ListComments", ctx, repo, number)}
300+}
301+
302+func (_c *IssueStore_ListComments_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_ListComments_Call {
303+ _c.Call.Run(func(args mock.Arguments) {
304+ var arg0 context.Context
305+ if args[0] != nil {
306+ arg0 = args[0].(context.Context)
307+ }
308+ var arg1 domain.RepoName
309+ if args[1] != nil {
310+ arg1 = args[1].(domain.RepoName)
311+ }
312+ var arg2 int64
313+ if args[2] != nil {
314+ arg2 = args[2].(int64)
315+ }
316+ run(
317+ arg0,
318+ arg1,
319+ arg2,
320+ )
321+ })
322+ return _c
323+}
324+
325+func (_c *IssueStore_ListComments_Call) Return(issueComments []domain.IssueComment, err error) *IssueStore_ListComments_Call {
326+ _c.Call.Return(issueComments, err)
327+ return _c
328+}
329+
330+func (_c *IssueStore_ListComments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)) *IssueStore_ListComments_Call {
331+ _c.Call.Return(run)
332+ return _c
333+}
334+
335+// ListOpenAll provides a mock function for the type IssueStore
336+func (_mock *IssueStore) ListOpenAll(ctx context.Context) ([]domain.Issue, error) {
337+ ret := _mock.Called(ctx)
338+
339+ if len(ret) == 0 {
340+ panic("no return value specified for ListOpenAll")
341+ }
342+
343+ var r0 []domain.Issue
344+ var r1 error
345+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Issue, error)); ok {
346+ return returnFunc(ctx)
347+ }
348+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Issue); ok {
349+ r0 = returnFunc(ctx)
350+ } else {
351+ if ret.Get(0) != nil {
352+ r0 = ret.Get(0).([]domain.Issue)
353+ }
354+ }
355+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
356+ r1 = returnFunc(ctx)
357+ } else {
358+ r1 = ret.Error(1)
359+ }
360+ return r0, r1
361+}
362+
363+// IssueStore_ListOpenAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenAll'
364+type IssueStore_ListOpenAll_Call struct {
365+ *mock.Call
366+}
367+
368+// ListOpenAll is a helper method to define mock.On call
369+// - ctx context.Context
370+func (_e *IssueStore_Expecter) ListOpenAll(ctx any) *IssueStore_ListOpenAll_Call {
371+ return &IssueStore_ListOpenAll_Call{Call: _e.mock.On("ListOpenAll", ctx)}
372+}
373+
374+func (_c *IssueStore_ListOpenAll_Call) Run(run func(ctx context.Context)) *IssueStore_ListOpenAll_Call {
375+ _c.Call.Run(func(args mock.Arguments) {
376+ var arg0 context.Context
377+ if args[0] != nil {
378+ arg0 = args[0].(context.Context)
379+ }
380+ run(
381+ arg0,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *IssueStore_ListOpenAll_Call) Return(issues []domain.Issue, err error) *IssueStore_ListOpenAll_Call {
388+ _c.Call.Return(issues, err)
389+ return _c
390+}
391+
392+func (_c *IssueStore_ListOpenAll_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Issue, error)) *IssueStore_ListOpenAll_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// SaveComment provides a mock function for the type IssueStore
398+func (_mock *IssueStore) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
399+ ret := _mock.Called(ctx, issue, c)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for SaveComment")
403+ }
404+
405+ var r0 error
406+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue, domain.IssueComment) error); ok {
407+ r0 = returnFunc(ctx, issue, c)
408+ } else {
409+ r0 = ret.Error(0)
410+ }
411+ return r0
412+}
413+
414+// IssueStore_SaveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveComment'
415+type IssueStore_SaveComment_Call struct {
416+ *mock.Call
417+}
418+
419+// SaveComment is a helper method to define mock.On call
420+// - ctx context.Context
421+// - issue domain.Issue
422+// - c domain.IssueComment
423+func (_e *IssueStore_Expecter) SaveComment(ctx any, issue any, c any) *IssueStore_SaveComment_Call {
424+ return &IssueStore_SaveComment_Call{Call: _e.mock.On("SaveComment", ctx, issue, c)}
425+}
426+
427+func (_c *IssueStore_SaveComment_Call) Run(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment)) *IssueStore_SaveComment_Call {
428+ _c.Call.Run(func(args mock.Arguments) {
429+ var arg0 context.Context
430+ if args[0] != nil {
431+ arg0 = args[0].(context.Context)
432+ }
433+ var arg1 domain.Issue
434+ if args[1] != nil {
435+ arg1 = args[1].(domain.Issue)
436+ }
437+ var arg2 domain.IssueComment
438+ if args[2] != nil {
439+ arg2 = args[2].(domain.IssueComment)
440+ }
441+ run(
442+ arg0,
443+ arg1,
444+ arg2,
445+ )
446+ })
447+ return _c
448+}
449+
450+func (_c *IssueStore_SaveComment_Call) Return(err error) *IssueStore_SaveComment_Call {
451+ _c.Call.Return(err)
452+ return _c
453+}
454+
455+func (_c *IssueStore_SaveComment_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment) error) *IssueStore_SaveComment_Call {
456+ _c.Call.Return(run)
457+ return _c
458+}
459+
460+// Update provides a mock function for the type IssueStore
461+func (_mock *IssueStore) Update(ctx context.Context, issue domain.Issue) error {
462+ ret := _mock.Called(ctx, issue)
463+
464+ if len(ret) == 0 {
465+ panic("no return value specified for Update")
466+ }
467+
468+ var r0 error
469+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) error); ok {
470+ r0 = returnFunc(ctx, issue)
471+ } else {
472+ r0 = ret.Error(0)
473+ }
474+ return r0
475+}
476+
477+// IssueStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
478+type IssueStore_Update_Call struct {
479+ *mock.Call
480+}
481+
482+// Update is a helper method to define mock.On call
483+// - ctx context.Context
484+// - issue domain.Issue
485+func (_e *IssueStore_Expecter) Update(ctx any, issue any) *IssueStore_Update_Call {
486+ return &IssueStore_Update_Call{Call: _e.mock.On("Update", ctx, issue)}
487+}
488+
489+func (_c *IssueStore_Update_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Update_Call {
490+ _c.Call.Run(func(args mock.Arguments) {
491+ var arg0 context.Context
492+ if args[0] != nil {
493+ arg0 = args[0].(context.Context)
494+ }
495+ var arg1 domain.Issue
496+ if args[1] != nil {
497+ arg1 = args[1].(domain.Issue)
498+ }
499+ run(
500+ arg0,
501+ arg1,
502+ )
503+ })
504+ return _c
505+}
506+
507+func (_c *IssueStore_Update_Call) Return(err error) *IssueStore_Update_Call {
508+ _c.Call.Return(err)
509+ return _c
510+}
511+
512+func (_c *IssueStore_Update_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) error) *IssueStore_Update_Call {
513+ _c.Call.Return(run)
514+ return _c
515+}
@@ -0,0 +1,515 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueStore creates a new instance of IssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueStore is an autogenerated mock type for the IssueStore type
39+type IssueStore struct {
40+ mock.Mock
41+}
42+
43+type IssueStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueStore) EXPECT() *IssueStore_Expecter {
48+ return &IssueStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type IssueStore
52+func (_mock *IssueStore) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, issue)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, issue)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) domain.Issue); ok {
65+ r0 = returnFunc(ctx, issue)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Issue) error); ok {
70+ r1 = returnFunc(ctx, issue)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
78+type IssueStore_Create_Call struct {
79+ *mock.Call
80+}
81+
82+// Create is a helper method to define mock.On call
83+// - ctx context.Context
84+// - issue domain.Issue
85+func (_e *IssueStore_Expecter) Create(ctx any, issue any) *IssueStore_Create_Call {
86+ return &IssueStore_Create_Call{Call: _e.mock.On("Create", ctx, issue)}
87+}
88+
89+func (_c *IssueStore_Create_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Create_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Issue
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Issue)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *IssueStore_Create_Call) Return(issue1 domain.Issue, err error) *IssueStore_Create_Call {
108+ _c.Call.Return(issue1, err)
109+ return _c
110+}
111+
112+func (_c *IssueStore_Create_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) (domain.Issue, error)) *IssueStore_Create_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// Get provides a mock function for the type IssueStore
118+func (_mock *IssueStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
119+ ret := _mock.Called(ctx, repo, number)
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for Get")
123+ }
124+
125+ var r0 domain.Issue
126+ var r1 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Issue, error)); ok {
128+ return returnFunc(ctx, repo, number)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Issue); ok {
131+ r0 = returnFunc(ctx, repo, number)
132+ } else {
133+ r0 = ret.Get(0).(domain.Issue)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
136+ r1 = returnFunc(ctx, repo, number)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// IssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
144+type IssueStore_Get_Call struct {
145+ *mock.Call
146+}
147+
148+// Get is a helper method to define mock.On call
149+// - ctx context.Context
150+// - repo domain.RepoName
151+// - number int64
152+func (_e *IssueStore_Expecter) Get(ctx any, repo any, number any) *IssueStore_Get_Call {
153+ return &IssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, number)}
154+}
155+
156+func (_c *IssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_Get_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 context.Context
159+ if args[0] != nil {
160+ arg0 = args[0].(context.Context)
161+ }
162+ var arg1 domain.RepoName
163+ if args[1] != nil {
164+ arg1 = args[1].(domain.RepoName)
165+ }
166+ var arg2 int64
167+ if args[2] != nil {
168+ arg2 = args[2].(int64)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *IssueStore_Get_Call) Return(issue domain.Issue, err error) *IssueStore_Get_Call {
180+ _c.Call.Return(issue, err)
181+ return _c
182+}
183+
184+func (_c *IssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)) *IssueStore_Get_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// List provides a mock function for the type IssueStore
190+func (_mock *IssueStore) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
191+ ret := _mock.Called(ctx, repo, params)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for List")
195+ }
196+
197+ var r0 ports.IssuePage
198+ var r1 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
200+ return returnFunc(ctx, repo, params)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
203+ r0 = returnFunc(ctx, repo, params)
204+ } else {
205+ r0 = ret.Get(0).(ports.IssuePage)
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListIssuesParams) error); ok {
208+ r1 = returnFunc(ctx, repo, params)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// IssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
216+type IssueStore_List_Call struct {
217+ *mock.Call
218+}
219+
220+// List is a helper method to define mock.On call
221+// - ctx context.Context
222+// - repo domain.RepoName
223+// - params ports.ListIssuesParams
224+func (_e *IssueStore_Expecter) List(ctx any, repo any, params any) *IssueStore_List_Call {
225+ return &IssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, params)}
226+}
227+
228+func (_c *IssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams)) *IssueStore_List_Call {
229+ _c.Call.Run(func(args mock.Arguments) {
230+ var arg0 context.Context
231+ if args[0] != nil {
232+ arg0 = args[0].(context.Context)
233+ }
234+ var arg1 domain.RepoName
235+ if args[1] != nil {
236+ arg1 = args[1].(domain.RepoName)
237+ }
238+ var arg2 ports.ListIssuesParams
239+ if args[2] != nil {
240+ arg2 = args[2].(ports.ListIssuesParams)
241+ }
242+ run(
243+ arg0,
244+ arg1,
245+ arg2,
246+ )
247+ })
248+ return _c
249+}
250+
251+func (_c *IssueStore_List_Call) Return(issuePage ports.IssuePage, err error) *IssueStore_List_Call {
252+ _c.Call.Return(issuePage, err)
253+ return _c
254+}
255+
256+func (_c *IssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueStore_List_Call {
257+ _c.Call.Return(run)
258+ return _c
259+}
260+
261+// ListComments provides a mock function for the type IssueStore
262+func (_mock *IssueStore) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
263+ ret := _mock.Called(ctx, repo, number)
264+
265+ if len(ret) == 0 {
266+ panic("no return value specified for ListComments")
267+ }
268+
269+ var r0 []domain.IssueComment
270+ var r1 error
271+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueComment, error)); ok {
272+ return returnFunc(ctx, repo, number)
273+ }
274+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueComment); ok {
275+ r0 = returnFunc(ctx, repo, number)
276+ } else {
277+ if ret.Get(0) != nil {
278+ r0 = ret.Get(0).([]domain.IssueComment)
279+ }
280+ }
281+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
282+ r1 = returnFunc(ctx, repo, number)
283+ } else {
284+ r1 = ret.Error(1)
285+ }
286+ return r0, r1
287+}
288+
289+// IssueStore_ListComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListComments'
290+type IssueStore_ListComments_Call struct {
291+ *mock.Call
292+}
293+
294+// ListComments is a helper method to define mock.On call
295+// - ctx context.Context
296+// - repo domain.RepoName
297+// - number int64
298+func (_e *IssueStore_Expecter) ListComments(ctx any, repo any, number any) *IssueStore_ListComments_Call {
299+ return &IssueStore_ListComments_Call{Call: _e.mock.On("ListComments", ctx, repo, number)}
300+}
301+
302+func (_c *IssueStore_ListComments_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_ListComments_Call {
303+ _c.Call.Run(func(args mock.Arguments) {
304+ var arg0 context.Context
305+ if args[0] != nil {
306+ arg0 = args[0].(context.Context)
307+ }
308+ var arg1 domain.RepoName
309+ if args[1] != nil {
310+ arg1 = args[1].(domain.RepoName)
311+ }
312+ var arg2 int64
313+ if args[2] != nil {
314+ arg2 = args[2].(int64)
315+ }
316+ run(
317+ arg0,
318+ arg1,
319+ arg2,
320+ )
321+ })
322+ return _c
323+}
324+
325+func (_c *IssueStore_ListComments_Call) Return(issueComments []domain.IssueComment, err error) *IssueStore_ListComments_Call {
326+ _c.Call.Return(issueComments, err)
327+ return _c
328+}
329+
330+func (_c *IssueStore_ListComments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)) *IssueStore_ListComments_Call {
331+ _c.Call.Return(run)
332+ return _c
333+}
334+
335+// ListOpenAll provides a mock function for the type IssueStore
336+func (_mock *IssueStore) ListOpenAll(ctx context.Context) ([]domain.Issue, error) {
337+ ret := _mock.Called(ctx)
338+
339+ if len(ret) == 0 {
340+ panic("no return value specified for ListOpenAll")
341+ }
342+
343+ var r0 []domain.Issue
344+ var r1 error
345+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Issue, error)); ok {
346+ return returnFunc(ctx)
347+ }
348+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Issue); ok {
349+ r0 = returnFunc(ctx)
350+ } else {
351+ if ret.Get(0) != nil {
352+ r0 = ret.Get(0).([]domain.Issue)
353+ }
354+ }
355+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
356+ r1 = returnFunc(ctx)
357+ } else {
358+ r1 = ret.Error(1)
359+ }
360+ return r0, r1
361+}
362+
363+// IssueStore_ListOpenAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenAll'
364+type IssueStore_ListOpenAll_Call struct {
365+ *mock.Call
366+}
367+
368+// ListOpenAll is a helper method to define mock.On call
369+// - ctx context.Context
370+func (_e *IssueStore_Expecter) ListOpenAll(ctx any) *IssueStore_ListOpenAll_Call {
371+ return &IssueStore_ListOpenAll_Call{Call: _e.mock.On("ListOpenAll", ctx)}
372+}
373+
374+func (_c *IssueStore_ListOpenAll_Call) Run(run func(ctx context.Context)) *IssueStore_ListOpenAll_Call {
375+ _c.Call.Run(func(args mock.Arguments) {
376+ var arg0 context.Context
377+ if args[0] != nil {
378+ arg0 = args[0].(context.Context)
379+ }
380+ run(
381+ arg0,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *IssueStore_ListOpenAll_Call) Return(issues []domain.Issue, err error) *IssueStore_ListOpenAll_Call {
388+ _c.Call.Return(issues, err)
389+ return _c
390+}
391+
392+func (_c *IssueStore_ListOpenAll_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Issue, error)) *IssueStore_ListOpenAll_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// SaveComment provides a mock function for the type IssueStore
398+func (_mock *IssueStore) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
399+ ret := _mock.Called(ctx, issue, c)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for SaveComment")
403+ }
404+
405+ var r0 error
406+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue, domain.IssueComment) error); ok {
407+ r0 = returnFunc(ctx, issue, c)
408+ } else {
409+ r0 = ret.Error(0)
410+ }
411+ return r0
412+}
413+
414+// IssueStore_SaveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveComment'
415+type IssueStore_SaveComment_Call struct {
416+ *mock.Call
417+}
418+
419+// SaveComment is a helper method to define mock.On call
420+// - ctx context.Context
421+// - issue domain.Issue
422+// - c domain.IssueComment
423+func (_e *IssueStore_Expecter) SaveComment(ctx any, issue any, c any) *IssueStore_SaveComment_Call {
424+ return &IssueStore_SaveComment_Call{Call: _e.mock.On("SaveComment", ctx, issue, c)}
425+}
426+
427+func (_c *IssueStore_SaveComment_Call) Run(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment)) *IssueStore_SaveComment_Call {
428+ _c.Call.Run(func(args mock.Arguments) {
429+ var arg0 context.Context
430+ if args[0] != nil {
431+ arg0 = args[0].(context.Context)
432+ }
433+ var arg1 domain.Issue
434+ if args[1] != nil {
435+ arg1 = args[1].(domain.Issue)
436+ }
437+ var arg2 domain.IssueComment
438+ if args[2] != nil {
439+ arg2 = args[2].(domain.IssueComment)
440+ }
441+ run(
442+ arg0,
443+ arg1,
444+ arg2,
445+ )
446+ })
447+ return _c
448+}
449+
450+func (_c *IssueStore_SaveComment_Call) Return(err error) *IssueStore_SaveComment_Call {
451+ _c.Call.Return(err)
452+ return _c
453+}
454+
455+func (_c *IssueStore_SaveComment_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment) error) *IssueStore_SaveComment_Call {
456+ _c.Call.Return(run)
457+ return _c
458+}
459+
460+// Update provides a mock function for the type IssueStore
461+func (_mock *IssueStore) Update(ctx context.Context, issue domain.Issue) error {
462+ ret := _mock.Called(ctx, issue)
463+
464+ if len(ret) == 0 {
465+ panic("no return value specified for Update")
466+ }
467+
468+ var r0 error
469+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) error); ok {
470+ r0 = returnFunc(ctx, issue)
471+ } else {
472+ r0 = ret.Error(0)
473+ }
474+ return r0
475+}
476+
477+// IssueStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
478+type IssueStore_Update_Call struct {
479+ *mock.Call
480+}
481+
482+// Update is a helper method to define mock.On call
483+// - ctx context.Context
484+// - issue domain.Issue
485+func (_e *IssueStore_Expecter) Update(ctx any, issue any) *IssueStore_Update_Call {
486+ return &IssueStore_Update_Call{Call: _e.mock.On("Update", ctx, issue)}
487+}
488+
489+func (_c *IssueStore_Update_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Update_Call {
490+ _c.Call.Run(func(args mock.Arguments) {
491+ var arg0 context.Context
492+ if args[0] != nil {
493+ arg0 = args[0].(context.Context)
494+ }
495+ var arg1 domain.Issue
496+ if args[1] != nil {
497+ arg1 = args[1].(domain.Issue)
498+ }
499+ run(
500+ arg0,
501+ arg1,
502+ )
503+ })
504+ return _c
505+}
506+
507+func (_c *IssueStore_Update_Call) Return(err error) *IssueStore_Update_Call {
508+ _c.Call.Return(err)
509+ return _c
510+}
511+
512+func (_c *IssueStore_Update_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) error) *IssueStore_Update_Call {
513+ _c.Call.Return(run)
514+ return _c
515+}
A internal/ports/mocks/IssueSubmissionService.go +190 -0
@@ -0,0 +1,190 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueSubmissionService creates a new instance of IssueSubmissionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueSubmissionService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueSubmissionService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueSubmissionService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueSubmissionService is an autogenerated mock type for the IssueSubmissionService type
39+type IssueSubmissionService struct {
40+ mock.Mock
41+}
42+
43+type IssueSubmissionService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueSubmissionService) EXPECT() *IssueSubmissionService_Expecter {
48+ return &IssueSubmissionService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Challenge provides a mock function for the type IssueSubmissionService
52+func (_mock *IssueSubmissionService) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
53+ ret := _mock.Called(ctx, repo, addr)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Challenge")
57+ }
58+
59+ var r0 ports.IssueChallenge
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (ports.IssueChallenge, error)); ok {
62+ return returnFunc(ctx, repo, addr)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) ports.IssueChallenge); ok {
65+ r0 = returnFunc(ctx, repo, addr)
66+ } else {
67+ r0 = ret.Get(0).(ports.IssueChallenge)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
70+ r1 = returnFunc(ctx, repo, addr)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueSubmissionService_Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Challenge'
78+type IssueSubmissionService_Challenge_Call struct {
79+ *mock.Call
80+}
81+
82+// Challenge is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - addr string
86+func (_e *IssueSubmissionService_Expecter) Challenge(ctx any, repo any, addr any) *IssueSubmissionService_Challenge_Call {
87+ return &IssueSubmissionService_Challenge_Call{Call: _e.mock.On("Challenge", ctx, repo, addr)}
88+}
89+
90+func (_c *IssueSubmissionService_Challenge_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string)) *IssueSubmissionService_Challenge_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.RepoName
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.RepoName)
99+ }
100+ var arg2 string
101+ if args[2] != nil {
102+ arg2 = args[2].(string)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *IssueSubmissionService_Challenge_Call) Return(issueChallenge ports.IssueChallenge, err error) *IssueSubmissionService_Challenge_Call {
114+ _c.Call.Return(issueChallenge, err)
115+ return _c
116+}
117+
118+func (_c *IssueSubmissionService_Challenge_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error)) *IssueSubmissionService_Challenge_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
123+// Submit provides a mock function for the type IssueSubmissionService
124+func (_mock *IssueSubmissionService) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
125+ ret := _mock.Called(ctx, repo, addr, f)
126+
127+ if len(ret) == 0 {
128+ panic("no return value specified for Submit")
129+ }
130+
131+ var r0 error
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, ports.IssueSubmission) error); ok {
133+ r0 = returnFunc(ctx, repo, addr, f)
134+ } else {
135+ r0 = ret.Error(0)
136+ }
137+ return r0
138+}
139+
140+// IssueSubmissionService_Submit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Submit'
141+type IssueSubmissionService_Submit_Call struct {
142+ *mock.Call
143+}
144+
145+// Submit is a helper method to define mock.On call
146+// - ctx context.Context
147+// - repo domain.RepoName
148+// - addr string
149+// - f ports.IssueSubmission
150+func (_e *IssueSubmissionService_Expecter) Submit(ctx any, repo any, addr any, f any) *IssueSubmissionService_Submit_Call {
151+ return &IssueSubmissionService_Submit_Call{Call: _e.mock.On("Submit", ctx, repo, addr, f)}
152+}
153+
154+func (_c *IssueSubmissionService_Submit_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission)) *IssueSubmissionService_Submit_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 context.Context
157+ if args[0] != nil {
158+ arg0 = args[0].(context.Context)
159+ }
160+ var arg1 domain.RepoName
161+ if args[1] != nil {
162+ arg1 = args[1].(domain.RepoName)
163+ }
164+ var arg2 string
165+ if args[2] != nil {
166+ arg2 = args[2].(string)
167+ }
168+ var arg3 ports.IssueSubmission
169+ if args[3] != nil {
170+ arg3 = args[3].(ports.IssueSubmission)
171+ }
172+ run(
173+ arg0,
174+ arg1,
175+ arg2,
176+ arg3,
177+ )
178+ })
179+ return _c
180+}
181+
182+func (_c *IssueSubmissionService_Submit_Call) Return(err error) *IssueSubmissionService_Submit_Call {
183+ _c.Call.Return(err)
184+ return _c
185+}
186+
187+func (_c *IssueSubmissionService_Submit_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error) *IssueSubmissionService_Submit_Call {
188+ _c.Call.Return(run)
189+ return _c
190+}
@@ -0,0 +1,190 @@
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+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueSubmissionService creates a new instance of IssueSubmissionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueSubmissionService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueSubmissionService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueSubmissionService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueSubmissionService is an autogenerated mock type for the IssueSubmissionService type
39+type IssueSubmissionService struct {
40+ mock.Mock
41+}
42+
43+type IssueSubmissionService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueSubmissionService) EXPECT() *IssueSubmissionService_Expecter {
48+ return &IssueSubmissionService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Challenge provides a mock function for the type IssueSubmissionService
52+func (_mock *IssueSubmissionService) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
53+ ret := _mock.Called(ctx, repo, addr)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Challenge")
57+ }
58+
59+ var r0 ports.IssueChallenge
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (ports.IssueChallenge, error)); ok {
62+ return returnFunc(ctx, repo, addr)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) ports.IssueChallenge); ok {
65+ r0 = returnFunc(ctx, repo, addr)
66+ } else {
67+ r0 = ret.Get(0).(ports.IssueChallenge)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
70+ r1 = returnFunc(ctx, repo, addr)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueSubmissionService_Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Challenge'
78+type IssueSubmissionService_Challenge_Call struct {
79+ *mock.Call
80+}
81+
82+// Challenge is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - addr string
86+func (_e *IssueSubmissionService_Expecter) Challenge(ctx any, repo any, addr any) *IssueSubmissionService_Challenge_Call {
87+ return &IssueSubmissionService_Challenge_Call{Call: _e.mock.On("Challenge", ctx, repo, addr)}
88+}
89+
90+func (_c *IssueSubmissionService_Challenge_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string)) *IssueSubmissionService_Challenge_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.RepoName
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.RepoName)
99+ }
100+ var arg2 string
101+ if args[2] != nil {
102+ arg2 = args[2].(string)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *IssueSubmissionService_Challenge_Call) Return(issueChallenge ports.IssueChallenge, err error) *IssueSubmissionService_Challenge_Call {
114+ _c.Call.Return(issueChallenge, err)
115+ return _c
116+}
117+
118+func (_c *IssueSubmissionService_Challenge_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error)) *IssueSubmissionService_Challenge_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
123+// Submit provides a mock function for the type IssueSubmissionService
124+func (_mock *IssueSubmissionService) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
125+ ret := _mock.Called(ctx, repo, addr, f)
126+
127+ if len(ret) == 0 {
128+ panic("no return value specified for Submit")
129+ }
130+
131+ var r0 error
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, ports.IssueSubmission) error); ok {
133+ r0 = returnFunc(ctx, repo, addr, f)
134+ } else {
135+ r0 = ret.Error(0)
136+ }
137+ return r0
138+}
139+
140+// IssueSubmissionService_Submit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Submit'
141+type IssueSubmissionService_Submit_Call struct {
142+ *mock.Call
143+}
144+
145+// Submit is a helper method to define mock.On call
146+// - ctx context.Context
147+// - repo domain.RepoName
148+// - addr string
149+// - f ports.IssueSubmission
150+func (_e *IssueSubmissionService_Expecter) Submit(ctx any, repo any, addr any, f any) *IssueSubmissionService_Submit_Call {
151+ return &IssueSubmissionService_Submit_Call{Call: _e.mock.On("Submit", ctx, repo, addr, f)}
152+}
153+
154+func (_c *IssueSubmissionService_Submit_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission)) *IssueSubmissionService_Submit_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 context.Context
157+ if args[0] != nil {
158+ arg0 = args[0].(context.Context)
159+ }
160+ var arg1 domain.RepoName
161+ if args[1] != nil {
162+ arg1 = args[1].(domain.RepoName)
163+ }
164+ var arg2 string
165+ if args[2] != nil {
166+ arg2 = args[2].(string)
167+ }
168+ var arg3 ports.IssueSubmission
169+ if args[3] != nil {
170+ arg3 = args[3].(ports.IssueSubmission)
171+ }
172+ run(
173+ arg0,
174+ arg1,
175+ arg2,
176+ arg3,
177+ )
178+ })
179+ return _c
180+}
181+
182+func (_c *IssueSubmissionService_Submit_Call) Return(err error) *IssueSubmissionService_Submit_Call {
183+ _c.Call.Return(err)
184+ return _c
185+}
186+
187+func (_c *IssueSubmissionService_Submit_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error) *IssueSubmissionService_Submit_Call {
188+ _c.Call.Return(run)
189+ return _c
190+}
M internal/ports/mocks/MergeRequestStore.go +62 -0
A internal/ports/mocks/PendingIssueStore.go +452 -0
A internal/ports/mocks/SubmissionLimiter.go +188 -0
M internal/ports/ports.go +81 -0
M internal/ports/services.go +94 -0
M internal/usecase/events.go +97 -3
A internal/usecase/events_owner_test.go +200 -0
M internal/usecase/events_test.go +43 -0
M internal/usecase/export_test.go +34 -0
A internal/usecase/inbox.go +164 -0
A internal/usecase/inbox_test.go +110 -0
A internal/usecase/issue_keys.go +62 -0
A internal/usecase/issue_moderation.go +119 -0
A internal/usecase/issue_moderation_test.go +251 -0
A internal/usecase/issue_pow_vector_test.go +39 -0
A internal/usecase/issue_submissions.go +383 -0
A internal/usecase/issue_submissions_test.go +670 -0
A internal/usecase/issues.go +291 -0
A internal/usecase/issues_integration_test.go +87 -0
A internal/usecase/issues_test.go +276 -0
M internal/usecase/mergerequests_integration_test.go +1 -1
M internal/usecase/repos_write.go +13 -3
M internal/usecase/repos_write_test.go +42 -0
A proto/ermite/storage/issue.proto +71 -0
M proto/ermite/storage/merge_request.proto +10 -0
M skills/ermite/CLI.md +15 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 43

↑↓ moveenter openesc close