detached bafd741

Merge 346e37f5c6d7b838423d30ea0c9b7a5f83162b65 into eba40da6c2f55746079d8f7bf90538ee1c211b4f

commit
bafd741bdc2ebb5b1e0c13f90ea256886b53a3d2
author
ermite · · 2026-10-19 09:39 UTC
merge of
eba40da + 346e37f · diff against the first parent
signature
unsigned
174 files · +18065 -742
M README.md +1 -0
@@ -134,6 +134,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
134134 - [Git over HTTP](docs/git-http.md): URLs and authentication
135135 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
136136 - [Merge requests](docs/merge-requests.md)
137+- [Issues](docs/issues.md)
137138 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys
138139 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
139140 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
M docs/cli.md +7 -2
@@ -48,7 +48,7 @@ It does not run:
4848
4949 ```sh
5050 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false]
51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--issues off|owner|public]
5252 ermite repo delete <name> [--yes]
5353
5454 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -58,6 +58,9 @@ ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo
5858 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
5959 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
6060
61+ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
62+ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
63+
6164 ermite run list <repo> [--workflow …] [--branch …] [--status …]
6265 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
6366 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
@@ -81,9 +84,11 @@ ermite version # client and server versions
8184 ermite completion bash|zsh|fish
8285 ```
8386
87+Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).
88+
8489 ## Output
8590
86-- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
91+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
8792 - `watch --json` prints one object per line.
8893 - Errors are one line on stderr (`ermite: …`) with exit status 1.
8994 - Shell completion completes repository names from the server.
M docs/configuration.md +1 -1
@@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
6666
6767 ## Storage schema
6868
69-This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
69+This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
A docs/issues.md +55 -0
@@ -0,0 +1,55 @@
1+# Issues
2+
3+An issue records a task, a bug or a question about a repository: a title, a body, comments, and an open or closed state. Issues share the numbering of merge requests: after merge request 1, the next issue is number 2.
4+
5+```sh
6+ermite repo edit myrepo --issues owner
7+ermite issue create myrepo --title "Crash on start" --body "It crashes."
8+ermite issue list myrepo [--state open|closed|all]
9+ermite issue show myrepo 2
10+ermite issue comment myrepo 2 -m "Reproduced."
11+ermite issue close myrepo 2
12+ermite issue reopen myrepo 2
13+```
14+
15+All commands take `--json`. `--body -` and `--body-file -` read standard input. A server needs the `issues` feature (`ermite version`); without it the commands, `repo edit --issues` and `token create --scope issue:write` are refused.
16+
17+## The `issues` setting
18+
19+`ermite repo edit <repo> --issues off|owner|public`, shown by `repo show` (JSON field `issues`):
20+
21+- `off` (default): issues are disabled.
22+- `owner`: the owner and tokens with `issue:write` write issues.
23+- `public`: anyone may propose an issue through a moderated form (see [Public form and moderation](#public-form-and-moderation)). The repository must be public too.
24+
25+## Who can write
26+
27+The owner, and tokens with the `issue:write` scope. For an agent that proposes changes on its own branches and reports issues:
28+
29+```sh
30+ermite token create claude --preset agent --scope issue:write --repo <repo> --branch-prefix agent/<name>/
31+```
32+
33+## Numbering
34+
35+Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
36+
37+## Inbox
38+
39+The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
40+
41+The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
42+
43+## Public form and moderation
44+
45+With `issues` set to `public` on a public repository, a visitor who is not signed in finds a **New issue** button on the Issues tab, which opens `/<repo>/issues/new`. The form needs JavaScript: the page solves a short proof of work in a worker while the visitor writes (a second or so; harder after many recent submissions), and the button stays disabled until it is done and a few seconds have passed. Without JavaScript the form stays hidden and the page says why. A repository that is private, or whose mode is `off` or `owner`, has no such page: it answers the same 404 as an unknown one.
46+
47+Limits: a title of at most 120 characters, a description of at most 10,000, an optional name of at most 40 (not verified). Each network may submit 3 issues per hour and 10 per day. At most 200 submissions await moderation; beyond that the form says submissions are paused. Before the proof of work is valid, nothing reveals whether a network is banned or limited, or whether the queue is full: every such failure gets the same generic refusal. A submission never appears on its own: it is stored as pending and shows no id to the visitor.
48+
49+The owner moderates in the Inbox, in the first section, **Awaiting moderation**, whose count is in the counters row. Each submission shows its repository, age, name (or "anonymous"), title and the start of the description, as plain text, with three buttons: **Approve** opens it as the next issue of the repository (numbers are shared with merge requests), **Reject** deletes it, **Reject and ban** deletes it and refuses further submissions from the same network for 30 days. The section updates live when a submission arrives.
50+
51+An approved issue is rendered as untrusted Markdown: raw HTML is dropped, images are never loaded (their alt text is shown), only `http`, `https` and `mailto` links are kept, each with `rel="nofollow noopener noreferrer ugc"`, and every other link loses its link and keeps its text.
52+
53+No IP address is written to disk. The rate limiter keys on the normalized network address (the IPv4 address, or the IPv6 /64) for at most 24 hours and keeps it only in memory. Pending submissions and bans store only a keyed hash (HMAC) of that network, made with a server key.
54+
55+The limits see the address of the client only if `ERMITE_TRUSTED_PROXIES` lists your reverse proxy; otherwise every visitor shares the proxy's address and one budget (see [configuration](configuration.md)).
M docs/tokens.md +2 -1
@@ -6,7 +6,7 @@ ermite token list
66 ermite token revoke <id>
77 ```
88
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).
9+The secret is printed once. Presets and `--scope` add up (`issue:write` lets a token write issues). The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).
1010
1111 | Preset | For |
1212 |---|---|
@@ -29,6 +29,7 @@ The token **can**:
2929
3030 - push, force-push and delete branches under its prefixes;
3131 - open, comment on and close merge requests;
32+- with `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;
3233 - with `mr:merge`, merge into a branch under its prefixes;
3334 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
3435 - create tokens whose prefixes lie within its own.
A gen/ermite/storage/issue.pb.go +541 -0 generated file: collapsed
M gen/ermite/storage/merge_request.pb.go +102 -31 generated file: collapsed
M go.mod +3 -1
@@ -32,6 +32,7 @@ require (
3232 golang.org/x/crypto v0.57.0
3333 golang.org/x/sys v0.49.0
3434 golang.org/x/term v0.46.0
35+ golang.org/x/text v0.42.0
3536 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
3637 google.golang.org/protobuf v1.36.12
3738 )
@@ -164,7 +165,6 @@ require (
164165 golang.org/x/mod v0.41.0 // indirect
165166 golang.org/x/net v0.59.0 // indirect
166167 golang.org/x/sync v0.23.0 // indirect
167- golang.org/x/text v0.42.0 // indirect
168168 golang.org/x/tools v0.51.0 // indirect
169169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
170170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
@@ -172,3 +172,5 @@ require (
172172 mvdan.cc/xurls/v2 v2.6.0 // indirect
173173 pluginrpc.com/pluginrpc v0.5.0 // indirect
174174 )
175+
176+replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/feat-issue-form
M go.sum +0 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
@@ -95,6 +95,11 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient {
9595 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)
9696 }
9797
98+// Issues returns the IssueService client.
99+func (c *Client) Issues() ermiteconnect.IssueServiceClient {
100+ return ermiteconnect.NewIssueServiceClient(c.http, c.base, c.opts...)
101+}
102+
98103 // Secrets returns the SecretService client.
99104 func (c *Client) Secrets() ermiteconnect.SecretServiceClient {
100105 return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +3 -3
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
150+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession,
151151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
M internal/adapters/client/features.go +4 -1
@@ -18,6 +18,9 @@ const (
1818 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch
1919 // (`ermite mr merge --delete-branch`).
2020 FeatureMergeDeleteBranch = "merge_requests.delete_branch"
21+ // FeatureIssues is IssueService, RepoSettings.issues and the issue:write
22+ // scope (`ermite issue`, `ermite repo edit --issues`).
23+ FeatureIssues = "issues"
2124 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
2225 FeatureSigningKey = "signing_key"
2326 // FeatureSession is SessionService (`ermite web login`, `web logout`).
@@ -55,7 +58,7 @@ const (
5558
5659 // UsedFeatures lists the features the CLI uses, in the order they are reported.
5760 func UsedFeatures() []string {
58- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
61+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
5962 }
6063
6164 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/client/scopes.go +5 -3
@@ -35,12 +35,14 @@ func ScopeName(s ermite.Scope) string {
3535 return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1)
3636 }
3737
38-// AllScopes lists every scope the contract defines, in enum order. Deriving it
39-// keeps the `cli` preset ("every scope") true when the contract grows.
38+// AllScopes lists every scope the contract defines that this server accepts, in
39+// enum order. Deriving it keeps the `cli` preset ("every scope") true when the
40+// contract grows. SCOPE_MR_REVIEW is in the contract but not yet implemented
41+// here, and the server rejects it like any unknown scope, so it is left out.
4042 func AllScopes() []ermite.Scope {
4143 var scopes []ermite.Scope
4244 for n := range ermite.Scope_name {
43- if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED {
45+ if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED && s != ermite.Scope_SCOPE_MR_REVIEW {
4446 scopes = append(scopes, s)
4547 }
4648 }
M internal/adapters/client/scopes_test.go +2 -0
@@ -25,6 +25,7 @@ func TestScopeWireNames(t *testing.T) {
2525 ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write",
2626 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
2727 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
28+ ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
2829 }
2930 for s, name := range want {
3031 assert.Equal(t, name, client.ScopeName(s))
@@ -43,6 +44,7 @@ func TestPresetExpansion(t *testing.T) {
4344
4445 agent, err := client.PresetScopes("agent")
4546 require.NoError(t, err)
47+ assert.NotContains(t, agent, ermite.Scope_SCOPE_ISSUE_WRITE, "agents get issue:write only when asked")
4648 assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent)
4749
4850 ci, err := client.PresetScopes("CI")
M internal/adapters/kvstore/export_test.go +41 -1
@@ -2,7 +2,13 @@
22
33 package kvstore
44
5-import "context"
5+import (
6+ "context"
7+
8+ kv "bnema.dev/ermite-kv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
612
713 // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of
814 // DeleteEnvironment and its batch, and returns the function that removes it.
@@ -25,3 +31,37 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) {
2531 item, ok := c.db.Get(ctx, runnerKey(id))
2632 return item.Version, ok
2733 }
34+
35+// CountKeysForTest counts the keys of the database under prefix.
36+func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int {
37+ n := 0
38+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
39+ n++
40+ }
41+ return n
42+}
43+
44+// DropIssueRecordForTest deletes the issue record and leaves its open index
45+// entry: the state of an index that is stale.
46+func (i *Issues) DropIssueRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error {
47+ var b kv.Batch
48+ b.Delete(issueKey(repo, n))
49+ _, err := i.db.Apply(ctx, &b)
50+ return err
51+}
52+
53+// DropMRRecordForTest is DropIssueRecordForTest for a merge request.
54+func (m *MergeRequests) DropMRRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error {
55+ var b kv.Batch
56+ b.Delete(mrKey(repo, n))
57+ _, err := m.db.Apply(ctx, &b)
58+ return err
59+}
60+
61+// PutRawIssueForTest stores value as the issue record, bypassing the codec.
62+func (i *Issues) PutRawIssueForTest(ctx context.Context, repo domain.RepoName, n int64, value []byte) error {
63+ var b kv.Batch
64+ b.Put(issueKey(repo, n), value)
65+ _, err := i.db.Apply(ctx, &b)
66+ return err
67+}
A internal/adapters/kvstore/issues.go +437 -0
@@ -0,0 +1,437 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of the issues, like that of the merge requests. The numbers come
23+// from the sequence of the merge requests (mrseq/<repo>): an issue and a merge
24+// request never share one.
25+const (
26+ issuePrefix = "is/" // is/<repo>/<number:010d>
27+ issueCommentPrefix = "isc/" // isc/<repo>/<number:010d>/<id:010d>
28+ issueOpenPrefix = "isopen/" // isopen/<repo>/<number:010d>, written with the issue
29+)
30+
31+// Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32+// the Store it comes from.
33+type Issues struct {
34+ db *kv.DB
35+ locks *numberLocks
36+}
37+
38+var _ ports.IssueStore = (*Issues)(nil)
39+
40+// Issues returns the issue store over the same database. It is valid until the
41+// Store is closed.
42+func (s *Store) Issues() *Issues { return &Issues{db: s.db, locks: s.locks} }
43+
44+func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
45+func issueKey(repo domain.RepoName, n int64) []byte {
46+ return []byte(issueRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
47+}
48+func issueCommentsPrefix(repo domain.RepoName, n int64) string {
49+ return issueCommentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/"
50+}
51+func issueCommentKey(repo domain.RepoName, n, id int64) []byte {
52+ return []byte(issueCommentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id))
53+}
54+func issueOpenRepoPrefix(repo domain.RepoName) string { return issueOpenPrefix + repo.String() + "/" }
55+func issueOpenKey(repo domain.RepoName, n int64) []byte {
56+ return []byte(issueOpenRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
57+}
58+
59+// indexOpenIssue adds to b the write that keeps the index of the open issues in
60+// step with the issue: the key is put when it is open, deleted otherwise
61+// (deleting a key that is not there is not an error).
62+func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
63+ if issue.IsOpen() {
64+ b.Put(issueOpenKey(issue.Repo, issue.Number), []byte{1})
65+ return
66+ }
67+ b.Delete(issueOpenKey(issue.Repo, issue.Number))
68+}
69+
70+// issueKeys lists every key of the snapshot that belongs to the repository's
71+// issues.
72+func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
73+ var keys [][]byte
74+ for _, p := range []string{
75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
76+ } {
77+ for k := range snap.Prefix(ctx, []byte(p)) {
78+ keys = append(keys, slices.Clone(k))
79+ }
80+ }
81+ return keys
82+}
83+
84+// Create takes the next number of the repository from mrseq/<repo>, the
85+// sequence of the merge requests, and stores the issue under it; see
86+// createNumbered. A missing repository gives domain.ErrNotFound.
87+//
88+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
89+func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
90+ err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {
91+ issue.Number = n
92+ val, err := encodeIssue(issue)
93+ if err != nil {
94+ return err
95+ }
96+ b.RequireAbsent(issueKey(issue.Repo, n))
97+ b.Put(issueKey(issue.Repo, n), val)
98+ indexOpenIssue(b, issue)
99+ return nil
100+ })
101+ if err != nil {
102+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", issue.Repo, err)
103+ }
104+ return i.Get(ctx, issue.Repo, issue.Number)
105+}
106+
107+// Get reads is/<repo>/<number>.
108+func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
109+ if number < 1 {
110+ return domain.Issue{}, domain.ErrNotFound
111+ }
112+ item, ok := i.db.Get(ctx, issueKey(repo, number))
113+ if !ok {
114+ return domain.Issue{}, domain.ErrNotFound
115+ }
116+ return decodeIssue(repo, item)
117+}
118+
119+// List returns a page of the issues of the repository, highest number first. The
120+// page token is the last number of the previous page.
121+//
122+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
123+func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
124+ before := int64(0) // 0: from the highest
125+ if params.PageToken != "" {
126+ n, err := strconv.ParseInt(params.PageToken, 10, 64)
127+ if err != nil || n < 1 {
128+ return ports.IssuePage{}, fmt.Errorf("%w: page token", domain.ErrInvalid)
129+ }
130+ before = n
131+ }
132+ // The keys sort by number: walk [first, cursor) ascending, keep the last
133+ // PageSize+1 matching records undecoded (the extra one tells whether
134+ // another page follows), then decode only those.
135+ start := []byte(issueRepoPrefix(repo))
136+ end := append(slices.Clone(start[:len(start)-1]), '/'+1) // the prefix ends in '/'
137+ if before > 0 {
138+ end = issueKey(repo, before)
139+ }
140+ keep := params.PageSize + 1
141+ var window []kv.Item
142+ for _, item := range i.db.Snapshot(ctx).Range(ctx, start, end) {
143+ if params.State != "" {
144+ var pb storagepb.Issue
145+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
146+ return ports.IssuePage{}, fmt.Errorf("decode issue: %w", err)
147+ }
148+ st, err := decodeIssueState(pb.GetState())
149+ if err != nil {
150+ return ports.IssuePage{}, fmt.Errorf("decode issue of %q: %w", repo, err)
151+ }
152+ if st != params.State {
153+ continue
154+ }
155+ }
156+ window = append(window, item)
157+ if params.PageSize > 0 && len(window) >= 2*keep {
158+ window = append(window[:0], window[len(window)-keep:]...)
159+ }
160+ }
161+ if params.PageSize > 0 && len(window) > keep {
162+ window = window[len(window)-keep:]
163+ }
164+ all := make([]domain.Issue, 0, len(window))
165+ for _, item := range slices.Backward(window) {
166+ issue, err := decodeIssue(repo, item)
167+ if err != nil {
168+ return ports.IssuePage{}, err
169+ }
170+ all = append(all, issue)
171+ }
172+ var page ports.IssuePage
173+ if params.PageSize > 0 && len(all) > params.PageSize {
174+ all = all[:params.PageSize]
175+ page.NextPageToken = strconv.FormatInt(all[len(all)-1].Number, 10)
176+ }
177+ page.Issues = all
178+ return page, nil
179+}
180+
181+// ListOpenAll returns the open issues of every repository through the isopen/
182+// index: the issues it names are read, no other.
183+//
184+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open issues of all repositories through their index: a storage concern, no policy
185+func (i *Issues) ListOpenAll(ctx context.Context) ([]domain.Issue, error) {
186+ type ref struct {
187+ repo domain.RepoName
188+ n int64
189+ }
190+ var refs []ref
191+ for key := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueOpenPrefix)) {
192+ if err := ctx.Err(); err != nil {
193+ return nil, err
194+ }
195+ repoName, num, ok := strings.Cut(strings.TrimPrefix(string(key), issueOpenPrefix), "/")
196+ if !ok {
197+ return nil, fmt.Errorf("index key %q", key)
198+ }
199+ repo, err := domain.ParseRepoName(repoName)
200+ if err != nil {
201+ return nil, fmt.Errorf("index key %q: %w", key, err)
202+ }
203+ n, err := strconv.ParseInt(num, 10, 64)
204+ if err != nil {
205+ return nil, fmt.Errorf("index key %q: %w", key, err)
206+ }
207+ refs = append(refs, ref{repo, n})
208+ }
209+ var out []domain.Issue
210+ for _, r := range refs {
211+ issue, err := i.Get(ctx, r.repo, r.n)
212+ if errors.Is(err, domain.ErrNotFound) {
213+ continue // closed or deleted since the snapshot of the index
214+ }
215+ if err != nil {
216+ return nil, err
217+ }
218+ if issue.IsOpen() {
219+ out = append(out, issue)
220+ }
221+ }
222+ return out, nil
223+}
224+
225+// Update replaces the issue with a version precondition and moves its index key
226+// in the same batch. The repository must exist (see guardRepoKey), so that a
227+// concurrent delete of the repository leaves no index key behind.
228+//
229+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
230+func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
231+ val, err := encodeIssue(issue)
232+ if err != nil {
233+ return err
234+ }
235+ key := issueKey(issue.Repo, issue.Number)
236+ if _, ok := i.db.Get(ctx, key); !ok {
237+ return domain.ErrNotFound
238+ }
239+ if issue.Version == 0 {
240+ return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
241+ }
242+ var b kv.Batch
243+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
244+ return err
245+ }
246+ b.RequireVersion(key, issue.Version)
247+ b.Put(key, val)
248+ indexOpenIssue(&b, issue)
249+ if _, err := i.db.Apply(ctx, &b); err != nil {
250+ if errors.Is(err, kv.ErrConflict) {
251+ return domain.ErrConflict
252+ }
253+ return fmt.Errorf("update issue %d of %q: %w", issue.Number, issue.Repo, err)
254+ }
255+ return nil
256+}
257+
258+// ListComments returns the comments of the issue, oldest first.
259+func (i *Issues) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
260+ var out []domain.IssueComment
261+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueCommentsPrefix(repo, number))) {
262+ c, err := decodeIssueComment(item)
263+ if err != nil {
264+ return nil, fmt.Errorf("decode %s: %w", key, err)
265+ }
266+ out = append(out, c)
267+ }
268+ return out, nil
269+}
270+
271+// SaveComment writes the comment and the issue in one batch, each with its
272+// version precondition (absent for a new comment), and requires the repository
273+// to exist (see guardRepoKey): domain.ErrNotFound otherwise.
274+func (i *Issues) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
275+ issueVal, err := encodeIssue(issue)
276+ if err != nil {
277+ return err
278+ }
279+ cVal, err := encodeIssueComment(c)
280+ if err != nil {
281+ return err
282+ }
283+ if issue.Version == 0 {
284+ return fmt.Errorf("save comment on %d of %q: read the issue first: %w", issue.Number, issue.Repo, domain.ErrConflict)
285+ }
286+ var b kv.Batch
287+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
288+ return err
289+ }
290+ b.RequireVersion(issueKey(issue.Repo, issue.Number), issue.Version)
291+ b.Put(issueKey(issue.Repo, issue.Number), issueVal)
292+ indexOpenIssue(&b, issue)
293+ ckey := issueCommentKey(issue.Repo, issue.Number, c.ID)
294+ if c.Version == 0 {
295+ b.RequireAbsent(ckey)
296+ } else {
297+ b.RequireVersion(ckey, c.Version)
298+ }
299+ b.Put(ckey, cVal)
300+ if _, err := i.db.Apply(ctx, &b); err != nil {
301+ if errors.Is(err, kv.ErrConflict) {
302+ return domain.ErrConflict
303+ }
304+ return fmt.Errorf("save comment on %d of %q: %w", issue.Number, issue.Repo, err)
305+ }
306+ return nil
307+}
308+
309+func encodeIssue(issue domain.Issue) ([]byte, error) {
310+ if issue.Repo.IsZero() {
311+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
312+ }
313+ if !utf8.ValidString(issue.Author) {
314+ return nil, errors.New("issue author is not valid UTF-8")
315+ }
316+ state, err := encodeIssueState(issue.State)
317+ if err != nil {
318+ return nil, err
319+ }
320+ return mustMarshal(&storagepb.Issue{
321+ Number: issue.Number,
322+ Title: issue.Title.String(),
323+ Body: issue.Body.String(),
324+ State: state,
325+ Author: issue.Author,
326+ CreatedAt: encodeTime(issue.CreatedAt),
327+ UpdatedAt: encodeTime(issue.UpdatedAt),
328+ CommentSeq: issue.CommentSeq,
329+ External: issue.External,
330+ }), nil
331+}
332+
333+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334+func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
335+ var pb storagepb.Issue
336+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
337+ return domain.Issue{}, fmt.Errorf("decode issue: %w", err)
338+ }
339+ fail := func(err error) (domain.Issue, error) {
340+ return domain.Issue{}, fmt.Errorf("decode issue %d of %q: %w", pb.GetNumber(), repo, err)
341+ }
342+ title, err := domain.ParseIssueTitle(pb.GetTitle())
343+ if err != nil {
344+ return fail(err)
345+ }
346+ body, err := domain.ParseIssueBody(pb.GetBody())
347+ if err != nil {
348+ return fail(err)
349+ }
350+ state, err := decodeIssueState(pb.GetState())
351+ if err != nil {
352+ return fail(err)
353+ }
354+ return domain.Issue{
355+ Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
356+ Author: pb.GetAuthor(),
357+ CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
358+ CommentSeq: pb.GetCommentSeq(),
359+ External: pb.GetExternal(),
360+ Version: item.Version,
361+ }, nil
362+}
363+
364+func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
365+ if !utf8.ValidString(c.Author) {
366+ return nil, errors.New("issue comment author is not valid UTF-8")
367+ }
368+ return mustMarshal(&storagepb.IssueComment{
369+ Id: c.ID,
370+ Author: c.Author,
371+ Text: c.Text.String(),
372+ CreatedAt: encodeTime(c.CreatedAt),
373+ }), nil
374+}
375+
376+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
377+func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
378+ var pb storagepb.IssueComment
379+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
380+ return domain.IssueComment{}, fmt.Errorf("decode issue comment: %w", err)
381+ }
382+ text, err := domain.ParseIssueBody(pb.GetText())
383+ if err != nil {
384+ return domain.IssueComment{}, fmt.Errorf("decode issue comment %d: %w", pb.GetId(), err)
385+ }
386+ return domain.IssueComment{
387+ ID: pb.GetId(), Author: pb.GetAuthor(), Text: text,
388+ CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
389+ }, nil
390+}
391+
392+func encodeIssueState(s domain.IssueState) (storagepb.IssueState, error) {
393+ switch s {
394+ case domain.IssueStateOpen:
395+ return storagepb.IssueState_ISSUE_STATE_OPEN, nil
396+ case domain.IssueStateClosed:
397+ return storagepb.IssueState_ISSUE_STATE_CLOSED, nil
398+ }
399+ return 0, fmt.Errorf("%w: issue state %q", domain.ErrInvalid, s)
400+}
401+
402+func decodeIssueState(s storagepb.IssueState) (domain.IssueState, error) {
403+ switch s {
404+ case storagepb.IssueState_ISSUE_STATE_OPEN:
405+ return domain.IssueStateOpen, nil
406+ case storagepb.IssueState_ISSUE_STATE_CLOSED:
407+ return domain.IssueStateClosed, nil
408+ case storagepb.IssueState_ISSUE_STATE_PENDING:
409+ return "", errors.New("stored issue is pending: a numbered issue is open or closed")
410+ }
411+ return "", fmt.Errorf("unknown issue state %d", s)
412+}
413+
414+func encodeIssuesMode(m domain.IssuesMode) (storagepb.IssuesMode, error) {
415+ switch m {
416+ case "":
417+ return storagepb.IssuesMode_ISSUES_MODE_UNSPECIFIED, nil
418+ case domain.IssuesOff:
419+ return storagepb.IssuesMode_ISSUES_MODE_OFF, nil
420+ case domain.IssuesOwner:
421+ return storagepb.IssuesMode_ISSUES_MODE_OWNER, nil
422+ case domain.IssuesPublic:
423+ return storagepb.IssuesMode_ISSUES_MODE_PUBLIC, nil
424+ }
425+ return 0, fmt.Errorf("%w: issues mode %q in the settings", domain.ErrInvalid, m)
426+}
427+
428+// decodeIssuesMode reads an unspecified or unknown mode as off: the safe side.
429+func decodeIssuesMode(m storagepb.IssuesMode) domain.IssuesMode {
430+ switch m {
431+ case storagepb.IssuesMode_ISSUES_MODE_OWNER:
432+ return domain.IssuesOwner
433+ case storagepb.IssuesMode_ISSUES_MODE_PUBLIC:
434+ return domain.IssuesPublic
435+ }
436+ return domain.IssuesOff
437+}
A internal/adapters/kvstore/issues_pending.go +408 -0
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "time"
15+ "unicode/utf8"
16+
17+ kv "bnema.dev/ermite-kv"
18+ "github.com/bnema/zerowrap"
19+ "google.golang.org/protobuf/proto"
20+
21+ storagepb "bnema.dev/ermite/gen/ermite/storage"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+)
25+
26+// Key layout of the public issue form.
27+const (
28+ pendingPrefix = "ispend/" // ispend/<repo>/<id>
29+ banPrefix = "isban/" // isban/<hex of the hash>
30+ issueKeyKey = "meta/issuekey"
31+ issueKeyLen = 32
32+)
33+
34+// PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the
35+// database of the Store it comes from.
36+type PendingIssues struct {
37+ db *kv.DB
38+ locks *numberLocks
39+}
40+
41+var _ ports.PendingIssueStore = (*PendingIssues)(nil)
42+
43+// PendingIssues returns the pending issue store over the same database. It is
44+// valid until the Store is closed.
45+func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db, locks: s.locks} }
46+
47+func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }
48+func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {
49+ return []byte(pendingRepoPrefix(repo) + id.String())
50+}
51+
52+// pendingKeys lists every key of the snapshot that holds a pending issue of
53+// the repository.
54+func pendingKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
55+ var keys [][]byte
56+ for k := range snap.Prefix(ctx, []byte(pendingRepoPrefix(repo))) {
57+ keys = append(keys, slices.Clone(k))
58+ }
59+ return keys
60+}
61+
62+// Put stores the pending issue under a new key. The repository must exist (see
63+// guardRepoKey): domain.ErrNotFound otherwise.
64+func (s *PendingIssues) Put(ctx context.Context, p domain.PendingIssue) error {
65+ val, err := encodePending(p)
66+ if err != nil {
67+ return err
68+ }
69+ var b kv.Batch
70+ if err := guardRepoKey(ctx, s.db, &b, p.Repo); err != nil {
71+ return err
72+ }
73+ b.RequireAbsent(pendingKey(p.Repo, p.ID))
74+ b.Put(pendingKey(p.Repo, p.ID), val)
75+ if _, err := s.db.Apply(ctx, &b); err != nil {
76+ if errors.Is(err, kv.ErrConflict) {
77+ return domain.ErrConflict
78+ }
79+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
80+ }
81+ return nil
82+}
83+
84+// Get reads ispend/<repo>/<id>.
85+func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
86+ item, ok := s.db.Get(ctx, pendingKey(repo, id))
87+ if !ok {
88+ return domain.PendingIssue{}, domain.ErrNotFound
89+ }
90+ return decodePending(repo, id, item)
91+}
92+
93+// List reads the pending issues of the repository, or of all, sorted by
94+// creation time then repository then id. There are at most
95+// domain.MaxPendingIssues of them (a little more under a race), so they are
96+// read and sorted. The page token is the position of the last item returned.
97+//
98+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
99+func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
100+ prefix := pendingPrefix
101+ if !repo.IsZero() {
102+ prefix = pendingRepoPrefix(repo)
103+ }
104+ var after pendingCursor
105+ if page.PageToken != "" {
106+ var err error
107+ if after, err = parsePendingCursor(page.PageToken); err != nil {
108+ return ports.PendingIssuePage{}, err
109+ }
110+ }
111+ var all []domain.PendingIssue
112+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
113+ if err := ctx.Err(); err != nil {
114+ return ports.PendingIssuePage{}, err
115+ }
116+ r, id, err := splitPendingKey(string(key))
117+ if err != nil {
118+ return ports.PendingIssuePage{}, err
119+ }
120+ p, err := decodePending(r, id, item)
121+ if err != nil {
122+ return ports.PendingIssuePage{}, fmt.Errorf("decode %s: %w", key, err)
123+ }
124+ all = append(all, p)
125+ }
126+ slices.SortFunc(all, comparePending)
127+ if page.PageToken != "" {
128+ all = slices.DeleteFunc(all, func(p domain.PendingIssue) bool { return comparePending(p, after.item()) <= 0 })
129+ }
130+ var out ports.PendingIssuePage
131+ if page.PageSize > 0 && len(all) > page.PageSize {
132+ all = all[:page.PageSize]
133+ out.NextPageToken = encodePendingCursor(all[len(all)-1])
134+ }
135+ out.Issues = all
136+ return out, nil
137+}
138+
139+func comparePending(a, b domain.PendingIssue) int {
140+ if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
141+ return c
142+ }
143+ if c := strings.Compare(a.Repo.String(), b.Repo.String()); c != 0 {
144+ return c
145+ }
146+ return strings.Compare(a.ID.String(), b.ID.String())
147+}
148+
149+type pendingCursor struct {
150+ nanos int64
151+ repo domain.RepoName
152+ id domain.PendingIssueID
153+}
154+
155+func (c pendingCursor) item() domain.PendingIssue {
156+ return domain.PendingIssue{Repo: c.repo, ID: c.id, CreatedAt: time.Unix(0, c.nanos)}
157+}
158+
159+func encodePendingCursor(p domain.PendingIssue) string {
160+ return strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "/" + p.Repo.String() + "/" + p.ID.String()
161+}
162+
163+func parsePendingCursor(s string) (pendingCursor, error) {
164+ bad := fmt.Errorf("%w: page token", domain.ErrInvalid)
165+ parts := strings.Split(s, "/")
166+ if len(parts) != 3 {
167+ return pendingCursor{}, bad
168+ }
169+ n, err := strconv.ParseInt(parts[0], 10, 64)
170+ if err != nil {
171+ return pendingCursor{}, bad
172+ }
173+ repo, err := domain.ParseRepoName(parts[1])
174+ if err != nil {
175+ return pendingCursor{}, bad
176+ }
177+ id, err := domain.ParsePendingIssueID(parts[2])
178+ if err != nil {
179+ return pendingCursor{}, bad
180+ }
181+ return pendingCursor{nanos: n, repo: repo, id: id}, nil
182+}
183+
184+func splitPendingKey(key string) (domain.RepoName, domain.PendingIssueID, error) {
185+ rest := strings.TrimPrefix(key, pendingPrefix)
186+ repoName, idStr, ok := strings.Cut(rest, "/")
187+ if !ok {
188+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q", key)
189+ }
190+ repo, err := domain.ParseRepoName(repoName)
191+ if err != nil {
192+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
193+ }
194+ id, err := domain.ParsePendingIssueID(idStr)
195+ if err != nil {
196+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
197+ }
198+ return repo, id, nil
199+}
200+
201+// Count counts the keys under ispend/, stopping after MaxPendingIssues+1.
202+func (s *PendingIssues) Count(ctx context.Context) (int, error) {
203+ n := 0
204+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(pendingPrefix)) {
205+ if err := ctx.Err(); err != nil {
206+ return 0, err
207+ }
208+ n++
209+ if n > domain.MaxPendingIssues {
210+ break
211+ }
212+ }
213+ return n, nil
214+}
215+
216+// Approve numbers the pending issue from mrseq/<repo> (see createNumbered),
217+// writes the open issue and its index key and deletes the pending record, in
218+// one batch that requires the pending record to be unchanged.
219+//
220+//hexcheck:ignore suspicious-business-logic-in-adapter only sets the number under a version precondition; the issue itself is built by domain.ApprovedIssue
221+func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
222+ var number int64
223+ err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error {
224+ key := pendingKey(repo, id)
225+ item, ok := s.db.Get(ctx, key)
226+ if !ok {
227+ return domain.ErrNotFound
228+ }
229+ p, err := decodePending(repo, id, item)
230+ if err != nil {
231+ return err
232+ }
233+ issue := domain.ApprovedIssue(p, now)
234+ issue.Repo, issue.Number = repo, n
235+ val, err := encodeIssue(issue)
236+ if err != nil {
237+ return err
238+ }
239+ b.RequireVersion(key, item.Version)
240+ b.Delete(key)
241+ b.RequireAbsent(issueKey(repo, n))
242+ b.Put(issueKey(repo, n), val)
243+ indexOpenIssue(b, issue)
244+ number = n
245+ return nil
246+ })
247+ if err != nil {
248+ if errors.Is(err, domain.ErrNotFound) {
249+ return domain.Issue{}, domain.ErrNotFound
250+ }
251+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
252+ }
253+ return (&Issues{db: s.db, locks: s.locks}).Get(ctx, repo, number)
254+}
255+
256+// Delete removes the pending record, or returns domain.ErrNotFound.
257+func (s *PendingIssues) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
258+ key := pendingKey(repo, id)
259+ item, ok := s.db.Get(ctx, key)
260+ if !ok {
261+ return domain.ErrNotFound
262+ }
263+ var b kv.Batch
264+ b.RequireVersion(key, item.Version)
265+ b.Delete(key)
266+ if _, err := s.db.Apply(ctx, &b); err != nil {
267+ if errors.Is(err, kv.ErrConflict) {
268+ return domain.ErrNotFound // gone or approved meanwhile
269+ }
270+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
271+ }
272+ return nil
273+}
274+
275+func encodePending(p domain.PendingIssue) ([]byte, error) {
276+ if p.Repo.IsZero() {
277+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
278+ }
279+ if _, err := domain.ParsePendingIssueID(p.ID.String()); err != nil {
280+ return nil, err
281+ }
282+ if !utf8.ValidString(p.Author) {
283+ return nil, errors.New("pending issue author is not valid UTF-8")
284+ }
285+ if len(p.IPHash) != domain.IPHashLen {
286+ return nil, fmt.Errorf("%w: ip hash is %d bytes, want %d", domain.ErrInvalid, len(p.IPHash), domain.IPHashLen)
287+ }
288+ return mustMarshal(&storagepb.PendingIssue{
289+ Title: p.Title.String(), Body: p.Body.String(), Author: p.Author,
290+ CreatedAt: encodeTime(p.CreatedAt), IpHash: p.IPHash,
291+ }), nil
292+}
293+
294+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
295+func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {
296+ var pb storagepb.PendingIssue
297+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
298+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue: %w", err)
299+ }
300+ title, err := domain.ParseIssueTitle(pb.GetTitle())
301+ if err != nil {
302+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
303+ }
304+ body, err := domain.ParseIssueBody(pb.GetBody())
305+ if err != nil {
306+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
307+ }
308+ return domain.PendingIssue{
309+ ID: id, Repo: repo, Title: title, Body: body, Author: pb.GetAuthor(),
310+ CreatedAt: decodeTime(pb.GetCreatedAt()), IPHash: slices.Clone(pb.GetIpHash()), Version: item.Version,
311+ }, nil
312+}
313+
314+// IssueKeys is the ermite-kv backed ports.IssueKeyStore.
315+type IssueKeys struct {
316+ db *kv.DB
317+}
318+
319+var _ ports.IssueKeyStore = (*IssueKeys)(nil)
320+
321+// IssueKeys returns the issue key store over the same database. It is valid
322+// until the Store is closed.
323+func (s *Store) IssueKeys() *IssueKeys { return &IssueKeys{db: s.db} }
324+
325+// Key reads meta/issuekey and generates it when absent. The key is never
326+// logged.
327+func (k *IssueKeys) Key(ctx context.Context) ([]byte, error) {
328+ for range updateAttempts {
329+ if item, ok := k.db.Get(ctx, []byte(issueKeyKey)); ok {
330+ var m storagepb.IssueKey
331+ if err := proto.Unmarshal(item.Value, &m); err != nil {
332+ return nil, fmt.Errorf("decode issue key: %w", err)
333+ }
334+ if len(m.GetKey()) != issueKeyLen {
335+ return nil, fmt.Errorf("decode issue key: %d bytes, want %d", len(m.GetKey()), issueKeyLen)
336+ }
337+ return append([]byte(nil), m.GetKey()...), nil
338+ }
339+ key := make([]byte, issueKeyLen)
340+ if _, err := rand.Read(key); err != nil {
341+ return nil, fmt.Errorf("generate issue key: %w", err)
342+ }
343+ var b kv.Batch
344+ b.RequireAbsent([]byte(issueKeyKey))
345+ b.Put([]byte(issueKeyKey), mustMarshal(&storagepb.IssueKey{Key: key}))
346+ _, err := k.db.Apply(ctx, &b)
347+ if errors.Is(err, kv.ErrConflict) {
348+ continue // written meanwhile: read it
349+ }
350+ if err != nil {
351+ return nil, fmt.Errorf("store issue key: %w", err)
352+ }
353+ log := zerowrap.FromCtx(ctx)
354+ log.Info().Msg("issue key generated")
355+ return key, nil
356+ }
357+ return nil, fmt.Errorf("store issue key: %w", domain.ErrConflict)
358+}
359+
360+// IssueBans is the ermite-kv backed ports.IssueBanStore.
361+type IssueBans struct {
362+ db *kv.DB
363+}
364+
365+var _ ports.IssueBanStore = (*IssueBans)(nil)
366+
367+// IssueBans returns the ban store over the same database. It is valid until
368+// the Store is closed.
369+func (s *Store) IssueBans() *IssueBans { return &IssueBans{db: s.db} }
370+
371+func banKey(hash []byte) []byte { return []byte(banPrefix + hex.EncodeToString(hash)) }
372+
373+// Ban writes isban/<hex hash> with its expiry.
374+func (s *IssueBans) Ban(ctx context.Context, hash []byte, until time.Time) error {
375+ if len(hash) != domain.IPHashLen {
376+ return fmt.Errorf("%w: ban hash is %d bytes, want %d", domain.ErrInvalid, len(hash), domain.IPHashLen)
377+ }
378+ var b kv.Batch
379+ b.Put(banKey(hash), mustMarshal(&storagepb.IssueBan{Until: encodeTime(until)}))
380+ if _, err := s.db.Apply(ctx, &b); err != nil {
381+ return fmt.Errorf("store ban: %w", err)
382+ }
383+ return nil
384+}
385+
386+// Banned reads the ban; one that expired is not a ban and is deleted (if it
387+// has not been replaced meanwhile).
388+func (s *IssueBans) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) {
389+ key := banKey(hash)
390+ item, ok := s.db.Get(ctx, key)
391+ if !ok {
392+ return false, nil
393+ }
394+ var pb storagepb.IssueBan
395+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
396+ return false, fmt.Errorf("decode ban: %w", err)
397+ }
398+ if now.Before(decodeTime(pb.GetUntil())) {
399+ return true, nil
400+ }
401+ var b kv.Batch
402+ b.RequireVersion(key, item.Version)
403+ b.Delete(key)
404+ if _, err := s.db.Apply(ctx, &b); err != nil && !errors.Is(err, kv.ErrConflict) {
405+ return false, fmt.Errorf("delete expired ban: %w", err)
406+ }
407+ return false, nil
408+}
A internal/adapters/kvstore/issues_pending_test.go +271 -0
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "sync"
9+ "testing"
10+ "time"
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+func newPending(t *testing.T, repo domain.RepoName, title, author string, at time.Time) domain.PendingIssue {
20+ t.Helper()
21+ id, err := domain.NewPendingIssueID()
22+ require.NoError(t, err)
23+ ti, err := domain.ParseIssueTitle(title)
24+ require.NoError(t, err)
25+ body, err := domain.ParseIssueBody("details")
26+ require.NoError(t, err)
27+ return domain.PendingIssue{
28+ ID: id, Repo: repo, Title: ti, Body: body, Author: author, CreatedAt: at,
29+ IPHash: bytes.Repeat([]byte{7}, domain.IPHashLen),
30+ }
31+}
32+
33+func TestPendingIssuePutGetListCount(t *testing.T) {
34+ ctx := context.Background()
35+ s, _, n := repoWithMRs(t)
36+ other := name(t, "other")
37+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
38+ ps := s.PendingIssues()
39+
40+ a := newPending(t, n, "a", "Alex", created.Add(time.Minute))
41+ b := newPending(t, other, "b", "", created.Add(2*time.Minute))
42+ c := newPending(t, n, "c", "", created.Add(3*time.Minute))
43+ for _, p := range []domain.PendingIssue{c, a, b} {
44+ require.NoError(t, ps.Put(ctx, p))
45+ }
46+ require.ErrorIs(t, ps.Put(ctx, a), domain.ErrConflict, "an id is used once")
47+ require.ErrorIs(t, ps.Put(ctx, newPending(t, name(t, "ghost"), "g", "", created)), domain.ErrNotFound)
48+
49+ got, err := ps.Get(ctx, n, a.ID)
50+ require.NoError(t, err)
51+ assert.Equal(t, a.Title, got.Title)
52+ assert.Equal(t, "Alex", got.Author)
53+ assert.Equal(t, a.IPHash, got.IPHash)
54+ assert.True(t, got.CreatedAt.Equal(a.CreatedAt))
55+ assert.NotZero(t, got.Version)
56+ _, err = ps.Get(ctx, other, a.ID)
57+ assert.ErrorIs(t, err, domain.ErrNotFound, "ids are per repository")
58+
59+ all, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{})
60+ require.NoError(t, err)
61+ require.Len(t, all.Issues, 3)
62+ assert.Equal(t, []domain.PendingIssueID{a.ID, b.ID, c.ID}, []domain.PendingIssueID{all.Issues[0].ID, all.Issues[1].ID, all.Issues[2].ID}, "oldest first")
63+
64+ mine, err := ps.List(ctx, n, ports.ListPendingParams{})
65+ require.NoError(t, err)
66+ assert.Len(t, mine.Issues, 2)
67+
68+ p1, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2})
69+ require.NoError(t, err)
70+ require.Len(t, p1.Issues, 2)
71+ require.NotEmpty(t, p1.NextPageToken)
72+ p2, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2, PageToken: p1.NextPageToken})
73+ require.NoError(t, err)
74+ require.Len(t, p2.Issues, 1)
75+ assert.Equal(t, c.ID, p2.Issues[0].ID)
76+ assert.Empty(t, p2.NextPageToken)
77+ _, err = ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageToken: "x"})
78+ assert.ErrorIs(t, err, domain.ErrInvalid)
79+
80+ cnt, err := ps.Count(ctx)
81+ require.NoError(t, err)
82+ assert.Equal(t, 3, cnt)
83+
84+ require.NoError(t, ps.Delete(ctx, n, a.ID))
85+ assert.ErrorIs(t, ps.Delete(ctx, n, a.ID), domain.ErrNotFound)
86+ cnt, err = ps.Count(ctx)
87+ require.NoError(t, err)
88+ assert.Equal(t, 2, cnt)
89+}
90+
91+func TestPendingIssueCountIsCapped(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ ps := s.PendingIssues()
95+ for i := range domain.MaxPendingIssues + 5 {
96+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))))
97+ }
98+ cnt, err := ps.Count(ctx)
99+ require.NoError(t, err)
100+ assert.Equal(t, domain.MaxPendingIssues+1, cnt)
101+}
102+
103+func TestPendingIssueApproveSharesNumbers(t *testing.T) {
104+ ctx := context.Background()
105+ s, mrs, n := repoWithMRs(t)
106+ ps := s.PendingIssues()
107+
108+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
109+ require.NoError(t, err)
110+ assert.Equal(t, int64(1), mr.Number)
111+
112+ named := newPending(t, n, "named", "Alex", created)
113+ anon := newPending(t, n, "anon", "", created.Add(time.Second))
114+ require.NoError(t, ps.Put(ctx, named))
115+ require.NoError(t, ps.Put(ctx, anon))
116+
117+ issue, err := ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
118+ require.NoError(t, err)
119+ assert.Equal(t, int64(2), issue.Number)
120+ assert.Equal(t, domain.IssueStateOpen, issue.State)
121+ assert.Equal(t, "Alex", issue.Author)
122+ assert.Equal(t, "named", issue.Title.String())
123+ assert.True(t, issue.External, "an approved submission is external")
124+
125+ _, err = ps.Get(ctx, n, named.ID)
126+ assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
127+ _, err = ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
128+ assert.ErrorIs(t, err, domain.ErrNotFound, "approved once")
129+
130+ got, err := s.Issues().Get(ctx, n, 2)
131+ require.NoError(t, err)
132+ assert.Equal(t, issue.Title, got.Title)
133+ assert.True(t, got.External, "the flag is stored")
134+ open := openOf(t, s.Issues(), n)
135+ require.Len(t, open, 1)
136+ assert.Equal(t, int64(2), open[0].Number)
137+
138+ second, err := ps.Approve(ctx, n, anon.ID, created.Add(time.Hour))
139+ require.NoError(t, err)
140+ assert.Equal(t, int64(3), second.Number)
141+ assert.Equal(t, "anonymous", second.Author)
142+
143+ next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
144+ require.NoError(t, err)
145+ assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
146+ assert.False(t, next.External, "an issue of the owner is not external")
147+}
148+
149+func TestPendingIssueApproveConcurrent(t *testing.T) {
150+ ctx := context.Background()
151+ s, mrs, n := repoWithMRs(t)
152+ ps := s.PendingIssues()
153+ const k = 8
154+ ids := make([]domain.PendingIssueID, k)
155+ for i := range k {
156+ p := newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))
157+ ids[i] = p.ID
158+ require.NoError(t, ps.Put(ctx, p))
159+ }
160+ var mu sync.Mutex
161+ seen := map[int64]bool{}
162+ var wg sync.WaitGroup
163+ for _, id := range ids {
164+ wg.Add(1)
165+ go func() {
166+ defer wg.Done()
167+ is, err := ps.Approve(ctx, n, id, created.Add(time.Hour))
168+ if assert.NoError(t, err) {
169+ mu.Lock()
170+ seen[is.Number] = true
171+ mu.Unlock()
172+ }
173+ }()
174+ }
175+ wg.Add(1)
176+ go func() {
177+ defer wg.Done()
178+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
179+ if assert.NoError(t, err) {
180+ mu.Lock()
181+ seen[mr.Number] = true
182+ mu.Unlock()
183+ }
184+ }()
185+ wg.Wait()
186+ assert.Len(t, seen, k+1, "no number is given twice")
187+}
188+
189+func TestPendingIssueApproveOnDeletedRepoFails(t *testing.T) {
190+ ctx := context.Background()
191+ s, _, n := repoWithMRs(t)
192+ ps := s.PendingIssues()
193+ p := newPending(t, n, "t", "", created)
194+ require.NoError(t, ps.Put(ctx, p))
195+ require.NoError(t, s.Delete(ctx, n))
196+
197+ _, err := ps.Approve(ctx, n, p.ID, created.Add(time.Hour))
198+ assert.ErrorIs(t, err, domain.ErrNotFound)
199+ assert.ErrorIs(t, ps.Put(ctx, newPending(t, n, "t", "", created)), domain.ErrNotFound)
200+ assert.Zero(t, countKeys(t, s, "is/"))
201+ assert.Zero(t, countKeys(t, s, "isopen/"))
202+}
203+
204+func TestRepoDeleteRemovesPendingIssues(t *testing.T) {
205+ ctx := context.Background()
206+ s, _, n := repoWithMRs(t)
207+ other := name(t, "other")
208+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
209+ ps := s.PendingIssues()
210+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created)))
211+ require.NoError(t, ps.Put(ctx, newPending(t, n, "u", "", created)))
212+ keep := newPending(t, other, "k", "", created)
213+ require.NoError(t, ps.Put(ctx, keep))
214+
215+ require.NoError(t, s.Delete(ctx, n))
216+ assert.Zero(t, countKeys(t, s, "ispend/"+n.String()+"/"))
217+ _, err := ps.Get(ctx, other, keep.ID)
218+ assert.NoError(t, err, "another repository keeps its pending issues")
219+ cnt, err := ps.Count(ctx)
220+ require.NoError(t, err)
221+ assert.Equal(t, 1, cnt)
222+}
223+
224+func TestIssueBansExpire(t *testing.T) {
225+ ctx := context.Background()
226+ s := openStore(t, t.TempDir())
227+ bans := s.IssueBans()
228+ h := bytes.Repeat([]byte{1}, domain.IPHashLen)
229+ other := bytes.Repeat([]byte{2}, domain.IPHashLen)
230+ now := created
231+
232+ banned, err := bans.Banned(ctx, h, now)
233+ require.NoError(t, err)
234+ assert.False(t, banned)
235+
236+ require.NoError(t, bans.Ban(ctx, h, now.Add(domain.BanDuration)))
237+ require.ErrorIs(t, bans.Ban(ctx, []byte("short"), now), domain.ErrInvalid)
238+ banned, err = bans.Banned(ctx, h, now.Add(time.Hour))
239+ require.NoError(t, err)
240+ assert.True(t, banned)
241+ banned, err = bans.Banned(ctx, other, now.Add(time.Hour))
242+ require.NoError(t, err)
243+ assert.False(t, banned, "another hash is not banned")
244+ assert.Equal(t, 1, countKeys(t, s, "isban/"))
245+
246+ banned, err = bans.Banned(ctx, h, now.Add(domain.BanDuration))
247+ require.NoError(t, err)
248+ assert.False(t, banned, "expired")
249+ assert.Zero(t, countKeys(t, s, "isban/"), "an expired ban is deleted")
250+}
251+
252+func TestIssueKeyPersists(t *testing.T) {
253+ ctx := context.Background()
254+ dir := t.TempDir()
255+ s := openStore(t, dir)
256+ key, err := s.IssueKeys().Key(ctx)
257+ require.NoError(t, err)
258+ assert.Len(t, key, 32)
259+ again, err := s.IssueKeys().Key(ctx)
260+ require.NoError(t, err)
261+ assert.Equal(t, key, again)
262+ sess, err := s.Sessions().Key(ctx)
263+ require.NoError(t, err)
264+ assert.NotEqual(t, sess, key, "not the session key")
265+ require.NoError(t, s.Close(ctx))
266+
267+ s = openStore(t, dir)
268+ reopened, err := s.IssueKeys().Key(ctx)
269+ require.NoError(t, err)
270+ assert.Equal(t, key, reopened, "a restart keeps the key")
271+}
A internal/adapters/kvstore/issues_test.go +532 -0
@@ -0,0 +1,532 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "sort"
8+ "strconv"
9+ "sync"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+ "google.golang.org/protobuf/proto"
15+
16+ storagepb "bnema.dev/ermite/gen/ermite/storage"
17+ "bnema.dev/ermite/internal/adapters/kvstore"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
23+ t.Helper()
24+ ti, err := domain.ParseIssueTitle(title)
25+ require.NoError(t, err)
26+ body, err := domain.ParseIssueBody("what\nhappened")
27+ require.NoError(t, err)
28+ return domain.Issue{
29+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
30+ Author: "agent", CreatedAt: created, UpdatedAt: created,
31+ }
32+}
33+
34+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
35+ t.Helper()
36+ body, err := domain.ParseIssueBody(text)
37+ require.NoError(t, err)
38+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
39+}
40+
41+// countKeys counts the keys of the store under prefix.
42+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
43+ t.Helper()
44+ return s.CountKeysForTest(context.Background(), prefix)
45+}
46+
47+func TestIssueRoundTripAndPaging(t *testing.T) {
48+ ctx := context.Background()
49+ s, _, n := repoWithMRs(t)
50+ is := s.Issues()
51+
52+ first, err := is.Create(ctx, newIssue(t, n, "one"))
53+ require.NoError(t, err)
54+ assert.Equal(t, int64(1), first.Number)
55+ assert.NotZero(t, first.Version)
56+ got, err := is.Get(ctx, n, 1)
57+ require.NoError(t, err)
58+ assert.Equal(t, first, got)
59+ _, err = is.Get(ctx, n, 2)
60+ assert.ErrorIs(t, err, domain.ErrNotFound)
61+
62+ for _, title := range []string{"two", "three", "four"} {
63+ _, err := is.Create(ctx, newIssue(t, n, title))
64+ require.NoError(t, err)
65+ }
66+ closed, err := is.Get(ctx, n, 2)
67+ require.NoError(t, err)
68+ closed.State = domain.IssueStateClosed
69+ require.NoError(t, is.Update(ctx, closed))
70+
71+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
72+ require.NoError(t, err)
73+ require.Len(t, page.Issues, 3)
74+ assert.Equal(t, int64(4), page.Issues[0].Number)
75+ require.NotEmpty(t, page.NextPageToken)
76+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
77+ require.NoError(t, err)
78+ require.Len(t, page.Issues, 1)
79+ assert.Equal(t, int64(1), page.Issues[0].Number)
80+ assert.Empty(t, page.NextPageToken)
81+
82+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
83+ require.NoError(t, err)
84+ require.Len(t, page.Issues, 1)
85+ assert.Equal(t, int64(2), page.Issues[0].Number)
86+
87+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
88+ assert.ErrorIs(t, err, domain.ErrInvalid)
89+
90+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
91+ assert.ErrorIs(t, err, domain.ErrNotFound)
92+}
93+
94+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
95+ ctx := context.Background()
96+ s, _, n := repoWithMRs(t)
97+ is := s.Issues()
98+ first, err := is.Create(ctx, newIssue(t, n, "t"))
99+ require.NoError(t, err)
100+ a, b := first, first
101+ a.State = domain.IssueStateClosed
102+ require.NoError(t, is.Update(ctx, a))
103+ b.State = domain.IssueStateClosed
104+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
105+}
106+
107+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
108+ ctx := context.Background()
109+ s, _, n := repoWithMRs(t)
110+ is := s.Issues()
111+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
112+ require.NoError(t, err)
113+
114+ issue.CommentSeq = 1
115+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
116+ // The issue moved on: the same version is refused.
117+ issue.CommentSeq = 2
118+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
119+ issue, err = is.Get(ctx, n, issue.Number)
120+ require.NoError(t, err)
121+ issue.CommentSeq = 2
122+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
123+
124+ comments, err := is.ListComments(ctx, n, issue.Number)
125+ require.NoError(t, err)
126+ require.Len(t, comments, 2)
127+ assert.Equal(t, "first", comments[0].Text.String())
128+ assert.Equal(t, int64(2), comments[1].ID)
129+
130+ require.NoError(t, s.Delete(ctx, n))
131+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
132+}
133+
134+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
135+ ctx := context.Background()
136+ s, mrs, n := repoWithMRs(t)
137+ is := s.Issues()
138+ const workers = 16
139+ var (
140+ wg sync.WaitGroup
141+ mu sync.Mutex
142+ numbers []int64
143+ )
144+ for w := range workers {
145+ wg.Go(func() {
146+ var number int64
147+ if w%2 == 0 {
148+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
149+ if !assert.NoError(t, err) {
150+ return
151+ }
152+ number = got.Number
153+ } else {
154+ got, err := is.Create(ctx, newIssue(t, n, "t"))
155+ if !assert.NoError(t, err) {
156+ return
157+ }
158+ number = got.Number
159+ }
160+ mu.Lock()
161+ numbers = append(numbers, number)
162+ mu.Unlock()
163+ })
164+ }
165+ wg.Wait()
166+ want := make([]int64, workers)
167+ for i := range want {
168+ want[i] = int64(i + 1)
169+ }
170+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
171+}
172+
173+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
174+ ctx := context.Background()
175+ s, _, n := repoWithMRs(t)
176+ sib := name(t, "proj-2")
177+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
178+ is := s.Issues()
179+
180+ for _, repo := range []domain.RepoName{n, sib} {
181+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
182+ require.NoError(t, err)
183+ issue.CommentSeq = 1
184+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
185+ }
186+ require.NoError(t, s.Delete(ctx, n))
187+
188+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
189+ require.NoError(t, err)
190+ assert.Empty(t, page.Issues)
191+ comments, err := is.ListComments(ctx, n, 1)
192+ require.NoError(t, err)
193+ assert.Empty(t, comments)
194+ assert.Empty(t, openOf(t, is, n))
195+ assert.Zero(t, countKeys(t, s, "is/proj/"))
196+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
197+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
198+
199+ open := openOf(t, is, sib)
200+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
201+ comments, err = is.ListComments(ctx, sib, 1)
202+ require.NoError(t, err)
203+ assert.Len(t, comments, 1)
204+
205+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
206+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
207+ require.NoError(t, err)
208+ assert.Equal(t, int64(1), again.Number)
209+}
210+
211+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
212+ ctx := context.Background()
213+ s, _, n := repoWithMRs(t)
214+ is := s.Issues()
215+ a, err := is.Create(ctx, newIssue(t, n, "a"))
216+ require.NoError(t, err)
217+ b, err := is.Create(ctx, newIssue(t, n, "b"))
218+ require.NoError(t, err)
219+
220+ numbers := func() []int64 {
221+ open := openOf(t, is, n)
222+ var out []int64
223+ for _, i := range open {
224+ out = append(out, i.Number)
225+ }
226+ return out
227+ }
228+ assert.Equal(t, []int64{2, 1}, numbers())
229+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
230+
231+ a.State = domain.IssueStateClosed
232+ require.NoError(t, is.Update(ctx, a))
233+ assert.Equal(t, []int64{2}, numbers())
234+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
235+
236+ a, err = is.Get(ctx, n, a.Number)
237+ require.NoError(t, err)
238+ a.State = domain.IssueStateOpen
239+ require.NoError(t, is.Update(ctx, a))
240+ assert.Equal(t, []int64{2, 1}, numbers())
241+
242+ // A comment keeps the index as the issue says.
243+ b.State = domain.IssueStateClosed
244+ b.CommentSeq = 1
245+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
246+ assert.Equal(t, []int64{1}, numbers())
247+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
248+}
249+
250+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
251+ ctx := context.Background()
252+ s := openStore(t, t.TempDir())
253+ n := name(t, "proj")
254+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
255+ got, err := s.Get(ctx, n)
256+ require.NoError(t, err)
257+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
258+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
259+ got.Settings.Issues = m
260+ require.NoError(t, s.Update(ctx, got))
261+ again, err := s.Get(ctx, n)
262+ require.NoError(t, err)
263+ assert.Equal(t, m, again.Settings.Issues)
264+ }
265+ got.Settings.Issues = "everyone"
266+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
267+}
268+
269+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope
270+// before issue:write existed keep their owner rights; no other token changes.
271+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
272+ ctx := context.Background()
273+ dir := t.TempDir()
274+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
275+
276+ all9 := []string{
277+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
278+ "ci:write", "secrets:write", "runner:register", "token:admin",
279+ }
280+ tokens := map[string]*storagepb.Token{
281+ "full": {Id: "full", Scopes: all9},
282+ "missing": {Id: "missing", Scopes: all9[:8]},
283+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
284+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
285+ "small": {Id: "small", Scopes: []string{"repo:read"}},
286+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
287+ }
288+ for id, tok := range tokens {
289+ tok.Hash = make([]byte, 32)
290+ tok.Hash[0] = byte(len(id))
291+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
292+ }
293+
294+ s, err := kvstore.Open(ctx, dir)
295+ require.NoError(t, err)
296+ t.Cleanup(func() { _ = s.Close(ctx) })
297+ get := func(id string) domain.Token {
298+ tok, err := s.Tokens().Get(ctx, id)
299+ require.NoError(t, err)
300+ return tok
301+ }
302+
303+ full := get("full")
304+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
305+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
306+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
307+
308+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
309+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
310+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
311+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
312+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
313+ assert.Len(t, get("missing").Scopes, 8)
314+ assert.Len(t, get("repos").Scopes, 9)
315+ assert.Len(t, get("prefix").Scopes, 9)
316+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
317+ assert.Equal(t, "full", full.ID)
318+
319+ require.NoError(t, s.Close(ctx))
320+ assert.Equal(t, uint32(4), readSchema(t, dir))
321+
322+ // A second open changes nothing.
323+ s2, err := kvstore.Open(ctx, dir)
324+ require.NoError(t, err)
325+ tok, err := s2.Tokens().Get(ctx, "full")
326+ require.NoError(t, err)
327+ assert.Equal(t, full.Scopes, tok.Scopes)
328+ require.NoError(t, s2.Close(ctx))
329+}
330+
331+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
332+ ctx := context.Background()
333+ dir := t.TempDir()
334+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
335+ _, err := kvstore.Open(ctx, dir)
336+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
337+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
338+}
339+
340+// A reopen racing the delete of the repository either lands before the delete
341+// removes it or fails; it never leaves an isopen/ key of a repository that is
342+// gone.
343+func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
344+ ctx := context.Background()
345+ for range 30 {
346+ s, _, n := repoWithMRs(t)
347+ is := s.Issues()
348+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
349+ require.NoError(t, err)
350+ issue.State = domain.IssueStateClosed
351+ require.NoError(t, is.Update(ctx, issue))
352+ closed, err := is.Get(ctx, n, issue.Number)
353+ require.NoError(t, err)
354+ closed.State = domain.IssueStateOpen
355+
356+ var wg sync.WaitGroup
357+ start := make(chan struct{})
358+ wg.Add(2)
359+ go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
360+ go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
361+ close(start)
362+ wg.Wait()
363+
364+ for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
365+ assert.Zero(t, countKeys(t, s, prefix), prefix)
366+ }
367+ assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
368+ }
369+}
370+
371+func TestListOpenAllSpansRepositoriesThroughTheIndex(t *testing.T) {
372+ ctx := context.Background()
373+ s, mrs, n := repoWithMRs(t)
374+ other := name(t, "other")
375+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
376+ is := s.Issues()
377+
378+ i1, err := is.Create(ctx, newIssue(t, n, "one"))
379+ require.NoError(t, err)
380+ i2, err := is.Create(ctx, newIssue(t, other, "two"))
381+ require.NoError(t, err)
382+ i3, err := is.Create(ctx, newIssue(t, other, "three"))
383+ require.NoError(t, err)
384+ i3.State = domain.IssueStateClosed
385+ require.NoError(t, is.Update(ctx, i3))
386+ _, err = mrs.Create(ctx, newMR(t, n, "mr1"))
387+ require.NoError(t, err)
388+ m2, err := mrs.Create(ctx, newMR(t, other, "mr2"))
389+ require.NoError(t, err)
390+ m3, err := mrs.Create(ctx, newMR(t, other, "mr3"))
391+ require.NoError(t, err)
392+ m3.State = domain.MRStateClosed
393+ require.NoError(t, mrs.Update(ctx, m3))
394+
395+ issues, err := is.ListOpenAll(ctx)
396+ require.NoError(t, err)
397+ var got []string
398+ for _, i := range issues {
399+ got = append(got, i.Repo.String()+"#"+strconv.FormatInt(i.Number, 10))
400+ }
401+ assert.ElementsMatch(t, []string{
402+ i1.Repo.String() + "#" + strconv.FormatInt(i1.Number, 10),
403+ i2.Repo.String() + "#" + strconv.FormatInt(i2.Number, 10),
404+ }, got)
405+
406+ open, err := mrs.ListOpenAll(ctx)
407+ require.NoError(t, err)
408+ got = nil
409+ for _, m := range open {
410+ got = append(got, m.Repo.String()+"#"+strconv.FormatInt(m.Number, 10))
411+ }
412+ assert.ElementsMatch(t, []string{"proj#2", m2.Repo.String() + "#" + strconv.FormatInt(m2.Number, 10)}, got)
413+ assert.Len(t, open, 2)
414+}
415+
416+func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) {
417+ ctx := context.Background()
418+ s, mrs, _ := repoWithMRs(t)
419+ issues, err := s.Issues().ListOpenAll(ctx)
420+ require.NoError(t, err)
421+ assert.Empty(t, issues)
422+ open, err := mrs.ListOpenAll(ctx)
423+ require.NoError(t, err)
424+ assert.Empty(t, open)
425+}
426+
427+func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) {
428+ ctx := context.Background()
429+ s, mrs, n := repoWithMRs(t)
430+ is := s.Issues()
431+ keepI, err := is.Create(ctx, newIssue(t, n, "keep"))
432+ require.NoError(t, err)
433+ goneI, err := is.Create(ctx, newIssue(t, n, "gone"))
434+ require.NoError(t, err)
435+ keepM, err := mrs.Create(ctx, newMR(t, n, "keep"))
436+ require.NoError(t, err)
437+ goneM, err := mrs.Create(ctx, newMR(t, n, "gone"))
438+ require.NoError(t, err)
439+
440+ require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number))
441+ require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number))
442+ require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there")
443+ require.Positive(t, countKeys(t, s, "mropen/proj/"))
444+
445+ issues, err := is.ListOpenAll(ctx)
446+ require.NoError(t, err)
447+ require.Len(t, issues, 1)
448+ assert.Equal(t, keepI.Number, issues[0].Number)
449+ open, err := mrs.ListOpenAll(ctx)
450+ require.NoError(t, err)
451+ require.Len(t, open, 1)
452+ assert.Equal(t, keepM.Number, open[0].Number)
453+}
454+
455+func TestIssueListPagesThroughManyIssues(t *testing.T) {
456+ ctx := context.Background()
457+ s, _, n := repoWithMRs(t)
458+ is := s.Issues()
459+ const total = 53
460+ for i := 1; i <= total; i++ {
461+ _, err := is.Create(ctx, newIssue(t, n, "issue "+strconv.Itoa(i)))
462+ require.NoError(t, err)
463+ }
464+ for i := 2; i <= total; i += 3 { // 2, 5, 8, ...
465+ is, err := is.Get(ctx, n, int64(i))
466+ require.NoError(t, err)
467+ is.State = domain.IssueStateClosed
468+ require.NoError(t, s.Issues().Update(ctx, is))
469+ }
470+
471+ walk := func(state domain.IssueState, size int) []int64 {
472+ var got []int64
473+ token := ""
474+ for {
475+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{State: state, PageSize: size, PageToken: token})
476+ require.NoError(t, err)
477+ require.LessOrEqual(t, len(page.Issues), size)
478+ for _, is := range page.Issues {
479+ got = append(got, is.Number)
480+ }
481+ if page.NextPageToken == "" {
482+ return got
483+ }
484+ token = page.NextPageToken
485+ }
486+ }
487+ for _, size := range []int{1, 7, 10, 53, 100} {
488+ var all, closed []int64
489+ for i := int64(total); i >= 1; i-- {
490+ all = append(all, i)
491+ if i%3 == 2 {
492+ closed = append(closed, i)
493+ }
494+ }
495+ assert.Equal(t, all, walk("", size), "size %d", size)
496+ assert.Equal(t, closed, walk(domain.IssueStateClosed, size), "closed, size %d", size)
497+ }
498+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{PageSize: 10, PageToken: "1"})
499+ require.NoError(t, err)
500+ assert.Empty(t, page.Issues)
501+ assert.Empty(t, page.NextPageToken)
502+}
503+
504+// openOf lists the open issues of one repository through the isopen/ index.
505+func openOf(t *testing.T, is *kvstore.Issues, repo domain.RepoName) []domain.Issue {
506+ t.Helper()
507+ all, err := is.ListOpenAll(context.Background())
508+ require.NoError(t, err)
509+ var out []domain.Issue
510+ for _, i := range all {
511+ if i.Repo == repo {
512+ out = append(out, i)
513+ }
514+ }
515+ sort.Slice(out, func(a, b int) bool { return out[a].Number > out[b].Number })
516+ return out
517+}
518+
519+func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
520+ ctx := context.Background()
521+ s, _, n := repoWithMRs(t)
522+ is := s.Issues()
523+ raw, err := proto.Marshal(&storagepb.Issue{Number: 1, Title: "x", State: storagepb.IssueState_ISSUE_STATE_PENDING})
524+ require.NoError(t, err)
525+ require.NoError(t, is.PutRawIssueForTest(ctx, n, 1, raw))
526+ _, err = is.Get(ctx, n, 1)
527+ assert.ErrorContains(t, err, "pending")
528+ _, err = is.List(ctx, n, ports.ListIssuesParams{})
529+ assert.ErrorContains(t, err, "pending")
530+ _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
531+ assert.ErrorContains(t, err, "pending")
532+}
M internal/adapters/kvstore/mergerequests.go +123 -26
@@ -6,10 +6,12 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "hash/fnv"
910 "net/url"
1011 "slices"
1112 "strconv"
1213 "strings"
14+ "sync"
1315 "unicode/utf8"
1416
1517 kv "bnema.dev/ermite-kv"
@@ -44,14 +46,15 @@ const (
4446 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the
4547 // database of the Store it comes from.
4648 type MergeRequests struct {
47- db *kv.DB
49+ db *kv.DB
50+ locks *numberLocks
4851 }
4952
5053 var _ ports.MergeRequestStore = (*MergeRequests)(nil)
5154
5255 // MergeRequests returns the merge request store over the same database. It is
5356 // valid until the Store is closed.
54-func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db} }
57+func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db, locks: s.locks} }
5558
5659 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }
5760 func mrKey(repo domain.RepoName, n int64) []byte {
@@ -198,47 +201,90 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198201 //
199202 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200203 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
204+ err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {
205+ mr.Number = n
206+ val, err := encodeMergeRequest(mr)
207+ if err != nil {
208+ return err
209+ }
210+ b.RequireAbsent(mrKey(mr.Repo, n))
211+ b.Put(mrKey(mr.Repo, n), val)
212+ indexOpen(b, nil, mr)
213+ return nil
214+ })
215+ if err != nil {
216+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
217+ }
218+ return m.Get(ctx, mr.Repo, mr.Number)
219+}
220+
221+// createNumbered takes the next number of the repository from mrseq/<repo>,
222+// shared by the merge requests and the issues, and lets add put the record
223+// under it into a batch that requires the sequence to be unchanged since it was
224+// read (absent for the first number) and the repository to be unchanged (see
225+// guardRepoKey): two concurrent creations, of either kind, never share a
226+// number. It retries when the batch conflicts; a missing repository gives
227+// domain.ErrNotFound.
228+//
229+// Creations in one repository are serialized in the process (numberLocks), so
230+// a burst of merge requests and issues never exhausts the retries; the version
231+// checks still guard against any other writer.
232+func createNumbered(ctx context.Context, db *kv.DB, locks *numberLocks, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
233+ unlock := locks.lock(repo)
234+ defer unlock()
201235 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))
236+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203237 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound
238+ return domain.ErrNotFound
205239 }
206240 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))
241+ item, ok := db.Get(ctx, seqKey(repo))
208242 if ok {
209243 var seq storagepb.Sequence
210244 if err := proto.Unmarshal(item.Value, &seq); err != nil {
211- return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err)
245+ return fmt.Errorf("decode sequence of %q: %w", repo, err)
212246 }
213247 last = seq.GetLast()
214248 }
215- mr.Number = last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
249+ number := last + 1
220250 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo
251+ b.RequireVersion(repoKey(repo), repoItem.Version)
252+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223253 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)
254+ b.RequireVersion(seqKey(repo), item.Version)
225255 } else {
226- b.RequireAbsent(seqKey(mr.Repo))
256+ b.RequireAbsent(seqKey(repo))
227257 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))
230- b.Put(mrKey(mr.Repo, mr.Number), val)
231- indexOpen(&b, nil, mr)
232- _, err = m.db.Apply(ctx, &b)
258+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
259+ if err := add(&b, number); err != nil {
260+ return err
261+ }
262+ _, err := db.Apply(ctx, &b)
233263 if errors.Is(err, kv.ErrConflict) {
234264 continue
235265 }
236- if err != nil {
237- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
238- }
239- return m.Get(ctx, mr.Repo, mr.Number)
266+ return err
240267 }
241- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict)
268+ return domain.ErrConflict
269+}
270+
271+// numberLockStripes is the size of the table of numbering locks.
272+const numberLockStripes = 64
273+
274+// numberLocks is a fixed table of mutexes, one stripe per hash of the
275+// repository name, for createNumbered: the memory is bounded whatever the
276+// number of repositories, and two repositories sharing a stripe only wait for
277+// each other. One table is shared by every store of a Store, so the merge
278+// requests and the issues of a repository are numbered one at a time.
279+type numberLocks [numberLockStripes]sync.Mutex
280+
281+// lock locks the numbering of repo and returns the unlock function.
282+func (l *numberLocks) lock(repo domain.RepoName) func() {
283+ h := fnv.New32a()
284+ _, _ = h.Write([]byte(repo.String()))
285+ mu := &l[h.Sum32()%numberLockStripes]
286+ mu.Lock()
287+ return mu.Unlock
242288 }
243289
244290 // Get reads mr/<repo>/<number>.
@@ -344,6 +390,52 @@ func (m *MergeRequests) ListOpen(ctx context.Context, repo domain.RepoName) ([]d
344390 return out, nil
345391 }
346392
393+// ListOpenAll returns the open merge requests of every repository, repository
394+// by repository and highest number first, through the mropen/ index: the
395+// requests it names are read, no other.
396+//
397+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open merge requests of all repositories through their index: a storage concern, no policy
398+func (m *MergeRequests) ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error) {
399+ type ref struct {
400+ repo domain.RepoName
401+ n int64
402+ }
403+ var refs []ref
404+ for key := range m.db.Snapshot(ctx).Prefix(ctx, []byte(mrOpenPrefix)) {
405+ if err := ctx.Err(); err != nil {
406+ return nil, err
407+ }
408+ rest := strings.TrimPrefix(string(key), mrOpenPrefix)
409+ repoName, _, ok := strings.Cut(rest, "/")
410+ if !ok {
411+ return nil, fmt.Errorf("index key %q", key)
412+ }
413+ repo, err := domain.ParseRepoName(repoName)
414+ if err != nil {
415+ return nil, fmt.Errorf("index key %q: %w", key, err)
416+ }
417+ n, err := strconv.ParseInt(rest[strings.LastIndexByte(rest, '/')+1:], 10, 64)
418+ if err != nil {
419+ return nil, fmt.Errorf("index key %q: %w", key, err)
420+ }
421+ refs = append(refs, ref{repo, n})
422+ }
423+ var out []domain.MergeRequest
424+ for _, r := range refs {
425+ mr, err := m.Get(ctx, r.repo, r.n)
426+ if errors.Is(err, domain.ErrNotFound) {
427+ continue // closed or deleted since the snapshot of the index
428+ }
429+ if err != nil {
430+ return nil, err
431+ }
432+ if mr.IsOpen() {
433+ out = append(out, mr)
434+ }
435+ }
436+ return out, nil
437+}
438+
347439 // CountOpen counts the open merge requests of the repository from the keys of
348440 // the index: no request is decoded.
349441 func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
@@ -683,6 +775,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
683775 }
684776 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
685777 }
778+ issues, err := encodeIssuesMode(s.Issues)
779+ if err != nil {
780+ return nil, err
781+ }
782+ pb.Issues = issues
686783 return pb, nil
687784 }
688785
@@ -707,5 +804,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
707804 if err != nil {
708805 return domain.DefaultRepoSettings()
709806 }
710- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}
807+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
711808 }
M internal/adapters/kvstore/mergerequests_test.go +1 -1
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441441 require.NoError(t, err)
442442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443443
444- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}
444+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445445 require.NoError(t, s.Update(ctx, got))
446446 again, err := s.Get(ctx, n)
447447 require.NoError(t, err)
M internal/adapters/kvstore/repos.go +3 -1
@@ -144,7 +144,9 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144144 for range deleteTries {
145145 snap := s.db.Snapshot(ctx)
146146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147- keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)
147+ keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, pendingKeys(ctx, snap, name)...)
149+ keys = append(keys, ciKeys(ctx, snap, name)...)
148150 keys = append(keys, settingsKeys(ctx, snap, name)...)
149151 for len(keys) > deleteChunk {
150152 var b kv.Batch
M internal/adapters/kvstore/store.go +65 -4
@@ -8,12 +8,14 @@ import (
88 "context"
99 "errors"
1010 "fmt"
11+ "slices"
1112
1213 kv "bnema.dev/ermite-kv"
1314 "github.com/bnema/zerowrap"
1415 "google.golang.org/protobuf/proto"
1516
1617 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
1719 "bnema.dev/ermite/internal/ports"
1820 )
1921
@@ -27,8 +29,17 @@ import (
2729 // (CIRun.head_sha: it would attach their checks to the merge commit, where
2830 // nothing looks for them), and the index of the open merge requests by source
2931 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).
31-const SchemaVersion = 3
32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
33+// isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34+// RepoSettings.issues): an older build would not delete them with their
35+// repository, nor keep the numbers apart, and it would misread the issue:write
36+// scope, so it must refuse the data. It also adds the pending issues of the
37+// public form (ispend/<repo>/<id>, removed with their repository), the bans of
38+// their submitters (isban/<hex hash>) and the form's server key
39+// (meta/issuekey). The upgrade also adds issue:write to the
40+// stored tokens that held all nine earlier scopes with no repository list and
41+// no branch prefix, so that they keep the owner rights (see upgradeTokens).
42+const SchemaVersion = 4
3243
3344 const schemaKey = "meta/schema"
3445
@@ -37,7 +48,8 @@ var ErrSchemaTooNew = errors.New("storage schema is newer than this ermite suppo
3748
3849 // Store is the ermite-kv backed metadata store.
3950 type Store struct {
40- db *kv.DB
51+ db *kv.DB
52+ locks *numberLocks // shared by the merge request and issue stores
4153 }
4254
4355 var _ ports.RepoMetaStore = (*Store)(nil)
@@ -50,7 +62,7 @@ func Open(ctx context.Context, dir string) (*Store, error) {
5062 if err != nil {
5163 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)
5264 }
53- s := &Store{db: db}
65+ s := &Store{db: db, locks: new(numberLocks)}
5466 if err := s.checkSchema(ctx); err != nil {
5567 _ = db.Close(context.WithoutCancel(ctx))
5668 return nil, err
@@ -125,8 +137,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125137 if sv.GetVersion() < SchemaVersion {
126138 // Older data stays readable as it is (the changes are additive);
127139 // the new version is recorded so that older builds refuse it.
140+ // The tokens are upgraded in the same batch, so that a crash
141+ // leaves the old version and the upgrade runs again.
128142 var b kv.Batch
129143 b.RequireVersion(key, item.Version)
144+ if sv.GetVersion() < 4 {
145+ if err := s.upgradeTokens(ctx, &b); err != nil {
146+ return err
147+ }
148+ }
130149 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131150 _, err := s.db.Apply(ctx, &b)
132151 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +170,45 @@ func mustMarshal(m proto.Message) []byte {
151170 }
152171 return b
153172 }
173+
174+// preIssueScopes are the nine scopes that existed before schema 4.
175+var preIssueScopes = []domain.Scope{
176+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
177+ domain.ScopeMRWrite, domain.ScopeMRMerge,
178+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
179+ domain.ScopeTokenAdmin,
180+}
181+
182+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
183+// holds all of preIssueScopes, limited to no repository and no branch prefix,
184+// had the owner rights, which AllScopes now needs issue:write for, so it gets
185+// the scope (this is no escalation). Every other token is left as it is. The
186+// batch requires each token to be at the version it was read at.
187+//
188+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
189+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
190+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
191+ pb := &storagepb.Token{}
192+ if err := proto.Unmarshal(item.Value, pb); err != nil {
193+ return fmt.Errorf("upgrade token %s: %w", key, err)
194+ }
195+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 ||
196+ slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) {
197+ continue
198+ }
199+ full := true
200+ for _, sc := range preIssueScopes {
201+ if !slices.Contains(pb.GetScopes(), sc.String()) {
202+ full = false
203+ break
204+ }
205+ }
206+ if !full {
207+ continue
208+ }
209+ pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String())
210+ b.RequireVersion(slices.Clone(key), item.Version)
211+ b.Put(slices.Clone(key), mustMarshal(pb))
212+ }
213+ return nil
214+}
M internal/adapters/kvstore/store_test.go +4 -4
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231231 })
232232
233- t.Run("version 2 is upgraded to 3, its runs kept", func(t *testing.T) {
233+ t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
234234 dir := t.TempDir()
235235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -261,10 +261,10 @@ func TestSchema(t *testing.T) {
261261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263263 require.NoError(t, s.Close(ctx))
264- assert.Equal(t, uint32(3), readSchema(t, dir))
264+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
265265 })
266266
267- t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) {
267+ t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
268268 dir := t.TempDir()
269269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -273,7 +273,7 @@ func TestSchema(t *testing.T) {
273273 _, err = s.Get(ctx, name(t, "old"))
274274 require.NoError(t, err)
275275 require.NoError(t, s.Close(ctx))
276- assert.Equal(t, uint32(3), readSchema(t, dir))
276+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
277277 })
278278
279279 // Downgrade guard: once a build with reusable workflows has opened the data
M internal/adapters/rpc/api.go +8 -3
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -55,7 +55,11 @@ type Config struct {
5555 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
5656 MergeRequests ports.MergeRequestService
5757 SigningKey ports.SigningKeyService
58- Version string
58+ // Issues serves IssueService.
59+ Issues ports.IssueService
60+ // IssueModeration serves the moderation calls of IssueService.
61+ IssueModeration ports.IssueModerationService
62+ Version string
5963 // TrustedProxies are the networks allowed to set X-Forwarded-For.
6064 TrustedProxies []netip.Prefix
6165 // Limiter counts failed authentications; nil builds a default one. Share
@@ -141,7 +145,8 @@ func New(cfg Config) *API {
141145 features = append(features, FeatureCommitSignatures)
142146 }
143147 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
144- mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
148+ mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{mrs: cfg.MergeRequests}, opts...))
149+ mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{issues: cfg.Issues, moderation: cfg.IssueModeration}, opts...))
145150 if cfg.Sessions != nil {
146151 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
147152 }
M internal/adapters/rpc/convert.go +19 -1
@@ -75,7 +75,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
7575 }
7676
7777 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
78- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
78+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}
7979 for _, m := range s.AllowedMergeModes {
8080 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
8181 }
@@ -133,6 +133,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
133133 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
134134 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
135135 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
136+ domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
136137 }
137138
138139 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -471,6 +472,21 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
471472 case domain.CommentAdded:
472473 out.Time = timestamp(ev.At)
473474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
475+ case domain.IssueOpened:
476+ out.Time = timestamp(ev.At)
477+ out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
478+ case domain.IssueClosed:
479+ out.Time = timestamp(ev.At)
480+ out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
481+ case domain.IssueReopened:
482+ out.Time = timestamp(ev.At)
483+ out.Kind = &pb.Event_IssueReopened{IssueReopened: &pb.IssueReopened{Issue: issueToPB(ev.Issue)}}
484+ case domain.IssueCommented:
485+ out.Time = timestamp(ev.At)
486+ out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
487+ case domain.IssueSubmitted:
488+ out.Time = timestamp(ev.At)
489+ out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
474490 case domain.RunUpdated:
475491 out.Time = timestamp(ev.At)
476492 out.Kind = runEventToPB(ev)
@@ -478,6 +494,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
478494 out.Time = timestamp(ev.At)
479495 out.Kind = jobEventToPB(ev)
480496 default:
497+ // Web-only events, such as domain.IssuePendingRemoved (the API has
498+ // no message for it; watchers list pending issues on demand).
481499 return nil
482500 }
483501 return out
M internal/adapters/rpc/convert_test.go +46 -1
@@ -50,12 +50,17 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
5050 }
5151 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
5252 assert.ErrorIs(t, err, domain.ErrInvalidScope)
53- // Every scope of the contract is a scope of the domain.
53+ // Every scope of the contract is a scope of the domain, except mr:review.
5454 for v := range pb.Scope_name {
5555 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
5656 continue
5757 }
5858 _, err := scopeFromPB(pb.Scope(v))
59+ if pb.Scope(v) == pb.Scope_SCOPE_MR_REVIEW {
60+ // In the contract, not implemented here yet: rejected like an unknown scope.
61+ assert.ErrorIs(t, err, domain.ErrInvalidScope, pb.Scope(v))
62+ continue
63+ }
5964 assert.NoError(t, err, pb.Scope(v))
6065 }
6166 })
@@ -336,3 +341,43 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
336341 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337342 assert.EqualValues(t, 3, added.GetComment().GetLine())
338343 }
344+
345+func TestIssueMappers(t *testing.T) {
346+ t.Parallel()
347+ for _, m := range domain.IssuesModes() {
348+ got, err := issuesModeFromPB(issuesModeToPB(m))
349+ require.NoError(t, err)
350+ assert.Equal(t, m, got)
351+ }
352+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
353+ _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
354+ assert.ErrorIs(t, err, domain.ErrInvalid)
355+
356+ s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
357+ require.NoError(t, err)
358+ assert.Empty(t, s)
359+ _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
360+ assert.ErrorIs(t, err, domain.ErrInvalid)
361+
362+ n, err := domain.ParseRepoName("proj")
363+ require.NoError(t, err)
364+ ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
365+ require.NotNil(t, ev.GetIssueClosed())
366+ assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
367+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
368+ ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
369+ assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
370+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
371+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
372+}
373+
374+func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) {
375+ t.Parallel()
376+ assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}}))
377+}
378+
379+func TestIssueStateToPBNeverPending(t *testing.T) {
380+ t.Parallel()
381+ assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
382+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
383+}
A internal/adapters/rpc/issue.go +262 -0
@@ -0,0 +1,262 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+
10+ "connectrpc.com/connect"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// issueService implements ermiteconnect.IssueServiceHandler.
18+type issueService struct {
19+ issues ports.IssueService
20+ moderation ports.IssueModerationService
21+}
22+
23+func issueStateToPB(s domain.IssueState) pb.IssueState {
24+ switch s {
25+ case domain.IssueStateOpen:
26+ return pb.IssueState_ISSUE_STATE_OPEN
27+ case domain.IssueStateClosed:
28+ return pb.IssueState_ISSUE_STATE_CLOSED
29+ }
30+ return pb.IssueState_ISSUE_STATE_UNSPECIFIED
31+}
32+
33+// issueStateFromPB maps the state filter of a listing; unspecified is the
34+// empty state, which means every state. Pending is not listable (InvalidArgument): a domain Issue is never pending.
35+func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
36+ switch s {
37+ case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
38+ return "", nil
39+ case pb.IssueState_ISSUE_STATE_OPEN:
40+ return domain.IssueStateOpen, nil
41+ case pb.IssueState_ISSUE_STATE_CLOSED:
42+ return domain.IssueStateClosed, nil
43+ }
44+ return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
45+}
46+
47+func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
48+ switch m.OrDefault() {
49+ case domain.IssuesOff:
50+ return pb.IssuesMode_ISSUES_MODE_OFF
51+ case domain.IssuesOwner:
52+ return pb.IssuesMode_ISSUES_MODE_OWNER
53+ case domain.IssuesPublic:
54+ return pb.IssuesMode_ISSUES_MODE_PUBLIC
55+ }
56+ return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
57+}
58+
59+func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
60+ switch m {
61+ case pb.IssuesMode_ISSUES_MODE_OFF:
62+ return domain.IssuesOff, nil
63+ case pb.IssuesMode_ISSUES_MODE_OWNER:
64+ return domain.IssuesOwner, nil
65+ case pb.IssuesMode_ISSUES_MODE_PUBLIC:
66+ return domain.IssuesPublic, nil
67+ }
68+ return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
69+}
70+
71+func issueToPB(i domain.Issue) *pb.Issue {
72+ return &pb.Issue{
73+ Repo: i.Repo.String(),
74+ Number: i.Number,
75+ Title: i.Title.String(),
76+ Body: i.Body.String(),
77+ State: issueStateToPB(i.State),
78+ Author: i.Author,
79+ CreatedAt: timestamp(i.CreatedAt),
80+ UpdatedAt: timestamp(i.UpdatedAt),
81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ }
83+}
84+
85+func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
86+ return &pb.IssueComment{
87+ Id: strconv.FormatInt(c.ID, 10),
88+ Author: c.Author,
89+ Text: c.Text.String(),
90+ CreatedAt: timestamp(c.CreatedAt),
91+ }
92+}
93+
94+func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
95+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
96+ if err != nil {
97+ return nil, connectError(ctx, err)
98+ }
99+ issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
100+ if err != nil {
101+ return nil, connectError(ctx, err)
102+ }
103+ return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
104+}
105+
106+func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
107+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
108+ if err != nil {
109+ return nil, connectError(ctx, err)
110+ }
111+ state, err := issueStateFromPB(req.Msg.GetState())
112+ if err != nil {
113+ return nil, connectError(ctx, err)
114+ }
115+ page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
116+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
117+ })
118+ if err != nil {
119+ return nil, connectError(ctx, err)
120+ }
121+ out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
122+ for _, i := range page.Issues {
123+ out.Issues = append(out.Issues, issueToPB(i))
124+ }
125+ return connect.NewResponse(out), nil
126+}
127+
128+func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
129+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
130+ if err != nil {
131+ return nil, connectError(ctx, err)
132+ }
133+ d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
134+ if err != nil {
135+ return nil, connectError(ctx, err)
136+ }
137+ out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
138+ for _, c := range d.Comments {
139+ out.Comments = append(out.Comments, issueCommentToPB(c))
140+ }
141+ return connect.NewResponse(out), nil
142+}
143+
144+func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
145+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
146+ if err != nil {
147+ return nil, connectError(ctx, err)
148+ }
149+ c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
150+ if err != nil {
151+ return nil, connectError(ctx, err)
152+ }
153+ return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
154+}
155+
156+func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
157+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
158+ if err != nil {
159+ return nil, connectError(ctx, err)
160+ }
161+ issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
162+ if err != nil {
163+ return nil, connectError(ctx, err)
164+ }
165+ return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
166+}
167+
168+func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
169+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
170+ if err != nil {
171+ return nil, connectError(ctx, err)
172+ }
173+ issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
174+ if err != nil {
175+ return nil, connectError(ctx, err)
176+ }
177+ return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
178+}
179+
180+func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
181+ return &pb.PendingIssue{
182+ Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
183+ Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
184+ }
185+}
186+
187+// pendingRef parses the repository and the id of a moderation request. A
188+// malformed id is reported as not found, like an unknown one.
189+func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
190+ name, err := domain.ParseRepoName(repo)
191+ if err != nil {
192+ return domain.RepoName{}, "", err
193+ }
194+ pid, err := domain.ParsePendingIssueID(id)
195+ if err != nil {
196+ return domain.RepoName{}, "", domain.ErrNotFound
197+ }
198+ return name, pid, nil
199+}
200+
201+// requireOwner answers NotFound to anyone without owner rights, before the
202+// request is parsed: a malformed repository must not tell a non-owner more
203+// than a well-formed one does.
204+func requireOwner(ctx context.Context) error {
205+ if !caller(ctx).HasOwnerRights() {
206+ return connectError(ctx, domain.ErrNotFound)
207+ }
208+ return nil
209+}
210+
211+func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
212+ if err := requireOwner(ctx); err != nil {
213+ return nil, err
214+ }
215+ var name domain.RepoName
216+ if req.Msg.GetRepo() != "" {
217+ var err error
218+ if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
219+ return nil, connectError(ctx, err)
220+ }
221+ }
222+ page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
223+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
224+ })
225+ if err != nil {
226+ return nil, connectError(ctx, err)
227+ }
228+ out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
229+ for _, p := range page.Issues {
230+ out.Issues = append(out.Issues, pendingIssueToPB(p))
231+ }
232+ return connect.NewResponse(out), nil
233+}
234+
235+func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
236+ if err := requireOwner(ctx); err != nil {
237+ return nil, err
238+ }
239+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
240+ if err != nil {
241+ return nil, connectError(ctx, err)
242+ }
243+ issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
244+ if err != nil {
245+ return nil, connectError(ctx, err)
246+ }
247+ return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
248+}
249+
250+func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
251+ if err := requireOwner(ctx); err != nil {
252+ return nil, err
253+ }
254+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
255+ if err != nil {
256+ return nil, connectError(ctx, err)
257+ }
258+ if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
259+ return nil, connectError(ctx, err)
260+ }
261+ return connect.NewResponse(&pb.RejectIssueResponse{}), nil
262+}
M internal/adapters/rpc/mergerequest.go +7 -1
@@ -10,12 +10,18 @@ import (
1010 "connectrpc.com/connect"
1111
1212 pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1314 "bnema.dev/ermite/internal/domain"
1415 "bnema.dev/ermite/internal/ports"
1516 )
1617
1718 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18-type mergeRequestService struct{ mrs ports.MergeRequestService }
19+type mergeRequestService struct {
20+ // The note methods (edit, delete, verdicts) are implemented by the merge
21+ // request notes work; until then they answer CodeUnimplemented.
22+ ermiteconnect.UnimplementedMergeRequestServiceHandler
23+ mrs ports.MergeRequestService
24+}
1925
2026 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
2127 switch s {
M internal/adapters/rpc/mergerequest_test.go +11 -11
@@ -41,7 +41,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
4141 Title: "t", Description: "d", Source: "feat", Target: "main",
4242 }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()
4343 }
44- resp, err := (&mergeRequestService{mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
44+ resp, err := (&mergeRequestService{mrs: mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
4545 Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",
4646 }))
4747 if tt.wantCode != 0 {
@@ -57,7 +57,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
5757
5858 func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {
5959 t.Parallel()
60- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
60+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
6161 connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))
6262 requireCode(t, connect.CodeInvalidArgument, err)
6363 }
@@ -68,7 +68,7 @@ func TestMergeRequestServiceListFiltersByState(t *testing.T) {
6868 mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{
6969 State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",
7070 }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()
71- resp, err := (&mergeRequestService{mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
71+ resp, err := (&mergeRequestService{mrs: mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
7272 Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",
7373 }))
7474 require.NoError(t, err)
@@ -99,7 +99,7 @@ func TestMergeRequestServiceAddComment(t *testing.T) {
9999 mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).
100100 Return(domain.Comment{ID: 5}, tt.usecase).Once()
101101 }
102- resp, err := (&mergeRequestService{mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
102+ resp, err := (&mergeRequestService{mrs: mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
103103 Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",
104104 }))
105105 if tt.wantCode != 0 {
@@ -120,7 +120,7 @@ func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) {
120120 mrs := mocks.NewMergeRequestService(t)
121121 mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).
122122 Return(domain.Comment{}, domain.ErrNotFound).Once()
123- _, err := (&mergeRequestService{mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
123+ _, err := (&mergeRequestService{mrs: mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
124124 Repo: "demo", Number: 1, CommentId: id, Resolved: true,
125125 }))
126126 requireCode(t, connect.CodeNotFound, err)
@@ -154,7 +154,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
154154 return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
155155 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
156156 }
157- resp, err := (&mergeRequestService{mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
157+ resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
158158 if tt.wantCode != 0 {
159159 requireCode(t, tt.wantCode, err)
160160 return
@@ -171,20 +171,20 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
171171 t.Parallel()
172172 mrs := mocks.NewMergeRequestService(t)
173173 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
174- _, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
174+ _, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
175175 requireCode(t, connect.CodeNotFound, err)
176176 })
177177 t.Run("close ok", func(t *testing.T) {
178178 t.Parallel()
179179 mrs := mocks.NewMergeRequestService(t)
180180 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
181- resp, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
181+ resp, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
182182 require.NoError(t, err)
183183 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
184184 })
185185 t.Run("mark read rejects a short head", func(t *testing.T) {
186186 t.Parallel()
187- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
187+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
188188 connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
189189 requireCode(t, connect.CodeInvalidArgument, err)
190190 })
@@ -192,7 +192,7 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
192192 t.Parallel()
193193 mrs := mocks.NewMergeRequestService(t)
194194 mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
195- _, err := (&mergeRequestService{mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
195+ _, err := (&mergeRequestService{mrs: mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
196196 requireCode(t, connect.CodeUnauthenticated, err)
197197 })
198198 }
@@ -214,7 +214,7 @@ func TestMergeRequestServiceGet(t *testing.T) {
214214 mrs := mocks.NewMergeRequestService(t)
215215 mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
216216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217- resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
217+ resp, err := (&mergeRequestService{mrs: mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218218 if tt.wantCode != 0 {
219219 requireCode(t, tt.wantCode, err)
220220 return
M internal/adapters/rpc/repo.go +10 -2
@@ -81,15 +81,23 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8181 // updateParams maps the field mask of an update. The mask must name at least
8282 // one of "description", "topics", "visibility", "default_branch", "settings",
8383 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else.
84+// nothing else; the optional issues field stands outside the mask, and alone
85+// it is a valid update.
8586 //
8687 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
8788 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
8889 var params ports.UpdateRepoParams
8990 paths := msg.GetUpdateMask().GetPaths()
90- if len(paths) == 0 {
91+ if len(paths) == 0 && msg.Issues == nil {
9192 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
9293 }
94+ if msg.Issues != nil {
95+ mode, err := issuesModeFromPB(msg.GetIssues())
96+ if err != nil {
97+ return params, err
98+ }
99+ params.Issues = &mode
100+ }
93101 for _, path := range paths {
94102 switch path {
95103 case "description":
M internal/adapters/web/ASSETS.md +2 -0
@@ -13,6 +13,8 @@ content-hashed names. Nothing is fetched at runtime.
1313 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1414 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
1515 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
16+| `static/js/issue-form.js` | the public issue form: starts the proof-of-work worker on load, length counters, the state of the submit button (`window.ermiteIssueForm`; core tested in Node by `jstest/issue-form.test.mjs`) | written here | |
17+| `static/js/pow-worker.js` | the dedicated Worker of the form: finds the counter `n` such that SHA-256(nonce bytes \|\| ":" \|\| decimal `n`) has the required leading zero bits, with `crypto.subtle`; same encoding as `usecase/issue_submissions.go` (shared vector tested on both sides). Loaded from the same origin by `new Worker(url)`, no `blob:` | written here | |
1618 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1719 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1820 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/assets.go +3 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -65,6 +65,8 @@ var scripts = []struct {
6565 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6666 {"log.js", func(a *Assets, u string) { a.Log = u }},
6767 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68+ {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69+ {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
6870 }
6971
7072 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
M internal/adapters/web/events.go +59 -3
@@ -27,10 +27,13 @@ import (
2727 // the HTML fragments the page shows, sent as Server-Sent Events:
2828 //
2929 // GET /-/events?topic=home
30+// GET /-/events?topic=owner&view=inbox[&repo=<name>] (the signed-in owner only)
3031 // GET /-/events?topic=repo:<name>&view=summary|refs
3132 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3233 // GET /-/events?topic=repo:<name>&view=mrs
3334 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
35+// GET /-/events?topic=repo:<name>&view=issues
36+// GET /-/events?topic=repo:<name>&view=issue&n=<number>
3437 // GET /-/events?topic=repo:<name>&view=actions
3538 // GET /-/events?topic=repo:<name>&view=run&n=<run id>
3639 // GET /-/events?topic=repo:<name>&view=job&job=<job id>
@@ -51,7 +54,7 @@ import (
5154 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5255 // <dl id="refs-kv">…</dl>, the facts of the rail
5356 // commits-head data: <div id="commits-head">N new commits — reload</div>
54-// mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
57+// mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
5558 // something changed, with the link that reloads the page
5659 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
5760 // <section id="recent-actions">…</section> (the rail of the summary) or
@@ -82,9 +85,14 @@ const (
8285 retryAfter = 10
8386 )
8487
85-// parseTopic reads "home" or "repo:<name>"; repo is "" for home.
88+// ownerTopic is the stream of the inbox: the merge requests and issues of every
89+// repository, for the owner.
90+const ownerTopic = "owner"
91+
92+// parseTopic reads "home", "owner" or "repo:<name>"; repo is "" for home and
93+// owner.
8694 func parseTopic(topic string) (repo string, ok bool) {
87- if topic == "home" {
95+ if topic == "home" || topic == ownerTopic {
8896 return "", true
8997 }
9098 if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
@@ -93,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) {
93101 return "", false
94102 }
95103
104+// ownerStreamOK tells whether the request may follow the owner topic: the
105+// signed-in owner, with the inbox view and a valid repository filter, on a
106+// server that has the inbox.
107+func (s *Server) ownerStreamOK(ctx context.Context, q url.Values) bool {
108+ if s.cfg.Inbox == nil || !SignedIn(ctx) || q.Get("view") != "inbox" {
109+ return false
110+ }
111+ if f := q.Get("repo"); f != "" {
112+ if _, err := domain.ParseRepoName(f); err != nil {
113+ return false
114+ }
115+ }
116+ return true
117+}
118+
96119 // events is GET /-/events.
97120 func (s *Server) events(w http.ResponseWriter, r *http.Request) {
98121 if s.cfg.Events == nil {
@@ -102,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
102125 q := r.URL.Query()
103126 topic := q.Get("topic")
104127 repo, ok := parseTopic(topic)
128+ if topic == ownerTopic {
129+ // Anyone but the signed-in owner gets the answer of an unknown topic.
130+ ok = s.ownerStreamOK(r.Context(), q)
131+ repo = ""
132+ }
105133 if !ok {
106134 denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
107135 return
@@ -114,6 +142,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
114142 // A repository the visitor cannot see is a missing one (AC2).
115143 cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
116144 err := s.canSee(cctx, repo)
145+ if err == nil {
146+ if v := q.Get("view"); v == "issues" || v == "issue" {
147+ err = s.issuesOn(cctx, repo)
148+ }
149+ }
117150 ccancel()
118151 if err != nil {
119152 s.denyEvents(ctx, w, err)
@@ -220,6 +253,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
220253 }
221254
222255 st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
256+ if topic == ownerTopic {
257+ st.filter = q.Get("repo")
258+ }
223259 handle := func(m recvd) bool {
224260 if m.err != nil {
225261 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
@@ -298,6 +334,26 @@ func (s *Server) canSee(ctx context.Context, rawName string) error {
298334 return err
299335 }
300336
337+// issuesOn is domain.ErrNotFound unless the repository has issues on and
338+// s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.
339+func (s *Server) issuesOn(ctx context.Context, rawName string) error {
340+ if s.cfg.Issues == nil {
341+ return domain.ErrNotFound
342+ }
343+ name, err := domain.ParseRepoName(rawName)
344+ if err != nil {
345+ return err
346+ }
347+ v, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
348+ if err != nil {
349+ return err
350+ }
351+ if v.Settings.Issues.OrDefault() == domain.IssuesOff {
352+ return domain.ErrNotFound
353+ }
354+ return nil
355+}
356+
301357 // denyStream answers a request that gets no stream.
302358 func denyStream(w http.ResponseWriter, status int, msg string) {
303359 w.Header().Set("Cache-Control", "no-store")
M internal/adapters/web/events_test.go +38 -1
@@ -36,7 +36,7 @@ func TestFormatEvent(t *testing.T) {
3636
3737 func TestParseTopic(t *testing.T) {
3838 t.Parallel()
39- for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} {
39+ for in, want := range map[string]string{"home": "", "owner": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} {
4040 repo, ok := parseTopic(in)
4141 assert.True(t, ok, in)
4242 assert.Equal(t, want, repo, in)
@@ -428,3 +428,40 @@ func TestEventsInvalidCookieStream(t *testing.T) {
428428 assert.Contains(t, resp.Header.Values("Vary"), "Cookie")
429429 assert.Contains(t, resp.Header.Get("Set-Cookie"), "Max-Age=0")
430430 }
431+
432+// An issues stream on a repository with issues off, unknown or private is
433+// refused the same way: 404 with the same body.
434+func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
435+ t.Parallel()
436+ priv := mocks.NewRepoService(t)
437+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
438+ for _, view := range []string{"issues", "issue&n=1"} {
439+ answers := map[string]string{}
440+ for name, repos := range map[string]ports.RepoService{
441+ "off": reposServing(t, issueRepoView(t, domain.IssuesOff)),
442+ "unset": reposServing(t, issueRepoView(t, "")),
443+ "unknown": reposServing(t),
444+ "private": priv,
445+ } {
446+ f := &watchProbe{release: make(chan struct{})}
447+ srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
448+ ts := httptest.NewServer(srv)
449+ t.Cleanup(ts.Close)
450+ resp, _ := open(t, ts, "topic=repo:proj&view="+view, nil)
451+ body, _ := io.ReadAll(resp.Body)
452+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, "%s %s", view, name)
453+ answers[name] = resp.Header.Get("Cache-Control") + "|" + string(body)
454+ assert.Zero(t, f.opened.Load(), "%s %s: no watch is opened", view, name)
455+ }
456+ for name, a := range answers {
457+ assert.Equal(t, answers["unknown"], a, "%s %s", view, name)
458+ }
459+ }
460+ // With issues on, the stream opens.
461+ f := &watchProbe{release: make(chan struct{})}
462+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
463+ ts := httptest.NewServer(srv)
464+ t.Cleanup(ts.Close)
465+ resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466+ assert.Equal(t, http.StatusOK, resp.StatusCode)
467+}
M internal/adapters/web/fragments.go +36 -6
@@ -21,11 +21,13 @@ import (
2121 type liveStream struct {
2222 s *Server
2323 repo string // "" on the home page
24- view string // "summary", "refs" or "commits"
25- ref string // commits: the branch listed
26- head string // commits: the commit listed first
27- n string // mr: the number of the merge request shown; run: the run id
28- job string // job: the id of the job shown
24+ // filter is the repository the inbox is filtered on, "" for all.
25+ filter string
26+ view string // "summary", "refs" or "commits"
27+ ref string // commits: the branch listed
28+ head string // commits: the commit listed first
29+ n string // mr: the number of the merge request shown; run: the run id
30+ job string // job: the id of the job shown
2931 // sha is the commit of the run of the job shown, read once when the stream
3032 // is set up: the annotations link to files at it, and it never changes.
3133 sha string
@@ -59,7 +61,8 @@ func liveEvent(we ports.WatchEvent) bool {
5961 switch we.Event.(type) {
6062 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6163 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
62- domain.RunUpdated, domain.JobUpdated:
64+ domain.RunUpdated, domain.JobUpdated,
65+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6366 return true
6467 }
6568 return false
@@ -74,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen
7477 if we.Event == nil {
7578 return nil, nil
7679 }
80+ if l.view == "inbox" {
81+ return l.inbox(ctx, we)
82+ }
7783 if l.repo == "" {
7884 return l.home(ctx, we)
7985 }
@@ -166,6 +172,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
166172 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
167173 case domain.CommentAdded:
168174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
175+ case domain.IssueOpened:
176+ return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
177+ case domain.IssueClosed:
178+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
179+ case domain.IssueReopened:
180+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
181+ case domain.IssueCommented:
182+ return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
169183 case domain.PushEvent:
170184 switch l.view {
171185 case "mr":
@@ -400,6 +414,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
400414 return nil, nil
401415 }
402416
417+// issueNotice tells an issue page that something happened to the issue it
418+// shows, or the list that its content changed. Like mrNotice it sends a fixed
419+// sentence only, in the same slot.
420+func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
421+ switch l.view {
422+ case "issues":
423+ return l.notice(ctx, "issues changed — reload", issuesBase(l.repo))
424+ case "issue":
425+ if strconv.FormatInt(number, 10) != l.n {
426+ return nil, nil
427+ }
428+ return l.notice(ctx, text, issueHref(l.repo, number))
429+ }
430+ return nil, nil
431+}
432+
403433 // mrPushed announces a push to the source branch of the merge request shown.
404434 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
405435 if l.ref == "" {
M internal/adapters/web/fragments.templ +1 -1
@@ -102,7 +102,7 @@ templ Live(l Layout) {
102102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 htmx:sse:before:connection -> ermiteLive.gate(event);
105- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
105+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106106 gap -> ermiteLive.reload()"
107107 ></span>
108108 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/home.go +3 -0
@@ -121,6 +121,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
121121 p.Sorts = append(p.Sorts, sl)
122122 }
123123 p.Rows = s.homeRows(ctx, repos, now)
124+ if n, show := s.inboxCount(ctx); show {
125+ p.Inbox = &InboxButton{Href: inboxPath, Count: n}
126+ }
124127
125128 l := s.base()
126129 l.Title = s.instance
A internal/adapters/web/inbox.go +294 -0
@@ -0,0 +1,294 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/url"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "github.com/a-h/templ"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20+// issues of every repository, newest activity first, with a filter by
21+// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22+// signed-in owner sees it: for anyone else the path is as missing as any other.
23+// It follows the stream "owner" with view=inbox: each event of a merge request
24+// or an issue sends the counters, the two lists and the filter again.
25+
26+// inboxPath is the page of the inbox.
27+const inboxPath = "/-/inbox"
28+
29+// inboxEvent is the name of the live events of the inbox.
30+const inboxEvent = "inbox"
31+
32+// InboxRow is one open item of the inbox.
33+type InboxRow struct {
34+ Repo string
35+ RepoHref string
36+ Number string
37+ Href string
38+ Title string
39+ When string
40+ WhenAt string
41+ Cursor bool
42+}
43+
44+// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45+// it comes from an anonymous visitor and is never rendered as Markdown.
46+type PendingRow struct {
47+ Repo string
48+ RepoHref string
49+ Author string
50+ Title string
51+ // Preview is the start of the body, at most pendingPreviewRunes characters.
52+ Preview string
53+ When string
54+ WhenAt string
55+ // Approve, Reject and Ban are the URLs the buttons post to; empty without
56+ // the moderation use case.
57+ Approve, Reject, Ban string
58+}
59+
60+// pendingPreviewRunes bounds the preview of a pending body.
61+const pendingPreviewRunes = 300
62+
63+// InboxRepoLink is an entry of the repository filter.
64+type InboxRepoLink struct {
65+ Label string
66+ Href string
67+ Current bool
68+}
69+
70+// InboxPage is the inbox: the counters, the filter and the two lists.
71+type InboxPage struct {
72+ // MergeRequests and Issues count every open item, whatever the filter.
73+ MergeRequests, Issues int
74+ // PendingCount counts every issue awaiting moderation.
75+ PendingCount int
76+ Pending []PendingRow
77+ Filter string // the repository filtered on, "" for all
78+ Repos []InboxRepoLink
79+ MRs []InboxRow
80+ IssueRows []InboxRow
81+}
82+
83+// inboxHref is the inbox page, filtered on repo when it is not "".
84+func inboxHref(repo string) string {
85+ if repo == "" {
86+ return inboxPath
87+ }
88+ return inboxPath + "?repo=" + url.QueryEscape(repo)
89+}
90+
91+// inboxFilter reads ?repo=. A name that is not valid is a page that is not
92+// there.
93+func inboxFilter(r *http.Request) (domain.RepoName, error) {
94+ raw := r.URL.Query().Get("repo")
95+ if raw == "" {
96+ return domain.RepoName{}, nil
97+ }
98+ name, err := domain.ParseRepoName(raw)
99+ if err != nil {
100+ return domain.RepoName{}, errNotFound
101+ }
102+ return name, nil
103+}
104+
105+// inboxPage builds the page from the overview.
106+func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107+ now := s.cfg.Now()
108+ p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109+ for _, pi := range ov.Pending {
110+ p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111+ }
112+ row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
113+ ts := updated
114+ if ts.IsZero() {
115+ ts = created
116+ }
117+ r := InboxRow{
118+ Repo: repo.String(), RepoHref: repoHref(repo.String()),
119+ Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
120+ }
121+ r.When, r.WhenAt = whenOf(now, ts)
122+ return r
123+ }
124+ for _, m := range ov.MergeRequests {
125+ p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
126+ }
127+ for _, i := range ov.Issues {
128+ p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
129+ }
130+ switch {
131+ case len(p.MRs) > 0:
132+ p.MRs[0].Cursor = true
133+ case len(p.IssueRows) > 0:
134+ p.IssueRows[0].Cursor = true
135+ }
136+ p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
137+ for _, r := range ov.Repos {
138+ p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
139+ }
140+ return p
141+}
142+
143+// pendingRow is the row of a pending issue: the texts as plain strings, the
144+// invisible characters marked, the body cut.
145+func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146+ repo := pi.Repo.String()
147+ row := PendingRow{
148+ Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149+ Title: visibleText(pi.Title.String()),
150+ }
151+ if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152+ row.Author = a
153+ }
154+ body := strings.TrimSpace(visibleText(pi.Body.String()))
155+ row.Preview = cutRunes(body, pendingPreviewRunes)
156+ if len(row.Preview) < len(body) {
157+ row.Preview += "…"
158+ }
159+ row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160+ if s.cfg.Moderation != nil {
161+ base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162+ var q string
163+ if !filter.IsZero() {
164+ q = "?repo=" + url.QueryEscape(filter.String())
165+ }
166+ row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167+ }
168+ return row
169+}
170+
171+// pendingBack is where a moderation action returns: the inbox, on the same
172+// repository filter.
173+func pendingBack(r *http.Request) string {
174+ if f, err := inboxFilter(r); err == nil {
175+ return inboxHref(f.String())
176+ }
177+ return inboxPath
178+}
179+
180+// pendingID reads the repository and the id of the path.
181+func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182+ name, err := domain.ParseRepoName(r.PathValue("repo"))
183+ if err != nil {
184+ return domain.RepoName{}, "", err
185+ }
186+ id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187+ if err != nil {
188+ return domain.RepoName{}, "", domain.ErrNotFound
189+ }
190+ return name, id, nil
191+}
192+
193+// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194+func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195+ back := pendingBack(r)
196+ name, id, err := pendingID(r)
197+ if err != nil {
198+ return "", back, err
199+ }
200+ if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201+ return "", back, err
202+ }
203+ return back, back, nil
204+}
205+
206+// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207+// also bans the submitter.
208+func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209+ return func(ctx context.Context, r *http.Request) (string, string, error) {
210+ back := pendingBack(r)
211+ name, id, err := pendingID(r)
212+ if err != nil {
213+ return "", back, err
214+ }
215+ if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216+ return "", back, err
217+ }
218+ return back, back, nil
219+ }
220+}
221+
222+// inbox is GET /-/inbox.
223+func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
224+ if !SignedIn(ctx) {
225+ return nil, 0, errNotFound
226+ }
227+ filter, err := inboxFilter(r)
228+ if err != nil {
229+ return nil, 0, err
230+ }
231+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
232+ if err != nil {
233+ return nil, 0, err
234+ }
235+ l := s.base()
236+ l.Title = "inbox · " + s.instance
237+ l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
238+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
239+ if s.cfg.Events != nil {
240+ q := "view=inbox"
241+ if !filter.IsZero() {
242+ q += "&repo=" + url.QueryEscape(filter.String())
243+ }
244+ l.Live, l.LiveURL = true, liveURL("owner", q)
245+ }
246+ return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
247+}
248+
249+// inboxCount is the number of open items the home button shows, and whether
250+// there is a button: only for the signed-in owner, and only when the inbox is
251+// there. A failed read leaves the button without a number.
252+func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
253+ if s.cfg.Inbox == nil || !SignedIn(ctx) {
254+ return 0, false
255+ }
256+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
257+ if err != nil {
258+ return -1, true
259+ }
260+ return ov.Counts.MergeRequests + ov.Counts.Issues, true
261+}
262+
263+// inbox renders the fragments an event of a merge request, an issue or a repository changes
264+// on the inbox page: the counters, the filter and the two lists.
265+func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266+ switch we.Event.(type) {
267+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
268+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269+ domain.RepoUpdated, domain.RepoDeleted:
270+ default:
271+ return nil, nil
272+ }
273+ if l.s.cfg.Inbox == nil {
274+ return nil, nil
275+ }
276+ var filter domain.RepoName
277+ if l.filter != "" {
278+ filter, _ = domain.ParseRepoName(l.filter)
279+ }
280+ ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
281+ if err != nil {
282+ return nil, err
283+ }
284+ p := l.s.inboxPage(ov, filter)
285+ var out []fragment
286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
287+ f, err := renderFragment(ctx, inboxEvent, c)
288+ if err != nil {
289+ return nil, err
290+ }
291+ out = append(out, f)
292+ }
293+ return out, nil
294+}
A internal/adapters/web/inbox.templ +115 -0
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Inbox is the owner's inbox: what is open across every repository. The
6+// counters, the filter and the lists are fragments with an id that the live
7+// stream replaces as a whole (ermiteLive.swap).
8+templ Inbox(l Layout, p InboxPage) {
9+ @Shell(l, nil) {
10+ <h1 class="sr">Inbox</h1>
11+ <div class="inbox">
12+ @InboxCounters(p)
13+ @InboxRepos(p)
14+ @InboxPending(p)
15+ @InboxMRs(p)
16+ @InboxIssues(p)
17+ </div>
18+ }
19+}
20+
21+templ InboxCounters(p InboxPage) {
22+ <p id="inbox-counts" class="inbox-counts">
23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
24+ </p>
25+}
26+
27+templ InboxRepos(p InboxPage) {
28+ <nav id="inbox-repos" class="since" aria-label="Repository">
29+ for _, r := range p.Repos {
30+ <a
31+ href={ templ.SafeURL(r.Href) }
32+ if r.Current {
33+ aria-current="page"
34+ }
35+ >{ r.Label }</a>
36+ }
37+ </nav>
38+}
39+
40+// InboxPending is the issues awaiting moderation, first on the page. Their
41+// texts come from anonymous visitors: they are shown as plain text, escaped,
42+// never as Markdown.
43+templ InboxPending(p InboxPage) {
44+ <section id="inbox-pending" aria-labelledby="inbox-pending-h">
45+ <h2 id="inbox-pending-h" class="rule-h"><span>Awaiting moderation</span><span class="count">{ itoa(p.PendingCount) }</span></h2>
46+ if len(p.Pending) > 0 {
47+ <ul class="pending-list">
48+ for _, m := range p.Pending {
49+ <li class="pending">
50+ <div class="p-head">
51+ <a href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a>
52+ <span>{ m.Author }</span>
53+ if m.WhenAt != "" {
54+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
55+ }
56+ </div>
57+ <p class="p-title">{ m.Title }</p>
58+ if m.Preview != "" {
59+ <p class="p-body">{ m.Preview }</p>
60+ }
61+ if m.Approve != "" {
62+ <div class="p-btns">
63+ <form method="post" action={ templ.SafeURL(m.Approve) }><button type="submit" class="btn primary">Approve</button></form>
64+ <form method="post" action={ templ.SafeURL(m.Reject) }><button type="submit" class="btn">Reject</button></form>
65+ <form method="post" action={ templ.SafeURL(m.Ban) }><button type="submit" class="btn danger">Reject and ban</button></form>
66+ </div>
67+ }
68+ </li>
69+ }
70+ </ul>
71+ } else {
72+ <p class="lede dim">nothing awaits moderation.</p>
73+ }
74+ </section>
75+}
76+
77+templ InboxMRs(p InboxPage) {
78+ <section id="inbox-mrs" aria-labelledby="inbox-mrs-h">
79+ <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2>
80+ @inboxRows(p.MRs, "Open merge requests")
81+ if len(p.MRs) == 0 {
82+ <p class="lede dim">no open merge requests.</p>
83+ }
84+ </section>
85+}
86+
87+templ InboxIssues(p InboxPage) {
88+ <section id="inbox-issues" aria-labelledby="inbox-issues-h">
89+ <h2 id="inbox-issues-h" class="rule-h"><span>Open issues</span><span class="count">{ itoa(len(p.IssueRows)) }</span></h2>
90+ @inboxRows(p.IssueRows, "Open issues")
91+ if len(p.IssueRows) == 0 {
92+ <p class="lede dim">no open issues.</p>
93+ }
94+ </section>
95+}
96+
97+templ inboxRows(rows []InboxRow, label string) {
98+ if len(rows) > 0 {
99+ <table class="list mrs" aria-label={ label }>
100+ <tbody data-rows>
101+ for _, m := range rows {
102+ <tr data-cursor?={ m.Cursor } data-state="open">
103+ <td class="c-n mute">{ m.Number }</td>
104+ <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <a class="mute" href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a></td>
105+ <td class="c-when nowrap dim">
106+ if m.WhenAt != "" {
107+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
108+ }
109+ </td>
110+ </tr>
111+ }
112+ </tbody>
113+ </table>
114+ }
115+}
A internal/adapters/web/inbox_moderation_test.go +148 -0
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+const testPendingID = "aaaaaaaaaaaaaaaaaaaaaaaaaa"
22+
23+func testPendingIssue(t testing.TB) domain.PendingIssue {
24+ t.Helper()
25+ id, err := domain.NewPendingIssueID()
26+ require.NoError(t, err)
27+ title, err := domain.ParseIssueTitle("<script>alert(1)</script> crash")
28+ require.NoError(t, err)
29+ body, err := domain.ParseIssueBody("**not bold** [x](javascript:alert(1)) <img src=x onerror=y>\n" + strings.Repeat("long line ", 60))
30+ require.NoError(t, err)
31+ return domain.PendingIssue{ID: id, Repo: repoName(t, "alpha"), Title: title, Body: body, Author: "Eve <i>", CreatedAt: time.Now().Add(-3 * time.Hour)}
32+}
33+
34+func moderationServer(t *testing.T, inbox ports.InboxService, mod ports.IssueModerationService) *Server {
35+ t.Helper()
36+ return newTestServer(t, Config{
37+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, Moderation: mod,
38+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
39+ })
40+}
41+
42+func TestInboxShowsPendingIssuesAsPlainTextFirst(t *testing.T) {
43+ t.Parallel()
44+ p := testPendingIssue(t)
45+ ov := inboxOverview(t)
46+ ov.Pending = []domain.PendingIssue{p, {ID: p.ID, Repo: repoName(t, "beta"), Title: p.Title, CreatedAt: time.Now()}}
47+ ov.Counts.Pending = 2
48+ svc := mocks.NewInboxService(t)
49+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil)
50+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
51+
52+ rec := inboxGet(srv, "/-/inbox", true)
53+ require.Equal(t, 200, rec.Code)
54+ body := rec.Body.String()
55+ assert.Contains(t, body, "<strong>2</strong> awaiting moderation")
56+ assert.Less(t, strings.Index(body, `id="inbox-pending"`), strings.Index(body, `id="inbox-mrs"`), "first section")
57+ assert.Contains(t, body, "&lt;script&gt;alert(1)&lt;/script&gt; crash")
58+ assert.NotContains(t, body, "<script>alert")
59+ assert.Contains(t, body, "**not bold**", "no Markdown")
60+ assert.NotContains(t, body, "<strong>not bold")
61+ assert.NotContains(t, body, `href="javascript`)
62+ assert.NotContains(t, body, "<img")
63+ assert.Contains(t, body, "Eve &lt;i&gt;")
64+ assert.Contains(t, body, ">anonymous<", "no label")
65+ assert.Contains(t, body, "…</p>", "a preview of about 300 characters")
66+ assert.Contains(t, body, `action="/-/inbox/pending/alpha/`+p.ID.String()+`/approve"`)
67+ assert.Contains(t, body, `/reject"`)
68+ assert.Contains(t, body, `/ban"`)
69+ assert.Contains(t, body, "Reject and ban")
70+}
71+
72+func TestInboxPendingKeepsTheFilterInItsActions(t *testing.T) {
73+ t.Parallel()
74+ p := testPendingIssue(t)
75+ ov := inboxOverview(t)
76+ ov.Pending = []domain.PendingIssue{p}
77+ ov.Counts.Pending = 1
78+ svc := mocks.NewInboxService(t)
79+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "alpha")).Return(ov, nil)
80+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
81+ rec := inboxGet(srv, "/-/inbox?repo=alpha", true)
82+ assert.Contains(t, rec.Body.String(), `/approve?repo=alpha"`)
83+}
84+
85+func TestInboxModerationActions(t *testing.T) {
86+ t.Parallel()
87+ post := func(srv *Server, path string, hdr map[string]string) *httptest.ResponseRecorder {
88+ req := httptest.NewRequest(http.MethodPost, path, nil)
89+ for k, v := range hdr {
90+ req.Header.Set(k, v)
91+ }
92+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
93+ rec := httptest.NewRecorder()
94+ srv.ServeHTTP(rec, req)
95+ return rec
96+ }
97+ id, err := domain.ParsePendingIssueID(testPendingID)
98+ require.NoError(t, err)
99+ alpha := repoName(t, "alpha")
100+ site := map[string]string{"Origin": testPublicURL}
101+
102+ t.Run("approve", func(t *testing.T) {
103+ t.Parallel()
104+ mod := mocks.NewIssueModerationService(t)
105+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, nil).Once()
106+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve?repo=alpha", site)
107+ assert.Equal(t, 303, rec.Code)
108+ assert.Equal(t, "/-/inbox?repo=alpha", rec.Header().Get("Location"))
109+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
110+ })
111+ t.Run("reject", func(t *testing.T) {
112+ t.Parallel()
113+ mod := mocks.NewIssueModerationService(t)
114+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, false).Return(nil).Once()
115+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/reject", site)
116+ assert.Equal(t, 303, rec.Code)
117+ assert.Equal(t, "/-/inbox", rec.Header().Get("Location"))
118+ })
119+ t.Run("ban", func(t *testing.T) {
120+ t.Parallel()
121+ mod := mocks.NewIssueModerationService(t)
122+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, true).Return(nil).Once()
123+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/ban", site)
124+ assert.Equal(t, 303, rec.Code)
125+ })
126+ t.Run("gone", func(t *testing.T) {
127+ t.Parallel()
128+ mod := mocks.NewIssueModerationService(t)
129+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, domain.ErrNotFound).Once()
130+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve", site)
131+ assert.Equal(t, 404, rec.Code)
132+ })
133+ t.Run("a malformed id is not found and calls nothing", func(t *testing.T) {
134+ t.Parallel()
135+ rec := post(moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t)), "/-/inbox/pending/alpha/nope/approve", site)
136+ assert.Equal(t, 404, rec.Code)
137+ })
138+ t.Run("the gate: no origin, no session", func(t *testing.T) {
139+ t.Parallel()
140+ srv := moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t))
141+ assert.Equal(t, 403, post(srv, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil).Code)
142+ req := httptest.NewRequest(http.MethodPost, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil)
143+ req.Header.Set("Origin", testPublicURL)
144+ rec := httptest.NewRecorder()
145+ srv.ServeHTTP(rec, req)
146+ assert.Equal(t, 403, rec.Code, "anonymous")
147+ })
148+}
A internal/adapters/web/inbox_templ.go +759 -0 generated file: collapsed
A internal/adapters/web/inbox_test.go +310 -0
@@ -0,0 +1,310 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bufio"
7+ "context"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+func inboxOverview(t testing.TB) ports.InboxOverview {
25+ t.Helper()
26+ mrTitle, err := domain.ParseMRTitle("fix <b>search</b>")
27+ require.NoError(t, err)
28+ isTitle, err := domain.ParseIssueTitle("crash on start")
29+ require.NoError(t, err)
30+ at := time.Now().Add(-2 * time.Hour)
31+ ov := ports.InboxOverview{
32+ MergeRequests: []domain.MergeRequest{{
33+ Repo: repoName(t, "alpha"), Number: 4, Title: mrTitle, State: domain.MRStateOpen, UpdatedAt: at,
34+ }},
35+ Issues: []domain.Issue{{Repo: repoName(t, "beta"), Number: 7, Title: isTitle, State: domain.IssueStateOpen, UpdatedAt: at}},
36+ Repos: []domain.RepoName{repoName(t, "alpha"), repoName(t, "beta")},
37+ }
38+ ov.Counts = ports.InboxCounts{MergeRequests: 1, Issues: 1}
39+ return ov
40+}
41+
42+func inboxServer(t *testing.T, inbox ports.InboxService) *Server {
43+ t.Helper()
44+ return newTestServer(t, Config{
45+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox,
46+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
47+ })
48+}
49+
50+func inboxGet(srv *Server, path string, owner bool) *httptest.ResponseRecorder {
51+ req := httptest.NewRequest(http.MethodGet, path, nil)
52+ if owner {
53+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
54+ }
55+ rec := httptest.NewRecorder()
56+ srv.ServeHTTP(rec, req)
57+ return rec
58+}
59+
60+func TestInboxPageForTheOwner(t *testing.T) {
61+ t.Parallel()
62+ svc := mocks.NewInboxService(t)
63+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
64+ srv := inboxServer(t, svc)
65+
66+ rec := inboxGet(srv, "/-/inbox", true)
67+ require.Equal(t, 200, rec.Code, rec.Body.String())
68+ body := rec.Body.String()
69+ assert.Contains(t, body, "<strong>1</strong> merge requests · <strong>1</strong> issues")
70+ assert.Contains(t, body, "Open merge requests")
71+ assert.Contains(t, body, "Open issues")
72+ assert.Contains(t, body, `href="/alpha/merge-requests/4"`)
73+ assert.Contains(t, body, "fix &lt;b&gt;search&lt;/b&gt;")
74+ assert.Contains(t, body, `href="/beta/issues/7"`)
75+ assert.Contains(t, body, "#7")
76+ assert.Contains(t, body, "2h ago")
77+ assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter")
78+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
79+}
80+
81+func TestInboxPageIsMissingForEveryoneElse(t *testing.T) {
82+ t.Parallel()
83+ svc := mocks.NewInboxService(t) // never asked
84+ srv := inboxServer(t, svc)
85+ missing := inboxGet(srv, "/-/nothing-here", false)
86+ require.Equal(t, 404, missing.Code)
87+
88+ anon := inboxGet(srv, "/-/inbox", false)
89+ assert.Equal(t, 404, anon.Code)
90+ assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one")
91+ assert.NotContains(t, anon.Body.String(), "Open merge requests")
92+
93+ // A bad cookie is anonymous as well.
94+ req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
95+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"})
96+ rec := httptest.NewRecorder()
97+ srv.ServeHTTP(rec, req)
98+ assert.Equal(t, 404, rec.Code)
99+}
100+
101+func TestInboxNotMountedWithoutTheService(t *testing.T) {
102+ t.Parallel()
103+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}})
104+ assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code)
105+}
106+
107+func TestInboxFilterAndEmptyStates(t *testing.T) {
108+ t.Parallel()
109+ svc := mocks.NewInboxService(t)
110+ ov := inboxOverview(t)
111+ filtered := ov
112+ filtered.MergeRequests, filtered.Issues = nil, nil
113+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil)
114+ srv := inboxServer(t, svc)
115+
116+ rec := inboxGet(srv, "/-/inbox?repo=gamma", true)
117+ require.Equal(t, 200, rec.Code)
118+ body := rec.Body.String()
119+ assert.Contains(t, body, "no open merge requests.")
120+ assert.Contains(t, body, "no open issues.")
121+ assert.Contains(t, body, `href="/-/inbox"`, "the link back to all")
122+
123+ assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page")
124+}
125+
126+func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) {
127+ t.Parallel()
128+ svc := mocks.NewInboxService(t)
129+ ov := inboxOverview(t)
130+ ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2}
131+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only
132+ srv := inboxServer(t, svc)
133+
134+ anon := inboxGet(srv, "/", false)
135+ require.Equal(t, 200, anon.Code)
136+ assert.NotContains(t, anon.Body.String(), "/-/inbox")
137+ assert.NotContains(t, anon.Body.String(), "Inbox")
138+
139+ owner := inboxGet(srv, "/", true)
140+ require.Equal(t, 200, owner.Code)
141+ assert.Contains(t, owner.Body.String(), `href="/-/inbox"`)
142+ assert.Contains(t, owner.Body.String(), `<span class="count">5</span>`)
143+}
144+
145+func TestInboxStreamIsTheOwnersAlone(t *testing.T) {
146+ t.Parallel()
147+ f := &watchProbe{release: make(chan struct{})}
148+ svc := mocks.NewInboxService(t)
149+ srv := newTestServer(t, Config{
150+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
151+ Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
152+ })
153+ ts := httptest.NewServer(srv)
154+ t.Cleanup(ts.Close)
155+
156+ unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
157+ want, _ := io.ReadAll(unknown.Body)
158+ for name, hdr := range map[string]map[string]string{
159+ "anonymous": nil,
160+ "another token": {"Cookie": CookieName + "=other"},
161+ } {
162+ resp, _ := open(t, ts, "topic=owner&view=inbox", hdr)
163+ body, _ := io.ReadAll(resp.Body)
164+ assert.Equal(t, unknown.StatusCode, resp.StatusCode, name)
165+ assert.Equal(t, string(want), string(body), name)
166+ assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name)
167+ }
168+ assert.Zero(t, f.opened.Load(), "no watch is opened for them")
169+
170+ resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
171+ assert.Equal(t, http.StatusOK, resp.StatusCode)
172+ assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
173+}
174+
175+func TestInboxLiveFragments(t *testing.T) {
176+ t.Parallel()
177+ svc := mocks.NewInboxService(t)
178+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
179+ srv := inboxServer(t, svc)
180+ ls := &liveStream{s: srv, view: "inbox"}
181+ frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182+ require.NoError(t, err)
183+ require.Len(t, frags, 5)
184+ assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185+ assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186+ assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
189+
190+ none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191+ require.NoError(t, err)
192+ assert.Empty(t, none, "a push is not inbox news")
193+}
194+
195+// withOwner is a context as the session middleware leaves it for the owner.
196+func withOwner(ctx context.Context) context.Context {
197+ return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner())
198+}
199+
200+// A bearer token is not a web session: the inbox answers it as it answers
201+// anyone else, with the unknown-page and unknown-topic responses.
202+func TestInboxRefusesABearerToken(t *testing.T) {
203+ t.Parallel()
204+ f := &watchProbe{release: make(chan struct{})}
205+ svc := mocks.NewInboxService(t) // never asked
206+ srv := newTestServer(t, Config{
207+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
208+ Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
209+ })
210+ bearer := map[string]string{"Authorization": "Bearer tok"}
211+
212+ // The page.
213+ missing := inboxGet(srv, "/-/nothing-here", false)
214+ req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
215+ req.Header.Set("Authorization", "Bearer tok")
216+ rec := httptest.NewRecorder()
217+ srv.ServeHTTP(rec, req)
218+ assert.Equal(t, missing.Code, rec.Code)
219+ assert.Equal(t, http.StatusNotFound, rec.Code)
220+ assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response")
221+
222+ // The stream.
223+ ts := httptest.NewServer(srv)
224+ t.Cleanup(ts.Close)
225+ unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
226+ want, _ := io.ReadAll(unknown.Body)
227+ resp, _ := open(t, ts, "topic=owner&view=inbox", bearer)
228+ body, _ := io.ReadAll(resp.Body)
229+ assert.Equal(t, unknown.StatusCode, resp.StatusCode)
230+ assert.Equal(t, string(want), string(body), "the unknown-topic response")
231+ assert.Zero(t, f.opened.Load(), "no watch is opened")
232+}
233+
234+// The owner's inbox stream ends once the session is invalid: the next event is
235+// not sent.
236+func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) {
237+ t.Parallel()
238+ f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
239+ sess, ended := newRevocable(t, "tok")
240+ svc := mocks.NewInboxService(t)
241+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe()
242+ srv := newTestServer(t, Config{
243+ Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
244+ Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{},
245+ })
246+ ts := httptest.NewServer(srv)
247+ t.Cleanup(ts.Close)
248+ resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
249+ require.Equal(t, http.StatusOK, resp.StatusCode)
250+ defer time.AfterFunc(5*time.Second, cancel).Stop()
251+ rd := bufio.NewReader(resp.Body)
252+ line := func() string {
253+ s, err := rd.ReadString('\n')
254+ if err != nil {
255+ return "EOF"
256+ }
257+ return strings.TrimRight(s, "\n")
258+ }
259+ require.Equal(t, ": connected", line())
260+ line()
261+ ev := func(id string) ports.WatchEvent {
262+ return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}
263+ }
264+ f.push <- ev("b-1")
265+ var seen []string
266+ for l := line(); !strings.HasPrefix(l, "data:"); l = line() {
267+ seen = append(seen, l)
268+ require.NotEqual(t, "EOF", l)
269+ }
270+ assert.Contains(t, seen, "id: b-1")
271+
272+ ended.Store(true)
273+ select {
274+ case f.push <- ev("b-2"):
275+ case <-time.After(5 * time.Second):
276+ t.Fatal("the event was not delivered to the web")
277+ }
278+ rest, _ := io.ReadAll(rd)
279+ assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended")
280+ require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed")
281+}
282+
283+// A repository event refreshes the inbox: issues turned off or a deletion
284+// remove its items from the fragments.
285+func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286+ t.Parallel()
287+ for name, ev := range map[string]domain.Event{
288+ "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
289+ "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
290+ } {
291+ t.Run(name, func(t *testing.T) {
292+ t.Parallel()
293+ after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}}
294+ svc := mocks.NewInboxService(t)
295+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil)
296+ srv := inboxServer(t, svc)
297+ ls := &liveStream{s: srv, view: "inbox"}
298+ frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299+ require.NoError(t, err)
300+ require.Len(t, frags, 5)
301+ all := ""
302+ for _, f := range frags {
303+ all += f.html
304+ }
305+ assert.Contains(t, all, `id="inbox-issues"`)
306+ assert.NotContains(t, all, "crash on start", "the issue is gone")
307+ assert.NotContains(t, all, "/beta", "the repository is gone from the filter")
308+ })
309+ }
310+}
A internal/adapters/web/issues.go +363 -0
@@ -0,0 +1,363 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// The issue pages (ADR 0031). They follow the merge request pages: they read
21+// through IssueService as the visitor of the request, anonymous sees the
22+// issues of public repositories, and only the signed-in owner gets the forms
23+// (issues_post.go handles them). A repository whose issues mode is off, a
24+// missing one and a private one for an anonymous visitor are the same 404.
25+//
26+// Routes:
27+//
28+// GET /<repo>/issues the list, open or closed (?state=), paged (?page=)
29+// GET /<repo>/issues/new the form that opens one (owner)
30+// GET /<repo>/issues/<n> the issue with its comments
31+//
32+// Issues and merge requests share their numbers, so "#n" may name either: with
33+// an IssueNumberResolver the page of the wrong kind redirects to the right one.
34+
35+// issuesBase is the URL of the issue pages of a repository.
36+func issuesBase(repo string) string { return repoHref(repo, "issues") }
37+
38+func issueHref(repo string, n int64, sub ...string) string {
39+ return repoHref(repo, append([]string{"issues", strconv.FormatInt(n, 10)}, sub...)...)
40+}
41+
42+// IssueRow is one issue in the list.
43+type IssueRow struct {
44+ Number string
45+ Href string
46+ Title string
47+ Author string
48+ State string
49+ When string
50+ WhenAt string
51+ Cursor bool
52+}
53+
54+// IssueListPage is the list of the issues of a repository.
55+type IssueListPage struct {
56+ Rows []IssueRow
57+ // Closed is true on the tab of the closed issues.
58+ Closed bool
59+ OpenHref, ClosedHref string
60+ Older, Newest string
61+ NewHref string // the form that opens an issue; "" unless the owner
62+ Live bool
63+}
64+
65+// IssueActions are the buttons of the owner on an issue.
66+type IssueActions struct {
67+ Action string // the URL that closes or reopens
68+ Label string
69+}
70+
71+// IssuePage is an issue with its comments.
72+type IssuePage struct {
73+ Number string
74+ Title string
75+ State string
76+ Open bool
77+ Opened Person
78+ Updated string
79+ UpdatedAt string
80+ // Desc is the body rendered as Markdown, "" when it is empty.
81+ Desc template.HTML
82+ Comments []CommentView
83+ Form *CommentForm
84+ Transition *IssueActions
85+ Live bool
86+}
87+
88+// IssueNewPage is the form that opens an issue.
89+type IssueNewPage struct {
90+ Action, Cancel string
91+}
92+
93+// issueRepo reads the repository of an issue page: one with issues off is
94+// as missing as one the caller cannot see.
95+func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, error) {
96+ d, err := s.loadRepo(ctx, rawName)
97+ if err != nil {
98+ return nil, err
99+ }
100+ if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff {
101+ return nil, errNotFound
102+ }
103+ return d, nil
104+}
105+
106+// externalAuthor is the label a visitor gave, as plain text, or "anonymous".
107+func (s *Server) externalAuthor(label string) string {
108+ if a := strings.TrimSpace(visibleText(label)); a != "" {
109+ return a
110+ }
111+ return "anonymous"
112+}
113+
114+func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
115+ row := IssueRow{
116+ Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number),
117+ Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author),
118+ }
119+ if i.External {
120+ row.Author = s.externalAuthor(i.Author)
121+ }
122+ ts := i.UpdatedAt
123+ if ts.IsZero() {
124+ ts = i.CreatedAt
125+ }
126+ row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
127+ return row
128+}
129+
130+// issueList lists the issues of one state, a page at a time.
131+//
132+//hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode
133+func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
135+ if err != nil {
136+ return nil, 0, err
137+ }
138+ state := domain.IssueStateOpen
139+ switch r.URL.Query().Get("state") {
140+ case "", "open":
141+ case "closed":
142+ state = domain.IssueStateClosed
143+ default:
144+ return nil, 0, errNotFound
145+ }
146+ token := r.URL.Query().Get("page")
147+ base := issuesBase(d.name())
148+ closed := state == domain.IssueStateClosed
149+ link := func(page string) string {
150+ q := url.Values{}
151+ if closed {
152+ q.Set("state", "closed")
153+ }
154+ if page != "" {
155+ q.Set("page", page)
156+ }
157+ if len(q) == 0 {
158+ return base
159+ }
160+ return base + "?" + q.Encode()
161+ }
162+
163+ res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{
164+ State: state, PageSize: pageSize, PageToken: token,
165+ })
166+ if err != nil {
167+ return nil, 0, err
168+ }
169+ p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
170+ for _, i := range res.Issues {
171+ p.Rows = append(p.Rows, s.issueRow(d.name(), i))
172+ }
173+ if len(p.Rows) > 0 {
174+ p.Rows[0].Cursor = true
175+ }
176+ if res.NextPageToken != "" {
177+ p.Older = link(res.NextPageToken)
178+ }
179+ if token != "" {
180+ p.Newest = link("")
181+ }
182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {
183+ p.NewHref = issuesBase(d.name()) + "/new"
184+ }
185+
186+ l := s.repoLayout(ctx, d, "issues")
187+ l.Title = "issues · " + d.name() + " · " + s.instance
188+ l.Crumbs[0].Current = false
189+ l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true})
190+ if token == "" {
191+ p.Live = true
192+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues")
193+ }
194+ l.HasRail = true
195+ return IssueList(l, p), http.StatusOK, nil
196+}
197+
198+// issueNew is the form that opens an issue; it is the owner's.
199+func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) {
200+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
201+ if err != nil {
202+ return nil, 0, err
203+ }
204+ if !SignedIn(ctx) {
205+ return nil, 0, errNotFound
206+ }
207+ l := s.repoLayout(ctx, d, "issues")
208+ l.Title = "new issue · " + d.name() + " · " + s.instance
209+ l.Crumbs[0].Current = false
210+ l.Crumbs = append(l.Crumbs,
211+ Crumb{Label: "issues", Href: issuesBase(d.name())},
212+ Crumb{Label: "new", Current: true},
213+ )
214+ l.HasRail = true
215+ return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil
216+}
217+
218+// issueShow shows an issue with its comments.
219+//
220+//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
221+func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222+ n, err := mrNumber(r)
223+ if err != nil {
224+ return nil, 0, err
225+ }
226+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
227+ if err != nil {
228+ return nil, 0, err
229+ }
230+ res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
231+ if err != nil {
232+ return nil, 0, err
233+ }
234+ i := res.Issue
235+ now := s.cfg.Now()
236+ signed := SignedIn(ctx)
237+ self := issueHref(d.name(), n)
238+
239+ p := IssuePage{
240+ Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
241+ State: string(i.State), Open: i.IsOpen(),
242+ }
243+ if body := i.Body.String(); strings.TrimSpace(body) != "" {
244+ var h template.HTML
245+ var err error
246+ if i.External {
247+ // Written by an anonymous visitor: the untrusted policy.
248+ h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
249+ } else {
250+ h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
251+ }
252+ if err != nil {
253+ return nil, 0, err
254+ }
255+ p.Desc = h
256+ }
257+ p.Opened.Name = s.who(i.Author)
258+ if i.External {
259+ // The label is the visitor's, not the owner's: shown as plain text.
260+ p.Opened.Name = s.externalAuthor(i.Author)
261+ }
262+ p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
263+ if !i.CreatedAt.IsZero() {
264+ p.Opened.Abs = absolute(i.CreatedAt)
265+ }
266+ p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
267+ for _, c := range res.Comments {
268+ v := CommentView{
269+ ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
270+ }
271+ v.When, v.WhenAt = whenOf(now, c.CreatedAt)
272+ if !c.CreatedAt.IsZero() {
273+ v.Abs = absolute(c.CreatedAt)
274+ }
275+ p.Comments = append(p.Comments, v)
276+ }
277+ if signed {
278+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue"}
279+ switch i.State {
280+ case domain.IssueStateOpen:
281+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
282+ case domain.IssueStateClosed:
283+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
284+ }
285+ }
286+
287+ l := s.repoLayout(ctx, d, "issues")
288+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
289+ l.Crumbs[0].Current = false
290+ l.Crumbs = append(l.Crumbs,
291+ Crumb{Label: "issues", Href: issuesBase(d.name())},
292+ Crumb{Label: p.Number, Href: self, Current: true},
293+ )
294+ p.Live = true
295+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
296+ l.HasRail = true
297+ return IssueShow(l, p), http.StatusOK, nil
298+}
299+
300+// pageOrRedirect is page(), except that a page that is not found may be the
301+// page of the other kind of the number: target then names where to send the
302+// visitor ("" when there is none).
303+func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
304+ plain := s.page(h)
305+ if s.cfg.IssueNumbers == nil {
306+ return plain
307+ }
308+ return func(w http.ResponseWriter, r *http.Request) {
309+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
310+ defer cancel()
311+ c, status, err := h(ctx, r)
312+ if errors.Is(err, domain.ErrNotFound) {
313+ to, terr := target(ctx, r)
314+ switch {
315+ case terr != nil && !errors.Is(terr, domain.ErrNotFound):
316+ s.fail(ctx, w, r, terr)
317+ return
318+ case terr == nil && to != "":
319+ seeOther(w, r, to)
320+ return
321+ }
322+ }
323+ if err != nil {
324+ s.fail(ctx, w, r, err)
325+ return
326+ }
327+ s.write(ctx, w, r, status, c)
328+ }
329+}
330+
331+// numberKind asks what the number of the path names.
332+func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
333+ n, err := mrNumber(r)
334+ if err != nil {
335+ return domain.NumberNone, 0, err
336+ }
337+ name, err := domain.ParseRepoName(r.PathValue("repo"))
338+ if err != nil {
339+ return domain.NumberNone, 0, err
340+ }
341+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
342+ return kind, n, err
343+}
344+
345+// issueToMR is where the number of an issue URL goes when it names a merge
346+// request.
347+func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
348+ kind, n, err := s.numberKind(ctx, r)
349+ if err != nil || kind != domain.NumberMergeRequest {
350+ return "", err
351+ }
352+ return mrHref(r.PathValue("repo"), n), nil
353+}
354+
355+// mrToIssue is where the number of a merge request URL goes when it names an
356+// issue.
357+func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
358+ kind, n, err := s.numberKind(ctx, r)
359+ if err != nil || kind != domain.NumberIssue {
360+ return "", err
361+ }
362+ return issueHref(r.PathValue("repo"), n), nil
363+}
A internal/adapters/web/issues.templ +187 -0
@@ -0,0 +1,187 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueList is the list of the issues of a repository, open or closed.
6+templ IssueList(l Layout, p IssueListPage) {
7+ @Shell(l, issueListRail(p)) {
8+ if p.Live {
9+ @LiveSlot("mr-notice")
10+ }
11+ <nav class="since" aria-label="State">
12+ <a
13+ href={ templ.SafeURL(p.OpenHref) }
14+ if !p.Closed {
15+ aria-current="page"
16+ }
17+ >open</a>
18+ <a
19+ href={ templ.SafeURL(p.ClosedHref) }
20+ if p.Closed {
21+ aria-current="page"
22+ }
23+ >closed</a>
24+ </nav>
25+ <div class="filter">
26+ <label for="issue-filter" class="sr">filter</label>
27+ <input
28+ id="issue-filter"
29+ type="search"
30+ data-filter=".mrs tbody tr"
31+ placeholder="Filter by title, author or state…"
32+ autocomplete="off"
33+ hx-on:input="ermite.filter(this)"
34+ />
35+ </div>
36+ <h2 class="rule-h">
37+ if p.Closed {
38+ <span>Closed</span>
39+ } else {
40+ <span>Open</span>
41+ }
42+ <span class="count">{ itoa(len(p.Rows)) }</span>
43+ </h2>
44+ @issueRows(p.Rows)
45+ if len(p.Rows) == 0 {
46+ if p.Closed {
47+ <p class="lede dim">no closed issues.</p>
48+ } else {
49+ <p class="lede dim">no open issues.</p>
50+ }
51+ }
52+ if p.Older != "" || p.Newest != "" {
53+ <nav class="pager" aria-label="Pages">
54+ if p.Newest != "" {
55+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
56+ } else {
57+ <span class="btn" aria-disabled="true">← Newest</span>
58+ }
59+ if p.Older != "" {
60+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
61+ } else {
62+ <span class="btn" aria-disabled="true">Older →</span>
63+ }
64+ </nav>
65+ }
66+ }
67+}
68+
69+templ issueRows(rows []IssueRow) {
70+ if len(rows) > 0 {
71+ <table class="list mrs" aria-label="Issues">
72+ <tbody data-rows>
73+ for _, i := range rows {
74+ <tr data-cursor?={ i.Cursor } data-state={ i.State }>
75+ <td class="c-n mute">{ i.Number }</td>
76+ <td class="c-state">{ i.State }</td>
77+ <td class="c-subject"><a href={ templ.SafeURL(i.Href) }>{ i.Title }</a></td>
78+ <td class="c-who dim">{ i.Author }</td>
79+ <td class="c-when nowrap dim">
80+ if i.WhenAt != "" {
81+ <time datetime={ i.WhenAt } title={ i.WhenAt }>{ i.When }</time>
82+ }
83+ </td>
84+ </tr>
85+ }
86+ </tbody>
87+ </table>
88+ }
89+}
90+
91+templ issueListRail(p IssueListPage) {
92+ <section aria-labelledby="r-states">
93+ <h2 id="r-states">States</h2>
94+ <dl class="kv">
95+ <dt>open</dt>
96+ <dd>being worked on</dd>
97+ <dt>closed</dt>
98+ <dd>done with, can be reopened</dd>
99+ </dl>
100+ </section>
101+ if p.NewHref != "" {
102+ <section aria-labelledby="r-new">
103+ <h2 id="r-new">Open one</h2>
104+ <a class="btn primary" href={ templ.SafeURL(p.NewHref) }>New issue</a>
105+ </section>
106+ }
107+}
108+
109+// IssueNew is the form that opens an issue.
110+templ IssueNew(l Layout, p IssueNewPage) {
111+ @Shell(l, issueNewRail()) {
112+ <header class="show mr-h">
113+ <h1>New issue</h1>
114+ </header>
115+ <form class="cform create" method="post" action={ templ.SafeURL(p.Action) }>
116+ <label for="issue-title">title</label>
117+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" autofocus/>
118+ <label for="issue-body">description</label>
119+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional"></textarea>
120+ <div class="f-btns">
121+ <button type="submit" class="btn primary">Create issue</button>
122+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
123+ </div>
124+ </form>
125+ }
126+}
127+
128+templ issueNewRail() {
129+ <section aria-labelledby="r-about">
130+ <h2 id="r-about">Issues</h2>
131+ <p class="dim">issues share their numbers with the merge requests of the repository.</p>
132+ </section>
133+}
134+
135+// IssueShow is an issue: its facts, its body, its comments and, for the
136+// owner, the form to comment and the button that closes or reopens it.
137+templ IssueShow(l Layout, p IssuePage) {
138+ @Shell(l, issueRail(p)) {
139+ <header class="show mr-h">
140+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
141+ <dl class="kv show-kv">
142+ <dt>state</dt>
143+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
144+ <dt>opened</dt>
145+ <dd>
146+ { p.Opened.Name }
147+ if p.Opened.WhenAt != "" {
148+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
149+ }
150+ </dd>
151+ if p.UpdatedAt != "" {
152+ <dt>updated</dt>
153+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
154+ }
155+ </dl>
156+ </header>
157+ if p.Live {
158+ @LiveSlot("mr-notice")
159+ }
160+ if p.Desc != "" {
161+ <article class="mr-desc prose" aria-label="description" hx-ignore>
162+ @templ.Raw(string(p.Desc))
163+ </article>
164+ }
165+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166+ for _, c := range p.Comments {
167+ @commentView(c, "")
168+ }
169+ if len(p.Comments) == 0 {
170+ <p class="dim">no comments yet.</p>
171+ }
172+ if p.Form != nil {
173+ @commentForm(*p.Form, "")
174+ }
175+ }
176+}
177+
178+templ issueRail(p IssuePage) {
179+ if p.Transition != nil {
180+ <section aria-labelledby="r-act">
181+ <h2 id="r-act">Issue</h2>
182+ <form method="post" action={ templ.SafeURL(p.Transition.Action) }>
183+ <button type="submit" class="btn">{ p.Transition.Label }</button>
184+ </form>
185+ </section>
186+ }
187+}
A internal/adapters/web/issues_form.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/clientlimit"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+)
20+
21+// The public issue form (ADR 0031). A visitor of a public repository whose
22+// issues mode is public writes an issue that awaits the owner's moderation.
23+//
24+// GET /<repo>/issues/new the form, with a proof of work solved by script
25+// POST /<repo>/issues/new the submission (the one write a visitor may make)
26+// GET /<repo>/issues/submitted the thanks page
27+//
28+// Every answer is "Cache-Control: no-store". The challenge token is never
29+// echoed back, and neither the address of the client nor the text of the
30+// submission is logged. A refusal for a bot-related reason is one generic page.
31+
32+// maxIssueFormBytes bounds the body of the submission: 10,000 characters are
33+// at most 40,000 bytes, plus the title, the name and the tokens.
34+const maxIssueFormBytes = 64 << 10
35+
36+// The fields of the form, and so the only ones read.
37+const (
38+ fieldTitle = "title"
39+ fieldBody = "body"
40+ fieldAuthor = "author"
41+ fieldToken = "token"
42+ fieldFormToken = "form_token"
43+ fieldSolution = "solution"
44+ fieldHoneypot = "website"
45+)
46+
47+var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot}
48+
49+// IssueFormPage is the public form.
50+type IssueFormPage struct {
51+ Action, Cancel string
52+ Token string
53+ FormToken string
54+ Nonce string
55+ Difficulty int
56+ MinSubmitAt int64 // unix milliseconds
57+ Worker string
58+ // Title, Body and Author are what the visitor already wrote.
59+ Title, Body, Author string
60+ // ErrorField is "title", "body" or "author" and Error the message to show
61+ // next to it; both empty without an error.
62+ ErrorField, Error string
63+ // Max lengths, in characters.
64+ MaxTitle, MaxBody, MaxAuthor int
65+}
66+
67+// issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in,
68+// else the public form, else the page that is not there.
69+func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) {
70+ if SignedIn(r.Context()) || s.cfg.IssueForm == nil {
71+ s.page(s.issueNew)(w, r)
72+ return
73+ }
74+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
75+ defer cancel()
76+ name, err := domain.ParseRepoName(r.PathValue("repo"))
77+ if err != nil {
78+ s.fail(ctx, w, r, err)
79+ return
80+ }
81+ page := IssueFormPage{}
82+ status, err := s.fillIssueForm(ctx, r, name, &page)
83+ if err != nil {
84+ s.formFailed(ctx, w, r, err, page.Cancel)
85+ return
86+ }
87+ s.writeIssueForm(ctx, w, r, status, name, page)
88+}
89+
90+// fillIssueForm asks a challenge for the client and fills what the form needs
91+// from it, leaving the text of the visitor as it is.
92+func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) {
93+ base := issuesBase(name.String())
94+ p.Cancel = base
95+ ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r))
96+ if err != nil {
97+ return 0, err
98+ }
99+ p.Action = base + "/new"
100+ p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty
101+ p.MinSubmitAt = ch.MinSubmitAt.UnixMilli()
102+ p.Worker = s.assets.urls.PowWorker
103+ p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen
104+ return http.StatusOK, nil
105+}
106+
107+// formClient is the key of the client for the limits of the submissions.
108+func (s *Server) formClient(r *http.Request) string {
109+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
110+}
111+
112+// writeIssueForm renders the form in the layout of the issues of the repository.
113+func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) {
114+ d, err := s.issueRepo(ctx, name.String())
115+ if err != nil {
116+ s.fail(ctx, w, r, err)
117+ return
118+ }
119+ l := s.repoLayout(ctx, d, "issues")
120+ l.Title = "new issue · " + d.name() + " · " + s.instance
121+ l.Crumbs[0].Current = false
122+ l.Crumbs = append(l.Crumbs,
123+ Crumb{Label: "issues", Href: issuesBase(d.name())},
124+ Crumb{Label: "new", Current: true},
125+ )
126+ l.HasRail = true
127+ l.IssueForm = true
128+ w.Header().Set("Cache-Control", noStore)
129+ s.write(ctx, w, r, status, IssueForm(l, p))
130+}
131+
132+// formFailed answers an error of the challenge or of the submission.
133+func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
134+ var retry *domain.SubmissionRetryError
135+ switch {
136+ case errors.Is(err, context.Canceled):
137+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText):
138+ // The same page, with the same headers, as every other page that is
139+ // not there (a POST has set no-store already).
140+ s.fail(ctx, w, r, domain.ErrNotFound)
141+ case errors.As(err, &retry):
142+ secs := int(retry.After.Round(time.Second).Seconds())
143+ if retry.After > time.Duration(secs)*time.Second {
144+ secs++
145+ }
146+ secs = max(secs, 1)
147+ w.Header().Set("Retry-After", strconv.Itoa(secs))
148+ mins := (secs + 59) / 60
149+ unit := " minutes."
150+ if mins == 1 {
151+ unit = " minute."
152+ }
153+ s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions",
154+ "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back)
155+ case errors.Is(err, domain.ErrSubmissionsPaused):
156+ s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back)
157+ case errors.Is(err, domain.ErrSubmissionRefused):
158+ s.refusal(ctx, w, r, http.StatusBadRequest, "not verified",
159+ "The submission could not be verified. Reload the page and try again.", back)
160+ default:
161+ s.internalFailure(ctx, w, r, err)
162+ }
163+}
164+
165+// refusal writes a state page for a visitor, never cached.
166+func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
167+ if back == "" {
168+ back = "/"
169+ }
170+ l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
171+ w.Header().Set("Cache-Control", noStore)
172+ s.write(ctx, w, r, status, State(l, StatePage{
173+ Code: itoa(status), Title: title, Lines: []string{line},
174+ Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
175+ }))
176+}
177+
178+// issueFormPost is POST /<repo>/issues/new.
179+func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) {
180+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
181+ defer cancel()
182+ w.Header().Set("Cache-Control", noStore)
183+ name, err := domain.ParseRepoName(r.PathValue("repo"))
184+ if err != nil {
185+ s.formFailed(ctx, w, r, err, "")
186+ return
187+ }
188+ base := issuesBase(name.String())
189+ r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes)
190+ if err := r.ParseForm(); err != nil {
191+ var tooBig *http.MaxBytesError
192+ if errors.As(err, &tooBig) {
193+ s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new")
194+ return
195+ }
196+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
197+ return
198+ }
199+ for _, f := range issueFormFields {
200+ if len(r.PostForm[f]) > 1 {
201+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
202+ return
203+ }
204+ }
205+ in := ports.IssueSubmission{
206+ Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution),
207+ FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot),
208+ Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)),
209+ Author: r.PostForm.Get(fieldAuthor),
210+ }
211+ err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in)
212+ if err == nil {
213+ seeOther(w, r, base+"/submitted")
214+ return
215+ }
216+ var te *domain.TextError
217+ if errors.As(err, &te) {
218+ s.formAgain(ctx, w, r, name, in, err, te)
219+ return
220+ }
221+ s.formFailed(ctx, w, r, err, base+"/new")
222+}
223+
224+// formAgain shows the form again after a text the visitor can fix: a new
225+// challenge (the first one is spent), the text kept, the message at its field.
226+func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) {
227+ p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author}
228+ field, _, _ := strings.Cut(err.Error(), ": ")
229+ switch field {
230+ case fieldTitle, fieldBody, fieldAuthor:
231+ default:
232+ field = fieldBody
233+ }
234+ label := field
235+ if field == fieldAuthor {
236+ label = "name"
237+ }
238+ p.ErrorField = field
239+ p.Error = "The " + label + " " + visibleText(te.Reason) + "."
240+ if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil {
241+ s.formFailed(ctx, w, r, cerr, p.Cancel)
242+ return
243+ }
244+ s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p)
245+}
246+
247+// issueSubmittedPage is GET /<repo>/issues/submitted.
248+func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250+ defer cancel()
251+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
252+ if err != nil {
253+ s.fail(ctx, w, r, err)
254+ return
255+ }
256+ if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
257+ s.fail(ctx, w, r, errNotFound)
258+ return
259+ }
260+ w.Header().Set("Cache-Control", noStore)
261+ s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name()))
262+}
263+
264+func (s *Server) submittedPage(repo string) templ.Component {
265+ l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}})
266+ return State(l, StatePage{
267+ Code: "ok", Title: "thanks",
268+ Lines: []string{"Thanks, your issue is waiting for moderation."},
269+ Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}},
270+ })
271+}
272+
273+func i64(n int64) string { return strconv.FormatInt(n, 10) }
274+
275+// runeCount is the length of s in characters, like the limits of the form.
276+func runeCount(s string) int { return utf8.RuneCountInString(s) }
A internal/adapters/web/issues_form.templ +67 -0
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueForm is the public form that proposes an issue to the owner. It needs
6+// JavaScript: the script (issue-form.js) solves a proof of work in a worker,
7+// so the form stays hidden until it runs, and the page says why without it.
8+templ IssueForm(l Layout, p IssueFormPage) {
9+ @Shell(l, issueFormRail()) {
10+ <header class="show mr-h">
11+ <h1>New issue</h1>
12+ </header>
13+ <noscript>
14+ <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p>
15+ </noscript>
16+ <form
17+ class="cform create"
18+ method="post"
19+ action={ templ.SafeURL(p.Action) }
20+ hidden
21+ data-issue-form
22+ data-nonce={ p.Nonce }
23+ data-difficulty={ itoa(p.Difficulty) }
24+ data-min-submit-at={ i64(p.MinSubmitAt) }
25+ data-worker={ p.Worker }
26+ autocomplete="off"
27+ >
28+ <input type="hidden" name="token" value={ p.Token }/>
29+ <input type="hidden" name="form_token" value={ p.FormToken }/>
30+ <input type="hidden" name="solution" value=""/>
31+ <label for="issue-title">title</label>
32+ <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>
33+ <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34+ if p.ErrorField == "title" {
35+ <p class="f-error" role="alert">{ p.Error }</p>
36+ }
37+ <label for="issue-body">description</label>
38+ <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>
39+ <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40+ if p.ErrorField == "body" {
41+ <p class="f-error" role="alert">{ p.Error }</p>
42+ }
43+ <label for="issue-author">name (optional, not verified)</label>
44+ <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>
45+ <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46+ if p.ErrorField == "author" {
47+ <p class="f-error" role="alert">{ p.Error }</p>
48+ }
49+ <div class="hp" aria-hidden="true">
50+ <label for="issue-website">Leave this field empty</label>
51+ <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/>
52+ </div>
53+ <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p>
54+ <div class="f-btns">
55+ <button type="submit" class="btn primary" disabled>Submit for moderation</button>
56+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
57+ </div>
58+ </form>
59+ }
60+}
61+
62+templ issueFormRail() {
63+ <section aria-labelledby="r-about">
64+ <h2 id="r-about">Before you write</h2>
65+ <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
66+ </section>
67+}
A internal/adapters/web/issues_form_templ.go +421 -0 generated file: collapsed
A internal/adapters/web/issues_form_test.go +345 -0
@@ -0,0 +1,345 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+type formRig struct {
24+ *issueRig
25+ form *mocks.IssueSubmissionService
26+}
27+
28+func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig {
29+ t.Helper()
30+ r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)}
31+ browse := mocks.NewBrowseService(t)
32+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
33+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
34+ r.srv = newTestServer(t, Config{
35+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
36+ IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
37+ })
38+ return r
39+}
40+
41+func challenge(n int) ports.IssueChallenge {
42+ return ports.IssueChallenge{
43+ Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18,
44+ FormToken: fmt.Sprintf("form-%d", n),
45+ IssuedAt: time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000),
46+ ExpiresAt: time.UnixMilli(1_700_000_600_000),
47+ }
48+}
49+
50+// post sends a submission as a browser of this site would.
51+func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder {
52+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
53+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
54+ for k, v := range hdr {
55+ req.Header.Set(k, v)
56+ }
57+ rec := httptest.NewRecorder()
58+ r.srv.ServeHTTP(rec, req)
59+ return rec
60+}
61+
62+var fromSite = map[string]string{"Origin": testPublicURL}
63+
64+func goodForm() url.Values {
65+ return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}}
66+}
67+
68+func TestIssueFormGet(t *testing.T) {
69+ t.Parallel()
70+ r := newFormRig(t, domain.IssuesPublic)
71+ r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once()
72+
73+ rec := r.do("GET", "/proj/issues/new", false, nil)
74+ require.Equal(t, 200, rec.Code, rec.Body.String())
75+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
76+ body := rec.Body.String()
77+ for _, want := range []string{
78+ `<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.",
79+ `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80+ `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81+ `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82+ `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
83+ `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84+ `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85+ `/-/static/js/issue-form.`,
86+ } {
87+ assert.Contains(t, body, want)
88+ }
89+ assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units")
90+ assert.NotContains(t, body, `name="website" type="hidden"`)
91+ assert.NotContains(t, body, `type="hidden" name="website"`)
92+ noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
93+ assert.NotContains(t, noscript, "<form", "no usable form without JavaScript")
94+ assert.NotContains(t, body, "style=", "no inline style")
95+ for _, tag := range strings.Split(body, "<script")[1:] {
96+ head, _, _ := strings.Cut(tag, ">")
97+ assert.Contains(t, head, `src="`, "no inline script")
98+ }
99+}
100+
101+func TestIssueFormIsNotThereUnlessPublic(t *testing.T) {
102+ t.Parallel()
103+ want := ""
104+ for i, tc := range []struct {
105+ name string
106+ mode domain.IssuesMode
107+ err error
108+ }{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} {
109+ r := newFormRig(t, tc.mode)
110+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe()
111+ path := "/proj/issues/new"
112+ if tc.name == "unknown" {
113+ path = "/nope/issues/new"
114+ }
115+ rec := r.do("GET", path, false, nil)
116+ assert.Equal(t, 404, rec.Code, tc.name)
117+ if i == 0 {
118+ want = rec.Body.String()
119+ continue
120+ }
121+ assert.Equal(t, want, rec.Body.String(), tc.name+": the same page")
122+ }
123+}
124+
125+func TestIssueFormOwnerKeepsHisForm(t *testing.T) {
126+ t.Parallel()
127+ r := newFormRig(t, domain.IssuesPublic) // no Challenge expected
128+ rec := r.do("GET", "/proj/issues/new", true, nil)
129+ require.Equal(t, 200, rec.Code)
130+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
131+ assert.NotContains(t, rec.Body.String(), "data-issue-form")
132+}
133+
134+func TestIssueFormGetPausedAndLimited(t *testing.T) {
135+ t.Parallel()
136+ r := newFormRig(t, domain.IssuesPublic)
137+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once()
138+ rec := r.do("GET", "/proj/issues/new", false, nil)
139+ assert.Equal(t, 503, rec.Code)
140+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
141+ assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.")
142+}
143+
144+func TestIssueFormPostOutcomes(t *testing.T) {
145+ t.Parallel()
146+ generic := "The submission could not be verified. Reload the page and try again."
147+ tests := []struct {
148+ name string
149+ err error
150+ status int
151+ body string
152+ hdr map[string]string
153+ }{
154+ {"not found", domain.ErrNotFound, 404, "no such page", nil},
155+ {"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}},
156+ {"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}},
157+ {"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil},
158+ {"invalid", domain.ErrSubmissionInvalid, 400, generic, nil},
159+ {"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil},
160+ }
161+ for _, tc := range tests {
162+ t.Run(tc.name, func(t *testing.T) {
163+ t.Parallel()
164+ r := newFormRig(t, domain.IssuesPublic)
165+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once()
166+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
167+ assert.Equal(t, tc.status, rec.Code, rec.Body.String())
168+ assert.Contains(t, rec.Body.String(), tc.body)
169+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
170+ for k, v := range tc.hdr {
171+ assert.Equal(t, v, rec.Header().Get(k))
172+ }
173+ assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed")
174+ for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} {
175+ assert.NotContains(t, rec.Body.String(), leak, "no detail")
176+ }
177+ })
178+ }
179+}
180+
181+// TestIssueFormPostInvalidTokenLooksTheSame: the use case answers an invalid
182+// submission the same whatever the client's ban, limit or the queue; the page
183+// must not differ either.
184+func TestIssueFormPostInvalidTokenLooksTheSame(t *testing.T) {
185+ t.Parallel()
186+ var first *httptest.ResponseRecorder
187+ for _, state := range []string{"banned", "rate limited", "queue full"} {
188+ r := newFormRig(t, domain.IssuesPublic)
189+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(domain.ErrSubmissionInvalid).Once()
190+ f := goodForm()
191+ f.Set("token", "garbage")
192+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
193+ assert.Equal(t, 400, rec.Code, state)
194+ if first == nil {
195+ first = rec
196+ continue
197+ }
198+ assert.Equal(t, first.Body.String(), rec.Body.String(), state)
199+ assert.Equal(t, first.Header().Get("Retry-After"), rec.Header().Get("Retry-After"), state)
200+ }
201+}
202+
203+func TestIssueFormPostSuccess(t *testing.T) {
204+ t.Parallel()
205+ r := newFormRig(t, domain.IssuesPublic)
206+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{
207+ Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex",
208+ }).Return(nil).Once()
209+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
210+ assert.Equal(t, 303, rec.Code)
211+ assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location"))
212+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
213+
214+ thanks := r.do("GET", "/proj/issues/submitted", false, nil)
215+ assert.Equal(t, 200, thanks.Code)
216+ assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control"))
217+ assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.")
218+}
219+
220+func TestIssueSubmittedPageOnlyForPublic(t *testing.T) {
221+ t.Parallel()
222+ r := newFormRig(t, domain.IssuesOwner)
223+ assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code)
224+}
225+
226+func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) {
227+ t.Parallel()
228+ r := newFormRig(t, domain.IssuesPublic)
229+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
230+ Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once()
231+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once()
232+ f := goodForm()
233+ f.Set("title", "<b>mine</b>")
234+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
235+ require.Equal(t, 422, rec.Code, rec.Body.String())
236+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
237+ body := rec.Body.String()
238+ assert.Contains(t, body, "&lt;b&gt;mine&lt;/b&gt;", "the title is kept")
239+ assert.Contains(t, body, "line1\nline2", "the body is kept")
240+ assert.Contains(t, body, `value="Alex"`)
241+ assert.Contains(t, body, "The title is empty.")
242+ assert.Contains(t, body, `name="token" value="tok-2.sig"`, "a new challenge")
243+ assert.NotContains(t, body, "tok-1.sig", "the spent token is not echoed")
244+}
245+
246+func TestIssueFormPostLimits(t *testing.T) {
247+ t.Parallel()
248+ t.Run("oversize", func(t *testing.T) {
249+ t.Parallel()
250+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
251+ big := url.Values{"title": {"x"}, "body": {strings.Repeat("a", maxIssueFormBytes+10)}}
252+ rec := r.post("/proj/issues/new", fromSite, big.Encode())
253+ assert.Equal(t, 413, rec.Code)
254+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
255+ })
256+ t.Run("duplicated fields", func(t *testing.T) {
257+ t.Parallel()
258+ for _, f := range issueFormFields {
259+ r := newFormRig(t, domain.IssuesPublic)
260+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()+"&"+f+"=again")
261+ assert.Equal(t, 400, rec.Code, f)
262+ assert.Contains(t, rec.Body.String(), "could not be verified", f)
263+ }
264+ })
265+}
266+
267+// The gate still demands the Origin of this site for the anonymous POST.
268+func TestIssueFormPostNeedsTheOrigin(t *testing.T) {
269+ t.Parallel()
270+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
271+ for name, hdr := range map[string]map[string]string{
272+ "missing": nil,
273+ "cross origin": {"Origin": "https://evil.example"},
274+ "null": {"Origin": "null"},
275+ "same host, other scheme": {"Origin": "http://forge.example"},
276+ } {
277+ rec := r.post("/proj/issues/new", hdr, goodForm().Encode())
278+ assert.Equal(t, 403, rec.Code, name)
279+ }
280+}
281+
282+func TestIssueFormPostOnOwnerModeIsNotFound(t *testing.T) {
283+ t.Parallel()
284+ r := newFormRig(t, domain.IssuesOwner)
285+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once()
286+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
287+ assert.Equal(t, 404, rec.Code)
288+}
289+
290+// anonymousPost opens exactly /<repo>/issues/new and nothing else.
291+func TestAnonymousPostOpensOnlyTheIssueForm(t *testing.T) {
292+ t.Parallel()
293+ r := newFormRig(t, domain.IssuesPublic)
294+ for _, p := range []string{
295+ "/x/issues/new/extra", "/x/issues/123", "/x/issues", "/x/issues/new/", "/x/issues/123/close", "/x/issues/new/close",
296+ "/x%2Fissues/new", "/x%2Fy/issues/new", "/%2Fissues/new", "/issues/new", "//issues/new", "/X/issues/new",
297+ "/a/b/issues/new", "/x.git/issues/new", "/../issues/new", "/-/inbox/pending/x/y/approve", "/x/merge-requests",
298+ "/x/issues%2Fnew", "/x/issues/new%2Fextra", "/x/issues/%6Eew",
299+ } {
300+ u := &url.URL{Path: p}
301+ if dec, err := url.PathUnescape(p); err == nil {
302+ u = &url.URL{Path: dec, RawPath: p}
303+ }
304+ req := &http.Request{Method: http.MethodPost, URL: u}
305+ got := r.srv.anonymousPost(req)
306+ want := false
307+ assert.Equal(t, want, got, p)
308+ }
309+ for _, p := range []string{"/x/issues/new", "/proj/issues/new", "/a.b_c-d/issues/new"} {
310+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: p}}
311+ assert.True(t, r.srv.anonymousPost(req), p)
312+ req.Method = http.MethodPut
313+ assert.False(t, r.srv.anonymousPost(req), p+" PUT")
314+ }
315+ // Without the use case the path is not opened at all.
316+ plain := newIssueRig(t, domain.IssuesPublic, false)
317+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/proj/issues/new"}}
318+ assert.False(t, plain.srv.anonymousPost(req))
319+}
320+
321+func TestIssueListShowsNewIssueToVisitorsOfPublicRepos(t *testing.T) {
322+ t.Parallel()
323+ for mode, want := range map[domain.IssuesMode]bool{domain.IssuesPublic: true, domain.IssuesOwner: false} {
324+ r := newFormRig(t, mode)
325+ r.issues.EXPECT().List(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.IssuePage{}, nil)
326+ rec := r.do("GET", "/proj/issues", false, nil)
327+ require.Equal(t, 200, rec.Code)
328+ assert.Equal(t, want, strings.Contains(rec.Body.String(), `href="/proj/issues/new"`), string(mode))
329+ }
330+}
331+
332+func TestExternalIssueIsRenderedUntrusted(t *testing.T) {
333+ t.Parallel()
334+ r := newFormRig(t, domain.IssuesPublic)
335+ i := testIssue(t, 5, domain.IssueStateOpen, "bug", "[a](javascript:alert(1)) [b](https://example.org/) <script>x</script>")
336+ i.External, i.Author = true, "Eve <b>"
337+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{Issue: i}, nil)
338+ rec := r.do("GET", "/proj/issues/5", false, nil)
339+ require.Equal(t, 200, rec.Code)
340+ body := rec.Body.String()
341+ assert.NotContains(t, body, "javascript:")
342+ assert.NotContains(t, body, "<script>x")
343+ assert.Contains(t, body, `rel="nofollow noopener noreferrer ugc"`)
344+ assert.Contains(t, body, "Eve &lt;b&gt;")
345+}
A internal/adapters/web/issues_post.go +86 -0
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// The writes of the issue pages follow those of the merge request pages
15+// (mr_post.go): each is a POST that passed the gate (an Origin of this site
16+// and the owner's session), calls the use case as the owner and answers 303
17+// to the page that shows the result, "Cache-Control: no-store".
18+
19+// issueAction wraps a POST handler like mrAction does.
20+func (s *Server) issueAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
21+ return s.mrAction(h)
22+}
23+
24+// issueCreate is POST /<repo>/issues: it opens an issue.
25+func (s *Server) issueCreate(ctx context.Context, r *http.Request) (string, string, error) {
26+ repo := r.PathValue("repo")
27+ back := issuesBase(repo) + "/new"
28+ name, err := domain.ParseRepoName(repo)
29+ if err != nil {
30+ return "", back, err
31+ }
32+ i, err := s.cfg.Issues.Create(ctx, principal(ctx), name, ports.CreateIssueParams{
33+ Title: r.PostFormValue("title"), Body: normalizeNewlines(r.PostFormValue("body")),
34+ })
35+ if err != nil {
36+ return "", back, err
37+ }
38+ return issueHref(repo, i.Number), back, nil
39+}
40+
41+// issueComment is POST /<repo>/issues/<n>/comments.
42+func (s *Server) issueComment(ctx context.Context, r *http.Request) (string, string, error) {
43+ repo := r.PathValue("repo")
44+ n, err := number(r)
45+ if err != nil {
46+ return "", "", err
47+ }
48+ back := issueHref(repo, n)
49+ name, err := domain.ParseRepoName(repo)
50+ if err != nil {
51+ return "", back, err
52+ }
53+ c, err := s.cfg.Issues.AddComment(ctx, principal(ctx), name, n, normalizeNewlines(r.PostFormValue("text")))
54+ if err != nil {
55+ return "", back, err
56+ }
57+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
58+}
59+
60+// issueClose is POST /<repo>/issues/<n>/close.
61+func (s *Server) issueClose(ctx context.Context, r *http.Request) (string, string, error) {
62+ return s.issueTransition(ctx, r, s.cfg.Issues.Close)
63+}
64+
65+// issueReopen is POST /<repo>/issues/<n>/reopen.
66+func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, string, error) {
67+ return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
68+}
69+
70+//hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which
71+func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
72+ repo := r.PathValue("repo")
73+ n, err := number(r)
74+ if err != nil {
75+ return "", "", err
76+ }
77+ back := issueHref(repo, n)
78+ name, err := domain.ParseRepoName(repo)
79+ if err != nil {
80+ return "", back, err
81+ }
82+ if _, err := do(ctx, principal(ctx), name, n); err != nil {
83+ return "", back, err
84+ }
85+ return back, back, nil
86+}
A internal/adapters/web/issues_templ.go +885 -0 generated file: collapsed
A internal/adapters/web/issues_test.go +332 -0
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
A internal/adapters/web/jstest/issue-form.test.mjs +183 -0
@@ -0,0 +1,183 @@
1+// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2+// and the pure core of the form (static/js/issue-form.js). Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { createHash } from 'node:crypto';
6+import { test } from 'node:test';
7+
8+globalThis.window = globalThis;
9+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10+await import('../static/js/pow-worker.js');
11+await import('../static/js/issue-form.js');
12+const P = globalThis.ermitePow;
13+const C = window.ermiteIssueForm.core;
14+
15+// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16+// nonce at this difficulty, found by the server's own powHash.
17+const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18+
19+test('leadingZeroBits counts the zero bits at the start', () => {
20+ assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26+ assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27+ assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28+});
29+
30+test('b64urlDecode reads the nonce the server sends', () => {
31+ assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32+ assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33+});
34+
35+test('the hashed bytes are nonce || ":" || decimal solution', () => {
36+ const nonce = P.b64urlDecode(VECTOR.nonce);
37+ const got = P.input(nonce, 2491);
38+ assert.equal(got.length, 16 + 1 + 4);
39+ assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40+ assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41+});
42+
43+test('the solver finds the very solution of the Go vector', async () => {
44+ const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45+ assert.equal(sol, VECTOR.solution);
46+ const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47+ assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48+ // and no smaller counter works
49+ for (let n = 0; n < Number(sol); n++) {
50+ const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51+ assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52+ }
53+});
54+
55+test('the solver is independent of the batch size and reports progress', async () => {
56+ const nonce = P.b64urlDecode(VECTOR.nonce);
57+ const seen = [];
58+ const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59+ assert.equal(sol, VECTOR.solution);
60+ assert.deepEqual(seen.slice(0, 2), [100, 200]);
61+ const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62+ assert.equal(stopped, null, 'stopped by the caller');
63+});
64+
65+test('counterClass: warn from 90 %, over above the maximum', () => {
66+ assert.equal(C.counterClass(0, 120), '');
67+ assert.equal(C.counterClass(107, 120), '');
68+ assert.equal(C.counterClass(108, 120), 'warn');
69+ assert.equal(C.counterClass(120, 120), 'warn');
70+ assert.equal(C.counterClass(121, 120), 'over');
71+ assert.equal(C.counterClass(9000, 10000), 'warn');
72+ assert.equal(C.counterClass(8999, 10000), '');
73+ assert.equal(C.counterClass(41, 40), 'over');
74+});
75+
76+test('length counts characters, not UTF-16 units', () => {
77+ assert.equal(C.length('abc'), 3);
78+ assert.equal(C.length('a😀b'), 3);
79+ assert.equal(C.length(''), 0);
80+});
81+
82+test('state: the button opens only when solved, waited and not over', () => {
83+ const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84+ const s = (o) => C.state({ ...base, ...o });
85+ assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86+ assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87+ assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88+ assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89+ assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90+ assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91+ assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92+ assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93+ assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94+ assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95+ assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96+});
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
M internal/adapters/web/keys.templ +1 -0
@@ -27,6 +27,7 @@ templ Keys() {
2727 k-c from:body -> ermite.tab('c');
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
3031 k-next from:body -> ermite.nextNote();
3132 k-split from:body -> ermite.layout('split');
3233 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1
@@ -34,7 +34,7 @@ func Keys() templ.Component {
3434 templ_7745c5c3_Var1 = templ.NopComponent
3535 }
3636 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
3838 if templ_7745c5c3_Err != nil {
3939 return templ_7745c5c3_Err
4040 }
M internal/adapters/web/markdown.go +74 -4
@@ -62,9 +62,25 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6262 // #fragment links chosen, so that several documents on one page (the summaries
6363 // of a run) do not share ids.
6464 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
65- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
65+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix})
66+}
67+
68+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
69+// approved from the public form). On top of the policy of render, which already
70+// drops raw HTML and never loads an image:
71+// - only http, https and mailto links are kept, every one with
72+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
73+// every other scheme lose their link and keep the text;
74+// - nothing points into the repository.
75+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
76+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true})
77+ return h, err
78+}
79+
80+func (m *markdown) renderPolicy(source []byte, pol linkPolicy) (template.HTML, []Heading, error) {
81+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
6682 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
67- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
83+ pol.source = source
6884 pol.apply(doc)
6985
7086 var buf bytes.Buffer
@@ -131,6 +147,20 @@ type linkPolicy struct {
131147 source []byte
132148 // idPrefix is the prefix of the heading ids that #fragment links point at.
133149 idPrefix string
150+ // untrusted is the policy of text from an anonymous visitor.
151+ untrusted bool
152+}
153+
154+const (
155+ relExternal = "nofollow noopener noreferrer"
156+ relUntrusted = "nofollow noopener noreferrer ugc"
157+)
158+
159+func (p linkPolicy) rel() []byte {
160+ if p.untrusted {
161+ return []byte(relUntrusted)
162+ }
163+ return []byte(relExternal)
134164 }
135165
136166 func (p linkPolicy) apply(doc ast.Node) {
@@ -163,13 +193,17 @@ func (p linkPolicy) apply(doc ast.Node) {
163193 l.Destination = []byte(dest)
164194 l.Title = nil
165195 if kind == external {
166- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
196+ l.SetAttributeString("rel", p.rel())
167197 }
168198 }
169199 for _, n := range autos {
170200 a := n.(*ast.AutoLink)
171201 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
172- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
202+ a.SetAttributeString("rel", p.rel())
203+ continue
204+ }
205+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
206+ a.SetAttributeString("rel", p.rel())
173207 continue
174208 }
175209 // An email address or another scheme: the label as plain text.
@@ -209,6 +243,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
209243 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
210244 return "", dropped
211245 }
246+ if p.untrusted {
247+ return untrustedTarget(dest)
248+ }
212249 if strings.HasPrefix(dest, "#") {
213250 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
214251 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -256,6 +293,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
256293 return href, internal
257294 }
258295
296+// untrustedTarget keeps absolute http and https links with a host, and mailto
297+// links to one plain address.
298+func untrustedTarget(dest string) (string, linkKind) {
299+ if strings.HasPrefix(dest, "//") {
300+ return "", dropped
301+ }
302+ u, err := url.Parse(dest)
303+ if err != nil {
304+ return "", dropped
305+ }
306+ switch strings.ToLower(u.Scheme) {
307+ case "http", "https":
308+ if u.Host == "" || u.User != nil {
309+ return "", dropped
310+ }
311+ return u.String(), external
312+ case "mailto":
313+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
314+ return "", dropped
315+ }
316+ return "mailto:" + u.Opaque, external
317+ }
318+ return "", dropped
319+}
320+
321+// validMailbox tells whether s is a bare address: no space, quote, angle
322+// bracket, percent sign, comma or second at sign.
323+func validMailbox(s string) bool {
324+ local, host, ok := strings.Cut(s, "@")
325+ return ok && local != "" && host != "" && len(s) <= 254 &&
326+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
327+}
328+
259329 func isWebURL(s string) bool {
260330 u, err := url.Parse(s)
261331 if err != nil || u.Host == "" {
M internal/adapters/web/markdown_test.go +67 -0
@@ -136,3 +136,70 @@ func TestMarkdownGFM(t *testing.T) {
136136 assert.Contains(t, out, "<del>gone</del>")
137137 assert.Contains(t, out, `type="checkbox"`)
138138 }
139+
140+func renderUntrustedMD(t *testing.T, src string) string {
141+ t.Helper()
142+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143+ require.NoError(t, err)
144+ return string(h)
145+}
146+
147+// An issue from the public form is rendered with the strictest policy.
148+func TestMarkdownUntrusted(t *testing.T) {
149+ t.Parallel()
150+ tests := []struct {
151+ name, src string
152+ absent []string
153+ present []string
154+ }{
155+ {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156+ {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157+ {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158+ {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159+ {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160+ {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161+ {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162+ {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163+ []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164+ {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165+ {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166+ {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167+ {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168+ {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169+ {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170+ {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171+ {"image is its alt text", "![pwn](https://evil.test/p.png)\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172+ {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173+ {"entity-encoded scheme", "[x](&#106;avascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ t.Parallel()
178+ out := renderUntrustedMD(t, tt.src)
179+ for _, a := range tt.absent {
180+ assert.NotContains(t, out, a)
181+ }
182+ for _, p := range tt.present {
183+ assert.Contains(t, out, p)
184+ }
185+ })
186+ }
187+}
188+
189+func TestMarkdownUntrustedBigInputs(t *testing.T) {
190+ t.Parallel()
191+ long := strings.Repeat("a", 10000)
192+ out := renderUntrustedMD(t, long)
193+ assert.Contains(t, out, long)
194+
195+ rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196+ out = renderUntrustedMD(t, rows)
197+ assert.NotContains(t, out, "<b>")
198+ assert.NotContains(t, out, "javascript")
199+ assert.NotContains(t, out, "style=")
200+
201+ deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202+ assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203+
204+ assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205+}
M internal/adapters/web/mr_post.go +8 -1
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
3131 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
3232 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
3333 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))
34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
38+ if strings.Contains(r.URL.Path, "/issues") {
39+ back, what = issuesBase(r.PathValue("repo")), "an issue"
40+ }
41+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
3542 return false
3643 }
3744 return true
M internal/adapters/web/overlays.templ +1 -1
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
2626 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
3030 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3131 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
3232 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
M internal/adapters/web/overlays_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/pages.templ +8 -0
@@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) {
99 <div class="home" data-layout="cards">
1010 <section class="home-repos" aria-label="Repositories">
1111 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
1220 <div class="filter">
1321 <label for="home-filter" class="sr">filter</label>
1422 <input
M internal/adapters/web/pages_templ.go +409 -363 generated file: collapsed
M internal/adapters/web/repo.go +5 -0
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131131 // layout is the Layout of the pages of a repository: the sections, the ref
132132 // switcher on the head ref and the ":" targets. The caller sets the title,
133133 // the crumbs, the status and the rail.
134+//
135+//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
134136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135137 l := s.base()
136138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156158 l.Sections = append(l.Sections, mrs)
157159 }
160+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162+ }
158163 l.Sections = s.withActions(l.Sections, d.name(), section)
159164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160165
M internal/adapters/web/session.go +16 -1
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196196 if r.Method != http.MethodPost {
197197 return false
198198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200215 }
201216
202217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
M internal/adapters/web/shell.templ +3 -0
@@ -33,6 +33,9 @@ templ Shell(l Layout, rail templ.Component) {
3333 if l.MR {
3434 <script src={ l.Assets.MR } defer></script>
3535 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
3639 </head>
3740 <body
3841 data-ref={ l.RefPath }
M internal/adapters/web/shell_templ.go +180 -161 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +25 -0
@@ -173,6 +173,22 @@
173173 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
174174 }
175175
176+ /* the public issue form: counters, field errors, the honeypot out of sight */
177+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
178+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
179+ .cform output.over { color: var(--red); font-weight: 700; }
180+ .cform .f-error { color: var(--red); margin: 0; }
181+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
182+
183+ /* the inbox: items awaiting moderation, shown as text */
184+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
185+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
186+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
187+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
188+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
189+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
190+ .pending .p-btns form { margin: 0; }
191+
176192 .list.mrs {
177193 & .c-n { width: 64px; }
178194 & .c-state { width: 110px; }
@@ -186,6 +202,15 @@
186202 }
187203 #mr-notice:empty { display: none; }
188204
205+ /* the owner's inbox: counters, repository filter, then the two lists */
206+ .inbox {
207+ display: flex; flex-direction: column; gap: var(--s4);
208+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
209+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
210+ & .since { align-self: flex-start; max-width: 100%; }
211+ }
212+ .inbox-btn { gap: var(--s2); }
213+
189214 @media (max-width: 700px) {
190215 .list.mrs .c-who { display: none; }
191216 }
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -555,11 +555,11 @@
555555
556556 // 1. Keys.
557557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559559 // the merge request page (mr.js): the merge menu, mark read
560560 M: 'merge', R: 'read' };
561561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563563 let pending = 0;
564564 let pendingTimer = 0;
565565 addEventListener('keydown', (e) => {
A internal/adapters/web/static/js/issue-form.js +175 -0
@@ -0,0 +1,175 @@
1+// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2+// The server sends a proof of work with the form; this script starts the
3+// worker (pow-worker.js) when the page loads, so it solves while the visitor
4+// writes, shows the length counters, and enables the button once the solution
5+// is found and the minimum fill time has passed. Without scripts the form
6+// stays hidden (the page says why).
7+//
8+// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9+window.ermiteIssueForm = (() => {
10+ const core = {
11+ // counterClass is the class of a length counter: "over" above max, "warn"
12+ // from 90 % of it, else none.
13+ counterClass(n, max) {
14+ if (n > max) return 'over';
15+ if (n * 10 >= max * 9) return 'warn';
16+ return '';
17+ },
18+ // length counts code points, like the server.
19+ length(s) { return [...String(s)].length; },
20+ // serverLength is the length the server will see for field ('title',
21+ // 'body' or 'author'): NFC, control and bidi characters removed (body
22+ // keeps \n and \t), title and author trimmed (domain.cleanText).
23+ serverLength(field, value) {
24+ const multiline = field === 'body';
25+ let s = String(value).normalize('NFC');
26+ if (multiline) s = s.replace(/\r\n/g, '\n');
27+ let out = '';
28+ for (const ch of s) {
29+ const c = ch.codePointAt(0);
30+ if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32+ if (c === 0x200e || c === 0x200f || c === 0x061c ||
33+ (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34+ out += ch;
35+ }
36+ if (!multiline) out = out.trim();
37+ return [...out].length;
38+ },
39+ draftKey(path) { return 'ermite:issue-draft:' + path; },
40+ // saveDraft keeps the text fields in storage (never the solution).
41+ saveDraft(storage, key, draft) {
42+ try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43+ },
44+ // restoreDraft returns the saved draft, or null, and clears it.
45+ restoreDraft(storage, key) {
46+ try {
47+ const raw = storage.getItem(key);
48+ if (raw === null || raw === undefined) return null;
49+ storage.removeItem(key);
50+ const d = JSON.parse(raw);
51+ if (!d || typeof d !== 'object') return null;
52+ return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53+ } catch (_) { return null; }
54+ },
55+ // onPageShow decides what to do on pageshow: a page restored from the
56+ // back/forward cache has a challenge that may be spent or expired.
57+ onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58+ // state is what the button and the status line show.
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61+ if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62+ if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63+ if (sending) return { disabled: true, status: 'Sending…' };
64+ if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65+ if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66+ if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
68+ return { disabled: false, status: 'Ready.' };
69+ },
70+ };
71+
72+ function init(form) {
73+ const $ = (sel) => form.querySelector(sel);
74+ const button = $('button[type="submit"]');
75+ const status = $('[data-status]');
76+ const solution = $('input[name="solution"]');
77+ const minAt = Number(form.dataset.minSubmitAt) || 0;
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79+ let timer = null;
80+
81+ function render() {
82+ const s = core.state({ ...ctx, now: Date.now(), minAt });
83+ button.disabled = s.disabled;
84+ if (status) status.textContent = s.status;
85+ clearTimeout(timer);
86+ if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87+ }
88+
89+ const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90+ const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91+ const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92+ if (storage) {
93+ const d = core.restoreDraft(storage, draftKey);
94+ if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95+ }
96+ window.addEventListener('pageshow', (e) => {
97+ if (core.onPageShow(e.persisted) !== 'reload') return;
98+ // The old challenge may be spent or expired: never reuse its solution.
99+ if (button) button.disabled = true;
100+ if (storage) {
101+ core.saveDraft(storage, draftKey, {
102+ title: fields.title ? fields.title.value : '',
103+ body: fields.body ? fields.body.value : '',
104+ author: fields.author ? fields.author.value : '',
105+ });
106+ }
107+ window.location.reload();
108+ });
109+
110+ const outs = [...form.querySelectorAll('output[data-for]')];
111+ for (const out of outs) {
112+ const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
114+ const max = Number(out.dataset.max);
115+ const update = () => {
116+ const n = core.serverLength(name, field.value);
117+ out.textContent = n + ' / ' + max;
118+ out.className = core.counterClass(n, max);
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
121+ render();
122+ };
123+ field.addEventListener('input', update);
124+ update();
125+ }
126+
127+ form.addEventListener('submit', (e) => {
128+ if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129+ ctx.sending = true;
130+ render();
131+ });
132+
133+ form.hidden = false;
134+ form.removeAttribute('hidden');
135+
136+ if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137+ ctx.unsupported = true;
138+ render();
139+ return;
140+ }
141+ render();
142+ let worker;
143+ try {
144+ worker = new window.Worker(form.dataset.worker);
145+ } catch (_) {
146+ ctx.unsupported = true;
147+ render();
148+ return;
149+ }
150+ worker.onmessage = (e) => {
151+ const m = e.data || {};
152+ if (m.solution !== undefined) {
153+ solution.value = m.solution;
154+ ctx.solved = true;
155+ worker.terminate();
156+ } else if (m.error === 'nocrypto') {
157+ ctx.unsupported = true;
158+ } else if (m.error) {
159+ ctx.failed = true;
160+ } else {
161+ return;
162+ }
163+ render();
164+ };
165+ worker.onerror = () => { ctx.failed = true; render(); };
166+ worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167+ }
168+
169+ return { core, init };
170+})();
171+
172+document.addEventListener('DOMContentLoaded', () => {
173+ const form = document.querySelector('form[data-issue-form]');
174+ if (form) window.ermiteIssueForm.init(form);
175+});
A internal/adapters/web/static/js/pow-worker.js +72 -0
@@ -0,0 +1,72 @@
1+// pow-worker.js: the proof of work of the public issue form, in a dedicated
2+// Worker (started by issue-form.js). It finds the smallest decimal counter n
3+// such that SHA-256(nonce bytes || ":" || n) has at least `difficulty` leading
4+// zero bits: the very check of the server (usecase/issue_submissions.go,
5+// powHash). The nonce arrives as base64url without padding, like the server
6+// sends it. Messages in: {nonce, difficulty}. Messages out:
7+// {progress: tried} now and then, then {solution: "<decimal>"} or {error}.
8+//
9+// window-less on purpose: ermitePow is also loaded by Node for the tests
10+// (jstest/issue-form.test.mjs).
11+(() => {
12+ const BATCH = 2048;
13+ const enc = new TextEncoder();
14+
15+ // leadingZeroBits counts the zero bits at the start of the bytes.
16+ function leadingZeroBits(bytes) {
17+ let n = 0;
18+ for (const b of bytes) {
19+ if (b === 0) { n += 8; continue; }
20+ return n + Math.clz32(b) - 24;
21+ }
22+ return n;
23+ }
24+
25+ // b64urlDecode turns unpadded base64url into bytes.
26+ function b64urlDecode(s) {
27+ const bin = atob(String(s).replace(/-/g, '+').replace(/_/g, '/'));
28+ const out = new Uint8Array(bin.length);
29+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30+ return out;
31+ }
32+
33+ // input is nonce || ":" || decimal(n), the bytes the server hashes.
34+ function input(nonce, n) {
35+ const tail = enc.encode(':' + n);
36+ const out = new Uint8Array(nonce.length + tail.length);
37+ out.set(nonce, 0);
38+ out.set(tail, nonce.length);
39+ return out;
40+ }
41+
42+ // solve returns the smallest solution as a decimal string. digest is
43+ // (Uint8Array) => Promise<ArrayBuffer-like>, crypto.subtle.digest by default.
44+ // Hashes are asked a batch at a time, in order, so the first hit of the first
45+ // batch that has one is the smallest. onProgress(tried) is called after each
46+ // batch; it may return true to stop (solve then returns null).
47+ async function solve(nonce, difficulty, { digest, batch = BATCH, onProgress } = {}) {
48+ const dig = digest || ((d) => globalThis.crypto.subtle.digest('SHA-256', d));
49+ for (let start = 0; ; start += batch) {
50+ const sums = await Promise.all(Array.from({ length: batch }, async (_, i) => new Uint8Array(await dig(input(nonce, start + i)))));
51+ for (let i = 0; i < batch; i++) {
52+ if (leadingZeroBits(sums[i]) >= difficulty) return String(start + i);
53+ }
54+ if (onProgress && onProgress(start + batch)) return null;
55+ }
56+ }
57+
58+ globalThis.ermitePow = { leadingZeroBits, b64urlDecode, input, solve };
59+
60+ if (typeof WorkerGlobalScope !== 'undefined' && typeof globalThis.postMessage === 'function') {
61+ globalThis.onmessage = async (e) => {
62+ try {
63+ if (!globalThis.crypto || !globalThis.crypto.subtle) { postMessage({ error: 'nocrypto' }); return; }
64+ const { nonce, difficulty } = e.data;
65+ const solution = await solve(b64urlDecode(nonce), difficulty, { onProgress: (tried) => { postMessage({ progress: tried }); } });
66+ postMessage({ solution });
67+ } catch (err) {
68+ postMessage({ error: String(err && err.message || err) });
69+ }
70+ };
71+ }
72+})();
M internal/adapters/web/view.go +12 -0
@@ -69,6 +69,8 @@ type Layout struct {
6969 Passkeys bool
7070 // MR loads the script of the merge request page (mr.js).
7171 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
7274 }
7375
7476 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135137 type HomePage struct {
136138 Sorts []SortLink
137139 Rows []HomeRow
140+ // Inbox is the button to the owner's inbox; nil unless the owner is signed
141+ // in.
142+ Inbox *InboxButton
143+}
144+
145+// InboxButton is the link to the inbox with the number of open items; Count is
146+// negative when it could not be read.
147+type InboxButton struct {
148+ Href string
149+ Count int
138150 }
139151
140152 // Fact is one key/value row of the facts block.
M internal/adapters/web/web.go +46 -1
@@ -54,6 +54,28 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
75+ // Inbox serves the owner's inbox page (/-/inbox) and the button that
76+ // leads to it. Without it there is neither. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Inbox ports.InboxService
5779 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
5880 // commits and merge requests. Without it there are no such pages. Leave it
5981 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166188 if s.passkeysOn() {
167189 s.passkeyRoutes(mux)
168190 }
191+ if s.cfg.Inbox != nil {
192+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
198+ }
169199 mux.HandleFunc("GET /{$}", s.page(s.home))
170200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182212 if s.cfg.MergeRequests != nil {
183213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
215+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -195,6 +225,21 @@ func (s *Server) routes() *http.ServeMux {
195225 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
196226 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
197227 }
228+ if s.cfg.Issues != nil {
229+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
230+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
231+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
232+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
233+ // The writes: POST only, behind the gate (ServeHTTP).
234+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
235+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
236+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
238+ }
239+ if s.cfg.IssueForm != nil {
240+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
241+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
242+ }
198243 if s.cfg.Runs != nil {
199244 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
200245 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/api_helpers_test.go +2 -0
@@ -31,6 +31,7 @@ type clients struct {
3131 event ermiteconnect.EventServiceClient
3232 server ermiteconnect.ServerServiceClient
3333 mr ermiteconnect.MergeRequestServiceClient
34+ issue ermiteconnect.IssueServiceClient
3435
3536 session ermiteconnect.SessionServiceClient
3637 runner ermiteconnect.RunnerServiceClient
@@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
8586 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
8687 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
8788 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
89+ issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...),
8890
8991 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
9092 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
M internal/app/cli_e2e_test.go +2 -2
@@ -191,7 +191,7 @@ type eventOut struct {
191191 MergeRequest *mrOut `json:"merge_request"`
192192 }
193193
194-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "topics", "visibility"}
194+var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
195195
196196 // AC6: the CLI end to end against a real forge.
197197 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338338 ver := decode[versionOut](t, res.stdout)
339339 assert.Equal(t, c.url, ver.Server.URL)
340340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343343 stdout, stderr, code = c.bin(t, secret, "version")
344344 require.Equal(t, 0, code, stderr)
M internal/app/cli_fake_test.go +27 -0
@@ -31,6 +31,7 @@ type fakeAPI struct {
3131 ermiteconnect.UnimplementedServerServiceHandler
3232 ermiteconnect.UnimplementedEventServiceHandler
3333 ermiteconnect.UnimplementedMergeRequestServiceHandler
34+ ermiteconnect.UnimplementedIssueServiceHandler
3435 ermiteconnect.UnimplementedSessionServiceHandler
3536 ermiteconnect.UnimplementedRunServiceHandler
3637 ermiteconnect.UnimplementedTrustedKeyServiceHandler
@@ -61,6 +62,12 @@ type fakeAPI struct {
6162 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
6263 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
6364 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
65+ createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error)
66+ listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
67+ getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
68+ addIssueCmt func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error)
69+ closeIssue func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error)
70+ reopenIssue func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error)
6471 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
6572
6673 listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
@@ -148,6 +155,24 @@ func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite
148155 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
149156 return respond(f, "MarkRead", r, f.markRead)
150157 }
158+func (f *fakeAPI) CreateIssue(_ context.Context, r *connect.Request[ermite.CreateIssueRequest]) (*connect.Response[ermite.CreateIssueResponse], error) {
159+ return respond(f, "CreateIssue", r, f.createIssue)
160+}
161+func (f *fakeAPI) ListIssues(_ context.Context, r *connect.Request[ermite.ListIssuesRequest]) (*connect.Response[ermite.ListIssuesResponse], error) {
162+ return respond(f, "ListIssues", r, f.listIssues)
163+}
164+func (f *fakeAPI) GetIssue(_ context.Context, r *connect.Request[ermite.GetIssueRequest]) (*connect.Response[ermite.GetIssueResponse], error) {
165+ return respond(f, "GetIssue", r, f.getIssue)
166+}
167+func (f *fakeAPI) AddIssueComment(_ context.Context, r *connect.Request[ermite.AddIssueCommentRequest]) (*connect.Response[ermite.AddIssueCommentResponse], error) {
168+ return respond(f, "AddIssueComment", r, f.addIssueCmt)
169+}
170+func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) {
171+ return respond(f, "CloseIssue", r, f.closeIssue)
172+}
173+func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) {
174+ return respond(f, "ReopenIssue", r, f.reopenIssue)
175+}
151176 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
152177 return respond(f, "GetSigningKey", r, f.getSigningKey)
153178 }
@@ -189,6 +214,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
189214 mount(p, h)
190215 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
191216 mount(p, h)
217+ p, h = ermiteconnect.NewIssueServiceHandler(f)
218+ mount(p, h)
192219 p, h = ermiteconnect.NewSessionServiceHandler(f)
193220 mount(p, h)
194221 p, h = ermiteconnect.NewRunServiceHandler(f)
A internal/app/cli_issue.go +455 -0
@@ -0,0 +1,455 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "slices"
12+ "strconv"
13+ "strings"
14+
15+ "connectrpc.com/connect"
16+ "github.com/spf13/cobra"
17+
18+ "bnema.dev/ermite-api/gen/ermite"
19+ "bnema.dev/ermite/internal/adapters/client"
20+)
21+
22+const statePending = "pending"
23+
24+func issueStateName(s ermite.IssueState) string {
25+ switch s {
26+ case ermite.IssueState_ISSUE_STATE_OPEN:
27+ return stateOpen
28+ case ermite.IssueState_ISSUE_STATE_CLOSED:
29+ return stateClosed
30+ case ermite.IssueState_ISSUE_STATE_PENDING:
31+ return statePending
32+ case ermite.IssueState_ISSUE_STATE_UNSPECIFIED:
33+ }
34+ return "unknown"
35+}
36+
37+func parseIssueState(s string) (ermite.IssueState, error) {
38+ switch strings.ToLower(strings.TrimSpace(s)) {
39+ case stateOpen:
40+ return ermite.IssueState_ISSUE_STATE_OPEN, nil
41+ case stateClosed:
42+ return ermite.IssueState_ISSUE_STATE_CLOSED, nil
43+ case stateAll:
44+ return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil
45+ }
46+ return 0, fmt.Errorf("invalid state %q: use %s, %s or %s", s, stateOpen, stateClosed, stateAll)
47+}
48+
49+// jsonIssue is the stable JSON shape of an issue.
50+type jsonIssue struct {
51+ Repo string `json:"repo"`
52+ Number int64 `json:"number"`
53+ Title string `json:"title"`
54+ Body string `json:"body"`
55+ State string `json:"state"`
56+ Author string `json:"author"`
57+ CreatedAt *string `json:"created_at"`
58+ UpdatedAt *string `json:"updated_at"`
59+ CommentCount int32 `json:"comment_count"`
60+}
61+
62+func toJSONIssue(i *ermite.Issue) jsonIssue {
63+ return jsonIssue{
64+ Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
65+ State: issueStateName(i.GetState()), Author: i.GetAuthor(),
66+ CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
67+ }
68+}
69+
70+// jsonIssueComment is the stable JSON shape of an issue comment.
71+type jsonIssueComment struct {
72+ ID string `json:"id"`
73+ Author string `json:"author"`
74+ Text string `json:"text"`
75+ CreatedAt *string `json:"created_at"`
76+}
77+
78+func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
79+ return jsonIssueComment{ID: c.GetId(), Author: c.GetAuthor(), Text: c.GetText(), CreatedAt: jsonTime(c.GetCreatedAt())}
80+}
81+
82+// jsonIssueDetail is the stable JSON shape of `issue show --json`.
83+type jsonIssueDetail struct {
84+ jsonIssue
85+ Comments []jsonIssueComment `json:"comments"`
86+}
87+
88+const jsonIssueHelp = `
89+JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
90+(token label), created_at and updated_at (RFC 3339 UTC), comment_count.`
91+
92+const jsonIssueCommentHelp = `
93+JSON fields of an issue comment: id, author, text, created_at.`
94+
95+func newIssueCmd(env *cliEnv) *cobra.Command {
96+ issue := &cobra.Command{
97+ Use: "issue",
98+ Short: "Track issues on a remote server",
99+ Long: "Issues record a task, a bug or a question about a repository. They share the numbering of merge\n" +
100+ "requests: the next issue after merge request 1 is number 2. Issues must be enabled for the\n" +
101+ "repository (`ermite repo edit <repo> --issues owner`). Needs a server with the issues feature (see\n" +
102+ "`ermite version`). An issue is named by its repository and its number: `ermite issue show proj 3`.",
103+ }
104+ issue.AddCommand(
105+ newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env),
106+ newIssueCloseCmd(env), newIssueReopenCmd(env),
107+ )
108+ return issue
109+}
110+
111+// requireIssues refuses to go on with a server that does not list issues: it
112+// has no IssueService. A server whose features cannot be read is refused too.
113+// errNoIssues marks a server that lacks the issues feature.
114+var errNoIssues = errors.New("issues are not available")
115+
116+func requireIssues(ctx context.Context, c *client.Client) error {
117+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
118+ if err != nil {
119+ return c.Describe(err)
120+ }
121+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) {
122+ return fmt.Errorf("%w: the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", errNoIssues, client.FeatureIssues)
123+ }
124+ return nil
125+}
126+
127+// issueClient returns a client for a server that supports issues.
128+func (e *cliEnv) issueClient(cmd *cobra.Command) (*client.Client, error) {
129+ c, err := e.client(cmd)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if err := requireIssues(e.ctx(cmd), c); err != nil {
134+ return nil, err
135+ }
136+ return c, nil
137+}
138+
139+// parseIssueNumber reads the number of an issue, written 3 or #3.
140+func parseIssueNumber(s string) (int64, error) {
141+ n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "#"), 10, 64)
142+ if err != nil || n < 1 {
143+ return 0, fmt.Errorf("invalid issue number %q: use a positive number such as 3 or #3", s)
144+ }
145+ return n, nil
146+}
147+
148+func completeIssueRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
149+ return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
150+ switch len(args) {
151+ case 0:
152+ return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
153+ case 1:
154+ c, err := env.completionClient(cmd)
155+ if err != nil {
156+ return nil, cobra.ShellCompDirectiveNoFileComp
157+ }
158+ issues, err := listIssues(env.ctx(cmd), c, args[0], ermite.IssueState_ISSUE_STATE_UNSPECIFIED)
159+ if err != nil {
160+ return nil, cobra.ShellCompDirectiveNoFileComp
161+ }
162+ var out []string
163+ for _, i := range issues {
164+ if n := strconv.FormatInt(i.GetNumber(), 10); strings.HasPrefix(n, toComplete) {
165+ out = append(out, n+"\t"+issueStateName(i.GetState())+", "+clean(i.GetTitle()))
166+ }
167+ }
168+ return out, cobra.ShellCompDirectiveNoFileComp
169+ }
170+ return nil, cobra.ShellCompDirectiveNoFileComp
171+ }
172+}
173+
174+func listIssues(ctx context.Context, c *client.Client, repo string, state ermite.IssueState) ([]*ermite.Issue, error) {
175+ return collect(ctx, func(token string) ([]*ermite.Issue, string, error) {
176+ res, err := c.Issues().ListIssues(ctx, connect.NewRequest(&ermite.ListIssuesRequest{
177+ Repo: repo, State: state, PageToken: token,
178+ }))
179+ if err != nil {
180+ return nil, "", c.Describe(err)
181+ }
182+ return res.Msg.GetIssues(), res.Msg.GetNextPageToken(), nil
183+ })
184+}
185+
186+// outputIssue prints an issue as JSON, or as one confirmation line.
187+func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error {
188+ if asJSON {
189+ return writeJSON(cmd.OutOrStdout(), toJSONIssue(i))
190+ }
191+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()))
192+ return err
193+}
194+
195+func newIssueCreateCmd(env *cliEnv) *cobra.Command {
196+ var title, body, bodyFile string
197+ cmd := &cobra.Command{
198+ Use: "create <repo>",
199+ Short: "Open an issue",
200+ Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
201+ "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
202+ "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
203+ "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,
204+ Args: cobra.ExactArgs(1),
205+ ValidArgsFunction: completeRepoNames(env),
206+ }
207+ cmd.Flags().StringVar(&title, "title", "", "title of the issue (required)")
208+ cmd.Flags().StringVar(&body, "body", "", "body, or - to read standard input")
209+ cmd.Flags().StringVar(&bodyFile, "body-file", "", "read the body from this file, or - for standard input")
210+ cmd.MarkFlagsMutuallyExclusive("body", "body-file")
211+ _ = cmd.MarkFlagRequired("title")
212+ _ = cmd.MarkFlagFilename("body-file")
213+ asJSON := addJSONFlag(cmd)
214+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
215+ text := body
216+ if cmd.Flags().Changed("body-file") {
217+ if bodyFile == "-" {
218+ text = "-"
219+ } else {
220+ b, err := os.ReadFile(bodyFile) //nolint:gosec // a path typed by the user
221+ if err != nil {
222+ return fmt.Errorf("read --body-file: %w", err)
223+ }
224+ text = strings.TrimRight(string(b), "\r\n")
225+ }
226+ }
227+ text, err := readText(cmd, text)
228+ if err != nil {
229+ return err
230+ }
231+ c, err := env.issueClient(cmd)
232+ if err != nil {
233+ return err
234+ }
235+ res, err := c.Issues().CreateIssue(env.ctx(cmd), connect.NewRequest(&ermite.CreateIssueRequest{
236+ Repo: args[0], Title: title, Body: text,
237+ }))
238+ if err != nil {
239+ return c.Describe(err)
240+ }
241+ return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened")
242+ }
243+ return cmd
244+}
245+
246+func newIssueListCmd(env *cliEnv) *cobra.Command {
247+ var state string
248+ cmd := &cobra.Command{
249+ Use: "list <repo>",
250+ Short: "List the issues of a repository",
251+ Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" +
252+ "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.",
253+ Args: cobra.ExactArgs(1),
254+ ValidArgsFunction: completeRepoNames(env),
255+ }
256+ cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all")
257+ _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
258+ return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp
259+ })
260+ asJSON := addJSONFlag(cmd)
261+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
262+ st, err := parseIssueState(state)
263+ if err != nil {
264+ return err
265+ }
266+ c, err := env.issueClient(cmd)
267+ if err != nil {
268+ return err
269+ }
270+ issues, err := listIssues(env.ctx(cmd), c, args[0], st)
271+ if err != nil {
272+ return err
273+ }
274+ if *asJSON {
275+ out := make([]jsonIssue, 0, len(issues))
276+ for _, i := range issues {
277+ out = append(out, toJSONIssue(i))
278+ }
279+ return writeJSON(cmd.OutOrStdout(), out)
280+ }
281+ if len(issues) == 0 {
282+ noticef(cmd, "no issues")
283+ return nil
284+ }
285+ rows := make([][]string, 0, len(issues))
286+ for _, i := range issues {
287+ rows = append(rows, []string{
288+ "#" + strconv.FormatInt(i.GetNumber(), 10), issueStateName(i.GetState()), i.GetAuthor(),
289+ strconv.Itoa(int(i.GetCommentCount())), shortTime(i.GetUpdatedAt(), "-"), i.GetTitle(),
290+ })
291+ }
292+ return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "AUTHOR", "COMMENTS", "UPDATED", "TITLE"}, rows)
293+ }
294+ return cmd
295+}
296+
297+func newIssueShowCmd(env *cliEnv) *cobra.Command {
298+ cmd := &cobra.Command{
299+ Use: "show <repo> <number>",
300+ Short: "Show an issue with its comments",
301+ Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +
302+ "\n--json adds comments to the issue fields.",
303+ Args: cobra.ExactArgs(2),
304+ ValidArgsFunction: completeIssueRepoThenNumber(env),
305+ }
306+ asJSON := addJSONFlag(cmd)
307+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
308+ n, err := parseIssueNumber(args[1])
309+ if err != nil {
310+ return err
311+ }
312+ c, err := env.issueClient(cmd)
313+ if err != nil {
314+ return err
315+ }
316+ res, err := c.Issues().GetIssue(env.ctx(cmd), connect.NewRequest(&ermite.GetIssueRequest{Repo: args[0], Number: n}))
317+ if err != nil {
318+ return c.Describe(err)
319+ }
320+ if *asJSON {
321+ out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}
322+ for _, cm := range res.Msg.GetComments() {
323+ out.Comments = append(out.Comments, toJSONIssueComment(cm))
324+ }
325+ return writeJSON(cmd.OutOrStdout(), out)
326+ }
327+ return printIssueDetail(cmd.OutOrStdout(), res.Msg)
328+ }
329+ return cmd
330+}
331+
332+func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
333+ i := d.GetIssue()
334+ body := i.GetBody()
335+ if body == "" {
336+ body = "-"
337+ }
338+ err := fields(w, [][2]string{
339+ {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340+ {"title", i.GetTitle()},
341+ {"state", issueStateName(i.GetState())},
342+ {"author", i.GetAuthor()},
343+ {"created", shortTime(i.GetCreatedAt(), "-")},
344+ {"updated", shortTime(i.GetUpdatedAt(), "-")},
345+ })
346+ if err != nil {
347+ return err
348+ }
349+ if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350+ return err
351+ }
352+ if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353+ return err
354+ }
355+ for _, c := range d.GetComments() {
356+ if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {
357+ return err
358+ }
359+ }
360+ return nil
361+}
362+
363+func newIssueCommentCmd(env *cliEnv) *cobra.Command {
364+ var message string
365+ cmd := &cobra.Command{
366+ Use: "comment <repo> <number>",
367+ Short: "Comment on an issue",
368+ Long: "Add a plain-text comment (at most 10,000 characters; disallowed control characters are stripped).\n" +
369+ "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp,
370+ Args: cobra.ExactArgs(2),
371+ ValidArgsFunction: completeIssueRepoThenNumber(env),
372+ }
373+ cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")
374+ cmd.Flags().StringVar(&message, "text", "", "alias of --message")
375+ _ = cmd.Flags().MarkHidden("text")
376+ asJSON := addJSONFlag(cmd)
377+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
378+ n, err := parseIssueNumber(args[1])
379+ if err != nil {
380+ return err
381+ }
382+ if !cmd.Flags().Changed("message") && !cmd.Flags().Changed("text") {
383+ return errors.New("the comment is required: give it with -m, or -m - for standard input")
384+ }
385+ text, err := readText(cmd, message)
386+ if err != nil {
387+ return err
388+ }
389+ c, err := env.issueClient(cmd)
390+ if err != nil {
391+ return err
392+ }
393+ res, err := c.Issues().AddIssueComment(env.ctx(cmd), connect.NewRequest(&ermite.AddIssueCommentRequest{
394+ Repo: args[0], Number: n, Text: text,
395+ }))
396+ if err != nil {
397+ return c.Describe(err)
398+ }
399+ if *asJSON {
400+ return writeJSON(cmd.OutOrStdout(), toJSONIssueComment(res.Msg.GetComment()))
401+ }
402+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s#%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n)
403+ return err
404+ }
405+ return cmd
406+}
407+
408+func newIssueCloseCmd(env *cliEnv) *cobra.Command {
409+ return newIssueStateCmd(env, "close", "closed", "Close an issue", "Close an open issue. `ermite issue reopen` opens it again. Needs the issue:write scope.",
410+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
411+ res, err := c.Issues().CloseIssue(ctx, connect.NewRequest(&ermite.CloseIssueRequest{Repo: repo, Number: n}))
412+ if err != nil {
413+ return nil, err
414+ }
415+ return res.Msg.GetIssue(), nil
416+ })
417+}
418+
419+func newIssueReopenCmd(env *cliEnv) *cobra.Command {
420+ return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.",
421+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
422+ res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n}))
423+ if err != nil {
424+ return nil, err
425+ }
426+ return res.Msg.GetIssue(), nil
427+ })
428+}
429+
430+func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(context.Context, *client.Client, string, int64) (*ermite.Issue, error)) *cobra.Command {
431+ cmd := &cobra.Command{
432+ Use: use + " <repo> <number>",
433+ Short: short,
434+ Long: long + jsonIssueHelp,
435+ Args: cobra.ExactArgs(2),
436+ ValidArgsFunction: completeIssueRepoThenNumber(env),
437+ }
438+ asJSON := addJSONFlag(cmd)
439+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
440+ n, err := parseIssueNumber(args[1])
441+ if err != nil {
442+ return err
443+ }
444+ c, err := env.issueClient(cmd)
445+ if err != nil {
446+ return err
447+ }
448+ i, err := do(env.ctx(cmd), c, args[0], n)
449+ if err != nil {
450+ return c.Describe(err)
451+ }
452+ return outputIssue(cmd, i, *asJSON, verb)
453+ }
454+ return cmd
455+}
A internal/app/cli_issue_e2e_test.go +154 -0
@@ -0,0 +1,154 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+type issueOut struct {
16+ Repo string `json:"repo"`
17+ Number int64 `json:"number"`
18+ Title string `json:"title"`
19+ Body string `json:"body"`
20+ State string `json:"state"`
21+ Author string `json:"author"`
22+ CreatedAt *string `json:"created_at"`
23+ UpdatedAt *string `json:"updated_at"`
24+ CommentCount int `json:"comment_count"`
25+}
26+
27+var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28+
29+type issueCommentOut struct {
30+ ID string `json:"id"`
31+ Author string `json:"author"`
32+ Text string `json:"text"`
33+}
34+
35+// The issue commands against a real forge: issues share the numbering of the
36+// merge requests.
37+func TestIssuesCLIEndToEnd(t *testing.T) {
38+ c := newCLIHarness(t)
39+ agent := strings.TrimSpace(func() string {
40+ out, errOut, code := c.bin(t, adminToken, "token", "create", "agent-7", "--preset", "agent", "--scope", "issue:write", "--repo", "proj")
41+ require.Equal(t, 0, code, errOut)
42+ return out
43+ }())
44+ c.cli(t, adminToken, "repo", "create", "proj").ok(t)
45+
46+ // Issues are off by default.
47+ res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
48+ assert.Equal(t, "off", decode[struct {
49+ Issues string `json:"issues"`
50+ }](t, res.stdout).Issues)
51+ res = c.cli(t, adminToken, "issue", "create", "proj", "--title", "too early")
52+ require.Error(t, res.err)
53+
54+ // repo edit --issues.
55+ res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner", "--json").ok(t)
56+ assert.Equal(t, "owner", decode[struct {
57+ Issues string `json:"issues"`
58+ }](t, res.stdout).Issues)
59+ res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
60+ assert.Regexp(t, `issues\s+owner`, res.stdout)
61+ require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "everyone").err)
62+ res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public", "--json").ok(t)
63+ assert.Equal(t, "public", decode[struct {
64+ Issues string `json:"issues"`
65+ }](t, res.stdout).Issues)
66+ c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner").ok(t)
67+
68+ // A merge request first: the issue takes the next number.
69+ wt := c.workTree("main", "README.md")
70+ url := c.urlAuth(adminToken, "/proj.git")
71+ c.mustGit(wt, "push", "-q", url, "main")
72+ c.mustGit(wt, "checkout", "-q", "-b", "feat/x")
73+ c.writeFile(wt, "x.txt", []byte("x\n"))
74+ c.mustGit(wt, "push", "-q", url, "feat/x")
75+ c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/x", "--title", "First").ok(t)
76+
77+ res = c.cli(t, agent, "issue", "create", "proj", "--title", "Crash on start", "--body", "It crashes.\nSecond line.", "--json").ok(t)
78+ assert.Equal(t, issueKeys, keys(t, res.stdout))
79+ issue := decode[issueOut](t, res.stdout)
80+ assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests")
81+ assert.Equal(t, "open", issue.State)
82+ assert.Equal(t, "agent-7", issue.Author)
83+ assert.Equal(t, "It crashes.\nSecond line.", issue.Body)
84+
85+ res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t)
86+ assert.Equal(t, "opened proj#3 (Second)\n", res.stdout)
87+
88+ // list.
89+ res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
90+ list := decode[[]issueOut](t, res.stdout)
91+ require.Len(t, list, 2)
92+ assert.EqualValues(t, 3, list[0].Number, "highest number first")
93+ res = c.cli(t, adminToken, "issue", "list", "proj").ok(t)
94+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
95+ require.Len(t, lines, 3)
96+ assert.Regexp(t, `^NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE$`, lines[0])
97+ assert.Regexp(t, `^#3\s+open\s+agent-7\s+0\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+Second$`, lines[1])
98+
99+ // comment, then show with it.
100+ res = c.cli(t, agent, "issue", "comment", "proj", "2", "-m", "Reproduced.", "--json").ok(t)
101+ cm := decode[issueCommentOut](t, res.stdout)
102+ assert.Equal(t, "agent-7", cm.Author)
103+ assert.Equal(t, "Reproduced.", cm.Text)
104+ res = c.cli(t, adminToken, "issue", "comment", "proj", "#2", "-m", "Thanks.").ok(t)
105+ assert.Regexp(t, `^comment \S+ added to proj#2\n$`, res.stdout)
106+ res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107+ type showOut struct {
108+ issueOut
109+ Comments []issueCommentOut `json:"comments"`
110+ }
111+ show := decode[showOut](t, res.stdout)
112+ assert.Equal(t, 2, show.CommentCount)
113+ require.Len(t, show.Comments, 2)
114+ assert.Equal(t, "Reproduced.", show.Comments[0].Text, "oldest first")
115+ res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116+ assert.Contains(t, res.stdout, "Crash on start")
117+ assert.Contains(t, res.stdout, "comments (2)")
118+
119+ // close and reopen.
120+ res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
121+ assert.Equal(t, "closed", decode[issueOut](t, res.stdout).State)
122+ res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
123+ assert.Len(t, decode[[]issueOut](t, res.stdout), 1)
124+ res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "closed", "--json").ok(t)
125+ assert.Len(t, decode[[]issueOut](t, res.stdout), 1)
126+ res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t)
127+ assert.Len(t, decode[[]issueOut](t, res.stdout), 2)
128+ res = c.cli(t, agent, "issue", "reopen", "proj", "2").ok(t)
129+ assert.Equal(t, "reopened proj#2 (Crash on start)\n", res.stdout)
130+
131+ // An issue is not a merge request and the other way round.
132+ _, stderr, code := c.bin(t, adminToken, "issue", "show", "proj", "1")
133+ requireOneLineError(t, stderr, code, "not found")
134+ _, stderr, code = c.bin(t, adminToken, "issue", "show", "proj", "9")
135+ requireOneLineError(t, stderr, code, "not found")
136+}
137+
138+// A server without the issues feature is refused before anything is created.
139+func TestIssueWriteTokenRefusedWithoutTheFeature(t *testing.T) {
140+ f := &fakeAPI{
141+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
142+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil
143+ },
144+ createToken: func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
145+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t"}, Secret: "ermite_S"}, nil
146+ },
147+ }
148+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "bot", "--preset", "agent", "--scope", "issue:write")
149+ require.Error(t, res.err)
150+ assert.Contains(t, res.err.Error(), "does not support issues")
151+ assert.Empty(t, res.stdout)
152+ calls, _ := f.snapshot()
153+ assert.NotContains(t, calls, "CreateToken")
154+}
A internal/app/cli_issue_test.go +264 -0
@@ -0,0 +1,264 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+)
16+
17+var issueOne = &ermite.Issue{
18+ Repo: "alpha", Number: 2, Title: "Crash on start", Body: "It crashes.", State: ermite.IssueState_ISSUE_STATE_OPEN,
19+ Author: "owner", CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), CommentCount: 1,
20+}
21+
22+func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
23+ return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil
24+}
25+
26+func TestIssueCreateMapsFlags(t *testing.T) {
27+ f := &fakeAPI{getVersion: withIssues, createIssue: func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) {
28+ return &ermite.CreateIssueResponse{Issue: issueOne}, nil
29+ }}
30+ url := f.serve(t)
31+ res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)
32+ assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout)
33+ _, reqs := f.snapshot()
34+ got := reqs[1].(*ermite.CreateIssueRequest)
35+ assert.Equal(t, "alpha", got.GetRepo())
36+ assert.Equal(t, "Crash on start", got.GetTitle())
37+ assert.Equal(t, "It crashes.", got.GetBody())
38+
39+ runCLI(t, envFor(url), "from\nstdin\n", "issue", "create", "alpha", "--title", "t", "--body", "-").ok(t)
40+ runCLI(t, envFor(url), "piped\n", "issue", "create", "alpha", "--title", "t", "--body-file", "-").ok(t)
41+ file := filepath.Join(t.TempDir(), "body.txt")
42+ require.NoError(t, os.WriteFile(file, []byte("from file\n"), 0o600))
43+ runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--body-file", file).ok(t)
44+ _, reqs = f.snapshot()
45+ var bodies []string
46+ for _, r := range reqs {
47+ if c, ok := r.(*ermite.CreateIssueRequest); ok {
48+ bodies = append(bodies, c.GetBody())
49+ }
50+ }
51+ assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
52+
53+ res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)
55+
56+ for _, args := range [][]string{
57+ {"issue", "create", "alpha"},
58+ {"issue", "create", "alpha", "--title", "t", "--body", "a", "--body-file", file},
59+ } {
60+ require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
61+ }
62+}
63+
64+func TestIssueListTableJSONAndPages(t *testing.T) {
65+ closed := &ermite.Issue{Repo: "alpha", Number: 1, Title: "Old", State: ermite.IssueState_ISSUE_STATE_CLOSED, Author: "bot", UpdatedAt: ts("2026-09-01T08:00:00Z")}
66+ f := &fakeAPI{getVersion: withIssues, listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
67+ if r.GetPageToken() == "" {
68+ return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{issueOne}, NextPageToken: "p2"}, nil
69+ }
70+ return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{closed}}, nil
71+ }}
72+ url := f.serve(t)
73+ res := runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "all").ok(t)
74+ assert.Regexp(t, `NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE\n#2\s+open\s+owner\s+1\s+2026-10-02 11:30\s+Crash on start\n#1\s+closed\s+bot\s+0\s+2026-09-01 08:00\s+Old\n`, res.stdout)
75+ _, reqs := f.snapshot()
76+ require.Len(t, reqs, 3) // GetVersion, two pages
77+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_UNSPECIFIED, reqs[1].(*ermite.ListIssuesRequest).GetState())
78+ assert.Equal(t, "p2", reqs[2].(*ermite.ListIssuesRequest).GetPageToken())
79+
80+ runCLI(t, envFor(url), "", "issue", "list", "alpha").ok(t)
81+ _, reqs = f.snapshot()
82+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_OPEN, reqs[len(reqs)-2].(*ermite.ListIssuesRequest).GetState())
83+
84+ res = runCLI(t, envFor(url), "", "issue", "list", "alpha", "--json").ok(t)
85+ assert.Contains(t, res.stdout, `"state": "closed"`)
86+
87+ require.Error(t, runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "merged").err)
88+
89+ empty := &fakeAPI{getVersion: withIssues, listIssues: func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
90+ return &ermite.ListIssuesResponse{}, nil
91+ }}
92+ res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha", "--json").ok(t)
93+ assert.Equal(t, "[]\n", res.stdout)
94+ res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha").ok(t)
95+ assert.Equal(t, "ermite: no issues\n", res.stderr)
96+}
97+
98+func TestIssueShowCommentCloseReopen(t *testing.T) {
99+ cmt := &ermite.IssueComment{Id: "1", Author: "agent-7", Text: "On it.", CreatedAt: ts("2026-10-02T11:30:00Z")}
100+ reopened := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_OPEN}
101+ f := &fakeAPI{
102+ getVersion: withIssues,
103+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
104+ return &ermite.GetIssueResponse{Issue: issueOne, Comments: []*ermite.IssueComment{cmt}}, nil
105+ },
106+ addIssueCmt: func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) {
107+ return &ermite.AddIssueCommentResponse{Comment: cmt}, nil
108+ },
109+ closeIssue: func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) {
110+ c := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED}
111+ return &ermite.CloseIssueResponse{Issue: c}, nil
112+ },
113+ reopenIssue: func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) {
114+ return &ermite.ReopenIssueResponse{Issue: reopened}, nil
115+ },
116+ }
117+ url := f.serve(t)
118+
119+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "#2").ok(t)
120+ assert.Contains(t, res.stdout, "issue alpha#2")
121+ assert.Contains(t, res.stdout, "It crashes.")
122+ assert.Contains(t, res.stdout, "comments (1)")
123+ assert.Contains(t, res.stdout, "#1 agent-7")
124+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
125+ assert.Contains(t, res.stdout, `"comments"`)
126+ assert.Contains(t, res.stdout, `"text": "On it."`)
127+
128+ res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "On it.").ok(t)
129+ assert.Equal(t, "comment 1 added to alpha#2\n", res.stdout)
130+ runCLI(t, envFor(url), "multi\nline\n", "issue", "comment", "alpha", "2", "--text", "-").ok(t)
131+ res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "x", "--json").ok(t)
132+ assert.JSONEq(t, `{"id":"1","author":"agent-7","text":"On it.","created_at":"2026-10-02T11:30:00Z"}`, res.stdout)
133+ _, reqs := f.snapshot()
134+ var texts []string
135+ for _, r := range reqs {
136+ if c, ok := r.(*ermite.AddIssueCommentRequest); ok {
137+ assert.EqualValues(t, 2, c.GetNumber())
138+ texts = append(texts, c.GetText())
139+ }
140+ }
141+ assert.Equal(t, []string{"On it.", "multi\nline", "x"}, texts)
142+ require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)
143+
144+ res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)
145+ assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout)
146+ res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)
147+ assert.Contains(t, res.stdout, `"state": "open"`)
148+
149+ for _, n := range []string{"0", "x", "-1"} {
150+ require.Error(t, runCLI(t, envFor(url), "", "issue", "show", "alpha", n).err, n)
151+ }
152+}
153+
154+func TestIssueNeedsTheFeature(t *testing.T) {
155+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
156+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil
157+ }}
158+ url := f.serve(t)
159+ for _, args := range [][]string{
160+ {"issue", "list", "alpha"}, {"issue", "show", "alpha", "1"}, {"issue", "create", "alpha", "--title", "t"},
161+ {"issue", "comment", "alpha", "1", "-m", "x"}, {"issue", "close", "alpha", "1"}, {"issue", "reopen", "alpha", "1"},
162+ {"repo", "edit", "alpha", "--issues", "owner"},
163+ } {
164+ res := runCLI(t, envFor(url), "", args...)
165+ require.Error(t, res.err, "%v", args)
166+ assert.Contains(t, res.err.Error(), "does not support issues", "%v", args)
167+ }
168+ calls, _ := f.snapshot()
169+ for _, c := range calls {
170+ assert.Equal(t, "GetVersion", c)
171+ }
172+}
173+
174+func TestRepoEditIssues(t *testing.T) {
175+ updated := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
176+ f := &fakeAPI{getVersion: withIssues, updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
177+ return &ermite.UpdateRepoResponse{Repo: updated}, nil
178+ }}
179+ url := f.serve(t)
180+ for value, want := range map[string]ermite.IssuesMode{
181+ "off": ermite.IssuesMode_ISSUES_MODE_OFF, "owner": ermite.IssuesMode_ISSUES_MODE_OWNER, "PUBLIC": ermite.IssuesMode_ISSUES_MODE_PUBLIC,
182+ } {
183+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", value, "--json").ok(t)
184+ assert.Contains(t, res.stdout, `"issues": "owner"`)
185+ _, reqs := f.snapshot()
186+ got := reqs[len(reqs)-1].(*ermite.UpdateRepoRequest)
187+ require.NotNil(t, got.Issues, value)
188+ assert.Equal(t, want, got.GetIssues())
189+ }
190+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", "all")
191+ require.Error(t, res.err)
192+ assert.Contains(t, res.err.Error(), "off, owner or public")
193+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha")
194+ require.Error(t, res.err) // getRepo not set: unimplemented
195+}
196+
197+func TestRepoShowIssues(t *testing.T) {
198+ r := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
199+ f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
200+ return &ermite.GetRepoResponse{Repo: r}, nil
201+ }}
202+ url := f.serve(t)
203+ res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
204+ assert.Contains(t, res.stdout, "issues owner\n")
205+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
206+ assert.Contains(t, res.stdout, `"issues": "owner"`)
207+}
208+
209+func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) {
210+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
211+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
212+ }
213+ f := &fakeAPI{createToken: echo, getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
214+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
215+ }}
216+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--scope", "issue:write")
217+ require.Error(t, res.err)
218+ assert.Contains(t, res.err.Error(), "does not support issues")
219+ calls, _ := f.snapshot()
220+ assert.NotContains(t, calls, "CreateToken")
221+
222+ f = &fakeAPI{createToken: echo, getVersion: withIssues}
223+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)
224+ assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)
225+}
226+
227+func TestTokenCreatePresetCLIOnAnOldServerDropsIssueWrite(t *testing.T) {
228+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
229+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
230+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
231+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
232+ }}
233+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli").ok(t)
234+ got := lastCreateToken(t, f).GetScopes()
235+ assert.NotContains(t, got, ermite.Scope_SCOPE_ISSUE_WRITE)
236+ assert.NotEmpty(t, got)
237+ assert.Contains(t, res.stderr, "no issue:write")
238+
239+ res = runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli", "--scope", "issue:write")
240+ require.Error(t, res.err)
241+ assert.Contains(t, res.err.Error(), "does not support issues")
242+}
243+
244+type failingWriter struct{ after int }
245+
246+func (w *failingWriter) Write(p []byte) (int, error) {
247+ if w.after <= 0 {
248+ return 0, errors.New("write failed")
249+ }
250+ w.after--
251+ return len(p), nil
252+}
253+
254+func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
255+ d := &ermite.GetIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 1, Title: "t"}, Comments: []*ermite.IssueComment{{Id: "1", Text: "c"}}}
256+ for after := range 60 {
257+ err := printIssueDetail(&failingWriter{after: after}, d)
258+ if err == nil {
259+ return // all writes succeeded: every earlier failure was reported
260+ }
261+ assert.ErrorContains(t, err, "write failed", after)
262+ }
263+ t.Fatal("never completed")
264+}
M internal/app/cli_repo.go +61 -6
@@ -36,6 +36,8 @@ type jsonRepo struct {
3636 // MergeModes are the allowed merge modes ("fast-forward", "squash").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
39+ // Issues is the issue mode ("off", "owner" or "public").
40+ Issues string `json:"issues"`
3941 }
4042
4143 func toJSONRepo(r *ermite.Repo) jsonRepo {
@@ -46,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4648 return jsonRepo{
4749 MergeModes: modes,
4850 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
51+ Issues: issuesModeName(r.GetSettings().GetIssues()),
4952 Name: r.GetName(),
5053 Description: r.GetDescription(),
5154 Topics: append([]string{}, r.GetTopics()...),
@@ -56,6 +59,36 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
5659 }
5760 }
5861
62+const (
63+ issuesOff = "off"
64+ issuesOwner = "owner"
65+ issuesPublic = "public"
66+)
67+
68+// issuesModeName names a mode; an unset one is off, the default.
69+func issuesModeName(m ermite.IssuesMode) string {
70+ switch m {
71+ case ermite.IssuesMode_ISSUES_MODE_OWNER:
72+ return issuesOwner
73+ case ermite.IssuesMode_ISSUES_MODE_PUBLIC:
74+ return issuesPublic
75+ case ermite.IssuesMode_ISSUES_MODE_OFF, ermite.IssuesMode_ISSUES_MODE_UNSPECIFIED:
76+ }
77+ return issuesOff
78+}
79+
80+func parseIssuesMode(s string) (ermite.IssuesMode, error) {
81+ switch strings.ToLower(strings.TrimSpace(s)) {
82+ case issuesOff:
83+ return ermite.IssuesMode_ISSUES_MODE_OFF, nil
84+ case issuesOwner:
85+ return ermite.IssuesMode_ISSUES_MODE_OWNER, nil
86+ case issuesPublic:
87+ return ermite.IssuesMode_ISSUES_MODE_PUBLIC, nil
88+ }
89+ return 0, fmt.Errorf("invalid issues mode %q: use %s, %s or %s", s, issuesOff, issuesOwner, issuesPublic)
90+}
91+
5992 func visibilityName(v ermite.Visibility) string {
6093 switch v {
6194 case ermite.Visibility_VISIBILITY_PUBLIC:
@@ -82,7 +115,8 @@ const jsonRepoHelp = `
82115 JSON fields of a repository (--json): name, description, visibility ("public" or
83116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
84117 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or
85-"squash") and failing_checks_block (whether a failing check prevents a merge).`
118+"squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119+mode: "off", "owner" or "public").`
86120
87121 func newRepoCmd(env *cliEnv) *cobra.Command {
88122 repo := &cobra.Command{
@@ -195,6 +229,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
195229 {"last push", shortTime(r.GetPushedAt(), "never")},
196230 {"merge modes", orDash(strings.Join(modes, ", "))},
197231 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
232+ {"issues", issuesModeName(r.GetSettings().GetIssues())},
198233 })
199234 }
200235
@@ -274,13 +309,16 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
274309 var description, visibility, defaultBranch string
275310 var topics, mergeModes []string
276311 var failingChecksBlock bool
312+ var issues string
277313 cmd := &cobra.Command{
278314 Use: "edit <name>",
279- Short: "Change a repository's description, topics, visibility, default branch or merge settings",
315+ Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",
280316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
281317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
282318 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
283319 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320+ "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321+ "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
284322 "Needs the repo:admin scope." + jsonRepoHelp,
285323 Args: cobra.ExactArgs(1),
286324 ValidArgsFunction: completeRepoNames(env),
@@ -291,13 +329,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
291329 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
292330 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
293331 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
332+ cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
333+ _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
334+ return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp
335+ })
294336 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
295337 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
296338 return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp
297339 })
298340 asJSON := addJSONFlag(cmd)
299341 cmd.RunE = func(cmd *cobra.Command, args []string) error {
300- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock)
342+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues)
301343 if err != nil {
302344 return err
303345 }
@@ -305,6 +347,11 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
305347 if err != nil {
306348 return err
307349 }
350+ if req.Issues != nil {
351+ if err := requireIssues(env.ctx(cmd), c); err != nil {
352+ return err
353+ }
354+ }
308355 res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))
309356 if err != nil {
310357 return c.Describe(err)
@@ -316,7 +363,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
316363
317364 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
318365 // the user passed go into the update mask.
319-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {
366+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) {
320367 req := &ermite.UpdateRepoRequest{Repo: name}
321368 var paths []string
322369 if cmd.Flags().Changed("description") {
@@ -365,8 +412,16 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
365412 req.Settings.FailingChecksBlock = failingChecksBlock
366413 paths = append(paths, "settings.failing_checks_block")
367414 }
368- if len(paths) == 0 {
369- return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block")
415+ if cmd.Flags().Changed("issues") {
416+ mode, err := parseIssuesMode(issues)
417+ if err != nil {
418+ return nil, err
419+ }
420+ // The contract keeps issues out of the update mask.
421+ req.Issues = &mode
422+ }
423+ if len(paths) == 0 && req.Issues == nil {
424+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues")
370425 }
371426 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
372427 return req, nil
M internal/app/cli_repo_test.go +6 -5
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
5454 {
5555 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5656 wantStdout: `[
57- {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58- {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"},
58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}
5959 ]`,
6060 },
6161 {
@@ -115,12 +115,13 @@ func TestRepoShow(t *testing.T) {
115115 "created 2026-01-02 03:04\n"+
116116 "last push 2026-03-04 05:06\n"+
117117 "merge modes fast-forward, squash\n"+
118- "failing checks block true\n", res.stdout)
118+ "failing checks block true\n"+
119+ "issues off\n", res.stdout)
119120 _, reqs := f.snapshot()
120121 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
121122
122123 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
123- assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)
124+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout)
124125 }
125126
126127 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -176,7 +177,7 @@ func TestRepoCreateJSON(t *testing.T) {
176177 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
177178 }}
178179 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
179- assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)
180+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout)
180181 }
181182
182183 func TestRepoEditMask(t *testing.T) {
M internal/app/cli_token.go +22 -0
@@ -90,6 +90,8 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
9090 " agent repo:write, mr:write\n" +
9191 " runner runner:register (a CI runner host)\n" +
9292 " ci repo:read\n" +
93+ "Scopes are repo:read, repo:write, repo:admin, mr:write, mr:merge, issue:write and more; give\n" +
94+ "issue:write (a server with the issues feature) to let a token write issues.\n" +
9395 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
9496 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
9597 "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
@@ -131,6 +133,18 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
131133 return err
132134 }
133135 }
136+ // A preset such as cli grows with the contract and must keep working on an
137+ // older server: issue:write from a preset alone is dropped there, while an
138+ // explicit --scope issue:write is refused.
139+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
140+ if err := requireIssues(env.ctx(cmd), c); err != nil {
141+ if !errors.Is(err, errNoIssues) || explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
142+ return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
143+ }
144+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_ISSUE_WRITE })
145+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146+ }
147+ }
134148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
135149 if err != nil {
136150 return c.Describe(err)
@@ -143,6 +157,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
143157 return cmd
144158 }
145159
160+// explicitScope reports whether the --scope flags name want.
161+func explicitScope(flags []string, want ermite.Scope) bool {
162+ return slices.ContainsFunc(flags, func(f string) bool {
163+ s, err := client.ParseScope(f)
164+ return err == nil && s == want
165+ })
166+}
167+
146168 // requireBranchPrefixes refuses, before anything is created, to ask a server
147169 // for a branch-limited token when it does not list token.branch_prefixes: such
148170 // a server ignores the field. A server whose features cannot be read is refused
M internal/app/cli_token_test.go +2 -3
@@ -174,12 +174,11 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174174 "cli": client.AllScopes(),
175175 } {
176176 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
177+ f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179179 }}
180180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
181- _, reqs := f.snapshot()
182- got := reqs[0].(*ermite.CreateTokenRequest)
181+ got := lastCreateToken(t, f)
183182 assert.ElementsMatch(t, want, got.GetScopes())
184183 assert.Empty(t, got.GetRepos())
185184 assert.Nil(t, got.GetExpiresAt())
M internal/app/cli_version.go +1 -0
@@ -113,6 +113,7 @@ var featureUse = map[string]string{
113113 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115115 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116+ client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused",
116117 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117118 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
118119 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/cli_watch_test.go +1 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
M internal/app/forge.go +71 -22
@@ -75,6 +75,10 @@ type options struct {
7575 // oidcKey generates the signing keys of the ID tokens; nil generates RSA
7676 // keys (tests give prepared ones: a 3072-bit key takes a while).
7777 oidcKey func() (*rsa.PrivateKey, error)
78+ // issuePoW lowers the proof of work of the public issue form (the base
79+ // and the highest difficulty, in bits) when positive, and shortens the
80+ // minimum fill time to a nanosecond; tests only.
81+ issuePoW int
7882 }
7983
8084 // remote returns the lister of github.com refs: the test's, else def.
@@ -115,7 +119,21 @@ type forge struct {
115119 // MergeRequests and SigningKey serve MergeRequestService and
116120 // ServerService.GetSigningKey.
117121 MergeRequests *usecase.MergeRequests
118- SigningKey *usecase.SigningKey
122+ // Issues serves IssueService, and tells what a number names.
123+ Issues *usecase.Issues
124+ // IssueModeration serves the moderation calls of IssueService.
125+ IssueModeration *usecase.IssueModeration
126+ // IssueKeys are the subkeys of the public issue form, and the stores and
127+ // the limiter its submissions use (the form itself comes later).
128+ IssueKeys *usecase.IssueKeys
129+ // IssueSubmissions serves the anonymous submission of issues.
130+ IssueSubmissions *usecase.IssueSubmissions
131+ SubmissionLimiter *clientlimit.SubmissionLimiter
132+ PendingIssues ports.PendingIssueStore
133+ IssueBans ports.IssueBanStore
134+ // Inbox serves the owner's inbox of the web.
135+ Inbox *usecase.Inbox
136+ SigningKey *usecase.SigningKey
119137 // Signatures are the trusted keys and the commit signature checks
120138 // (ADR 0030).
121139 Signatures *usecase.Signatures
@@ -316,6 +334,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
316334 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
317335 })
318336 f.MergeRequests.WithVerifier(f.Signatures)
337+ f.Issues = usecase.NewIssues(usecase.IssuesConfig{
338+ Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
339+ })
340+ f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
341+ Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
342+ })
343+ f.SubmissionLimiter = clientlimit.NewSubmissionLimiter(nil)
344+ f.PendingIssues, f.IssueBans = store.PendingIssues(), store.IssueBans()
345+ f.IssueKeys, err = usecase.NewIssueKeys(ctx, store.IssueKeys())
346+ if err != nil {
347+ return nil, err
348+ }
349+ f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
350+ Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
351+ Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
352+ BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
353+ })
354+ f.Inbox = usecase.NewInbox(usecase.InboxConfig{
355+ Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
356+ })
319357 f.CI.SetPullGate(f.MergeRequests)
320358 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
321359
@@ -356,27 +394,29 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
356394 jobArtifacts, artifacts = f.CI, f.CI
357395 }
358396 f.API = rpc.New(rpc.Config{
359- Auth: f.Auth,
360- Repos: f.Repos,
361- Tokens: f.Tokens,
362- Browse: f.Browse,
363- Events: f.EventLog,
364- MergeRequests: f.MergeRequests,
365- SigningKey: f.SigningKey,
366- TrustedKeys: f.Signatures,
367- Sessions: f.Sessions,
368- Passkeys: apiPasskeys,
369- Runner: f.CI,
370- Runs: f.CI,
371- Secrets: f.Settings,
372- Variables: f.Settings,
373- Environments: f.Settings,
374- OIDCKeys: apiOIDC,
375- JobArtifacts: jobArtifacts,
376- Artifacts: artifacts,
377- Version: serverVersion(),
378- TrustedProxies: cfg.TrustedProxies,
379- Limiter: limiter,
397+ Auth: f.Auth,
398+ Repos: f.Repos,
399+ Tokens: f.Tokens,
400+ Browse: f.Browse,
401+ Events: f.EventLog,
402+ MergeRequests: f.MergeRequests,
403+ Issues: f.Issues,
404+ IssueModeration: f.IssueModeration,
405+ SigningKey: f.SigningKey,
406+ TrustedKeys: f.Signatures,
407+ Sessions: f.Sessions,
408+ Passkeys: apiPasskeys,
409+ Runner: f.CI,
410+ Runs: f.CI,
411+ Secrets: f.Settings,
412+ Variables: f.Settings,
413+ Environments: f.Settings,
414+ OIDCKeys: apiOIDC,
415+ JobArtifacts: jobArtifacts,
416+ Artifacts: artifacts,
417+ Version: serverVersion(),
418+ TrustedProxies: cfg.TrustedProxies,
419+ Limiter: limiter,
380420 })
381421
382422 gh := githttp.New(githttp.Config{
@@ -536,3 +576,12 @@ func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key do
536576 f.Browse.WithVerifier(sigs)
537577 return nil
538578 }
579+
580+// fastFill is the minimum fill time of the public issue form for a test that
581+// lowers the proof of work: next to none. Zero keeps the production value.
582+func fastFill(issuePoW int) time.Duration {
583+ if issuePoW > 0 {
584+ return time.Nanosecond
585+ }
586+ return 0
587+}
A internal/app/issue_api_test.go +245 -0
@@ -0,0 +1,245 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+ "google.golang.org/protobuf/types/known/fieldmaskpb"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// issueRig is a harness with the repositories pub and priv, and one caller per
19+// kind of principal.
20+type issueRig struct {
21+ *harness
22+ anon, root, reader, writer, blind, branch *clients
23+}
24+
25+func newIssueRig(t *testing.T) *issueRig {
26+ t.Helper()
27+ h := newHarness(t, harnessOpts{})
28+ r := &issueRig{harness: h}
29+ h.seed("pub", domain.VisibilityPublic)
30+ h.seed("priv", domain.VisibilityPrivate)
31+ r.anon, r.root = h.api(""), h.api(adminToken)
32+ // reader has no issue:write; writer reads and writes; blind holds only
33+ // issue:write; branch is writer limited to branch prefixes.
34+ r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
35+ r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeIssueWrite)))
36+ r.blind = h.api(h.token(scopes(domain.ScopeIssueWrite)))
37+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeIssueWrite), []string{"agent/"})
38+ r.branch = h.api(secret)
39+ return r
40+}
41+
42+func (r *issueRig) mode(repo string, mode domain.IssuesMode) {
43+ r.t.Helper()
44+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(repo), ports.UpdateRepoParams{Issues: &mode})
45+ require.NoError(r.t, err)
46+}
47+
48+func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
49+ r := newIssueRig(t)
50+ ctx := r.ctx
51+ type who struct {
52+ name string
53+ c *clients
54+ }
55+ all := []who{{"anonymous", r.anon}, {"reader", r.reader}, {"writer", r.writer}, {"blind", r.blind}, {"branch-limited", r.branch}, {"root", r.root}}
56+
57+ calls := func(repo string) (reads, writes map[string]func(c *clients) error) {
58+ reads = map[string]func(c *clients) error{
59+ "ListIssues": func(c *clients) error {
60+ _, err := c.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: repo}))
61+ return err
62+ },
63+ "GetIssue": func(c *clients) error {
64+ _, err := c.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: repo, Number: 1}))
65+ return err
66+ },
67+ }
68+ writes = map[string]func(c *clients) error{
69+ "CreateIssue": func(c *clients) error {
70+ _, err := c.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: repo, Title: "t", Body: "b"}))
71+ return err
72+ },
73+ "AddIssueComment": func(c *clients) error {
74+ _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75+ return err
76+ },
77+ "CloseIssue": func(c *clients) error {
78+ _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79+ return err
80+ },
81+ "ReopenIssue": func(c *clients) error {
82+ _, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
83+ return err
84+ },
85+ }
86+ return reads, writes
87+ }
88+ // setState puts issue 1 in the state a call needs to succeed.
89+ setState := func(repo, call string) {
90+ name := r.name(repo)
91+ if call == "ReopenIssue" {
92+ _, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
93+ } else {
94+ _, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
95+ }
96+ }
97+
98+ // Mode owner: the first issue of each repository is #1.
99+ for _, repo := range []string{"pub", "priv"} {
100+ r.mode(repo, domain.IssuesOwner)
101+ _, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
102+ require.NoError(t, err)
103+ }
104+
105+ readWant := map[string]map[string]string{
106+ "priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
107+ "pub": {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
108+ }
109+ writeWant := map[string]map[string]string{
110+ // A token with issue:write alone sees the repository for the action,
111+ // as mr:write does; a reader without the scope is refused.
112+ "priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
113+ "pub": {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
114+ }
115+ for _, repo := range []string{"priv", "pub"} {
116+ reads, writes := calls(repo)
117+ for _, w := range all {
118+ for name, call := range reads {
119+ assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
120+ }
121+ for name, call := range writes {
122+ setState(repo, name)
123+ assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124+ }
125+ }
126+ }
127+
128+ // Issues off, an unknown repository: not found for everyone, root included.
129+ r.mode("priv", domain.IssuesOff)
130+ r.mode("pub", domain.IssuesOff)
131+ for _, repo := range []string{"priv", "pub", "nope"} {
132+ reads, writes := calls(repo)
133+ for _, w := range all {
134+ for name, call := range reads {
135+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
136+ }
137+ for name, call := range writes {
138+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
139+ }
140+ }
141+ }
142+}
143+
144+func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
145+ r := newMRRig(t, domain.VisibilityPublic)
146+ ctx := r.ctx
147+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
148+ require.NoError(t, err)
149+
150+ mr, err := r.create(r.root)
151+ require.NoError(t, err)
152+ assert.EqualValues(t, 1, mr.GetNumber())
153+
154+ created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
155+ require.NoError(t, err)
156+ issue := created.Msg.GetIssue()
157+ assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
158+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
159+ assert.Equal(t, "root", issue.GetAuthor())
160+
161+ // An issue is not a merge request, and the other way round.
162+ _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
163+ assert.Equal(t, "not_found", codeName(err))
164+ _, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
165+ assert.Equal(t, "not_found", codeName(err))
166+
167+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
168+ require.NoError(t, err)
169+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
170+ assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
171+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
172+ assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
173+
174+ _, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
175+ assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
176+ closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
177+ require.NoError(t, err)
178+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
179+ _, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
180+ assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
181+
182+ got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
183+ require.NoError(t, err)
184+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
185+ assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
186+ require.Len(t, got.Msg.GetComments(), 1)
187+ assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
188+
189+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
190+ require.NoError(t, err)
191+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
192+
193+ list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
194+ require.NoError(t, err)
195+ require.Len(t, list.Msg.GetIssues(), 1)
196+ list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
197+ require.NoError(t, err)
198+ assert.Empty(t, list.Msg.GetIssues())
199+}
200+
201+func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
202+ r := newIssueRig(t)
203+ ctx := r.ctx
204+ ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205+ require.NoError(t, err)
206+ assert.Contains(t, ver.Msg.GetFeatures(), "issues")
207+
208+ update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209+ res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
210+ Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
211+ }))
212+ if err != nil {
213+ return nil, err
214+ }
215+ return res.Msg.GetRepo(), nil
216+ }
217+ repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
218+ require.NoError(t, err)
219+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
220+
221+ updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222+ require.NoError(t, err)
223+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224+ assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226+ require.NoError(t, err)
227+
228+ updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229+ require.NoError(t, err, "public issues are accepted")
230+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
231+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
232+ require.NoError(t, err)
233+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
234+ assert.Equal(t, "invalid_argument", codeName(err))
235+ repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
236+ require.NoError(t, err)
237+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
238+
239+ _, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
240+ assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
241+ _, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
242+ assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
243+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244+ require.NoError(t, err)
245+}
A internal/app/issue_moderation_api_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+ "crypto/rand"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+func (r *issueRig) pending(repo, title string) domain.PendingIssue {
21+ r.t.Helper()
22+ id, err := domain.NewPendingIssueID()
23+ require.NoError(r.t, err)
24+ ti, err := domain.ParseIssueTitle(title)
25+ require.NoError(r.t, err)
26+ body, err := domain.ParseIssueBody("body of " + title)
27+ require.NoError(r.t, err)
28+ hash := make([]byte, domain.IPHashLen)
29+ _, err = rand.Read(hash)
30+ require.NoError(r.t, err)
31+ p := domain.PendingIssue{
32+ ID: id, Repo: r.name(repo), Title: ti, Body: body, Author: "visitor",
33+ CreatedAt: time.Now().UTC(), IPHash: hash,
34+ }
35+ require.NoError(r.t, r.forge.PendingIssues.Put(r.ctx, p))
36+ return p
37+}
38+
39+func TestIssueModerationOverTheAPI(t *testing.T) {
40+ r := newIssueRig(t)
41+ ctx := r.ctx
42+ r.mode("pub", domain.IssuesOwner)
43+ owner := r.api(r.token(domain.AllScopes()))
44+ p1 := r.pending("pub", "first")
45+ p2 := r.pending("pub", "second")
46+
47+ // Only owner rights see anything: everyone else gets NotFound.
48+ for name, c := range map[string]*clients{"anonymous": r.anon, "reader": r.reader, "writer": r.writer, "blind": r.blind, "branch": r.branch} {
49+ _, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
50+ assert.Equal(t, "not_found", codeName(err), "%s list", name)
51+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
52+ assert.Equal(t, "not_found", codeName(err), "%s approve", name)
53+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p1.ID.String(), Ban: true}))
54+ assert.Equal(t, "not_found", codeName(err), "%s reject", name)
55+ // A malformed repository tells a non-owner nothing more.
56+ _, err = c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "Bad Repo!"}))
57+ assert.Equal(t, "not_found", codeName(err), "%s list, malformed repo", name)
58+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
59+ assert.Equal(t, "not_found", codeName(err), "%s approve, malformed repo", name)
60+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "Bad Repo!", Id: "nope"}))
61+ assert.Equal(t, "not_found", codeName(err), "%s reject, malformed repo", name)
62+ }
63+ still, err := r.forge.PendingIssues.Count(ctx)
64+ require.NoError(t, err)
65+ assert.Equal(t, 2, still, "nothing changed")
66+
67+ for name, c := range map[string]*clients{"root": r.root, "owner token": owner} {
68+ res, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
69+ require.NoError(t, err, name)
70+ require.Len(t, res.Msg.GetIssues(), 2, name)
71+ assert.Equal(t, p1.ID.String(), res.Msg.GetIssues()[0].GetId(), "oldest first")
72+ assert.Equal(t, "visitor", res.Msg.GetIssues()[0].GetAuthor())
73+ }
74+ res, err := owner.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "priv"}))
75+ require.NoError(t, err)
76+ assert.Empty(t, res.Msg.GetIssues())
77+
78+ // An owner does learn that a repository name is malformed.
79+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
80+ assert.Equal(t, "invalid_argument", codeName(err))
81+
82+ // A malformed or unknown id is NotFound.
83+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: "nope"}))
84+ assert.Equal(t, "not_found", codeName(err))
85+
86+ // Approval takes the next number shared with the merge requests and issues.
87+ made, err := owner.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "by owner"}))
88+ require.NoError(t, err)
89+ assert.Equal(t, int64(1), made.Msg.GetIssue().GetNumber())
90+ ap, err := owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
91+ require.NoError(t, err)
92+ assert.Equal(t, int64(2), ap.Msg.GetIssue().GetNumber())
93+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, ap.Msg.GetIssue().GetState())
94+ assert.Equal(t, "first", ap.Msg.GetIssue().GetTitle())
95+ got, err := r.reader.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 2}))
96+ require.NoError(t, err)
97+ assert.Equal(t, "visitor", got.Msg.GetIssue().GetAuthor())
98+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
99+ assert.Equal(t, "not_found", codeName(err), "approved once")
100+
101+ // Rejecting with a ban stores the ban and removes the record.
102+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p2.ID.String(), Ban: true}))
103+ require.NoError(t, err)
104+ banned, err := r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now())
105+ require.NoError(t, err)
106+ assert.True(t, banned)
107+ banned, err = r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now().Add(domain.BanDuration+time.Hour))
108+ require.NoError(t, err)
109+ assert.False(t, banned, "the ban expires")
110+ _, err = r.forge.PendingIssues.Get(ctx, p2.Repo, p2.ID)
111+ assert.ErrorIs(t, err, domain.ErrNotFound)
112+
113+ // Rejecting without a ban bans nobody.
114+ p3 := r.pending("pub", "third")
115+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p3.ID.String()}))
116+ require.NoError(t, err)
117+ banned, err = r.forge.IssueBans.Banned(ctx, p3.IPHash, time.Now())
118+ require.NoError(t, err)
119+ assert.False(t, banned)
120+
121+ // The inbox lists what awaits.
122+ r.pending("pub", "fourth")
123+ ov, err := r.forge.Inbox.Overview(ctx, domain.Root(), domain.RepoName{})
124+ require.NoError(t, err)
125+ assert.Equal(t, 1, ov.Counts.Pending)
126+ require.Len(t, ov.Pending, 1)
127+ assert.Nil(t, ov.Pending[0].IPHash)
128+
129+ // The public mode is accepted.
130+ pub := domain.IssuesPublic
131+ _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
132+ assert.NoError(t, err)
133+}
134+
135+// clientlimit may not import the domain: its copies of the limits must match.
136+func TestSubmissionLimiterUsesTheDomainLimits(t *testing.T) {
137+ assert.Equal(t, domain.SubmitPerHour, clientlimit.PerHour)
138+ assert.Equal(t, domain.SubmitPerDay, clientlimit.PerDay)
139+}
M internal/app/root.go +1 -1
@@ -31,7 +31,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3131 root.AddCommand(
3232 newServeCmd(), newHookCmd(),
3333 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
34+ newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
3535 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
3737 return root
M internal/app/testdata/web/blob-binary.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-code.html +3 -1
@@ -297,8 +297,9 @@
297297 <kbd>c</kbd>
298298 <kbd>r</kbd>
299299 <kbd>m</kbd>
300+<kbd>i</kbd>
300301 </dt>
301-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
302+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
302303 </div>
303304 <div>
304305 <dt>
@@ -480,6 +481,7 @@
480481 k-c from:body -> ermite.tab('c');
481482 k-r from:body -> ermite.tab('r');
482483 k-m from:body -> ermite.tab('m');
484+ k-i from:body -> ermite.tab('i');
483485 k-next from:body -> ermite.nextNote();
484486 k-split from:body -> ermite.layout('split');
485487 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-empty.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-image.html +3 -1
@@ -229,8 +229,9 @@
229229 <kbd>c</kbd>
230230 <kbd>r</kbd>
231231 <kbd>m</kbd>
232+<kbd>i</kbd>
232233 </dt>
233-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
234+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
234235 </div>
235236 <div>
236237 <dt>
@@ -412,6 +413,7 @@
412413 k-c from:body -> ermite.tab('c');
413414 k-r from:body -> ermite.tab('r');
414415 k-m from:body -> ermite.tab('m');
416+ k-i from:body -> ermite.tab('i');
415417 k-next from:body -> ermite.nextNote();
416418 k-split from:body -> ermite.layout('split');
417419 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-markdown.html +3 -1
@@ -294,8 +294,9 @@
294294 <kbd>c</kbd>
295295 <kbd>r</kbd>
296296 <kbd>m</kbd>
297+<kbd>i</kbd>
297298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299300 </div>
300301 <div>
301302 <dt>
@@ -477,6 +478,7 @@
477478 k-c from:body -> ermite.tab('c');
478479 k-r from:body -> ermite.tab('r');
479480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480482 k-next from:body -> ermite.nextNote();
481483 k-split from:body -> ermite.layout('split');
482484 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-svg.html +3 -1
@@ -252,8 +252,9 @@
252252 <kbd>c</kbd>
253253 <kbd>r</kbd>
254254 <kbd>m</kbd>
255+<kbd>i</kbd>
255256 </dt>
256-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
257+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
257258 </div>
258259 <div>
259260 <dt>
@@ -435,6 +436,7 @@
435436 k-c from:body -> ermite.tab('c');
436437 k-r from:body -> ermite.tab('r');
437438 k-m from:body -> ermite.tab('m');
439+ k-i from:body -> ermite.tab('i');
438440 k-next from:body -> ermite.nextNote();
439441 k-split from:body -> ermite.layout('split');
440442 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-text.html +3 -1
@@ -247,8 +247,9 @@
247247 <kbd>c</kbd>
248248 <kbd>r</kbd>
249249 <kbd>m</kbd>
250+<kbd>i</kbd>
250251 </dt>
251-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
252+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
252253 </div>
253254 <div>
254255 <dt>
@@ -430,6 +431,7 @@
430431 k-c from:body -> ermite.tab('c');
431432 k-r from:body -> ermite.tab('r');
432433 k-m from:body -> ermite.tab('m');
434+ k-i from:body -> ermite.tab('i');
433435 k-next from:body -> ermite.nextNote();
434436 k-split from:body -> ermite.layout('split');
435437 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-too-large.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-trojan.html +3 -1
@@ -294,8 +294,9 @@
294294 <kbd>c</kbd>
295295 <kbd>r</kbd>
296296 <kbd>m</kbd>
297+<kbd>i</kbd>
297298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299300 </div>
300301 <div>
301302 <dt>
@@ -477,6 +478,7 @@
477478 k-c from:body -> ermite.tab('c');
478479 k-r from:body -> ermite.tab('r');
479480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480482 k-next from:body -> ermite.nextNote();
481483 k-split from:body -> ermite.layout('split');
482484 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-big.html +3 -1
@@ -269,8 +269,9 @@
269269 <kbd>c</kbd>
270270 <kbd>r</kbd>
271271 <kbd>m</kbd>
272+<kbd>i</kbd>
272273 </dt>
273-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
274+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
274275 </div>
275276 <div>
276277 <dt>
@@ -452,6 +453,7 @@
452453 k-c from:body -> ermite.tab('c');
453454 k-r from:body -> ermite.tab('r');
454455 k-m from:body -> ermite.tab('m');
456+ k-i from:body -> ermite.tab('i');
455457 k-next from:body -> ermite.nextNote();
456458 k-split from:body -> ermite.layout('split');
457459 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-binary.html +3 -1
@@ -261,8 +261,9 @@
261261 <kbd>c</kbd>
262262 <kbd>r</kbd>
263263 <kbd>m</kbd>
264+<kbd>i</kbd>
264265 </dt>
265-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
266+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
266267 </div>
267268 <div>
268269 <dt>
@@ -444,6 +445,7 @@
444445 k-c from:body -> ermite.tab('c');
445446 k-r from:body -> ermite.tab('r');
446447 k-m from:body -> ermite.tab('m');
448+ k-i from:body -> ermite.tab('i');
447449 k-next from:body -> ermite.nextNote();
448450 k-split from:body -> ermite.layout('split');
449451 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-merge.html +3 -1
@@ -303,8 +303,9 @@
303303 <kbd>c</kbd>
304304 <kbd>r</kbd>
305305 <kbd>m</kbd>
306+<kbd>i</kbd>
306307 </dt>
307-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
308+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
308309 </div>
309310 <div>
310311 <dt>
@@ -486,6 +487,7 @@
486487 k-c from:body -> ermite.tab('c');
487488 k-r from:body -> ermite.tab('r');
488489 k-m from:body -> ermite.tab('m');
490+ k-i from:body -> ermite.tab('i');
489491 k-next from:body -> ermite.nextNote();
490492 k-split from:body -> ermite.layout('split');
491493 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify-split.html +3 -1
@@ -426,8 +426,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
426426 <kbd>c</kbd>
427427 <kbd>r</kbd>
428428 <kbd>m</kbd>
429+<kbd>i</kbd>
429430 </dt>
430-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
431432 </div>
432433 <div>
433434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609610 k-c from:body -> ermite.tab('c');
610611 k-r from:body -> ermite.tab('r');
611612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612614 k-next from:body -> ermite.nextNote();
613615 k-split from:body -> ermite.layout('split');
614616 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify.html +3 -1
@@ -426,8 +426,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
426426 <kbd>c</kbd>
427427 <kbd>r</kbd>
428428 <kbd>m</kbd>
429+<kbd>i</kbd>
429430 </dt>
430-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
431432 </div>
432433 <div>
433434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609610 k-c from:body -> ermite.tab('c');
610611 k-r from:body -> ermite.tab('r');
611612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612614 k-next from:body -> ermite.nextNote();
613615 k-split from:body -> ermite.layout('split');
614616 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-rename.html +3 -1
@@ -330,8 +330,9 @@
330330 <kbd>c</kbd>
331331 <kbd>r</kbd>
332332 <kbd>m</kbd>
333+<kbd>i</kbd>
333334 </dt>
334-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
335+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
335336 </div>
336337 <div>
337338 <dt>
@@ -513,6 +514,7 @@
513514 k-c from:body -> ermite.tab('c');
514515 k-r from:body -> ermite.tab('r');
515516 k-m from:body -> ermite.tab('m');
517+ k-i from:body -> ermite.tab('i');
516518 k-next from:body -> ermite.nextNote();
517519 k-split from:body -> ermite.layout('split');
518520 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-root.html +3 -1
@@ -649,8 +649,9 @@
649649 <kbd>c</kbd>
650650 <kbd>r</kbd>
651651 <kbd>m</kbd>
652+<kbd>i</kbd>
652653 </dt>
653-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
654+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
654655 </div>
655656 <div>
656657 <dt>
@@ -832,6 +833,7 @@
832833 k-c from:body -> ermite.tab('c');
833834 k-r from:body -> ermite.tab('r');
834835 k-m from:body -> ermite.tab('m');
836+ k-i from:body -> ermite.tab('i');
835837 k-next from:body -> ermite.nextNote();
836838 k-split from:body -> ermite.layout('split');
837839 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-trojan.html +3 -1
@@ -478,8 +478,9 @@
478478 <kbd>c</kbd>
479479 <kbd>r</kbd>
480480 <kbd>m</kbd>
481+<kbd>i</kbd>
481482 </dt>
482-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
483+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
483484 </div>
484485 <div>
485486 <dt>
@@ -661,6 +662,7 @@
661662 k-c from:body -> ermite.tab('c');
662663 k-r from:body -> ermite.tab('r');
663664 k-m from:body -> ermite.tab('m');
665+ k-i from:body -> ermite.tab('i');
664666 k-next from:body -> ermite.nextNote();
665667 k-split from:body -> ermite.layout('split');
666668 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-page2.html +3 -1
@@ -254,8 +254,9 @@
254254 <kbd>c</kbd>
255255 <kbd>r</kbd>
256256 <kbd>m</kbd>
257+<kbd>i</kbd>
257258 </dt>
258-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
259260 </div>
260261 <div>
261262 <dt>
@@ -423,6 +424,7 @@
423424 k-c from:body -> ermite.tab('c');
424425 k-r from:body -> ermite.tab('r');
425426 k-m from:body -> ermite.tab('m');
427+ k-i from:body -> ermite.tab('i');
426428 k-next from:body -> ermite.nextNote();
427429 k-split from:body -> ermite.layout('split');
428430 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-path.html +3 -1
@@ -269,8 +269,9 @@
269269 <kbd>c</kbd>
270270 <kbd>r</kbd>
271271 <kbd>m</kbd>
272+<kbd>i</kbd>
272273 </dt>
273-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
274+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
274275 </div>
275276 <div>
276277 <dt>
@@ -438,6 +439,7 @@
438439 k-c from:body -> ermite.tab('c');
439440 k-r from:body -> ermite.tab('r');
440441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441443 k-next from:body -> ermite.nextNote();
442444 k-split from:body -> ermite.layout('split');
443445 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-root.html +4 -2
@@ -972,7 +972,7 @@
972972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 htmx:sse:before:connection -> ermiteLive.gate(event);
975- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
975+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976976 gap -> ermiteLive.reload()">
977977 </span>
978978 </div>
@@ -1058,8 +1058,9 @@
10581058 <kbd>c</kbd>
10591059 <kbd>r</kbd>
10601060 <kbd>m</kbd>
1061+<kbd>i</kbd>
10611062 </dt>
1062-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
1063+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
10631064 </div>
10641065 <div>
10651066 <dt>
@@ -1227,6 +1228,7 @@
12271228 k-c from:body -> ermite.tab('c');
12281229 k-r from:body -> ermite.tab('r');
12291230 k-m from:body -> ermite.tab('m');
1231+ k-i from:body -> ermite.tab('i');
12301232 k-next from:body -> ermite.nextNote();
12311233 k-split from:body -> ermite.layout('split');
12321234 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-small.html +4 -2
@@ -317,7 +317,7 @@
317317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 htmx:sse:before:connection -> ermiteLive.gate(event);
320- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
320+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321321 gap -> ermiteLive.reload()">
322322 </span>
323323 </div>
@@ -403,8 +403,9 @@
403403 <kbd>c</kbd>
404404 <kbd>r</kbd>
405405 <kbd>m</kbd>
406+<kbd>i</kbd>
406407 </dt>
407-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
408+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
408409 </div>
409410 <div>
410411 <dt>
@@ -592,6 +593,7 @@
592593 k-c from:body -> ermite.tab('c');
593594 k-r from:body -> ermite.tab('r');
594595 k-m from:body -> ermite.tab('m');
596+ k-i from:body -> ermite.tab('i');
595597 k-next from:body -> ermite.nextNote();
596598 k-split from:body -> ermite.layout('split');
597599 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/empty.html +3 -1
@@ -176,8 +176,9 @@
176176 <kbd>c</kbd>
177177 <kbd>r</kbd>
178178 <kbd>m</kbd>
179+<kbd>i</kbd>
179180 </dt>
180-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
181+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
181182 </div>
182183 <div>
183184 <dt>
@@ -276,6 +277,7 @@
276277 k-c from:body -> ermite.tab('c');
277278 k-r from:body -> ermite.tab('r');
278279 k-m from:body -> ermite.tab('m');
280+ k-i from:body -> ermite.tab('i');
279281 k-next from:body -> ermite.nextNote();
280282 k-split from:body -> ermite.layout('split');
281283 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/home.html +4 -2
@@ -191,7 +191,7 @@
191191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
192192 htmx:sse:error -> ermiteLive.error(event.detail.status);
193193 htmx:sse:before:connection -> ermiteLive.gate(event);
194- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
194+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
195195 gap -> ermiteLive.reload()">
196196 </span>
197197 </div>
@@ -277,8 +277,9 @@
277277 <kbd>c</kbd>
278278 <kbd>r</kbd>
279279 <kbd>m</kbd>
280+<kbd>i</kbd>
280281 </dt>
281-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
282+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
282283 </div>
283284 <div>
284285 <dt>
@@ -383,6 +384,7 @@
383384 k-c from:body -> ermite.tab('c');
384385 k-r from:body -> ermite.tab('r');
385386 k-m from:body -> ermite.tab('m');
387+ k-i from:body -> ermite.tab('i');
386388 k-next from:body -> ermite.nextNote();
387389 k-split from:body -> ermite.layout('split');
388390 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found-page.html +3 -1
@@ -165,8 +165,9 @@
165165 <kbd>c</kbd>
166166 <kbd>r</kbd>
167167 <kbd>m</kbd>
168+<kbd>i</kbd>
168169 </dt>
169-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
170+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
170171 </div>
171172 <div>
172173 <dt>
@@ -259,6 +260,7 @@
259260 k-c from:body -> ermite.tab('c');
260261 k-r from:body -> ermite.tab('r');
261262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262264 k-next from:body -> ermite.nextNote();
263265 k-split from:body -> ermite.layout('split');
264266 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found.html +3 -1
@@ -165,8 +165,9 @@
165165 <kbd>c</kbd>
166166 <kbd>r</kbd>
167167 <kbd>m</kbd>
168+<kbd>i</kbd>
168169 </dt>
169-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
170+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
170171 </div>
171172 <div>
172173 <dt>
@@ -259,6 +260,7 @@
259260 k-c from:body -> ermite.tab('c');
260261 k-r from:body -> ermite.tab('r');
261262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262264 k-next from:body -> ermite.nextNote();
263265 k-split from:body -> ermite.layout('split');
264266 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/refs.html +4 -2
@@ -199,7 +199,7 @@
199199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 htmx:sse:before:connection -> ermiteLive.gate(event);
202- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
202+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203203 gap -> ermiteLive.reload()">
204204 </span>
205205 </div>
@@ -285,8 +285,9 @@
285285 <kbd>c</kbd>
286286 <kbd>r</kbd>
287287 <kbd>m</kbd>
288+<kbd>i</kbd>
288289 </dt>
289-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
290+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
290291 </div>
291292 <div>
292293 <dt>
@@ -484,6 +485,7 @@
484485 k-c from:body -> ermite.tab('c');
485486 k-r from:body -> ermite.tab('r');
486487 k-m from:body -> ermite.tab('m');
488+ k-i from:body -> ermite.tab('i');
487489 k-next from:body -> ermite.nextNote();
488490 k-split from:body -> ermite.layout('split');
489491 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/summary.html +4 -2
@@ -225,7 +225,7 @@
225225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 htmx:sse:before:connection -> ermiteLive.gate(event);
228- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
228+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229229 gap -> ermiteLive.reload()">
230230 </span>
231231 </div>
@@ -311,8 +311,9 @@
311311 <kbd>c</kbd>
312312 <kbd>r</kbd>
313313 <kbd>m</kbd>
314+<kbd>i</kbd>
314315 </dt>
315-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
316+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
316317 </div>
317318 <div>
318319 <dt>
@@ -510,6 +511,7 @@
510511 k-c from:body -> ermite.tab('c');
511512 k-r from:body -> ermite.tab('r');
512513 k-m from:body -> ermite.tab('m');
514+ k-i from:body -> ermite.tab('i');
513515 k-next from:body -> ermite.nextNote();
514516 k-split from:body -> ermite.layout('split');
515517 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-nested.html +3 -1
@@ -242,8 +242,9 @@
242242 <kbd>c</kbd>
243243 <kbd>r</kbd>
244244 <kbd>m</kbd>
245+<kbd>i</kbd>
245246 </dt>
246-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
247+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
247248 </div>
248249 <div>
249250 <dt>
@@ -425,6 +426,7 @@
425426 k-c from:body -> ermite.tab('c');
426427 k-r from:body -> ermite.tab('r');
427428 k-m from:body -> ermite.tab('m');
429+ k-i from:body -> ermite.tab('i');
428430 k-next from:body -> ermite.nextNote();
429431 k-split from:body -> ermite.layout('split');
430432 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-root.html +3 -1
@@ -463,8 +463,9 @@
463463 <kbd>c</kbd>
464464 <kbd>r</kbd>
465465 <kbd>m</kbd>
466+<kbd>i</kbd>
466467 </dt>
467-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
468+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
468469 </div>
469470 <div>
470471 <dt>
@@ -646,6 +647,7 @@
646647 k-c from:body -> ermite.tab('c');
647648 k-r from:body -> ermite.tab('r');
648649 k-m from:body -> ermite.tab('m');
650+ k-i from:body -> ermite.tab('i');
649651 k-next from:body -> ermite.nextNote();
650652 k-split from:body -> ermite.layout('split');
651653 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-slash-ref.html +3 -1
@@ -239,8 +239,9 @@
239239 <kbd>c</kbd>
240240 <kbd>r</kbd>
241241 <kbd>m</kbd>
242+<kbd>i</kbd>
242243 </dt>
243-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
244+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
244245 </div>
245246 <div>
246247 <dt>
@@ -422,6 +423,7 @@
422423 k-c from:body -> ermite.tab('c');
423424 k-r from:body -> ermite.tab('r');
424425 k-m from:body -> ermite.tab('m');
426+ k-i from:body -> ermite.tab('i');
425427 k-next from:body -> ermite.nextNote();
426428 k-split from:body -> ermite.layout('split');
427429 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-subdir.html +3 -1
@@ -255,8 +255,9 @@
255255 <kbd>c</kbd>
256256 <kbd>r</kbd>
257257 <kbd>m</kbd>
258+<kbd>i</kbd>
258259 </dt>
259-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
260+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
260261 </div>
261262 <div>
262263 <dt>
@@ -438,6 +439,7 @@
438439 k-c from:body -> ermite.tab('c');
439440 k-r from:body -> ermite.tab('r');
440441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441443 k-next from:body -> ermite.nextNote();
442444 k-split from:body -> ermite.layout('split');
443445 k-unified from:body -> ermite.layout('unified');
M internal/app/web.go +5 -0
@@ -45,6 +45,11 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
4545 Repos: f.Repos,
4646 Browse: f.Browse,
4747 MergeRequests: f.MergeRequests,
48+ Issues: f.Issues,
49+ IssueNumbers: f.Issues,
50+ Inbox: f.Inbox,
51+ IssueForm: f.IssueSubmissions,
52+ Moderation: f.IssueModeration,
4853 Runs: f.CI,
4954 Artifacts: artifacts,
5055 Events: f.EventLog,
A internal/app/web_inbox_test.go +152 -0
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bufio"
7+ "context"
8+ "net/http"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The owner's inbox end to end: two repositories with open merge requests and
20+// issues, the filter, the home button, the owner stream, and a 404 for
21+// everyone who is not the signed-in owner.
22+func TestWebInboxEndToEnd(t *testing.T) {
23+ r := newMRRepo(t, domain.VisibilityPublic)
24+ b := browser{r.harness}
25+ token := r.signIn(t)
26+
27+ _, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate))
28+ require.NoError(t, err)
29+ otherURL := r.urlAuth(adminToken, "/other.git")
30+ r.mustGit(r.wt, "push", "-q", otherURL, "main")
31+ r.mustGit(r.wt, "push", "-q", otherURL, "feat/search")
32+
33+ owner := domain.IssuesOwner
34+ for _, n := range []string{"proj", "other"} {
35+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner})
36+ require.NoError(t, err)
37+ }
38+
39+ // Nothing is open yet.
40+ empty := b.get("/-/inbox", as(token))
41+ require.Equal(t, 200, empty.status, empty.body)
42+ assert.Contains(t, empty.body, "no open merge requests.")
43+ assert.Contains(t, empty.body, "no open issues.")
44+
45+ // The owner stream sees both repositories.
46+ stream := ownerStream(t, r.harness, token)
47+
48+ root := domain.Root()
49+ _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
50+ require.NoError(t, err)
51+ ev := stream.next(t, watchWait)
52+ assert.Equal(t, "inbox", ev.name)
53+ assert.Contains(t, ev.data, `id="inbox-counts"`)
54+ for range 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) }
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) }
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+}
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+}
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+}
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 // EventGap tells a subscriber that it missed events because it was too slow;
177249 // it must refresh its whole view.
178250 type EventGap struct{}
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+)
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+}
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.
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+}
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+}
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 }
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+}
M internal/domain/token.go +5 -1
@@ -38,15 +38,19 @@ const (
3838 ScopeRunnerRegister Scope = "runner:register"
3939 // ScopeTokenAdmin creates, lists and revokes stored tokens.
4040 ScopeTokenAdmin Scope = "token:admin"
41+ // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation
42+ // is not part of it: it stays with the owner.
43+ ScopeIssueWrite Scope = "issue:write"
4144 )
4245
43-// AllScopes returns the nine scopes in a stable order.
46+// AllScopes returns the ten scopes in a stable order.
4447 func AllScopes() []Scope {
4548 return []Scope{
4649 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
4750 ScopeMRWrite, ScopeMRMerge,
4851 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
4952 ScopeTokenAdmin,
53+ ScopeIssueWrite,
5054 }
5155 }
5256
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, 9)
19+ assert.Len(t, all, 10)
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
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
@@ -274,3 +322,24 @@ type LoginLink struct {
274322 URL string
275323 ExpiresAt time.Time
276324 }
325+
326+// InboxCounts are the numbers of open items across every repository, whatever
327+// the filter.
328+type InboxCounts struct{ MergeRequests, Issues, Pending int }
329+
330+// InboxOverview is what the owner has to look at across every repository: the
331+// open merge requests and the open issues, most recently updated first. A
332+// section for the items awaiting moderation can be added next to them.
333+type InboxOverview struct {
334+ Counts InboxCounts
335+ // MergeRequests and Issues are those of the filtered repository, or of
336+ // every repository when there is no filter. Counts always counts all.
337+ MergeRequests []domain.MergeRequest
338+ Issues []domain.Issue
339+ // Pending are the issues awaiting moderation, in every repository
340+ // whatever the filter, oldest first. At most domain.MaxPendingIssues.
341+ Pending []domain.PendingIssue
342+ // Repos are the repositories that have at least one open item, by name,
343+ // whatever the filter: they feed the filter itself.
344+ Repos []domain.RepoName
345+}
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+}
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+}
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+}
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+}
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+}
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+}
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+}
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+}
M internal/ports/mocks/MergeRequestStore.go +62 -0
@@ -611,6 +611,68 @@ func (_c *MergeRequestStore_ListOpen_Call) RunAndReturn(run func(ctx context.Con
611611 return _c
612612 }
613613
614+// ListOpenAll provides a mock function for the type MergeRequestStore
615+func (_mock *MergeRequestStore) ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error) {
616+ ret := _mock.Called(ctx)
617+
618+ if len(ret) == 0 {
619+ panic("no return value specified for ListOpenAll")
620+ }
621+
622+ var r0 []domain.MergeRequest
623+ var r1 error
624+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.MergeRequest, error)); ok {
625+ return returnFunc(ctx)
626+ }
627+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.MergeRequest); ok {
628+ r0 = returnFunc(ctx)
629+ } else {
630+ if ret.Get(0) != nil {
631+ r0 = ret.Get(0).([]domain.MergeRequest)
632+ }
633+ }
634+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
635+ r1 = returnFunc(ctx)
636+ } else {
637+ r1 = ret.Error(1)
638+ }
639+ return r0, r1
640+}
641+
642+// MergeRequestStore_ListOpenAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenAll'
643+type MergeRequestStore_ListOpenAll_Call struct {
644+ *mock.Call
645+}
646+
647+// ListOpenAll is a helper method to define mock.On call
648+// - ctx context.Context
649+func (_e *MergeRequestStore_Expecter) ListOpenAll(ctx any) *MergeRequestStore_ListOpenAll_Call {
650+ return &MergeRequestStore_ListOpenAll_Call{Call: _e.mock.On("ListOpenAll", ctx)}
651+}
652+
653+func (_c *MergeRequestStore_ListOpenAll_Call) Run(run func(ctx context.Context)) *MergeRequestStore_ListOpenAll_Call {
654+ _c.Call.Run(func(args mock.Arguments) {
655+ var arg0 context.Context
656+ if args[0] != nil {
657+ arg0 = args[0].(context.Context)
658+ }
659+ run(
660+ arg0,
661+ )
662+ })
663+ return _c
664+}
665+
666+func (_c *MergeRequestStore_ListOpenAll_Call) Return(mergeRequests []domain.MergeRequest, err error) *MergeRequestStore_ListOpenAll_Call {
667+ _c.Call.Return(mergeRequests, err)
668+ return _c
669+}
670+
671+func (_c *MergeRequestStore_ListOpenAll_Call) RunAndReturn(run func(ctx context.Context) ([]domain.MergeRequest, error)) *MergeRequestStore_ListOpenAll_Call {
672+ _c.Call.Return(run)
673+ return _c
674+}
675+
614676 // OpenBySource provides a mock function for the type MergeRequestStore
615677 func (_mock *MergeRequestStore) OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) ([]int64, bool, error) {
616678 ret := _mock.Called(ctx, repo, branches, limit)
A internal/ports/mocks/PendingIssueStore.go +452 -0
@@ -0,0 +1,452 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewPendingIssueStore creates a new instance of PendingIssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewPendingIssueStore(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *PendingIssueStore {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &PendingIssueStore{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// PendingIssueStore is an autogenerated mock type for the PendingIssueStore type
40+type PendingIssueStore struct {
41+ mock.Mock
42+}
43+
44+type PendingIssueStore_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *PendingIssueStore) EXPECT() *PendingIssueStore_Expecter {
49+ return &PendingIssueStore_Expecter{mock: &_m.Mock}
50+}
51+
52+// Approve provides a mock function for the type PendingIssueStore
53+func (_mock *PendingIssueStore) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
54+ ret := _mock.Called(ctx, repo, id, now)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for Approve")
58+ }
59+
60+ var r0 domain.Issue
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) (domain.Issue, error)); ok {
63+ return returnFunc(ctx, repo, id, now)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) domain.Issue); ok {
66+ r0 = returnFunc(ctx, repo, id, now)
67+ } else {
68+ r0 = ret.Get(0).(domain.Issue)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) error); ok {
71+ r1 = returnFunc(ctx, repo, id, now)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// PendingIssueStore_Approve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Approve'
79+type PendingIssueStore_Approve_Call struct {
80+ *mock.Call
81+}
82+
83+// Approve is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - id domain.PendingIssueID
87+// - now time.Time
88+func (_e *PendingIssueStore_Expecter) Approve(ctx any, repo any, id any, now any) *PendingIssueStore_Approve_Call {
89+ return &PendingIssueStore_Approve_Call{Call: _e.mock.On("Approve", ctx, repo, id, now)}
90+}
91+
92+func (_c *PendingIssueStore_Approve_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time)) *PendingIssueStore_Approve_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 domain.PendingIssueID
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.PendingIssueID)
105+ }
106+ var arg3 time.Time
107+ if args[3] != nil {
108+ arg3 = args[3].(time.Time)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *PendingIssueStore_Approve_Call) Return(issue domain.Issue, err error) *PendingIssueStore_Approve_Call {
121+ _c.Call.Return(issue, err)
122+ return _c
123+}
124+
125+func (_c *PendingIssueStore_Approve_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error)) *PendingIssueStore_Approve_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// Count provides a mock function for the type PendingIssueStore
131+func (_mock *PendingIssueStore) Count(ctx context.Context) (int, error) {
132+ ret := _mock.Called(ctx)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for Count")
136+ }
137+
138+ var r0 int
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context) (int, error)); ok {
141+ return returnFunc(ctx)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context) int); ok {
144+ r0 = returnFunc(ctx)
145+ } else {
146+ r0 = ret.Get(0).(int)
147+ }
148+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
149+ r1 = returnFunc(ctx)
150+ } else {
151+ r1 = ret.Error(1)
152+ }
153+ return r0, r1
154+}
155+
156+// PendingIssueStore_Count_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Count'
157+type PendingIssueStore_Count_Call struct {
158+ *mock.Call
159+}
160+
161+// Count is a helper method to define mock.On call
162+// - ctx context.Context
163+func (_e *PendingIssueStore_Expecter) Count(ctx any) *PendingIssueStore_Count_Call {
164+ return &PendingIssueStore_Count_Call{Call: _e.mock.On("Count", ctx)}
165+}
166+
167+func (_c *PendingIssueStore_Count_Call) Run(run func(ctx context.Context)) *PendingIssueStore_Count_Call {
168+ _c.Call.Run(func(args mock.Arguments) {
169+ var arg0 context.Context
170+ if args[0] != nil {
171+ arg0 = args[0].(context.Context)
172+ }
173+ run(
174+ arg0,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *PendingIssueStore_Count_Call) Return(n int, err error) *PendingIssueStore_Count_Call {
181+ _c.Call.Return(n, err)
182+ return _c
183+}
184+
185+func (_c *PendingIssueStore_Count_Call) RunAndReturn(run func(ctx context.Context) (int, error)) *PendingIssueStore_Count_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
189+
190+// Delete provides a mock function for the type PendingIssueStore
191+func (_mock *PendingIssueStore) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
192+ ret := _mock.Called(ctx, repo, id)
193+
194+ if len(ret) == 0 {
195+ panic("no return value specified for Delete")
196+ }
197+
198+ var r0 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) error); ok {
200+ r0 = returnFunc(ctx, repo, id)
201+ } else {
202+ r0 = ret.Error(0)
203+ }
204+ return r0
205+}
206+
207+// PendingIssueStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
208+type PendingIssueStore_Delete_Call struct {
209+ *mock.Call
210+}
211+
212+// Delete is a helper method to define mock.On call
213+// - ctx context.Context
214+// - repo domain.RepoName
215+// - id domain.PendingIssueID
216+func (_e *PendingIssueStore_Expecter) Delete(ctx any, repo any, id any) *PendingIssueStore_Delete_Call {
217+ return &PendingIssueStore_Delete_Call{Call: _e.mock.On("Delete", ctx, repo, id)}
218+}
219+
220+func (_c *PendingIssueStore_Delete_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID)) *PendingIssueStore_Delete_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 context.Context
223+ if args[0] != nil {
224+ arg0 = args[0].(context.Context)
225+ }
226+ var arg1 domain.RepoName
227+ if args[1] != nil {
228+ arg1 = args[1].(domain.RepoName)
229+ }
230+ var arg2 domain.PendingIssueID
231+ if args[2] != nil {
232+ arg2 = args[2].(domain.PendingIssueID)
233+ }
234+ run(
235+ arg0,
236+ arg1,
237+ arg2,
238+ )
239+ })
240+ return _c
241+}
242+
243+func (_c *PendingIssueStore_Delete_Call) Return(err error) *PendingIssueStore_Delete_Call {
244+ _c.Call.Return(err)
245+ return _c
246+}
247+
248+func (_c *PendingIssueStore_Delete_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error) *PendingIssueStore_Delete_Call {
249+ _c.Call.Return(run)
250+ return _c
251+}
252+
253+// Get provides a mock function for the type PendingIssueStore
254+func (_mock *PendingIssueStore) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
255+ ret := _mock.Called(ctx, repo, id)
256+
257+ if len(ret) == 0 {
258+ panic("no return value specified for Get")
259+ }
260+
261+ var r0 domain.PendingIssue
262+ var r1 error
263+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) (domain.PendingIssue, error)); ok {
264+ return returnFunc(ctx, repo, id)
265+ }
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) domain.PendingIssue); ok {
267+ r0 = returnFunc(ctx, repo, id)
268+ } else {
269+ r0 = ret.Get(0).(domain.PendingIssue)
270+ }
271+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.PendingIssueID) error); ok {
272+ r1 = returnFunc(ctx, repo, id)
273+ } else {
274+ r1 = ret.Error(1)
275+ }
276+ return r0, r1
277+}
278+
279+// PendingIssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
280+type PendingIssueStore_Get_Call struct {
281+ *mock.Call
282+}
283+
284+// Get is a helper method to define mock.On call
285+// - ctx context.Context
286+// - repo domain.RepoName
287+// - id domain.PendingIssueID
288+func (_e *PendingIssueStore_Expecter) Get(ctx any, repo any, id any) *PendingIssueStore_Get_Call {
289+ return &PendingIssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, id)}
290+}
291+
292+func (_c *PendingIssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID)) *PendingIssueStore_Get_Call {
293+ _c.Call.Run(func(args mock.Arguments) {
294+ var arg0 context.Context
295+ if args[0] != nil {
296+ arg0 = args[0].(context.Context)
297+ }
298+ var arg1 domain.RepoName
299+ if args[1] != nil {
300+ arg1 = args[1].(domain.RepoName)
301+ }
302+ var arg2 domain.PendingIssueID
303+ if args[2] != nil {
304+ arg2 = args[2].(domain.PendingIssueID)
305+ }
306+ run(
307+ arg0,
308+ arg1,
309+ arg2,
310+ )
311+ })
312+ return _c
313+}
314+
315+func (_c *PendingIssueStore_Get_Call) Return(pendingIssue domain.PendingIssue, err error) *PendingIssueStore_Get_Call {
316+ _c.Call.Return(pendingIssue, err)
317+ return _c
318+}
319+
320+func (_c *PendingIssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error)) *PendingIssueStore_Get_Call {
321+ _c.Call.Return(run)
322+ return _c
323+}
324+
325+// List provides a mock function for the type PendingIssueStore
326+func (_mock *PendingIssueStore) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
327+ ret := _mock.Called(ctx, repo, page)
328+
329+ if len(ret) == 0 {
330+ panic("no return value specified for List")
331+ }
332+
333+ var r0 ports.PendingIssuePage
334+ var r1 error
335+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListPendingParams) (ports.PendingIssuePage, error)); ok {
336+ return returnFunc(ctx, repo, page)
337+ }
338+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListPendingParams) ports.PendingIssuePage); ok {
339+ r0 = returnFunc(ctx, repo, page)
340+ } else {
341+ r0 = ret.Get(0).(ports.PendingIssuePage)
342+ }
343+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListPendingParams) error); ok {
344+ r1 = returnFunc(ctx, repo, page)
345+ } else {
346+ r1 = ret.Error(1)
347+ }
348+ return r0, r1
349+}
350+
351+// PendingIssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
352+type PendingIssueStore_List_Call struct {
353+ *mock.Call
354+}
355+
356+// List is a helper method to define mock.On call
357+// - ctx context.Context
358+// - repo domain.RepoName
359+// - page ports.ListPendingParams
360+func (_e *PendingIssueStore_Expecter) List(ctx any, repo any, page any) *PendingIssueStore_List_Call {
361+ return &PendingIssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, page)}
362+}
363+
364+func (_c *PendingIssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams)) *PendingIssueStore_List_Call {
365+ _c.Call.Run(func(args mock.Arguments) {
366+ var arg0 context.Context
367+ if args[0] != nil {
368+ arg0 = args[0].(context.Context)
369+ }
370+ var arg1 domain.RepoName
371+ if args[1] != nil {
372+ arg1 = args[1].(domain.RepoName)
373+ }
374+ var arg2 ports.ListPendingParams
375+ if args[2] != nil {
376+ arg2 = args[2].(ports.ListPendingParams)
377+ }
378+ run(
379+ arg0,
380+ arg1,
381+ arg2,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *PendingIssueStore_List_Call) Return(pendingIssuePage ports.PendingIssuePage, err error) *PendingIssueStore_List_Call {
388+ _c.Call.Return(pendingIssuePage, err)
389+ return _c
390+}
391+
392+func (_c *PendingIssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error)) *PendingIssueStore_List_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// Put provides a mock function for the type PendingIssueStore
398+func (_mock *PendingIssueStore) Put(ctx context.Context, p domain.PendingIssue) error {
399+ ret := _mock.Called(ctx, p)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for Put")
403+ }
404+
405+ var r0 error
406+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.PendingIssue) error); ok {
407+ r0 = returnFunc(ctx, p)
408+ } else {
409+ r0 = ret.Error(0)
410+ }
411+ return r0
412+}
413+
414+// PendingIssueStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
415+type PendingIssueStore_Put_Call struct {
416+ *mock.Call
417+}
418+
419+// Put is a helper method to define mock.On call
420+// - ctx context.Context
421+// - p domain.PendingIssue
422+func (_e *PendingIssueStore_Expecter) Put(ctx any, p any) *PendingIssueStore_Put_Call {
423+ return &PendingIssueStore_Put_Call{Call: _e.mock.On("Put", ctx, p)}
424+}
425+
426+func (_c *PendingIssueStore_Put_Call) Run(run func(ctx context.Context, p domain.PendingIssue)) *PendingIssueStore_Put_Call {
427+ _c.Call.Run(func(args mock.Arguments) {
428+ var arg0 context.Context
429+ if args[0] != nil {
430+ arg0 = args[0].(context.Context)
431+ }
432+ var arg1 domain.PendingIssue
433+ if args[1] != nil {
434+ arg1 = args[1].(domain.PendingIssue)
435+ }
436+ run(
437+ arg0,
438+ arg1,
439+ )
440+ })
441+ return _c
442+}
443+
444+func (_c *PendingIssueStore_Put_Call) Return(err error) *PendingIssueStore_Put_Call {
445+ _c.Call.Return(err)
446+ return _c
447+}
448+
449+func (_c *PendingIssueStore_Put_Call) RunAndReturn(run func(ctx context.Context, p domain.PendingIssue) error) *PendingIssueStore_Put_Call {
450+ _c.Call.Return(run)
451+ return _c
452+}
A internal/ports/mocks/SubmissionLimiter.go +188 -0
@@ -0,0 +1,188 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewSubmissionLimiter creates a new instance of SubmissionLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewSubmissionLimiter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *SubmissionLimiter {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &SubmissionLimiter{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// SubmissionLimiter is an autogenerated mock type for the SubmissionLimiter type
38+type SubmissionLimiter struct {
39+ mock.Mock
40+}
41+
42+type SubmissionLimiter_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *SubmissionLimiter) EXPECT() *SubmissionLimiter_Expecter {
47+ return &SubmissionLimiter_Expecter{mock: &_m.Mock}
48+}
49+
50+// Recent provides a mock function for the type SubmissionLimiter
51+func (_mock *SubmissionLimiter) Recent(ctx context.Context, addr string) (int, int) {
52+ ret := _mock.Called(ctx, addr)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Recent")
56+ }
57+
58+ var r0 int
59+ var r1 int
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int, int)); ok {
61+ return returnFunc(ctx, addr)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) int); ok {
64+ r0 = returnFunc(ctx, addr)
65+ } else {
66+ r0 = ret.Get(0).(int)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) int); ok {
69+ r1 = returnFunc(ctx, addr)
70+ } else {
71+ r1 = ret.Get(1).(int)
72+ }
73+ return r0, r1
74+}
75+
76+// SubmissionLimiter_Recent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Recent'
77+type SubmissionLimiter_Recent_Call struct {
78+ *mock.Call
79+}
80+
81+// Recent is a helper method to define mock.On call
82+// - ctx context.Context
83+// - addr string
84+func (_e *SubmissionLimiter_Expecter) Recent(ctx any, addr any) *SubmissionLimiter_Recent_Call {
85+ return &SubmissionLimiter_Recent_Call{Call: _e.mock.On("Recent", ctx, addr)}
86+}
87+
88+func (_c *SubmissionLimiter_Recent_Call) Run(run func(ctx context.Context, addr string)) *SubmissionLimiter_Recent_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 string
95+ if args[1] != nil {
96+ arg1 = args[1].(string)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *SubmissionLimiter_Recent_Call) Return(perAddr int, global int) *SubmissionLimiter_Recent_Call {
107+ _c.Call.Return(perAddr, global)
108+ return _c
109+}
110+
111+func (_c *SubmissionLimiter_Recent_Call) RunAndReturn(run func(ctx context.Context, addr string) (int, int)) *SubmissionLimiter_Recent_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
115+
116+// Reserve provides a mock function for the type SubmissionLimiter
117+func (_mock *SubmissionLimiter) Reserve(ctx context.Context, addr string) (bool, time.Duration, func()) {
118+ ret := _mock.Called(ctx, addr)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for Reserve")
122+ }
123+
124+ var r0 bool
125+ var r1 time.Duration
126+ var r2 func()
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, time.Duration, func())); ok {
128+ return returnFunc(ctx, addr)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
131+ r0 = returnFunc(ctx, addr)
132+ } else {
133+ r0 = ret.Get(0).(bool)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) time.Duration); ok {
136+ r1 = returnFunc(ctx, addr)
137+ } else {
138+ r1 = ret.Get(1).(time.Duration)
139+ }
140+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) func()); ok {
141+ r2 = returnFunc(ctx, addr)
142+ } else {
143+ if ret.Get(2) != nil {
144+ r2 = ret.Get(2).(func())
145+ }
146+ }
147+ return r0, r1, r2
148+}
149+
150+// SubmissionLimiter_Reserve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reserve'
151+type SubmissionLimiter_Reserve_Call struct {
152+ *mock.Call
153+}
154+
155+// Reserve is a helper method to define mock.On call
156+// - ctx context.Context
157+// - addr string
158+func (_e *SubmissionLimiter_Expecter) Reserve(ctx any, addr any) *SubmissionLimiter_Reserve_Call {
159+ return &SubmissionLimiter_Reserve_Call{Call: _e.mock.On("Reserve", ctx, addr)}
160+}
161+
162+func (_c *SubmissionLimiter_Reserve_Call) Run(run func(ctx context.Context, addr string)) *SubmissionLimiter_Reserve_Call {
163+ _c.Call.Run(func(args mock.Arguments) {
164+ var arg0 context.Context
165+ if args[0] != nil {
166+ arg0 = args[0].(context.Context)
167+ }
168+ var arg1 string
169+ if args[1] != nil {
170+ arg1 = args[1].(string)
171+ }
172+ run(
173+ arg0,
174+ arg1,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *SubmissionLimiter_Reserve_Call) Return(ok bool, retryAfter time.Duration, release func()) *SubmissionLimiter_Reserve_Call {
181+ _c.Call.Return(ok, retryAfter, release)
182+ return _c
183+}
184+
185+func (_c *SubmissionLimiter_Reserve_Call) RunAndReturn(run func(ctx context.Context, addr string) (bool, time.Duration, func())) *SubmissionLimiter_Reserve_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
M internal/ports/ports.go +81 -0
@@ -241,6 +241,83 @@ type TokenStore interface {
241241 Touch(ctx context.Context, id string, at time.Time) error
242242 }
243243
244+// IssueStore keeps the issues of the repositories and their comments. Issues
245+// share the number sequence of the merge requests of their repository, so that
246+// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
247+// the repository does not exist, so that deleting a repository leaves nothing
248+// for one created again under its name. *kvstore.Issues implements it.
249+type IssueStore interface {
250+ // Create stores the issue under the next number of its repository (the
251+ // one merge requests draw from), which it sets, and returns it. A number
252+ // is never given twice, to an issue or to a merge request, even by a
253+ // concurrent Create.
254+ Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
255+ // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
256+ // which state it was read at.
257+ Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
258+ // List returns a page of the issues of the repository in the given state
259+ // (any when empty), highest number first.
260+ List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
261+ // ListOpenAll returns the open issues of every repository, reading only
262+ // the open ones (through the same index). The order is unspecified.
263+ ListOpenAll(ctx context.Context) ([]domain.Issue, error)
264+ // Update replaces the stored issue and keeps the index of the open issues
265+ // in step. It returns domain.ErrNotFound if there is none and
266+ // domain.ErrConflict if another write changed it since issue was read.
267+ Update(ctx context.Context, issue domain.Issue) error
268+ // SaveComment writes a comment and the issue it belongs to in one batch: a
269+ // new comment (Version 0) must not exist yet, an existing one must be
270+ // unchanged since it was read, and so must issue. It returns
271+ // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
272+ // comment's ID for a new comment, and issue.UpdatedAt.
273+ SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
274+ // ListComments returns the comments of the issue, oldest first.
275+ ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
276+}
277+
278+// PendingIssueStore keeps the issues submitted through the public form until
279+// the owner decides. They have no number: Approve gives one. Put and Approve
280+// return domain.ErrNotFound when the repository does not exist, so that
281+// deleting a repository leaves nothing for one created again under its name.
282+// *kvstore.PendingIssues implements it.
283+type PendingIssueStore interface {
284+ // Put stores the pending issue; its id must be new.
285+ Put(ctx context.Context, p domain.PendingIssue) error
286+ // Get returns the pending issue, or domain.ErrNotFound.
287+ Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error)
288+ // List returns a page of the pending issues of the repository, or of
289+ // every repository when repo is zero, oldest first. A PageSize of 0
290+ // means every one.
291+ List(ctx context.Context, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
292+ // Count returns how many pending issues there are in all repositories,
293+ // reading at most domain.MaxPendingIssues+1 of them.
294+ Count(ctx context.Context) (int, error)
295+ // Approve takes the next number of the repository (the one merge requests
296+ // and issues share), writes the issue domain.ApprovedIssue(pending, now)
297+ // with that number, indexes it as open and deletes the pending record,
298+ // in one batch. It returns domain.ErrNotFound when either is missing.
299+ Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error)
300+ // Delete removes the pending issue, or returns domain.ErrNotFound.
301+ Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error
302+}
303+
304+// IssueKeyStore keeps the server key of the public issue form, from which the
305+// use cases derive their subkeys.
306+type IssueKeyStore interface {
307+ // Key returns the 32-byte secret. The first call on an empty store
308+ // generates and stores it; a restart keeps it.
309+ Key(ctx context.Context) ([]byte, error)
310+}
311+
312+// IssueBanStore keeps the bans of submitters, by the hash of their client key.
313+type IssueBanStore interface {
314+ // Ban bans the hash until the given time, replacing an earlier ban.
315+ Ban(ctx context.Context, hash []byte, until time.Time) error
316+ // Banned reports whether the hash is banned at now. An expired ban counts
317+ // as not banned and is deleted.
318+ Banned(ctx context.Context, hash []byte, now time.Time) (bool, error)
319+}
320+
244321 // MergeRequestStore keeps the merge requests of the repositories, their
245322 // comments and the read position of the reviewer. A repository has no entry
246323 // until its first merge request. Create, SaveComment and SetRead return
@@ -260,6 +337,10 @@ type MergeRequestStore interface {
260337 // ListOpen returns the open merge requests of the repository, highest
261338 // number first, reading only the open ones (through an index).
262339 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
340+ // ListOpenAll returns the open merge requests of every repository,
341+ // reading only the open ones (through the same index). The order is
342+ // unspecified.
343+ ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error)
263344 // CountOpen returns the number of open merge requests of the repository
264345 // from the index of keys: no request is read.
265346 CountOpen(ctx context.Context, repo domain.RepoName) (int, error)
M internal/ports/services.go +94 -0
@@ -124,6 +124,87 @@ type MergeRequestService interface {
124124 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
125125 }
126126
127+// IssueService is the issue use cases. *usecase.Issues implements it.
128+type IssueService interface {
129+ Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateIssueParams) (domain.Issue, error)
130+ List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListIssuesParams) (IssuePage, error)
131+ // Get returns the issue with its comments, oldest first.
132+ Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (IssueDetail, error)
133+ AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)
134+ Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
135+ Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
136+}
137+
138+// IssueModerationService is the owner's moderation of the pending issues.
139+// Every call needs owner rights and answers domain.ErrNotFound otherwise.
140+// *usecase.IssueModeration implements it.
141+type IssueModerationService interface {
142+ // ListPending lists the pending issues of a repository, or of every
143+ // repository when repo is zero, oldest first.
144+ ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
145+ // Approve opens the pending issue under a new number.
146+ Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error)
147+ // Reject deletes the pending issue and, with ban, bans its submitter.
148+ Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error
149+}
150+
151+// IssueChallenge is what the public form needs to submit: a signed token, the
152+// proof-of-work to solve and the timing.
153+type IssueChallenge struct {
154+ // Token is the signed challenge, sent back untouched.
155+ Token string
156+ // Nonce is the base64url of the 16 random bytes the browser hashes:
157+ // SHA-256(nonce bytes || ":" || decimal counter) must have Difficulty
158+ // leading zero bits.
159+ Nonce string
160+ // Difficulty is the number of leading zero bits required.
161+ Difficulty int
162+ // FormToken is a hidden field of the form, sent back untouched.
163+ FormToken string
164+ // IssuedAt, MinSubmitAt and ExpiresAt bound when the form may be sent.
165+ IssuedAt, MinSubmitAt, ExpiresAt time.Time
166+}
167+
168+// IssueSubmission is what the browser sends back.
169+type IssueSubmission struct {
170+ Token string
171+ // Solution is the decimal counter that solves the proof of work.
172+ Solution string
173+ FormToken string
174+ // Honeypot must be empty: a field only a bot fills.
175+ Honeypot string
176+ Title, Body, Author string
177+}
178+
179+// IssueSubmissionService is the anonymous issue submission. Both calls answer
180+// domain.ErrNotFound for a repository that is unknown, not public or without
181+// public issues. Submit refuses with an error matching
182+// domain.ErrSubmissionRefused (ErrSubmissionRateLimited, ErrSubmissionsPaused,
183+// ErrSubmissionInvalid) or with a *domain.TextError the user can fix.
184+// addr is the client key (clientlimit.Key of the address).
185+// *usecase.IssueSubmissions implements it.
186+type IssueSubmissionService interface {
187+ Challenge(ctx context.Context, repo domain.RepoName, addr string) (IssueChallenge, error)
188+ Submit(ctx context.Context, repo domain.RepoName, addr string, f IssueSubmission) error
189+}
190+
191+// IssueNumberResolver tells whether a number of a repository names an issue or
192+// a merge request, for the callers that follow a "#n" reference. *usecase.Issues
193+// implements it.
194+type IssueNumberResolver interface {
195+ // Kind needs the access of reading the repository and answers
196+ // domain.ErrNotFound otherwise, also when issues are off.
197+ Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
198+}
199+
200+// InboxService is the owner's inbox: what is open across every repository.
201+// *usecase.Inbox implements it.
202+type InboxService interface {
203+ // Overview needs owner rights and answers domain.ErrNotFound to anyone
204+ // else. The filter, when not zero, restricts the lists to that repository.
205+ Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (InboxOverview, error)
206+}
207+
127208 // SigningKeyService tells which key signs the commits of the forge.
128209 // *usecase.SigningKey implements it.
129210 type SigningKeyService interface {
@@ -237,6 +318,19 @@ type FailureLimiter interface {
237318 Fail(ctx context.Context, addr string)
238319 }
239320
321+// SubmissionLimiter limits the submissions of the public issue form per
322+// client. *clientlimit.SubmissionLimiter implements it.
323+type SubmissionLimiter interface {
324+ // Reserve atomically checks the limits for addr and, if it may submit,
325+ // counts a submission. When it may not, retryAfter says when to retry.
326+ // release refunds the reservation (only when the submission was not
327+ // stored); it is never nil and safe to call once.
328+ Reserve(ctx context.Context, addr string) (ok bool, retryAfter time.Duration, release func())
329+ // Recent returns the submissions of the last hour from addr and from all
330+ // clients.
331+ Recent(ctx context.Context, addr string) (perAddr, global int)
332+}
333+
240334 // SecretPage is one page of secrets.
241335 type SecretPage struct {
242336 Secrets []domain.Secret
M internal/usecase/events.go +96 -3
@@ -38,6 +38,10 @@ const (
3838 const (
3939 // TopicHome follows every repository the caller may see.
4040 TopicHome = "home"
41+ // TopicOwner follows the merge requests and issues of every repository,
42+ // for the owner only (the inbox). Anyone else is told it is an unknown
43+ // topic.
44+ TopicOwner = "owner"
4145 // topicRepoPrefix starts the topic of one repository: "repo:<name>".
4246 topicRepoPrefix = "repo:"
4347 )
@@ -178,6 +182,16 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179183 case domain.CommentAdded:
180184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.IssueOpened:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.IssueClosed:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReopened:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
191+ case domain.IssueCommented:
192+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
193+ case domain.IssueSubmitted, domain.IssuePendingRemoved:
194+ return bufferedEvent{known: true} // owner topic only: no repository snapshot
181195 case domain.RunUpdated:
182196 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183197 case domain.JobUpdated:
@@ -258,6 +272,7 @@ func (e *Events) parseID(id string) (uint64, bool) {
258272 type watch struct {
259273 p domain.Principal
260274 home bool
275+ owner bool // the owner topic: merge request, issue, repo update and deletion events of every repository
261276 repos map[domain.RepoName]bool
262277
263278 checked time.Time // when the token store last confirmed the principal
@@ -265,6 +280,12 @@ type watch struct {
265280
266281 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
267282
283+// wantsEvent is wants for an event: the owner topic also follows the events
284+// about merge requests and issues of every repository.
285+func (w *watch) wantsEvent(name domain.RepoName, ev domain.Event) bool {
286+ return w.wants(name) || (w.owner && (isItemEvent(ev) || isRepoLifecycleEvent(ev)))
287+}
288+
268289 // parseTopics validates the topics of a watch. A repository topic needs a
269290 // repository the principal may read (else domain.ErrNotFound); an unknown
270291 // topic, or more than MaxWatchTopics, is domain.ErrInvalid.
@@ -281,8 +302,14 @@ func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []s
281302 w.home = true
282303 continue
283304 }
305+ if t == TopicOwner && p.HasOwnerRights() {
306+ w.owner = true
307+ continue
308+ }
284309 raw, ok := strings.CutPrefix(t, topicRepoPrefix)
285310 if !ok {
311+ // Includes "owner" for anyone but the owner: the same error as
312+ // any unknown topic.
286313 return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)
287314 }
288315 name, err := domain.ParseRepoName(raw)
@@ -419,7 +446,10 @@ func (e *Events) idleCheck() time.Duration {
419446
420447 // stillAllowed checks that the holder of a stored token may still watch: the
421448 // token has not expired and, at most every few seconds, still exists. Root and
422-// anonymous callers have nothing to lose.
449+// anonymous callers have nothing to lose. The owner topic needs nothing more:
450+// a stored token with owner rights loses them only by ceasing to exist, which
451+// is checked here, and the owner's web session, which this use case cannot see,
452+// is checked by the web adapter before it sends anything.
423453 func (e *Events) stillAllowed(ctx context.Context, w *watch) error {
424454 if w.p.TokenID() == "" {
425455 return nil
@@ -467,10 +497,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
467497 return send(ports.WatchEvent{Gap: true})
468498 case domain.RepoDeleted:
469499 // The repository is gone: use the visibility it had.
470- if !w.wants(ev.Repo) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
500+ if !w.wantsEvent(ev.Repo, ev) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
471501 return nil
472502 }
473503 return send(out)
504+ case domain.IssueSubmitted:
505+ return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
506+ case domain.IssuePendingRemoved:
507+ return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
474508 case domain.RepoCreated:
475509 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
476510 case domain.RepoUpdated:
@@ -487,6 +521,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
487521 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
488522 case domain.CommentAdded:
489523 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
524+ case domain.IssueOpened:
525+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
526+ case domain.IssueClosed:
527+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
528+ case domain.IssueReopened:
529+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
530+ case domain.IssueCommented:
531+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490532 case domain.RunUpdated:
491533 // Home shows the CI state of each repository.
492534 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -502,6 +544,25 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
502544 return nil // an event this version does not stream
503545 }
504546
547+// deliverOwnerOnly sends an owner-topic event: never home, never a repository
548+// topic, not even to an owner who watches the repository. No content travels.
549+func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.RepoName, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
550+ if !w.owner {
551+ return nil
552+ }
553+ repo, err := lookupRepo(ctx, e.repos.meta, name)
554+ switch {
555+ case errors.Is(err, domain.ErrNotFound), ctx.Err() != nil:
556+ return nil
557+ case err != nil:
558+ return fmt.Errorf("check access to %q: %w", name, err)
559+ }
560+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
561+ return nil
562+ }
563+ return send(out)
564+}
565+
505566 // deliverRepo sends an event about a repository if the watcher could read the
506567 // repository when the event happened and can read it now, and the repository
507568 // is still the one the event is about. A repository that no longer exists is
@@ -511,7 +572,7 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
511572 // read it before are told once, with its name and its new visibility only,
512573 // so that what they show can be removed.
513574 func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
514- if !w.wants(name) || !be.known {
575+ if !w.wantsEvent(name, be.ev) || !be.known {
515576 return nil
516577 }
517578 repo, err := lookupRepo(ctx, e.repos.meta, name)
@@ -536,6 +597,9 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
536597 if !canRead(w.p, repo) {
537598 return nil
538599 }
600+ if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff {
601+ return nil // issues were turned off since: a replay must not show them
602+ }
539603 if withRepo {
540604 view, err := e.repos.View(ctx, repo)
541605 if err != nil {
@@ -545,3 +609,32 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
545609 }
546610 return send(out)
547611 }
612+
613+// isIssueEvent reports whether ev is about an issue.
614+func isIssueEvent(ev domain.Event) bool {
615+ switch ev.(type) {
616+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
617+ return true
618+ }
619+ return false
620+}
621+
622+// isRepoLifecycleEvent reports whether ev is a repository update or deletion:
623+// the owner topic follows them, for they change what the inbox lists.
624+func isRepoLifecycleEvent(ev domain.Event) bool {
625+ switch ev.(type) {
626+ case domain.RepoUpdated, domain.RepoDeleted:
627+ return true
628+ }
629+ return false
630+}
631+
632+// isItemEvent reports whether ev is about a merge request or an issue.
633+func isItemEvent(ev domain.Event) bool {
634+ switch ev.(type) {
635+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
636+ domain.MergeRequestReady, domain.CommentAdded:
637+ return true
638+ }
639+ return isIssueEvent(ev)
640+}
A internal/usecase/events_owner_test.go +200 -0
@@ -0,0 +1,200 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "testing/synctest"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+func TestWatchOwnerTopicIsUnknownToEveryoneElse(t *testing.T) {
22+ t.Parallel()
23+ r := newEventRig(t, 0)
24+ noop := func(ports.WatchEvent) error { return nil }
25+ unknown := r.events.Watch(context.Background(), domain.Anonymous(), ports.WatchParams{Topics: []string{"nope"}}, noop)
26+ require.ErrorIs(t, unknown, domain.ErrInvalid)
27+
28+ partial := tokenWith("t1", now.Add(0), "pub") // limited to a repository
29+ for _, p := range []domain.Principal{domain.Anonymous(), partial, issuePrincipal(domain.ScopeIssueWrite)} {
30+ err := r.events.Watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}}, noop)
31+ require.ErrorIs(t, err, domain.ErrInvalid)
32+ assert.Equal(t, strings.ReplaceAll(unknown.Error(), "nope", "owner"), err.Error(), "same answer as an unknown topic")
33+ }
34+}
35+
36+func TestWatchOwnerTopicSpansRepositoriesAndHonoursIssuesOff(t *testing.T) {
37+ t.Parallel()
38+ synctest.Test(t, func(t *testing.T) {
39+ r := newEventRig(t, 16)
40+ a, b := name(t, "aaa"), name(t, "bbb")
41+ var mu sync.Mutex
42+ bMode := domain.IssuesOwner
43+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil)
44+ r.meta.EXPECT().Get(mock.Anything, b).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
45+ mu.Lock()
46+ defer mu.Unlock()
47+ return domain.Repo{Name: b, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(bMode)}, nil
48+ })
49+ ctx, cancel := context.WithCancel(context.Background())
50+ defer cancel()
51+ w := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
52+ synctest.Wait()
53+
54+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
55+ _, ok := w.next(t).Event.(domain.IssueOpened)
56+ require.True(t, ok)
57+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
58+ _, ok = w.next(t).Event.(domain.MergeRequestOpened)
59+ require.True(t, ok, "a second repository")
60+
61+ // Pushes are not inbox news.
62+ r.feed <- r.pushEvent(a, sentinelOID, "refs/heads/x")
63+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 3}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
64+ _, ok = w.next(t).Event.(domain.IssueOpened)
65+ require.True(t, ok, "the push was skipped")
66+
67+ mu.Lock()
68+ bMode = domain.IssuesOff
69+ mu.Unlock()
70+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 4}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
71+ r.feed <- domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
72+ e := w.next(t)
73+ _, ok = e.Event.(domain.MergeRequestClosed)
74+ require.True(t, ok, "the issue event of the issues-off repository is not delivered, got %T", e.Event)
75+ })
76+}
77+
78+func withIssues(m domain.IssuesMode) domain.RepoSettings {
79+ st := domain.DefaultRepoSettings()
80+ st.Issues = m
81+ return st
82+}
83+
84+func allScopesToken(mutate func(*domain.Token)) domain.Principal {
85+ tok := domain.Token{ID: "tok", Scopes: domain.AllScopes()}
86+ if mutate != nil {
87+ mutate(&tok)
88+ }
89+ return domain.TokenPrincipal(tok)
90+}
91+
92+func TestWatchOwnerTopicAccessMatrix(t *testing.T) {
93+ t.Parallel()
94+ missingOne := domain.AllScopes()[1:]
95+ cases := []struct {
96+ name string
97+ p domain.Principal
98+ allowed bool
99+ }{
100+ {"root", domain.Root(), true},
101+ {"owner session", domain.Owner(), true},
102+ {"unrestricted all-scope token", allScopesToken(nil), true},
103+ {"token missing one scope", allScopesToken(func(tk *domain.Token) { tk.Scopes = missingOne }), false},
104+ {"repo-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{name(t, "aaa")} }), false},
105+ {"branch-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }), false},
106+ {"anonymous", domain.Anonymous(), false},
107+ }
108+ for _, tc := range cases {
109+ t.Run(tc.name, func(t *testing.T) {
110+ t.Parallel()
111+ synctest.Test(t, func(t *testing.T) {
112+ r := newEventRig(t, 16)
113+ a, b := name(t, "aaa"), name(t, "bbb")
114+ for _, n := range []domain.RepoName{a, b} {
115+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil).Maybe()
116+ }
117+ ctx, cancel := context.WithCancel(context.Background())
118+ defer cancel()
119+ noop := func(ports.WatchEvent) error { return nil }
120+ if !tc.allowed {
121+ err := r.events.Watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}, noop)
122+ require.ErrorIs(t, err, domain.ErrInvalid)
123+ assert.EqualError(t, err, domain.ErrInvalid.Error()+`: unknown topic "owner" (want "home" or "repo:<name>")`)
124+ return
125+ }
126+ w := r.watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}})
127+ synctest.Wait()
128+ require.Equal(t, 1, r.events.Watchers())
129+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
130+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
131+ assert.Equal(t, a, w.next(t).Event.(domain.IssueOpened).Issue.Repo)
132+ assert.Equal(t, b, w.next(t).Event.(domain.MergeRequestOpened).MergeRequest.Repo)
133+ })
134+ })
135+ }
136+}
137+
138+func TestWatchOwnerTopicFollowsRepoUpdatedAndDeleted(t *testing.T) {
139+ t.Parallel()
140+ synctest.Test(t, func(t *testing.T) {
141+ r := newEventRig(t, 16)
142+ a := r.repo("aaa", domain.VisibilityPrivate)
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
146+ synctest.Wait()
147+ r.feed <- domain.RepoUpdated{Repo: a, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
148+ r.feed <- domain.RepoDeleted{Repo: a, Visibility: domain.VisibilityPrivate, At: now}
149+ _, ok := owner.next(t).Event.(domain.RepoUpdated)
150+ require.True(t, ok)
151+ _, ok = owner.next(t).Event.(domain.RepoDeleted)
152+ require.True(t, ok)
153+ })
154+}
155+
156+func TestWatchOwnerTopicEndsWhenTheTokenIsRevoked(t *testing.T) {
157+ t.Parallel()
158+ synctest.Test(t, func(t *testing.T) {
159+ r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1})
160+ priv := r.repo("priv", domain.VisibilityPrivate)
161+ ctx, cancel := context.WithCancel(context.Background())
162+ defer cancel()
163+ w := r.watch(ctx, allScopesToken(nil), ports.WatchParams{Topics: []string{"owner"}})
164+ synctest.Wait()
165+ require.Equal(t, 1, r.events.Watchers())
166+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
167+ w.next(t)
168+
169+ r.revoked.Store("tok", true)
170+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
171+ synctest.Wait()
172+ select {
173+ case err := <-w.done:
174+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
175+ default:
176+ t.Fatal("the owner watch of a revoked token did not end")
177+ }
178+ assert.Empty(t, w.got, "nothing was sent after the revocation")
179+ })
180+}
181+
182+func TestWatchOwnerTopicEndsWhenTheTokenExpires(t *testing.T) {
183+ t.Parallel()
184+ synctest.Test(t, func(t *testing.T) {
185+ r := newEventRig(t, 0)
186+ priv := r.repo("priv", domain.VisibilityPrivate)
187+ // The rig's clock stands at now: this token expired at now.
188+ p := allScopesToken(func(tk *domain.Token) { tk.ExpiresAt = now })
189+ w := r.watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}})
190+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
191+ synctest.Wait()
192+ select {
193+ case err := <-w.done:
194+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
195+ default:
196+ t.Fatal("the owner watch of an expired token did not end")
197+ }
198+ assert.Empty(t, w.got)
199+ })
200+}
M internal/usecase/events_test.go +43 -0
@@ -206,6 +206,49 @@ func TestWatchRechecksAccessPerEvent(t *testing.T) {
206206 })
207207 }
208208
209+func TestWatchDropsIssueEventsOnceIssuesAreOff(t *testing.T) {
210+ t.Parallel()
211+ synctest.Test(t, func(t *testing.T) {
212+ r := newEventRig(t, 16)
213+ n := name(t, "iss")
214+ var mu sync.Mutex
215+ mode := domain.IssuesOwner
216+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
217+ mu.Lock()
218+ defer mu.Unlock()
219+ st := domain.DefaultRepoSettings()
220+ st.Issues = mode
221+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: st}, nil
222+ })
223+ ctx, cancel := context.WithCancel(context.Background())
224+ defer cancel()
225+ first := r.watch(ctx, domain.Root(), ports.WatchParams{})
226+ synctest.Wait()
227+
228+ r.push(n, 1)
229+ pushID := first.next(t).ID
230+ r.feed <- domain.IssueCommented{Repo: n, Number: 1, Comment: domain.IssueComment{ID: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
231+ _, ok := first.next(t).Event.(domain.IssueCommented)
232+ require.True(t, ok, "delivered while issues are on")
233+ r.push(n, 2)
234+ first.next(t)
235+
236+ mu.Lock()
237+ mode = domain.IssuesOff
238+ mu.Unlock()
239+
240+ // The replay holds the issue event: it is dropped, the other ones stay.
241+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: pushID})
242+ e := w.next(t)
243+ _, isPush := e.Event.(domain.PushEvent)
244+ require.True(t, isPush, "got %T", e.Event)
245+ w.quiet(t, r, n)
246+
247+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: n, Number: 2}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
248+ w.quiet(t, r, n)
249+ })
250+}
251+
209252 func TestWatchResumeAndGap(t *testing.T) {
210253 t.Parallel()
211254 const size = 8
M internal/usecase/export_test.go +34 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "crypto/rsa"
88 "slices"
9+ "strconv"
910 "time"
1011
1112 "bnema.dev/ermite-actions/expr"
@@ -130,3 +131,36 @@ func (o *OIDC) SignForTest(claims map[string]any) (string, error) {
130131 }
131132 return o.sign(out)
132133 }
134+
135+// DifficultyFor exposes the adaptive difficulty with the production base and
136+// cap to the tests.
137+func DifficultyFor(perAddr, global int) int {
138+ return difficultyFor(baseDifficulty, maxDifficulty, perAddr, global)
139+}
140+
141+// Production parameters of the submission, for the tests.
142+const (
143+ BaseDifficulty = baseDifficulty
144+ MaxDifficulty = maxDifficulty
145+ MinFillTime = minFillTime
146+ ChallengeTTL = challengeTTL
147+)
148+
149+// SpentLen is the number of nonces the submission remembers as spent.
150+func (s *IssueSubmissions) SpentLen() int {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ return len(s.spent)
154+}
155+
156+// FillSpent fills the spent set with n unexpired nonces.
157+func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
158+ s.mu.Lock()
159+ defer s.mu.Unlock()
160+ for i := range n {
161+ s.spent["fill"+strconv.Itoa(i)] = exp
162+ }
163+}
164+
165+// MaxSpent is the bound of the spent set.
166+const MaxSpent = maxSpent
A internal/usecase/inbox.go +164 -0
@@ -0,0 +1,164 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of
18+// every repository in one place. Only the owner may read it; anyone else
19+// gets domain.ErrNotFound, as for a page that does not exist. The issues of a
20+// repository whose issues mode is off are left out.
21+type Inbox struct {
22+ meta ports.RepoMetaStore
23+ mrs ports.MergeRequestStore
24+ issues ports.IssueStore
25+ // pending is nil when the public form is not wired.
26+ pending ports.PendingIssueStore
27+}
28+
29+var _ ports.InboxService = (*Inbox)(nil)
30+
31+// InboxConfig holds what the inbox needs. Every field is required.
32+type InboxConfig struct {
33+ Meta ports.RepoMetaStore
34+ MergeRequests ports.MergeRequestStore
35+ Issues ports.IssueStore
36+ // Pending lists the issues awaiting moderation. Optional: without it the
37+ // inbox has no pending section.
38+ Pending ports.PendingIssueStore
39+}
40+
41+// NewInbox builds the inbox use case.
42+func NewInbox(cfg InboxConfig) *Inbox {
43+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
44+}
45+
46+// Overview lists what is open, through the indexes of open items. The counts
47+// and the list of repositories do not depend on the filter, the lists do.
48+func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
49+ var out ports.InboxOverview
50+ if !p.HasOwnerRights() {
51+ return out, domain.ErrNotFound
52+ }
53+ mrs, err := in.mrs.ListOpenAll(ctx)
54+ if err != nil {
55+ return out, fmt.Errorf("list open merge requests: %w", err)
56+ }
57+ issues, err := in.issues.ListOpenAll(ctx)
58+ if err != nil {
59+ return out, fmt.Errorf("list open issues: %w", err)
60+ }
61+
62+ // exists and issuesOn cache what is read of each repository.
63+ issuesOn := make(map[domain.RepoName]bool)
64+ exists := make(map[domain.RepoName]bool)
65+ look := func(name domain.RepoName) error {
66+ if _, ok := exists[name]; ok {
67+ return nil
68+ }
69+ repo, err := lookupRepo(ctx, in.meta, name)
70+ switch {
71+ case errors.Is(err, domain.ErrNotFound):
72+ exists[name] = false
73+ case err != nil:
74+ return fmt.Errorf("read repository %q: %w", name, err)
75+ default:
76+ exists[name] = true
77+ issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff
78+ }
79+ return nil
80+ }
81+
82+ repos := make(map[domain.RepoName]bool)
83+ for _, m := range mrs {
84+ if err := look(m.Repo); err != nil {
85+ return out, err
86+ }
87+ if !exists[m.Repo] {
88+ continue
89+ }
90+ repos[m.Repo] = true
91+ out.Counts.MergeRequests++
92+ if filter.IsZero() || m.Repo == filter {
93+ out.MergeRequests = append(out.MergeRequests, m)
94+ }
95+ }
96+ for _, i := range issues {
97+ if err := look(i.Repo); err != nil {
98+ return out, err
99+ }
100+ if !exists[i.Repo] || !issuesOn[i.Repo] {
101+ continue
102+ }
103+ repos[i.Repo] = true
104+ out.Counts.Issues++
105+ if filter.IsZero() || i.Repo == filter {
106+ out.Issues = append(out.Issues, i)
107+ }
108+ }
109+
110+ if in.pending != nil {
111+ page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
112+ if err != nil {
113+ return out, fmt.Errorf("list pending issues: %w", err)
114+ }
115+ for _, pi := range page.Issues {
116+ if err := look(pi.Repo); err != nil {
117+ return out, err
118+ }
119+ if !exists[pi.Repo] || !issuesOn[pi.Repo] {
120+ continue
121+ }
122+ pi.IPHash = nil // the hash is for bans, the page never needs it
123+ out.Pending = append(out.Pending, pi)
124+ repos[pi.Repo] = true
125+ }
126+ out.Counts.Pending = len(out.Pending)
127+ }
128+
129+ slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130+ return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131+ })
132+ slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int {
133+ return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
134+ })
135+ for r := range repos {
136+ out.Repos = append(out.Repos, r)
137+ }
138+ slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
139+ return out, nil
140+}
141+
142+// compareRecent orders by update time, most recent first (the creation time
143+// stands in for a missing one), then by repository and number.
144+func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int {
145+ if aUp.IsZero() {
146+ aUp = aCr
147+ }
148+ if bUp.IsZero() {
149+ bUp = bCr
150+ }
151+ if c := bUp.Compare(aUp); c != 0 {
152+ return c
153+ }
154+ if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 {
155+ return c
156+ }
157+ switch {
158+ case aN > bN:
159+ return -1
160+ case aN < bN:
161+ return 1
162+ }
163+ return 0
164+}
A internal/usecase/inbox_test.go +110 -0
@@ -0,0 +1,110 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+func inboxMR(t *testing.T, repo string, n int64, up time.Time) domain.MergeRequest {
21+ t.Helper()
22+ ti, err := domain.ParseMRTitle("mr")
23+ require.NoError(t, err)
24+ return domain.MergeRequest{Repo: name(t, repo), Number: n, Title: ti, State: domain.MRStateOpen, UpdatedAt: up}
25+}
26+
27+func inboxIssue(t *testing.T, repo string, n int64, up time.Time) domain.Issue {
28+ t.Helper()
29+ ti, err := domain.ParseIssueTitle("issue")
30+ require.NoError(t, err)
31+ return domain.Issue{Repo: name(t, repo), Number: n, Title: ti, State: domain.IssueStateOpen, UpdatedAt: up}
32+}
33+
34+func newInboxUnit(t *testing.T) (*usecase.Inbox, *mocks.MergeRequestStore, *mocks.IssueStore, *mocks.RepoMetaStore) {
35+ t.Helper()
36+ mrs, is, meta := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t)
37+ return usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is}), mrs, is, meta
38+}
39+
40+func repoWithIssues(t *testing.T, n string, mode domain.IssuesMode) domain.Repo {
41+ s := domain.DefaultRepoSettings()
42+ s.Issues = mode
43+ return domain.Repo{Name: name(t, n), Visibility: domain.VisibilityPrivate, Settings: s}
44+}
45+
46+func TestInboxIsOwnerOnly(t *testing.T) {
47+ t.Parallel()
48+ in, _, _, _ := newInboxUnit(t) // the stores are never read
49+ for _, p := range []domain.Principal{domain.Anonymous(), issuePrincipal(domain.ScopeIssueWrite)} {
50+ _, err := in.Overview(context.Background(), p, domain.RepoName{})
51+ assert.ErrorIs(t, err, domain.ErrNotFound)
52+ }
53+}
54+
55+func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) {
56+ t.Parallel()
57+ ctx := context.Background()
58+ t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
59+ in, mrs, is, meta := newInboxUnit(t)
60+ mrs.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{
61+ inboxMR(t, "alpha", 1, t0), inboxMR(t, "beta", 1, t0.Add(time.Hour)), inboxMR(t, "gone", 1, t0),
62+ }, nil)
63+ is.EXPECT().ListOpenAll(mock.Anything).Return([]domain.Issue{
64+ inboxIssue(t, "alpha", 2, t0), inboxIssue(t, "beta", 2, t0.Add(2*time.Hour)), inboxIssue(t, "quiet", 2, t0),
65+ }, nil)
66+ meta.EXPECT().Get(mock.Anything, name(t, "alpha")).Return(repoWithIssues(t, "alpha", domain.IssuesOwner), nil)
67+ meta.EXPECT().Get(mock.Anything, name(t, "beta")).Return(repoWithIssues(t, "beta", domain.IssuesPublic), nil)
68+ meta.EXPECT().Get(mock.Anything, name(t, "quiet")).Return(repoWithIssues(t, "quiet", domain.IssuesOff), nil)
69+ meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
70+
71+ got, err := in.Overview(ctx, domain.Owner(), domain.RepoName{})
72+ require.NoError(t, err)
73+ assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts)
74+ require.Len(t, got.MergeRequests, 2)
75+ assert.Equal(t, "beta", got.MergeRequests[0].Repo.String(), "most recently updated first")
76+ require.Len(t, got.Issues, 2)
77+ assert.Equal(t, "beta", got.Issues[0].Repo.String())
78+ assert.Equal(t, []domain.RepoName{name(t, "alpha"), name(t, "beta")}, got.Repos)
79+
80+ got, err = in.Overview(ctx, domain.Root(), name(t, "alpha"))
81+ require.NoError(t, err)
82+ assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts, "counts ignore the filter")
83+ require.Len(t, got.MergeRequests, 1)
84+ require.Len(t, got.Issues, 1)
85+ assert.Equal(t, "alpha", got.Issues[0].Repo.String())
86+ assert.Len(t, got.Repos, 2, "the filter list keeps every repository")
87+}
88+
89+func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
90+ t.Parallel()
91+ ctx := context.Background()
92+ mrs, is, meta, pending := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewPendingIssueStore(t)
93+ in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is, Pending: pending})
94+ mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
95+ is.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
96+ meta.EXPECT().Get(mock.Anything, name(t, "on")).Return(repoWithIssues(t, "on", domain.IssuesPublic), nil)
97+ meta.EXPECT().Get(mock.Anything, name(t, "off")).Return(repoWithIssues(t, "off", domain.IssuesOff), nil)
98+ ti, err := domain.ParseIssueTitle("hi")
99+ require.NoError(t, err)
100+ pending.EXPECT().List(mock.Anything, domain.RepoName{}, mock.Anything).Return(ports.PendingIssuePage{Issues: []domain.PendingIssue{
101+ {ID: "a", Repo: name(t, "on"), Title: ti, IPHash: []byte("secret")},
102+ {ID: "b", Repo: name(t, "off"), Title: ti},
103+ }}, nil)
104+ ov, err := in.Overview(ctx, domain.Owner(), name(t, "other"))
105+ require.NoError(t, err)
106+ assert.Equal(t, 1, ov.Counts.Pending, "issues off hides pending ones too")
107+ require.Len(t, ov.Pending, 1)
108+ assert.Nil(t, ov.Pending[0].IPHash)
109+ assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110+}
A internal/usecase/issue_keys.go +62 -0
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hkdf"
8+ "crypto/hmac"
9+ "crypto/sha256"
10+ "fmt"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// HKDF info strings of the subkeys of the issue form key. Each purpose has its
17+// own key, so that a value signed for one can never pass for another.
18+const (
19+ infoIPHash = "ermite issue ip-hash"
20+ infoPoW = "ermite issue pow"
21+ infoFormToken = "ermite issue form-token"
22+)
23+
24+// IssueKeys holds the subkeys of the public issue form, derived with
25+// HKDF-SHA256 from the server key of the IssueKeyStore.
26+type IssueKeys struct {
27+ ipHash, pow, formToken []byte
28+}
29+
30+// NewIssueKeys loads the server key (generated on the first call) and derives
31+// the subkeys.
32+func NewIssueKeys(ctx context.Context, store ports.IssueKeyStore) (*IssueKeys, error) {
33+ master, err := store.Key(ctx)
34+ if err != nil {
35+ return nil, fmt.Errorf("load issue key: %w", err)
36+ }
37+ k := &IssueKeys{}
38+ for _, d := range []struct {
39+ dst *[]byte
40+ info string
41+ }{{&k.ipHash, infoIPHash}, {&k.pow, infoPoW}, {&k.formToken, infoFormToken}} {
42+ if *d.dst, err = hkdf.Key(sha256.New, master, nil, d.info, sha256.Size); err != nil {
43+ return nil, fmt.Errorf("derive %q: %w", d.info, err)
44+ }
45+ }
46+ return k, nil
47+}
48+
49+// IPHash returns HMAC-SHA256 of the client key (clientlimit.Key of the
50+// address) under the ip-hash subkey: domain.IPHashLen bytes. It is stable
51+// across restarts and says nothing about the address without the key.
52+func (k *IssueKeys) IPHash(addrKey string) []byte {
53+ m := hmac.New(sha256.New, k.ipHash)
54+ m.Write([]byte(addrKey))
55+ return m.Sum(nil)[:domain.IPHashLen]
56+}
57+
58+// PoWKey returns a copy of the subkey that signs the proof-of-work challenges.
59+func (k *IssueKeys) PoWKey() []byte { return append([]byte(nil), k.pow...) }
60+
61+// FormTokenKey returns a copy of the subkey that signs the form tokens.
62+func (k *IssueKeys) FormTokenKey() []byte { return append([]byte(nil), k.formToken...) }
A internal/usecase/issue_moderation.go +119 -0
@@ -0,0 +1,119 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// IssueModeration is the owner's moderation of the issues submitted through
17+// the public form (ADR 0031): list, approve and reject, with an optional ban of
18+// the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as
19+// for a page that does not exist.
20+type IssueModeration struct {
21+ meta ports.RepoMetaStore
22+ pending ports.PendingIssueStore
23+ bans ports.IssueBanStore
24+ bus ports.EventBus
25+ clock ports.Clock
26+}
27+
28+var _ ports.IssueModerationService = (*IssueModeration)(nil)
29+
30+// IssueModerationConfig holds what the moderation needs. Every field is
31+// required.
32+type IssueModerationConfig struct {
33+ Meta ports.RepoMetaStore
34+ Pending ports.PendingIssueStore
35+ Bans ports.IssueBanStore
36+ Bus ports.EventBus
37+ Clock ports.Clock
38+}
39+
40+// NewIssueModeration builds the moderation use case.
41+func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration {
42+ return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock}
43+}
44+
45+// ListPending lists the pending issues of a repository, or of every repository
46+// when repo is zero, oldest first.
47+func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
48+ if !p.HasOwnerRights() {
49+ return ports.PendingIssuePage{}, domain.ErrNotFound
50+ }
51+ size, err := pageSize(page.PageSize)
52+ if err != nil {
53+ return ports.PendingIssuePage{}, err
54+ }
55+ page.PageSize = size
56+ out, err := m.pending.List(ctx, repo, page)
57+ if err != nil {
58+ return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err)
59+ }
60+ return out, nil
61+}
62+
63+// Approve gives the pending issue the next number of its repository and opens
64+// it, then publishes IssueOpened. The repository must exist with issues on
65+// (else domain.ErrNotFound), and so must the pending issue.
66+func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
67+ if !p.HasOwnerRights() {
68+ return domain.Issue{}, domain.ErrNotFound
69+ }
70+ r, err := lookupRepo(ctx, m.meta, repo)
71+ if err != nil {
72+ return domain.Issue{}, err
73+ }
74+ if r.Settings.Issues.OrDefault() == domain.IssuesOff {
75+ return domain.Issue{}, domain.ErrNotFound
76+ }
77+ now := m.clock.Now().UTC()
78+ issue, err := m.pending.Approve(ctx, repo, id, now)
79+ if err != nil {
80+ if errors.Is(err, domain.ErrNotFound) {
81+ return domain.Issue{}, err
82+ }
83+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
84+ }
85+ log := zerowrap.FromCtx(ctx)
86+ log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
87+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88+ m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89+ return issue, nil
90+}
91+
92+// Reject deletes the pending issue. With ban, its submitter is first banned
93+// for domain.BanDuration.
94+func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
95+ if !p.HasOwnerRights() {
96+ return domain.ErrNotFound
97+ }
98+ pending, err := m.pending.Get(ctx, repo, id)
99+ if err != nil {
100+ return err
101+ }
102+ if ban {
103+ until := m.clock.Now().UTC().Add(domain.BanDuration)
104+ if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
105+ return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
106+ }
107+ }
108+ if err := m.pending.Delete(ctx, repo, id); err != nil {
109+ if errors.Is(err, domain.ErrNotFound) {
110+ return err
111+ }
112+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
113+ }
114+ log := zerowrap.FromCtx(ctx)
115+ log.Info().Str("repo", repo.String()).Bool("ban", ban).
116+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
117+ m.bus.Publish(ctx, domain.IssuePendingRemoved{Repo: repo, ID: id, At: m.clock.Now().UTC()})
118+ return nil
119+}
A internal/usecase/issue_moderation_test.go +244 -0
@@ -0,0 +1,244 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "testing"
9+ "testing/synctest"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+type moderationUnit struct {
22+ meta *mocks.RepoMetaStore
23+ pending *mocks.PendingIssueStore
24+ bans *mocks.IssueBanStore
25+ bus *mocks.EventBus
26+ mod *usecase.IssueModeration
27+ repo domain.Repo
28+ id domain.PendingIssueID
29+}
30+
31+func newModerationUnit(t *testing.T, mode domain.IssuesMode) *moderationUnit {
32+ t.Helper()
33+ id, err := domain.NewPendingIssueID()
34+ require.NoError(t, err)
35+ u := &moderationUnit{
36+ meta: mocks.NewRepoMetaStore(t),
37+ pending: mocks.NewPendingIssueStore(t),
38+ bans: mocks.NewIssueBanStore(t),
39+ bus: mocks.NewEventBus(t),
40+ repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(mode)},
41+ id: id,
42+ }
43+ clock := mocks.NewClock(t)
44+ clock.EXPECT().Now().Return(now).Maybe()
45+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
46+ u.mod = usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: u.meta, Pending: u.pending, Bans: u.bans, Bus: u.bus, Clock: clock})
47+ return u
48+}
49+
50+func TestIssueModerationIsOwnerOnly(t *testing.T) {
51+ t.Parallel()
52+ u := newModerationUnit(t, domain.IssuesPublic) // no store is ever called
53+ ctx := context.Background()
54+ notOwners := map[string]domain.Principal{
55+ "anonymous": domain.Anonymous(),
56+ "issue writer": issuePrincipal(domain.ScopeIssueWrite),
57+ "one scope short": allScopesToken(func(tk *domain.Token) {
58+ tk.Scopes = tk.Scopes[1:]
59+ }),
60+ "repo limited": allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{u.repo.Name} }),
61+ "branch limited": allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }),
62+ }
63+ for who, p := range notOwners {
64+ _, err := u.mod.ListPending(ctx, p, domain.RepoName{}, ports.ListPendingParams{})
65+ assert.ErrorIs(t, err, domain.ErrNotFound, who)
66+ _, err = u.mod.Approve(ctx, p, u.repo.Name, u.id)
67+ assert.ErrorIs(t, err, domain.ErrNotFound, who)
68+ assert.ErrorIs(t, u.mod.Reject(ctx, p, u.repo.Name, u.id, true), domain.ErrNotFound, who)
69+ }
70+}
71+
72+func TestIssueModerationListAppliesPageLimits(t *testing.T) {
73+ t.Parallel()
74+ u := newModerationUnit(t, domain.IssuesPublic)
75+ u.pending.EXPECT().List(mock.Anything, domain.RepoName{}, ports.ListPendingParams{PageSize: usecase.DefaultPageSize, PageToken: "t"}).
76+ Return(ports.PendingIssuePage{NextPageToken: "n"}, nil).Once()
77+ got, err := u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageToken: "t"})
78+ require.NoError(t, err)
79+ assert.Equal(t, "n", got.NextPageToken)
80+ _, err = u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageSize: -1})
81+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
82+}
83+
84+func TestIssueModerationApprove(t *testing.T) {
85+ t.Parallel()
86+ ctx := context.Background()
87+ t.Run("publishes IssueOpened after the commit", func(t *testing.T) {
88+ u := newModerationUnit(t, domain.IssuesPublic)
89+ issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
90+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
91+ u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
92+ got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
93+ require.NoError(t, err)
94+ assert.Equal(t, int64(7), got.Number)
95+ })
96+ t.Run("issues off hides the repository", func(t *testing.T) {
97+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
98+ u := newModerationUnit(t, mode)
99+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
100+ assert.ErrorIs(t, err, domain.ErrNotFound)
101+ }
102+ })
103+ t.Run("no event when the store refuses", func(t *testing.T) {
104+ u := newModerationUnit(t, domain.IssuesOwner)
105+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(domain.Issue{}, domain.ErrNotFound).Once()
106+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
107+ assert.ErrorIs(t, err, domain.ErrNotFound)
108+ })
109+}
110+
111+func TestIssueModerationReject(t *testing.T) {
112+ t.Parallel()
113+ ctx := context.Background()
114+ hash := bytes.Repeat([]byte{3}, domain.IPHashLen)
115+ t.Run("with a ban", func(t *testing.T) {
116+ u := newModerationUnit(t, domain.IssuesPublic)
117+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, Repo: u.repo.Name, IPHash: hash}, nil).Once()
118+ u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once()
119+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
120+ u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
121+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true))
122+ })
123+ t.Run("without a ban", func(t *testing.T) {
124+ u := newModerationUnit(t, domain.IssuesPublic)
125+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
126+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
127+ u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
128+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false))
129+ })
130+ t.Run("unknown id", func(t *testing.T) {
131+ u := newModerationUnit(t, domain.IssuesPublic)
132+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once()
133+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound)
134+ })
135+ t.Run("a failed ban keeps the pending issue", func(t *testing.T) {
136+ u := newModerationUnit(t, domain.IssuesPublic)
137+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
138+ u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once()
139+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError)
140+ })
141+}
142+
143+func TestIssueKeysSubkeys(t *testing.T) {
144+ t.Parallel()
145+ ctx := context.Background()
146+ master := bytes.Repeat([]byte{0xAB}, 32)
147+ store := mocks.NewIssueKeyStore(t)
148+ store.EXPECT().Key(mock.Anything).Return(master, nil)
149+ k1, err := usecase.NewIssueKeys(ctx, store)
150+ require.NoError(t, err)
151+ k2, err := usecase.NewIssueKeys(ctx, store)
152+ require.NoError(t, err)
153+
154+ assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable")
155+ assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey())
156+ assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4"))
157+
158+ assert.Len(t, k1.PoWKey(), 32)
159+ assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose")
160+ assert.NotEqual(t, master, k1.PoWKey())
161+ assert.NotEqual(t, master, k1.FormTokenKey())
162+
163+ h := k1.IPHash("1.2.3.4")
164+ assert.Len(t, h, domain.IPHashLen)
165+ assert.NotEqual(t, h, k1.IPHash("1.2.3.5"))
166+ assert.NotContains(t, string(h), "1.2.3.4")
167+
168+ other := mocks.NewIssueKeyStore(t)
169+ other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil)
170+ k3, err := usecase.NewIssueKeys(ctx, other)
171+ require.NoError(t, err)
172+ assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash")
173+
174+ // The returned keys are copies.
175+ pow := k1.PoWKey()
176+ pow[0] ^= 0xFF
177+ assert.NotEqual(t, pow, k1.PoWKey())
178+}
179+
180+func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) {
181+ t.Parallel()
182+ synctest.Test(t, func(t *testing.T) {
183+ r := newEventRig(t, 16)
184+ a := r.repo("aaa", domain.VisibilityPublic)
185+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
186+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
187+ ctx, cancel := context.WithCancel(context.Background())
188+ defer cancel()
189+
190+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
191+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
192+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
193+ rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
194+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
195+ synctest.Wait()
196+
197+ r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now}
198+ // A sentinel every watcher sees, to prove the first one was skipped.
199+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
200+
201+ sub, ok := owner.next(t).Event.(domain.IssueSubmitted)
202+ require.True(t, ok, "the owner topic gets it")
203+ assert.Equal(t, a, sub.Repo)
204+ _, ok = owner.next(t).Event.(domain.IssueOpened)
205+ require.True(t, ok)
206+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} {
207+ e, ok := w.next(t).Event.(domain.IssueOpened)
208+ require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e)
209+ }
210+ })
211+}
212+
213+func TestIssuePendingRemovedReachesOnlyOwnerTopics(t *testing.T) {
214+ t.Parallel()
215+ synctest.Test(t, func(t *testing.T) {
216+ r := newEventRig(t, 16)
217+ a := r.repo("aaa", domain.VisibilityPublic)
218+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
219+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
220+ ctx, cancel := context.WithCancel(context.Background())
221+ defer cancel()
222+
223+ owner1 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
224+ owner2 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
225+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
226+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
227+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
228+ synctest.Wait()
229+
230+ r.feed <- domain.IssuePendingRemoved{Repo: a, ID: "x", At: now}
231+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
232+
233+ for name, w := range map[string]*watcher{"owner 1": owner1, "owner 2": owner2} {
234+ _, ok := w.next(t).Event.(domain.IssuePendingRemoved)
235+ require.True(t, ok, "%s gets the refresh", name)
236+ _, ok = w.next(t).Event.(domain.IssueOpened)
237+ require.True(t, ok)
238+ }
239+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "anonymous": anon} {
240+ e, ok := w.next(t).Event.(domain.IssueOpened)
241+ require.True(t, ok, "%s: the first event is the sentinel (got %T)", name, e)
242+ }
243+ })
244+}
A internal/usecase/issue_pow_vector_test.go +39 -0
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "encoding/base64"
7+ "strconv"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// The proof-of-work vector shared with the browser solver: the same nonce,
15+// difficulty and smallest solution are asserted in
16+// internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both.
17+const (
18+ powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding
19+ powVectorDifficulty = 12
20+ powVectorSolution = "2491"
21+)
22+
23+func TestPowVector(t *testing.T) {
24+ nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce)
25+ require.NoError(t, err)
26+ require.Len(t, nonce, nonceLen)
27+
28+ first := ""
29+ for n := 0; n < 1<<22; n++ {
30+ s := strconv.Itoa(n)
31+ if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty {
32+ first = s
33+ break
34+ }
35+ }
36+ require.NotEmpty(t, first)
37+ assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector")
38+ assert.True(t, validSolution(first))
39+}
A internal/usecase/issue_submissions.go +383 -0
@@ -0,0 +1,383 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hmac"
8+ "crypto/rand"
9+ "crypto/sha256"
10+ "encoding/base64"
11+ "encoding/json"
12+ "errors"
13+ "fmt"
14+ "math/bits"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+)
24+
25+// Parameters of the anonymous submission (ADR 0031).
26+const (
27+ // baseDifficulty is the proof-of-work of a client that has not submitted
28+ // lately, in leading zero bits of SHA-256: 2^18 hashes on average, about
29+ // one second for a SHA-256 in JavaScript on a mid-range phone.
30+ baseDifficulty = 18
31+ // maxDifficulty caps the adaptive proof-of-work: about 8 to 10 seconds.
32+ maxDifficulty = 21
33+ // perAddrStep and globalStep: one more bit per that many submissions of the
34+ // last hour from the client, and from everyone.
35+ perAddrStep = 2
36+ globalStep = 20
37+
38+ // challengeTTL is how long a challenge may be solved and sent.
39+ challengeTTL = 10 * time.Minute
40+ // minFillTime is the least time between the challenge and the submission:
41+ // a human cannot write an issue faster.
42+ minFillTime = 3 * time.Second
43+ // maxSpent bounds the nonces remembered as spent. Past it submissions are
44+ // paused rather than a live nonce forgotten.
45+ maxSpent = 100_000
46+
47+ tokenVersion = 1
48+ nonceLen = 16
49+ maxChallengeLen = 1024
50+ maxSolutionLen = 20
51+ spentSweep = time.Minute
52+)
53+
54+// difficultyFor is the proof-of-work for a client with perAddr submissions in
55+// the last hour among global ones: base, plus a bit per perAddrStep and per
56+// globalStep, at most limit. It decays as the sliding window empties.
57+func difficultyFor(base, limit, perAddr, global int) int {
58+ return min(base+max(perAddr, 0)/perAddrStep+max(global, 0)/globalStep, limit)
59+}
60+
61+// leadingZeroBits counts the zero bits at the start of sum.
62+func leadingZeroBits(sum []byte) int {
63+ n := 0
64+ for _, b := range sum {
65+ if b == 0 {
66+ n += 8
67+ continue
68+ }
69+ return n + bits.LeadingZeros8(b)
70+ }
71+ return n
72+}
73+
74+// powHash is SHA-256(nonce || ":" || solution).
75+func powHash(nonce []byte, solution string) []byte {
76+ h := sha256.New()
77+ h.Write(nonce)
78+ h.Write([]byte(":"))
79+ h.Write([]byte(solution))
80+ return h.Sum(nil)
81+}
82+
83+// challengeClaims is the signed payload of a challenge token.
84+type challengeClaims struct {
85+ V int `json:"v"`
86+ Repo string `json:"repo"`
87+ Nonce []byte `json:"nonce"`
88+ Difficulty int `json:"d"`
89+ IssuedAt int64 `json:"iat"`
90+ ExpiresAt int64 `json:"exp"`
91+ IP []byte `json:"ip"`
92+}
93+
94+// IssueSubmissions is the anonymous submission of issues: a signed challenge
95+// with an adaptive proof of work, and every server-side check before the issue
96+// awaits moderation.
97+type IssueSubmissions struct {
98+ meta ports.RepoMetaStore
99+ pending ports.PendingIssueStore
100+ bans ports.IssueBanStore
101+ limiter ports.SubmissionLimiter
102+ keys *IssueKeys
103+ bus ports.EventBus
104+ clock ports.Clock
105+ base int
106+ limit int
107+ minFill time.Duration
108+
109+ admitMu sync.Mutex // serializes the pending cap check and the store
110+
111+ mu sync.Mutex
112+ spent map[string]time.Time // nonce -> expiry of its challenge
113+ nextSweep time.Time
114+}
115+
116+var _ ports.IssueSubmissionService = (*IssueSubmissions)(nil)
117+
118+// IssueSubmissionsConfig holds what the submission needs. Every field but the
119+// difficulties is required.
120+type IssueSubmissionsConfig struct {
121+ Meta ports.RepoMetaStore
122+ Pending ports.PendingIssueStore
123+ Bans ports.IssueBanStore
124+ Limiter ports.SubmissionLimiter
125+ Keys *IssueKeys
126+ Bus ports.EventBus
127+ Clock ports.Clock
128+ // BaseDifficulty and MaxDifficulty replace the production constants; for
129+ // tests. Zero keeps the constants.
130+ BaseDifficulty, MaxDifficulty int
131+ // MinFillTime replaces minFillTime when positive; for tests.
132+ MinFillTime time.Duration
133+}
134+
135+// NewIssueSubmissions builds the submission use case.
136+func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
137+ s := &IssueSubmissions{
138+ meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
139+ keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
140+ base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime,
141+ spent: make(map[string]time.Time),
142+ }
143+ if cfg.BaseDifficulty > 0 {
144+ s.base = cfg.BaseDifficulty
145+ }
146+ if cfg.MaxDifficulty > 0 {
147+ s.limit = cfg.MaxDifficulty
148+ }
149+ if cfg.MinFillTime > 0 {
150+ s.minFill = cfg.MinFillTime
151+ }
152+ return s
153+}
154+
155+// openRepo returns the repository if anyone may submit to it, else
156+// domain.ErrNotFound.
157+func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) error {
158+ r, err := lookupRepo(ctx, s.meta, repo)
159+ if err != nil {
160+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrInvalidName) {
161+ return domain.ErrNotFound
162+ }
163+ return err
164+ }
165+ if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {
166+ return domain.ErrNotFound
167+ }
168+ return nil
169+}
170+
171+func (s *IssueSubmissions) sign(key, msg []byte) []byte {
172+ m := hmac.New(sha256.New, key)
173+ m.Write(msg)
174+ return m.Sum(nil)
175+}
176+
177+// Challenge issues a challenge for a client of the repository.
178+func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
179+ if err := ctx.Err(); err != nil {
180+ return ports.IssueChallenge{}, err
181+ }
182+ if err := s.openRepo(ctx, repo); err != nil {
183+ return ports.IssueChallenge{}, err
184+ }
185+ nonce := make([]byte, nonceLen)
186+ if _, err := rand.Read(nonce); err != nil {
187+ return ports.IssueChallenge{}, fmt.Errorf("random nonce: %w", err)
188+ }
189+ perAddr, global := s.limiter.Recent(ctx, addr)
190+ d := difficultyFor(s.base, s.limit, perAddr, global)
191+ now := s.clock.Now().UTC()
192+ claims := challengeClaims{
193+ V: tokenVersion, Repo: repo.String(), Nonce: nonce, Difficulty: d,
194+ IssuedAt: now.Unix(), ExpiresAt: now.Add(challengeTTL).Unix(), IP: s.keys.IPHash(addr),
195+ }
196+ payload, err := json.Marshal(claims)
197+ if err != nil {
198+ return ports.IssueChallenge{}, fmt.Errorf("encode challenge: %w", err)
199+ }
200+ return ports.IssueChallenge{
201+ Token: enc64(payload) + "." + enc64(s.sign(s.keys.PoWKey(), payload)),
202+ Nonce: enc64(nonce),
203+ Difficulty: d,
204+ FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
205+ IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
206+ MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(s.minFill),
207+ ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
208+ }, nil
209+}
210+
211+// parseToken verifies the signature and returns the claims, or false.
212+func (s *IssueSubmissions) parseToken(tok string) (challengeClaims, bool) {
213+ var c challengeClaims
214+ if len(tok) > maxChallengeLen {
215+ return c, false
216+ }
217+ p, sig, ok := strings.Cut(tok, ".")
218+ if !ok {
219+ return c, false
220+ }
221+ payload, err := base64.RawURLEncoding.DecodeString(p)
222+ if err != nil {
223+ return c, false
224+ }
225+ got, err := base64.RawURLEncoding.DecodeString(sig)
226+ if err != nil || !hmac.Equal(got, s.sign(s.keys.PoWKey(), payload)) {
227+ return c, false
228+ }
229+ if json.Unmarshal(payload, &c) != nil {
230+ return c, false
231+ }
232+ return c, true
233+}
234+
235+func validSolution(sol string) bool {
236+ if len(sol) < 1 || len(sol) > maxSolutionLen {
237+ return false
238+ }
239+ for i := 0; i < len(sol); i++ {
240+ if sol[i] < '0' || sol[i] > '9' {
241+ return false
242+ }
243+ }
244+ return true
245+}
246+
247+// spend marks the nonce spent. It returns domain.ErrSubmissionInvalid for a
248+// nonce already spent and domain.ErrSubmissionsPaused when the set is full.
249+func (s *IssueSubmissions) spend(nonce []byte, exp, now time.Time) error {
250+ s.mu.Lock()
251+ defer s.mu.Unlock()
252+ if len(s.spent) >= maxSpent || !now.Before(s.nextSweep) {
253+ for k, e := range s.spent {
254+ if now.After(e) { // an expired challenge is refused anyway
255+ delete(s.spent, k)
256+ }
257+ }
258+ s.nextSweep = now.Add(spentSweep)
259+ }
260+ key := string(nonce)
261+ if _, dup := s.spent[key]; dup {
262+ return domain.ErrSubmissionInvalid
263+ }
264+ if len(s.spent) >= maxSpent {
265+ return domain.ErrSubmissionsPaused
266+ }
267+ s.spent[key] = exp
268+ return nil
269+}
270+
271+// Submit checks the submission and stores it as a pending issue. The checks
272+// run in a fixed order and stop at the first failure; every bot-related
273+// failure answers domain.ErrSubmissionInvalid, and the precise reason is only
274+// logged (without address, hash or text).
275+func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
276+ if err := ctx.Err(); err != nil {
277+ return err
278+ }
279+ log := zerowrap.FromCtx(ctx)
280+ refuse := func(reason string, err error) error {
281+ log.Info().Str("repo", repo.String()).Str("reason", reason).Msg("issue submission refused")
282+ return err
283+ }
284+ if err := s.openRepo(ctx, repo); err != nil {
285+ return err
286+ }
287+ ipHash := s.keys.IPHash(addr)
288+ now := s.clock.Now().UTC()
289+ if f.Honeypot != "" {
290+ return refuse("honeypot", domain.ErrSubmissionInvalid)
291+ }
292+ c, ok := s.parseToken(f.Token)
293+ if !ok {
294+ return refuse("bad_token", domain.ErrSubmissionInvalid)
295+ }
296+ exp := time.Unix(c.ExpiresAt, 0)
297+ switch {
298+ case c.V != tokenVersion || len(c.Nonce) != nonceLen:
299+ return refuse("bad_token", domain.ErrSubmissionInvalid)
300+ case c.Repo != repo.String():
301+ return refuse("token_other_repo", domain.ErrSubmissionInvalid)
302+ case now.After(exp):
303+ return refuse("token_expired", domain.ErrSubmissionInvalid)
304+ case !hmac.Equal(c.IP, ipHash):
305+ return refuse("token_other_client", domain.ErrSubmissionInvalid)
306+ }
307+ if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
308+ return refuse("bad_form_token", domain.ErrSubmissionInvalid)
309+ }
310+ if now.Before(time.Unix(c.IssuedAt, 0).Add(s.minFill)) {
311+ return refuse("too_fast", domain.ErrSubmissionInvalid)
312+ }
313+ if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||
314+ leadingZeroBits(powHash(c.Nonce, f.Solution)) < c.Difficulty {
315+ return refuse("bad_pow", domain.ErrSubmissionInvalid)
316+ }
317+ if err := s.spend(c.Nonce, exp, now); err != nil {
318+ if errors.Is(err, domain.ErrSubmissionsPaused) {
319+ return refuse("spent_full", err)
320+ }
321+ return refuse("replayed", err)
322+ }
323+ banned, err := s.bans.Banned(ctx, ipHash, now)
324+ if err != nil {
325+ return fmt.Errorf("check ban: %w", err)
326+ }
327+ if banned {
328+ return refuse("banned", domain.ErrSubmissionInvalid)
329+ }
330+ title, err := domain.ParseIssueTitle(f.Title)
331+ if err != nil {
332+ return refuse("text_title", fmt.Errorf("title: %w", err))
333+ }
334+ body, err := domain.ParseIssueBody(f.Body)
335+ if err != nil {
336+ return refuse("text_body", fmt.Errorf("body: %w", err))
337+ }
338+ author, err := domain.ParseAuthorLabel(f.Author)
339+ if err != nil {
340+ return refuse("text_author", fmt.Errorf("author: %w", err))
341+ }
342+ id, err := domain.NewPendingIssueID()
343+ if err != nil {
344+ return fmt.Errorf("new pending issue id: %w", err)
345+ }
346+ if err := s.admit(ctx, addr, refuse, domain.PendingIssue{
347+ ID: id, Repo: repo, Title: title, Body: body, Author: author, CreatedAt: now, IPHash: ipHash,
348+ }); err != nil {
349+ return err
350+ }
351+ log.Info().Str("repo", repo.String()).Msg("issue submitted for moderation")
352+ s.bus.Publish(ctx, domain.IssueSubmitted{Repo: repo, ID: id, At: now})
353+ return nil
354+}
355+
356+// admit reserves the client's quota, checks the global queue cap and stores
357+// the issue. Ermite is a single process (ADR 0001), so admitMu serializes the
358+// Count -> Put section: concurrent submissions cannot exceed
359+// domain.MaxPendingIssues. It would not hold with several processes.
360+func (s *IssueSubmissions) admit(ctx context.Context, addr string, refuse func(string, error) error, p domain.PendingIssue) error {
361+ s.admitMu.Lock()
362+ defer s.admitMu.Unlock()
363+ ok, after, release := s.limiter.Reserve(ctx, addr)
364+ if !ok {
365+ return refuse("rate_limited", &domain.SubmissionRetryError{After: after})
366+ }
367+ n, err := s.pending.Count(ctx)
368+ if err != nil {
369+ release()
370+ return fmt.Errorf("count pending issues: %w", err)
371+ }
372+ if n >= domain.MaxPendingIssues {
373+ release()
374+ return refuse("pending_full", domain.ErrSubmissionsPaused)
375+ }
376+ if err := s.pending.Put(ctx, p); err != nil {
377+ release()
378+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
379+ }
380+ return nil
381+}
382+
383+func enc64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
A internal/usecase/issue_submissions_test.go +670 -0
@@ -0,0 +1,670 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "errors"
10+ "math/bits"
11+ "strconv"
12+ "strings"
13+ "sync"
14+ "sync/atomic"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/ports/mocks"
25+ "bnema.dev/ermite/internal/usecase"
26+)
27+
28+const testBits = 8
29+
30+type subFixture struct {
31+ t *testing.T
32+ meta *mocks.RepoMetaStore
33+ pending *mocks.PendingIssueStore
34+ bans *mocks.IssueBanStore
35+ limiter *mocks.SubmissionLimiter
36+ bus *mocks.EventBus
37+ uc *usecase.IssueSubmissions
38+ repo domain.Repo
39+ addr string
40+
41+ mu sync.Mutex
42+ now time.Time
43+}
44+
45+func (f *subFixture) advance(d time.Duration) {
46+ f.mu.Lock()
47+ defer f.mu.Unlock()
48+ f.now = f.now.Add(d)
49+}
50+
51+func newSubFixture(t *testing.T) *subFixture {
52+ t.Helper()
53+ f := &subFixture{
54+ t: t, meta: mocks.NewRepoMetaStore(t), pending: mocks.NewPendingIssueStore(t),
55+ bans: mocks.NewIssueBanStore(t), limiter: mocks.NewSubmissionLimiter(t), bus: mocks.NewEventBus(t),
56+ addr: "ip:203.0.113.7", now: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC),
57+ }
58+ f.repo = domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: f.now, Settings: withIssues(domain.IssuesPublic)}
59+ f.meta.EXPECT().Get(mock.Anything, f.repo.Name).Return(f.repo, nil).Maybe()
60+ clock := mocks.NewClock(t)
61+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
62+ f.mu.Lock()
63+ defer f.mu.Unlock()
64+ return f.now
65+ }).Maybe()
66+ keyStore := mocks.NewIssueKeyStore(t)
67+ keyStore.EXPECT().Key(mock.Anything).Return([]byte(strings.Repeat("k", 32)), nil)
68+ keys, err := usecase.NewIssueKeys(context.Background(), keyStore)
69+ require.NoError(t, err)
70+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
71+ Meta: f.meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Keys: keys, Bus: f.bus, Clock: clock,
72+ BaseDifficulty: testBits, MaxDifficulty: testBits + 3,
73+ })
74+ return f
75+}
76+
77+// open expects the checks after the proof of work: not banned, quota
78+// available, room. It returns the number of reservations released.
79+func (f *subFixture) open(pendingCount int) *atomic.Int32 {
80+ released := new(atomic.Int32)
81+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
82+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
83+ return true, 0, func() { released.Add(1) }
84+ }).Maybe()
85+ f.pending.EXPECT().Count(mock.Anything).Return(pendingCount, nil).Maybe()
86+ return released
87+}
88+
89+func (f *subFixture) challenge(addr string) ports.IssueChallenge {
90+ f.t.Helper()
91+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
92+ ch, err := f.uc.Challenge(context.Background(), f.repo.Name, addr)
93+ require.NoError(f.t, err)
94+ return ch
95+}
96+
97+func solve(t *testing.T, ch ports.IssueChallenge) string {
98+ t.Helper()
99+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
100+ require.NoError(t, err)
101+ for i := 0; ; i++ {
102+ s := strconv.Itoa(i)
103+ sum := sha256.Sum256(append(append(append([]byte(nil), nonce...), ':'), s...))
104+ if zeroBits(sum[:]) >= ch.Difficulty {
105+ return s
106+ }
107+ }
108+}
109+
110+func zeroBits(b []byte) int {
111+ n := 0
112+ for _, x := range b {
113+ if x == 0 {
114+ n += 8
115+ continue
116+ }
117+ return n + bits.LeadingZeros8(x)
118+ }
119+ return n
120+}
121+
122+// valid is a submission that passes every check once the fill time is over.
123+func (f *subFixture) valid(ch ports.IssueChallenge) ports.IssueSubmission {
124+ return ports.IssueSubmission{
125+ Token: ch.Token, Solution: solve(f.t, ch), FormToken: ch.FormToken,
126+ Title: "A bug", Body: "It breaks.", Author: "me",
127+ }
128+}
129+
130+func TestIssueChallenge(t *testing.T) {
131+ t.Parallel()
132+ f := newSubFixture(t)
133+ ch := f.challenge(f.addr)
134+ assert.Equal(t, testBits, ch.Difficulty)
135+ assert.Equal(t, f.now, ch.IssuedAt)
136+ assert.Equal(t, f.now.Add(usecase.MinFillTime), ch.MinSubmitAt)
137+ assert.Equal(t, f.now.Add(usecase.ChallengeTTL), ch.ExpiresAt)
138+ assert.Contains(t, ch.Token, ".")
139+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
140+ require.NoError(t, err)
141+ assert.Len(t, nonce, 16)
142+ assert.NotEmpty(t, ch.FormToken)
143+
144+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(4, 40).Once() // +2 +2
145+ ch, err = f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
146+ require.NoError(t, err)
147+ assert.Equal(t, testBits+3, ch.Difficulty) // capped at base+3
148+}
149+
150+func TestIssueSubmissionRepoAccess(t *testing.T) {
151+ t.Parallel()
152+ for name_, repo := range map[string]domain.Repo{
153+ "private": {Visibility: domain.VisibilityPrivate, Settings: withIssues(domain.IssuesPublic)},
154+ "owner issues": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOwner)},
155+ "issues off": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOff)},
156+ } {
157+ t.Run(name_, func(t *testing.T) {
158+ t.Parallel()
159+ f := newSubFixture(t)
160+ meta := mocks.NewRepoMetaStore(t)
161+ repo.Name = f.repo.Name
162+ meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
163+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Bus: f.bus, Clock: mocks.NewClock(t)})
164+ _, err := f.uc.Challenge(context.Background(), repo.Name, f.addr)
165+ assert.ErrorIs(t, err, domain.ErrNotFound)
166+ err = f.uc.Submit(context.Background(), repo.Name, f.addr, ports.IssueSubmission{})
167+ assert.ErrorIs(t, err, domain.ErrNotFound)
168+ })
169+ }
170+ t.Run("unknown", func(t *testing.T) {
171+ t.Parallel()
172+ f := newSubFixture(t)
173+ other := name(t, "other")
174+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{}, domain.ErrNotFound)
175+ assert.ErrorIs(t, f.uc.Submit(context.Background(), other, f.addr, ports.IssueSubmission{}), domain.ErrNotFound)
176+ _, err := f.uc.Challenge(context.Background(), other, f.addr)
177+ assert.ErrorIs(t, err, domain.ErrNotFound)
178+ })
179+}
180+
181+func TestIssueSubmitSuccess(t *testing.T) {
182+ t.Parallel()
183+ f := newSubFixture(t)
184+ f.open(0)
185+ ch := f.challenge(f.addr)
186+ sub := f.valid(ch)
187+ f.advance(usecase.MinFillTime)
188+ var put domain.PendingIssue
189+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.PendingIssue) error {
190+ put = p
191+ return nil
192+ }).Once()
193+ f.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
194+ e, ok := ev.(domain.IssueSubmitted)
195+ return ok && e.Repo == f.repo.Name && e.ID == put.ID
196+ })).Once()
197+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
198+ assert.Equal(t, "A bug", put.Title.String())
199+ assert.Equal(t, "It breaks.", put.Body.String())
200+ assert.Equal(t, "me", put.Author)
201+ assert.Equal(t, f.repo.Name, put.Repo)
202+ assert.Len(t, put.IPHash, domain.IPHashLen)
203+ assert.False(t, strings.Contains(string(put.IPHash), f.addr))
204+ assert.Equal(t, f.now, put.CreatedAt)
205+}
206+
207+// TestIssueSubmitRefusals: each failing check returns its sentinel; the
208+// strict mocks fail the test on any Put, Record or Publish.
209+func TestIssueSubmitRefusals(t *testing.T) {
210+ t.Parallel()
211+ type tc struct {
212+ name string
213+ want error
214+ arrange func(f *subFixture, ch *ports.IssueChallenge, sub *ports.IssueSubmission) (addr string)
215+ wait time.Duration
216+ setup func(f *subFixture) // replaces f.open
217+ }
218+ wait := usecase.MinFillTime
219+ cases := []tc{
220+ {name: "banned", want: domain.ErrSubmissionInvalid, wait: wait, setup: func(f *subFixture) {
221+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
222+ }},
223+ {name: "rate limited", want: domain.ErrSubmissionRateLimited, wait: wait, setup: func(f *subFixture) {
224+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
225+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(false, 5*time.Minute, func() {})
226+ }},
227+ {name: "paused at 200", want: domain.ErrSubmissionsPaused, wait: wait, setup: func(f *subFixture) {
228+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
229+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {})
230+ f.pending.EXPECT().Count(mock.Anything).Return(domain.MaxPendingIssues, nil)
231+ }},
232+ {name: "honeypot", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
233+ s.Honeypot = "http://spam"
234+ return ""
235+ }},
236+ {name: "forged signature", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
237+ p, sig, _ := strings.Cut(s.Token, ".")
238+ b := []byte(sig)
239+ b[0] ^= 1 // still base64url? flip between 'A'/'B'
240+ if b[0] > 'z' || b[0] < '0' {
241+ b[0] = 'A'
242+ }
243+ if string(b) == sig {
244+ b[0] = 'B'
245+ }
246+ s.Token = p + "." + string(b)
247+ return ""
248+ }},
249+ {name: "forged payload", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
250+ _, sig, _ := strings.Cut(s.Token, ".")
251+ s.Token = base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"d":1}`)) + "." + sig
252+ return ""
253+ }},
254+ {name: "token of another repo", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
255+ // a valid token for "other", sent to "proj"
256+ other := name(f.t, "other")
257+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil)
258+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(0, 0).Once()
259+ c2, err := f.uc.Challenge(context.Background(), other, f.addr)
260+ require.NoError(f.t, err)
261+ *s = f.valid(c2)
262+ return ""
263+ }},
264+ {name: "expired", want: domain.ErrSubmissionInvalid, wait: usecase.ChallengeTTL + time.Second},
265+ {name: "other ip", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, _ *ports.IssueChallenge, _ *ports.IssueSubmission) string {
266+ return "ip:198.51.100.9"
267+ }},
268+ {name: "too fast", want: domain.ErrSubmissionInvalid, wait: wait - time.Second},
269+ {name: "wrong form token", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
270+ s.FormToken = "AAAA"
271+ return ""
272+ }},
273+ {name: "insufficient pow", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
274+ // a counter that does not solve it
275+ nonce, _ := base64.RawURLEncoding.DecodeString(ch.Nonce)
276+ for i := 0; ; i++ {
277+ sum := sha256.Sum256(append(append(nonce, ':'), strconv.Itoa(i)...))
278+ if zeroBits(sum[:]) < ch.Difficulty {
279+ s.Solution = strconv.Itoa(i)
280+ return ""
281+ }
282+ }
283+ }},
284+ {name: "non digit solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
285+ s.Solution = "12a"
286+ return ""
287+ }},
288+ {name: "empty solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
289+ s.Solution = ""
290+ return ""
291+ }},
292+ {name: "huge solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
293+ s.Solution = strings.Repeat("1", 21)
294+ return ""
295+ }},
296+ }
297+ for _, c := range cases {
298+ t.Run(c.name, func(t *testing.T) {
299+ t.Parallel()
300+ f := newSubFixture(t)
301+ if c.setup != nil {
302+ c.setup(f)
303+ } else {
304+ f.open(0)
305+ }
306+ ch := f.challenge(f.addr)
307+ sub := f.valid(ch)
308+ addr := f.addr
309+ if c.arrange != nil {
310+ if a := c.arrange(f, &ch, &sub); a != "" {
311+ addr = a
312+ }
313+ }
314+ f.advance(c.wait)
315+ err := f.uc.Submit(context.Background(), f.repo.Name, addr, sub)
316+ require.ErrorIs(t, err, c.want)
317+ assert.ErrorIs(t, err, domain.ErrSubmissionRefused)
318+ if c.want == domain.ErrSubmissionRateLimited {
319+ var re *domain.SubmissionRetryError
320+ require.ErrorAs(t, err, &re)
321+ assert.Equal(t, 5*time.Minute, re.After)
322+ }
323+ })
324+ }
325+}
326+
327+func TestIssueSubmitReplayedNonce(t *testing.T) {
328+ t.Parallel()
329+ f := newSubFixture(t)
330+ f.open(0)
331+ ch := f.challenge(f.addr)
332+ sub := f.valid(ch)
333+ f.advance(usecase.MinFillTime)
334+ // first: text refused, nonce is spent anyway (nothing reserved)
335+ bad := sub
336+ bad.Title = ""
337+ var te *domain.TextError
338+ require.ErrorAs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, bad), &te)
339+ assert.Equal(t, 1, f.uc.SpentLen())
340+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
341+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
342+ assert.False(t, errors.Is(err, domain.ErrInvalidText))
343+}
344+
345+func TestIssueSubmitReplayAfterSuccess(t *testing.T) {
346+ t.Parallel()
347+ f := newSubFixture(t)
348+ f.open(0)
349+ ch := f.challenge(f.addr)
350+ sub := f.valid(ch)
351+ f.advance(usecase.MinFillTime)
352+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
353+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
354+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
355+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionInvalid)
356+}
357+
358+func TestIssueSubmitTextErrors(t *testing.T) {
359+ t.Parallel()
360+ for name_, mod := range map[string]func(*ports.IssueSubmission){
361+ "title": func(s *ports.IssueSubmission) { s.Title = "two\nlines" },
362+ "body": func(s *ports.IssueSubmission) { s.Body = strings.Repeat("x", domain.MaxIssueBodyLen+1) },
363+ "author": func(s *ports.IssueSubmission) { s.Author = strings.Repeat("x", domain.MaxAuthorLabelLen+1) },
364+ } {
365+ t.Run(name_, func(t *testing.T) {
366+ t.Parallel()
367+ f := newSubFixture(t)
368+ f.open(0)
369+ ch := f.challenge(f.addr)
370+ sub := f.valid(ch)
371+ mod(&sub)
372+ f.advance(usecase.MinFillTime)
373+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
374+ var te *domain.TextError
375+ require.ErrorAs(t, err, &te)
376+ assert.NotErrorIs(t, err, domain.ErrSubmissionRefused)
377+ })
378+ }
379+}
380+
381+func TestIssueSubmitSpentSetFull(t *testing.T) {
382+ t.Parallel()
383+ f := newSubFixture(t)
384+ f.open(0)
385+ ch := f.challenge(f.addr)
386+ sub := f.valid(ch)
387+ f.advance(usecase.MinFillTime)
388+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(time.Hour))
389+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
390+ assert.Equal(t, usecase.MaxSpent, f.uc.SpentLen()) // nothing live evicted
391+}
392+
393+func TestIssueSubmitSpentSetExpiry(t *testing.T) {
394+ t.Parallel()
395+ f := newSubFixture(t)
396+ f.open(0)
397+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Maybe()
398+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
399+ ch := f.challenge(f.addr)
400+ sub := f.valid(ch)
401+ f.advance(usecase.MinFillTime)
402+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
403+ assert.Equal(t, 1, f.uc.SpentLen())
404+ f.advance(usecase.ChallengeTTL + 2*time.Minute)
405+ ch2 := f.challenge(f.addr)
406+ sub2 := f.valid(ch2)
407+ f.advance(usecase.MinFillTime)
408+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub2))
409+ assert.Equal(t, 1, f.uc.SpentLen(), "the expired nonce was swept")
410+
411+ // a full set of expired nonces is swept, not a reason to pause
412+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(-time.Second))
413+ ch3 := f.challenge(f.addr)
414+ sub3 := f.valid(ch3)
415+ f.advance(usecase.MinFillTime)
416+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub3))
417+ assert.Equal(t, 2, f.uc.SpentLen(), "expired fillers swept, live nonces kept")
418+}
419+
420+func TestIssueSubmitGarbageDoesNotPanic(t *testing.T) {
421+ t.Parallel()
422+ f := newSubFixture(t)
423+ f.open(0)
424+ ch := f.challenge(f.addr)
425+ p, sig, _ := strings.Cut(ch.Token, ".")
426+ huge := strings.Repeat("A", 1<<20)
427+ tokens := []string{
428+ "", ".", "..", "a", "a.b", "!!!.???", huge, huge + "." + huge, p, p + ".", "." + sig,
429+ p + "." + sig + "." + sig, p + "." + sig[:len(sig)-3], p[:len(p)/2] + "." + sig,
430+ base64.RawURLEncoding.EncodeToString([]byte("null")) + "." + sig,
431+ base64.StdEncoding.EncodeToString([]byte("{}")) + "==." + sig, "\x00\xff.\x00",
432+ }
433+ f.advance(usecase.MinFillTime)
434+ for _, tok := range tokens {
435+ for _, s := range []ports.IssueSubmission{
436+ {Token: tok},
437+ {Token: tok, Solution: huge, FormToken: huge, Title: huge, Body: huge, Author: huge},
438+ {Token: tok, Solution: "0", FormToken: "\xff"},
439+ } {
440+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
441+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
442+ }
443+ }
444+ // the genuine token with garbage around it
445+ for _, s := range []ports.IssueSubmission{
446+ {Token: ch.Token, FormToken: huge, Solution: "1"},
447+ {Token: ch.Token, FormToken: ch.FormToken, Solution: huge},
448+ } {
449+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, s), domain.ErrSubmissionInvalid)
450+ }
451+}
452+
453+func TestIssueSubmissionHonoursContext(t *testing.T) {
454+ t.Parallel()
455+ f := newSubFixture(t)
456+ ctx, cancel := context.WithCancel(context.Background())
457+ cancel()
458+ assert.ErrorIs(t, f.uc.Submit(ctx, f.repo.Name, f.addr, ports.IssueSubmission{}), context.Canceled)
459+ _, err := f.uc.Challenge(ctx, f.repo.Name, f.addr)
460+ assert.ErrorIs(t, err, context.Canceled)
461+}
462+
463+func TestDifficulty(t *testing.T) {
464+ t.Parallel()
465+ for _, c := range []struct{ perAddr, global, want int }{
466+ {0, 0, 18}, {1, 0, 18}, {2, 0, 19}, {3, 0, 19}, {4, 0, 20}, {6, 0, 21}, {100, 0, 21},
467+ {0, 19, 18}, {0, 20, 19}, {0, 40, 20}, {0, 60, 21}, {0, 1000, 21},
468+ {2, 20, 20}, {4, 20, 21}, {-5, -5, 18},
469+ } {
470+ assert.Equal(t, c.want, usecase.DifficultyFor(c.perAddr, c.global), "%d/%d", c.perAddr, c.global)
471+ }
472+ assert.Equal(t, 18, usecase.BaseDifficulty)
473+ assert.Equal(t, 21, usecase.MaxDifficulty)
474+}
475+
476+// submitters builds n valid, distinct submissions (one challenge each) for the
477+// addresses addrOf(i) and the repositories repoOf(i), ready to be sent.
478+type prepared struct {
479+ repo domain.RepoName
480+ addr string
481+ sub ports.IssueSubmission
482+}
483+
484+func (f *subFixture) prepare(n int, addrOf func(int) string, repoOf func(int) domain.RepoName) []prepared {
485+ out := make([]prepared, n)
486+ for i := range n {
487+ addr := addrOf(i)
488+ repo := repoOf(i)
489+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
490+ ch, err := f.uc.Challenge(context.Background(), repo, addr)
491+ require.NoError(f.t, err)
492+ out[i] = prepared{repo: repo, addr: addr, sub: f.valid(ch)}
493+ }
494+ f.advance(usecase.MinFillTime)
495+ return out
496+}
497+
498+// race sends every prepared submission at once and returns the errors.
499+func (f *subFixture) race(ps []prepared) []error {
500+ errs := make([]error, len(ps))
501+ var wg sync.WaitGroup
502+ start := make(chan struct{})
503+ for i, p := range ps {
504+ wg.Add(1)
505+ go func() {
506+ defer wg.Done()
507+ <-start
508+ errs[i] = f.uc.Submit(context.Background(), p.repo, p.addr, p.sub)
509+ }()
510+ }
511+ close(start)
512+ wg.Wait()
513+ return errs
514+}
515+
516+func TestIssueSubmitConcurrentSameAddrRespectsQuota(t *testing.T) {
517+ t.Parallel()
518+ f := newSubFixture(t)
519+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
520+ f.pending.EXPECT().Count(mock.Anything).Return(0, nil).Maybe()
521+ // a real, atomic limiter semantics: PerHour grants then refusals
522+ var granted atomic.Int32
523+ var rlMu sync.Mutex
524+ f.limiter.EXPECT().Reserve(mock.Anything, f.addr).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
525+ rlMu.Lock()
526+ defer rlMu.Unlock()
527+ if granted.Load() >= 3 {
528+ return false, time.Minute, func() {}
529+ }
530+ granted.Add(1)
531+ return true, 0, func() {}
532+ }).Maybe()
533+ var puts atomic.Int32
534+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
535+ puts.Add(1)
536+ return nil
537+ }).Maybe()
538+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
539+ ps := f.prepare(20, func(int) string { return f.addr }, func(int) domain.RepoName { return f.repo.Name })
540+ var ok, limited int
541+ for _, err := range f.race(ps) {
542+ var re *domain.SubmissionRetryError
543+ switch {
544+ case err == nil:
545+ ok++
546+ case errors.As(err, &re):
547+ limited++
548+ default:
549+ t.Fatalf("unexpected error: %v", err)
550+ }
551+ }
552+ assert.Equal(t, 3, ok)
553+ assert.Equal(t, 17, limited)
554+ assert.Equal(t, int32(3), puts.Load())
555+}
556+
557+func TestIssueSubmitConcurrentRespectsPendingCap(t *testing.T) {
558+ t.Parallel()
559+ f := newSubFixture(t)
560+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
561+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
562+ other := name(t, "other")
563+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
564+ var count atomic.Int32
565+ count.Store(domain.MaxPendingIssues - 1)
566+ f.pending.EXPECT().Count(mock.Anything).RunAndReturn(func(context.Context) (int, error) {
567+ return int(count.Load()), nil
568+ }).Maybe()
569+ var puts atomic.Int32
570+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
571+ puts.Add(1)
572+ count.Add(1)
573+ return nil
574+ }).Maybe()
575+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
576+ ps := f.prepare(20, func(i int) string { return "ip:198.51.100." + strconv.Itoa(i+1) }, func(i int) domain.RepoName {
577+ if i%2 == 0 {
578+ return f.repo.Name
579+ }
580+ return other
581+ })
582+ var ok, paused int
583+ for _, err := range f.race(ps) {
584+ switch {
585+ case err == nil:
586+ ok++
587+ case errors.Is(err, domain.ErrSubmissionsPaused):
588+ paused++
589+ default:
590+ t.Fatalf("unexpected error: %v", err)
591+ }
592+ }
593+ assert.Equal(t, 1, ok)
594+ assert.Equal(t, 19, paused)
595+ assert.Equal(t, int32(1), puts.Load())
596+}
597+
598+func TestIssueSubmitReleasesTheReservationWhenStoreFails(t *testing.T) {
599+ t.Parallel()
600+ f := newSubFixture(t)
601+ released := f.open(0)
602+ boom := errors.New("disk full")
603+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(boom).Once()
604+ ch := f.challenge(f.addr)
605+ sub := f.valid(ch)
606+ f.advance(usecase.MinFillTime)
607+ require.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), boom)
608+ assert.Equal(t, int32(1), released.Load())
609+
610+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
611+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
612+ ch = f.challenge(f.addr)
613+ sub = f.valid(ch)
614+ f.advance(usecase.MinFillTime)
615+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
616+ assert.Equal(t, int32(1), released.Load(), "a stored submission keeps its reservation")
617+}
618+
619+func TestIssueSubmitReleasesTheReservationWhenPaused(t *testing.T) {
620+ t.Parallel()
621+ f := newSubFixture(t)
622+ released := f.open(domain.MaxPendingIssues)
623+ ch := f.challenge(f.addr)
624+ sub := f.valid(ch)
625+ f.advance(usecase.MinFillTime)
626+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
627+ assert.Equal(t, int32(1), released.Load())
628+}
629+
630+// TestIssueSubmitNoStateOracleBeforeProofOfWork: a banned client, a limited
631+// one, or a full queue all get the same answer to an invalid submission, and
632+// no limiter, count or store is touched (the mocks have no expectation).
633+func TestIssueSubmitNoStateOracleBeforeProofOfWork(t *testing.T) {
634+ t.Parallel()
635+ f := newSubFixture(t)
636+ ch := f.challenge(f.addr)
637+ valid := f.valid(ch)
638+ f.advance(usecase.MinFillTime)
639+ bad := []ports.IssueSubmission{
640+ {},
641+ {Token: "garbage", Solution: "x", FormToken: "y", Title: "", Body: ""},
642+ {Token: ch.Token, Honeypot: "x"},
643+ {Token: ch.Token, FormToken: ch.FormToken, Solution: "nope"},
644+ {Token: valid.Token, FormToken: valid.FormToken, Solution: "abc", Title: "two\nlines"}, // text error + bad pow
645+ }
646+ for _, s := range bad {
647+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
648+ require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
649+ assert.NotErrorIs(t, err, domain.ErrInvalidText)
650+ }
651+ // a fully valid submission from a banned client: still invalid, not
652+ // limited, paused or a text error, even with bad text.
653+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil).Once()
654+ bannedBadText := valid
655+ bannedBadText.Title = ""
656+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, bannedBadText)
657+ require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
658+ assert.NotErrorIs(t, err, domain.ErrInvalidText)
659+ var re *domain.SubmissionRetryError
660+ assert.False(t, errors.As(err, &re))
661+ assert.NotErrorIs(t, err, domain.ErrSubmissionsPaused)
662+}
663+
664+func TestIssueChallengeRevealsNoState(t *testing.T) {
665+ t.Parallel()
666+ f := newSubFixture(t) // no Banned, Reserve or Count expectation
667+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(50, 500).Once()
668+ _, err := f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
669+ require.NoError(t, err)
670+}
A internal/usecase/issues.go +291 -0
@@ -0,0 +1,291 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// MaxCommentsPerIssue is the most comments an issue holds. Past it AddComment
17+// fails with domain.ErrResourceExhausted. Comments are never deleted, so the
18+// limit counts the ones ever added.
19+const MaxCommentsPerIssue = 1000
20+
21+// Issues are the issue use cases (ADR 0031): open, comment on, close and
22+// reopen an issue. Issues share their numbers with the merge requests of the
23+// repository. A repository whose issues mode is off answers domain.ErrNotFound
24+// to everyone, the owner included, like a repository that does not exist.
25+// Reading needs the access of reading the repository (canRead), writing needs
26+// issue:write on it, which the branch prefixes of a token do not restrict:
27+// an issue writes no ref.
28+type Issues struct {
29+ meta ports.RepoMetaStore
30+ store ports.IssueStore
31+ mrs ports.MergeRequestStore
32+ bus ports.EventBus
33+ clock ports.Clock
34+}
35+
36+var (
37+ _ ports.IssueService = (*Issues)(nil)
38+ _ ports.IssueNumberResolver = (*Issues)(nil)
39+)
40+
41+// IssuesConfig holds what the issue use cases need. Every field is required.
42+type IssuesConfig struct {
43+ Meta ports.RepoMetaStore
44+ Store ports.IssueStore
45+ // MergeRequests tells which numbers name a merge request (Kind).
46+ MergeRequests ports.MergeRequestStore
47+ Bus ports.EventBus
48+ Clock ports.Clock
49+}
50+
51+// NewIssues builds the issue use cases.
52+func NewIssues(cfg IssuesConfig) *Issues {
53+ return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54+}
55+
56+// enabled looks the repository up and hides it when issues are off.
57+func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
58+ repo, err := lookupRepo(ctx, i.meta, name)
59+ if err != nil {
60+ return domain.Repo{}, err
61+ }
62+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
63+ return domain.Repo{}, domain.ErrNotFound
64+ }
65+ return repo, nil
66+}
67+
68+// readable applies the read rule: the access of reading the repository.
69+func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
70+ repo, err := i.enabled(ctx, name)
71+ if err != nil {
72+ return domain.Repo{}, err
73+ }
74+ if !canRead(p, repo) {
75+ return domain.Repo{}, domain.ErrNotFound
76+ }
77+ return repo, nil
78+}
79+
80+// writable is the write rule, as MergeRequests.writable: a principal that can
81+// neither read the repository nor holds the scope on it gets
82+// domain.ErrNotFound, one that cannot write the error of scopeAccess.
83+func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
84+ repo, err := i.enabled(ctx, name)
85+ if err != nil {
86+ return domain.Repo{}, err
87+ }
88+ if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) {
89+ return domain.Repo{}, domain.ErrNotFound
90+ }
91+ if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil {
92+ return domain.Repo{}, err
93+ }
94+ return repo, nil
95+}
96+
97+// Create opens an issue and publishes IssueOpened. The principal needs
98+// issue:write. Invalid texts give an *domain.InvalidFieldsError.
99+func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
100+ repo, err := i.writable(ctx, p, name)
101+ if err != nil {
102+ return domain.Issue{}, err
103+ }
104+ var v domain.Violations
105+ title, err := domain.ParseIssueTitle(params.Title)
106+ if err != nil {
107+ v.AddErr("title", err)
108+ }
109+ body, err := domain.ParseIssueBody(params.Body)
110+ if err != nil {
111+ v.AddErr("body", err)
112+ }
113+ if err := v.Err(); err != nil {
114+ return domain.Issue{}, err
115+ }
116+ now := i.clock.Now().UTC()
117+ issue, err := i.store.Create(ctx, domain.Issue{
118+ Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119+ Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
120+ })
121+ if err != nil {
122+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
123+ }
124+ log := zerowrap.FromCtx(ctx)
125+ log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128+ return issue, nil
129+}
130+
131+// List returns a page of the issues of a repository, highest number first.
132+func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
133+ size, err := pageSize(params.PageSize)
134+ if err != nil {
135+ return ports.IssuePage{}, err
136+ }
137+ if _, err := i.readable(ctx, p, name); err != nil {
138+ return ports.IssuePage{}, err
139+ }
140+ params.PageSize = size
141+ page, err := i.store.List(ctx, name, params)
142+ if err != nil {
143+ return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
144+ }
145+ return page, nil
146+}
147+
148+// Get returns an issue with its comments.
149+func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
150+ if _, err := i.readable(ctx, p, name); err != nil {
151+ return ports.IssueDetail{}, err
152+ }
153+ issue, err := i.store.Get(ctx, name, number)
154+ if err != nil {
155+ return ports.IssueDetail{}, err
156+ }
157+ comments, err := i.store.ListComments(ctx, name, number)
158+ if err != nil {
159+ return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
160+ }
161+ return ports.IssueDetail{Issue: issue, Comments: comments}, nil
162+}
163+
164+// AddComment adds a comment and publishes IssueCommented. The principal needs
165+// issue:write. The text must not be blank. A comment may follow a closed
166+// issue; one that already holds MaxCommentsPerIssue comments gives
167+// domain.ErrResourceExhausted.
168+func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
169+ repo, err := i.writable(ctx, p, name)
170+ if err != nil {
171+ return domain.IssueComment{}, err
172+ }
173+ issue, err := i.store.Get(ctx, name, number)
174+ if err != nil {
175+ return domain.IssueComment{}, err
176+ }
177+ var v domain.Violations
178+ body, err := domain.ParseIssueBody(text)
179+ switch {
180+ case err != nil:
181+ v.AddErr("text", err)
182+ case body.IsBlank():
183+ v.Add("text", "is required")
184+ }
185+ if err := v.Err(); err != nil {
186+ return domain.IssueComment{}, err
187+ }
188+ var c domain.IssueComment
189+ saved := false
190+ for range maxWriteRetries {
191+ if issue.CommentSeq >= MaxCommentsPerIssue {
192+ return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
193+ }
194+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: authorOf(p), Text: body, CreatedAt: i.clock.Now().UTC()}
195+ issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
196+ err = i.store.SaveComment(ctx, issue, c)
197+ if err == nil {
198+ saved = true
199+ break
200+ }
201+ if !errors.Is(err, domain.ErrConflict) {
202+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
203+ }
204+ if issue, err = i.store.Get(ctx, name, number); err != nil {
205+ return domain.IssueComment{}, err
206+ }
207+ }
208+ if !saved {
209+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict)
210+ }
211+ log := zerowrap.FromCtx(ctx)
212+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
214+ i.bus.Publish(ctx, domain.IssueCommented{
215+ Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216+ })
217+ return c, nil
218+}
219+
220+// Close closes an open issue and publishes IssueClosed. The principal needs
221+// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
222+func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
223+ return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
224+}
225+
226+// Reopen reopens a closed issue and publishes IssueReopened. The principal
227+// needs issue:write. An issue that is not closed gives
228+// domain.ErrPreconditionFailed.
229+func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
230+ return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
231+}
232+
233+// transition moves an issue from one state to the other, retrying when another
234+// write changes it in between.
235+func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
236+ repo, err := i.writable(ctx, p, name)
237+ if err != nil {
238+ return domain.Issue{}, err
239+ }
240+ verb := "close"
241+ if to == domain.IssueStateOpen {
242+ verb = "reopen"
243+ }
244+ for range maxWriteRetries {
245+ issue, err := i.store.Get(ctx, name, number)
246+ if err != nil {
247+ return domain.Issue{}, err
248+ }
249+ if issue.State != from {
250+ return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
251+ }
252+ issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
253+ err = i.store.Update(ctx, issue)
254+ if errors.Is(err, domain.ErrConflict) {
255+ continue
256+ }
257+ if err != nil {
258+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
259+ }
260+ log := zerowrap.FromCtx(ctx)
261+ log.Info().Str("repo", name.String()).Int64("number", number).
262+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
263+ if to == domain.IssueStateOpen {
264+ i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265+ } else {
266+ i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
267+ }
268+ return issue, nil
269+ }
270+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
271+}
272+
273+// Kind tells what number n of the repository names. The principal needs the
274+// access of reading the repository, and issues must be on there: otherwise it
275+// is domain.ErrNotFound.
276+func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
277+ if _, err := i.readable(ctx, p, name); err != nil {
278+ return domain.NumberNone, err
279+ }
280+ if _, err := i.store.Get(ctx, name, n); err == nil {
281+ return domain.NumberIssue, nil
282+ } else if !errors.Is(err, domain.ErrNotFound) {
283+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
284+ }
285+ if _, err := i.mrs.Get(ctx, name, n); err == nil {
286+ return domain.NumberMergeRequest, nil
287+ } else if !errors.Is(err, domain.ErrNotFound) {
288+ return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
289+ }
290+ return domain.NumberNone, nil
291+}
A internal/usecase/issues_integration_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/adapters/eventbus"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// Issues over the real store: shared numbers with merge requests, the life of
21+// an issue and its events.
22+func TestIssuesOverTheStore(t *testing.T) {
23+ ctx := context.Background()
24+ store, err := kvstore.Open(ctx, filepath.Join(t.TempDir(), "kv"))
25+ require.NoError(t, err)
26+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
27+ bus := eventbus.NewWithBuffer(64)
28+ events := bus.Subscribe(ctx)
29+ n := name(t, "proj")
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = domain.IssuesOwner
32+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}))
33+
34+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: bus, Clock: fixedClock{}})
35+ agent := domain.TokenPrincipal(domain.Token{ID: "t", Label: "bot", Scopes: []domain.Scope{domain.ScopeIssueWrite}, BranchPrefixes: []string{"x/"}})
36+
37+ mrTitle, err := domain.ParseMRTitle("Change")
38+ require.NoError(t, err)
39+ mr, err := store.MergeRequests().Create(ctx, domain.MergeRequest{
40+ Repo: n, Title: mrTitle, State: domain.MRStateOpen, Source: "feat", Target: "main", Author: "a", CreatedAt: now, UpdatedAt: now,
41+ })
42+ require.NoError(t, err)
43+ assert.EqualValues(t, 1, mr.Number)
44+
45+ issue, err := issues.Create(ctx, agent, n, ports.CreateIssueParams{Title: "Crash", Body: "details"})
46+ require.NoError(t, err)
47+ assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48+ assert.Equal(t, "bot", issue.Author)
49+
50+ kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51+ require.NoError(t, err)
52+ assert.Equal(t, domain.NumberMergeRequest, kind)
53+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 2)
54+ require.NoError(t, err)
55+ assert.Equal(t, domain.NumberIssue, kind)
56+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 3)
57+ require.NoError(t, err)
58+ assert.Equal(t, domain.NumberNone, kind)
59+
60+ _, err = issues.AddComment(ctx, agent, n, 2, "me too")
61+ require.NoError(t, err)
62+ _, err = issues.Close(ctx, agent, n, 2)
63+ require.NoError(t, err)
64+ _, err = issues.AddComment(ctx, agent, n, 2, "after close")
65+ require.NoError(t, err)
66+ _, err = issues.Close(ctx, agent, n, 2)
67+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
68+ _, err = issues.Reopen(ctx, agent, n, 2)
69+ require.NoError(t, err)
70+
71+ d, err := issues.Get(ctx, domain.Anonymous(), n, 2)
72+ require.NoError(t, err)
73+ assert.Equal(t, domain.IssueStateOpen, d.Issue.State)
74+ require.Len(t, d.Comments, 2)
75+ assert.Equal(t, "me too", d.Comments[0].Text.String())
76+ assert.EqualValues(t, 2, d.Issue.CommentSeq)
77+
78+ page, err := issues.List(ctx, domain.Anonymous(), n, ports.ListIssuesParams{State: domain.IssueStateClosed})
79+ require.NoError(t, err)
80+ assert.Empty(t, page.Issues)
81+
82+ var kinds []string
83+ for len(events) > 0 {
84+ kinds = append(kinds, (<-events).EventKind())
85+ }
86+ assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
87+}
A internal/usecase/issues_test.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+type issueUnit struct {
20+ meta *mocks.RepoMetaStore
21+ store *mocks.IssueStore
22+ mrs *mocks.MergeRequestStore
23+ bus *mocks.EventBus
24+ issues *usecase.Issues
25+ repo domain.Repo
26+}
27+
28+func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *issueUnit {
29+ t.Helper()
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = mode
32+ u := &issueUnit{
33+ meta: mocks.NewRepoMetaStore(t),
34+ store: mocks.NewIssueStore(t),
35+ mrs: mocks.NewMergeRequestStore(t),
36+ bus: mocks.NewEventBus(t),
37+ repo: domain.Repo{Name: name(t, "proj"), Visibility: vis, Settings: settings},
38+ }
39+ clock := mocks.NewClock(t)
40+ clock.EXPECT().Now().Return(now).Maybe()
41+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43+ return u
44+}
45+
46+func issuePrincipal(scopes ...domain.Scope) domain.Principal {
47+ return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes, BranchPrefixes: []string{"agent/"}})
48+}
49+
50+func TestIssuesOffHidesEverything(t *testing.T) {
51+ t.Parallel()
52+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
53+ u := newIssueUnit(t, domain.VisibilityPublic, mode)
54+ ctx, n := context.Background(), u.repo.Name
55+ _, err := u.issues.Create(ctx, domain.Root(), n, ports.CreateIssueParams{Title: "t"})
56+ assert.ErrorIs(t, err, domain.ErrNotFound)
57+ _, err = u.issues.List(ctx, domain.Root(), n, ports.ListIssuesParams{})
58+ assert.ErrorIs(t, err, domain.ErrNotFound)
59+ _, err = u.issues.Get(ctx, domain.Owner(), n, 1)
60+ assert.ErrorIs(t, err, domain.ErrNotFound)
61+ _, err = u.issues.AddComment(ctx, domain.Root(), n, 1, "x")
62+ assert.ErrorIs(t, err, domain.ErrNotFound)
63+ _, err = u.issues.Close(ctx, domain.Root(), n, 1)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
66+ assert.ErrorIs(t, err, domain.ErrNotFound)
67+ _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+ }
70+}
71+
72+func TestIssueAccessRules(t *testing.T) {
73+ t.Parallel()
74+ ctx := context.Background()
75+ t.Run("private repository hides reads", func(t *testing.T) {
76+ t.Parallel()
77+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
78+ _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{})
79+ assert.ErrorIs(t, err, domain.ErrNotFound)
80+ _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1)
81+ assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read")
82+ })
83+ t.Run("writes", func(t *testing.T) {
84+ t.Parallel()
85+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
86+ p := ports.CreateIssueParams{Title: "t"}
87+ _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p)
88+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
89+ _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p)
90+ assert.ErrorIs(t, err, domain.ErrForbidden)
91+ })
92+ t.Run("private write without the scope", func(t *testing.T) {
93+ t.Parallel()
94+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
95+ _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"})
96+ assert.ErrorIs(t, err, domain.ErrNotFound)
97+ })
98+}
99+
100+func TestIssueCreateAndTransitions(t *testing.T) {
101+ t.Parallel()
102+ ctx := context.Background()
103+ p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref
104+
105+ t.Run("create", func(t *testing.T) {
106+ t.Parallel()
107+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
108+ u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
109+ return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" &&
110+ i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now)
111+ })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil })
112+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
113+ o, ok := ev.(domain.IssueOpened)
114+ return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate
115+ })).Return()
116+ got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117+ require.NoError(t, err)
118+ assert.EqualValues(t, 7, got.Number)
119+ })
120+
121+ t.Run("invalid text stores nothing", func(t *testing.T) {
122+ t.Parallel()
123+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
124+ _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""})
125+ var fe *domain.InvalidFieldsError
126+ require.ErrorAs(t, err, &fe)
127+ })
128+
129+ open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1}
130+
131+ t.Run("close with every update conflicting is an error and publishes nothing", func(t *testing.T) {
132+ t.Parallel()
133+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
134+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
135+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict)
136+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
137+ assert.ErrorIs(t, err, domain.ErrConflict)
138+ })
139+
140+ t.Run("close retries a conflict then publishes", func(t *testing.T) {
141+ t.Parallel()
142+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
143+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice()
144+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
145+ u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).Return(nil).Once()
146+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
147+ c, ok := ev.(domain.IssueClosed)
148+ return ok && c.Issue.State == domain.IssueStateClosed
149+ })).Return()
150+ got, err := u.issues.Close(ctx, p, u.repo.Name, 3)
151+ require.NoError(t, err)
152+ assert.Equal(t, domain.IssueStateClosed, got.State)
153+ })
154+
155+ t.Run("close of a closed issue", func(t *testing.T) {
156+ t.Parallel()
157+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
158+ closed := open
159+ closed.State = domain.IssueStateClosed
160+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
161+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
162+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
163+ })
164+
165+ t.Run("reopen", func(t *testing.T) {
166+ t.Parallel()
167+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
168+ closed := open
169+ closed.State = domain.IssueStateClosed
170+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
171+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil)
172+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return()
173+ got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
174+ require.NoError(t, err)
175+ assert.Equal(t, domain.IssueStateOpen, got.State)
176+ })
177+
178+ t.Run("reopen of an open issue", func(t *testing.T) {
179+ t.Parallel()
180+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
181+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
182+ _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
183+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
184+ })
185+}
186+
187+func TestIssueAddComment(t *testing.T) {
188+ t.Parallel()
189+ ctx := context.Background()
190+ p := issuePrincipal(domain.ScopeIssueWrite)
191+ issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1}
192+
193+ t.Run("every save conflicting is an error and publishes nothing", func(t *testing.T) {
194+ t.Parallel()
195+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
196+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
197+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict)
198+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi") // the bus mock fails on any Publish
199+ assert.ErrorIs(t, err, domain.ErrConflict)
200+ })
201+
202+ t.Run("on a closed issue, with a retry", func(t *testing.T) {
203+ t.Parallel()
204+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
205+ fresh := issue
206+ fresh.CommentSeq = 5
207+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once()
208+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once()
209+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
210+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }),
211+ mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once()
212+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
213+ c, ok := ev.(domain.IssueCommented)
214+ return ok && c.Number == 3 && c.Comment.ID == 6
215+ })).Return()
216+ c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi")
217+ require.NoError(t, err)
218+ assert.EqualValues(t, 6, c.ID)
219+ })
220+
221+ t.Run("blank", func(t *testing.T) {
222+ t.Parallel()
223+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
224+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
225+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ")
226+ var fe *domain.InvalidFieldsError
227+ require.ErrorAs(t, err, &fe)
228+ })
229+
230+ t.Run("full", func(t *testing.T) {
231+ t.Parallel()
232+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
233+ full := issue
234+ full.CommentSeq = usecase.MaxCommentsPerIssue
235+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil)
236+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x")
237+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
238+ })
239+}
240+
241+func TestIssueKind(t *testing.T) {
242+ t.Parallel()
243+ ctx := context.Background()
244+ t.Run("issue", func(t *testing.T) {
245+ t.Parallel()
246+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
247+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil)
248+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
249+ require.NoError(t, err)
250+ assert.Equal(t, domain.NumberIssue, k)
251+ })
252+ t.Run("merge request", func(t *testing.T) {
253+ t.Parallel()
254+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
255+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
256+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
257+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
258+ require.NoError(t, err)
259+ assert.Equal(t, domain.NumberMergeRequest, k)
260+ })
261+ t.Run("none", func(t *testing.T) {
262+ t.Parallel()
263+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
264+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
265+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound)
266+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
267+ require.NoError(t, err)
268+ assert.Equal(t, domain.NumberNone, k)
269+ })
270+ t.Run("private repository", func(t *testing.T) {
271+ t.Parallel()
272+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
273+ _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
274+ assert.ErrorIs(t, err, domain.ErrNotFound)
275+ })
276+}
M internal/usecase/mergerequests_integration_test.go +1 -1
@@ -1199,7 +1199,7 @@ func TestUpdateRepoSettings(t *testing.T) {
11991199 off := false
12001200 got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
12011201 require.NoError(t, err)
1202- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)
1202+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)
12031203 again, err := r.repos.Get(r.ctx, root, r.repo)
12041204 require.NoError(t, err)
12051205 assert.Equal(t, got.Settings, again.Settings)
M internal/usecase/repos_write.go +13 -3
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6262 }
6363 before := repo
6464 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
6868 if params.AllowedMergeModes != nil {
@@ -72,6 +72,13 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
7272 }
7373 params.AllowedMergeModes = &modes
7474 }
75+ if params.Issues != nil {
76+ mode, err := domain.ParseIssuesMode(params.Issues.String())
77+ if err != nil {
78+ return domain.Repo{}, err
79+ }
80+ params.Issues = &mode
81+ }
7582 if params.Description != nil {
7683 if err := validateDescription(*params.Description); err != nil {
7784 return domain.Repo{}, err
@@ -109,7 +116,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
109116 // branch was checked above, so the remaining causes are a vanished
110117 // repository or an I/O failure.
111118 metaDone := false
112- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
113120 if repo, err = r.updateMeta(ctx, name, params); err != nil {
114121 return domain.Repo{}, err
115122 }
@@ -132,7 +139,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
132139 Bool("topics", params.Topics != nil).
133140 Bool("visibility", params.Visibility != nil).
134141 Bool("default_branch", params.DefaultBranch != nil).
135- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
142+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
136143 Str("principal", p.Label()).Str("token_id", p.TokenID()).
137144 Msg("repository updated")
138145 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -171,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
171178 if params.FailingChecksBlock != nil {
172179 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
173180 }
181+ if params.Issues != nil {
182+ repo.Settings.Issues = *params.Issues
183+ }
174184 err = r.meta.Update(ctx, repo)
175185 if errors.Is(err, domain.ErrConflict) {
176186 continue
M internal/usecase/repos_write_test.go +42 -0
@@ -215,6 +215,7 @@ func TestUpdate(t *testing.T) {
215215 "description": {Description: ptr("x")},
216216 "merge modes": {AllowedMergeModes: &modes},
217217 "checks rule": {FailingChecksBlock: &block},
218+ "issues": {Issues: ptr(domain.IssuesOwner)},
218219 } {
219220 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
220221 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
@@ -240,6 +241,47 @@ func TestUpdate(t *testing.T) {
240241 })
241242 }
242243
244+func TestUpdateIssuesMode(t *testing.T) {
245+ t.Parallel()
246+ ctx := context.Background()
247+ n := name(t, "tools")
248+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
249+
250+ t.Run("public is stored and published", func(t *testing.T) {
251+ t.Parallel()
252+ f := newFixture(t)
253+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
254+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
255+ return r.Settings.Issues == domain.IssuesPublic
256+ })).Return(nil)
257+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259+ require.NoError(t, err)
260+ assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues)
261+ })
262+
263+ t.Run("an unknown mode is invalid", func(t *testing.T) {
264+ t.Parallel()
265+ f := newFixture(t)
266+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
267+ _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesMode("all"))})
268+ assert.ErrorIs(t, err, domain.ErrInvalid)
269+ })
270+
271+ t.Run("owner is stored and published", func(t *testing.T) {
272+ t.Parallel()
273+ f := newFixture(t)
274+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
275+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
276+ return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
277+ })).Return(nil)
278+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
279+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
280+ require.NoError(t, err)
281+ assert.Equal(t, domain.IssuesOwner, repo.Settings.Issues)
282+ })
283+}
284+
243285 func TestDelete(t *testing.T) {
244286 t.Parallel()
245287 ctx := context.Background()
A proto/ermite/storage/issue.proto +71 -0
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+syntax = "proto3";
6+
7+package ermite.storage;
8+
9+import "google/protobuf/timestamp.proto";
10+
11+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
12+
13+// IssueState is where an issue is in its life.
14+enum IssueState {
15+ ISSUE_STATE_UNSPECIFIED = 0;
16+ ISSUE_STATE_OPEN = 1;
17+ ISSUE_STATE_CLOSED = 2;
18+ // Awaiting moderation. Not stored yet.
19+ ISSUE_STATE_PENDING = 3;
20+}
21+
22+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
23+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
24+// indexed under "isopen/<repo>/<number:010d>".
25+message Issue {
26+ int64 number = 1;
27+ string title = 2;
28+ string body = 3;
29+ IssueState state = 4;
30+ // Label of the token that opened it.
31+ string author = 5;
32+ google.protobuf.Timestamp created_at = 6;
33+ google.protobuf.Timestamp updated_at = 7;
34+ // Last comment id handed out for this issue.
35+ int64 comment_seq = 8;
36+ // True for an issue approved from the public form: its text comes from
37+ // an anonymous visitor and is rendered as untrusted.
38+ bool external = 9;
39+}
40+
41+// IssueComment is stored under the key
42+// "isc/<repo>/<number:010d>/<id:010d>".
43+message IssueComment {
44+ int64 id = 1;
45+ string author = 2;
46+ string text = 3;
47+ google.protobuf.Timestamp created_at = 4;
48+}
49+
50+// PendingIssue is an issue submitted through the public form, stored under
51+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
52+// It has no number until the owner approves it.
53+message PendingIssue {
54+ string title = 1;
55+ string body = 2;
56+ // Display name given by the submitter. May be empty.
57+ string author = 3;
58+ google.protobuf.Timestamp created_at = 4;
59+ // HMAC-SHA256 of the client key (32 bytes).
60+ bytes ip_hash = 5;
61+}
62+
63+// IssueBan is stored under the key "isban/<hex of the hash>".
64+message IssueBan {
65+ google.protobuf.Timestamp until = 1;
66+}
67+
68+// IssueKey is stored under the key "meta/issuekey".
69+message IssueKey {
70+ bytes key = 1;
71+}
M proto/ermite/storage/merge_request.proto +10 -0
@@ -22,6 +22,16 @@ message RepoSettings {
2222 // Never empty once stored.
2323 repeated MergeMode allowed_merge_modes = 1;
2424 bool failing_checks_block = 2;
25+ // Who may write issues; unspecified reads as off.
26+ IssuesMode issues = 3;
27+}
28+
29+// IssuesMode says who may write issues in a repository.
30+enum IssuesMode {
31+ ISSUES_MODE_UNSPECIFIED = 0;
32+ ISSUES_MODE_OFF = 1;
33+ ISSUES_MODE_OWNER = 2;
34+ ISSUES_MODE_PUBLIC = 3;
2535 }
2636
2737 // MergeRequestState is where a merge request is in its life.
M skills/ermite/CLI.md +15 -0
@@ -41,6 +41,7 @@ Scopes:
4141 | `repo:admin` | delete a repository, change visibility, description, default branch, merge settings |
4242 | `mr:write` | open, comment on, resolve, close, mark read |
4343 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
44+| `issue:write` | open, comment on, close and reopen issues |
4445 | `token:admin` | create, list and revoke tokens |
4546 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
4647
@@ -66,6 +67,20 @@ merge request web URL on its own line. With `--json`, merge request objects
6667
6768 Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
6869
70+## Issues
71+
72+Issues share the numbering of merge requests and must be enabled per repository: `repo edit <repo> --issues off|owner|public` (`repo:admin`; default `off`; `public` also opens a moderated form to visitors of a public repository). Reading and writing needs a server with the `issues` feature.
73+
74+| Command | Scope | Notes |
75+|---|---|---|
76+| `issue create <repo> --title T [--body TEXT\|-] [--body-file F\|-]` | `issue:write` | title of 120 characters max |
77+| `issue list <repo> [--state open\|closed\|all] [--json]` | read access (public repository, or `repo:read` on a private one) | default `open`; `issue:write` alone does not grant reading |
78+| `issue show <repo> <n> [--json]` | read access (public repository, or `repo:read` on a private one) | issue and comments; `issue:write` alone does not grant reading |
79+| `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |
80+| `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |
81+
82+Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.
83+
6984 ## Workflows
7085
7186 | Command | Scope | Notes |

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close