detached f099a6f

Merge 67508cc051e5d547a6da8b5716f39abda92fc705 into 86ac875add464391373c8352a8777dc283bc2016

commit
f099a6f1e210361ad737a8642db4a18afbbe2ed5
author
ermite · · 2026-10-19 12:50 UTC
merge of
86ac875 + 67508cc · diff against the first parent
signature
unsigned
176 files · +18416 -1025
M README.md +1 -0
@@ -134,6 +134,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
134 - [Git over HTTP](docs/git-http.md): URLs and authentication134 - [Git over HTTP](docs/git-http.md): URLs and authentication
135 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens135 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
136 - [Merge requests](docs/merge-requests.md)136 - [Merge requests](docs/merge-requests.md)
137+- [Issues](docs/issues.md)
137 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys138 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys
138 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links139 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
139 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows140 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
M docs/cli.md +7 -2
@@ -48,7 +48,7 @@ It does not run:
48 48
49 ```sh49 ```sh
50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]50 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]
52 ermite repo delete <name> [--yes]52 ermite repo delete <name> [--yes]
53 53
54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -59,6 +59,9 @@ ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--
59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
60 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>60 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
61 61
62+ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63+ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+
62 ermite run list <repo> [--workflow …] [--branch …] [--status …]65 ermite run list <repo> [--workflow …] [--branch …] [--status …]
63 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]66 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
64 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]67 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
@@ -82,9 +85,11 @@ ermite version # client and server versions
82 ermite completion bash|zsh|fish85 ermite completion bash|zsh|fish
83 ```86 ```
84 87
88+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).
89+
85 ## Output90 ## Output
86 91
87-- `--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`.92+- `--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`.
88 - `watch --json` prints one object per line.93 - `watch --json` prints one object per line.
89 - Errors are one line on stderr (`ermite: …`) with exit status 1.94 - Errors are one line on stderr (`ermite: …`) with exit status 1.
90 - Shell completion completes repository names from the server.95 - 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
66 66
67 ## Storage schema67 ## Storage schema
68 68
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
6 ermite token revoke <id>6 ermite token revoke <id>
7 ```7 ```
8 8
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). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.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). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
10 10
11 | Preset | For |11 | Preset | For |
12 |---|---|12 |---|---|
@@ -29,6 +29,7 @@ The token **can**:
29 29
30 - push, force-push and delete branches under its prefixes;30 - push, force-push and delete branches under its prefixes;
31 - open, comment on and close merge requests; with `mr:review`, give verdicts;31 - open, comment on and close merge requests; with `mr:review`, give verdicts;
32+- with `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;
32 - with `mr:merge`, merge into a branch under its prefixes;33 - with `mr:merge`, merge into a branch under its prefixes;
33 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;34 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
34 - create tokens whose prefixes lie within its own.35 - 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 +109 -38 generated file: collapsed
M go.mod +2 -2
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.8.016+ bnema.dev/ermite-api v0.9.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
@@ -32,6 +32,7 @@ require (
32 golang.org/x/crypto v0.57.032 golang.org/x/crypto v0.57.0
33 golang.org/x/sys v0.49.033 golang.org/x/sys v0.49.0
34 golang.org/x/term v0.46.034 golang.org/x/term v0.46.0
35+ golang.org/x/text v0.42.0
35 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a436 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
36 google.golang.org/protobuf v1.36.1237 google.golang.org/protobuf v1.36.12
37 )38 )
@@ -164,7 +165,6 @@ require (
164 golang.org/x/mod v0.41.0 // indirect165 golang.org/x/mod v0.41.0 // indirect
165 golang.org/x/net v0.59.0 // indirect166 golang.org/x/net v0.59.0 // indirect
166 golang.org/x/sync v0.23.0 // indirect167 golang.org/x/sync v0.23.0 // indirect
167- golang.org/x/text v0.42.0 // indirect
168 golang.org/x/tools v0.51.0 // indirect168 golang.org/x/tools v0.51.0 // indirect
169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
@@ -95,6 +95,11 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient {
95 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)95 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)
96 }96 }
97 97
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+
98 // Secrets returns the SecretService client.103 // Secrets returns the SecretService client.
99 func (c *Client) Secrets() ermiteconnect.SecretServiceClient {104 func (c *Client) Secrets() ermiteconnect.SecretServiceClient {
100 return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...)105 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) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 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},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 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,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 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,
151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
M internal/adapters/client/features.go +4 -1
@@ -18,6 +18,9 @@ const (
18 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch18 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch
19 // (`ermite mr merge --delete-branch`).19 // (`ermite mr merge --delete-branch`).
20 FeatureMergeDeleteBranch = "merge_requests.delete_branch"20 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"
21 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).24 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
22 FeatureSigningKey = "signing_key"25 FeatureSigningKey = "signing_key"
23 // FeatureSession is SessionService (`ermite web login`, `web logout`).26 // FeatureSession is SessionService (`ermite web login`, `web logout`).
@@ -55,7 +58,7 @@ const (
55 58
56 // UsedFeatures lists the features the CLI uses, in the order they are reported.59 // UsedFeatures lists the features the CLI uses, in the order they are reported.
57 func UsedFeatures() []string {60 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}
59 }62 }
60 63
61 // MissingFeatures returns the features the CLI uses that have is lacking.64 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/client/scopes_test.go +2 -0
@@ -26,6 +26,7 @@ func TestScopeWireNames(t *testing.T) {
26 ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write",26 ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write",
27 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",27 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
28 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",28 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
29+ ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
29 }30 }
30 for s, name := range want {31 for s, name := range want {
31 assert.Equal(t, name, client.ScopeName(s))32 assert.Equal(t, name, client.ScopeName(s))
@@ -44,6 +45,7 @@ func TestPresetExpansion(t *testing.T) {
44 45
45 agent, err := client.PresetScopes("agent")46 agent, err := client.PresetScopes("agent")
46 require.NoError(t, err)47 require.NoError(t, err)
48+ assert.NotContains(t, agent, ermite.Scope_SCOPE_ISSUE_WRITE, "agents get issue:write only when asked")
47 assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent)49 assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent)
48 50
49 ci, err := client.PresetScopes("CI")51 ci, err := client.PresetScopes("CI")
M internal/adapters/kvstore/export_test.go +41 -1
@@ -2,7 +2,13 @@
2 2
3 package kvstore3 package kvstore
4 4
5-import "context"5+import (
6+ "context"
7+
8+ kv "bnema.dev/ermite-kv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
6 12
7 // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of13 // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of
8 // DeleteEnvironment and its batch, and returns the function that removes it.14 // 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) {
25 item, ok := c.db.Get(ctx, runnerKey(id))31 item, ok := c.db.Get(ctx, runnerKey(id))
26 return item.Version, ok32 return item.Version, ok
27 }33 }
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 +561 -0
@@ -0,0 +1,561 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "slices"
8+ "sort"
9+ "strconv"
10+ "sync"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/adapters/kvstore"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
24+ t.Helper()
25+ ti, err := domain.ParseIssueTitle(title)
26+ require.NoError(t, err)
27+ body, err := domain.ParseIssueBody("what\nhappened")
28+ require.NoError(t, err)
29+ return domain.Issue{
30+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
31+ Author: "agent", CreatedAt: created, UpdatedAt: created,
32+ }
33+}
34+
35+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
36+ t.Helper()
37+ body, err := domain.ParseIssueBody(text)
38+ require.NoError(t, err)
39+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
40+}
41+
42+// countKeys counts the keys of the store under prefix.
43+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
44+ t.Helper()
45+ return s.CountKeysForTest(context.Background(), prefix)
46+}
47+
48+func TestIssueRoundTripAndPaging(t *testing.T) {
49+ ctx := context.Background()
50+ s, _, n := repoWithMRs(t)
51+ is := s.Issues()
52+
53+ first, err := is.Create(ctx, newIssue(t, n, "one"))
54+ require.NoError(t, err)
55+ assert.Equal(t, int64(1), first.Number)
56+ assert.NotZero(t, first.Version)
57+ got, err := is.Get(ctx, n, 1)
58+ require.NoError(t, err)
59+ assert.Equal(t, first, got)
60+ _, err = is.Get(ctx, n, 2)
61+ assert.ErrorIs(t, err, domain.ErrNotFound)
62+
63+ for _, title := range []string{"two", "three", "four"} {
64+ _, err := is.Create(ctx, newIssue(t, n, title))
65+ require.NoError(t, err)
66+ }
67+ closed, err := is.Get(ctx, n, 2)
68+ require.NoError(t, err)
69+ closed.State = domain.IssueStateClosed
70+ require.NoError(t, is.Update(ctx, closed))
71+
72+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
73+ require.NoError(t, err)
74+ require.Len(t, page.Issues, 3)
75+ assert.Equal(t, int64(4), page.Issues[0].Number)
76+ require.NotEmpty(t, page.NextPageToken)
77+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
78+ require.NoError(t, err)
79+ require.Len(t, page.Issues, 1)
80+ assert.Equal(t, int64(1), page.Issues[0].Number)
81+ assert.Empty(t, page.NextPageToken)
82+
83+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
84+ require.NoError(t, err)
85+ require.Len(t, page.Issues, 1)
86+ assert.Equal(t, int64(2), page.Issues[0].Number)
87+
88+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
89+ assert.ErrorIs(t, err, domain.ErrInvalid)
90+
91+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
92+ assert.ErrorIs(t, err, domain.ErrNotFound)
93+}
94+
95+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
96+ ctx := context.Background()
97+ s, _, n := repoWithMRs(t)
98+ is := s.Issues()
99+ first, err := is.Create(ctx, newIssue(t, n, "t"))
100+ require.NoError(t, err)
101+ a, b := first, first
102+ a.State = domain.IssueStateClosed
103+ require.NoError(t, is.Update(ctx, a))
104+ b.State = domain.IssueStateClosed
105+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
106+}
107+
108+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
109+ ctx := context.Background()
110+ s, _, n := repoWithMRs(t)
111+ is := s.Issues()
112+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
113+ require.NoError(t, err)
114+
115+ issue.CommentSeq = 1
116+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
117+ // The issue moved on: the same version is refused.
118+ issue.CommentSeq = 2
119+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
120+ issue, err = is.Get(ctx, n, issue.Number)
121+ require.NoError(t, err)
122+ issue.CommentSeq = 2
123+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
124+
125+ comments, err := is.ListComments(ctx, n, issue.Number)
126+ require.NoError(t, err)
127+ require.Len(t, comments, 2)
128+ assert.Equal(t, "first", comments[0].Text.String())
129+ assert.Equal(t, int64(2), comments[1].ID)
130+
131+ require.NoError(t, s.Delete(ctx, n))
132+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
133+}
134+
135+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
136+ ctx := context.Background()
137+ s, mrs, n := repoWithMRs(t)
138+ is := s.Issues()
139+ const workers = 16
140+ var (
141+ wg sync.WaitGroup
142+ mu sync.Mutex
143+ numbers []int64
144+ )
145+ for w := range workers {
146+ wg.Go(func() {
147+ var number int64
148+ if w%2 == 0 {
149+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
150+ if !assert.NoError(t, err) {
151+ return
152+ }
153+ number = got.Number
154+ } else {
155+ got, err := is.Create(ctx, newIssue(t, n, "t"))
156+ if !assert.NoError(t, err) {
157+ return
158+ }
159+ number = got.Number
160+ }
161+ mu.Lock()
162+ numbers = append(numbers, number)
163+ mu.Unlock()
164+ })
165+ }
166+ wg.Wait()
167+ want := make([]int64, workers)
168+ for i := range want {
169+ want[i] = int64(i + 1)
170+ }
171+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
172+}
173+
174+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
175+ ctx := context.Background()
176+ s, _, n := repoWithMRs(t)
177+ sib := name(t, "proj-2")
178+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
179+ is := s.Issues()
180+
181+ for _, repo := range []domain.RepoName{n, sib} {
182+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
183+ require.NoError(t, err)
184+ issue.CommentSeq = 1
185+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
186+ }
187+ require.NoError(t, s.Delete(ctx, n))
188+
189+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
190+ require.NoError(t, err)
191+ assert.Empty(t, page.Issues)
192+ comments, err := is.ListComments(ctx, n, 1)
193+ require.NoError(t, err)
194+ assert.Empty(t, comments)
195+ assert.Empty(t, openOf(t, is, n))
196+ assert.Zero(t, countKeys(t, s, "is/proj/"))
197+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
198+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
199+
200+ open := openOf(t, is, sib)
201+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
202+ comments, err = is.ListComments(ctx, sib, 1)
203+ require.NoError(t, err)
204+ assert.Len(t, comments, 1)
205+
206+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
207+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
208+ require.NoError(t, err)
209+ assert.Equal(t, int64(1), again.Number)
210+}
211+
212+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
213+ ctx := context.Background()
214+ s, _, n := repoWithMRs(t)
215+ is := s.Issues()
216+ a, err := is.Create(ctx, newIssue(t, n, "a"))
217+ require.NoError(t, err)
218+ b, err := is.Create(ctx, newIssue(t, n, "b"))
219+ require.NoError(t, err)
220+
221+ numbers := func() []int64 {
222+ open := openOf(t, is, n)
223+ var out []int64
224+ for _, i := range open {
225+ out = append(out, i.Number)
226+ }
227+ return out
228+ }
229+ assert.Equal(t, []int64{2, 1}, numbers())
230+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231+
232+ a.State = domain.IssueStateClosed
233+ require.NoError(t, is.Update(ctx, a))
234+ assert.Equal(t, []int64{2}, numbers())
235+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236+
237+ a, err = is.Get(ctx, n, a.Number)
238+ require.NoError(t, err)
239+ a.State = domain.IssueStateOpen
240+ require.NoError(t, is.Update(ctx, a))
241+ assert.Equal(t, []int64{2, 1}, numbers())
242+
243+ // A comment keeps the index as the issue says.
244+ b.State = domain.IssueStateClosed
245+ b.CommentSeq = 1
246+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247+ assert.Equal(t, []int64{1}, numbers())
248+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249+}
250+
251+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252+ ctx := context.Background()
253+ s := openStore(t, t.TempDir())
254+ n := name(t, "proj")
255+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256+ got, err := s.Get(ctx, n)
257+ require.NoError(t, err)
258+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260+ got.Settings.Issues = m
261+ require.NoError(t, s.Update(ctx, got))
262+ again, err := s.Get(ctx, n)
263+ require.NoError(t, err)
264+ assert.Equal(t, m, again.Settings.Issues)
265+ }
266+ got.Settings.Issues = "everyone"
267+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+}
269+
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
271+// original scope keep their owner rights, gaining mr:review and issue:write
272+// (each only if missing); no other token changes.
273+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
274+ ctx := context.Background()
275+ dir := t.TempDir()
276+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
277+
278+ all9 := []string{
279+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
280+ "ci:write", "secrets:write", "runner:register", "token:admin",
281+ }
282+ tokens := map[string]*storagepb.Token{
283+ "full": {Id: "full", Scopes: all9},
284+ "full10": {Id: "full10", Scopes: append(slices.Clone(all9), "mr:review")},
285+ "reposmr": {Id: "reposmr", Scopes: append(slices.Clone(all9), "mr:review"), Repos: []string{"proj"}},
286+ "missing": {Id: "missing", Scopes: all9[:8]},
287+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
288+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
289+ "small": {Id: "small", Scopes: []string{"repo:read"}},
290+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
291+ }
292+ for id, tok := range tokens {
293+ tok.Hash = make([]byte, 32)
294+ tok.Hash[0] = byte(len(id))
295+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
296+ }
297+
298+ s, err := kvstore.Open(ctx, dir)
299+ require.NoError(t, err)
300+ t.Cleanup(func() { _ = s.Close(ctx) })
301+ get := func(id string) domain.Token {
302+ tok, err := s.Tokens().Get(ctx, id)
303+ require.NoError(t, err)
304+ return tok
305+ }
306+
307+ full := get("full")
308+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
309+ assert.Contains(t, full.Scopes, domain.ScopeMRReview)
310+ assert.Len(t, full.Scopes, 11)
311+ full10 := get("full10")
312+ assert.Len(t, full10.Scopes, 11)
313+ assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
314+ assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
315+ assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
316+ assert.Len(t, get("reposmr").Scopes, 10)
317+ assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
318+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
319+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
320+
321+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
322+ assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
323+ assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
324+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeMRReview)
325+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
326+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
327+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
328+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
329+ assert.Len(t, get("missing").Scopes, 8)
330+ assert.Len(t, get("repos").Scopes, 9)
331+ assert.Len(t, get("prefix").Scopes, 9)
332+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
333+ assert.Equal(t, "full", full.ID)
334+
335+ require.NoError(t, s.Close(ctx))
336+ assert.Equal(t, uint32(4), readSchema(t, dir))
337+
338+ // A second open changes nothing.
339+ s2, err := kvstore.Open(ctx, dir)
340+ require.NoError(t, err)
341+ tok, err := s2.Tokens().Get(ctx, "full")
342+ require.NoError(t, err)
343+ assert.Equal(t, full.Scopes, tok.Scopes)
344+ tok10, err := s2.Tokens().Get(ctx, "full10")
345+ require.NoError(t, err)
346+ assert.Equal(t, full10.Scopes, tok10.Scopes)
347+ require.NoError(t, s2.Close(ctx))
348+}
349+
350+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
351+ ctx := context.Background()
352+ dir := t.TempDir()
353+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
354+ _, err := kvstore.Open(ctx, dir)
355+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
356+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
357+}
358+
359+// A reopen racing the delete of the repository either lands before the delete
360+// removes it or fails; it never leaves an isopen/ key of a repository that is
361+// gone.
362+func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
363+ ctx := context.Background()
364+ for range 30 {
365+ s, _, n := repoWithMRs(t)
366+ is := s.Issues()
367+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
368+ require.NoError(t, err)
369+ issue.State = domain.IssueStateClosed
370+ require.NoError(t, is.Update(ctx, issue))
371+ closed, err := is.Get(ctx, n, issue.Number)
372+ require.NoError(t, err)
373+ closed.State = domain.IssueStateOpen
374+
375+ var wg sync.WaitGroup
376+ start := make(chan struct{})
377+ wg.Add(2)
378+ go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
379+ go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
380+ close(start)
381+ wg.Wait()
382+
383+ for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
384+ assert.Zero(t, countKeys(t, s, prefix), prefix)
385+ }
386+ assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
387+ }
388+}
389+
390+func TestListOpenAllSpansRepositoriesThroughTheIndex(t *testing.T) {
391+ ctx := context.Background()
392+ s, mrs, n := repoWithMRs(t)
393+ other := name(t, "other")
394+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
395+ is := s.Issues()
396+
397+ i1, err := is.Create(ctx, newIssue(t, n, "one"))
398+ require.NoError(t, err)
399+ i2, err := is.Create(ctx, newIssue(t, other, "two"))
400+ require.NoError(t, err)
401+ i3, err := is.Create(ctx, newIssue(t, other, "three"))
402+ require.NoError(t, err)
403+ i3.State = domain.IssueStateClosed
404+ require.NoError(t, is.Update(ctx, i3))
405+ _, err = mrs.Create(ctx, newMR(t, n, "mr1"))
406+ require.NoError(t, err)
407+ m2, err := mrs.Create(ctx, newMR(t, other, "mr2"))
408+ require.NoError(t, err)
409+ m3, err := mrs.Create(ctx, newMR(t, other, "mr3"))
410+ require.NoError(t, err)
411+ m3.State = domain.MRStateClosed
412+ require.NoError(t, mrs.Update(ctx, m3))
413+
414+ issues, err := is.ListOpenAll(ctx)
415+ require.NoError(t, err)
416+ var got []string
417+ for _, i := range issues {
418+ got = append(got, i.Repo.String()+"#"+strconv.FormatInt(i.Number, 10))
419+ }
420+ assert.ElementsMatch(t, []string{
421+ i1.Repo.String() + "#" + strconv.FormatInt(i1.Number, 10),
422+ i2.Repo.String() + "#" + strconv.FormatInt(i2.Number, 10),
423+ }, got)
424+
425+ open, err := mrs.ListOpenAll(ctx)
426+ require.NoError(t, err)
427+ got = nil
428+ for _, m := range open {
429+ got = append(got, m.Repo.String()+"#"+strconv.FormatInt(m.Number, 10))
430+ }
431+ assert.ElementsMatch(t, []string{"proj#2", m2.Repo.String() + "#" + strconv.FormatInt(m2.Number, 10)}, got)
432+ assert.Len(t, open, 2)
433+}
434+
435+func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) {
436+ ctx := context.Background()
437+ s, mrs, _ := repoWithMRs(t)
438+ issues, err := s.Issues().ListOpenAll(ctx)
439+ require.NoError(t, err)
440+ assert.Empty(t, issues)
441+ open, err := mrs.ListOpenAll(ctx)
442+ require.NoError(t, err)
443+ assert.Empty(t, open)
444+}
445+
446+func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) {
447+ ctx := context.Background()
448+ s, mrs, n := repoWithMRs(t)
449+ is := s.Issues()
450+ keepI, err := is.Create(ctx, newIssue(t, n, "keep"))
451+ require.NoError(t, err)
452+ goneI, err := is.Create(ctx, newIssue(t, n, "gone"))
453+ require.NoError(t, err)
454+ keepM, err := mrs.Create(ctx, newMR(t, n, "keep"))
455+ require.NoError(t, err)
456+ goneM, err := mrs.Create(ctx, newMR(t, n, "gone"))
457+ require.NoError(t, err)
458+
459+ require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number))
460+ require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number))
461+ require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there")
462+ require.Positive(t, countKeys(t, s, "mropen/proj/"))
463+
464+ issues, err := is.ListOpenAll(ctx)
465+ require.NoError(t, err)
466+ require.Len(t, issues, 1)
467+ assert.Equal(t, keepI.Number, issues[0].Number)
468+ open, err := mrs.ListOpenAll(ctx)
469+ require.NoError(t, err)
470+ require.Len(t, open, 1)
471+ assert.Equal(t, keepM.Number, open[0].Number)
472+}
473+
474+func TestIssueListPagesThroughManyIssues(t *testing.T) {
475+ ctx := context.Background()
476+ s, _, n := repoWithMRs(t)
477+ is := s.Issues()
478+ const total = 53
479+ for i := 1; i <= total; i++ {
480+ _, err := is.Create(ctx, newIssue(t, n, "issue "+strconv.Itoa(i)))
481+ require.NoError(t, err)
482+ }
483+ for i := 2; i <= total; i += 3 { // 2, 5, 8, ...
484+ is, err := is.Get(ctx, n, int64(i))
485+ require.NoError(t, err)
486+ is.State = domain.IssueStateClosed
487+ require.NoError(t, s.Issues().Update(ctx, is))
488+ }
489+
490+ walk := func(state domain.IssueState, size int) []int64 {
491+ var got []int64
492+ token := ""
493+ for {
494+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{State: state, PageSize: size, PageToken: token})
495+ require.NoError(t, err)
496+ require.LessOrEqual(t, len(page.Issues), size)
497+ for _, is := range page.Issues {
498+ got = append(got, is.Number)
499+ }
500+ if page.NextPageToken == "" {
501+ return got
502+ }
503+ token = page.NextPageToken
504+ }
505+ }
506+ for _, size := range []int{1, 7, 10, 53, 100} {
507+ var all, closed []int64
508+ for i := int64(total); i >= 1; i-- {
509+ all = append(all, i)
510+ if i%3 == 2 {
511+ closed = append(closed, i)
512+ }
513+ }
514+ assert.Equal(t, all, walk("", size), "size %d", size)
515+ assert.Equal(t, closed, walk(domain.IssueStateClosed, size), "closed, size %d", size)
516+ }
517+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{PageSize: 10, PageToken: "1"})
518+ require.NoError(t, err)
519+ assert.Empty(t, page.Issues)
520+ assert.Empty(t, page.NextPageToken)
521+}
522+
523+// openOf lists the open issues of one repository through the isopen/ index.
524+func openOf(t *testing.T, is *kvstore.Issues, repo domain.RepoName) []domain.Issue {
525+ t.Helper()
526+ all, err := is.ListOpenAll(context.Background())
527+ require.NoError(t, err)
528+ var out []domain.Issue
529+ for _, i := range all {
530+ if i.Repo == repo {
531+ out = append(out, i)
532+ }
533+ }
534+ sort.Slice(out, func(a, b int) bool { return out[a].Number > out[b].Number })
535+ return out
536+}
537+
538+func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
539+ ctx := context.Background()
540+ s, _, n := repoWithMRs(t)
541+ is := s.Issues()
542+ raw, err := proto.Marshal(&storagepb.Issue{Number: 1, Title: "x", State: storagepb.IssueState_ISSUE_STATE_PENDING})
543+ require.NoError(t, err)
544+ require.NoError(t, is.PutRawIssueForTest(ctx, n, 1, raw))
545+ _, err = is.Get(ctx, n, 1)
546+ assert.ErrorContains(t, err, "pending")
547+ _, err = is.List(ctx, n, ports.ListIssuesParams{})
548+ assert.ErrorContains(t, err, "pending")
549+ _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
550+ assert.ErrorContains(t, err, "pending")
551+}
552+
553+func countScope(scopes []domain.Scope, want domain.Scope) int {
554+ n := 0
555+ for _, s := range scopes {
556+ if s == want {
557+ n++
558+ }
559+ }
560+ return n
561+}
M internal/adapters/kvstore/mergerequests.go +123 -26
@@ -6,10 +6,12 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "hash/fnv"
9 "net/url"10 "net/url"
10 "slices"11 "slices"
11 "strconv"12 "strconv"
12 "strings"13 "strings"
14+ "sync"
13 "unicode/utf8"15 "unicode/utf8"
14 16
15 kv "bnema.dev/ermite-kv"17 kv "bnema.dev/ermite-kv"
@@ -44,14 +46,15 @@ const (
44 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the46 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the
45 // database of the Store it comes from.47 // database of the Store it comes from.
46 type MergeRequests struct {48 type MergeRequests struct {
47- db *kv.DB49+ db *kv.DB
50+ locks *numberLocks
48 }51 }
49 52
50 var _ ports.MergeRequestStore = (*MergeRequests)(nil)53 var _ ports.MergeRequestStore = (*MergeRequests)(nil)
51 54
52 // MergeRequests returns the merge request store over the same database. It is55 // MergeRequests returns the merge request store over the same database. It is
53 // valid until the Store is closed.56 // 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} }
55 58
56 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }59 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }
57 func mrKey(repo domain.RepoName, n int64) []byte {60 func mrKey(repo domain.RepoName, n int64) []byte {
@@ -198,47 +201,90 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198 //201 //
199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy202 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {203 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()
201 for range createTries {235 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))236+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203 if !repoOK {237 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound238+ return domain.ErrNotFound
205 }239 }
206 var last int64240 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))241+ item, ok := db.Get(ctx, seqKey(repo))
208 if ok {242 if ok {
209 var seq storagepb.Sequence243 var seq storagepb.Sequence
210 if err := proto.Unmarshal(item.Value, &seq); err != nil {244 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)
212 }246 }
213 last = seq.GetLast()247 last = seq.GetLast()
214 }248 }
215- mr.Number = last + 1249+ number := last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
220 var b kv.Batch250 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)251+ b.RequireVersion(repoKey(repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo252+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223 if ok {253 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)254+ b.RequireVersion(seqKey(repo), item.Version)
225 } else {255 } else {
226- b.RequireAbsent(seqKey(mr.Repo))256+ b.RequireAbsent(seqKey(repo))
227 }257 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))258+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))259+ if err := add(&b, number); err != nil {
230- b.Put(mrKey(mr.Repo, mr.Number), val)260+ return err
231- indexOpen(&b, nil, mr)261+ }
232- _, err = m.db.Apply(ctx, &b)262+ _, err := db.Apply(ctx, &b)
233 if errors.Is(err, kv.ErrConflict) {263 if errors.Is(err, kv.ErrConflict) {
234 continue264 continue
235 }265 }
236- if err != nil {266+ return err
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)
240 }267 }
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
242 }288 }
243 289
244 // Get reads mr/<repo>/<number>.290 // Get reads mr/<repo>/<number>.
@@ -344,6 +390,52 @@ func (m *MergeRequests) ListOpen(ctx context.Context, repo domain.RepoName) ([]d
344 return out, nil390 return out, nil
345 }391 }
346 392
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+
347 // CountOpen counts the open merge requests of the repository from the keys of439 // CountOpen counts the open merge requests of the repository from the keys of
348 // the index: no request is decoded.440 // the index: no request is decoded.
349 func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {441 func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
@@ -689,6 +781,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
689 }781 }
690 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)782 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
691 }783 }
784+ issues, err := encodeIssuesMode(s.Issues)
785+ if err != nil {
786+ return nil, err
787+ }
788+ pb.Issues = issues
692 return pb, nil789 return pb, nil
693 }790 }
694 791
@@ -713,5 +810,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
713 if err != nil {810 if err != nil {
714 return domain.DefaultRepoSettings()811 return domain.DefaultRepoSettings()
715 }812 }
716- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}813+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
717 }814 }
M internal/adapters/kvstore/mergerequests_test.go +1 -1
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441 require.NoError(t, err)441 require.NoError(t, err)
442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443 443
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}
445 require.NoError(t, s.Update(ctx, got))445 require.NoError(t, s.Update(ctx, got))
446 again, err := s.Get(ctx, n)446 again, err := s.Get(ctx, n)
447 require.NoError(t, err)447 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 {
144 for range deleteTries {144 for range deleteTries {
145 snap := s.db.Snapshot(ctx)145 snap := s.db.Snapshot(ctx)
146 repoItem, repoOK := snap.Get(ctx, repoKey(name))146 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)...)
148 keys = append(keys, settingsKeys(ctx, snap, name)...)150 keys = append(keys, settingsKeys(ctx, snap, name)...)
149 for len(keys) > deleteChunk {151 for len(keys) > deleteChunk {
150 var b kv.Batch152 var b kv.Batch
M internal/adapters/kvstore/store.go +75 -4
@@ -8,12 +8,14 @@ import (
8 "context"8 "context"
9 "errors"9 "errors"
10 "fmt"10 "fmt"
11+ "slices"
11 12
12 kv "bnema.dev/ermite-kv"13 kv "bnema.dev/ermite-kv"
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 "google.golang.org/protobuf/proto"15 "google.golang.org/protobuf/proto"
15 16
16 storagepb "bnema.dev/ermite/gen/ermite/storage"17 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
18 )20 )
19 21
@@ -27,8 +29,18 @@ import (
27 // (CIRun.head_sha: it would attach their checks to the merge commit, where29 // (CIRun.head_sha: it would attach their checks to the merge commit, where
28 // nothing looks for them), and the index of the open merge requests by source30 // nothing looks for them), and the index of the open merge requests by source
29 // branch (mropen/: an older build would not keep it up to date; the upgrade31 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
31-const SchemaVersion = 333+// 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 mr:review and issue:write (each only
40+// if missing) to the stored tokens that held all nine original scopes with no
41+// repository list and no branch prefix, so that they keep the owner rights (see
42+// upgradeTokens).
43+const SchemaVersion = 4
32 44
33 const schemaKey = "meta/schema"45 const schemaKey = "meta/schema"
34 46
@@ -37,7 +49,8 @@ var ErrSchemaTooNew = errors.New("storage schema is newer than this ermite suppo
37 49
38 // Store is the ermite-kv backed metadata store.50 // Store is the ermite-kv backed metadata store.
39 type Store struct {51 type Store struct {
40- db *kv.DB52+ db *kv.DB
53+ locks *numberLocks // shared by the merge request and issue stores
41 }54 }
42 55
43 var _ ports.RepoMetaStore = (*Store)(nil)56 var _ ports.RepoMetaStore = (*Store)(nil)
@@ -50,7 +63,7 @@ func Open(ctx context.Context, dir string) (*Store, error) {
50 if err != nil {63 if err != nil {
51 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)64 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)
52 }65 }
53- s := &Store{db: db}66+ s := &Store{db: db, locks: new(numberLocks)}
54 if err := s.checkSchema(ctx); err != nil {67 if err := s.checkSchema(ctx); err != nil {
55 _ = db.Close(context.WithoutCancel(ctx))68 _ = db.Close(context.WithoutCancel(ctx))
56 return nil, err69 return nil, err
@@ -125,8 +138,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125 if sv.GetVersion() < SchemaVersion {138 if sv.GetVersion() < SchemaVersion {
126 // Older data stays readable as it is (the changes are additive);139 // Older data stays readable as it is (the changes are additive);
127 // the new version is recorded so that older builds refuse it.140 // the new version is recorded so that older builds refuse it.
141+ // The tokens are upgraded in the same batch, so that a crash
142+ // leaves the old version and the upgrade runs again.
128 var b kv.Batch143 var b kv.Batch
129 b.RequireVersion(key, item.Version)144 b.RequireVersion(key, item.Version)
145+ if sv.GetVersion() < 4 {
146+ if err := s.upgradeTokens(ctx, &b); err != nil {
147+ return err
148+ }
149+ }
130 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))150 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131 _, err := s.db.Apply(ctx, &b)151 _, err := s.db.Apply(ctx, &b)
132 if errors.Is(err, kv.ErrConflict) {152 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +171,54 @@ func mustMarshal(m proto.Message) []byte {
151 }171 }
152 return b172 return b
153 }173 }
174+
175+// preIssueScopes are the nine original scopes, before mr:review and issue:write.
176+var preIssueScopes = []domain.Scope{
177+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
178+ domain.ScopeMRWrite, domain.ScopeMRMerge,
179+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
180+ domain.ScopeTokenAdmin,
181+}
182+
183+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
184+// holds all of preIssueScopes, limited to no repository and no branch prefix,
185+// had the owner rights, which AllScopes now needs mr:review and issue:write
186+// for, so it gets each of them if missing (this is no escalation). Every other
187+// token is left as it is. The batch requires each token to be at the version it
188+// was read at.
189+//
190+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
191+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
192+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
193+ pb := &storagepb.Token{}
194+ if err := proto.Unmarshal(item.Value, pb); err != nil {
195+ return fmt.Errorf("upgrade token %s: %w", key, err)
196+ }
197+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 {
198+ continue
199+ }
200+ full := true
201+ for _, sc := range preIssueScopes {
202+ if !slices.Contains(pb.GetScopes(), sc.String()) {
203+ full = false
204+ break
205+ }
206+ }
207+ if !full {
208+ continue
209+ }
210+ added := false
211+ for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} {
212+ if !slices.Contains(pb.GetScopes(), sc.String()) {
213+ pb.Scopes = append(pb.Scopes, sc.String())
214+ added = true
215+ }
216+ }
217+ if !added {
218+ continue
219+ }
220+ b.RequireVersion(slices.Clone(key), item.Version)
221+ b.Put(slices.Clone(key), mustMarshal(pb))
222+ }
223+ return nil
224+}
M internal/adapters/kvstore/store_test.go +4 -4
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231 })231 })
232 232
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) {
234 dir := t.TempDir()234 dir := t.TempDir()
235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))236 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) {
261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263 require.NoError(t, s.Close(ctx))263 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))
265 })265 })
266 266
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) {
268 dir := t.TempDir()268 dir := t.TempDir()
269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))270 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) {
273 _, err = s.Get(ctx, name(t, "old"))273 _, err = s.Get(ctx, name(t, "old"))
274 require.NoError(t, err)274 require.NoError(t, err)
275 require.NoError(t, s.Close(ctx))275 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))
277 })277 })
278 278
279 // Downgrade guard: once a build with reusable workflows has opened the data279 // 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
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // 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"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -55,7 +55,11 @@ type Config struct {
55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57 SigningKey ports.SigningKeyService57 SigningKey ports.SigningKeyService
58- Version string58+ // Issues serves IssueService.
59+ Issues ports.IssueService
60+ // IssueModeration serves the moderation calls of IssueService.
61+ IssueModeration ports.IssueModerationService
62+ Version string
59 // TrustedProxies are the networks allowed to set X-Forwarded-For.63 // TrustedProxies are the networks allowed to set X-Forwarded-For.
60 TrustedProxies []netip.Prefix64 TrustedProxies []netip.Prefix
61 // Limiter counts failed authentications; nil builds a default one. Share65 // Limiter counts failed authentications; nil builds a default one. Share
@@ -141,7 +145,8 @@ func New(cfg Config) *API {
141 features = append(features, FeatureCommitSignatures)145 features = append(features, FeatureCommitSignatures)
142 }146 }
143 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))147 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...))
145 if cfg.Sessions != nil {150 if cfg.Sessions != nil {
146 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))151 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
147 }152 }
M internal/adapters/rpc/convert.go +19 -1
@@ -75,7 +75,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
75 }75 }
76 76
77 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {77 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)}
79 for _, m := range s.AllowedMergeModes {79 for _, m := range s.AllowedMergeModes {
80 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))80 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
81 }81 }
@@ -134,6 +134,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
134 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,134 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
135 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,135 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
136 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,136 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
137+ domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
137 }138 }
138 139
139 func scopeFromPB(s pb.Scope) (domain.Scope, error) {140 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -472,6 +473,21 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
472 case domain.CommentAdded:473 case domain.CommentAdded:
473 out.Time = timestamp(ev.At)474 out.Time = timestamp(ev.At)
474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}475 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
476+ case domain.IssueOpened:
477+ out.Time = timestamp(ev.At)
478+ out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
479+ case domain.IssueClosed:
480+ out.Time = timestamp(ev.At)
481+ out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
482+ case domain.IssueReopened:
483+ out.Time = timestamp(ev.At)
484+ out.Kind = &pb.Event_IssueReopened{IssueReopened: &pb.IssueReopened{Issue: issueToPB(ev.Issue)}}
485+ case domain.IssueCommented:
486+ out.Time = timestamp(ev.At)
487+ out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
488+ case domain.IssueSubmitted:
489+ out.Time = timestamp(ev.At)
490+ out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
475 case domain.CommentEdited:491 case domain.CommentEdited:
476 out.Time = timestamp(ev.At)492 out.Time = timestamp(ev.At)
477 out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}493 out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
@@ -488,6 +504,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
488 out.Time = timestamp(ev.At)504 out.Time = timestamp(ev.At)
489 out.Kind = jobEventToPB(ev)505 out.Kind = jobEventToPB(ev)
490 default:506 default:
507+ // Web-only events, such as domain.IssuePendingRemoved (the API has
508+ // no message for it; watchers list pending issues on demand).
491 return nil509 return nil
492 }510 }
493 return out511 return out
M internal/adapters/rpc/convert_test.go +40 -0
@@ -336,3 +336,43 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337 assert.EqualValues(t, 3, added.GetComment().GetLine())337 assert.EqualValues(t, 3, added.GetComment().GetLine())
338 }338 }
339+
340+func TestIssueMappers(t *testing.T) {
341+ t.Parallel()
342+ for _, m := range domain.IssuesModes() {
343+ got, err := issuesModeFromPB(issuesModeToPB(m))
344+ require.NoError(t, err)
345+ assert.Equal(t, m, got)
346+ }
347+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348+ _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349+ assert.ErrorIs(t, err, domain.ErrInvalid)
350+
351+ s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352+ require.NoError(t, err)
353+ assert.Empty(t, s)
354+ _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355+ assert.ErrorIs(t, err, domain.ErrInvalid)
356+
357+ n, err := domain.ParseRepoName("proj")
358+ require.NoError(t, err)
359+ ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360+ require.NotNil(t, ev.GetIssueClosed())
361+ assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363+ ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364+ assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367+}
368+
369+func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) {
370+ t.Parallel()
371+ assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}}))
372+}
373+
374+func TestIssueStateToPBNeverPending(t *testing.T) {
375+ t.Parallel()
376+ assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378+}
A internal/adapters/rpc/issue.go +262 -0
@@ -0,0 +1,262 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+
10+ "connectrpc.com/connect"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// issueService implements ermiteconnect.IssueServiceHandler.
18+type issueService struct {
19+ issues ports.IssueService
20+ moderation ports.IssueModerationService
21+}
22+
23+func issueStateToPB(s domain.IssueState) pb.IssueState {
24+ switch s {
25+ case domain.IssueStateOpen:
26+ return pb.IssueState_ISSUE_STATE_OPEN
27+ case domain.IssueStateClosed:
28+ return pb.IssueState_ISSUE_STATE_CLOSED
29+ }
30+ return pb.IssueState_ISSUE_STATE_UNSPECIFIED
31+}
32+
33+// issueStateFromPB maps the state filter of a listing; unspecified is the
34+// empty state, which means every state. Pending is not listable (InvalidArgument): a domain Issue is never pending.
35+func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
36+ switch s {
37+ case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
38+ return "", nil
39+ case pb.IssueState_ISSUE_STATE_OPEN:
40+ return domain.IssueStateOpen, nil
41+ case pb.IssueState_ISSUE_STATE_CLOSED:
42+ return domain.IssueStateClosed, nil
43+ }
44+ return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
45+}
46+
47+func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
48+ switch m.OrDefault() {
49+ case domain.IssuesOff:
50+ return pb.IssuesMode_ISSUES_MODE_OFF
51+ case domain.IssuesOwner:
52+ return pb.IssuesMode_ISSUES_MODE_OWNER
53+ case domain.IssuesPublic:
54+ return pb.IssuesMode_ISSUES_MODE_PUBLIC
55+ }
56+ return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
57+}
58+
59+func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
60+ switch m {
61+ case pb.IssuesMode_ISSUES_MODE_OFF:
62+ return domain.IssuesOff, nil
63+ case pb.IssuesMode_ISSUES_MODE_OWNER:
64+ return domain.IssuesOwner, nil
65+ case pb.IssuesMode_ISSUES_MODE_PUBLIC:
66+ return domain.IssuesPublic, nil
67+ }
68+ return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
69+}
70+
71+func issueToPB(i domain.Issue) *pb.Issue {
72+ return &pb.Issue{
73+ Repo: i.Repo.String(),
74+ Number: i.Number,
75+ Title: i.Title.String(),
76+ Body: i.Body.String(),
77+ State: issueStateToPB(i.State),
78+ Author: i.Author,
79+ CreatedAt: timestamp(i.CreatedAt),
80+ UpdatedAt: timestamp(i.UpdatedAt),
81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ }
83+}
84+
85+func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
86+ return &pb.IssueComment{
87+ Id: strconv.FormatInt(c.ID, 10),
88+ Author: c.Author,
89+ Text: c.Text.String(),
90+ CreatedAt: timestamp(c.CreatedAt),
91+ }
92+}
93+
94+func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
95+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
96+ if err != nil {
97+ return nil, connectError(ctx, err)
98+ }
99+ issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
100+ if err != nil {
101+ return nil, connectError(ctx, err)
102+ }
103+ return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
104+}
105+
106+func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
107+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
108+ if err != nil {
109+ return nil, connectError(ctx, err)
110+ }
111+ state, err := issueStateFromPB(req.Msg.GetState())
112+ if err != nil {
113+ return nil, connectError(ctx, err)
114+ }
115+ page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
116+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
117+ })
118+ if err != nil {
119+ return nil, connectError(ctx, err)
120+ }
121+ out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
122+ for _, i := range page.Issues {
123+ out.Issues = append(out.Issues, issueToPB(i))
124+ }
125+ return connect.NewResponse(out), nil
126+}
127+
128+func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
129+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
130+ if err != nil {
131+ return nil, connectError(ctx, err)
132+ }
133+ d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
134+ if err != nil {
135+ return nil, connectError(ctx, err)
136+ }
137+ out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
138+ for _, c := range d.Comments {
139+ out.Comments = append(out.Comments, issueCommentToPB(c))
140+ }
141+ return connect.NewResponse(out), nil
142+}
143+
144+func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
145+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
146+ if err != nil {
147+ return nil, connectError(ctx, err)
148+ }
149+ c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
150+ if err != nil {
151+ return nil, connectError(ctx, err)
152+ }
153+ return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
154+}
155+
156+func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
157+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
158+ if err != nil {
159+ return nil, connectError(ctx, err)
160+ }
161+ issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
162+ if err != nil {
163+ return nil, connectError(ctx, err)
164+ }
165+ return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
166+}
167+
168+func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
169+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
170+ if err != nil {
171+ return nil, connectError(ctx, err)
172+ }
173+ issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
174+ if err != nil {
175+ return nil, connectError(ctx, err)
176+ }
177+ return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
178+}
179+
180+func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
181+ return &pb.PendingIssue{
182+ Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
183+ Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
184+ }
185+}
186+
187+// pendingRef parses the repository and the id of a moderation request. A
188+// malformed id is reported as not found, like an unknown one.
189+func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
190+ name, err := domain.ParseRepoName(repo)
191+ if err != nil {
192+ return domain.RepoName{}, "", err
193+ }
194+ pid, err := domain.ParsePendingIssueID(id)
195+ if err != nil {
196+ return domain.RepoName{}, "", domain.ErrNotFound
197+ }
198+ return name, pid, nil
199+}
200+
201+// requireOwner answers NotFound to anyone without owner rights, before the
202+// request is parsed: a malformed repository must not tell a non-owner more
203+// than a well-formed one does.
204+func requireOwner(ctx context.Context) error {
205+ if !caller(ctx).HasOwnerRights() {
206+ return connectError(ctx, domain.ErrNotFound)
207+ }
208+ return nil
209+}
210+
211+func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
212+ if err := requireOwner(ctx); err != nil {
213+ return nil, err
214+ }
215+ var name domain.RepoName
216+ if req.Msg.GetRepo() != "" {
217+ var err error
218+ if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
219+ return nil, connectError(ctx, err)
220+ }
221+ }
222+ page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
223+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
224+ })
225+ if err != nil {
226+ return nil, connectError(ctx, err)
227+ }
228+ out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
229+ for _, p := range page.Issues {
230+ out.Issues = append(out.Issues, pendingIssueToPB(p))
231+ }
232+ return connect.NewResponse(out), nil
233+}
234+
235+func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
236+ if err := requireOwner(ctx); err != nil {
237+ return nil, err
238+ }
239+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
240+ if err != nil {
241+ return nil, connectError(ctx, err)
242+ }
243+ issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
244+ if err != nil {
245+ return nil, connectError(ctx, err)
246+ }
247+ return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
248+}
249+
250+func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
251+ if err := requireOwner(ctx); err != nil {
252+ return nil, err
253+ }
254+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
255+ if err != nil {
256+ return nil, connectError(ctx, err)
257+ }
258+ if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
259+ return nil, connectError(ctx, err)
260+ }
261+ return connect.NewResponse(&pb.RejectIssueResponse{}), nil
262+}
M internal/adapters/rpc/mergerequest.go +3 -1
@@ -15,7 +15,9 @@ import (
15 )15 )
16 16
17 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.17 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18-type mergeRequestService struct{ mrs ports.MergeRequestService }18+type mergeRequestService struct {
19+ mrs ports.MergeRequestService
20+}
19 21
20 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {22 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
21 name, err := domain.ParseRepoName(req.Msg.GetRepo())23 name, err := domain.ParseRepoName(req.Msg.GetRepo())
M internal/adapters/rpc/mergerequest_test.go +11 -11
@@ -41,7 +41,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
41 Title: "t", Description: "d", Source: "feat", Target: "main",41 Title: "t", Description: "d", Source: "feat", Target: "main",
42 }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()42 }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()
43 }43 }
44- resp, err := (&mergeRequestService{mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{44+ resp, err := (&mergeRequestService{mrs: mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
45 Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",45 Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",
46 }))46 }))
47 if tt.wantCode != 0 {47 if tt.wantCode != 0 {
@@ -57,7 +57,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
57 57
58 func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {58 func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {
59 t.Parallel()59 t.Parallel()
60- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),60+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
61 connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))61 connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))
62 requireCode(t, connect.CodeInvalidArgument, err)62 requireCode(t, connect.CodeInvalidArgument, err)
63 }63 }
@@ -68,7 +68,7 @@ func TestMergeRequestServiceListFiltersByState(t *testing.T) {
68 mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{68 mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{
69 State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",69 State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",
70 }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()70 }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()
71- resp, err := (&mergeRequestService{mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{71+ resp, err := (&mergeRequestService{mrs: mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
72 Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",72 Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",
73 }))73 }))
74 require.NoError(t, err)74 require.NoError(t, err)
@@ -99,7 +99,7 @@ func TestMergeRequestServiceAddComment(t *testing.T) {
99 mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).99 mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).
100 Return(domain.Comment{ID: 5}, tt.usecase).Once()100 Return(domain.Comment{ID: 5}, tt.usecase).Once()
101 }101 }
102- resp, err := (&mergeRequestService{mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{102+ resp, err := (&mergeRequestService{mrs: mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
103 Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",103 Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",
104 }))104 }))
105 if tt.wantCode != 0 {105 if tt.wantCode != 0 {
@@ -120,7 +120,7 @@ func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) {
120 mrs := mocks.NewMergeRequestService(t)120 mrs := mocks.NewMergeRequestService(t)
121 mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).121 mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).
122 Return(domain.Comment{}, domain.ErrNotFound).Once()122 Return(domain.Comment{}, domain.ErrNotFound).Once()
123- _, err := (&mergeRequestService{mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{123+ _, err := (&mergeRequestService{mrs: mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
124 Repo: "demo", Number: 1, CommentId: id, Resolved: true,124 Repo: "demo", Number: 1, CommentId: id, Resolved: true,
125 }))125 }))
126 requireCode(t, connect.CodeNotFound, err)126 requireCode(t, connect.CodeNotFound, err)
@@ -154,7 +154,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
154 return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()154 return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
155 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()155 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
156 }156 }
157- resp, err := (&mergeRequestService{mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))157+ resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
158 if tt.wantCode != 0 {158 if tt.wantCode != 0 {
159 requireCode(t, tt.wantCode, err)159 requireCode(t, tt.wantCode, err)
160 return160 return
@@ -171,20 +171,20 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
171 t.Parallel()171 t.Parallel()
172 mrs := mocks.NewMergeRequestService(t)172 mrs := mocks.NewMergeRequestService(t)
173 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()173 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
174- _, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))174+ _, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
175 requireCode(t, connect.CodeNotFound, err)175 requireCode(t, connect.CodeNotFound, err)
176 })176 })
177 t.Run("close ok", func(t *testing.T) {177 t.Run("close ok", func(t *testing.T) {
178 t.Parallel()178 t.Parallel()
179 mrs := mocks.NewMergeRequestService(t)179 mrs := mocks.NewMergeRequestService(t)
180 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()180 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
181- resp, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))181+ resp, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
182 require.NoError(t, err)182 require.NoError(t, err)
183 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())183 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
184 })184 })
185 t.Run("mark read rejects a short head", func(t *testing.T) {185 t.Run("mark read rejects a short head", func(t *testing.T) {
186 t.Parallel()186 t.Parallel()
187- _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),187+ _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
188 connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))188 connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
189 requireCode(t, connect.CodeInvalidArgument, err)189 requireCode(t, connect.CodeInvalidArgument, err)
190 })190 })
@@ -192,7 +192,7 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
192 t.Parallel()192 t.Parallel()
193 mrs := mocks.NewMergeRequestService(t)193 mrs := mocks.NewMergeRequestService(t)
194 mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()194 mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
195- _, err := (&mergeRequestService{mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))195+ _, err := (&mergeRequestService{mrs: mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
196 requireCode(t, connect.CodeUnauthenticated, err)196 requireCode(t, connect.CodeUnauthenticated, err)
197 })197 })
198 }198 }
@@ -214,7 +214,7 @@ func TestMergeRequestServiceGet(t *testing.T) {
214 mrs := mocks.NewMergeRequestService(t)214 mrs := mocks.NewMergeRequestService(t)
215 mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).215 mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()216 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217- resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))217+ resp, err := (&mergeRequestService{mrs: mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218 if tt.wantCode != 0 {218 if tt.wantCode != 0 {
219 requireCode(t, tt.wantCode, err)219 requireCode(t, tt.wantCode, err)
220 return220 return
M internal/adapters/rpc/repo.go +10 -2
@@ -81,15 +81,23 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82 // one of "description", "topics", "visibility", "default_branch", "settings",82 // one of "description", "topics", "visibility", "default_branch", "settings",
83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else.84+// nothing else; the optional issues field stands outside the mask, and alone
85+// it is a valid update.
85 //86 //
86 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here87 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
87 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {88 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
88 var params ports.UpdateRepoParams89 var params ports.UpdateRepoParams
89 paths := msg.GetUpdateMask().GetPaths()90 paths := msg.GetUpdateMask().GetPaths()
90- if len(paths) == 0 {91+ if len(paths) == 0 && msg.Issues == nil {
91 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)92 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
92 }93 }
94+ if msg.Issues != nil {
95+ mode, err := issuesModeFromPB(msg.GetIssues())
96+ if err != nil {
97+ return params, err
98+ }
99+ params.Issues = &mode
100+ }
93 for _, path := range paths {101 for _, path := range paths {
94 switch path {102 switch path {
95 case "description":103 case "description":
M internal/adapters/web/ASSETS.md +2 -0
@@ -13,6 +13,8 @@ content-hashed names. Nothing is fetched at runtime.
13 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |13 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
14 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |14 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
15 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |15 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
16+| `static/js/issue-form.js` | the public issue form: starts the proof-of-work worker on load, length counters, the state of the submit button (`window.ermiteIssueForm`; core tested in Node by `jstest/issue-form.test.mjs`) | written here | |
17+| `static/js/pow-worker.js` | the dedicated Worker of the form: finds the counter `n` such that SHA-256(nonce bytes \|\| ":" \|\| decimal `n`) has the required leading zero bits, with `crypto.subtle`; same encoding as `usecase/issue_submissions.go` (shared vector tested on both sides). Loaded from the same origin by `new Worker(url)`, no `blob:` | written here | |
16 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |18 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
17 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
18 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/assets.go +3 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -65,6 +65,8 @@ var scripts = []struct {
65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
66 {"log.js", func(a *Assets, u string) { a.Log = u }},66 {"log.js", func(a *Assets, u string) { a.Log = u }},
67 {"mr.js", func(a *Assets, u string) { a.MR = u }},67 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68+ {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69+ {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
68 }70 }
69 71
70 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):72 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
M internal/adapters/web/events.go +59 -3
@@ -27,10 +27,13 @@ import (
27 // the HTML fragments the page shows, sent as Server-Sent Events:27 // the HTML fragments the page shows, sent as Server-Sent Events:
28 //28 //
29 // GET /-/events?topic=home29 // GET /-/events?topic=home
30+// GET /-/events?topic=owner&view=inbox[&repo=<name>] (the signed-in owner only)
30 // GET /-/events?topic=repo:<name>&view=summary|refs31 // GET /-/events?topic=repo:<name>&view=summary|refs
31 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>32 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
32 // GET /-/events?topic=repo:<name>&view=mrs33 // GET /-/events?topic=repo:<name>&view=mrs
33 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>34 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
35+// GET /-/events?topic=repo:<name>&view=issues
36+// GET /-/events?topic=repo:<name>&view=issue&n=<number>
34 // GET /-/events?topic=repo:<name>&view=actions37 // GET /-/events?topic=repo:<name>&view=actions
35 // GET /-/events?topic=repo:<name>&view=run&n=<run id>38 // GET /-/events?topic=repo:<name>&view=run&n=<run id>
36 // GET /-/events?topic=repo:<name>&view=job&job=<job id>39 // GET /-/events?topic=repo:<name>&view=job&job=<job id>
@@ -51,7 +54,7 @@ import (
51 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with54 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
52 // <dl id="refs-kv">…</dl>, the facts of the rail55 // <dl id="refs-kv">…</dl>, the facts of the rail
53 // commits-head data: <div id="commits-head">N new commits — reload</div>56 // commits-head data: <div id="commits-head">N new commits — reload</div>
54-// mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:57+// mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
55 // something changed, with the link that reloads the page58 // something changed, with the link that reloads the page
56 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),59 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
57 // <section id="recent-actions">…</section> (the rail of the summary) or60 // <section id="recent-actions">…</section> (the rail of the summary) or
@@ -82,9 +85,14 @@ const (
82 retryAfter = 1085 retryAfter = 10
83 )86 )
84 87
85-// parseTopic reads "home" or "repo:<name>"; repo is "" for home.88+// ownerTopic is the stream of the inbox: the merge requests and issues of every
89+// repository, for the owner.
90+const ownerTopic = "owner"
91+
92+// parseTopic reads "home", "owner" or "repo:<name>"; repo is "" for home and
93+// owner.
86 func parseTopic(topic string) (repo string, ok bool) {94 func parseTopic(topic string) (repo string, ok bool) {
87- if topic == "home" {95+ if topic == "home" || topic == ownerTopic {
88 return "", true96 return "", true
89 }97 }
90 if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {98 if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
@@ -93,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) {
93 return "", false101 return "", false
94 }102 }
95 103
104+// ownerStreamOK tells whether the request may follow the owner topic: the
105+// signed-in owner, with the inbox view and a valid repository filter, on a
106+// server that has the inbox.
107+func (s *Server) ownerStreamOK(ctx context.Context, q url.Values) bool {
108+ if s.cfg.Inbox == nil || !SignedIn(ctx) || q.Get("view") != "inbox" {
109+ return false
110+ }
111+ if f := q.Get("repo"); f != "" {
112+ if _, err := domain.ParseRepoName(f); err != nil {
113+ return false
114+ }
115+ }
116+ return true
117+}
118+
96 // events is GET /-/events.119 // events is GET /-/events.
97 func (s *Server) events(w http.ResponseWriter, r *http.Request) {120 func (s *Server) events(w http.ResponseWriter, r *http.Request) {
98 if s.cfg.Events == nil {121 if s.cfg.Events == nil {
@@ -102,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
102 q := r.URL.Query()125 q := r.URL.Query()
103 topic := q.Get("topic")126 topic := q.Get("topic")
104 repo, ok := parseTopic(topic)127 repo, ok := parseTopic(topic)
128+ if topic == ownerTopic {
129+ // Anyone but the signed-in owner gets the answer of an unknown topic.
130+ ok = s.ownerStreamOK(r.Context(), q)
131+ repo = ""
132+ }
105 if !ok {133 if !ok {
106 denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")134 denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
107 return135 return
@@ -114,6 +142,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
114 // A repository the visitor cannot see is a missing one (AC2).142 // A repository the visitor cannot see is a missing one (AC2).
115 cctx, ccancel := context.WithTimeout(ctx, requestTimeout)143 cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
116 err := s.canSee(cctx, repo)144 err := s.canSee(cctx, repo)
145+ if err == nil {
146+ if v := q.Get("view"); v == "issues" || v == "issue" {
147+ err = s.issuesOn(cctx, repo)
148+ }
149+ }
117 ccancel()150 ccancel()
118 if err != nil {151 if err != nil {
119 s.denyEvents(ctx, w, err)152 s.denyEvents(ctx, w, err)
@@ -220,6 +253,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
220 }253 }
221 254
222 st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}255 st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
256+ if topic == ownerTopic {
257+ st.filter = q.Get("repo")
258+ }
223 handle := func(m recvd) bool {259 handle := func(m recvd) bool {
224 if m.err != nil {260 if m.err != nil {
225 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {261 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
@@ -298,6 +334,26 @@ func (s *Server) canSee(ctx context.Context, rawName string) error {
298 return err334 return err
299 }335 }
300 336
337+// issuesOn is domain.ErrNotFound unless the repository has issues on and
338+// s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.
339+func (s *Server) issuesOn(ctx context.Context, rawName string) error {
340+ if s.cfg.Issues == nil {
341+ return domain.ErrNotFound
342+ }
343+ name, err := domain.ParseRepoName(rawName)
344+ if err != nil {
345+ return err
346+ }
347+ v, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
348+ if err != nil {
349+ return err
350+ }
351+ if v.Settings.Issues.OrDefault() == domain.IssuesOff {
352+ return domain.ErrNotFound
353+ }
354+ return nil
355+}
356+
301 // denyStream answers a request that gets no stream.357 // denyStream answers a request that gets no stream.
302 func denyStream(w http.ResponseWriter, status int, msg string) {358 func denyStream(w http.ResponseWriter, status int, msg string) {
303 w.Header().Set("Cache-Control", "no-store")359 w.Header().Set("Cache-Control", "no-store")
M internal/adapters/web/events_test.go +38 -1
@@ -36,7 +36,7 @@ func TestFormatEvent(t *testing.T) {
36 36
37 func TestParseTopic(t *testing.T) {37 func TestParseTopic(t *testing.T) {
38 t.Parallel()38 t.Parallel()
39- for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} {39+ for in, want := range map[string]string{"home": "", "owner": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} {
40 repo, ok := parseTopic(in)40 repo, ok := parseTopic(in)
41 assert.True(t, ok, in)41 assert.True(t, ok, in)
42 assert.Equal(t, want, repo, in)42 assert.Equal(t, want, repo, in)
@@ -428,3 +428,40 @@ func TestEventsInvalidCookieStream(t *testing.T) {
428 assert.Contains(t, resp.Header.Values("Vary"), "Cookie")428 assert.Contains(t, resp.Header.Values("Vary"), "Cookie")
429 assert.Contains(t, resp.Header.Get("Set-Cookie"), "Max-Age=0")429 assert.Contains(t, resp.Header.Get("Set-Cookie"), "Max-Age=0")
430 }430 }
431+
432+// An issues stream on a repository with issues off, unknown or private is
433+// refused the same way: 404 with the same body.
434+func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
435+ t.Parallel()
436+ priv := mocks.NewRepoService(t)
437+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
438+ for _, view := range []string{"issues", "issue&n=1"} {
439+ answers := map[string]string{}
440+ for name, repos := range map[string]ports.RepoService{
441+ "off": reposServing(t, issueRepoView(t, domain.IssuesOff)),
442+ "unset": reposServing(t, issueRepoView(t, "")),
443+ "unknown": reposServing(t),
444+ "private": priv,
445+ } {
446+ f := &watchProbe{release: make(chan struct{})}
447+ srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
448+ ts := httptest.NewServer(srv)
449+ t.Cleanup(ts.Close)
450+ resp, _ := open(t, ts, "topic=repo:proj&view="+view, nil)
451+ body, _ := io.ReadAll(resp.Body)
452+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, "%s %s", view, name)
453+ answers[name] = resp.Header.Get("Cache-Control") + "|" + string(body)
454+ assert.Zero(t, f.opened.Load(), "%s %s: no watch is opened", view, name)
455+ }
456+ for name, a := range answers {
457+ assert.Equal(t, answers["unknown"], a, "%s %s", view, name)
458+ }
459+ }
460+ // With issues on, the stream opens.
461+ f := &watchProbe{release: make(chan struct{})}
462+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: mocks.NewBrowseService(t), Events: f.service(t), Issues: mocks.NewIssueService(t), Heartbeat: time.Hour})
463+ ts := httptest.NewServer(srv)
464+ t.Cleanup(ts.Close)
465+ resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466+ assert.Equal(t, http.StatusOK, resp.StatusCode)
467+}
M internal/adapters/web/fragments.go +36 -6
@@ -21,11 +21,13 @@ import (
21 type liveStream struct {21 type liveStream struct {
22 s *Server22 s *Server
23 repo string // "" on the home page23 repo string // "" on the home page
24- view string // "summary", "refs" or "commits"24+ // filter is the repository the inbox is filtered on, "" for all.
25- ref string // commits: the branch listed25+ filter string
26- head string // commits: the commit listed first26+ view string // "summary", "refs" or "commits"
27- n string // mr: the number of the merge request shown; run: the run id27+ ref string // commits: the branch listed
28- job string // job: the id of the job shown28+ head string // commits: the commit listed first
29+ n string // mr: the number of the merge request shown; run: the run id
30+ job string // job: the id of the job shown
29 // sha is the commit of the run of the job shown, read once when the stream31 // sha is the commit of the run of the job shown, read once when the stream
30 // is set up: the annotations link to files at it, and it never changes.32 // is set up: the annotations link to files at it, and it never changes.
31 sha string33 sha string
@@ -59,7 +61,8 @@ func liveEvent(we ports.WatchEvent) bool {
59 switch we.Event.(type) {61 switch we.Event.(type) {
60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,62 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
61 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,63 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
62- domain.RunUpdated, domain.JobUpdated:64+ domain.RunUpdated, domain.JobUpdated,
65+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
63 return true66 return true
64 }67 }
65 return false68 return false
@@ -74,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen
74 if we.Event == nil {77 if we.Event == nil {
75 return nil, nil78 return nil, nil
76 }79 }
80+ if l.view == "inbox" {
81+ return l.inbox(ctx, we)
82+ }
77 if l.repo == "" {83 if l.repo == "" {
78 return l.home(ctx, we)84 return l.home(ctx, we)
79 }85 }
@@ -172,6 +178,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
172 return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")178 return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
173 case domain.CommentAdded:179 case domain.CommentAdded:
174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")180 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
181+ case domain.IssueOpened:
182+ return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
183+ case domain.IssueClosed:
184+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
185+ case domain.IssueReopened:
186+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
187+ case domain.IssueCommented:
188+ return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
175 case domain.PushEvent:189 case domain.PushEvent:
176 switch l.view {190 switch l.view {
177 case "mr":191 case "mr":
@@ -406,6 +420,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
406 return nil, nil420 return nil, nil
407 }421 }
408 422
423+// issueNotice tells an issue page that something happened to the issue it
424+// shows, or the list that its content changed. Like mrNotice it sends a fixed
425+// sentence only, in the same slot.
426+func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
427+ switch l.view {
428+ case "issues":
429+ return l.notice(ctx, "issues changed — reload", issuesBase(l.repo))
430+ case "issue":
431+ if strconv.FormatInt(number, 10) != l.n {
432+ return nil, nil
433+ }
434+ return l.notice(ctx, text, issueHref(l.repo, number))
435+ }
436+ return nil, nil
437+}
438+
409 // mrPushed announces a push to the source branch of the merge request shown.439 // mrPushed announces a push to the source branch of the merge request shown.
410 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {440 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
411 if l.ref == "" {441 if l.ref == "" {
M internal/adapters/web/fragments.templ +1 -1
@@ -102,7 +102,7 @@ templ Live(l Layout) {
102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103 htmx:sse:error -> ermiteLive.error(event.detail.status);103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104 htmx:sse:before:connection -> ermiteLive.gate(event);104 htmx:sse:before:connection -> ermiteLive.gate(event);
105- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);105+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106 gap -> ermiteLive.reload()"106 gap -> ermiteLive.reload()"
107 ></span>107 ></span>
108 }108 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/home.go +3 -0
@@ -121,6 +121,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
121 p.Sorts = append(p.Sorts, sl)121 p.Sorts = append(p.Sorts, sl)
122 }122 }
123 p.Rows = s.homeRows(ctx, repos, now)123 p.Rows = s.homeRows(ctx, repos, now)
124+ if n, show := s.inboxCount(ctx); show {
125+ p.Inbox = &InboxButton{Href: inboxPath, Count: n}
126+ }
124 127
125 l := s.base()128 l := s.base()
126 l.Title = s.instance129 l.Title = s.instance
A internal/adapters/web/inbox.go +294 -0
@@ -0,0 +1,294 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/url"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "github.com/a-h/templ"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20+// issues of every repository, newest activity first, with a filter by
21+// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22+// signed-in owner sees it: for anyone else the path is as missing as any other.
23+// It follows the stream "owner" with view=inbox: each event of a merge request
24+// or an issue sends the counters, the two lists and the filter again.
25+
26+// inboxPath is the page of the inbox.
27+const inboxPath = "/-/inbox"
28+
29+// inboxEvent is the name of the live events of the inbox.
30+const inboxEvent = "inbox"
31+
32+// InboxRow is one open item of the inbox.
33+type InboxRow struct {
34+ Repo string
35+ RepoHref string
36+ Number string
37+ Href string
38+ Title string
39+ When string
40+ WhenAt string
41+ Cursor bool
42+}
43+
44+// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45+// it comes from an anonymous visitor and is never rendered as Markdown.
46+type PendingRow struct {
47+ Repo string
48+ RepoHref string
49+ Author string
50+ Title string
51+ // Preview is the start of the body, at most pendingPreviewRunes characters.
52+ Preview string
53+ When string
54+ WhenAt string
55+ // Approve, Reject and Ban are the URLs the buttons post to; empty without
56+ // the moderation use case.
57+ Approve, Reject, Ban string
58+}
59+
60+// pendingPreviewRunes bounds the preview of a pending body.
61+const pendingPreviewRunes = 300
62+
63+// InboxRepoLink is an entry of the repository filter.
64+type InboxRepoLink struct {
65+ Label string
66+ Href string
67+ Current bool
68+}
69+
70+// InboxPage is the inbox: the counters, the filter and the two lists.
71+type InboxPage struct {
72+ // MergeRequests and Issues count every open item, whatever the filter.
73+ MergeRequests, Issues int
74+ // PendingCount counts every issue awaiting moderation.
75+ PendingCount int
76+ Pending []PendingRow
77+ Filter string // the repository filtered on, "" for all
78+ Repos []InboxRepoLink
79+ MRs []InboxRow
80+ IssueRows []InboxRow
81+}
82+
83+// inboxHref is the inbox page, filtered on repo when it is not "".
84+func inboxHref(repo string) string {
85+ if repo == "" {
86+ return inboxPath
87+ }
88+ return inboxPath + "?repo=" + url.QueryEscape(repo)
89+}
90+
91+// inboxFilter reads ?repo=. A name that is not valid is a page that is not
92+// there.
93+func inboxFilter(r *http.Request) (domain.RepoName, error) {
94+ raw := r.URL.Query().Get("repo")
95+ if raw == "" {
96+ return domain.RepoName{}, nil
97+ }
98+ name, err := domain.ParseRepoName(raw)
99+ if err != nil {
100+ return domain.RepoName{}, errNotFound
101+ }
102+ return name, nil
103+}
104+
105+// inboxPage builds the page from the overview.
106+func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107+ now := s.cfg.Now()
108+ p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109+ for _, pi := range ov.Pending {
110+ p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111+ }
112+ row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
113+ ts := updated
114+ if ts.IsZero() {
115+ ts = created
116+ }
117+ r := InboxRow{
118+ Repo: repo.String(), RepoHref: repoHref(repo.String()),
119+ Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
120+ }
121+ r.When, r.WhenAt = whenOf(now, ts)
122+ return r
123+ }
124+ for _, m := range ov.MergeRequests {
125+ p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
126+ }
127+ for _, i := range ov.Issues {
128+ p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
129+ }
130+ switch {
131+ case len(p.MRs) > 0:
132+ p.MRs[0].Cursor = true
133+ case len(p.IssueRows) > 0:
134+ p.IssueRows[0].Cursor = true
135+ }
136+ p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
137+ for _, r := range ov.Repos {
138+ p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
139+ }
140+ return p
141+}
142+
143+// pendingRow is the row of a pending issue: the texts as plain strings, the
144+// invisible characters marked, the body cut.
145+func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146+ repo := pi.Repo.String()
147+ row := PendingRow{
148+ Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149+ Title: visibleText(pi.Title.String()),
150+ }
151+ if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152+ row.Author = a
153+ }
154+ body := strings.TrimSpace(visibleText(pi.Body.String()))
155+ row.Preview = cutRunes(body, pendingPreviewRunes)
156+ if len(row.Preview) < len(body) {
157+ row.Preview += "…"
158+ }
159+ row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160+ if s.cfg.Moderation != nil {
161+ base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162+ var q string
163+ if !filter.IsZero() {
164+ q = "?repo=" + url.QueryEscape(filter.String())
165+ }
166+ row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167+ }
168+ return row
169+}
170+
171+// pendingBack is where a moderation action returns: the inbox, on the same
172+// repository filter.
173+func pendingBack(r *http.Request) string {
174+ if f, err := inboxFilter(r); err == nil {
175+ return inboxHref(f.String())
176+ }
177+ return inboxPath
178+}
179+
180+// pendingID reads the repository and the id of the path.
181+func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182+ name, err := domain.ParseRepoName(r.PathValue("repo"))
183+ if err != nil {
184+ return domain.RepoName{}, "", err
185+ }
186+ id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187+ if err != nil {
188+ return domain.RepoName{}, "", domain.ErrNotFound
189+ }
190+ return name, id, nil
191+}
192+
193+// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194+func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195+ back := pendingBack(r)
196+ name, id, err := pendingID(r)
197+ if err != nil {
198+ return "", back, err
199+ }
200+ if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201+ return "", back, err
202+ }
203+ return back, back, nil
204+}
205+
206+// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207+// also bans the submitter.
208+func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209+ return func(ctx context.Context, r *http.Request) (string, string, error) {
210+ back := pendingBack(r)
211+ name, id, err := pendingID(r)
212+ if err != nil {
213+ return "", back, err
214+ }
215+ if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216+ return "", back, err
217+ }
218+ return back, back, nil
219+ }
220+}
221+
222+// inbox is GET /-/inbox.
223+func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
224+ if !SignedIn(ctx) {
225+ return nil, 0, errNotFound
226+ }
227+ filter, err := inboxFilter(r)
228+ if err != nil {
229+ return nil, 0, err
230+ }
231+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
232+ if err != nil {
233+ return nil, 0, err
234+ }
235+ l := s.base()
236+ l.Title = "inbox · " + s.instance
237+ l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
238+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
239+ if s.cfg.Events != nil {
240+ q := "view=inbox"
241+ if !filter.IsZero() {
242+ q += "&repo=" + url.QueryEscape(filter.String())
243+ }
244+ l.Live, l.LiveURL = true, liveURL("owner", q)
245+ }
246+ return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
247+}
248+
249+// inboxCount is the number of open items the home button shows, and whether
250+// there is a button: only for the signed-in owner, and only when the inbox is
251+// there. A failed read leaves the button without a number.
252+func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
253+ if s.cfg.Inbox == nil || !SignedIn(ctx) {
254+ return 0, false
255+ }
256+ ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
257+ if err != nil {
258+ return -1, true
259+ }
260+ return ov.Counts.MergeRequests + ov.Counts.Issues, true
261+}
262+
263+// inbox renders the fragments an event of a merge request, an issue or a repository changes
264+// on the inbox page: the counters, the filter and the two lists.
265+func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266+ switch we.Event.(type) {
267+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269+ domain.RepoUpdated, domain.RepoDeleted:
270+ default:
271+ return nil, nil
272+ }
273+ if l.s.cfg.Inbox == nil {
274+ return nil, nil
275+ }
276+ var filter domain.RepoName
277+ if l.filter != "" {
278+ filter, _ = domain.ParseRepoName(l.filter)
279+ }
280+ ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
281+ if err != nil {
282+ return nil, err
283+ }
284+ p := l.s.inboxPage(ov, filter)
285+ var out []fragment
286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
287+ f, err := renderFragment(ctx, inboxEvent, c)
288+ if err != nil {
289+ return nil, err
290+ }
291+ out = append(out, f)
292+ }
293+ return out, nil
294+}
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", Plain: true}
279+ switch i.State {
280+ case domain.IssueStateOpen:
281+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
282+ case domain.IssueStateClosed:
283+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
284+ }
285+ }
286+
287+ l := s.repoLayout(ctx, d, "issues")
288+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
289+ l.Crumbs[0].Current = false
290+ l.Crumbs = append(l.Crumbs,
291+ Crumb{Label: "issues", Href: issuesBase(d.name())},
292+ Crumb{Label: p.Number, Href: self, Current: true},
293+ )
294+ p.Live = true
295+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
296+ l.HasRail = true
297+ return IssueShow(l, p), http.StatusOK, nil
298+}
299+
300+// pageOrRedirect is page(), except that a page that is not found may be the
301+// page of the other kind of the number: target then names where to send the
302+// visitor ("" when there is none).
303+func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
304+ plain := s.page(h)
305+ if s.cfg.IssueNumbers == nil {
306+ return plain
307+ }
308+ return func(w http.ResponseWriter, r *http.Request) {
309+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
310+ defer cancel()
311+ c, status, err := h(ctx, r)
312+ if errors.Is(err, domain.ErrNotFound) {
313+ to, terr := target(ctx, r)
314+ switch {
315+ case terr != nil && !errors.Is(terr, domain.ErrNotFound):
316+ s.fail(ctx, w, r, terr)
317+ return
318+ case terr == nil && to != "":
319+ seeOther(w, r, to)
320+ return
321+ }
322+ }
323+ if err != nil {
324+ s.fail(ctx, w, r, err)
325+ return
326+ }
327+ s.write(ctx, w, r, status, c)
328+ }
329+}
330+
331+// numberKind asks what the number of the path names.
332+func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
333+ n, err := mrNumber(r)
334+ if err != nil {
335+ return domain.NumberNone, 0, err
336+ }
337+ name, err := domain.ParseRepoName(r.PathValue("repo"))
338+ if err != nil {
339+ return domain.NumberNone, 0, err
340+ }
341+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
342+ return kind, n, err
343+}
344+
345+// issueToMR is where the number of an issue URL goes when it names a merge
346+// request.
347+func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
348+ kind, n, err := s.numberKind(ctx, r)
349+ if err != nil || kind != domain.NumberMergeRequest {
350+ return "", err
351+ }
352+ return mrHref(r.PathValue("repo"), n), nil
353+}
354+
355+// mrToIssue is where the number of a merge request URL goes when it names an
356+// issue.
357+func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
358+ kind, n, err := s.numberKind(ctx, r)
359+ if err != nil || kind != domain.NumberIssue {
360+ return "", err
361+ }
362+ return issueHref(r.PathValue("repo"), n), nil
363+}
A internal/adapters/web/issues.templ +187 -0
@@ -0,0 +1,187 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueList is the list of the issues of a repository, open or closed.
6+templ IssueList(l Layout, p IssueListPage) {
7+ @Shell(l, issueListRail(p)) {
8+ if p.Live {
9+ @LiveSlot("mr-notice")
10+ }
11+ <nav class="since" aria-label="State">
12+ <a
13+ href={ templ.SafeURL(p.OpenHref) }
14+ if !p.Closed {
15+ aria-current="page"
16+ }
17+ >open</a>
18+ <a
19+ href={ templ.SafeURL(p.ClosedHref) }
20+ if p.Closed {
21+ aria-current="page"
22+ }
23+ >closed</a>
24+ </nav>
25+ <div class="filter">
26+ <label for="issue-filter" class="sr">filter</label>
27+ <input
28+ id="issue-filter"
29+ type="search"
30+ data-filter=".mrs tbody tr"
31+ placeholder="Filter by title, author or state…"
32+ autocomplete="off"
33+ hx-on:input="ermite.filter(this)"
34+ />
35+ </div>
36+ <h2 class="rule-h">
37+ if p.Closed {
38+ <span>Closed</span>
39+ } else {
40+ <span>Open</span>
41+ }
42+ <span class="count">{ itoa(len(p.Rows)) }</span>
43+ </h2>
44+ @issueRows(p.Rows)
45+ if len(p.Rows) == 0 {
46+ if p.Closed {
47+ <p class="lede dim">no closed issues.</p>
48+ } else {
49+ <p class="lede dim">no open issues.</p>
50+ }
51+ }
52+ if p.Older != "" || p.Newest != "" {
53+ <nav class="pager" aria-label="Pages">
54+ if p.Newest != "" {
55+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
56+ } else {
57+ <span class="btn" aria-disabled="true">← Newest</span>
58+ }
59+ if p.Older != "" {
60+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
61+ } else {
62+ <span class="btn" aria-disabled="true">Older →</span>
63+ }
64+ </nav>
65+ }
66+ }
67+}
68+
69+templ issueRows(rows []IssueRow) {
70+ if len(rows) > 0 {
71+ <table class="list mrs" aria-label="Issues">
72+ <tbody data-rows>
73+ for _, i := range rows {
74+ <tr data-cursor?={ i.Cursor } data-state={ i.State }>
75+ <td class="c-n mute">{ i.Number }</td>
76+ <td class="c-state">{ i.State }</td>
77+ <td class="c-subject"><a href={ templ.SafeURL(i.Href) }>{ i.Title }</a></td>
78+ <td class="c-who dim">{ i.Author }</td>
79+ <td class="c-when nowrap dim">
80+ if i.WhenAt != "" {
81+ <time datetime={ i.WhenAt } title={ i.WhenAt }>{ i.When }</time>
82+ }
83+ </td>
84+ </tr>
85+ }
86+ </tbody>
87+ </table>
88+ }
89+}
90+
91+templ issueListRail(p IssueListPage) {
92+ <section aria-labelledby="r-states">
93+ <h2 id="r-states">States</h2>
94+ <dl class="kv">
95+ <dt>open</dt>
96+ <dd>being worked on</dd>
97+ <dt>closed</dt>
98+ <dd>done with, can be reopened</dd>
99+ </dl>
100+ </section>
101+ if p.NewHref != "" {
102+ <section aria-labelledby="r-new">
103+ <h2 id="r-new">Open one</h2>
104+ <a class="btn primary" href={ templ.SafeURL(p.NewHref) }>New issue</a>
105+ </section>
106+ }
107+}
108+
109+// IssueNew is the form that opens an issue.
110+templ IssueNew(l Layout, p IssueNewPage) {
111+ @Shell(l, issueNewRail()) {
112+ <header class="show mr-h">
113+ <h1>New issue</h1>
114+ </header>
115+ <form class="cform create" method="post" action={ templ.SafeURL(p.Action) }>
116+ <label for="issue-title">title</label>
117+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" autofocus/>
118+ <label for="issue-body">description</label>
119+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional"></textarea>
120+ <div class="f-btns">
121+ <button type="submit" class="btn primary">Create issue</button>
122+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
123+ </div>
124+ </form>
125+ }
126+}
127+
128+templ issueNewRail() {
129+ <section aria-labelledby="r-about">
130+ <h2 id="r-about">Issues</h2>
131+ <p class="dim">issues share their numbers with the merge requests of the repository.</p>
132+ </section>
133+}
134+
135+// IssueShow is an issue: its facts, its body, its comments and, for the
136+// owner, the form to comment and the button that closes or reopens it.
137+templ IssueShow(l Layout, p IssuePage) {
138+ @Shell(l, issueRail(p)) {
139+ <header class="show mr-h">
140+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
141+ <dl class="kv show-kv">
142+ <dt>state</dt>
143+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
144+ <dt>opened</dt>
145+ <dd>
146+ { p.Opened.Name }
147+ if p.Opened.WhenAt != "" {
148+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
149+ }
150+ </dd>
151+ if p.UpdatedAt != "" {
152+ <dt>updated</dt>
153+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
154+ }
155+ </dl>
156+ </header>
157+ if p.Live {
158+ @LiveSlot("mr-notice")
159+ }
160+ if p.Desc != "" {
161+ <article class="mr-desc prose" aria-label="description" hx-ignore>
162+ @templ.Raw(string(p.Desc))
163+ </article>
164+ }
165+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166+ for _, c := range p.Comments {
167+ @commentView(c, "")
168+ }
169+ if len(p.Comments) == 0 {
170+ <p class="dim">no comments yet.</p>
171+ }
172+ if p.Form != nil {
173+ @commentForm(*p.Form, "")
174+ }
175+ }
176+}
177+
178+templ issueRail(p IssuePage) {
179+ if p.Transition != nil {
180+ <section aria-labelledby="r-act">
181+ <h2 id="r-act">Issue</h2>
182+ <form method="post" action={ templ.SafeURL(p.Transition.Action) }>
183+ <button type="submit" class="btn">{ p.Transition.Label }</button>
184+ </form>
185+ </section>
186+ }
187+}
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() {
27 k-c from:body -> ermite.tab('c');27 k-c from:body -> ermite.tab('c');
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
30 k-next from:body -> ermite.nextNote();31 k-next from:body -> ermite.nextNote();
31 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
32 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/markdown.go +73 -7
@@ -64,19 +64,31 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
64 // #fragment links chosen, so that several documents on one page (the summaries64 // #fragment links chosen, so that several documents on one page (the summaries
65 // of a run) do not share ids.65 // of a run) do not share ids.
66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderDocument(source, repo, ref, dir, idPrefix, nil)67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
68 }68 }
69 69
70 // renderNote uses the repository Markdown policy, then adds local note links.70 // renderNote uses the repository Markdown policy, then adds local note links.
71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73 return h, err73 return h, err
74 }74 }
75 75
76-func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {76+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))77+// approved from the public form). On top of the policy of render, which already
78+// drops raw HTML and never loads an image:
79+// - only http, https and mailto links are kept, every one with
80+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81+// every other scheme lose their link and keep the text;
82+// - nothing points into the repository.
83+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85+ return h, err
86+}
87+
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
78 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))90 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}91+ pol.source = source
80 // Collect eligible text before the policy unwraps rejected links: their92 // Collect eligible text before the policy unwraps rejected links: their
81 // labels must not accidentally become references either.93 // labels must not accidentally become references either.
82 var refs []*ast.Text94 var refs []*ast.Text
@@ -163,6 +175,20 @@ type linkPolicy struct {
163 source []byte175 source []byte
164 // idPrefix is the prefix of the heading ids that #fragment links point at.176 // idPrefix is the prefix of the heading ids that #fragment links point at.
165 idPrefix string177 idPrefix string
178+ // untrusted is the policy of text from an anonymous visitor.
179+ untrusted bool
180+}
181+
182+const (
183+ relExternal = "nofollow noopener noreferrer"
184+ relUntrusted = "nofollow noopener noreferrer ugc"
185+)
186+
187+func (p linkPolicy) rel() []byte {
188+ if p.untrusted {
189+ return []byte(relUntrusted)
190+ }
191+ return []byte(relExternal)
166 }192 }
167 193
168 func (p linkPolicy) apply(doc ast.Node) {194 func (p linkPolicy) apply(doc ast.Node) {
@@ -195,13 +221,17 @@ func (p linkPolicy) apply(doc ast.Node) {
195 l.Destination = []byte(dest)221 l.Destination = []byte(dest)
196 l.Title = nil222 l.Title = nil
197 if kind == external {223 if kind == external {
198- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))224+ l.SetAttributeString("rel", p.rel())
199 }225 }
200 }226 }
201 for _, n := range autos {227 for _, n := range autos {
202 a := n.(*ast.AutoLink)228 a := n.(*ast.AutoLink)
203 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))230+ a.SetAttributeString("rel", p.rel())
231+ continue
232+ }
233+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234+ a.SetAttributeString("rel", p.rel())
205 continue235 continue
206 }236 }
207 // An email address or another scheme: the label as plain text.237 // An email address or another scheme: the label as plain text.
@@ -241,6 +271,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
241 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242 return "", dropped272 return "", dropped
243 }273 }
274+ if p.untrusted {
275+ return untrustedTarget(dest)
276+ }
244 if strings.HasPrefix(dest, "#") {277 if strings.HasPrefix(dest, "#") {
245 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246 if frag == "" || strings.ContainsAny(frag, " \"'<>") {279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -288,6 +321,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
288 return href, internal321 return href, internal
289 }322 }
290 323
324+// untrustedTarget keeps absolute http and https links with a host, and mailto
325+// links to one plain address.
326+func untrustedTarget(dest string) (string, linkKind) {
327+ if strings.HasPrefix(dest, "//") {
328+ return "", dropped
329+ }
330+ u, err := url.Parse(dest)
331+ if err != nil {
332+ return "", dropped
333+ }
334+ switch strings.ToLower(u.Scheme) {
335+ case "http", "https":
336+ if u.Host == "" || u.User != nil {
337+ return "", dropped
338+ }
339+ return u.String(), external
340+ case "mailto":
341+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342+ return "", dropped
343+ }
344+ return "mailto:" + u.Opaque, external
345+ }
346+ return "", dropped
347+}
348+
349+// validMailbox tells whether s is a bare address: no space, quote, angle
350+// bracket, percent sign, comma or second at sign.
351+func validMailbox(s string) bool {
352+ local, host, ok := strings.Cut(s, "@")
353+ return ok && local != "" && host != "" && len(s) <= 254 &&
354+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355+}
356+
291 func isWebURL(s string) bool {357 func isWebURL(s string) bool {
292 u, err := url.Parse(s)358 u, err := url.Parse(s)
293 if err != nil || u.Host == "" {359 if err != nil || u.Host == "" {
M internal/adapters/web/markdown_test.go +67 -0
@@ -137,6 +137,73 @@ func TestMarkdownGFM(t *testing.T) {
137 assert.Contains(t, out, `type="checkbox"`)137 assert.Contains(t, out, `type="checkbox"`)
138 }138 }
139 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+}
206+
140 func TestNoteMarkdown(t *testing.T) {207 func TestNoteMarkdown(t *testing.T) {
141 t.Parallel()208 t.Parallel()
142 tests := []struct {209 tests := []struct {
M internal/adapters/web/mr.go +13 -8
@@ -101,12 +101,15 @@ type MRListPage struct {
101 101
102 // CommentView is a comment of a merge request, ready to show.102 // CommentView is a comment of a merge request, ready to show.
103 type CommentView struct {103 type CommentView struct {
104- ID string104+ ID string
105- Author string105+ Author string
106- When string106+ When string
107- WhenAt string107+ WhenAt string
108- Abs string108+ Abs string
109- Body, BodySplit template.HTML109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
110 Replies []CommentView113 Replies []CommentView
111 Edited, Deleted bool114 Edited, Deleted bool
112 Verdict, Range, Quote string115 Verdict, Range, Quote string
@@ -156,8 +159,10 @@ type CommentForm struct {
156 // Path, Line and Side designate the line; empty for a general comment.159 // Path, Line and Side designate the line; empty for a general comment.
157 Path, Line, Side string160 Path, Line, Side string
158 StartLine, ReplyTo string161 StartLine, ReplyTo string
159- Cancel string162+ // Plain is a form of an issue comment: plain text, no verdict.
160- Focus bool163+ Plain bool
164+ Cancel string
165+ Focus bool
161 // From is the window of the page the form is on (see mrWindow), sent166 // From is the window of the page the form is on (see mrWindow), sent
162 // back so that the answer returns to it; 0 for the first.167 // back so that the answer returns to it; 0 for the first.
163 From int168 From int
M internal/adapters/web/mr.templ +10 -3
@@ -247,7 +247,9 @@ templ commentView(c CommentView, suffix string) {
247 { " " }<span class="c-where mute">{ c.Where }</span>247 { " " }<span class="c-where mute">{ c.Where }</span>
248 }248 }
249 </p>249 </p>
250- if c.Deleted {250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
251 if c.Verdict != "" {253 if c.Verdict != "" {
252 <p class="mute">text deleted</p>254 <p class="mute">text deleted</p>
253 } else {255 } else {
@@ -316,7 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
316 name="text"318 name="text"
317 rows="3"319 rows="3"
318 maxlength="8000"320 maxlength="8000"
319- placeholder="Markdown, 8000 characters at most"321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
320 if f.Focus {327 if f.Focus {
321 autofocus328 autofocus
322 }329 }
@@ -330,7 +337,7 @@ templ commentForm(f CommentForm, suffix string) {
330 if f.ReplyTo != "" {337 if f.ReplyTo != "" {
331 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>338 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332 }339 }
333- if f.Path == "" && f.ReplyTo == "" {340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
334 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>341 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
335 }342 }
336 if f.From > 0 {343 if f.From > 0 {
M internal/adapters/web/mr_post.go +8 -1
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33 if err := r.ParseForm(); err != nil {33 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)
35 return false42 return false
36 }43 }
37 return true44 return true
M internal/adapters/web/mr_templ.go +289 -265 generated file: collapsed
M internal/adapters/web/overlays.templ +1 -1
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
27 <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>27 <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>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <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>
30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>32 <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) {
9 <div class="home" data-layout="cards">9 <div class="home" data-layout="cards">
10 <section class="home-repos" aria-label="Repositories">10 <section class="home-repos" aria-label="Repositories">
11 <div class="home-bar">11 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
12 <div class="filter">20 <div class="filter">
13 <label for="home-filter" class="sr">filter</label>21 <label for="home-filter" class="sr">filter</label>
14 <input22 <input
M internal/adapters/web/pages_templ.go +409 -363 generated file: collapsed
M internal/adapters/web/repo.go +5 -0
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131 // layout is the Layout of the pages of a repository: the sections, the ref131 // layout is the Layout of the pages of a repository: the sections, the ref
132 // switcher on the head ref and the ":" targets. The caller sets the title,132 // switcher on the head ref and the ":" targets. The caller sets the title,
133 // the crumbs, the status and the rail.133 // 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
134 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135 l := s.base()137 l := s.base()
136 l.Repo = d.name()138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155 mrs.Count = s.openMRCount(ctx, d.repo.Name)157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156 l.Sections = append(l.Sections, mrs)158 l.Sections = append(l.Sections, mrs)
157 }159 }
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+ }
158 l.Sections = s.withActions(l.Sections, d.name(), section)163 l.Sections = s.withActions(l.Sections, d.name(), section)
159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160 165
M internal/adapters/web/session.go +16 -1
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196 if r.Method != http.MethodPost {196 if r.Method != http.MethodPost {
197 return false197 return false
198 }198 }
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
200 }215 }
201 216
202 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of217 // 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) {
33 if l.MR {33 if l.MR {
34 <script src={ l.Assets.MR } defer></script>34 <script src={ l.Assets.MR } defer></script>
35 }35 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
36 </head>39 </head>
37 <body40 <body
38 data-ref={ l.RefPath }41 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
@@ -171,6 +171,22 @@
171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
172 }172 }
173 173
174+ /* the public issue form: counters, field errors, the honeypot out of sight */
175+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
176+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
177+ .cform output.over { color: var(--red); font-weight: 700; }
178+ .cform .f-error { color: var(--red); margin: 0; }
179+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
180+
181+ /* the inbox: items awaiting moderation, shown as text */
182+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
183+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
184+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
185+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
186+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
187+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
188+ .pending .p-btns form { margin: 0; }
189+
174 .list.mrs {190 .list.mrs {
175 & .c-n { width: 64px; }191 & .c-n { width: 64px; }
176 & .c-state { width: 110px; }192 & .c-state { width: 110px; }
@@ -184,6 +200,15 @@
184 }200 }
185 #mr-notice:empty { display: none; }201 #mr-notice:empty { display: none; }
186 202
203+ /* the owner's inbox: counters, repository filter, then the two lists */
204+ .inbox {
205+ display: flex; flex-direction: column; gap: var(--s4);
206+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
207+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208+ & .since { align-self: flex-start; max-width: 100%; }
209+ }
210+ .inbox-btn { gap: var(--s2); }
211+
187 @media (max-width: 700px) {212 @media (max-width: 700px) {
188 .list.mrs .c-who { display: none; }213 .list.mrs .c-who { display: none; }
189 }214 }
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -555,11 +555,11 @@
555 555
556 // 1. Keys.556 // 1. Keys.
557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',557 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',
559 // the merge request page (mr.js): the merge menu, mark read559 // the merge request page (mr.js): the merge menu, mark read
560 M: 'merge', R: 'read' };560 M: 'merge', R: 'read' };
561 // "g t" is the key "t": the tabs have one event each.561 // "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' };
563 let pending = 0;563 let pending = 0;
564 let pendingTimer = 0;564 let pendingTimer = 0;
565 addEventListener('keydown', (e) => {565 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 {
69 Passkeys bool69 Passkeys bool
70 // MR loads the script of the merge request page (mr.js).70 // MR loads the script of the merge request page (mr.js).
71 MR bool71 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
72 }74 }
73 75
74 // Link is a plain label + href pair.76 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135 type HomePage struct {137 type HomePage struct {
136 Sorts []SortLink138 Sorts []SortLink
137 Rows []HomeRow139 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
138 }150 }
139 151
140 // Fact is one key/value row of the facts block.152 // 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 {
54 // there are no such pages, nor section for them. Leave it unset rather54 // there are no such pages, nor section for them. Leave it unset rather
55 // than assigning a typed nil pointer.55 // than assigning a typed nil pointer.
56 MergeRequests ports.MergeRequestService56 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
57 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to79 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58 // commits and merge requests. Without it there are no such pages. Leave it80 // commits and merge requests. Without it there are no such pages. Leave it
59 // unset rather than assigning a typed nil pointer.81 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166 if s.passkeysOn() {188 if s.passkeysOn() {
167 s.passkeyRoutes(mux)189 s.passkeyRoutes(mux)
168 }190 }
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+ }
169 mux.HandleFunc("GET /{$}", s.page(s.home))199 mux.HandleFunc("GET /{$}", s.page(s.home))
170 mux.HandleFunc("GET /{repo}", s.page(s.summary))200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182 if s.cfg.MergeRequests != nil {212 if s.cfg.MergeRequests != nil {
183 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))214 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))
186 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -197,6 +227,21 @@ func (s *Server) routes() *http.ServeMux {
197 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
198 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
199 }229 }
230+ if s.cfg.Issues != nil {
231+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235+ // The writes: POST only, behind the gate (ServeHTTP).
236+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240+ }
241+ if s.cfg.IssueForm != nil {
242+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244+ }
200 if s.cfg.Runs != nil {245 if s.cfg.Runs != nil {
201 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
202 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))247 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 {
31 event ermiteconnect.EventServiceClient31 event ermiteconnect.EventServiceClient
32 server ermiteconnect.ServerServiceClient32 server ermiteconnect.ServerServiceClient
33 mr ermiteconnect.MergeRequestServiceClient33 mr ermiteconnect.MergeRequestServiceClient
34+ issue ermiteconnect.IssueServiceClient
34 35
35 session ermiteconnect.SessionServiceClient36 session ermiteconnect.SessionServiceClient
36 runner ermiteconnect.RunnerServiceClient37 runner ermiteconnect.RunnerServiceClient
@@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
85 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),86 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
86 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),87 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
87 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),88 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
89+ issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...),
88 90
89 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),91 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
90 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),92 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 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)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +2 -2
@@ -191,7 +191,7 @@ type eventOut struct {
191 MergeRequest *mrOut `json:"merge_request"`191 MergeRequest *mrOut `json:"merge_request"`
192 }192 }
193 193
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"}
195 195
196 // AC6: the CLI end to end against a real forge.196 // AC6: the CLI end to end against a real forge.
197 func TestAC6CLIEndToEnd(t *testing.T) {197 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338 ver := decode[versionOut](t, res.stdout)338 ver := decode[versionOut](t, res.stdout)
339 assert.Equal(t, c.url, ver.Server.URL)339 assert.Equal(t, c.url, ver.Server.URL)
340 assert.NotEmpty(t, ver.Server.Version)340 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)
342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343 stdout, stderr, code = c.bin(t, secret, "version")343 stdout, stderr, code = c.bin(t, secret, "version")
344 require.Equal(t, 0, code, stderr)344 require.Equal(t, 0, code, stderr)
M internal/app/cli_fake_test.go +27 -0
@@ -31,6 +31,7 @@ type fakeAPI struct {
31 ermiteconnect.UnimplementedServerServiceHandler31 ermiteconnect.UnimplementedServerServiceHandler
32 ermiteconnect.UnimplementedEventServiceHandler32 ermiteconnect.UnimplementedEventServiceHandler
33 ermiteconnect.UnimplementedMergeRequestServiceHandler33 ermiteconnect.UnimplementedMergeRequestServiceHandler
34+ ermiteconnect.UnimplementedIssueServiceHandler
34 ermiteconnect.UnimplementedSessionServiceHandler35 ermiteconnect.UnimplementedSessionServiceHandler
35 ermiteconnect.UnimplementedRunServiceHandler36 ermiteconnect.UnimplementedRunServiceHandler
36 ermiteconnect.UnimplementedTrustedKeyServiceHandler37 ermiteconnect.UnimplementedTrustedKeyServiceHandler
@@ -61,6 +62,12 @@ type fakeAPI struct {
61 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)62 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
62 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)63 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
63 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)64 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)
64 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)71 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
65 72
66 listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)73 listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
@@ -148,6 +155,24 @@ func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite
148 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {155 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
149 return respond(f, "MarkRead", r, f.markRead)156 return respond(f, "MarkRead", r, f.markRead)
150 }157 }
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+}
151 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {176 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
152 return respond(f, "GetSigningKey", r, f.getSigningKey)177 return respond(f, "GetSigningKey", r, f.getSigningKey)
153 }178 }
@@ -189,6 +214,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
189 mount(p, h)214 mount(p, h)
190 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)215 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
191 mount(p, h)216 mount(p, h)
217+ p, h = ermiteconnect.NewIssueServiceHandler(f)
218+ mount(p, h)
192 p, h = ermiteconnect.NewSessionServiceHandler(f)219 p, h = ermiteconnect.NewSessionServiceHandler(f)
193 mount(p, h)220 mount(p, h)
194 p, h = ermiteconnect.NewRunServiceHandler(f)221 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 {
36 // MergeModes are the allowed merge modes ("fast-forward", "squash").36 // MergeModes are the allowed merge modes ("fast-forward", "squash").
37 MergeModes []string `json:"merge_modes"`37 MergeModes []string `json:"merge_modes"`
38 FailingChecksBlock bool `json:"failing_checks_block"`38 FailingChecksBlock bool `json:"failing_checks_block"`
39+ // Issues is the issue mode ("off", "owner" or "public").
40+ Issues string `json:"issues"`
39 }41 }
40 42
41 func toJSONRepo(r *ermite.Repo) jsonRepo {43 func toJSONRepo(r *ermite.Repo) jsonRepo {
@@ -46,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
46 return jsonRepo{48 return jsonRepo{
47 MergeModes: modes,49 MergeModes: modes,
48 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),50 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
51+ Issues: issuesModeName(r.GetSettings().GetIssues()),
49 Name: r.GetName(),52 Name: r.GetName(),
50 Description: r.GetDescription(),53 Description: r.GetDescription(),
51 Topics: append([]string{}, r.GetTopics()...),54 Topics: append([]string{}, r.GetTopics()...),
@@ -56,6 +59,36 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
56 }59 }
57 }60 }
58 61
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+
59 func visibilityName(v ermite.Visibility) string {92 func visibilityName(v ermite.Visibility) string {
60 switch v {93 switch v {
61 case ermite.Visibility_VISIBILITY_PUBLIC:94 case ermite.Visibility_VISIBILITY_PUBLIC:
@@ -82,7 +115,8 @@ const jsonRepoHelp = `
82 JSON fields of a repository (--json): name, description, visibility ("public" or115 JSON fields of a repository (--json): name, description, visibility ("public" or
83 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
84 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or117 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").`
86 120
87 func newRepoCmd(env *cliEnv) *cobra.Command {121 func newRepoCmd(env *cliEnv) *cobra.Command {
88 repo := &cobra.Command{122 repo := &cobra.Command{
@@ -195,6 +229,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
195 {"last push", shortTime(r.GetPushedAt(), "never")},229 {"last push", shortTime(r.GetPushedAt(), "never")},
196 {"merge modes", orDash(strings.Join(modes, ", "))},230 {"merge modes", orDash(strings.Join(modes, ", "))},
197 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},231 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
232+ {"issues", issuesModeName(r.GetSettings().GetIssues())},
198 })233 })
199 }234 }
200 235
@@ -274,13 +309,16 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
274 var description, visibility, defaultBranch string309 var description, visibility, defaultBranch string
275 var topics, mergeModes []string310 var topics, mergeModes []string
276 var failingChecksBlock bool311 var failingChecksBlock bool
312+ var issues string
277 cmd := &cobra.Command{313 cmd := &cobra.Command{
278 Use: "edit <name>",314 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",
280 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
281 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
282 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +318 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
283 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +319 "--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" +
284 "Needs the repo:admin scope." + jsonRepoHelp,322 "Needs the repo:admin scope." + jsonRepoHelp,
285 Args: cobra.ExactArgs(1),323 Args: cobra.ExactArgs(1),
286 ValidArgsFunction: completeRepoNames(env),324 ValidArgsFunction: completeRepoNames(env),
@@ -291,13 +329,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
291 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")329 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
292 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")330 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
293 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")331 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+ })
294 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)336 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
295 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {337 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
296 return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp338 return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp
297 })339 })
298 asJSON := addJSONFlag(cmd)340 asJSON := addJSONFlag(cmd)
299 cmd.RunE = func(cmd *cobra.Command, args []string) error {341 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)
301 if err != nil {343 if err != nil {
302 return err344 return err
303 }345 }
@@ -305,6 +347,11 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
305 if err != nil {347 if err != nil {
306 return err348 return err
307 }349 }
350+ if req.Issues != nil {
351+ if err := requireIssues(env.ctx(cmd), c); err != nil {
352+ return err
353+ }
354+ }
308 res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))355 res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))
309 if err != nil {356 if err != nil {
310 return c.Describe(err)357 return c.Describe(err)
@@ -316,7 +363,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
316 363
317 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags364 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
318 // the user passed go into the update mask.365 // 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) {
320 req := &ermite.UpdateRepoRequest{Repo: name}367 req := &ermite.UpdateRepoRequest{Repo: name}
321 var paths []string368 var paths []string
322 if cmd.Flags().Changed("description") {369 if cmd.Flags().Changed("description") {
@@ -365,8 +412,16 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
365 req.Settings.FailingChecksBlock = failingChecksBlock412 req.Settings.FailingChecksBlock = failingChecksBlock
366 paths = append(paths, "settings.failing_checks_block")413 paths = append(paths, "settings.failing_checks_block")
367 }414 }
368- if len(paths) == 0 {415+ if cmd.Flags().Changed("issues") {
369- return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block")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")
370 }425 }
371 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}426 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
372 return req, nil427 return req, nil
M internal/app/cli_repo_test.go +6 -5
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
54 {54 {
55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56 wantStdout: `[56 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},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}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"}
59 ]`,59 ]`,
60 },60 },
61 {61 {
@@ -115,12 +115,13 @@ func TestRepoShow(t *testing.T) {
115 "created 2026-01-02 03:04\n"+115 "created 2026-01-02 03:04\n"+
116 "last push 2026-03-04 05:06\n"+116 "last push 2026-03-04 05:06\n"+
117 "merge modes fast-forward, squash\n"+117 "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)
119 _, reqs := f.snapshot()120 _, reqs := f.snapshot()
120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())121 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
121 122
122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)123 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)
124 }125 }
125 126
126 func TestRepoShowNeedsOneArg(t *testing.T) {127 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -176,7 +177,7 @@ func TestRepoCreateJSON(t *testing.T) {
176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil177 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
177 }}178 }}
178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)179 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)
180 }181 }
181 182
182 func TestRepoEditMask(t *testing.T) {183 func TestRepoEditMask(t *testing.T) {
M internal/app/cli_token.go +22 -0
@@ -90,6 +90,8 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
90 " agent repo:write, mr:write\n" +90 " agent repo:write, mr:write\n" +
91 " runner runner:register (a CI runner host)\n" +91 " runner runner:register (a CI runner host)\n" +
92 " ci repo:read\n" +92 " 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" +
93 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +95 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
94 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +96 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
95 "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +97 "--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 {
131 return err133 return err
132 }134 }
133 }135 }
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+ }
134 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
135 if err != nil {149 if err != nil {
136 return c.Describe(err)150 return c.Describe(err)
@@ -143,6 +157,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
143 return cmd157 return cmd
144 }158 }
145 159
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+
146 // requireBranchPrefixes refuses, before anything is created, to ask a server168 // requireBranchPrefixes refuses, before anything is created, to ask a server
147 // for a branch-limited token when it does not list token.branch_prefixes: such169 // for a branch-limited token when it does not list token.branch_prefixes: such
148 // a server ignores the field. A server whose features cannot be read is refused170 // 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) {
174 "cli": client.AllScopes(),174 "cli": client.AllScopes(),
175 } {175 } {
176 t.Run(preset, func(t *testing.T) {176 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) {
178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179 }}179 }}
180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
181- _, reqs := f.snapshot()181+ got := lastCreateToken(t, f)
182- got := reqs[0].(*ermite.CreateTokenRequest)
183 assert.ElementsMatch(t, want, got.GetScopes())182 assert.ElementsMatch(t, want, got.GetScopes())
184 assert.Empty(t, got.GetRepos())183 assert.Empty(t, got.GetRepos())
185 assert.Nil(t, got.GetExpiresAt())184 assert.Nil(t, got.GetExpiresAt())
M internal/app/cli_version.go +1 -0
@@ -113,6 +113,7 @@ var featureUse = map[string]string{
113 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",113 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",115 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",
116 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",117 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",118 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
118 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",119 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 (
17 )17 )
18 18
19 // allFeatures are the features this client uses.19 // 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"}
21 21
22 // Every feature the client uses needs a sentence for the version warning.22 // Every feature the client uses needs a sentence for the version warning.
23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
45 f := versionFake(allFeatures...)45 f := versionFake(allFeatures...)
46 url := f.serve(t)46 url := f.serve(t)
47 res := runCLI(t, envFor(url), "", "version").ok(t)47 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)
49 assert.Empty(t, res.stderr, "no warning when every feature is there")49 assert.Empty(t, res.stderr, "no warning when every feature is there")
50 50
51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)51 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)
53 }53 }
54 54
55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {55 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")
57 url := f.serve(t)57 url := f.serve(t)
58 res := runCLI(t, envFor(url), "", "version").ok(t)58 res := runCLI(t, envFor(url), "", "version").ok(t)
59 assert.Contains(t, res.stdout, "v0.4.0")59 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
61 assert.NotContains(t, res.stderr, "repo.update")61 assert.NotContains(t, res.stderr, "repo.update")
62 62
63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)63 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)
65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
66 66
67 f = versionFake()67 f = versionFake()
M internal/app/cli_watch_test.go +1 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96 }96 }
97 require.Len(t, lines, 5)97 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])
99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
M internal/app/forge.go +71 -22
@@ -75,6 +75,10 @@ type options struct {
75 // oidcKey generates the signing keys of the ID tokens; nil generates RSA75 // oidcKey generates the signing keys of the ID tokens; nil generates RSA
76 // keys (tests give prepared ones: a 3072-bit key takes a while).76 // keys (tests give prepared ones: a 3072-bit key takes a while).
77 oidcKey func() (*rsa.PrivateKey, error)77 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
78 }82 }
79 83
80 // remote returns the lister of github.com refs: the test's, else def.84 // remote returns the lister of github.com refs: the test's, else def.
@@ -115,7 +119,21 @@ type forge struct {
115 // MergeRequests and SigningKey serve MergeRequestService and119 // MergeRequests and SigningKey serve MergeRequestService and
116 // ServerService.GetSigningKey.120 // ServerService.GetSigningKey.
117 MergeRequests *usecase.MergeRequests121 MergeRequests *usecase.MergeRequests
118- SigningKey *usecase.SigningKey122+ // 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
119 // Signatures are the trusted keys and the commit signature checks137 // Signatures are the trusted keys and the commit signature checks
120 // (ADR 0030).138 // (ADR 0030).
121 Signatures *usecase.Signatures139 Signatures *usecase.Signatures
@@ -316,6 +334,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
316 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),334 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
317 })335 })
318 f.MergeRequests.WithVerifier(f.Signatures)336 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+ })
319 f.CI.SetPullGate(f.MergeRequests)357 f.CI.SetPullGate(f.MergeRequests)
320 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")358 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
321 359
@@ -356,27 +394,29 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
356 jobArtifacts, artifacts = f.CI, f.CI394 jobArtifacts, artifacts = f.CI, f.CI
357 }395 }
358 f.API = rpc.New(rpc.Config{396 f.API = rpc.New(rpc.Config{
359- Auth: f.Auth,397+ Auth: f.Auth,
360- Repos: f.Repos,398+ Repos: f.Repos,
361- Tokens: f.Tokens,399+ Tokens: f.Tokens,
362- Browse: f.Browse,400+ Browse: f.Browse,
363- Events: f.EventLog,401+ Events: f.EventLog,
364- MergeRequests: f.MergeRequests,402+ MergeRequests: f.MergeRequests,
365- SigningKey: f.SigningKey,403+ Issues: f.Issues,
366- TrustedKeys: f.Signatures,404+ IssueModeration: f.IssueModeration,
367- Sessions: f.Sessions,405+ SigningKey: f.SigningKey,
368- Passkeys: apiPasskeys,406+ TrustedKeys: f.Signatures,
369- Runner: f.CI,407+ Sessions: f.Sessions,
370- Runs: f.CI,408+ Passkeys: apiPasskeys,
371- Secrets: f.Settings,409+ Runner: f.CI,
372- Variables: f.Settings,410+ Runs: f.CI,
373- Environments: f.Settings,411+ Secrets: f.Settings,
374- OIDCKeys: apiOIDC,412+ Variables: f.Settings,
375- JobArtifacts: jobArtifacts,413+ Environments: f.Settings,
376- Artifacts: artifacts,414+ OIDCKeys: apiOIDC,
377- Version: serverVersion(),415+ JobArtifacts: jobArtifacts,
378- TrustedProxies: cfg.TrustedProxies,416+ Artifacts: artifacts,
379- Limiter: limiter,417+ Version: serverVersion(),
418+ TrustedProxies: cfg.TrustedProxies,
419+ Limiter: limiter,
380 })420 })
381 421
382 gh := githttp.New(githttp.Config{422 gh := githttp.New(githttp.Config{
@@ -536,3 +576,12 @@ func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key do
536 f.Browse.WithVerifier(sigs)576 f.Browse.WithVerifier(sigs)
537 return nil577 return nil
538 }578 }
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 {
31 root.AddCommand(31 root.AddCommand(
32 newServeCmd(), newHookCmd(),32 newServeCmd(), newHookCmd(),
33 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),33 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),
35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
36 )36 )
37 return root37 return root
M internal/app/testdata/web/blob-binary.html +3 -1
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </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>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-code.html +3 -1
@@ -297,8 +297,9 @@
297 <kbd>c</kbd>297 <kbd>c</kbd>
298 <kbd>r</kbd>298 <kbd>r</kbd>
299 <kbd>m</kbd>299 <kbd>m</kbd>
300+<kbd>i</kbd>
300 </dt>301 </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>
302 </div>303 </div>
303 <div>304 <div>
304 <dt>305 <dt>
@@ -480,6 +481,7 @@
480 k-c from:body -> ermite.tab('c');481 k-c from:body -> ermite.tab('c');
481 k-r from:body -> ermite.tab('r');482 k-r from:body -> ermite.tab('r');
482 k-m from:body -> ermite.tab('m');483 k-m from:body -> ermite.tab('m');
484+ k-i from:body -> ermite.tab('i');
483 k-next from:body -> ermite.nextNote();485 k-next from:body -> ermite.nextNote();
484 k-split from:body -> ermite.layout('split');486 k-split from:body -> ermite.layout('split');
485 k-unified from:body -> ermite.layout('unified');487 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-empty.html +3 -1
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </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>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-image.html +3 -1
@@ -229,8 +229,9 @@
229 <kbd>c</kbd>229 <kbd>c</kbd>
230 <kbd>r</kbd>230 <kbd>r</kbd>
231 <kbd>m</kbd>231 <kbd>m</kbd>
232+<kbd>i</kbd>
232 </dt>233 </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>
234 </div>235 </div>
235 <div>236 <div>
236 <dt>237 <dt>
@@ -412,6 +413,7 @@
412 k-c from:body -> ermite.tab('c');413 k-c from:body -> ermite.tab('c');
413 k-r from:body -> ermite.tab('r');414 k-r from:body -> ermite.tab('r');
414 k-m from:body -> ermite.tab('m');415 k-m from:body -> ermite.tab('m');
416+ k-i from:body -> ermite.tab('i');
415 k-next from:body -> ermite.nextNote();417 k-next from:body -> ermite.nextNote();
416 k-split from:body -> ermite.layout('split');418 k-split from:body -> ermite.layout('split');
417 k-unified from:body -> ermite.layout('unified');419 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-markdown.html +3 -1
@@ -294,8 +294,9 @@
294 <kbd>c</kbd>294 <kbd>c</kbd>
295 <kbd>r</kbd>295 <kbd>r</kbd>
296 <kbd>m</kbd>296 <kbd>m</kbd>
297+<kbd>i</kbd>
297 </dt>298 </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>
299 </div>300 </div>
300 <div>301 <div>
301 <dt>302 <dt>
@@ -477,6 +478,7 @@
477 k-c from:body -> ermite.tab('c');478 k-c from:body -> ermite.tab('c');
478 k-r from:body -> ermite.tab('r');479 k-r from:body -> ermite.tab('r');
479 k-m from:body -> ermite.tab('m');480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480 k-next from:body -> ermite.nextNote();482 k-next from:body -> ermite.nextNote();
481 k-split from:body -> ermite.layout('split');483 k-split from:body -> ermite.layout('split');
482 k-unified from:body -> ermite.layout('unified');484 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-svg.html +3 -1
@@ -252,8 +252,9 @@
252 <kbd>c</kbd>252 <kbd>c</kbd>
253 <kbd>r</kbd>253 <kbd>r</kbd>
254 <kbd>m</kbd>254 <kbd>m</kbd>
255+<kbd>i</kbd>
255 </dt>256 </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>
257 </div>258 </div>
258 <div>259 <div>
259 <dt>260 <dt>
@@ -435,6 +436,7 @@
435 k-c from:body -> ermite.tab('c');436 k-c from:body -> ermite.tab('c');
436 k-r from:body -> ermite.tab('r');437 k-r from:body -> ermite.tab('r');
437 k-m from:body -> ermite.tab('m');438 k-m from:body -> ermite.tab('m');
439+ k-i from:body -> ermite.tab('i');
438 k-next from:body -> ermite.nextNote();440 k-next from:body -> ermite.nextNote();
439 k-split from:body -> ermite.layout('split');441 k-split from:body -> ermite.layout('split');
440 k-unified from:body -> ermite.layout('unified');442 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-text.html +3 -1
@@ -247,8 +247,9 @@
247 <kbd>c</kbd>247 <kbd>c</kbd>
248 <kbd>r</kbd>248 <kbd>r</kbd>
249 <kbd>m</kbd>249 <kbd>m</kbd>
250+<kbd>i</kbd>
250 </dt>251 </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>
252 </div>253 </div>
253 <div>254 <div>
254 <dt>255 <dt>
@@ -430,6 +431,7 @@
430 k-c from:body -> ermite.tab('c');431 k-c from:body -> ermite.tab('c');
431 k-r from:body -> ermite.tab('r');432 k-r from:body -> ermite.tab('r');
432 k-m from:body -> ermite.tab('m');433 k-m from:body -> ermite.tab('m');
434+ k-i from:body -> ermite.tab('i');
433 k-next from:body -> ermite.nextNote();435 k-next from:body -> ermite.nextNote();
434 k-split from:body -> ermite.layout('split');436 k-split from:body -> ermite.layout('split');
435 k-unified from:body -> ermite.layout('unified');437 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-too-large.html +3 -1
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </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>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-trojan.html +3 -1
@@ -294,8 +294,9 @@
294 <kbd>c</kbd>294 <kbd>c</kbd>
295 <kbd>r</kbd>295 <kbd>r</kbd>
296 <kbd>m</kbd>296 <kbd>m</kbd>
297+<kbd>i</kbd>
297 </dt>298 </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>
299 </div>300 </div>
300 <div>301 <div>
301 <dt>302 <dt>
@@ -477,6 +478,7 @@
477 k-c from:body -> ermite.tab('c');478 k-c from:body -> ermite.tab('c');
478 k-r from:body -> ermite.tab('r');479 k-r from:body -> ermite.tab('r');
479 k-m from:body -> ermite.tab('m');480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480 k-next from:body -> ermite.nextNote();482 k-next from:body -> ermite.nextNote();
481 k-split from:body -> ermite.layout('split');483 k-split from:body -> ermite.layout('split');
482 k-unified from:body -> ermite.layout('unified');484 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-big.html +3 -1
@@ -269,8 +269,9 @@
269 <kbd>c</kbd>269 <kbd>c</kbd>
270 <kbd>r</kbd>270 <kbd>r</kbd>
271 <kbd>m</kbd>271 <kbd>m</kbd>
272+<kbd>i</kbd>
272 </dt>273 </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>
274 </div>275 </div>
275 <div>276 <div>
276 <dt>277 <dt>
@@ -452,6 +453,7 @@
452 k-c from:body -> ermite.tab('c');453 k-c from:body -> ermite.tab('c');
453 k-r from:body -> ermite.tab('r');454 k-r from:body -> ermite.tab('r');
454 k-m from:body -> ermite.tab('m');455 k-m from:body -> ermite.tab('m');
456+ k-i from:body -> ermite.tab('i');
455 k-next from:body -> ermite.nextNote();457 k-next from:body -> ermite.nextNote();
456 k-split from:body -> ermite.layout('split');458 k-split from:body -> ermite.layout('split');
457 k-unified from:body -> ermite.layout('unified');459 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-binary.html +3 -1
@@ -261,8 +261,9 @@
261 <kbd>c</kbd>261 <kbd>c</kbd>
262 <kbd>r</kbd>262 <kbd>r</kbd>
263 <kbd>m</kbd>263 <kbd>m</kbd>
264+<kbd>i</kbd>
264 </dt>265 </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>
266 </div>267 </div>
267 <div>268 <div>
268 <dt>269 <dt>
@@ -444,6 +445,7 @@
444 k-c from:body -> ermite.tab('c');445 k-c from:body -> ermite.tab('c');
445 k-r from:body -> ermite.tab('r');446 k-r from:body -> ermite.tab('r');
446 k-m from:body -> ermite.tab('m');447 k-m from:body -> ermite.tab('m');
448+ k-i from:body -> ermite.tab('i');
447 k-next from:body -> ermite.nextNote();449 k-next from:body -> ermite.nextNote();
448 k-split from:body -> ermite.layout('split');450 k-split from:body -> ermite.layout('split');
449 k-unified from:body -> ermite.layout('unified');451 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-merge.html +3 -1
@@ -303,8 +303,9 @@
303 <kbd>c</kbd>303 <kbd>c</kbd>
304 <kbd>r</kbd>304 <kbd>r</kbd>
305 <kbd>m</kbd>305 <kbd>m</kbd>
306+<kbd>i</kbd>
306 </dt>307 </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>
308 </div>309 </div>
309 <div>310 <div>
310 <dt>311 <dt>
@@ -486,6 +487,7 @@
486 k-c from:body -> ermite.tab('c');487 k-c from:body -> ermite.tab('c');
487 k-r from:body -> ermite.tab('r');488 k-r from:body -> ermite.tab('r');
488 k-m from:body -> ermite.tab('m');489 k-m from:body -> ermite.tab('m');
490+ k-i from:body -> ermite.tab('i');
489 k-next from:body -> ermite.nextNote();491 k-next from:body -> ermite.nextNote();
490 k-split from:body -> ermite.layout('split');492 k-split from:body -> ermite.layout('split');
491 k-unified from:body -> ermite.layout('unified');493 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>
426 <kbd>c</kbd>426 <kbd>c</kbd>
427 <kbd>r</kbd>427 <kbd>r</kbd>
428 <kbd>m</kbd>428 <kbd>m</kbd>
429+<kbd>i</kbd>
429 </dt>430 </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>
431 </div>432 </div>
432 <div>433 <div>
433 <dt>434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609 k-c from:body -> ermite.tab('c');610 k-c from:body -> ermite.tab('c');
610 k-r from:body -> ermite.tab('r');611 k-r from:body -> ermite.tab('r');
611 k-m from:body -> ermite.tab('m');612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612 k-next from:body -> ermite.nextNote();614 k-next from:body -> ermite.nextNote();
613 k-split from:body -> ermite.layout('split');615 k-split from:body -> ermite.layout('split');
614 k-unified from:body -> ermite.layout('unified');616 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>
426 <kbd>c</kbd>426 <kbd>c</kbd>
427 <kbd>r</kbd>427 <kbd>r</kbd>
428 <kbd>m</kbd>428 <kbd>m</kbd>
429+<kbd>i</kbd>
429 </dt>430 </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>
431 </div>432 </div>
432 <div>433 <div>
433 <dt>434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609 k-c from:body -> ermite.tab('c');610 k-c from:body -> ermite.tab('c');
610 k-r from:body -> ermite.tab('r');611 k-r from:body -> ermite.tab('r');
611 k-m from:body -> ermite.tab('m');612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612 k-next from:body -> ermite.nextNote();614 k-next from:body -> ermite.nextNote();
613 k-split from:body -> ermite.layout('split');615 k-split from:body -> ermite.layout('split');
614 k-unified from:body -> ermite.layout('unified');616 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-rename.html +3 -1
@@ -330,8 +330,9 @@
330 <kbd>c</kbd>330 <kbd>c</kbd>
331 <kbd>r</kbd>331 <kbd>r</kbd>
332 <kbd>m</kbd>332 <kbd>m</kbd>
333+<kbd>i</kbd>
333 </dt>334 </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>
335 </div>336 </div>
336 <div>337 <div>
337 <dt>338 <dt>
@@ -513,6 +514,7 @@
513 k-c from:body -> ermite.tab('c');514 k-c from:body -> ermite.tab('c');
514 k-r from:body -> ermite.tab('r');515 k-r from:body -> ermite.tab('r');
515 k-m from:body -> ermite.tab('m');516 k-m from:body -> ermite.tab('m');
517+ k-i from:body -> ermite.tab('i');
516 k-next from:body -> ermite.nextNote();518 k-next from:body -> ermite.nextNote();
517 k-split from:body -> ermite.layout('split');519 k-split from:body -> ermite.layout('split');
518 k-unified from:body -> ermite.layout('unified');520 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-root.html +3 -1
@@ -649,8 +649,9 @@
649 <kbd>c</kbd>649 <kbd>c</kbd>
650 <kbd>r</kbd>650 <kbd>r</kbd>
651 <kbd>m</kbd>651 <kbd>m</kbd>
652+<kbd>i</kbd>
652 </dt>653 </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>
654 </div>655 </div>
655 <div>656 <div>
656 <dt>657 <dt>
@@ -832,6 +833,7 @@
832 k-c from:body -> ermite.tab('c');833 k-c from:body -> ermite.tab('c');
833 k-r from:body -> ermite.tab('r');834 k-r from:body -> ermite.tab('r');
834 k-m from:body -> ermite.tab('m');835 k-m from:body -> ermite.tab('m');
836+ k-i from:body -> ermite.tab('i');
835 k-next from:body -> ermite.nextNote();837 k-next from:body -> ermite.nextNote();
836 k-split from:body -> ermite.layout('split');838 k-split from:body -> ermite.layout('split');
837 k-unified from:body -> ermite.layout('unified');839 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-trojan.html +3 -1
@@ -478,8 +478,9 @@
478 <kbd>c</kbd>478 <kbd>c</kbd>
479 <kbd>r</kbd>479 <kbd>r</kbd>
480 <kbd>m</kbd>480 <kbd>m</kbd>
481+<kbd>i</kbd>
481 </dt>482 </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>
483 </div>484 </div>
484 <div>485 <div>
485 <dt>486 <dt>
@@ -661,6 +662,7 @@
661 k-c from:body -> ermite.tab('c');662 k-c from:body -> ermite.tab('c');
662 k-r from:body -> ermite.tab('r');663 k-r from:body -> ermite.tab('r');
663 k-m from:body -> ermite.tab('m');664 k-m from:body -> ermite.tab('m');
665+ k-i from:body -> ermite.tab('i');
664 k-next from:body -> ermite.nextNote();666 k-next from:body -> ermite.nextNote();
665 k-split from:body -> ermite.layout('split');667 k-split from:body -> ermite.layout('split');
666 k-unified from:body -> ermite.layout('unified');668 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-page2.html +3 -1
@@ -254,8 +254,9 @@
254 <kbd>c</kbd>254 <kbd>c</kbd>
255 <kbd>r</kbd>255 <kbd>r</kbd>
256 <kbd>m</kbd>256 <kbd>m</kbd>
257+<kbd>i</kbd>
257 </dt>258 </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>
259 </div>260 </div>
260 <div>261 <div>
261 <dt>262 <dt>
@@ -423,6 +424,7 @@
423 k-c from:body -> ermite.tab('c');424 k-c from:body -> ermite.tab('c');
424 k-r from:body -> ermite.tab('r');425 k-r from:body -> ermite.tab('r');
425 k-m from:body -> ermite.tab('m');426 k-m from:body -> ermite.tab('m');
427+ k-i from:body -> ermite.tab('i');
426 k-next from:body -> ermite.nextNote();428 k-next from:body -> ermite.nextNote();
427 k-split from:body -> ermite.layout('split');429 k-split from:body -> ermite.layout('split');
428 k-unified from:body -> ermite.layout('unified');430 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-path.html +3 -1
@@ -269,8 +269,9 @@
269 <kbd>c</kbd>269 <kbd>c</kbd>
270 <kbd>r</kbd>270 <kbd>r</kbd>
271 <kbd>m</kbd>271 <kbd>m</kbd>
272+<kbd>i</kbd>
272 </dt>273 </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>
274 </div>275 </div>
275 <div>276 <div>
276 <dt>277 <dt>
@@ -438,6 +439,7 @@
438 k-c from:body -> ermite.tab('c');439 k-c from:body -> ermite.tab('c');
439 k-r from:body -> ermite.tab('r');440 k-r from:body -> ermite.tab('r');
440 k-m from:body -> ermite.tab('m');441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441 k-next from:body -> ermite.nextNote();443 k-next from:body -> ermite.nextNote();
442 k-split from:body -> ermite.layout('split');444 k-split from:body -> ermite.layout('split');
443 k-unified from:body -> ermite.layout('unified');445 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-root.html +4 -2
@@ -972,7 +972,7 @@
972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973 htmx:sse:error -> ermiteLive.error(event.detail.status);973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974 htmx:sse:before:connection -> ermiteLive.gate(event);974 htmx:sse:before:connection -> ermiteLive.gate(event);
975- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);975+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976 gap -> ermiteLive.reload()">976 gap -> ermiteLive.reload()">
977 </span>977 </span>
978 </div>978 </div>
@@ -1058,8 +1058,9 @@
1058 <kbd>c</kbd>1058 <kbd>c</kbd>
1059 <kbd>r</kbd>1059 <kbd>r</kbd>
1060 <kbd>m</kbd>1060 <kbd>m</kbd>
1061+<kbd>i</kbd>
1061 </dt>1062 </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>
1063 </div>1064 </div>
1064 <div>1065 <div>
1065 <dt>1066 <dt>
@@ -1227,6 +1228,7 @@
1227 k-c from:body -> ermite.tab('c');1228 k-c from:body -> ermite.tab('c');
1228 k-r from:body -> ermite.tab('r');1229 k-r from:body -> ermite.tab('r');
1229 k-m from:body -> ermite.tab('m');1230 k-m from:body -> ermite.tab('m');
1231+ k-i from:body -> ermite.tab('i');
1230 k-next from:body -> ermite.nextNote();1232 k-next from:body -> ermite.nextNote();
1231 k-split from:body -> ermite.layout('split');1233 k-split from:body -> ermite.layout('split');
1232 k-unified from:body -> ermite.layout('unified');1234 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-small.html +4 -2
@@ -317,7 +317,7 @@
317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318 htmx:sse:error -> ermiteLive.error(event.detail.status);318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319 htmx:sse:before:connection -> ermiteLive.gate(event);319 htmx:sse:before:connection -> ermiteLive.gate(event);
320- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);320+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321 gap -> ermiteLive.reload()">321 gap -> ermiteLive.reload()">
322 </span>322 </span>
323 </div>323 </div>
@@ -403,8 +403,9 @@
403 <kbd>c</kbd>403 <kbd>c</kbd>
404 <kbd>r</kbd>404 <kbd>r</kbd>
405 <kbd>m</kbd>405 <kbd>m</kbd>
406+<kbd>i</kbd>
406 </dt>407 </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>
408 </div>409 </div>
409 <div>410 <div>
410 <dt>411 <dt>
@@ -592,6 +593,7 @@
592 k-c from:body -> ermite.tab('c');593 k-c from:body -> ermite.tab('c');
593 k-r from:body -> ermite.tab('r');594 k-r from:body -> ermite.tab('r');
594 k-m from:body -> ermite.tab('m');595 k-m from:body -> ermite.tab('m');
596+ k-i from:body -> ermite.tab('i');
595 k-next from:body -> ermite.nextNote();597 k-next from:body -> ermite.nextNote();
596 k-split from:body -> ermite.layout('split');598 k-split from:body -> ermite.layout('split');
597 k-unified from:body -> ermite.layout('unified');599 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/empty.html +3 -1
@@ -176,8 +176,9 @@
176 <kbd>c</kbd>176 <kbd>c</kbd>
177 <kbd>r</kbd>177 <kbd>r</kbd>
178 <kbd>m</kbd>178 <kbd>m</kbd>
179+<kbd>i</kbd>
179 </dt>180 </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>
181 </div>182 </div>
182 <div>183 <div>
183 <dt>184 <dt>
@@ -276,6 +277,7 @@
276 k-c from:body -> ermite.tab('c');277 k-c from:body -> ermite.tab('c');
277 k-r from:body -> ermite.tab('r');278 k-r from:body -> ermite.tab('r');
278 k-m from:body -> ermite.tab('m');279 k-m from:body -> ermite.tab('m');
280+ k-i from:body -> ermite.tab('i');
279 k-next from:body -> ermite.nextNote();281 k-next from:body -> ermite.nextNote();
280 k-split from:body -> ermite.layout('split');282 k-split from:body -> ermite.layout('split');
281 k-unified from:body -> ermite.layout('unified');283 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/home.html +4 -2
@@ -191,7 +191,7 @@
191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
192 htmx:sse:error -> ermiteLive.error(event.detail.status);192 htmx:sse:error -> ermiteLive.error(event.detail.status);
193 htmx:sse:before:connection -> ermiteLive.gate(event);193 htmx:sse:before:connection -> ermiteLive.gate(event);
194- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);194+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
195 gap -> ermiteLive.reload()">195 gap -> ermiteLive.reload()">
196 </span>196 </span>
197 </div>197 </div>
@@ -277,8 +277,9 @@
277 <kbd>c</kbd>277 <kbd>c</kbd>
278 <kbd>r</kbd>278 <kbd>r</kbd>
279 <kbd>m</kbd>279 <kbd>m</kbd>
280+<kbd>i</kbd>
280 </dt>281 </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>
282 </div>283 </div>
283 <div>284 <div>
284 <dt>285 <dt>
@@ -383,6 +384,7 @@
383 k-c from:body -> ermite.tab('c');384 k-c from:body -> ermite.tab('c');
384 k-r from:body -> ermite.tab('r');385 k-r from:body -> ermite.tab('r');
385 k-m from:body -> ermite.tab('m');386 k-m from:body -> ermite.tab('m');
387+ k-i from:body -> ermite.tab('i');
386 k-next from:body -> ermite.nextNote();388 k-next from:body -> ermite.nextNote();
387 k-split from:body -> ermite.layout('split');389 k-split from:body -> ermite.layout('split');
388 k-unified from:body -> ermite.layout('unified');390 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found-page.html +3 -1
@@ -165,8 +165,9 @@
165 <kbd>c</kbd>165 <kbd>c</kbd>
166 <kbd>r</kbd>166 <kbd>r</kbd>
167 <kbd>m</kbd>167 <kbd>m</kbd>
168+<kbd>i</kbd>
168 </dt>169 </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>
170 </div>171 </div>
171 <div>172 <div>
172 <dt>173 <dt>
@@ -259,6 +260,7 @@
259 k-c from:body -> ermite.tab('c');260 k-c from:body -> ermite.tab('c');
260 k-r from:body -> ermite.tab('r');261 k-r from:body -> ermite.tab('r');
261 k-m from:body -> ermite.tab('m');262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262 k-next from:body -> ermite.nextNote();264 k-next from:body -> ermite.nextNote();
263 k-split from:body -> ermite.layout('split');265 k-split from:body -> ermite.layout('split');
264 k-unified from:body -> ermite.layout('unified');266 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found.html +3 -1
@@ -165,8 +165,9 @@
165 <kbd>c</kbd>165 <kbd>c</kbd>
166 <kbd>r</kbd>166 <kbd>r</kbd>
167 <kbd>m</kbd>167 <kbd>m</kbd>
168+<kbd>i</kbd>
168 </dt>169 </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>
170 </div>171 </div>
171 <div>172 <div>
172 <dt>173 <dt>
@@ -259,6 +260,7 @@
259 k-c from:body -> ermite.tab('c');260 k-c from:body -> ermite.tab('c');
260 k-r from:body -> ermite.tab('r');261 k-r from:body -> ermite.tab('r');
261 k-m from:body -> ermite.tab('m');262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262 k-next from:body -> ermite.nextNote();264 k-next from:body -> ermite.nextNote();
263 k-split from:body -> ermite.layout('split');265 k-split from:body -> ermite.layout('split');
264 k-unified from:body -> ermite.layout('unified');266 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/refs.html +4 -2
@@ -199,7 +199,7 @@
199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200 htmx:sse:error -> ermiteLive.error(event.detail.status);200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201 htmx:sse:before:connection -> ermiteLive.gate(event);201 htmx:sse:before:connection -> ermiteLive.gate(event);
202- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);202+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203 gap -> ermiteLive.reload()">203 gap -> ermiteLive.reload()">
204 </span>204 </span>
205 </div>205 </div>
@@ -285,8 +285,9 @@
285 <kbd>c</kbd>285 <kbd>c</kbd>
286 <kbd>r</kbd>286 <kbd>r</kbd>
287 <kbd>m</kbd>287 <kbd>m</kbd>
288+<kbd>i</kbd>
288 </dt>289 </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>
290 </div>291 </div>
291 <div>292 <div>
292 <dt>293 <dt>
@@ -484,6 +485,7 @@
484 k-c from:body -> ermite.tab('c');485 k-c from:body -> ermite.tab('c');
485 k-r from:body -> ermite.tab('r');486 k-r from:body -> ermite.tab('r');
486 k-m from:body -> ermite.tab('m');487 k-m from:body -> ermite.tab('m');
488+ k-i from:body -> ermite.tab('i');
487 k-next from:body -> ermite.nextNote();489 k-next from:body -> ermite.nextNote();
488 k-split from:body -> ermite.layout('split');490 k-split from:body -> ermite.layout('split');
489 k-unified from:body -> ermite.layout('unified');491 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/summary.html +4 -2
@@ -225,7 +225,7 @@
225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226 htmx:sse:error -> ermiteLive.error(event.detail.status);226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227 htmx:sse:before:connection -> ermiteLive.gate(event);227 htmx:sse:before:connection -> ermiteLive.gate(event);
228- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);228+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229 gap -> ermiteLive.reload()">229 gap -> ermiteLive.reload()">
230 </span>230 </span>
231 </div>231 </div>
@@ -311,8 +311,9 @@
311 <kbd>c</kbd>311 <kbd>c</kbd>
312 <kbd>r</kbd>312 <kbd>r</kbd>
313 <kbd>m</kbd>313 <kbd>m</kbd>
314+<kbd>i</kbd>
314 </dt>315 </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>
316 </div>317 </div>
317 <div>318 <div>
318 <dt>319 <dt>
@@ -510,6 +511,7 @@
510 k-c from:body -> ermite.tab('c');511 k-c from:body -> ermite.tab('c');
511 k-r from:body -> ermite.tab('r');512 k-r from:body -> ermite.tab('r');
512 k-m from:body -> ermite.tab('m');513 k-m from:body -> ermite.tab('m');
514+ k-i from:body -> ermite.tab('i');
513 k-next from:body -> ermite.nextNote();515 k-next from:body -> ermite.nextNote();
514 k-split from:body -> ermite.layout('split');516 k-split from:body -> ermite.layout('split');
515 k-unified from:body -> ermite.layout('unified');517 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-nested.html +3 -1
@@ -242,8 +242,9 @@
242 <kbd>c</kbd>242 <kbd>c</kbd>
243 <kbd>r</kbd>243 <kbd>r</kbd>
244 <kbd>m</kbd>244 <kbd>m</kbd>
245+<kbd>i</kbd>
245 </dt>246 </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>
247 </div>248 </div>
248 <div>249 <div>
249 <dt>250 <dt>
@@ -425,6 +426,7 @@
425 k-c from:body -> ermite.tab('c');426 k-c from:body -> ermite.tab('c');
426 k-r from:body -> ermite.tab('r');427 k-r from:body -> ermite.tab('r');
427 k-m from:body -> ermite.tab('m');428 k-m from:body -> ermite.tab('m');
429+ k-i from:body -> ermite.tab('i');
428 k-next from:body -> ermite.nextNote();430 k-next from:body -> ermite.nextNote();
429 k-split from:body -> ermite.layout('split');431 k-split from:body -> ermite.layout('split');
430 k-unified from:body -> ermite.layout('unified');432 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-root.html +3 -1
@@ -463,8 +463,9 @@
463 <kbd>c</kbd>463 <kbd>c</kbd>
464 <kbd>r</kbd>464 <kbd>r</kbd>
465 <kbd>m</kbd>465 <kbd>m</kbd>
466+<kbd>i</kbd>
466 </dt>467 </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>
468 </div>469 </div>
469 <div>470 <div>
470 <dt>471 <dt>
@@ -646,6 +647,7 @@
646 k-c from:body -> ermite.tab('c');647 k-c from:body -> ermite.tab('c');
647 k-r from:body -> ermite.tab('r');648 k-r from:body -> ermite.tab('r');
648 k-m from:body -> ermite.tab('m');649 k-m from:body -> ermite.tab('m');
650+ k-i from:body -> ermite.tab('i');
649 k-next from:body -> ermite.nextNote();651 k-next from:body -> ermite.nextNote();
650 k-split from:body -> ermite.layout('split');652 k-split from:body -> ermite.layout('split');
651 k-unified from:body -> ermite.layout('unified');653 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-slash-ref.html +3 -1
@@ -239,8 +239,9 @@
239 <kbd>c</kbd>239 <kbd>c</kbd>
240 <kbd>r</kbd>240 <kbd>r</kbd>
241 <kbd>m</kbd>241 <kbd>m</kbd>
242+<kbd>i</kbd>
242 </dt>243 </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>
244 </div>245 </div>
245 <div>246 <div>
246 <dt>247 <dt>
@@ -422,6 +423,7 @@
422 k-c from:body -> ermite.tab('c');423 k-c from:body -> ermite.tab('c');
423 k-r from:body -> ermite.tab('r');424 k-r from:body -> ermite.tab('r');
424 k-m from:body -> ermite.tab('m');425 k-m from:body -> ermite.tab('m');
426+ k-i from:body -> ermite.tab('i');
425 k-next from:body -> ermite.nextNote();427 k-next from:body -> ermite.nextNote();
426 k-split from:body -> ermite.layout('split');428 k-split from:body -> ermite.layout('split');
427 k-unified from:body -> ermite.layout('unified');429 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-subdir.html +3 -1
@@ -255,8 +255,9 @@
255 <kbd>c</kbd>255 <kbd>c</kbd>
256 <kbd>r</kbd>256 <kbd>r</kbd>
257 <kbd>m</kbd>257 <kbd>m</kbd>
258+<kbd>i</kbd>
258 </dt>259 </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>
260 </div>261 </div>
261 <div>262 <div>
262 <dt>263 <dt>
@@ -438,6 +439,7 @@
438 k-c from:body -> ermite.tab('c');439 k-c from:body -> ermite.tab('c');
439 k-r from:body -> ermite.tab('r');440 k-r from:body -> ermite.tab('r');
440 k-m from:body -> ermite.tab('m');441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441 k-next from:body -> ermite.nextNote();443 k-next from:body -> ermite.nextNote();
442 k-split from:body -> ermite.layout('split');444 k-split from:body -> ermite.layout('split');
443 k-unified from:body -> ermite.layout('unified');445 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
45 Repos: f.Repos,45 Repos: f.Repos,
46 Browse: f.Browse,46 Browse: f.Browse,
47 MergeRequests: f.MergeRequests,47 MergeRequests: f.MergeRequests,
48+ Issues: f.Issues,
49+ IssueNumbers: f.Issues,
50+ Inbox: f.Inbox,
51+ IssueForm: f.IssueSubmissions,
52+ Moderation: f.IssueModeration,
48 Runs: f.CI,53 Runs: f.CI,
49 Artifacts: artifacts,54 Artifacts: artifacts,
50 Events: f.EventLog,55 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 {
11 defer l.mu.Unlock()11 defer l.mu.Unlock()
12 return len(l.failures)12 return len(l.failures)
13 }13 }
14+
15+// Tracked returns how many clients have submissions on record.
16+func (l *SubmissionLimiter) Tracked() int {
17+ l.mu.Lock()
18+ defer l.mu.Unlock()
19+ return len(l.subs)
20+}
A internal/clientlimit/submissions.go +178 -0
@@ -0,0 +1,178 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit
4+
5+import (
6+ "context"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// Submission windows of the public issue form.
15+const (
16+ // HourWindow and DayWindow are the sliding windows of the limits
17+ // PerHour and PerDay.
18+ HourWindow = time.Hour
19+ DayWindow = 24 * time.Hour
20+
21+ // PerHour and PerDay are the submissions one client may make in each
22+ // window. They are the limits of the form (PerHour and
23+ // PerDay), repeated here because this package may not import
24+ // the domain; a test keeps them equal.
25+ PerHour = 3
26+ PerDay = 10
27+
28+ submissionSweepInterval = HourWindow / 4
29+)
30+
31+// SubmissionLimiter limits the submissions of the public issue form per client
32+// (an IPv4 address or an IPv6 /64, see Key): PerHour in a sliding
33+// hour and PerDay in a sliding day. It also counts the
34+// submissions of all clients in the last hour. It lives in memory, is bounded
35+// like Limiter, and is safe for concurrent use.
36+type SubmissionLimiter struct {
37+ now func() time.Time
38+
39+ mu sync.Mutex
40+ subs map[string][]time.Time // oldest first, all within DayWindow
41+ global []time.Time // oldest first, all within HourWindow
42+ lastSweep time.Time
43+}
44+
45+var _ ports.SubmissionLimiter = (*SubmissionLimiter)(nil)
46+
47+// NewSubmissionLimiter builds a limiter. now defaults to time.Now.
48+func NewSubmissionLimiter(now func() time.Time) *SubmissionLimiter {
49+ if now == nil {
50+ now = time.Now
51+ }
52+ return &SubmissionLimiter{now: now, subs: make(map[string][]time.Time)}
53+}
54+
55+// Reserve checks the limits for addr and, if it may submit now, counts a
56+// submission, atomically: concurrent callers cannot all pass the check. When
57+// addr may not submit, retryAfter is how long until the oldest submission that
58+// counts leaves its window and release is a no-op. Otherwise release refunds
59+// the reservation; call it only if the submission was not stored.
60+func (l *SubmissionLimiter) Reserve(_ context.Context, addr string) (ok bool, retryAfter time.Duration, release func()) {
61+ key := limiterKey(addr)
62+ now := l.now()
63+ l.mu.Lock()
64+ defer l.mu.Unlock()
65+ l.maintain(now)
66+ ts := l.prune(key, now)
67+ var wait time.Duration
68+ if h := withinWindow(ts, now, HourWindow); len(h) >= PerHour {
69+ wait = h[len(h)-PerHour].Add(HourWindow).Sub(now)
70+ }
71+ if len(ts) >= PerDay {
72+ wait = max(wait, ts[len(ts)-PerDay].Add(DayWindow).Sub(now))
73+ }
74+ if wait > 0 {
75+ return false, max(wait, time.Second), func() {}
76+ }
77+ ts = append(ts, now)
78+ if len(ts) > PerDay*2 {
79+ ts = ts[len(ts)-PerDay*2:] // enough to compute the wait
80+ }
81+ l.subs[key] = ts
82+ l.global = append(l.pruneGlobal(now), now)
83+ if len(l.global) > maxTracked {
84+ l.global = l.global[len(l.global)-maxTracked:] // bound the memory
85+ }
86+ var once sync.Once
87+ return true, 0, func() { once.Do(func() { l.refund(key, now) }) }
88+}
89+
90+// refund removes one reservation made at t for key.
91+func (l *SubmissionLimiter) refund(key string, t time.Time) {
92+ l.mu.Lock()
93+ defer l.mu.Unlock()
94+ if ts := l.subs[key]; ts != nil {
95+ l.subs[key] = removeLast(ts, t)
96+ if len(l.subs[key]) == 0 {
97+ delete(l.subs, key)
98+ }
99+ }
100+ l.global = removeLast(l.global, t)
101+}
102+
103+// removeLast removes the last element of ts equal to t, if any.
104+func removeLast(ts []time.Time, t time.Time) []time.Time {
105+ for i := len(ts) - 1; i >= 0; i-- {
106+ if ts[i].Equal(t) {
107+ return slices.Delete(ts, i, i+1)
108+ }
109+ }
110+ return ts
111+}
112+
113+// Recent returns the submissions of addr and of all clients in the last hour.
114+func (l *SubmissionLimiter) Recent(_ context.Context, addr string) (perAddr, global int) {
115+ key := limiterKey(addr)
116+ now := l.now()
117+ l.mu.Lock()
118+ defer l.mu.Unlock()
119+ return len(withinWindow(l.prune(key, now), now, HourWindow)), len(l.pruneGlobal(now))
120+}
121+
122+// withinWindow returns the tail of ts (oldest first) that is within window.
123+func withinWindow(ts []time.Time, now time.Time, window time.Duration) []time.Time {
124+ cut := now.Add(-window)
125+ i, _ := slices.BinarySearchFunc(ts, cut, func(t, c time.Time) int {
126+ if t.After(c) {
127+ return 1
128+ }
129+ return -1
130+ })
131+ return ts[i:]
132+}
133+
134+// prune drops the submissions of key older than DayWindow. The caller holds mu.
135+func (l *SubmissionLimiter) prune(key string, now time.Time) []time.Time {
136+ ts := withinWindow(l.subs[key], now, DayWindow)
137+ if len(ts) == 0 {
138+ delete(l.subs, key)
139+ return nil
140+ }
141+ l.subs[key] = ts
142+ return ts
143+}
144+
145+// pruneGlobal drops the global submissions older than HourWindow. The caller
146+// holds mu.
147+func (l *SubmissionLimiter) pruneGlobal(now time.Time) []time.Time {
148+ l.global = withinWindow(l.global, now, HourWindow)
149+ return l.global
150+}
151+
152+// maintain keeps the table small: expired entries go at most once per
153+// submissionSweepInterval, and a table at its bound loses its oldest tenth.
154+// The caller holds mu.
155+func (l *SubmissionLimiter) maintain(now time.Time) {
156+ if now.Sub(l.lastSweep) >= submissionSweepInterval {
157+ l.lastSweep = now
158+ for key := range l.subs {
159+ l.prune(key, now)
160+ }
161+ }
162+ if len(l.subs) < maxTracked {
163+ return
164+ }
165+ drop := len(l.subs) - (maxTracked - maxTracked/evictFraction)
166+ type entry struct {
167+ key string
168+ last time.Time
169+ }
170+ all := make([]entry, 0, len(l.subs))
171+ for key, ts := range l.subs {
172+ all = append(all, entry{key, ts[len(ts)-1]})
173+ }
174+ slices.SortFunc(all, func(a, b entry) int { return a.last.Compare(b.last) })
175+ for _, e := range all[:drop] {
176+ delete(l.subs, e.key)
177+ }
178+}
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 {
173 // EventKind implements Event.173 // EventKind implements Event.
174 func (CommentAdded) EventKind() string { return "mr.comment_added" }174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175 175
176+// IssueOpened is published when an issue is opened.
177+type IssueOpened struct {
178+ Issue Issue
179+ At time.Time
180+ // Visibility and CreatedAt are those of the repository when the event
181+ // was published, like PushEvent's.
182+ Visibility Visibility
183+ CreatedAt time.Time
184+}
185+
186+// EventKind implements Event.
187+func (IssueOpened) EventKind() string { return "issue.opened" }
188+
189+// IssueClosed is published when an issue was closed.
190+type IssueClosed struct {
191+ Issue Issue
192+ At time.Time
193+ Visibility Visibility
194+ CreatedAt time.Time
195+}
196+
197+// EventKind implements Event.
198+func (IssueClosed) EventKind() string { return "issue.closed" }
199+
200+// IssueReopened is published when a closed issue was reopened.
201+type IssueReopened struct {
202+ Issue Issue
203+ At time.Time
204+ Visibility Visibility
205+ CreatedAt time.Time
206+}
207+
208+// EventKind implements Event.
209+func (IssueReopened) EventKind() string { return "issue.reopened" }
210+
211+// IssueCommented is published when a comment was added to an issue.
212+type IssueCommented struct {
213+ Repo RepoName
214+ Number int64
215+ Comment IssueComment
216+ At time.Time
217+ Visibility Visibility
218+ CreatedAt time.Time
219+}
220+
221+// EventKind implements Event.
222+func (IssueCommented) EventKind() string { return "issue.commented" }
223+
224+// IssueSubmitted is published when an anonymous submission awaits moderation.
225+// It carries no content, and is delivered to the owner topic only: never to
226+// home or to a repository topic.
227+type IssueSubmitted struct {
228+ Repo RepoName
229+ ID PendingIssueID
230+ At time.Time
231+}
232+
233+// EventKind implements Event.
234+func (IssueSubmitted) EventKind() string { return "issue.submitted" }
235+
236+// IssuePendingRemoved is published when a pending submission is rejected, so
237+// the other open inboxes drop it. Like IssueSubmitted it carries no content
238+// and is delivered to the owner topic only.
239+type IssuePendingRemoved struct {
240+ Repo RepoName
241+ ID PendingIssueID
242+ At time.Time
243+}
244+
245+// EventKind implements Event.
246+func (IssuePendingRemoved) EventKind() string { return "issue.pending_removed" }
247+
176 // CommentEdited is published when the text of a note was edited.248 // CommentEdited is published when the text of a note was edited.
177 type CommentEdited struct {249 type CommentEdited struct {
178 Repo RepoName250 Repo RepoName
A internal/domain/issue.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+// IssuesMode says who may write issues in a repository.
11+type IssuesMode string
12+
13+// Issues modes.
14+const (
15+ // IssuesOff turns issues off: the default.
16+ IssuesOff IssuesMode = "off"
17+ // IssuesOwner lets the owner and tokens with ScopeIssueWrite write issues.
18+ IssuesOwner IssuesMode = "owner"
19+ // IssuesPublic also opens the repository to submissions from anyone.
20+ IssuesPublic IssuesMode = "public"
21+)
22+
23+// IssuesModes returns the three modes in their canonical order.
24+func IssuesModes() []IssuesMode { return []IssuesMode{IssuesOff, IssuesOwner, IssuesPublic} }
25+
26+// ParseIssuesMode parses "off", "owner" or "public".
27+func ParseIssuesMode(s string) (IssuesMode, error) {
28+ for _, m := range IssuesModes() {
29+ if string(m) == s {
30+ return m, nil
31+ }
32+ }
33+ return "", fmt.Errorf("%w: issues mode %q (want off, owner or public)", ErrInvalid, s)
34+}
35+
36+// String returns the mode as text.
37+func (m IssuesMode) String() string { return string(m) }
38+
39+// OrDefault returns IssuesOff for the empty mode, else m.
40+func (m IssuesMode) OrDefault() IssuesMode {
41+ if m == "" {
42+ return IssuesOff
43+ }
44+ return m
45+}
46+
47+// IssueState is where an issue is in its life.
48+type IssueState string
49+
50+// Issue states.
51+const (
52+ // IssueStateOpen is being worked on.
53+ IssueStateOpen IssueState = "open"
54+ // IssueStateClosed is done with; it can be reopened.
55+ IssueStateClosed IssueState = "closed"
56+)
57+
58+// Issue is a problem or a request about a repository. It shares its number
59+// sequence with the merge requests of the repository.
60+type Issue struct {
61+ Repo RepoName
62+ Number int64
63+ Title IssueTitle
64+ // Body may be empty.
65+ Body IssueBody
66+ State IssueState
67+ // Author is the label of the token that opened it, "root" or "owner".
68+ Author string
69+ CreatedAt time.Time
70+ UpdatedAt time.Time
71+ // CommentSeq is the id of the last comment of the issue.
72+ CommentSeq int64
73+ // External is true for an issue approved from the public form: its text
74+ // comes from an anonymous visitor and is rendered as untrusted.
75+ External bool
76+ // Version is set by the store on read: Update fails with ErrConflict when
77+ // the stored issue changed since. Opaque to everyone else.
78+ Version uint64
79+}
80+
81+// IsOpen reports whether the issue is open.
82+func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
83+
84+// IssueComment is a comment on an issue.
85+type IssueComment struct {
86+ ID int64
87+ Author string
88+ Text IssueBody
89+ CreatedAt time.Time
90+ // Version is set by the store on read, like Issue's.
91+ Version uint64
92+}
93+
94+// NumberKind says what a number of a repository names. Issues and merge
95+// requests share one sequence, so a number names at most one of them.
96+type NumberKind int
97+
98+// Number kinds.
99+const (
100+ // NumberNone: no issue or merge request has this number.
101+ NumberNone NumberKind = iota
102+ // NumberIssue: the number names an issue.
103+ NumberIssue
104+ // NumberMergeRequest: the number names a merge request.
105+ NumberMergeRequest
106+)
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 {
43 AllowedMergeModes []MergeMode43 AllowedMergeModes []MergeMode
44 // FailingChecksBlock says that a failing check prevents the merge.44 // FailingChecksBlock says that a failing check prevents the merge.
45 FailingChecksBlock bool45 FailingChecksBlock bool
46+ // Issues says who may write issues; the empty mode means IssuesOff.
47+ Issues IssuesMode
46 }48 }
47 49
48 // DefaultRepoSettings are the settings of a new repository: both modes, and50 // DefaultRepoSettings are the settings of a new repository: both modes, and
49 // failing checks block the merge.51 // failing checks block the merge.
50 func DefaultRepoSettings() RepoSettings {52 func DefaultRepoSettings() RepoSettings {
51- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true}53+ return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
52 }54 }
53 55
54 // IsZero reports whether s holds no mode, which only the zero value does.56 // IsZero reports whether s holds no mode, which only the zero value does.
A internal/domain/pending_issue.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/rand"
7+ "encoding/base32"
8+ "fmt"
9+ "strings"
10+ "time"
11+ "unicode/utf8"
12+)
13+
14+// Limits of the public issue form (ADR 0031).
15+const (
16+ // MaxPendingIssues is the most issues awaiting moderation, over every
17+ // repository. Past it submissions are refused.
18+ MaxPendingIssues = 200
19+ // SubmitPerHour and SubmitPerDay are the submissions one client may make
20+ // in a sliding hour and a sliding day.
21+ SubmitPerHour = 3
22+ SubmitPerDay = 10
23+ // BanDuration is how long a rejected submitter stays banned.
24+ BanDuration = 30 * 24 * time.Hour
25+ // MaxPendingAuthorLen is the longest display name of a submitter, in
26+ // code points.
27+ MaxPendingAuthorLen = 40
28+ // PendingIssueIDLen is the length of a pending issue id: 16 random bytes
29+ // in unpadded lowercase base32.
30+ PendingIssueIDLen = 26
31+ // IPHashLen is the size of the HMAC of a client key.
32+ IPHashLen = 32
33+)
34+
35+var pendingIDEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
36+
37+// PendingIssueID identifies an issue awaiting moderation. It is random and
38+// opaque, not a number: a pending issue has none yet.
39+type PendingIssueID string
40+
41+// NewPendingIssueID returns a fresh random id.
42+func NewPendingIssueID() (PendingIssueID, error) {
43+ var b [16]byte
44+ if _, err := rand.Read(b[:]); err != nil {
45+ return "", fmt.Errorf("generate pending issue id: %w", err)
46+ }
47+ return PendingIssueID(strings.ToLower(pendingIDEncoding.EncodeToString(b[:]))), nil
48+}
49+
50+// ParsePendingIssueID accepts exactly what NewPendingIssueID produces: 26
51+// characters of a-z and 2-7. Anything else gives an error wrapping ErrInvalid.
52+func ParsePendingIssueID(s string) (PendingIssueID, error) {
53+ if len(s) != PendingIssueIDLen {
54+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
55+ }
56+ for i := range len(s) {
57+ c := s[i]
58+ if (c < 'a' || c > 'z') && (c < '2' || c > '7') {
59+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
60+ }
61+ }
62+ // 26 base32 characters carry 130 bits; the last one must not set the two
63+ // spare bits, or the id would not round-trip.
64+ raw, err := pendingIDEncoding.DecodeString(strings.ToUpper(s))
65+ if err != nil || len(raw) != 16 || strings.ToLower(pendingIDEncoding.EncodeToString(raw)) != s {
66+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
67+ }
68+ return PendingIssueID(s), nil
69+}
70+
71+// String returns the id as text.
72+func (id PendingIssueID) String() string { return string(id) }
73+
74+// ParsePendingAuthor validates the optional display name of a submitter: valid
75+// UTF-8, one line, at most MaxPendingAuthorLen code points, normalized.
76+// The empty name is valid.
77+func ParsePendingAuthor(s string) (string, error) {
78+ if !utf8.ValidString(s) {
79+ return "", &TextError{Reason: "is not valid UTF-8"}
80+ }
81+ c := cleanText(s, false)
82+ if n := utf8.RuneCountInString(c); n > MaxPendingAuthorLen {
83+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxPendingAuthorLen, n)}
84+ }
85+ return c, nil
86+}
87+
88+// AnonymousAuthor labels an approved issue whose submitter gave no name.
89+const AnonymousAuthor = "anonymous"
90+
91+// ApprovedIssue is the open, external issue a pending submission becomes when
92+// the owner approves it at now. The store sets the number. A submitter who gave
93+// no name is AnonymousAuthor; the issue keeps the submission time as its
94+// creation time.
95+func ApprovedIssue(p PendingIssue, now time.Time) Issue {
96+ author := p.Author
97+ if author == "" {
98+ author = AnonymousAuthor
99+ }
100+ return Issue{
101+ Repo: p.Repo, Title: p.Title, Body: p.Body, State: IssueStateOpen,
102+ Author: author, CreatedAt: p.CreatedAt, UpdatedAt: now, External: true,
103+ }
104+}
105+
106+// PendingIssue is an issue submitted through the public form, awaiting the
107+// owner's decision. It has no number: approval gives it one.
108+type PendingIssue struct {
109+ ID PendingIssueID
110+ Repo RepoName
111+ Title IssueTitle
112+ Body IssueBody
113+ // Author is the display name the submitter gave; it may be empty and is
114+ // not verified.
115+ Author string
116+ CreatedAt time.Time
117+ // IPHash is the HMAC-SHA256 of the client key, IPHashLen bytes. It lets
118+ // the owner ban a submitter without the server keeping an address.
119+ IPHash []byte
120+ // Version is set by the store on read; opaque to everyone else.
121+ Version uint64
122+}
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
5 import (5 import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "strings"
8 "unicode"9 "unicode"
9 "unicode/utf8"10 "unicode/utf8"
11+
12+ "golang.org/x/text/unicode/norm"
10 )13 )
11 14
12 // Limits of the plain text of a merge request (ADR 0017), in characters.15 // Limits of the plain text of a merge request (ADR 0017), in characters.
@@ -178,3 +181,102 @@ func (v *Violations) Err() error {
178 }181 }
179 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}182 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
180 }183 }
184+
185+// Limits of the text of an issue, in code points.
186+const (
187+ // MaxIssueTitleLen is the longest issue title.
188+ MaxIssueTitleLen = 120
189+ // MaxIssueBodyLen is the longest issue body or issue comment.
190+ MaxIssueBodyLen = 10000
191+ // MaxAuthorLabelLen is the longest author label.
192+ MaxAuthorLabelLen = 40
193+)
194+
195+// IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code
196+// points, normalized. Only ParseIssueTitle builds a non-zero value.
197+type IssueTitle struct{ s string }
198+
199+// String returns the title.
200+func (t IssueTitle) String() string { return t.s }
201+
202+// IsZero reports whether t is the zero value.
203+func (t IssueTitle) IsZero() bool { return t.s == "" }
204+
205+// IssueBody is a validated issue body or issue comment: at most
206+// MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The
207+// zero value is the empty text.
208+type IssueBody struct{ s string }
209+
210+// String returns the text.
211+func (b IssueBody) String() string { return b.s }
212+
213+// IsBlank reports whether the text is empty or only white space.
214+func (b IssueBody) IsBlank() bool { return isBlank(b.s) }
215+
216+// ParseIssueTitle cleans s (see cleanText) and validates it: one line, not
217+// blank, at most MaxIssueTitleLen code points. A line break is refused, not
218+// removed, so that two lines are never glued into one.
219+func ParseIssueTitle(s string) (IssueTitle, error) {
220+ if !utf8.ValidString(s) {
221+ return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"}
222+ }
223+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
224+ return IssueTitle{}, &TextError{Reason: "must be one line"}
225+ }
226+ c := strings.TrimSpace(cleanText(s, false))
227+ if c == "" {
228+ return IssueTitle{}, &TextError{Reason: "is empty"}
229+ }
230+ if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen {
231+ return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)}
232+ }
233+ return IssueTitle{s: c}, nil
234+}
235+
236+// ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and
237+// validates it: at most MaxIssueBodyLen code points. The empty text is valid.
238+func ParseIssueBody(s string) (IssueBody, error) {
239+ if !utf8.ValidString(s) {
240+ return IssueBody{}, &TextError{Reason: "is not valid UTF-8"}
241+ }
242+ c := cleanText(s, true)
243+ if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen {
244+ return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)}
245+ }
246+ return IssueBody{s: c}, nil
247+}
248+
249+// ParseAuthorLabel cleans s like a title (one line) and validates it: at most
250+// MaxAuthorLabelLen code points. The empty label is valid.
251+func ParseAuthorLabel(s string) (string, error) {
252+ if !utf8.ValidString(s) {
253+ return "", &TextError{Reason: "is not valid UTF-8"}
254+ }
255+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
256+ return "", &TextError{Reason: "must be one line"}
257+ }
258+ c := strings.TrimSpace(cleanText(s, false))
259+ if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen {
260+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)}
261+ }
262+ return c, nil
263+}
264+
265+// cleanText normalizes s to NFC and removes the control characters (C0, C1 and
266+// DEL; with multiline, line feeds and tabs stay) and the bidirectional marks,
267+// embeddings, overrides and isolates. s must be valid UTF-8.
268+func cleanText(s string, multiline bool) string {
269+ s = norm.NFC.String(s)
270+ if multiline {
271+ s = strings.ReplaceAll(s, "\r\n", "\n")
272+ }
273+ return strings.Map(func(r rune) rune {
274+ switch {
275+ case multiline && (r == '\n' || r == '\t'):
276+ return r
277+ case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029':
278+ return -1
279+ }
280+ return r
281+ }, s)
282+}
M internal/domain/token.go +5 -1
@@ -40,15 +40,19 @@ const (
40 ScopeRunnerRegister Scope = "runner:register"40 ScopeRunnerRegister Scope = "runner:register"
41 // ScopeTokenAdmin creates, lists and revokes stored tokens.41 // ScopeTokenAdmin creates, lists and revokes stored tokens.
42 ScopeTokenAdmin Scope = "token:admin"42 ScopeTokenAdmin Scope = "token:admin"
43+ // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation
44+ // is not part of it: it stays with the owner.
45+ ScopeIssueWrite Scope = "issue:write"
43 )46 )
44 47
45-// AllScopes returns the ten scopes in a stable order.48+// AllScopes returns the eleven scopes in a stable order.
46 func AllScopes() []Scope {49 func AllScopes() []Scope {
47 return []Scope{50 return []Scope{
48 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,51 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
49 ScopeMRWrite, ScopeMRMerge, ScopeMRReview,52 ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
50 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,53 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
51 ScopeTokenAdmin,54 ScopeTokenAdmin,
55+ ScopeIssueWrite,
52 }56 }
53 }57 }
54 58
M internal/domain/token_test.go +6 -1
@@ -16,7 +16,11 @@ import (
16 func TestScopes(t *testing.T) {16 func TestScopes(t *testing.T) {
17 t.Parallel()17 t.Parallel()
18 all := domain.AllScopes()18 all := domain.AllScopes()
19- assert.Len(t, all, 10)19+ assert.Len(t, all, 11)
20+ assert.Contains(t, all, domain.ScopeIssueWrite)
21+ got, err := domain.ParseScope("issue:write")
22+ require.NoError(t, err)
23+ assert.Equal(t, domain.ScopeIssueWrite, got)
20 for _, s := range all {24 for _, s := range all {
21 got, err := domain.ParseScope(s.String())25 got, err := domain.ParseScope(s.String())
22 require.NoError(t, err)26 require.NoError(t, err)
@@ -110,6 +114,7 @@ func TestHasOwnerRights(t *testing.T) {
110 assert.False(t, domain.Anonymous().HasOwnerRights())114 assert.False(t, domain.Anonymous().HasOwnerRights())
111 assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights())115 assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights())
112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")116 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
117+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:9]}).HasOwnerRights(), "without issue:write")
113 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")118 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
114 }119 }
115 120
M internal/ports/dto.go +69 -0
@@ -49,6 +49,8 @@ type UpdateRepoParams struct {
49 AllowedMergeModes *[]domain.MergeMode49 AllowedMergeModes *[]domain.MergeMode
50 // FailingChecksBlock replaces the setting of that name.50 // FailingChecksBlock replaces the setting of that name.
51 FailingChecksBlock *bool51 FailingChecksBlock *bool
52+ // Issues replaces the issues mode.
53+ Issues *domain.IssuesMode
52 }54 }
53 55
54 // TokenPage is a page of stored tokens, newest first.56 // TokenPage is a page of stored tokens, newest first.
@@ -198,6 +200,52 @@ type MergeRequestPage struct {
198 NextPageToken string200 NextPageToken string
199 }201 }
200 202
203+// ListIssuesParams selects a page of issues from an IssueStore.
204+type ListIssuesParams struct {
205+ // State restricts the result; empty means every state.
206+ State domain.IssueState
207+ // PageSize is the most issues returned; 0 or less means no limit. The
208+ // use case chooses it.
209+ PageSize int
210+ // PageToken is the NextPageToken of the previous page, or empty.
211+ PageToken string
212+}
213+
214+// IssuePage is a page of issues, highest number first.
215+type IssuePage struct {
216+ Issues []domain.Issue
217+ // NextPageToken is empty on the last page.
218+ NextPageToken string
219+}
220+
221+// ListPendingParams selects a page of pending issues.
222+type ListPendingParams struct {
223+ // PageSize is the page size; the use case applies the default and the
224+ // maximum, a store takes it as is (0: every one).
225+ PageSize int
226+ // PageToken is the NextPageToken of the previous page.
227+ PageToken string
228+}
229+
230+// PendingIssuePage is a page of pending issues, oldest first.
231+type PendingIssuePage struct {
232+ Issues []domain.PendingIssue
233+ NextPageToken string
234+}
235+
236+// CreateIssueParams describes the issue to open.
237+type CreateIssueParams struct {
238+ Title string
239+ Body string
240+}
241+
242+// IssueDetail is an issue with its comments.
243+type IssueDetail struct {
244+ Issue domain.Issue
245+ // Comments are oldest first.
246+ Comments []domain.IssueComment
247+}
248+
201 // MergeRequestDetail is a merge request with its review data.249 // MergeRequestDetail is a merge request with its review data.
202 type MergeRequestDetail struct {250 type MergeRequestDetail struct {
203 MergeRequest domain.MergeRequest251 MergeRequest domain.MergeRequest
@@ -282,3 +330,24 @@ type LoginLink struct {
282 URL string330 URL string
283 ExpiresAt time.Time331 ExpiresAt time.Time
284 }332 }
333+
334+// InboxCounts are the numbers of open items across every repository, whatever
335+// the filter.
336+type InboxCounts struct{ MergeRequests, Issues, Pending int }
337+
338+// InboxOverview is what the owner has to look at across every repository: the
339+// open merge requests and the open issues, most recently updated first. A
340+// section for the items awaiting moderation can be added next to them.
341+type InboxOverview struct {
342+ Counts InboxCounts
343+ // MergeRequests and Issues are those of the filtered repository, or of
344+ // every repository when there is no filter. Counts always counts all.
345+ MergeRequests []domain.MergeRequest
346+ Issues []domain.Issue
347+ // Pending are the issues awaiting moderation, in every repository
348+ // whatever the filter, oldest first. At most domain.MaxPendingIssues.
349+ Pending []domain.PendingIssue
350+ // Repos are the repositories that have at least one open item, by name,
351+ // whatever the filter: they feed the filter itself.
352+ Repos []domain.RepoName
353+}
A internal/ports/mocks/InboxService.go +121 -0
@@ -0,0 +1,121 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewInboxService creates a new instance of InboxService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewInboxService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *InboxService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &InboxService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// InboxService is an autogenerated mock type for the InboxService type
39+type InboxService struct {
40+ mock.Mock
41+}
42+
43+type InboxService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *InboxService) EXPECT() *InboxService_Expecter {
48+ return &InboxService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Overview provides a mock function for the type InboxService
52+func (_mock *InboxService) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
53+ ret := _mock.Called(ctx, p, filter)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Overview")
57+ }
58+
59+ var r0 ports.InboxOverview
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.InboxOverview, error)); ok {
62+ return returnFunc(ctx, p, filter)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.InboxOverview); ok {
65+ r0 = returnFunc(ctx, p, filter)
66+ } else {
67+ r0 = ret.Get(0).(ports.InboxOverview)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
70+ r1 = returnFunc(ctx, p, filter)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// InboxService_Overview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Overview'
78+type InboxService_Overview_Call struct {
79+ *mock.Call
80+}
81+
82+// Overview is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - filter domain.RepoName
86+func (_e *InboxService_Expecter) Overview(ctx any, p any, filter any) *InboxService_Overview_Call {
87+ return &InboxService_Overview_Call{Call: _e.mock.On("Overview", ctx, p, filter)}
88+}
89+
90+func (_c *InboxService_Overview_Call) Run(run func(ctx context.Context, p domain.Principal, filter domain.RepoName)) *InboxService_Overview_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ var arg2 domain.RepoName
101+ if args[2] != nil {
102+ arg2 = args[2].(domain.RepoName)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *InboxService_Overview_Call) Return(inboxOverview ports.InboxOverview, err error) *InboxService_Overview_Call {
114+ _c.Call.Return(inboxOverview, err)
115+ return _c
116+}
117+
118+func (_c *InboxService_Overview_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error)) *InboxService_Overview_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
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
611 return _c611 return _c
612 }612 }
613 613
614+// ListOpenAll provides a mock function for the type MergeRequestStore
615+func (_mock *MergeRequestStore) ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error) {
616+ ret := _mock.Called(ctx)
617+
618+ if len(ret) == 0 {
619+ panic("no return value specified for ListOpenAll")
620+ }
621+
622+ var r0 []domain.MergeRequest
623+ var r1 error
624+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.MergeRequest, error)); ok {
625+ return returnFunc(ctx)
626+ }
627+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.MergeRequest); ok {
628+ r0 = returnFunc(ctx)
629+ } else {
630+ if ret.Get(0) != nil {
631+ r0 = ret.Get(0).([]domain.MergeRequest)
632+ }
633+ }
634+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
635+ r1 = returnFunc(ctx)
636+ } else {
637+ r1 = ret.Error(1)
638+ }
639+ return r0, r1
640+}
641+
642+// MergeRequestStore_ListOpenAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenAll'
643+type MergeRequestStore_ListOpenAll_Call struct {
644+ *mock.Call
645+}
646+
647+// ListOpenAll is a helper method to define mock.On call
648+// - ctx context.Context
649+func (_e *MergeRequestStore_Expecter) ListOpenAll(ctx any) *MergeRequestStore_ListOpenAll_Call {
650+ return &MergeRequestStore_ListOpenAll_Call{Call: _e.mock.On("ListOpenAll", ctx)}
651+}
652+
653+func (_c *MergeRequestStore_ListOpenAll_Call) Run(run func(ctx context.Context)) *MergeRequestStore_ListOpenAll_Call {
654+ _c.Call.Run(func(args mock.Arguments) {
655+ var arg0 context.Context
656+ if args[0] != nil {
657+ arg0 = args[0].(context.Context)
658+ }
659+ run(
660+ arg0,
661+ )
662+ })
663+ return _c
664+}
665+
666+func (_c *MergeRequestStore_ListOpenAll_Call) Return(mergeRequests []domain.MergeRequest, err error) *MergeRequestStore_ListOpenAll_Call {
667+ _c.Call.Return(mergeRequests, err)
668+ return _c
669+}
670+
671+func (_c *MergeRequestStore_ListOpenAll_Call) RunAndReturn(run func(ctx context.Context) ([]domain.MergeRequest, error)) *MergeRequestStore_ListOpenAll_Call {
672+ _c.Call.Return(run)
673+ return _c
674+}
675+
614 // OpenBySource provides a mock function for the type MergeRequestStore676 // OpenBySource provides a mock function for the type MergeRequestStore
615 func (_mock *MergeRequestStore) OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) ([]int64, bool, error) {677 func (_mock *MergeRequestStore) OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) ([]int64, bool, error) {
616 ret := _mock.Called(ctx, repo, branches, limit)678 ret := _mock.Called(ctx, repo, branches, limit)
A internal/ports/mocks/PendingIssueStore.go +452 -0
@@ -0,0 +1,452 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewPendingIssueStore creates a new instance of PendingIssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewPendingIssueStore(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *PendingIssueStore {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &PendingIssueStore{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// PendingIssueStore is an autogenerated mock type for the PendingIssueStore type
40+type PendingIssueStore struct {
41+ mock.Mock
42+}
43+
44+type PendingIssueStore_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *PendingIssueStore) EXPECT() *PendingIssueStore_Expecter {
49+ return &PendingIssueStore_Expecter{mock: &_m.Mock}
50+}
51+
52+// Approve provides a mock function for the type PendingIssueStore
53+func (_mock *PendingIssueStore) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
54+ ret := _mock.Called(ctx, repo, id, now)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for Approve")
58+ }
59+
60+ var r0 domain.Issue
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) (domain.Issue, error)); ok {
63+ return returnFunc(ctx, repo, id, now)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) domain.Issue); ok {
66+ r0 = returnFunc(ctx, repo, id, now)
67+ } else {
68+ r0 = ret.Get(0).(domain.Issue)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.PendingIssueID, time.Time) error); ok {
71+ r1 = returnFunc(ctx, repo, id, now)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// PendingIssueStore_Approve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Approve'
79+type PendingIssueStore_Approve_Call struct {
80+ *mock.Call
81+}
82+
83+// Approve is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - id domain.PendingIssueID
87+// - now time.Time
88+func (_e *PendingIssueStore_Expecter) Approve(ctx any, repo any, id any, now any) *PendingIssueStore_Approve_Call {
89+ return &PendingIssueStore_Approve_Call{Call: _e.mock.On("Approve", ctx, repo, id, now)}
90+}
91+
92+func (_c *PendingIssueStore_Approve_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time)) *PendingIssueStore_Approve_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 domain.PendingIssueID
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.PendingIssueID)
105+ }
106+ var arg3 time.Time
107+ if args[3] != nil {
108+ arg3 = args[3].(time.Time)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *PendingIssueStore_Approve_Call) Return(issue domain.Issue, err error) *PendingIssueStore_Approve_Call {
121+ _c.Call.Return(issue, err)
122+ return _c
123+}
124+
125+func (_c *PendingIssueStore_Approve_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error)) *PendingIssueStore_Approve_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// Count provides a mock function for the type PendingIssueStore
131+func (_mock *PendingIssueStore) Count(ctx context.Context) (int, error) {
132+ ret := _mock.Called(ctx)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for Count")
136+ }
137+
138+ var r0 int
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context) (int, error)); ok {
141+ return returnFunc(ctx)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context) int); ok {
144+ r0 = returnFunc(ctx)
145+ } else {
146+ r0 = ret.Get(0).(int)
147+ }
148+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
149+ r1 = returnFunc(ctx)
150+ } else {
151+ r1 = ret.Error(1)
152+ }
153+ return r0, r1
154+}
155+
156+// PendingIssueStore_Count_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Count'
157+type PendingIssueStore_Count_Call struct {
158+ *mock.Call
159+}
160+
161+// Count is a helper method to define mock.On call
162+// - ctx context.Context
163+func (_e *PendingIssueStore_Expecter) Count(ctx any) *PendingIssueStore_Count_Call {
164+ return &PendingIssueStore_Count_Call{Call: _e.mock.On("Count", ctx)}
165+}
166+
167+func (_c *PendingIssueStore_Count_Call) Run(run func(ctx context.Context)) *PendingIssueStore_Count_Call {
168+ _c.Call.Run(func(args mock.Arguments) {
169+ var arg0 context.Context
170+ if args[0] != nil {
171+ arg0 = args[0].(context.Context)
172+ }
173+ run(
174+ arg0,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *PendingIssueStore_Count_Call) Return(n int, err error) *PendingIssueStore_Count_Call {
181+ _c.Call.Return(n, err)
182+ return _c
183+}
184+
185+func (_c *PendingIssueStore_Count_Call) RunAndReturn(run func(ctx context.Context) (int, error)) *PendingIssueStore_Count_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
189+
190+// Delete provides a mock function for the type PendingIssueStore
191+func (_mock *PendingIssueStore) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
192+ ret := _mock.Called(ctx, repo, id)
193+
194+ if len(ret) == 0 {
195+ panic("no return value specified for Delete")
196+ }
197+
198+ var r0 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) error); ok {
200+ r0 = returnFunc(ctx, repo, id)
201+ } else {
202+ r0 = ret.Error(0)
203+ }
204+ return r0
205+}
206+
207+// PendingIssueStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
208+type PendingIssueStore_Delete_Call struct {
209+ *mock.Call
210+}
211+
212+// Delete is a helper method to define mock.On call
213+// - ctx context.Context
214+// - repo domain.RepoName
215+// - id domain.PendingIssueID
216+func (_e *PendingIssueStore_Expecter) Delete(ctx any, repo any, id any) *PendingIssueStore_Delete_Call {
217+ return &PendingIssueStore_Delete_Call{Call: _e.mock.On("Delete", ctx, repo, id)}
218+}
219+
220+func (_c *PendingIssueStore_Delete_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID)) *PendingIssueStore_Delete_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 context.Context
223+ if args[0] != nil {
224+ arg0 = args[0].(context.Context)
225+ }
226+ var arg1 domain.RepoName
227+ if args[1] != nil {
228+ arg1 = args[1].(domain.RepoName)
229+ }
230+ var arg2 domain.PendingIssueID
231+ if args[2] != nil {
232+ arg2 = args[2].(domain.PendingIssueID)
233+ }
234+ run(
235+ arg0,
236+ arg1,
237+ arg2,
238+ )
239+ })
240+ return _c
241+}
242+
243+func (_c *PendingIssueStore_Delete_Call) Return(err error) *PendingIssueStore_Delete_Call {
244+ _c.Call.Return(err)
245+ return _c
246+}
247+
248+func (_c *PendingIssueStore_Delete_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error) *PendingIssueStore_Delete_Call {
249+ _c.Call.Return(run)
250+ return _c
251+}
252+
253+// Get provides a mock function for the type PendingIssueStore
254+func (_mock *PendingIssueStore) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
255+ ret := _mock.Called(ctx, repo, id)
256+
257+ if len(ret) == 0 {
258+ panic("no return value specified for Get")
259+ }
260+
261+ var r0 domain.PendingIssue
262+ var r1 error
263+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) (domain.PendingIssue, error)); ok {
264+ return returnFunc(ctx, repo, id)
265+ }
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.PendingIssueID) domain.PendingIssue); ok {
267+ r0 = returnFunc(ctx, repo, id)
268+ } else {
269+ r0 = ret.Get(0).(domain.PendingIssue)
270+ }
271+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.PendingIssueID) error); ok {
272+ r1 = returnFunc(ctx, repo, id)
273+ } else {
274+ r1 = ret.Error(1)
275+ }
276+ return r0, r1
277+}
278+
279+// PendingIssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
280+type PendingIssueStore_Get_Call struct {
281+ *mock.Call
282+}
283+
284+// Get is a helper method to define mock.On call
285+// - ctx context.Context
286+// - repo domain.RepoName
287+// - id domain.PendingIssueID
288+func (_e *PendingIssueStore_Expecter) Get(ctx any, repo any, id any) *PendingIssueStore_Get_Call {
289+ return &PendingIssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, id)}
290+}
291+
292+func (_c *PendingIssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID)) *PendingIssueStore_Get_Call {
293+ _c.Call.Run(func(args mock.Arguments) {
294+ var arg0 context.Context
295+ if args[0] != nil {
296+ arg0 = args[0].(context.Context)
297+ }
298+ var arg1 domain.RepoName
299+ if args[1] != nil {
300+ arg1 = args[1].(domain.RepoName)
301+ }
302+ var arg2 domain.PendingIssueID
303+ if args[2] != nil {
304+ arg2 = args[2].(domain.PendingIssueID)
305+ }
306+ run(
307+ arg0,
308+ arg1,
309+ arg2,
310+ )
311+ })
312+ return _c
313+}
314+
315+func (_c *PendingIssueStore_Get_Call) Return(pendingIssue domain.PendingIssue, err error) *PendingIssueStore_Get_Call {
316+ _c.Call.Return(pendingIssue, err)
317+ return _c
318+}
319+
320+func (_c *PendingIssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error)) *PendingIssueStore_Get_Call {
321+ _c.Call.Return(run)
322+ return _c
323+}
324+
325+// List provides a mock function for the type PendingIssueStore
326+func (_mock *PendingIssueStore) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
327+ ret := _mock.Called(ctx, repo, page)
328+
329+ if len(ret) == 0 {
330+ panic("no return value specified for List")
331+ }
332+
333+ var r0 ports.PendingIssuePage
334+ var r1 error
335+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListPendingParams) (ports.PendingIssuePage, error)); ok {
336+ return returnFunc(ctx, repo, page)
337+ }
338+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListPendingParams) ports.PendingIssuePage); ok {
339+ r0 = returnFunc(ctx, repo, page)
340+ } else {
341+ r0 = ret.Get(0).(ports.PendingIssuePage)
342+ }
343+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListPendingParams) error); ok {
344+ r1 = returnFunc(ctx, repo, page)
345+ } else {
346+ r1 = ret.Error(1)
347+ }
348+ return r0, r1
349+}
350+
351+// PendingIssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
352+type PendingIssueStore_List_Call struct {
353+ *mock.Call
354+}
355+
356+// List is a helper method to define mock.On call
357+// - ctx context.Context
358+// - repo domain.RepoName
359+// - page ports.ListPendingParams
360+func (_e *PendingIssueStore_Expecter) List(ctx any, repo any, page any) *PendingIssueStore_List_Call {
361+ return &PendingIssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, page)}
362+}
363+
364+func (_c *PendingIssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams)) *PendingIssueStore_List_Call {
365+ _c.Call.Run(func(args mock.Arguments) {
366+ var arg0 context.Context
367+ if args[0] != nil {
368+ arg0 = args[0].(context.Context)
369+ }
370+ var arg1 domain.RepoName
371+ if args[1] != nil {
372+ arg1 = args[1].(domain.RepoName)
373+ }
374+ var arg2 ports.ListPendingParams
375+ if args[2] != nil {
376+ arg2 = args[2].(ports.ListPendingParams)
377+ }
378+ run(
379+ arg0,
380+ arg1,
381+ arg2,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *PendingIssueStore_List_Call) Return(pendingIssuePage ports.PendingIssuePage, err error) *PendingIssueStore_List_Call {
388+ _c.Call.Return(pendingIssuePage, err)
389+ return _c
390+}
391+
392+func (_c *PendingIssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error)) *PendingIssueStore_List_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// Put provides a mock function for the type PendingIssueStore
398+func (_mock *PendingIssueStore) Put(ctx context.Context, p domain.PendingIssue) error {
399+ ret := _mock.Called(ctx, p)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for Put")
403+ }
404+
405+ var r0 error
406+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.PendingIssue) error); ok {
407+ r0 = returnFunc(ctx, p)
408+ } else {
409+ r0 = ret.Error(0)
410+ }
411+ return r0
412+}
413+
414+// PendingIssueStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
415+type PendingIssueStore_Put_Call struct {
416+ *mock.Call
417+}
418+
419+// Put is a helper method to define mock.On call
420+// - ctx context.Context
421+// - p domain.PendingIssue
422+func (_e *PendingIssueStore_Expecter) Put(ctx any, p any) *PendingIssueStore_Put_Call {
423+ return &PendingIssueStore_Put_Call{Call: _e.mock.On("Put", ctx, p)}
424+}
425+
426+func (_c *PendingIssueStore_Put_Call) Run(run func(ctx context.Context, p domain.PendingIssue)) *PendingIssueStore_Put_Call {
427+ _c.Call.Run(func(args mock.Arguments) {
428+ var arg0 context.Context
429+ if args[0] != nil {
430+ arg0 = args[0].(context.Context)
431+ }
432+ var arg1 domain.PendingIssue
433+ if args[1] != nil {
434+ arg1 = args[1].(domain.PendingIssue)
435+ }
436+ run(
437+ arg0,
438+ arg1,
439+ )
440+ })
441+ return _c
442+}
443+
444+func (_c *PendingIssueStore_Put_Call) Return(err error) *PendingIssueStore_Put_Call {
445+ _c.Call.Return(err)
446+ return _c
447+}
448+
449+func (_c *PendingIssueStore_Put_Call) RunAndReturn(run func(ctx context.Context, p domain.PendingIssue) error) *PendingIssueStore_Put_Call {
450+ _c.Call.Return(run)
451+ return _c
452+}
A internal/ports/mocks/SubmissionLimiter.go +188 -0
@@ -0,0 +1,188 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewSubmissionLimiter creates a new instance of SubmissionLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewSubmissionLimiter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *SubmissionLimiter {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &SubmissionLimiter{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// SubmissionLimiter is an autogenerated mock type for the SubmissionLimiter type
38+type SubmissionLimiter struct {
39+ mock.Mock
40+}
41+
42+type SubmissionLimiter_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *SubmissionLimiter) EXPECT() *SubmissionLimiter_Expecter {
47+ return &SubmissionLimiter_Expecter{mock: &_m.Mock}
48+}
49+
50+// Recent provides a mock function for the type SubmissionLimiter
51+func (_mock *SubmissionLimiter) Recent(ctx context.Context, addr string) (int, int) {
52+ ret := _mock.Called(ctx, addr)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Recent")
56+ }
57+
58+ var r0 int
59+ var r1 int
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int, int)); ok {
61+ return returnFunc(ctx, addr)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) int); ok {
64+ r0 = returnFunc(ctx, addr)
65+ } else {
66+ r0 = ret.Get(0).(int)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) int); ok {
69+ r1 = returnFunc(ctx, addr)
70+ } else {
71+ r1 = ret.Get(1).(int)
72+ }
73+ return r0, r1
74+}
75+
76+// SubmissionLimiter_Recent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Recent'
77+type SubmissionLimiter_Recent_Call struct {
78+ *mock.Call
79+}
80+
81+// Recent is a helper method to define mock.On call
82+// - ctx context.Context
83+// - addr string
84+func (_e *SubmissionLimiter_Expecter) Recent(ctx any, addr any) *SubmissionLimiter_Recent_Call {
85+ return &SubmissionLimiter_Recent_Call{Call: _e.mock.On("Recent", ctx, addr)}
86+}
87+
88+func (_c *SubmissionLimiter_Recent_Call) Run(run func(ctx context.Context, addr string)) *SubmissionLimiter_Recent_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 string
95+ if args[1] != nil {
96+ arg1 = args[1].(string)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *SubmissionLimiter_Recent_Call) Return(perAddr int, global int) *SubmissionLimiter_Recent_Call {
107+ _c.Call.Return(perAddr, global)
108+ return _c
109+}
110+
111+func (_c *SubmissionLimiter_Recent_Call) RunAndReturn(run func(ctx context.Context, addr string) (int, int)) *SubmissionLimiter_Recent_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
115+
116+// Reserve provides a mock function for the type SubmissionLimiter
117+func (_mock *SubmissionLimiter) Reserve(ctx context.Context, addr string) (bool, time.Duration, func()) {
118+ ret := _mock.Called(ctx, addr)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for Reserve")
122+ }
123+
124+ var r0 bool
125+ var r1 time.Duration
126+ var r2 func()
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, time.Duration, func())); ok {
128+ return returnFunc(ctx, addr)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
131+ r0 = returnFunc(ctx, addr)
132+ } else {
133+ r0 = ret.Get(0).(bool)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) time.Duration); ok {
136+ r1 = returnFunc(ctx, addr)
137+ } else {
138+ r1 = ret.Get(1).(time.Duration)
139+ }
140+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) func()); ok {
141+ r2 = returnFunc(ctx, addr)
142+ } else {
143+ if ret.Get(2) != nil {
144+ r2 = ret.Get(2).(func())
145+ }
146+ }
147+ return r0, r1, r2
148+}
149+
150+// SubmissionLimiter_Reserve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reserve'
151+type SubmissionLimiter_Reserve_Call struct {
152+ *mock.Call
153+}
154+
155+// Reserve is a helper method to define mock.On call
156+// - ctx context.Context
157+// - addr string
158+func (_e *SubmissionLimiter_Expecter) Reserve(ctx any, addr any) *SubmissionLimiter_Reserve_Call {
159+ return &SubmissionLimiter_Reserve_Call{Call: _e.mock.On("Reserve", ctx, addr)}
160+}
161+
162+func (_c *SubmissionLimiter_Reserve_Call) Run(run func(ctx context.Context, addr string)) *SubmissionLimiter_Reserve_Call {
163+ _c.Call.Run(func(args mock.Arguments) {
164+ var arg0 context.Context
165+ if args[0] != nil {
166+ arg0 = args[0].(context.Context)
167+ }
168+ var arg1 string
169+ if args[1] != nil {
170+ arg1 = args[1].(string)
171+ }
172+ run(
173+ arg0,
174+ arg1,
175+ )
176+ })
177+ return _c
178+}
179+
180+func (_c *SubmissionLimiter_Reserve_Call) Return(ok bool, retryAfter time.Duration, release func()) *SubmissionLimiter_Reserve_Call {
181+ _c.Call.Return(ok, retryAfter, release)
182+ return _c
183+}
184+
185+func (_c *SubmissionLimiter_Reserve_Call) RunAndReturn(run func(ctx context.Context, addr string) (bool, time.Duration, func())) *SubmissionLimiter_Reserve_Call {
186+ _c.Call.Return(run)
187+ return _c
188+}
M internal/ports/ports.go +81 -0
@@ -241,6 +241,83 @@ type TokenStore interface {
241 Touch(ctx context.Context, id string, at time.Time) error241 Touch(ctx context.Context, id string, at time.Time) error
242 }242 }
243 243
244+// IssueStore keeps the issues of the repositories and their comments. Issues
245+// share the number sequence of the merge requests of their repository, so that
246+// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
247+// the repository does not exist, so that deleting a repository leaves nothing
248+// for one created again under its name. *kvstore.Issues implements it.
249+type IssueStore interface {
250+ // Create stores the issue under the next number of its repository (the
251+ // one merge requests draw from), which it sets, and returns it. A number
252+ // is never given twice, to an issue or to a merge request, even by a
253+ // concurrent Create.
254+ Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
255+ // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
256+ // which state it was read at.
257+ Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
258+ // List returns a page of the issues of the repository in the given state
259+ // (any when empty), highest number first.
260+ List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
261+ // ListOpenAll returns the open issues of every repository, reading only
262+ // the open ones (through the same index). The order is unspecified.
263+ ListOpenAll(ctx context.Context) ([]domain.Issue, error)
264+ // Update replaces the stored issue and keeps the index of the open issues
265+ // in step. It returns domain.ErrNotFound if there is none and
266+ // domain.ErrConflict if another write changed it since issue was read.
267+ Update(ctx context.Context, issue domain.Issue) error
268+ // SaveComment writes a comment and the issue it belongs to in one batch: a
269+ // new comment (Version 0) must not exist yet, an existing one must be
270+ // unchanged since it was read, and so must issue. It returns
271+ // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
272+ // comment's ID for a new comment, and issue.UpdatedAt.
273+ SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
274+ // ListComments returns the comments of the issue, oldest first.
275+ ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
276+}
277+
278+// PendingIssueStore keeps the issues submitted through the public form until
279+// the owner decides. They have no number: Approve gives one. Put and Approve
280+// return domain.ErrNotFound when the repository does not exist, so that
281+// deleting a repository leaves nothing for one created again under its name.
282+// *kvstore.PendingIssues implements it.
283+type PendingIssueStore interface {
284+ // Put stores the pending issue; its id must be new.
285+ Put(ctx context.Context, p domain.PendingIssue) error
286+ // Get returns the pending issue, or domain.ErrNotFound.
287+ Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error)
288+ // List returns a page of the pending issues of the repository, or of
289+ // every repository when repo is zero, oldest first. A PageSize of 0
290+ // means every one.
291+ List(ctx context.Context, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
292+ // Count returns how many pending issues there are in all repositories,
293+ // reading at most domain.MaxPendingIssues+1 of them.
294+ Count(ctx context.Context) (int, error)
295+ // Approve takes the next number of the repository (the one merge requests
296+ // and issues share), writes the issue domain.ApprovedIssue(pending, now)
297+ // with that number, indexes it as open and deletes the pending record,
298+ // in one batch. It returns domain.ErrNotFound when either is missing.
299+ Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error)
300+ // Delete removes the pending issue, or returns domain.ErrNotFound.
301+ Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error
302+}
303+
304+// IssueKeyStore keeps the server key of the public issue form, from which the
305+// use cases derive their subkeys.
306+type IssueKeyStore interface {
307+ // Key returns the 32-byte secret. The first call on an empty store
308+ // generates and stores it; a restart keeps it.
309+ Key(ctx context.Context) ([]byte, error)
310+}
311+
312+// IssueBanStore keeps the bans of submitters, by the hash of their client key.
313+type IssueBanStore interface {
314+ // Ban bans the hash until the given time, replacing an earlier ban.
315+ Ban(ctx context.Context, hash []byte, until time.Time) error
316+ // Banned reports whether the hash is banned at now. An expired ban counts
317+ // as not banned and is deleted.
318+ Banned(ctx context.Context, hash []byte, now time.Time) (bool, error)
319+}
320+
244 // MergeRequestStore keeps the merge requests of the repositories, their321 // MergeRequestStore keeps the merge requests of the repositories, their
245 // comments and the read position of the reviewer. A repository has no entry322 // comments and the read position of the reviewer. A repository has no entry
246 // until its first merge request. Create, SaveComment and SetRead return323 // until its first merge request. Create, SaveComment and SetRead return
@@ -260,6 +337,10 @@ type MergeRequestStore interface {
260 // ListOpen returns the open merge requests of the repository, highest337 // ListOpen returns the open merge requests of the repository, highest
261 // number first, reading only the open ones (through an index).338 // number first, reading only the open ones (through an index).
262 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)339 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
340+ // ListOpenAll returns the open merge requests of every repository,
341+ // reading only the open ones (through the same index). The order is
342+ // unspecified.
343+ ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error)
263 // CountOpen returns the number of open merge requests of the repository344 // CountOpen returns the number of open merge requests of the repository
264 // from the index of keys: no request is read.345 // from the index of keys: no request is read.
265 CountOpen(ctx context.Context, repo domain.RepoName) (int, error)346 CountOpen(ctx context.Context, repo domain.RepoName) (int, error)
M internal/ports/services.go +94 -0
@@ -126,6 +126,87 @@ type MergeRequestService interface {
126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
127 }127 }
128 128
129+// IssueService is the issue use cases. *usecase.Issues implements it.
130+type IssueService interface {
131+ Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateIssueParams) (domain.Issue, error)
132+ List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListIssuesParams) (IssuePage, error)
133+ // Get returns the issue with its comments, oldest first.
134+ Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (IssueDetail, error)
135+ AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)
136+ Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
137+ Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
138+}
139+
140+// IssueModerationService is the owner's moderation of the pending issues.
141+// Every call needs owner rights and answers domain.ErrNotFound otherwise.
142+// *usecase.IssueModeration implements it.
143+type IssueModerationService interface {
144+ // ListPending lists the pending issues of a repository, or of every
145+ // repository when repo is zero, oldest first.
146+ ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error)
147+ // Approve opens the pending issue under a new number.
148+ Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error)
149+ // Reject deletes the pending issue and, with ban, bans its submitter.
150+ Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error
151+}
152+
153+// IssueChallenge is what the public form needs to submit: a signed token, the
154+// proof-of-work to solve and the timing.
155+type IssueChallenge struct {
156+ // Token is the signed challenge, sent back untouched.
157+ Token string
158+ // Nonce is the base64url of the 16 random bytes the browser hashes:
159+ // SHA-256(nonce bytes || ":" || decimal counter) must have Difficulty
160+ // leading zero bits.
161+ Nonce string
162+ // Difficulty is the number of leading zero bits required.
163+ Difficulty int
164+ // FormToken is a hidden field of the form, sent back untouched.
165+ FormToken string
166+ // IssuedAt, MinSubmitAt and ExpiresAt bound when the form may be sent.
167+ IssuedAt, MinSubmitAt, ExpiresAt time.Time
168+}
169+
170+// IssueSubmission is what the browser sends back.
171+type IssueSubmission struct {
172+ Token string
173+ // Solution is the decimal counter that solves the proof of work.
174+ Solution string
175+ FormToken string
176+ // Honeypot must be empty: a field only a bot fills.
177+ Honeypot string
178+ Title, Body, Author string
179+}
180+
181+// IssueSubmissionService is the anonymous issue submission. Both calls answer
182+// domain.ErrNotFound for a repository that is unknown, not public or without
183+// public issues. Submit refuses with an error matching
184+// domain.ErrSubmissionRefused (ErrSubmissionRateLimited, ErrSubmissionsPaused,
185+// ErrSubmissionInvalid) or with a *domain.TextError the user can fix.
186+// addr is the client key (clientlimit.Key of the address).
187+// *usecase.IssueSubmissions implements it.
188+type IssueSubmissionService interface {
189+ Challenge(ctx context.Context, repo domain.RepoName, addr string) (IssueChallenge, error)
190+ Submit(ctx context.Context, repo domain.RepoName, addr string, f IssueSubmission) error
191+}
192+
193+// IssueNumberResolver tells whether a number of a repository names an issue or
194+// a merge request, for the callers that follow a "#n" reference. *usecase.Issues
195+// implements it.
196+type IssueNumberResolver interface {
197+ // Kind needs the access of reading the repository and answers
198+ // domain.ErrNotFound otherwise, also when issues are off.
199+ Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
200+}
201+
202+// InboxService is the owner's inbox: what is open across every repository.
203+// *usecase.Inbox implements it.
204+type InboxService interface {
205+ // Overview needs owner rights and answers domain.ErrNotFound to anyone
206+ // else. The filter, when not zero, restricts the lists to that repository.
207+ Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (InboxOverview, error)
208+}
209+
129 // SigningKeyService tells which key signs the commits of the forge.210 // SigningKeyService tells which key signs the commits of the forge.
130 // *usecase.SigningKey implements it.211 // *usecase.SigningKey implements it.
131 type SigningKeyService interface {212 type SigningKeyService interface {
@@ -239,6 +320,19 @@ type FailureLimiter interface {
239 Fail(ctx context.Context, addr string)320 Fail(ctx context.Context, addr string)
240 }321 }
241 322
323+// SubmissionLimiter limits the submissions of the public issue form per
324+// client. *clientlimit.SubmissionLimiter implements it.
325+type SubmissionLimiter interface {
326+ // Reserve atomically checks the limits for addr and, if it may submit,
327+ // counts a submission. When it may not, retryAfter says when to retry.
328+ // release refunds the reservation (only when the submission was not
329+ // stored); it is never nil and safe to call once.
330+ Reserve(ctx context.Context, addr string) (ok bool, retryAfter time.Duration, release func())
331+ // Recent returns the submissions of the last hour from addr and from all
332+ // clients.
333+ Recent(ctx context.Context, addr string) (perAddr, global int)
334+}
335+
242 // SecretPage is one page of secrets.336 // SecretPage is one page of secrets.
243 type SecretPage struct {337 type SecretPage struct {
244 Secrets []domain.Secret338 Secrets []domain.Secret
M internal/usecase/events.go +97 -3
@@ -38,6 +38,10 @@ const (
38 const (38 const (
39 // TopicHome follows every repository the caller may see.39 // TopicHome follows every repository the caller may see.
40 TopicHome = "home"40 TopicHome = "home"
41+ // TopicOwner follows the merge requests and issues of every repository,
42+ // for the owner only (the inbox). Anyone else is told it is an unknown
43+ // topic.
44+ TopicOwner = "owner"
41 // topicRepoPrefix starts the topic of one repository: "repo:<name>".45 // topicRepoPrefix starts the topic of one repository: "repo:<name>".
42 topicRepoPrefix = "repo:"46 topicRepoPrefix = "repo:"
43 )47 )
@@ -178,6 +182,16 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178 return repoSnapshot(ev.Visibility, ev.CreatedAt)182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179 case domain.CommentAdded:183 case domain.CommentAdded:
180 return repoSnapshot(ev.Visibility, ev.CreatedAt)184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.IssueOpened:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.IssueClosed:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReopened:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
191+ case domain.IssueCommented:
192+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
193+ case domain.IssueSubmitted, domain.IssuePendingRemoved:
194+ return bufferedEvent{known: true} // owner topic only: no repository snapshot
181 case domain.CommentEdited:195 case domain.CommentEdited:
182 return repoSnapshot(ev.Visibility, ev.CreatedAt)196 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183 case domain.CommentDeleted:197 case domain.CommentDeleted:
@@ -264,6 +278,7 @@ func (e *Events) parseID(id string) (uint64, bool) {
264 type watch struct {278 type watch struct {
265 p domain.Principal279 p domain.Principal
266 home bool280 home bool
281+ owner bool // the owner topic: merge request, issue, repo update and deletion events of every repository
267 repos map[domain.RepoName]bool282 repos map[domain.RepoName]bool
268 283
269 checked time.Time // when the token store last confirmed the principal284 checked time.Time // when the token store last confirmed the principal
@@ -271,6 +286,12 @@ type watch struct {
271 286
272 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }287 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
273 288
289+// wantsEvent is wants for an event: the owner topic also follows the events
290+// about merge requests and issues of every repository.
291+func (w *watch) wantsEvent(name domain.RepoName, ev domain.Event) bool {
292+ return w.wants(name) || (w.owner && (isItemEvent(ev) || isRepoLifecycleEvent(ev)))
293+}
294+
274 // parseTopics validates the topics of a watch. A repository topic needs a295 // parseTopics validates the topics of a watch. A repository topic needs a
275 // repository the principal may read (else domain.ErrNotFound); an unknown296 // repository the principal may read (else domain.ErrNotFound); an unknown
276 // topic, or more than MaxWatchTopics, is domain.ErrInvalid.297 // topic, or more than MaxWatchTopics, is domain.ErrInvalid.
@@ -287,8 +308,14 @@ func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []s
287 w.home = true308 w.home = true
288 continue309 continue
289 }310 }
311+ if t == TopicOwner && p.HasOwnerRights() {
312+ w.owner = true
313+ continue
314+ }
290 raw, ok := strings.CutPrefix(t, topicRepoPrefix)315 raw, ok := strings.CutPrefix(t, topicRepoPrefix)
291 if !ok {316 if !ok {
317+ // Includes "owner" for anyone but the owner: the same error as
318+ // any unknown topic.
292 return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)319 return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)
293 }320 }
294 name, err := domain.ParseRepoName(raw)321 name, err := domain.ParseRepoName(raw)
@@ -425,7 +452,10 @@ func (e *Events) idleCheck() time.Duration {
425 452
426 // stillAllowed checks that the holder of a stored token may still watch: the453 // stillAllowed checks that the holder of a stored token may still watch: the
427 // token has not expired and, at most every few seconds, still exists. Root and454 // token has not expired and, at most every few seconds, still exists. Root and
428-// anonymous callers have nothing to lose.455+// anonymous callers have nothing to lose. The owner topic needs nothing more:
456+// a stored token with owner rights loses them only by ceasing to exist, which
457+// is checked here, and the owner's web session, which this use case cannot see,
458+// is checked by the web adapter before it sends anything.
429 func (e *Events) stillAllowed(ctx context.Context, w *watch) error {459 func (e *Events) stillAllowed(ctx context.Context, w *watch) error {
430 if w.p.TokenID() == "" {460 if w.p.TokenID() == "" {
431 return nil461 return nil
@@ -473,10 +503,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
473 return send(ports.WatchEvent{Gap: true})503 return send(ports.WatchEvent{Gap: true})
474 case domain.RepoDeleted:504 case domain.RepoDeleted:
475 // The repository is gone: use the visibility it had.505 // The repository is gone: use the visibility it had.
476- if !w.wants(ev.Repo) || !canReadAs(w.p, ev.Repo, ev.Visibility) {506+ if !w.wantsEvent(ev.Repo, ev) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
477 return nil507 return nil
478 }508 }
479 return send(out)509 return send(out)
510+ case domain.IssueSubmitted:
511+ return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
512+ case domain.IssuePendingRemoved:
513+ return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
480 case domain.RepoCreated:514 case domain.RepoCreated:
481 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)515 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
482 case domain.RepoUpdated:516 case domain.RepoUpdated:
@@ -493,6 +527,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
493 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)527 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
494 case domain.CommentAdded:528 case domain.CommentAdded:
495 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)529 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
530+ case domain.IssueOpened:
531+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
532+ case domain.IssueClosed:
533+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
534+ case domain.IssueReopened:
535+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
536+ case domain.IssueCommented:
537+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
496 case domain.CommentEdited:538 case domain.CommentEdited:
497 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
498 case domain.CommentDeleted:540 case domain.CommentDeleted:
@@ -514,6 +556,25 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
514 return nil // an event this version does not stream556 return nil // an event this version does not stream
515 }557 }
516 558
559+// deliverOwnerOnly sends an owner-topic event: never home, never a repository
560+// topic, not even to an owner who watches the repository. No content travels.
561+func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.RepoName, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
562+ if !w.owner {
563+ return nil
564+ }
565+ repo, err := lookupRepo(ctx, e.repos.meta, name)
566+ switch {
567+ case errors.Is(err, domain.ErrNotFound), ctx.Err() != nil:
568+ return nil
569+ case err != nil:
570+ return fmt.Errorf("check access to %q: %w", name, err)
571+ }
572+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
573+ return nil
574+ }
575+ return send(out)
576+}
577+
517 // deliverRepo sends an event about a repository if the watcher could read the578 // deliverRepo sends an event about a repository if the watcher could read the
518 // repository when the event happened and can read it now, and the repository579 // repository when the event happened and can read it now, and the repository
519 // is still the one the event is about. A repository that no longer exists is580 // is still the one the event is about. A repository that no longer exists is
@@ -523,7 +584,7 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
523 // read it before are told once, with its name and its new visibility only,584 // read it before are told once, with its name and its new visibility only,
524 // so that what they show can be removed.585 // so that what they show can be removed.
525 func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {586 func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
526- if !w.wants(name) || !be.known {587+ if !w.wantsEvent(name, be.ev) || !be.known {
527 return nil588 return nil
528 }589 }
529 repo, err := lookupRepo(ctx, e.repos.meta, name)590 repo, err := lookupRepo(ctx, e.repos.meta, name)
@@ -548,6 +609,9 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
548 if !canRead(w.p, repo) {609 if !canRead(w.p, repo) {
549 return nil610 return nil
550 }611 }
612+ if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff {
613+ return nil // issues were turned off since: a replay must not show them
614+ }
551 if withRepo {615 if withRepo {
552 view, err := e.repos.View(ctx, repo)616 view, err := e.repos.View(ctx, repo)
553 if err != nil {617 if err != nil {
@@ -557,3 +621,33 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
557 }621 }
558 return send(out)622 return send(out)
559 }623 }
624+
625+// isIssueEvent reports whether ev is about an issue.
626+func isIssueEvent(ev domain.Event) bool {
627+ switch ev.(type) {
628+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
629+ return true
630+ }
631+ return false
632+}
633+
634+// isRepoLifecycleEvent reports whether ev is a repository update or deletion:
635+// the owner topic follows them, for they change what the inbox lists.
636+func isRepoLifecycleEvent(ev domain.Event) bool {
637+ switch ev.(type) {
638+ case domain.RepoUpdated, domain.RepoDeleted:
639+ return true
640+ }
641+ return false
642+}
643+
644+// isItemEvent reports whether ev is about a merge request or an issue.
645+func isItemEvent(ev domain.Event) bool {
646+ switch ev.(type) {
647+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
648+ domain.MergeRequestReady, domain.CommentAdded,
649+ domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
650+ return true
651+ }
652+ return isIssueEvent(ev)
653+}
A internal/usecase/events_owner_test.go +200 -0
@@ -0,0 +1,200 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "testing/synctest"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+func TestWatchOwnerTopicIsUnknownToEveryoneElse(t *testing.T) {
22+ t.Parallel()
23+ r := newEventRig(t, 0)
24+ noop := func(ports.WatchEvent) error { return nil }
25+ unknown := r.events.Watch(context.Background(), domain.Anonymous(), ports.WatchParams{Topics: []string{"nope"}}, noop)
26+ require.ErrorIs(t, unknown, domain.ErrInvalid)
27+
28+ partial := tokenWith("t1", now.Add(0), "pub") // limited to a repository
29+ for _, p := range []domain.Principal{domain.Anonymous(), partial, issuePrincipal(domain.ScopeIssueWrite)} {
30+ err := r.events.Watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}}, noop)
31+ require.ErrorIs(t, err, domain.ErrInvalid)
32+ assert.Equal(t, strings.ReplaceAll(unknown.Error(), "nope", "owner"), err.Error(), "same answer as an unknown topic")
33+ }
34+}
35+
36+func TestWatchOwnerTopicSpansRepositoriesAndHonoursIssuesOff(t *testing.T) {
37+ t.Parallel()
38+ synctest.Test(t, func(t *testing.T) {
39+ r := newEventRig(t, 16)
40+ a, b := name(t, "aaa"), name(t, "bbb")
41+ var mu sync.Mutex
42+ bMode := domain.IssuesOwner
43+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil)
44+ r.meta.EXPECT().Get(mock.Anything, b).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
45+ mu.Lock()
46+ defer mu.Unlock()
47+ return domain.Repo{Name: b, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(bMode)}, nil
48+ })
49+ ctx, cancel := context.WithCancel(context.Background())
50+ defer cancel()
51+ w := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
52+ synctest.Wait()
53+
54+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
55+ _, ok := w.next(t).Event.(domain.IssueOpened)
56+ require.True(t, ok)
57+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
58+ _, ok = w.next(t).Event.(domain.MergeRequestOpened)
59+ require.True(t, ok, "a second repository")
60+
61+ // Pushes are not inbox news.
62+ r.feed <- r.pushEvent(a, sentinelOID, "refs/heads/x")
63+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 3}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
64+ _, ok = w.next(t).Event.(domain.IssueOpened)
65+ require.True(t, ok, "the push was skipped")
66+
67+ mu.Lock()
68+ bMode = domain.IssuesOff
69+ mu.Unlock()
70+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 4}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
71+ r.feed <- domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
72+ e := w.next(t)
73+ _, ok = e.Event.(domain.MergeRequestClosed)
74+ require.True(t, ok, "the issue event of the issues-off repository is not delivered, got %T", e.Event)
75+ })
76+}
77+
78+func withIssues(m domain.IssuesMode) domain.RepoSettings {
79+ st := domain.DefaultRepoSettings()
80+ st.Issues = m
81+ return st
82+}
83+
84+func allScopesToken(mutate func(*domain.Token)) domain.Principal {
85+ tok := domain.Token{ID: "tok", Scopes: domain.AllScopes()}
86+ if mutate != nil {
87+ mutate(&tok)
88+ }
89+ return domain.TokenPrincipal(tok)
90+}
91+
92+func TestWatchOwnerTopicAccessMatrix(t *testing.T) {
93+ t.Parallel()
94+ missingOne := domain.AllScopes()[1:]
95+ cases := []struct {
96+ name string
97+ p domain.Principal
98+ allowed bool
99+ }{
100+ {"root", domain.Root(), true},
101+ {"owner session", domain.Owner(), true},
102+ {"unrestricted all-scope token", allScopesToken(nil), true},
103+ {"token missing one scope", allScopesToken(func(tk *domain.Token) { tk.Scopes = missingOne }), false},
104+ {"repo-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{name(t, "aaa")} }), false},
105+ {"branch-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }), false},
106+ {"anonymous", domain.Anonymous(), false},
107+ }
108+ for _, tc := range cases {
109+ t.Run(tc.name, func(t *testing.T) {
110+ t.Parallel()
111+ synctest.Test(t, func(t *testing.T) {
112+ r := newEventRig(t, 16)
113+ a, b := name(t, "aaa"), name(t, "bbb")
114+ for _, n := range []domain.RepoName{a, b} {
115+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil).Maybe()
116+ }
117+ ctx, cancel := context.WithCancel(context.Background())
118+ defer cancel()
119+ noop := func(ports.WatchEvent) error { return nil }
120+ if !tc.allowed {
121+ err := r.events.Watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}, noop)
122+ require.ErrorIs(t, err, domain.ErrInvalid)
123+ assert.EqualError(t, err, domain.ErrInvalid.Error()+`: unknown topic "owner" (want "home" or "repo:<name>")`)
124+ return
125+ }
126+ w := r.watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}})
127+ synctest.Wait()
128+ require.Equal(t, 1, r.events.Watchers())
129+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
130+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
131+ assert.Equal(t, a, w.next(t).Event.(domain.IssueOpened).Issue.Repo)
132+ assert.Equal(t, b, w.next(t).Event.(domain.MergeRequestOpened).MergeRequest.Repo)
133+ })
134+ })
135+ }
136+}
137+
138+func TestWatchOwnerTopicFollowsRepoUpdatedAndDeleted(t *testing.T) {
139+ t.Parallel()
140+ synctest.Test(t, func(t *testing.T) {
141+ r := newEventRig(t, 16)
142+ a := r.repo("aaa", domain.VisibilityPrivate)
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
146+ synctest.Wait()
147+ r.feed <- domain.RepoUpdated{Repo: a, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
148+ r.feed <- domain.RepoDeleted{Repo: a, Visibility: domain.VisibilityPrivate, At: now}
149+ _, ok := owner.next(t).Event.(domain.RepoUpdated)
150+ require.True(t, ok)
151+ _, ok = owner.next(t).Event.(domain.RepoDeleted)
152+ require.True(t, ok)
153+ })
154+}
155+
156+func TestWatchOwnerTopicEndsWhenTheTokenIsRevoked(t *testing.T) {
157+ t.Parallel()
158+ synctest.Test(t, func(t *testing.T) {
159+ r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1})
160+ priv := r.repo("priv", domain.VisibilityPrivate)
161+ ctx, cancel := context.WithCancel(context.Background())
162+ defer cancel()
163+ w := r.watch(ctx, allScopesToken(nil), ports.WatchParams{Topics: []string{"owner"}})
164+ synctest.Wait()
165+ require.Equal(t, 1, r.events.Watchers())
166+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
167+ w.next(t)
168+
169+ r.revoked.Store("tok", true)
170+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
171+ synctest.Wait()
172+ select {
173+ case err := <-w.done:
174+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
175+ default:
176+ t.Fatal("the owner watch of a revoked token did not end")
177+ }
178+ assert.Empty(t, w.got, "nothing was sent after the revocation")
179+ })
180+}
181+
182+func TestWatchOwnerTopicEndsWhenTheTokenExpires(t *testing.T) {
183+ t.Parallel()
184+ synctest.Test(t, func(t *testing.T) {
185+ r := newEventRig(t, 0)
186+ priv := r.repo("priv", domain.VisibilityPrivate)
187+ // The rig's clock stands at now: this token expired at now.
188+ p := allScopesToken(func(tk *domain.Token) { tk.ExpiresAt = now })
189+ w := r.watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}})
190+ r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now}
191+ synctest.Wait()
192+ select {
193+ case err := <-w.done:
194+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
195+ default:
196+ t.Fatal("the owner watch of an expired token did not end")
197+ }
198+ assert.Empty(t, w.got)
199+ })
200+}
M internal/usecase/events_test.go +43 -0
@@ -206,6 +206,49 @@ func TestWatchRechecksAccessPerEvent(t *testing.T) {
206 })206 })
207 }207 }
208 208
209+func TestWatchDropsIssueEventsOnceIssuesAreOff(t *testing.T) {
210+ t.Parallel()
211+ synctest.Test(t, func(t *testing.T) {
212+ r := newEventRig(t, 16)
213+ n := name(t, "iss")
214+ var mu sync.Mutex
215+ mode := domain.IssuesOwner
216+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
217+ mu.Lock()
218+ defer mu.Unlock()
219+ st := domain.DefaultRepoSettings()
220+ st.Issues = mode
221+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: st}, nil
222+ })
223+ ctx, cancel := context.WithCancel(context.Background())
224+ defer cancel()
225+ first := r.watch(ctx, domain.Root(), ports.WatchParams{})
226+ synctest.Wait()
227+
228+ r.push(n, 1)
229+ pushID := first.next(t).ID
230+ r.feed <- domain.IssueCommented{Repo: n, Number: 1, Comment: domain.IssueComment{ID: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
231+ _, ok := first.next(t).Event.(domain.IssueCommented)
232+ require.True(t, ok, "delivered while issues are on")
233+ r.push(n, 2)
234+ first.next(t)
235+
236+ mu.Lock()
237+ mode = domain.IssuesOff
238+ mu.Unlock()
239+
240+ // The replay holds the issue event: it is dropped, the other ones stay.
241+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: pushID})
242+ e := w.next(t)
243+ _, isPush := e.Event.(domain.PushEvent)
244+ require.True(t, isPush, "got %T", e.Event)
245+ w.quiet(t, r, n)
246+
247+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: n, Number: 2}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
248+ w.quiet(t, r, n)
249+ })
250+}
251+
209 func TestWatchResumeAndGap(t *testing.T) {252 func TestWatchResumeAndGap(t *testing.T) {
210 t.Parallel()253 t.Parallel()
211 const size = 8254 const size = 8
M internal/usecase/export_test.go +34 -0
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "crypto/rsa"7 "crypto/rsa"
8 "slices"8 "slices"
9+ "strconv"
9 "time"10 "time"
10 11
11 "bnema.dev/ermite-actions/expr"12 "bnema.dev/ermite-actions/expr"
@@ -127,3 +128,36 @@ func (o *OIDC) SignForTest(claims map[string]any) (string, error) {
127 }128 }
128 return o.sign(out)129 return o.sign(out)
129 }130 }
131+
132+// DifficultyFor exposes the adaptive difficulty with the production base and
133+// cap to the tests.
134+func DifficultyFor(perAddr, global int) int {
135+ return difficultyFor(baseDifficulty, maxDifficulty, perAddr, global)
136+}
137+
138+// Production parameters of the submission, for the tests.
139+const (
140+ BaseDifficulty = baseDifficulty
141+ MaxDifficulty = maxDifficulty
142+ MinFillTime = minFillTime
143+ ChallengeTTL = challengeTTL
144+)
145+
146+// SpentLen is the number of nonces the submission remembers as spent.
147+func (s *IssueSubmissions) SpentLen() int {
148+ s.mu.Lock()
149+ defer s.mu.Unlock()
150+ return len(s.spent)
151+}
152+
153+// FillSpent fills the spent set with n unexpired nonces.
154+func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
155+ s.mu.Lock()
156+ defer s.mu.Unlock()
157+ for i := range n {
158+ s.spent["fill"+strconv.Itoa(i)] = exp
159+ }
160+}
161+
162+// MaxSpent is the bound of the spent set.
163+const MaxSpent = maxSpent
A internal/usecase/inbox.go +164 -0
@@ -0,0 +1,164 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of
18+// every repository in one place. Only the owner may read it; anyone else
19+// gets domain.ErrNotFound, as for a page that does not exist. The issues of a
20+// repository whose issues mode is off are left out.
21+type Inbox struct {
22+ meta ports.RepoMetaStore
23+ mrs ports.MergeRequestStore
24+ issues ports.IssueStore
25+ // pending is nil when the public form is not wired.
26+ pending ports.PendingIssueStore
27+}
28+
29+var _ ports.InboxService = (*Inbox)(nil)
30+
31+// InboxConfig holds what the inbox needs. Every field is required.
32+type InboxConfig struct {
33+ Meta ports.RepoMetaStore
34+ MergeRequests ports.MergeRequestStore
35+ Issues ports.IssueStore
36+ // Pending lists the issues awaiting moderation. Optional: without it the
37+ // inbox has no pending section.
38+ Pending ports.PendingIssueStore
39+}
40+
41+// NewInbox builds the inbox use case.
42+func NewInbox(cfg InboxConfig) *Inbox {
43+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
44+}
45+
46+// Overview lists what is open, through the indexes of open items. The counts
47+// and the list of repositories do not depend on the filter, the lists do.
48+func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
49+ var out ports.InboxOverview
50+ if !p.HasOwnerRights() {
51+ return out, domain.ErrNotFound
52+ }
53+ mrs, err := in.mrs.ListOpenAll(ctx)
54+ if err != nil {
55+ return out, fmt.Errorf("list open merge requests: %w", err)
56+ }
57+ issues, err := in.issues.ListOpenAll(ctx)
58+ if err != nil {
59+ return out, fmt.Errorf("list open issues: %w", err)
60+ }
61+
62+ // exists and issuesOn cache what is read of each repository.
63+ issuesOn := make(map[domain.RepoName]bool)
64+ exists := make(map[domain.RepoName]bool)
65+ look := func(name domain.RepoName) error {
66+ if _, ok := exists[name]; ok {
67+ return nil
68+ }
69+ repo, err := lookupRepo(ctx, in.meta, name)
70+ switch {
71+ case errors.Is(err, domain.ErrNotFound):
72+ exists[name] = false
73+ case err != nil:
74+ return fmt.Errorf("read repository %q: %w", name, err)
75+ default:
76+ exists[name] = true
77+ issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff
78+ }
79+ return nil
80+ }
81+
82+ repos := make(map[domain.RepoName]bool)
83+ for _, m := range mrs {
84+ if err := look(m.Repo); err != nil {
85+ return out, err
86+ }
87+ if !exists[m.Repo] {
88+ continue
89+ }
90+ repos[m.Repo] = true
91+ out.Counts.MergeRequests++
92+ if filter.IsZero() || m.Repo == filter {
93+ out.MergeRequests = append(out.MergeRequests, m)
94+ }
95+ }
96+ for _, i := range issues {
97+ if err := look(i.Repo); err != nil {
98+ return out, err
99+ }
100+ if !exists[i.Repo] || !issuesOn[i.Repo] {
101+ continue
102+ }
103+ repos[i.Repo] = true
104+ out.Counts.Issues++
105+ if filter.IsZero() || i.Repo == filter {
106+ out.Issues = append(out.Issues, i)
107+ }
108+ }
109+
110+ if in.pending != nil {
111+ page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
112+ if err != nil {
113+ return out, fmt.Errorf("list pending issues: %w", err)
114+ }
115+ for _, pi := range page.Issues {
116+ if err := look(pi.Repo); err != nil {
117+ return out, err
118+ }
119+ if !exists[pi.Repo] || !issuesOn[pi.Repo] {
120+ continue
121+ }
122+ pi.IPHash = nil // the hash is for bans, the page never needs it
123+ out.Pending = append(out.Pending, pi)
124+ repos[pi.Repo] = true
125+ }
126+ out.Counts.Pending = len(out.Pending)
127+ }
128+
129+ slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130+ return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131+ })
132+ slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int {
133+ return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
134+ })
135+ for r := range repos {
136+ out.Repos = append(out.Repos, r)
137+ }
138+ slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
139+ return out, nil
140+}
141+
142+// compareRecent orders by update time, most recent first (the creation time
143+// stands in for a missing one), then by repository and number.
144+func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int {
145+ if aUp.IsZero() {
146+ aUp = aCr
147+ }
148+ if bUp.IsZero() {
149+ bUp = bCr
150+ }
151+ if c := bUp.Compare(aUp); c != 0 {
152+ return c
153+ }
154+ if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 {
155+ return c
156+ }
157+ switch {
158+ case aN > bN:
159+ return -1
160+ case aN < bN:
161+ return 1
162+ }
163+ return 0
164+}
A internal/usecase/inbox_test.go +110 -0
@@ -0,0 +1,110 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+func inboxMR(t *testing.T, repo string, n int64, up time.Time) domain.MergeRequest {
21+ t.Helper()
22+ ti, err := domain.ParseMRTitle("mr")
23+ require.NoError(t, err)
24+ return domain.MergeRequest{Repo: name(t, repo), Number: n, Title: ti, State: domain.MRStateOpen, UpdatedAt: up}
25+}
26+
27+func inboxIssue(t *testing.T, repo string, n int64, up time.Time) domain.Issue {
28+ t.Helper()
29+ ti, err := domain.ParseIssueTitle("issue")
30+ require.NoError(t, err)
31+ return domain.Issue{Repo: name(t, repo), Number: n, Title: ti, State: domain.IssueStateOpen, UpdatedAt: up}
32+}
33+
34+func newInboxUnit(t *testing.T) (*usecase.Inbox, *mocks.MergeRequestStore, *mocks.IssueStore, *mocks.RepoMetaStore) {
35+ t.Helper()
36+ mrs, is, meta := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t)
37+ return usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is}), mrs, is, meta
38+}
39+
40+func repoWithIssues(t *testing.T, n string, mode domain.IssuesMode) domain.Repo {
41+ s := domain.DefaultRepoSettings()
42+ s.Issues = mode
43+ return domain.Repo{Name: name(t, n), Visibility: domain.VisibilityPrivate, Settings: s}
44+}
45+
46+func TestInboxIsOwnerOnly(t *testing.T) {
47+ t.Parallel()
48+ in, _, _, _ := newInboxUnit(t) // the stores are never read
49+ for _, p := range []domain.Principal{domain.Anonymous(), issuePrincipal(domain.ScopeIssueWrite)} {
50+ _, err := in.Overview(context.Background(), p, domain.RepoName{})
51+ assert.ErrorIs(t, err, domain.ErrNotFound)
52+ }
53+}
54+
55+func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) {
56+ t.Parallel()
57+ ctx := context.Background()
58+ t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
59+ in, mrs, is, meta := newInboxUnit(t)
60+ mrs.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{
61+ inboxMR(t, "alpha", 1, t0), inboxMR(t, "beta", 1, t0.Add(time.Hour)), inboxMR(t, "gone", 1, t0),
62+ }, nil)
63+ is.EXPECT().ListOpenAll(mock.Anything).Return([]domain.Issue{
64+ inboxIssue(t, "alpha", 2, t0), inboxIssue(t, "beta", 2, t0.Add(2*time.Hour)), inboxIssue(t, "quiet", 2, t0),
65+ }, nil)
66+ meta.EXPECT().Get(mock.Anything, name(t, "alpha")).Return(repoWithIssues(t, "alpha", domain.IssuesOwner), nil)
67+ meta.EXPECT().Get(mock.Anything, name(t, "beta")).Return(repoWithIssues(t, "beta", domain.IssuesPublic), nil)
68+ meta.EXPECT().Get(mock.Anything, name(t, "quiet")).Return(repoWithIssues(t, "quiet", domain.IssuesOff), nil)
69+ meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
70+
71+ got, err := in.Overview(ctx, domain.Owner(), domain.RepoName{})
72+ require.NoError(t, err)
73+ assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts)
74+ require.Len(t, got.MergeRequests, 2)
75+ assert.Equal(t, "beta", got.MergeRequests[0].Repo.String(), "most recently updated first")
76+ require.Len(t, got.Issues, 2)
77+ assert.Equal(t, "beta", got.Issues[0].Repo.String())
78+ assert.Equal(t, []domain.RepoName{name(t, "alpha"), name(t, "beta")}, got.Repos)
79+
80+ got, err = in.Overview(ctx, domain.Root(), name(t, "alpha"))
81+ require.NoError(t, err)
82+ assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts, "counts ignore the filter")
83+ require.Len(t, got.MergeRequests, 1)
84+ require.Len(t, got.Issues, 1)
85+ assert.Equal(t, "alpha", got.Issues[0].Repo.String())
86+ assert.Len(t, got.Repos, 2, "the filter list keeps every repository")
87+}
88+
89+func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
90+ t.Parallel()
91+ ctx := context.Background()
92+ mrs, is, meta, pending := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewPendingIssueStore(t)
93+ in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is, Pending: pending})
94+ mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
95+ is.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
96+ meta.EXPECT().Get(mock.Anything, name(t, "on")).Return(repoWithIssues(t, "on", domain.IssuesPublic), nil)
97+ meta.EXPECT().Get(mock.Anything, name(t, "off")).Return(repoWithIssues(t, "off", domain.IssuesOff), nil)
98+ ti, err := domain.ParseIssueTitle("hi")
99+ require.NoError(t, err)
100+ pending.EXPECT().List(mock.Anything, domain.RepoName{}, mock.Anything).Return(ports.PendingIssuePage{Issues: []domain.PendingIssue{
101+ {ID: "a", Repo: name(t, "on"), Title: ti, IPHash: []byte("secret")},
102+ {ID: "b", Repo: name(t, "off"), Title: ti},
103+ }}, nil)
104+ ov, err := in.Overview(ctx, domain.Owner(), name(t, "other"))
105+ require.NoError(t, err)
106+ assert.Equal(t, 1, ov.Counts.Pending, "issues off hides pending ones too")
107+ require.Len(t, ov.Pending, 1)
108+ assert.Nil(t, ov.Pending[0].IPHash)
109+ assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110+}
A internal/usecase/issue_keys.go +62 -0
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hkdf"
8+ "crypto/hmac"
9+ "crypto/sha256"
10+ "fmt"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// HKDF info strings of the subkeys of the issue form key. Each purpose has its
17+// own key, so that a value signed for one can never pass for another.
18+const (
19+ infoIPHash = "ermite issue ip-hash"
20+ infoPoW = "ermite issue pow"
21+ infoFormToken = "ermite issue form-token"
22+)
23+
24+// IssueKeys holds the subkeys of the public issue form, derived with
25+// HKDF-SHA256 from the server key of the IssueKeyStore.
26+type IssueKeys struct {
27+ ipHash, pow, formToken []byte
28+}
29+
30+// NewIssueKeys loads the server key (generated on the first call) and derives
31+// the subkeys.
32+func NewIssueKeys(ctx context.Context, store ports.IssueKeyStore) (*IssueKeys, error) {
33+ master, err := store.Key(ctx)
34+ if err != nil {
35+ return nil, fmt.Errorf("load issue key: %w", err)
36+ }
37+ k := &IssueKeys{}
38+ for _, d := range []struct {
39+ dst *[]byte
40+ info string
41+ }{{&k.ipHash, infoIPHash}, {&k.pow, infoPoW}, {&k.formToken, infoFormToken}} {
42+ if *d.dst, err = hkdf.Key(sha256.New, master, nil, d.info, sha256.Size); err != nil {
43+ return nil, fmt.Errorf("derive %q: %w", d.info, err)
44+ }
45+ }
46+ return k, nil
47+}
48+
49+// IPHash returns HMAC-SHA256 of the client key (clientlimit.Key of the
50+// address) under the ip-hash subkey: domain.IPHashLen bytes. It is stable
51+// across restarts and says nothing about the address without the key.
52+func (k *IssueKeys) IPHash(addrKey string) []byte {
53+ m := hmac.New(sha256.New, k.ipHash)
54+ m.Write([]byte(addrKey))
55+ return m.Sum(nil)[:domain.IPHashLen]
56+}
57+
58+// PoWKey returns a copy of the subkey that signs the proof-of-work challenges.
59+func (k *IssueKeys) PoWKey() []byte { return append([]byte(nil), k.pow...) }
60+
61+// FormTokenKey returns a copy of the subkey that signs the form tokens.
62+func (k *IssueKeys) FormTokenKey() []byte { return append([]byte(nil), k.formToken...) }
A internal/usecase/issue_moderation.go +119 -0
@@ -0,0 +1,119 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// IssueModeration is the owner's moderation of the issues submitted through
17+// the public form (ADR 0031): list, approve and reject, with an optional ban of
18+// the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as
19+// for a page that does not exist.
20+type IssueModeration struct {
21+ meta ports.RepoMetaStore
22+ pending ports.PendingIssueStore
23+ bans ports.IssueBanStore
24+ bus ports.EventBus
25+ clock ports.Clock
26+}
27+
28+var _ ports.IssueModerationService = (*IssueModeration)(nil)
29+
30+// IssueModerationConfig holds what the moderation needs. Every field is
31+// required.
32+type IssueModerationConfig struct {
33+ Meta ports.RepoMetaStore
34+ Pending ports.PendingIssueStore
35+ Bans ports.IssueBanStore
36+ Bus ports.EventBus
37+ Clock ports.Clock
38+}
39+
40+// NewIssueModeration builds the moderation use case.
41+func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration {
42+ return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock}
43+}
44+
45+// ListPending lists the pending issues of a repository, or of every repository
46+// when repo is zero, oldest first.
47+func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
48+ if !p.HasOwnerRights() {
49+ return ports.PendingIssuePage{}, domain.ErrNotFound
50+ }
51+ size, err := pageSize(page.PageSize)
52+ if err != nil {
53+ return ports.PendingIssuePage{}, err
54+ }
55+ page.PageSize = size
56+ out, err := m.pending.List(ctx, repo, page)
57+ if err != nil {
58+ return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err)
59+ }
60+ return out, nil
61+}
62+
63+// Approve gives the pending issue the next number of its repository and opens
64+// it, then publishes IssueOpened. The repository must exist with issues on
65+// (else domain.ErrNotFound), and so must the pending issue.
66+func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
67+ if !p.HasOwnerRights() {
68+ return domain.Issue{}, domain.ErrNotFound
69+ }
70+ r, err := lookupRepo(ctx, m.meta, repo)
71+ if err != nil {
72+ return domain.Issue{}, err
73+ }
74+ if r.Settings.Issues.OrDefault() == domain.IssuesOff {
75+ return domain.Issue{}, domain.ErrNotFound
76+ }
77+ now := m.clock.Now().UTC()
78+ issue, err := m.pending.Approve(ctx, repo, id, now)
79+ if err != nil {
80+ if errors.Is(err, domain.ErrNotFound) {
81+ return domain.Issue{}, err
82+ }
83+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
84+ }
85+ log := zerowrap.FromCtx(ctx)
86+ log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
87+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88+ m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89+ return issue, nil
90+}
91+
92+// Reject deletes the pending issue. With ban, its submitter is first banned
93+// for domain.BanDuration.
94+func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
95+ if !p.HasOwnerRights() {
96+ return domain.ErrNotFound
97+ }
98+ pending, err := m.pending.Get(ctx, repo, id)
99+ if err != nil {
100+ return err
101+ }
102+ if ban {
103+ until := m.clock.Now().UTC().Add(domain.BanDuration)
104+ if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
105+ return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
106+ }
107+ }
108+ if err := m.pending.Delete(ctx, repo, id); err != nil {
109+ if errors.Is(err, domain.ErrNotFound) {
110+ return err
111+ }
112+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
113+ }
114+ log := zerowrap.FromCtx(ctx)
115+ log.Info().Str("repo", repo.String()).Bool("ban", ban).
116+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
117+ m.bus.Publish(ctx, domain.IssuePendingRemoved{Repo: repo, ID: id, At: m.clock.Now().UTC()})
118+ return nil
119+}
A internal/usecase/issue_moderation_test.go +251 -0
@@ -0,0 +1,251 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "testing"
9+ "testing/synctest"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+type moderationUnit struct {
22+ meta *mocks.RepoMetaStore
23+ pending *mocks.PendingIssueStore
24+ bans *mocks.IssueBanStore
25+ bus *mocks.EventBus
26+ mod *usecase.IssueModeration
27+ repo domain.Repo
28+ id domain.PendingIssueID
29+}
30+
31+func newModerationUnit(t *testing.T, mode domain.IssuesMode) *moderationUnit {
32+ t.Helper()
33+ id, err := domain.NewPendingIssueID()
34+ require.NoError(t, err)
35+ u := &moderationUnit{
36+ meta: mocks.NewRepoMetaStore(t),
37+ pending: mocks.NewPendingIssueStore(t),
38+ bans: mocks.NewIssueBanStore(t),
39+ bus: mocks.NewEventBus(t),
40+ repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(mode)},
41+ id: id,
42+ }
43+ clock := mocks.NewClock(t)
44+ clock.EXPECT().Now().Return(now).Maybe()
45+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
46+ u.mod = usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: u.meta, Pending: u.pending, Bans: u.bans, Bus: u.bus, Clock: clock})
47+ return u
48+}
49+
50+func TestIssueModerationIsOwnerOnly(t *testing.T) {
51+ t.Parallel()
52+ u := newModerationUnit(t, domain.IssuesPublic) // no store is ever called
53+ ctx := context.Background()
54+ notOwners := map[string]domain.Principal{
55+ "anonymous": domain.Anonymous(),
56+ "issue writer": issuePrincipal(domain.ScopeIssueWrite),
57+ "one scope short": allScopesToken(func(tk *domain.Token) {
58+ tk.Scopes = tk.Scopes[1:]
59+ }),
60+ "repo limited": allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{u.repo.Name} }),
61+ "branch limited": allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }),
62+ }
63+ for who, p := range notOwners {
64+ _, err := u.mod.ListPending(ctx, p, domain.RepoName{}, ports.ListPendingParams{})
65+ assert.ErrorIs(t, err, domain.ErrNotFound, who)
66+ _, err = u.mod.Approve(ctx, p, u.repo.Name, u.id)
67+ assert.ErrorIs(t, err, domain.ErrNotFound, who)
68+ assert.ErrorIs(t, u.mod.Reject(ctx, p, u.repo.Name, u.id, true), domain.ErrNotFound, who)
69+ }
70+}
71+
72+func TestIssueModerationListAppliesPageLimits(t *testing.T) {
73+ t.Parallel()
74+ u := newModerationUnit(t, domain.IssuesPublic)
75+ u.pending.EXPECT().List(mock.Anything, domain.RepoName{}, ports.ListPendingParams{PageSize: usecase.DefaultPageSize, PageToken: "t"}).
76+ Return(ports.PendingIssuePage{NextPageToken: "n"}, nil).Once()
77+ got, err := u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageToken: "t"})
78+ require.NoError(t, err)
79+ assert.Equal(t, "n", got.NextPageToken)
80+ _, err = u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageSize: -1})
81+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
82+}
83+
84+func TestIssueModerationApprove(t *testing.T) {
85+ t.Parallel()
86+ ctx := context.Background()
87+ t.Run("publishes IssueOpened after the commit", func(t *testing.T) {
88+ t.Parallel()
89+ u := newModerationUnit(t, domain.IssuesPublic)
90+ issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
91+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
92+ u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
93+ got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
94+ require.NoError(t, err)
95+ assert.Equal(t, int64(7), got.Number)
96+ })
97+ t.Run("issues off hides the repository", func(t *testing.T) {
98+ t.Parallel()
99+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
100+ u := newModerationUnit(t, mode)
101+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
102+ assert.ErrorIs(t, err, domain.ErrNotFound)
103+ }
104+ })
105+ t.Run("no event when the store refuses", func(t *testing.T) {
106+ t.Parallel()
107+ u := newModerationUnit(t, domain.IssuesOwner)
108+ u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(domain.Issue{}, domain.ErrNotFound).Once()
109+ _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id)
110+ assert.ErrorIs(t, err, domain.ErrNotFound)
111+ })
112+}
113+
114+func TestIssueModerationReject(t *testing.T) {
115+ t.Parallel()
116+ ctx := context.Background()
117+ hash := bytes.Repeat([]byte{3}, domain.IPHashLen)
118+ t.Run("with a ban", func(t *testing.T) {
119+ t.Parallel()
120+ u := newModerationUnit(t, domain.IssuesPublic)
121+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, Repo: u.repo.Name, IPHash: hash}, nil).Once()
122+ u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once()
123+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
124+ u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
125+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true))
126+ })
127+ t.Run("without a ban", func(t *testing.T) {
128+ t.Parallel()
129+ u := newModerationUnit(t, domain.IssuesPublic)
130+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
131+ u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once()
132+ u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once()
133+ require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false))
134+ })
135+ t.Run("unknown id", func(t *testing.T) {
136+ t.Parallel()
137+ u := newModerationUnit(t, domain.IssuesPublic)
138+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once()
139+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound)
140+ })
141+ t.Run("a failed ban keeps the pending issue", func(t *testing.T) {
142+ t.Parallel()
143+ u := newModerationUnit(t, domain.IssuesPublic)
144+ u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once()
145+ u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once()
146+ assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError)
147+ })
148+}
149+
150+func TestIssueKeysSubkeys(t *testing.T) {
151+ t.Parallel()
152+ ctx := context.Background()
153+ master := bytes.Repeat([]byte{0xAB}, 32)
154+ store := mocks.NewIssueKeyStore(t)
155+ store.EXPECT().Key(mock.Anything).Return(master, nil)
156+ k1, err := usecase.NewIssueKeys(ctx, store)
157+ require.NoError(t, err)
158+ k2, err := usecase.NewIssueKeys(ctx, store)
159+ require.NoError(t, err)
160+
161+ assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable")
162+ assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey())
163+ assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4"))
164+
165+ assert.Len(t, k1.PoWKey(), 32)
166+ assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose")
167+ assert.NotEqual(t, master, k1.PoWKey())
168+ assert.NotEqual(t, master, k1.FormTokenKey())
169+
170+ h := k1.IPHash("1.2.3.4")
171+ assert.Len(t, h, domain.IPHashLen)
172+ assert.NotEqual(t, h, k1.IPHash("1.2.3.5"))
173+ assert.NotContains(t, string(h), "1.2.3.4")
174+
175+ other := mocks.NewIssueKeyStore(t)
176+ other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil)
177+ k3, err := usecase.NewIssueKeys(ctx, other)
178+ require.NoError(t, err)
179+ assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash")
180+
181+ // The returned keys are copies.
182+ pow := k1.PoWKey()
183+ pow[0] ^= 0xFF
184+ assert.NotEqual(t, pow, k1.PoWKey())
185+}
186+
187+func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) {
188+ t.Parallel()
189+ synctest.Test(t, func(t *testing.T) {
190+ r := newEventRig(t, 16)
191+ a := r.repo("aaa", domain.VisibilityPublic)
192+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
193+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
194+ ctx, cancel := context.WithCancel(context.Background())
195+ defer cancel()
196+
197+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
198+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
199+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
200+ rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
201+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
202+ synctest.Wait()
203+
204+ r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now}
205+ // A sentinel every watcher sees, to prove the first one was skipped.
206+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
207+
208+ sub, ok := owner.next(t).Event.(domain.IssueSubmitted)
209+ require.True(t, ok, "the owner topic gets it")
210+ assert.Equal(t, a, sub.Repo)
211+ _, ok = owner.next(t).Event.(domain.IssueOpened)
212+ require.True(t, ok)
213+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} {
214+ e, ok := w.next(t).Event.(domain.IssueOpened)
215+ require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e)
216+ }
217+ })
218+}
219+
220+func TestIssuePendingRemovedReachesOnlyOwnerTopics(t *testing.T) {
221+ t.Parallel()
222+ synctest.Test(t, func(t *testing.T) {
223+ r := newEventRig(t, 16)
224+ a := r.repo("aaa", domain.VisibilityPublic)
225+ r.meta.EXPECT().Get(mock.Anything, a).Unset()
226+ r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
227+ ctx, cancel := context.WithCancel(context.Background())
228+ defer cancel()
229+
230+ owner1 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
231+ owner2 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
232+ home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}})
233+ repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}})
234+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}})
235+ synctest.Wait()
236+
237+ r.feed <- domain.IssuePendingRemoved{Repo: a, ID: "x", At: now}
238+ r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
239+
240+ for name, w := range map[string]*watcher{"owner 1": owner1, "owner 2": owner2} {
241+ _, ok := w.next(t).Event.(domain.IssuePendingRemoved)
242+ require.True(t, ok, "%s gets the refresh", name)
243+ _, ok = w.next(t).Event.(domain.IssueOpened)
244+ require.True(t, ok)
245+ }
246+ for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "anonymous": anon} {
247+ e, ok := w.next(t).Event.(domain.IssueOpened)
248+ require.True(t, ok, "%s: the first event is the sentinel (got %T)", name, e)
249+ }
250+ })
251+}
A internal/usecase/issue_pow_vector_test.go +39 -0
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "encoding/base64"
7+ "strconv"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// The proof-of-work vector shared with the browser solver: the same nonce,
15+// difficulty and smallest solution are asserted in
16+// internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both.
17+const (
18+ powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding
19+ powVectorDifficulty = 12
20+ powVectorSolution = "2491"
21+)
22+
23+func TestPowVector(t *testing.T) {
24+ nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce)
25+ require.NoError(t, err)
26+ require.Len(t, nonce, nonceLen)
27+
28+ first := ""
29+ for n := 0; n < 1<<22; n++ {
30+ s := strconv.Itoa(n)
31+ if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty {
32+ first = s
33+ break
34+ }
35+ }
36+ require.NotEmpty(t, first)
37+ assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector")
38+ assert.True(t, validSolution(first))
39+}
A internal/usecase/issue_submissions.go +383 -0
@@ -0,0 +1,383 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hmac"
8+ "crypto/rand"
9+ "crypto/sha256"
10+ "encoding/base64"
11+ "encoding/json"
12+ "errors"
13+ "fmt"
14+ "math/bits"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+)
24+
25+// Parameters of the anonymous submission (ADR 0031).
26+const (
27+ // baseDifficulty is the proof-of-work of a client that has not submitted
28+ // lately, in leading zero bits of SHA-256: 2^18 hashes on average, about
29+ // one second for a SHA-256 in JavaScript on a mid-range phone.
30+ baseDifficulty = 18
31+ // maxDifficulty caps the adaptive proof-of-work: about 8 to 10 seconds.
32+ maxDifficulty = 21
33+ // perAddrStep and globalStep: one more bit per that many submissions of the
34+ // last hour from the client, and from everyone.
35+ perAddrStep = 2
36+ globalStep = 20
37+
38+ // challengeTTL is how long a challenge may be solved and sent.
39+ challengeTTL = 10 * time.Minute
40+ // minFillTime is the least time between the challenge and the submission:
41+ // a human cannot write an issue faster.
42+ minFillTime = 3 * time.Second
43+ // maxSpent bounds the nonces remembered as spent. Past it submissions are
44+ // paused rather than a live nonce forgotten.
45+ maxSpent = 100_000
46+
47+ tokenVersion = 1
48+ nonceLen = 16
49+ maxChallengeLen = 1024
50+ maxSolutionLen = 20
51+ spentSweep = time.Minute
52+)
53+
54+// difficultyFor is the proof-of-work for a client with perAddr submissions in
55+// the last hour among global ones: base, plus a bit per perAddrStep and per
56+// globalStep, at most limit. It decays as the sliding window empties.
57+func difficultyFor(base, limit, perAddr, global int) int {
58+ return min(base+max(perAddr, 0)/perAddrStep+max(global, 0)/globalStep, limit)
59+}
60+
61+// leadingZeroBits counts the zero bits at the start of sum.
62+func leadingZeroBits(sum []byte) int {
63+ n := 0
64+ for _, b := range sum {
65+ if b == 0 {
66+ n += 8
67+ continue
68+ }
69+ return n + bits.LeadingZeros8(b)
70+ }
71+ return n
72+}
73+
74+// powHash is SHA-256(nonce || ":" || solution).
75+func powHash(nonce []byte, solution string) []byte {
76+ h := sha256.New()
77+ h.Write(nonce)
78+ h.Write([]byte(":"))
79+ h.Write([]byte(solution))
80+ return h.Sum(nil)
81+}
82+
83+// challengeClaims is the signed payload of a challenge token.
84+type challengeClaims struct {
85+ V int `json:"v"`
86+ Repo string `json:"repo"`
87+ Nonce []byte `json:"nonce"`
88+ Difficulty int `json:"d"`
89+ IssuedAt int64 `json:"iat"`
90+ ExpiresAt int64 `json:"exp"`
91+ IP []byte `json:"ip"`
92+}
93+
94+// IssueSubmissions is the anonymous submission of issues: a signed challenge
95+// with an adaptive proof of work, and every server-side check before the issue
96+// awaits moderation.
97+type IssueSubmissions struct {
98+ meta ports.RepoMetaStore
99+ pending ports.PendingIssueStore
100+ bans ports.IssueBanStore
101+ limiter ports.SubmissionLimiter
102+ keys *IssueKeys
103+ bus ports.EventBus
104+ clock ports.Clock
105+ base int
106+ limit int
107+ minFill time.Duration
108+
109+ admitMu sync.Mutex // serializes the pending cap check and the store
110+
111+ mu sync.Mutex
112+ spent map[string]time.Time // nonce -> expiry of its challenge
113+ nextSweep time.Time
114+}
115+
116+var _ ports.IssueSubmissionService = (*IssueSubmissions)(nil)
117+
118+// IssueSubmissionsConfig holds what the submission needs. Every field but the
119+// difficulties is required.
120+type IssueSubmissionsConfig struct {
121+ Meta ports.RepoMetaStore
122+ Pending ports.PendingIssueStore
123+ Bans ports.IssueBanStore
124+ Limiter ports.SubmissionLimiter
125+ Keys *IssueKeys
126+ Bus ports.EventBus
127+ Clock ports.Clock
128+ // BaseDifficulty and MaxDifficulty replace the production constants; for
129+ // tests. Zero keeps the constants.
130+ BaseDifficulty, MaxDifficulty int
131+ // MinFillTime replaces minFillTime when positive; for tests.
132+ MinFillTime time.Duration
133+}
134+
135+// NewIssueSubmissions builds the submission use case.
136+func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
137+ s := &IssueSubmissions{
138+ meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
139+ keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
140+ base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime,
141+ spent: make(map[string]time.Time),
142+ }
143+ if cfg.BaseDifficulty > 0 {
144+ s.base = cfg.BaseDifficulty
145+ }
146+ if cfg.MaxDifficulty > 0 {
147+ s.limit = cfg.MaxDifficulty
148+ }
149+ if cfg.MinFillTime > 0 {
150+ s.minFill = cfg.MinFillTime
151+ }
152+ return s
153+}
154+
155+// openRepo returns the repository if anyone may submit to it, else
156+// domain.ErrNotFound.
157+func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) error {
158+ r, err := lookupRepo(ctx, s.meta, repo)
159+ if err != nil {
160+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrInvalidName) {
161+ return domain.ErrNotFound
162+ }
163+ return err
164+ }
165+ if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {
166+ return domain.ErrNotFound
167+ }
168+ return nil
169+}
170+
171+func (s *IssueSubmissions) sign(key, msg []byte) []byte {
172+ m := hmac.New(sha256.New, key)
173+ m.Write(msg)
174+ return m.Sum(nil)
175+}
176+
177+// Challenge issues a challenge for a client of the repository.
178+func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
179+ if err := ctx.Err(); err != nil {
180+ return ports.IssueChallenge{}, err
181+ }
182+ if err := s.openRepo(ctx, repo); err != nil {
183+ return ports.IssueChallenge{}, err
184+ }
185+ nonce := make([]byte, nonceLen)
186+ if _, err := rand.Read(nonce); err != nil {
187+ return ports.IssueChallenge{}, fmt.Errorf("random nonce: %w", err)
188+ }
189+ perAddr, global := s.limiter.Recent(ctx, addr)
190+ d := difficultyFor(s.base, s.limit, perAddr, global)
191+ now := s.clock.Now().UTC()
192+ claims := challengeClaims{
193+ V: tokenVersion, Repo: repo.String(), Nonce: nonce, Difficulty: d,
194+ IssuedAt: now.Unix(), ExpiresAt: now.Add(challengeTTL).Unix(), IP: s.keys.IPHash(addr),
195+ }
196+ payload, err := json.Marshal(claims)
197+ if err != nil {
198+ return ports.IssueChallenge{}, fmt.Errorf("encode challenge: %w", err)
199+ }
200+ return ports.IssueChallenge{
201+ Token: enc64(payload) + "." + enc64(s.sign(s.keys.PoWKey(), payload)),
202+ Nonce: enc64(nonce),
203+ Difficulty: d,
204+ FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
205+ IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
206+ MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(s.minFill),
207+ ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
208+ }, nil
209+}
210+
211+// parseToken verifies the signature and returns the claims, or false.
212+func (s *IssueSubmissions) parseToken(tok string) (challengeClaims, bool) {
213+ var c challengeClaims
214+ if len(tok) > maxChallengeLen {
215+ return c, false
216+ }
217+ p, sig, ok := strings.Cut(tok, ".")
218+ if !ok {
219+ return c, false
220+ }
221+ payload, err := base64.RawURLEncoding.DecodeString(p)
222+ if err != nil {
223+ return c, false
224+ }
225+ got, err := base64.RawURLEncoding.DecodeString(sig)
226+ if err != nil || !hmac.Equal(got, s.sign(s.keys.PoWKey(), payload)) {
227+ return c, false
228+ }
229+ if json.Unmarshal(payload, &c) != nil {
230+ return c, false
231+ }
232+ return c, true
233+}
234+
235+func validSolution(sol string) bool {
236+ if len(sol) < 1 || len(sol) > maxSolutionLen {
237+ return false
238+ }
239+ for i := 0; i < len(sol); i++ {
240+ if sol[i] < '0' || sol[i] > '9' {
241+ return false
242+ }
243+ }
244+ return true
245+}
246+
247+// spend marks the nonce spent. It returns domain.ErrSubmissionInvalid for a
248+// nonce already spent and domain.ErrSubmissionsPaused when the set is full.
249+func (s *IssueSubmissions) spend(nonce []byte, exp, now time.Time) error {
250+ s.mu.Lock()
251+ defer s.mu.Unlock()
252+ if len(s.spent) >= maxSpent || !now.Before(s.nextSweep) {
253+ for k, e := range s.spent {
254+ if now.After(e) { // an expired challenge is refused anyway
255+ delete(s.spent, k)
256+ }
257+ }
258+ s.nextSweep = now.Add(spentSweep)
259+ }
260+ key := string(nonce)
261+ if _, dup := s.spent[key]; dup {
262+ return domain.ErrSubmissionInvalid
263+ }
264+ if len(s.spent) >= maxSpent {
265+ return domain.ErrSubmissionsPaused
266+ }
267+ s.spent[key] = exp
268+ return nil
269+}
270+
271+// Submit checks the submission and stores it as a pending issue. The checks
272+// run in a fixed order and stop at the first failure; every bot-related
273+// failure answers domain.ErrSubmissionInvalid, and the precise reason is only
274+// logged (without address, hash or text).
275+func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
276+ if err := ctx.Err(); err != nil {
277+ return err
278+ }
279+ log := zerowrap.FromCtx(ctx)
280+ refuse := func(reason string, err error) error {
281+ log.Info().Str("repo", repo.String()).Str("reason", reason).Msg("issue submission refused")
282+ return err
283+ }
284+ if err := s.openRepo(ctx, repo); err != nil {
285+ return err
286+ }
287+ ipHash := s.keys.IPHash(addr)
288+ now := s.clock.Now().UTC()
289+ if f.Honeypot != "" {
290+ return refuse("honeypot", domain.ErrSubmissionInvalid)
291+ }
292+ c, ok := s.parseToken(f.Token)
293+ if !ok {
294+ return refuse("bad_token", domain.ErrSubmissionInvalid)
295+ }
296+ exp := time.Unix(c.ExpiresAt, 0)
297+ switch {
298+ case c.V != tokenVersion || len(c.Nonce) != nonceLen:
299+ return refuse("bad_token", domain.ErrSubmissionInvalid)
300+ case c.Repo != repo.String():
301+ return refuse("token_other_repo", domain.ErrSubmissionInvalid)
302+ case now.After(exp):
303+ return refuse("token_expired", domain.ErrSubmissionInvalid)
304+ case !hmac.Equal(c.IP, ipHash):
305+ return refuse("token_other_client", domain.ErrSubmissionInvalid)
306+ }
307+ if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
308+ return refuse("bad_form_token", domain.ErrSubmissionInvalid)
309+ }
310+ if now.Before(time.Unix(c.IssuedAt, 0).Add(s.minFill)) {
311+ return refuse("too_fast", domain.ErrSubmissionInvalid)
312+ }
313+ if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||
314+ leadingZeroBits(powHash(c.Nonce, f.Solution)) < c.Difficulty {
315+ return refuse("bad_pow", domain.ErrSubmissionInvalid)
316+ }
317+ if err := s.spend(c.Nonce, exp, now); err != nil {
318+ if errors.Is(err, domain.ErrSubmissionsPaused) {
319+ return refuse("spent_full", err)
320+ }
321+ return refuse("replayed", err)
322+ }
323+ banned, err := s.bans.Banned(ctx, ipHash, now)
324+ if err != nil {
325+ return fmt.Errorf("check ban: %w", err)
326+ }
327+ if banned {
328+ return refuse("banned", domain.ErrSubmissionInvalid)
329+ }
330+ title, err := domain.ParseIssueTitle(f.Title)
331+ if err != nil {
332+ return refuse("text_title", fmt.Errorf("title: %w", err))
333+ }
334+ body, err := domain.ParseIssueBody(f.Body)
335+ if err != nil {
336+ return refuse("text_body", fmt.Errorf("body: %w", err))
337+ }
338+ author, err := domain.ParseAuthorLabel(f.Author)
339+ if err != nil {
340+ return refuse("text_author", fmt.Errorf("author: %w", err))
341+ }
342+ id, err := domain.NewPendingIssueID()
343+ if err != nil {
344+ return fmt.Errorf("new pending issue id: %w", err)
345+ }
346+ if err := s.admit(ctx, addr, refuse, domain.PendingIssue{
347+ ID: id, Repo: repo, Title: title, Body: body, Author: author, CreatedAt: now, IPHash: ipHash,
348+ }); err != nil {
349+ return err
350+ }
351+ log.Info().Str("repo", repo.String()).Msg("issue submitted for moderation")
352+ s.bus.Publish(ctx, domain.IssueSubmitted{Repo: repo, ID: id, At: now})
353+ return nil
354+}
355+
356+// admit reserves the client's quota, checks the global queue cap and stores
357+// the issue. Ermite is a single process (ADR 0001), so admitMu serializes the
358+// Count -> Put section: concurrent submissions cannot exceed
359+// domain.MaxPendingIssues. It would not hold with several processes.
360+func (s *IssueSubmissions) admit(ctx context.Context, addr string, refuse func(string, error) error, p domain.PendingIssue) error {
361+ s.admitMu.Lock()
362+ defer s.admitMu.Unlock()
363+ ok, after, release := s.limiter.Reserve(ctx, addr)
364+ if !ok {
365+ return refuse("rate_limited", &domain.SubmissionRetryError{After: after})
366+ }
367+ n, err := s.pending.Count(ctx)
368+ if err != nil {
369+ release()
370+ return fmt.Errorf("count pending issues: %w", err)
371+ }
372+ if n >= domain.MaxPendingIssues {
373+ release()
374+ return refuse("pending_full", domain.ErrSubmissionsPaused)
375+ }
376+ if err := s.pending.Put(ctx, p); err != nil {
377+ release()
378+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
379+ }
380+ return nil
381+}
382+
383+func enc64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
A internal/usecase/issue_submissions_test.go +670 -0
@@ -0,0 +1,670 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "errors"
10+ "math/bits"
11+ "strconv"
12+ "strings"
13+ "sync"
14+ "sync/atomic"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/ports/mocks"
25+ "bnema.dev/ermite/internal/usecase"
26+)
27+
28+const testBits = 8
29+
30+type subFixture struct {
31+ t *testing.T
32+ meta *mocks.RepoMetaStore
33+ pending *mocks.PendingIssueStore
34+ bans *mocks.IssueBanStore
35+ limiter *mocks.SubmissionLimiter
36+ bus *mocks.EventBus
37+ uc *usecase.IssueSubmissions
38+ repo domain.Repo
39+ addr string
40+
41+ mu sync.Mutex
42+ now time.Time
43+}
44+
45+func (f *subFixture) advance(d time.Duration) {
46+ f.mu.Lock()
47+ defer f.mu.Unlock()
48+ f.now = f.now.Add(d)
49+}
50+
51+func newSubFixture(t *testing.T) *subFixture {
52+ t.Helper()
53+ f := &subFixture{
54+ t: t, meta: mocks.NewRepoMetaStore(t), pending: mocks.NewPendingIssueStore(t),
55+ bans: mocks.NewIssueBanStore(t), limiter: mocks.NewSubmissionLimiter(t), bus: mocks.NewEventBus(t),
56+ addr: "ip:203.0.113.7", now: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC),
57+ }
58+ f.repo = domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: f.now, Settings: withIssues(domain.IssuesPublic)}
59+ f.meta.EXPECT().Get(mock.Anything, f.repo.Name).Return(f.repo, nil).Maybe()
60+ clock := mocks.NewClock(t)
61+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
62+ f.mu.Lock()
63+ defer f.mu.Unlock()
64+ return f.now
65+ }).Maybe()
66+ keyStore := mocks.NewIssueKeyStore(t)
67+ keyStore.EXPECT().Key(mock.Anything).Return([]byte(strings.Repeat("k", 32)), nil)
68+ keys, err := usecase.NewIssueKeys(context.Background(), keyStore)
69+ require.NoError(t, err)
70+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
71+ Meta: f.meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Keys: keys, Bus: f.bus, Clock: clock,
72+ BaseDifficulty: testBits, MaxDifficulty: testBits + 3,
73+ })
74+ return f
75+}
76+
77+// open expects the checks after the proof of work: not banned, quota
78+// available, room. It returns the number of reservations released.
79+func (f *subFixture) open(pendingCount int) *atomic.Int32 {
80+ released := new(atomic.Int32)
81+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
82+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
83+ return true, 0, func() { released.Add(1) }
84+ }).Maybe()
85+ f.pending.EXPECT().Count(mock.Anything).Return(pendingCount, nil).Maybe()
86+ return released
87+}
88+
89+func (f *subFixture) challenge(addr string) ports.IssueChallenge {
90+ f.t.Helper()
91+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
92+ ch, err := f.uc.Challenge(context.Background(), f.repo.Name, addr)
93+ require.NoError(f.t, err)
94+ return ch
95+}
96+
97+func solve(t *testing.T, ch ports.IssueChallenge) string {
98+ t.Helper()
99+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
100+ require.NoError(t, err)
101+ for i := 0; ; i++ {
102+ s := strconv.Itoa(i)
103+ sum := sha256.Sum256(append(append(append([]byte(nil), nonce...), ':'), s...))
104+ if zeroBits(sum[:]) >= ch.Difficulty {
105+ return s
106+ }
107+ }
108+}
109+
110+func zeroBits(b []byte) int {
111+ n := 0
112+ for _, x := range b {
113+ if x == 0 {
114+ n += 8
115+ continue
116+ }
117+ return n + bits.LeadingZeros8(x)
118+ }
119+ return n
120+}
121+
122+// valid is a submission that passes every check once the fill time is over.
123+func (f *subFixture) valid(ch ports.IssueChallenge) ports.IssueSubmission {
124+ return ports.IssueSubmission{
125+ Token: ch.Token, Solution: solve(f.t, ch), FormToken: ch.FormToken,
126+ Title: "A bug", Body: "It breaks.", Author: "me",
127+ }
128+}
129+
130+func TestIssueChallenge(t *testing.T) {
131+ t.Parallel()
132+ f := newSubFixture(t)
133+ ch := f.challenge(f.addr)
134+ assert.Equal(t, testBits, ch.Difficulty)
135+ assert.Equal(t, f.now, ch.IssuedAt)
136+ assert.Equal(t, f.now.Add(usecase.MinFillTime), ch.MinSubmitAt)
137+ assert.Equal(t, f.now.Add(usecase.ChallengeTTL), ch.ExpiresAt)
138+ assert.Contains(t, ch.Token, ".")
139+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
140+ require.NoError(t, err)
141+ assert.Len(t, nonce, 16)
142+ assert.NotEmpty(t, ch.FormToken)
143+
144+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(4, 40).Once() // +2 +2
145+ ch, err = f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
146+ require.NoError(t, err)
147+ assert.Equal(t, testBits+3, ch.Difficulty) // capped at base+3
148+}
149+
150+func TestIssueSubmissionRepoAccess(t *testing.T) {
151+ t.Parallel()
152+ for name_, repo := range map[string]domain.Repo{
153+ "private": {Visibility: domain.VisibilityPrivate, Settings: withIssues(domain.IssuesPublic)},
154+ "owner issues": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOwner)},
155+ "issues off": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOff)},
156+ } {
157+ t.Run(name_, func(t *testing.T) {
158+ t.Parallel()
159+ f := newSubFixture(t)
160+ meta := mocks.NewRepoMetaStore(t)
161+ repo.Name = f.repo.Name
162+ meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
163+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Bus: f.bus, Clock: mocks.NewClock(t)})
164+ _, err := f.uc.Challenge(context.Background(), repo.Name, f.addr)
165+ assert.ErrorIs(t, err, domain.ErrNotFound)
166+ err = f.uc.Submit(context.Background(), repo.Name, f.addr, ports.IssueSubmission{})
167+ assert.ErrorIs(t, err, domain.ErrNotFound)
168+ })
169+ }
170+ t.Run("unknown", func(t *testing.T) {
171+ t.Parallel()
172+ f := newSubFixture(t)
173+ other := name(t, "other")
174+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{}, domain.ErrNotFound)
175+ assert.ErrorIs(t, f.uc.Submit(context.Background(), other, f.addr, ports.IssueSubmission{}), domain.ErrNotFound)
176+ _, err := f.uc.Challenge(context.Background(), other, f.addr)
177+ assert.ErrorIs(t, err, domain.ErrNotFound)
178+ })
179+}
180+
181+func TestIssueSubmitSuccess(t *testing.T) {
182+ t.Parallel()
183+ f := newSubFixture(t)
184+ f.open(0)
185+ ch := f.challenge(f.addr)
186+ sub := f.valid(ch)
187+ f.advance(usecase.MinFillTime)
188+ var put domain.PendingIssue
189+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.PendingIssue) error {
190+ put = p
191+ return nil
192+ }).Once()
193+ f.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
194+ e, ok := ev.(domain.IssueSubmitted)
195+ return ok && e.Repo == f.repo.Name && e.ID == put.ID
196+ })).Once()
197+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
198+ assert.Equal(t, "A bug", put.Title.String())
199+ assert.Equal(t, "It breaks.", put.Body.String())
200+ assert.Equal(t, "me", put.Author)
201+ assert.Equal(t, f.repo.Name, put.Repo)
202+ assert.Len(t, put.IPHash, domain.IPHashLen)
203+ assert.False(t, strings.Contains(string(put.IPHash), f.addr))
204+ assert.Equal(t, f.now, put.CreatedAt)
205+}
206+
207+// TestIssueSubmitRefusals: each failing check returns its sentinel; the
208+// strict mocks fail the test on any Put, Record or Publish.
209+func TestIssueSubmitRefusals(t *testing.T) {
210+ t.Parallel()
211+ type tc struct {
212+ name string
213+ want error
214+ arrange func(f *subFixture, ch *ports.IssueChallenge, sub *ports.IssueSubmission) (addr string)
215+ wait time.Duration
216+ setup func(f *subFixture) // replaces f.open
217+ }
218+ wait := usecase.MinFillTime
219+ cases := []tc{
220+ {name: "banned", want: domain.ErrSubmissionInvalid, wait: wait, setup: func(f *subFixture) {
221+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
222+ }},
223+ {name: "rate limited", want: domain.ErrSubmissionRateLimited, wait: wait, setup: func(f *subFixture) {
224+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
225+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(false, 5*time.Minute, func() {})
226+ }},
227+ {name: "paused at 200", want: domain.ErrSubmissionsPaused, wait: wait, setup: func(f *subFixture) {
228+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
229+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {})
230+ f.pending.EXPECT().Count(mock.Anything).Return(domain.MaxPendingIssues, nil)
231+ }},
232+ {name: "honeypot", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
233+ s.Honeypot = "http://spam"
234+ return ""
235+ }},
236+ {name: "forged signature", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
237+ p, sig, _ := strings.Cut(s.Token, ".")
238+ b := []byte(sig)
239+ b[0] ^= 1 // still base64url? flip between 'A'/'B'
240+ if b[0] > 'z' || b[0] < '0' {
241+ b[0] = 'A'
242+ }
243+ if string(b) == sig {
244+ b[0] = 'B'
245+ }
246+ s.Token = p + "." + string(b)
247+ return ""
248+ }},
249+ {name: "forged payload", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
250+ _, sig, _ := strings.Cut(s.Token, ".")
251+ s.Token = base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"d":1}`)) + "." + sig
252+ return ""
253+ }},
254+ {name: "token of another repo", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
255+ // a valid token for "other", sent to "proj"
256+ other := name(f.t, "other")
257+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil)
258+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(0, 0).Once()
259+ c2, err := f.uc.Challenge(context.Background(), other, f.addr)
260+ require.NoError(f.t, err)
261+ *s = f.valid(c2)
262+ return ""
263+ }},
264+ {name: "expired", want: domain.ErrSubmissionInvalid, wait: usecase.ChallengeTTL + time.Second},
265+ {name: "other ip", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, _ *ports.IssueChallenge, _ *ports.IssueSubmission) string {
266+ return "ip:198.51.100.9"
267+ }},
268+ {name: "too fast", want: domain.ErrSubmissionInvalid, wait: wait - time.Second},
269+ {name: "wrong form token", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
270+ s.FormToken = "AAAA"
271+ return ""
272+ }},
273+ {name: "insufficient pow", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
274+ // a counter that does not solve it
275+ nonce, _ := base64.RawURLEncoding.DecodeString(ch.Nonce)
276+ for i := 0; ; i++ {
277+ sum := sha256.Sum256(append(append(nonce, ':'), strconv.Itoa(i)...))
278+ if zeroBits(sum[:]) < ch.Difficulty {
279+ s.Solution = strconv.Itoa(i)
280+ return ""
281+ }
282+ }
283+ }},
284+ {name: "non digit solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
285+ s.Solution = "12a"
286+ return ""
287+ }},
288+ {name: "empty solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
289+ s.Solution = ""
290+ return ""
291+ }},
292+ {name: "huge solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
293+ s.Solution = strings.Repeat("1", 21)
294+ return ""
295+ }},
296+ }
297+ for _, c := range cases {
298+ t.Run(c.name, func(t *testing.T) {
299+ t.Parallel()
300+ f := newSubFixture(t)
301+ if c.setup != nil {
302+ c.setup(f)
303+ } else {
304+ f.open(0)
305+ }
306+ ch := f.challenge(f.addr)
307+ sub := f.valid(ch)
308+ addr := f.addr
309+ if c.arrange != nil {
310+ if a := c.arrange(f, &ch, &sub); a != "" {
311+ addr = a
312+ }
313+ }
314+ f.advance(c.wait)
315+ err := f.uc.Submit(context.Background(), f.repo.Name, addr, sub)
316+ require.ErrorIs(t, err, c.want)
317+ assert.ErrorIs(t, err, domain.ErrSubmissionRefused)
318+ if c.want == domain.ErrSubmissionRateLimited {
319+ var re *domain.SubmissionRetryError
320+ require.ErrorAs(t, err, &re)
321+ assert.Equal(t, 5*time.Minute, re.After)
322+ }
323+ })
324+ }
325+}
326+
327+func TestIssueSubmitReplayedNonce(t *testing.T) {
328+ t.Parallel()
329+ f := newSubFixture(t)
330+ f.open(0)
331+ ch := f.challenge(f.addr)
332+ sub := f.valid(ch)
333+ f.advance(usecase.MinFillTime)
334+ // first: text refused, nonce is spent anyway (nothing reserved)
335+ bad := sub
336+ bad.Title = ""
337+ var te *domain.TextError
338+ require.ErrorAs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, bad), &te)
339+ assert.Equal(t, 1, f.uc.SpentLen())
340+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
341+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
342+ assert.False(t, errors.Is(err, domain.ErrInvalidText))
343+}
344+
345+func TestIssueSubmitReplayAfterSuccess(t *testing.T) {
346+ t.Parallel()
347+ f := newSubFixture(t)
348+ f.open(0)
349+ ch := f.challenge(f.addr)
350+ sub := f.valid(ch)
351+ f.advance(usecase.MinFillTime)
352+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
353+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
354+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
355+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionInvalid)
356+}
357+
358+func TestIssueSubmitTextErrors(t *testing.T) {
359+ t.Parallel()
360+ for name_, mod := range map[string]func(*ports.IssueSubmission){
361+ "title": func(s *ports.IssueSubmission) { s.Title = "two\nlines" },
362+ "body": func(s *ports.IssueSubmission) { s.Body = strings.Repeat("x", domain.MaxIssueBodyLen+1) },
363+ "author": func(s *ports.IssueSubmission) { s.Author = strings.Repeat("x", domain.MaxAuthorLabelLen+1) },
364+ } {
365+ t.Run(name_, func(t *testing.T) {
366+ t.Parallel()
367+ f := newSubFixture(t)
368+ f.open(0)
369+ ch := f.challenge(f.addr)
370+ sub := f.valid(ch)
371+ mod(&sub)
372+ f.advance(usecase.MinFillTime)
373+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
374+ var te *domain.TextError
375+ require.ErrorAs(t, err, &te)
376+ assert.NotErrorIs(t, err, domain.ErrSubmissionRefused)
377+ })
378+ }
379+}
380+
381+func TestIssueSubmitSpentSetFull(t *testing.T) {
382+ t.Parallel()
383+ f := newSubFixture(t)
384+ f.open(0)
385+ ch := f.challenge(f.addr)
386+ sub := f.valid(ch)
387+ f.advance(usecase.MinFillTime)
388+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(time.Hour))
389+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
390+ assert.Equal(t, usecase.MaxSpent, f.uc.SpentLen()) // nothing live evicted
391+}
392+
393+func TestIssueSubmitSpentSetExpiry(t *testing.T) {
394+ t.Parallel()
395+ f := newSubFixture(t)
396+ f.open(0)
397+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Maybe()
398+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
399+ ch := f.challenge(f.addr)
400+ sub := f.valid(ch)
401+ f.advance(usecase.MinFillTime)
402+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
403+ assert.Equal(t, 1, f.uc.SpentLen())
404+ f.advance(usecase.ChallengeTTL + 2*time.Minute)
405+ ch2 := f.challenge(f.addr)
406+ sub2 := f.valid(ch2)
407+ f.advance(usecase.MinFillTime)
408+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub2))
409+ assert.Equal(t, 1, f.uc.SpentLen(), "the expired nonce was swept")
410+
411+ // a full set of expired nonces is swept, not a reason to pause
412+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(-time.Second))
413+ ch3 := f.challenge(f.addr)
414+ sub3 := f.valid(ch3)
415+ f.advance(usecase.MinFillTime)
416+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub3))
417+ assert.Equal(t, 2, f.uc.SpentLen(), "expired fillers swept, live nonces kept")
418+}
419+
420+func TestIssueSubmitGarbageDoesNotPanic(t *testing.T) {
421+ t.Parallel()
422+ f := newSubFixture(t)
423+ f.open(0)
424+ ch := f.challenge(f.addr)
425+ p, sig, _ := strings.Cut(ch.Token, ".")
426+ huge := strings.Repeat("A", 1<<20)
427+ tokens := []string{
428+ "", ".", "..", "a", "a.b", "!!!.???", huge, huge + "." + huge, p, p + ".", "." + sig,
429+ p + "." + sig + "." + sig, p + "." + sig[:len(sig)-3], p[:len(p)/2] + "." + sig,
430+ base64.RawURLEncoding.EncodeToString([]byte("null")) + "." + sig,
431+ base64.StdEncoding.EncodeToString([]byte("{}")) + "==." + sig, "\x00\xff.\x00",
432+ }
433+ f.advance(usecase.MinFillTime)
434+ for _, tok := range tokens {
435+ for _, s := range []ports.IssueSubmission{
436+ {Token: tok},
437+ {Token: tok, Solution: huge, FormToken: huge, Title: huge, Body: huge, Author: huge},
438+ {Token: tok, Solution: "0", FormToken: "\xff"},
439+ } {
440+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
441+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
442+ }
443+ }
444+ // the genuine token with garbage around it
445+ for _, s := range []ports.IssueSubmission{
446+ {Token: ch.Token, FormToken: huge, Solution: "1"},
447+ {Token: ch.Token, FormToken: ch.FormToken, Solution: huge},
448+ } {
449+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, s), domain.ErrSubmissionInvalid)
450+ }
451+}
452+
453+func TestIssueSubmissionHonoursContext(t *testing.T) {
454+ t.Parallel()
455+ f := newSubFixture(t)
456+ ctx, cancel := context.WithCancel(context.Background())
457+ cancel()
458+ assert.ErrorIs(t, f.uc.Submit(ctx, f.repo.Name, f.addr, ports.IssueSubmission{}), context.Canceled)
459+ _, err := f.uc.Challenge(ctx, f.repo.Name, f.addr)
460+ assert.ErrorIs(t, err, context.Canceled)
461+}
462+
463+func TestDifficulty(t *testing.T) {
464+ t.Parallel()
465+ for _, c := range []struct{ perAddr, global, want int }{
466+ {0, 0, 18}, {1, 0, 18}, {2, 0, 19}, {3, 0, 19}, {4, 0, 20}, {6, 0, 21}, {100, 0, 21},
467+ {0, 19, 18}, {0, 20, 19}, {0, 40, 20}, {0, 60, 21}, {0, 1000, 21},
468+ {2, 20, 20}, {4, 20, 21}, {-5, -5, 18},
469+ } {
470+ assert.Equal(t, c.want, usecase.DifficultyFor(c.perAddr, c.global), "%d/%d", c.perAddr, c.global)
471+ }
472+ assert.Equal(t, 18, usecase.BaseDifficulty)
473+ assert.Equal(t, 21, usecase.MaxDifficulty)
474+}
475+
476+// submitters builds n valid, distinct submissions (one challenge each) for the
477+// addresses addrOf(i) and the repositories repoOf(i), ready to be sent.
478+type prepared struct {
479+ repo domain.RepoName
480+ addr string
481+ sub ports.IssueSubmission
482+}
483+
484+func (f *subFixture) prepare(n int, addrOf func(int) string, repoOf func(int) domain.RepoName) []prepared {
485+ out := make([]prepared, n)
486+ for i := range n {
487+ addr := addrOf(i)
488+ repo := repoOf(i)
489+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
490+ ch, err := f.uc.Challenge(context.Background(), repo, addr)
491+ require.NoError(f.t, err)
492+ out[i] = prepared{repo: repo, addr: addr, sub: f.valid(ch)}
493+ }
494+ f.advance(usecase.MinFillTime)
495+ return out
496+}
497+
498+// race sends every prepared submission at once and returns the errors.
499+func (f *subFixture) race(ps []prepared) []error {
500+ errs := make([]error, len(ps))
501+ var wg sync.WaitGroup
502+ start := make(chan struct{})
503+ for i, p := range ps {
504+ wg.Add(1)
505+ go func() {
506+ defer wg.Done()
507+ <-start
508+ errs[i] = f.uc.Submit(context.Background(), p.repo, p.addr, p.sub)
509+ }()
510+ }
511+ close(start)
512+ wg.Wait()
513+ return errs
514+}
515+
516+func TestIssueSubmitConcurrentSameAddrRespectsQuota(t *testing.T) {
517+ t.Parallel()
518+ f := newSubFixture(t)
519+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
520+ f.pending.EXPECT().Count(mock.Anything).Return(0, nil).Maybe()
521+ // a real, atomic limiter semantics: PerHour grants then refusals
522+ var granted atomic.Int32
523+ var rlMu sync.Mutex
524+ f.limiter.EXPECT().Reserve(mock.Anything, f.addr).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
525+ rlMu.Lock()
526+ defer rlMu.Unlock()
527+ if granted.Load() >= 3 {
528+ return false, time.Minute, func() {}
529+ }
530+ granted.Add(1)
531+ return true, 0, func() {}
532+ }).Maybe()
533+ var puts atomic.Int32
534+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
535+ puts.Add(1)
536+ return nil
537+ }).Maybe()
538+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
539+ ps := f.prepare(20, func(int) string { return f.addr }, func(int) domain.RepoName { return f.repo.Name })
540+ var ok, limited int
541+ for _, err := range f.race(ps) {
542+ var re *domain.SubmissionRetryError
543+ switch {
544+ case err == nil:
545+ ok++
546+ case errors.As(err, &re):
547+ limited++
548+ default:
549+ t.Fatalf("unexpected error: %v", err)
550+ }
551+ }
552+ assert.Equal(t, 3, ok)
553+ assert.Equal(t, 17, limited)
554+ assert.Equal(t, int32(3), puts.Load())
555+}
556+
557+func TestIssueSubmitConcurrentRespectsPendingCap(t *testing.T) {
558+ t.Parallel()
559+ f := newSubFixture(t)
560+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
561+ f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
562+ other := name(t, "other")
563+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
564+ var count atomic.Int32
565+ count.Store(domain.MaxPendingIssues - 1)
566+ f.pending.EXPECT().Count(mock.Anything).RunAndReturn(func(context.Context) (int, error) {
567+ return int(count.Load()), nil
568+ }).Maybe()
569+ var puts atomic.Int32
570+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
571+ puts.Add(1)
572+ count.Add(1)
573+ return nil
574+ }).Maybe()
575+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
576+ ps := f.prepare(20, func(i int) string { return "ip:198.51.100." + strconv.Itoa(i+1) }, func(i int) domain.RepoName {
577+ if i%2 == 0 {
578+ return f.repo.Name
579+ }
580+ return other
581+ })
582+ var ok, paused int
583+ for _, err := range f.race(ps) {
584+ switch {
585+ case err == nil:
586+ ok++
587+ case errors.Is(err, domain.ErrSubmissionsPaused):
588+ paused++
589+ default:
590+ t.Fatalf("unexpected error: %v", err)
591+ }
592+ }
593+ assert.Equal(t, 1, ok)
594+ assert.Equal(t, 19, paused)
595+ assert.Equal(t, int32(1), puts.Load())
596+}
597+
598+func TestIssueSubmitReleasesTheReservationWhenStoreFails(t *testing.T) {
599+ t.Parallel()
600+ f := newSubFixture(t)
601+ released := f.open(0)
602+ boom := errors.New("disk full")
603+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(boom).Once()
604+ ch := f.challenge(f.addr)
605+ sub := f.valid(ch)
606+ f.advance(usecase.MinFillTime)
607+ require.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), boom)
608+ assert.Equal(t, int32(1), released.Load())
609+
610+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
611+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
612+ ch = f.challenge(f.addr)
613+ sub = f.valid(ch)
614+ f.advance(usecase.MinFillTime)
615+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
616+ assert.Equal(t, int32(1), released.Load(), "a stored submission keeps its reservation")
617+}
618+
619+func TestIssueSubmitReleasesTheReservationWhenPaused(t *testing.T) {
620+ t.Parallel()
621+ f := newSubFixture(t)
622+ released := f.open(domain.MaxPendingIssues)
623+ ch := f.challenge(f.addr)
624+ sub := f.valid(ch)
625+ f.advance(usecase.MinFillTime)
626+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
627+ assert.Equal(t, int32(1), released.Load())
628+}
629+
630+// TestIssueSubmitNoStateOracleBeforeProofOfWork: a banned client, a limited
631+// one, or a full queue all get the same answer to an invalid submission, and
632+// no limiter, count or store is touched (the mocks have no expectation).
633+func TestIssueSubmitNoStateOracleBeforeProofOfWork(t *testing.T) {
634+ t.Parallel()
635+ f := newSubFixture(t)
636+ ch := f.challenge(f.addr)
637+ valid := f.valid(ch)
638+ f.advance(usecase.MinFillTime)
639+ bad := []ports.IssueSubmission{
640+ {},
641+ {Token: "garbage", Solution: "x", FormToken: "y", Title: "", Body: ""},
642+ {Token: ch.Token, Honeypot: "x"},
643+ {Token: ch.Token, FormToken: ch.FormToken, Solution: "nope"},
644+ {Token: valid.Token, FormToken: valid.FormToken, Solution: "abc", Title: "two\nlines"}, // text error + bad pow
645+ }
646+ for _, s := range bad {
647+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
648+ require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
649+ assert.NotErrorIs(t, err, domain.ErrInvalidText)
650+ }
651+ // a fully valid submission from a banned client: still invalid, not
652+ // limited, paused or a text error, even with bad text.
653+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil).Once()
654+ bannedBadText := valid
655+ bannedBadText.Title = ""
656+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, bannedBadText)
657+ require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
658+ assert.NotErrorIs(t, err, domain.ErrInvalidText)
659+ var re *domain.SubmissionRetryError
660+ assert.False(t, errors.As(err, &re))
661+ assert.NotErrorIs(t, err, domain.ErrSubmissionsPaused)
662+}
663+
664+func TestIssueChallengeRevealsNoState(t *testing.T) {
665+ t.Parallel()
666+ f := newSubFixture(t) // no Banned, Reserve or Count expectation
667+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(50, 500).Once()
668+ _, err := f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
669+ require.NoError(t, err)
670+}
A internal/usecase/issues.go +291 -0
@@ -0,0 +1,291 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// MaxCommentsPerIssue is the most comments an issue holds. Past it AddComment
17+// fails with domain.ErrResourceExhausted. Comments are never deleted, so the
18+// limit counts the ones ever added.
19+const MaxCommentsPerIssue = 1000
20+
21+// Issues are the issue use cases (ADR 0031): open, comment on, close and
22+// reopen an issue. Issues share their numbers with the merge requests of the
23+// repository. A repository whose issues mode is off answers domain.ErrNotFound
24+// to everyone, the owner included, like a repository that does not exist.
25+// Reading needs the access of reading the repository (canRead), writing needs
26+// issue:write on it, which the branch prefixes of a token do not restrict:
27+// an issue writes no ref.
28+type Issues struct {
29+ meta ports.RepoMetaStore
30+ store ports.IssueStore
31+ mrs ports.MergeRequestStore
32+ bus ports.EventBus
33+ clock ports.Clock
34+}
35+
36+var (
37+ _ ports.IssueService = (*Issues)(nil)
38+ _ ports.IssueNumberResolver = (*Issues)(nil)
39+)
40+
41+// IssuesConfig holds what the issue use cases need. Every field is required.
42+type IssuesConfig struct {
43+ Meta ports.RepoMetaStore
44+ Store ports.IssueStore
45+ // MergeRequests tells which numbers name a merge request (Kind).
46+ MergeRequests ports.MergeRequestStore
47+ Bus ports.EventBus
48+ Clock ports.Clock
49+}
50+
51+// NewIssues builds the issue use cases.
52+func NewIssues(cfg IssuesConfig) *Issues {
53+ return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54+}
55+
56+// enabled looks the repository up and hides it when issues are off.
57+func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
58+ repo, err := lookupRepo(ctx, i.meta, name)
59+ if err != nil {
60+ return domain.Repo{}, err
61+ }
62+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
63+ return domain.Repo{}, domain.ErrNotFound
64+ }
65+ return repo, nil
66+}
67+
68+// readable applies the read rule: the access of reading the repository.
69+func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
70+ repo, err := i.enabled(ctx, name)
71+ if err != nil {
72+ return domain.Repo{}, err
73+ }
74+ if !canRead(p, repo) {
75+ return domain.Repo{}, domain.ErrNotFound
76+ }
77+ return repo, nil
78+}
79+
80+// writable is the write rule, as MergeRequests.writable: a principal that can
81+// neither read the repository nor holds the scope on it gets
82+// domain.ErrNotFound, one that cannot write the error of scopeAccess.
83+func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
84+ repo, err := i.enabled(ctx, name)
85+ if err != nil {
86+ return domain.Repo{}, err
87+ }
88+ if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) {
89+ return domain.Repo{}, domain.ErrNotFound
90+ }
91+ if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil {
92+ return domain.Repo{}, err
93+ }
94+ return repo, nil
95+}
96+
97+// Create opens an issue and publishes IssueOpened. The principal needs
98+// issue:write. Invalid texts give an *domain.InvalidFieldsError.
99+func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
100+ repo, err := i.writable(ctx, p, name)
101+ if err != nil {
102+ return domain.Issue{}, err
103+ }
104+ var v domain.Violations
105+ title, err := domain.ParseIssueTitle(params.Title)
106+ if err != nil {
107+ v.AddErr("title", err)
108+ }
109+ body, err := domain.ParseIssueBody(params.Body)
110+ if err != nil {
111+ v.AddErr("body", err)
112+ }
113+ if err := v.Err(); err != nil {
114+ return domain.Issue{}, err
115+ }
116+ now := i.clock.Now().UTC()
117+ issue, err := i.store.Create(ctx, domain.Issue{
118+ Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119+ Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
120+ })
121+ if err != nil {
122+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
123+ }
124+ log := zerowrap.FromCtx(ctx)
125+ log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128+ return issue, nil
129+}
130+
131+// List returns a page of the issues of a repository, highest number first.
132+func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
133+ size, err := pageSize(params.PageSize)
134+ if err != nil {
135+ return ports.IssuePage{}, err
136+ }
137+ if _, err := i.readable(ctx, p, name); err != nil {
138+ return ports.IssuePage{}, err
139+ }
140+ params.PageSize = size
141+ page, err := i.store.List(ctx, name, params)
142+ if err != nil {
143+ return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
144+ }
145+ return page, nil
146+}
147+
148+// Get returns an issue with its comments.
149+func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
150+ if _, err := i.readable(ctx, p, name); err != nil {
151+ return ports.IssueDetail{}, err
152+ }
153+ issue, err := i.store.Get(ctx, name, number)
154+ if err != nil {
155+ return ports.IssueDetail{}, err
156+ }
157+ comments, err := i.store.ListComments(ctx, name, number)
158+ if err != nil {
159+ return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
160+ }
161+ return ports.IssueDetail{Issue: issue, Comments: comments}, nil
162+}
163+
164+// AddComment adds a comment and publishes IssueCommented. The principal needs
165+// issue:write. The text must not be blank. A comment may follow a closed
166+// issue; one that already holds MaxCommentsPerIssue comments gives
167+// domain.ErrResourceExhausted.
168+func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
169+ repo, err := i.writable(ctx, p, name)
170+ if err != nil {
171+ return domain.IssueComment{}, err
172+ }
173+ issue, err := i.store.Get(ctx, name, number)
174+ if err != nil {
175+ return domain.IssueComment{}, err
176+ }
177+ var v domain.Violations
178+ body, err := domain.ParseIssueBody(text)
179+ switch {
180+ case err != nil:
181+ v.AddErr("text", err)
182+ case body.IsBlank():
183+ v.Add("text", "is required")
184+ }
185+ if err := v.Err(); err != nil {
186+ return domain.IssueComment{}, err
187+ }
188+ var c domain.IssueComment
189+ saved := false
190+ for range maxWriteRetries {
191+ if issue.CommentSeq >= MaxCommentsPerIssue {
192+ return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
193+ }
194+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
195+ issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
196+ err = i.store.SaveComment(ctx, issue, c)
197+ if err == nil {
198+ saved = true
199+ break
200+ }
201+ if !errors.Is(err, domain.ErrConflict) {
202+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
203+ }
204+ if issue, err = i.store.Get(ctx, name, number); err != nil {
205+ return domain.IssueComment{}, err
206+ }
207+ }
208+ if !saved {
209+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict)
210+ }
211+ log := zerowrap.FromCtx(ctx)
212+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
214+ i.bus.Publish(ctx, domain.IssueCommented{
215+ Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216+ })
217+ return c, nil
218+}
219+
220+// Close closes an open issue and publishes IssueClosed. The principal needs
221+// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
222+func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
223+ return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
224+}
225+
226+// Reopen reopens a closed issue and publishes IssueReopened. The principal
227+// needs issue:write. An issue that is not closed gives
228+// domain.ErrPreconditionFailed.
229+func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
230+ return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
231+}
232+
233+// transition moves an issue from one state to the other, retrying when another
234+// write changes it in between.
235+func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
236+ repo, err := i.writable(ctx, p, name)
237+ if err != nil {
238+ return domain.Issue{}, err
239+ }
240+ verb := "close"
241+ if to == domain.IssueStateOpen {
242+ verb = "reopen"
243+ }
244+ for range maxWriteRetries {
245+ issue, err := i.store.Get(ctx, name, number)
246+ if err != nil {
247+ return domain.Issue{}, err
248+ }
249+ if issue.State != from {
250+ return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
251+ }
252+ issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
253+ err = i.store.Update(ctx, issue)
254+ if errors.Is(err, domain.ErrConflict) {
255+ continue
256+ }
257+ if err != nil {
258+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
259+ }
260+ log := zerowrap.FromCtx(ctx)
261+ log.Info().Str("repo", name.String()).Int64("number", number).
262+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
263+ if to == domain.IssueStateOpen {
264+ i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265+ } else {
266+ i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
267+ }
268+ return issue, nil
269+ }
270+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
271+}
272+
273+// Kind tells what number n of the repository names. The principal needs the
274+// access of reading the repository, and issues must be on there: otherwise it
275+// is domain.ErrNotFound.
276+func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
277+ if _, err := i.readable(ctx, p, name); err != nil {
278+ return domain.NumberNone, err
279+ }
280+ if _, err := i.store.Get(ctx, name, n); err == nil {
281+ return domain.NumberIssue, nil
282+ } else if !errors.Is(err, domain.ErrNotFound) {
283+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
284+ }
285+ if _, err := i.mrs.Get(ctx, name, n); err == nil {
286+ return domain.NumberMergeRequest, nil
287+ } else if !errors.Is(err, domain.ErrNotFound) {
288+ return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
289+ }
290+ return domain.NumberNone, nil
291+}
A internal/usecase/issues_integration_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/adapters/eventbus"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// Issues over the real store: shared numbers with merge requests, the life of
21+// an issue and its events.
22+func TestIssuesOverTheStore(t *testing.T) {
23+ ctx := context.Background()
24+ store, err := kvstore.Open(ctx, filepath.Join(t.TempDir(), "kv"))
25+ require.NoError(t, err)
26+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
27+ bus := eventbus.NewWithBuffer(64)
28+ events := bus.Subscribe(ctx)
29+ n := name(t, "proj")
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = domain.IssuesOwner
32+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}))
33+
34+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: bus, Clock: fixedClock{}})
35+ agent := domain.TokenPrincipal(domain.Token{ID: "t", Label: "bot", Scopes: []domain.Scope{domain.ScopeIssueWrite}, BranchPrefixes: []string{"x/"}})
36+
37+ mrTitle, err := domain.ParseMRTitle("Change")
38+ require.NoError(t, err)
39+ mr, err := store.MergeRequests().Create(ctx, domain.MergeRequest{
40+ Repo: n, Title: mrTitle, State: domain.MRStateOpen, Source: "feat", Target: "main", Author: "a", CreatedAt: now, UpdatedAt: now,
41+ })
42+ require.NoError(t, err)
43+ assert.EqualValues(t, 1, mr.Number)
44+
45+ issue, err := issues.Create(ctx, agent, n, ports.CreateIssueParams{Title: "Crash", Body: "details"})
46+ require.NoError(t, err)
47+ assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48+ assert.Equal(t, "bot", issue.Author)
49+
50+ kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51+ require.NoError(t, err)
52+ assert.Equal(t, domain.NumberMergeRequest, kind)
53+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 2)
54+ require.NoError(t, err)
55+ assert.Equal(t, domain.NumberIssue, kind)
56+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 3)
57+ require.NoError(t, err)
58+ assert.Equal(t, domain.NumberNone, kind)
59+
60+ _, err = issues.AddComment(ctx, agent, n, 2, "me too")
61+ require.NoError(t, err)
62+ _, err = issues.Close(ctx, agent, n, 2)
63+ require.NoError(t, err)
64+ _, err = issues.AddComment(ctx, agent, n, 2, "after close")
65+ require.NoError(t, err)
66+ _, err = issues.Close(ctx, agent, n, 2)
67+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
68+ _, err = issues.Reopen(ctx, agent, n, 2)
69+ require.NoError(t, err)
70+
71+ d, err := issues.Get(ctx, domain.Anonymous(), n, 2)
72+ require.NoError(t, err)
73+ assert.Equal(t, domain.IssueStateOpen, d.Issue.State)
74+ require.Len(t, d.Comments, 2)
75+ assert.Equal(t, "me too", d.Comments[0].Text.String())
76+ assert.EqualValues(t, 2, d.Issue.CommentSeq)
77+
78+ page, err := issues.List(ctx, domain.Anonymous(), n, ports.ListIssuesParams{State: domain.IssueStateClosed})
79+ require.NoError(t, err)
80+ assert.Empty(t, page.Issues)
81+
82+ var kinds []string
83+ for len(events) > 0 {
84+ kinds = append(kinds, (<-events).EventKind())
85+ }
86+ assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
87+}
A internal/usecase/issues_test.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+type issueUnit struct {
20+ meta *mocks.RepoMetaStore
21+ store *mocks.IssueStore
22+ mrs *mocks.MergeRequestStore
23+ bus *mocks.EventBus
24+ issues *usecase.Issues
25+ repo domain.Repo
26+}
27+
28+func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *issueUnit {
29+ t.Helper()
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = mode
32+ u := &issueUnit{
33+ meta: mocks.NewRepoMetaStore(t),
34+ store: mocks.NewIssueStore(t),
35+ mrs: mocks.NewMergeRequestStore(t),
36+ bus: mocks.NewEventBus(t),
37+ repo: domain.Repo{Name: name(t, "proj"), Visibility: vis, Settings: settings},
38+ }
39+ clock := mocks.NewClock(t)
40+ clock.EXPECT().Now().Return(now).Maybe()
41+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43+ return u
44+}
45+
46+func issuePrincipal(scopes ...domain.Scope) domain.Principal {
47+ return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes, BranchPrefixes: []string{"agent/"}})
48+}
49+
50+func TestIssuesOffHidesEverything(t *testing.T) {
51+ t.Parallel()
52+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
53+ u := newIssueUnit(t, domain.VisibilityPublic, mode)
54+ ctx, n := context.Background(), u.repo.Name
55+ _, err := u.issues.Create(ctx, domain.Root(), n, ports.CreateIssueParams{Title: "t"})
56+ assert.ErrorIs(t, err, domain.ErrNotFound)
57+ _, err = u.issues.List(ctx, domain.Root(), n, ports.ListIssuesParams{})
58+ assert.ErrorIs(t, err, domain.ErrNotFound)
59+ _, err = u.issues.Get(ctx, domain.Owner(), n, 1)
60+ assert.ErrorIs(t, err, domain.ErrNotFound)
61+ _, err = u.issues.AddComment(ctx, domain.Root(), n, 1, "x")
62+ assert.ErrorIs(t, err, domain.ErrNotFound)
63+ _, err = u.issues.Close(ctx, domain.Root(), n, 1)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
66+ assert.ErrorIs(t, err, domain.ErrNotFound)
67+ _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+ }
70+}
71+
72+func TestIssueAccessRules(t *testing.T) {
73+ t.Parallel()
74+ ctx := context.Background()
75+ t.Run("private repository hides reads", func(t *testing.T) {
76+ t.Parallel()
77+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
78+ _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{})
79+ assert.ErrorIs(t, err, domain.ErrNotFound)
80+ _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1)
81+ assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read")
82+ })
83+ t.Run("writes", func(t *testing.T) {
84+ t.Parallel()
85+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
86+ p := ports.CreateIssueParams{Title: "t"}
87+ _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p)
88+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
89+ _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p)
90+ assert.ErrorIs(t, err, domain.ErrForbidden)
91+ })
92+ t.Run("private write without the scope", func(t *testing.T) {
93+ t.Parallel()
94+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
95+ _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"})
96+ assert.ErrorIs(t, err, domain.ErrNotFound)
97+ })
98+}
99+
100+func TestIssueCreateAndTransitions(t *testing.T) {
101+ t.Parallel()
102+ ctx := context.Background()
103+ p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref
104+
105+ t.Run("create", func(t *testing.T) {
106+ t.Parallel()
107+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
108+ u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
109+ return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" &&
110+ i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now)
111+ })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil })
112+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
113+ o, ok := ev.(domain.IssueOpened)
114+ return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate
115+ })).Return()
116+ got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117+ require.NoError(t, err)
118+ assert.EqualValues(t, 7, got.Number)
119+ })
120+
121+ t.Run("invalid text stores nothing", func(t *testing.T) {
122+ t.Parallel()
123+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
124+ _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""})
125+ var fe *domain.InvalidFieldsError
126+ require.ErrorAs(t, err, &fe)
127+ })
128+
129+ open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1}
130+
131+ t.Run("close with every update conflicting is an error and publishes nothing", func(t *testing.T) {
132+ t.Parallel()
133+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
134+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
135+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict)
136+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
137+ assert.ErrorIs(t, err, domain.ErrConflict)
138+ })
139+
140+ t.Run("close retries a conflict then publishes", func(t *testing.T) {
141+ t.Parallel()
142+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
143+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice()
144+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
145+ u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).Return(nil).Once()
146+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
147+ c, ok := ev.(domain.IssueClosed)
148+ return ok && c.Issue.State == domain.IssueStateClosed
149+ })).Return()
150+ got, err := u.issues.Close(ctx, p, u.repo.Name, 3)
151+ require.NoError(t, err)
152+ assert.Equal(t, domain.IssueStateClosed, got.State)
153+ })
154+
155+ t.Run("close of a closed issue", func(t *testing.T) {
156+ t.Parallel()
157+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
158+ closed := open
159+ closed.State = domain.IssueStateClosed
160+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
161+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
162+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
163+ })
164+
165+ t.Run("reopen", func(t *testing.T) {
166+ t.Parallel()
167+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
168+ closed := open
169+ closed.State = domain.IssueStateClosed
170+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
171+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil)
172+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return()
173+ got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
174+ require.NoError(t, err)
175+ assert.Equal(t, domain.IssueStateOpen, got.State)
176+ })
177+
178+ t.Run("reopen of an open issue", func(t *testing.T) {
179+ t.Parallel()
180+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
181+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
182+ _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
183+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
184+ })
185+}
186+
187+func TestIssueAddComment(t *testing.T) {
188+ t.Parallel()
189+ ctx := context.Background()
190+ p := issuePrincipal(domain.ScopeIssueWrite)
191+ issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1}
192+
193+ t.Run("every save conflicting is an error and publishes nothing", func(t *testing.T) {
194+ t.Parallel()
195+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
196+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
197+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict)
198+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi") // the bus mock fails on any Publish
199+ assert.ErrorIs(t, err, domain.ErrConflict)
200+ })
201+
202+ t.Run("on a closed issue, with a retry", func(t *testing.T) {
203+ t.Parallel()
204+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
205+ fresh := issue
206+ fresh.CommentSeq = 5
207+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once()
208+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once()
209+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
210+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }),
211+ mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once()
212+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
213+ c, ok := ev.(domain.IssueCommented)
214+ return ok && c.Number == 3 && c.Comment.ID == 6
215+ })).Return()
216+ c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi")
217+ require.NoError(t, err)
218+ assert.EqualValues(t, 6, c.ID)
219+ })
220+
221+ t.Run("blank", func(t *testing.T) {
222+ t.Parallel()
223+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
224+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
225+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ")
226+ var fe *domain.InvalidFieldsError
227+ require.ErrorAs(t, err, &fe)
228+ })
229+
230+ t.Run("full", func(t *testing.T) {
231+ t.Parallel()
232+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
233+ full := issue
234+ full.CommentSeq = usecase.MaxCommentsPerIssue
235+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil)
236+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x")
237+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
238+ })
239+}
240+
241+func TestIssueKind(t *testing.T) {
242+ t.Parallel()
243+ ctx := context.Background()
244+ t.Run("issue", func(t *testing.T) {
245+ t.Parallel()
246+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
247+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil)
248+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
249+ require.NoError(t, err)
250+ assert.Equal(t, domain.NumberIssue, k)
251+ })
252+ t.Run("merge request", func(t *testing.T) {
253+ t.Parallel()
254+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
255+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
256+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
257+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
258+ require.NoError(t, err)
259+ assert.Equal(t, domain.NumberMergeRequest, k)
260+ })
261+ t.Run("none", func(t *testing.T) {
262+ t.Parallel()
263+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
264+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
265+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound)
266+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
267+ require.NoError(t, err)
268+ assert.Equal(t, domain.NumberNone, k)
269+ })
270+ t.Run("private repository", func(t *testing.T) {
271+ t.Parallel()
272+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
273+ _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
274+ assert.ErrorIs(t, err, domain.ErrNotFound)
275+ })
276+}
M internal/usecase/mergerequests_integration_test.go +1 -1
@@ -1202,7 +1202,7 @@ func TestUpdateRepoSettings(t *testing.T) {
1202 off := false1202 off := false
1203 got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})1203 got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1204 require.NoError(t, err)1204 require.NoError(t, err)
1205- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)1205+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)
1206 again, err := r.repos.Get(r.ctx, root, r.repo)1206 again, err := r.repos.Get(r.ctx, root, r.repo)
1207 require.NoError(t, err)1207 require.NoError(t, err)
1208 assert.Equal(t, got.Settings, again.Settings)1208 assert.Equal(t, got.Settings, again.Settings)
M internal/usecase/repos_write.go +13 -3
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
62 }62 }
63 before := repo63 before := repo
64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
68 if params.AllowedMergeModes != nil {68 if params.AllowedMergeModes != nil {
@@ -72,6 +72,13 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
72 }72 }
73 params.AllowedMergeModes = &modes73 params.AllowedMergeModes = &modes
74 }74 }
75+ if params.Issues != nil {
76+ mode, err := domain.ParseIssuesMode(params.Issues.String())
77+ if err != nil {
78+ return domain.Repo{}, err
79+ }
80+ params.Issues = &mode
81+ }
75 if params.Description != nil {82 if params.Description != nil {
76 if err := validateDescription(*params.Description); err != nil {83 if err := validateDescription(*params.Description); err != nil {
77 return domain.Repo{}, err84 return domain.Repo{}, err
@@ -109,7 +116,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
109 // branch was checked above, so the remaining causes are a vanished116 // branch was checked above, so the remaining causes are a vanished
110 // repository or an I/O failure.117 // repository or an I/O failure.
111 metaDone := false118 metaDone := false
112- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
113 if repo, err = r.updateMeta(ctx, name, params); err != nil {120 if repo, err = r.updateMeta(ctx, name, params); err != nil {
114 return domain.Repo{}, err121 return domain.Repo{}, err
115 }122 }
@@ -132,7 +139,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
132 Bool("topics", params.Topics != nil).139 Bool("topics", params.Topics != nil).
133 Bool("visibility", params.Visibility != nil).140 Bool("visibility", params.Visibility != nil).
134 Bool("default_branch", params.DefaultBranch != nil).141 Bool("default_branch", params.DefaultBranch != nil).
135- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).142+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
136 Str("principal", p.Label()).Str("token_id", p.TokenID()).143 Str("principal", p.Label()).Str("token_id", p.TokenID()).
137 Msg("repository updated")144 Msg("repository updated")
138 r.bus.Publish(ctx, domain.RepoUpdated{145 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -171,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
171 if params.FailingChecksBlock != nil {178 if params.FailingChecksBlock != nil {
172 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock179 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
173 }180 }
181+ if params.Issues != nil {
182+ repo.Settings.Issues = *params.Issues
183+ }
174 err = r.meta.Update(ctx, repo)184 err = r.meta.Update(ctx, repo)
175 if errors.Is(err, domain.ErrConflict) {185 if errors.Is(err, domain.ErrConflict) {
176 continue186 continue
M internal/usecase/repos_write_test.go +42 -0
@@ -215,6 +215,7 @@ func TestUpdate(t *testing.T) {
215 "description": {Description: ptr("x")},215 "description": {Description: ptr("x")},
216 "merge modes": {AllowedMergeModes: &modes},216 "merge modes": {AllowedMergeModes: &modes},
217 "checks rule": {FailingChecksBlock: &block},217 "checks rule": {FailingChecksBlock: &block},
218+ "issues": {Issues: ptr(domain.IssuesOwner)},
218 } {219 } {
219 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish220 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
220 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)221 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
@@ -240,6 +241,47 @@ func TestUpdate(t *testing.T) {
240 })241 })
241 }242 }
242 243
244+func TestUpdateIssuesMode(t *testing.T) {
245+ t.Parallel()
246+ ctx := context.Background()
247+ n := name(t, "tools")
248+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
249+
250+ t.Run("public is stored and published", func(t *testing.T) {
251+ t.Parallel()
252+ f := newFixture(t)
253+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
254+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
255+ return r.Settings.Issues == domain.IssuesPublic
256+ })).Return(nil)
257+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259+ require.NoError(t, err)
260+ assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues)
261+ })
262+
263+ t.Run("an unknown mode is invalid", func(t *testing.T) {
264+ t.Parallel()
265+ f := newFixture(t)
266+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
267+ _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesMode("all"))})
268+ assert.ErrorIs(t, err, domain.ErrInvalid)
269+ })
270+
271+ t.Run("owner is stored and published", func(t *testing.T) {
272+ t.Parallel()
273+ f := newFixture(t)
274+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
275+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
276+ return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
277+ })).Return(nil)
278+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
279+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
280+ require.NoError(t, err)
281+ assert.Equal(t, domain.IssuesOwner, repo.Settings.Issues)
282+ })
283+}
284+
243 func TestDelete(t *testing.T) {285 func TestDelete(t *testing.T) {
244 t.Parallel()286 t.Parallel()
245 ctx := context.Background()287 ctx := context.Background()
A proto/ermite/storage/issue.proto +71 -0
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+syntax = "proto3";
6+
7+package ermite.storage;
8+
9+import "google/protobuf/timestamp.proto";
10+
11+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
12+
13+// IssueState is where an issue is in its life.
14+enum IssueState {
15+ ISSUE_STATE_UNSPECIFIED = 0;
16+ ISSUE_STATE_OPEN = 1;
17+ ISSUE_STATE_CLOSED = 2;
18+ // Awaiting moderation. Not stored yet.
19+ ISSUE_STATE_PENDING = 3;
20+}
21+
22+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
23+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
24+// indexed under "isopen/<repo>/<number:010d>".
25+message Issue {
26+ int64 number = 1;
27+ string title = 2;
28+ string body = 3;
29+ IssueState state = 4;
30+ // Label of the token that opened it.
31+ string author = 5;
32+ google.protobuf.Timestamp created_at = 6;
33+ google.protobuf.Timestamp updated_at = 7;
34+ // Last comment id handed out for this issue.
35+ int64 comment_seq = 8;
36+ // True for an issue approved from the public form: its text comes from
37+ // an anonymous visitor and is rendered as untrusted.
38+ bool external = 9;
39+}
40+
41+// IssueComment is stored under the key
42+// "isc/<repo>/<number:010d>/<id:010d>".
43+message IssueComment {
44+ int64 id = 1;
45+ string author = 2;
46+ string text = 3;
47+ google.protobuf.Timestamp created_at = 4;
48+}
49+
50+// PendingIssue is an issue submitted through the public form, stored under
51+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
52+// It has no number until the owner approves it.
53+message PendingIssue {
54+ string title = 1;
55+ string body = 2;
56+ // Display name given by the submitter. May be empty.
57+ string author = 3;
58+ google.protobuf.Timestamp created_at = 4;
59+ // HMAC-SHA256 of the client key (32 bytes).
60+ bytes ip_hash = 5;
61+}
62+
63+// IssueBan is stored under the key "isban/<hex of the hash>".
64+message IssueBan {
65+ google.protobuf.Timestamp until = 1;
66+}
67+
68+// IssueKey is stored under the key "meta/issuekey".
69+message IssueKey {
70+ bytes key = 1;
71+}
M proto/ermite/storage/merge_request.proto +10 -0
@@ -22,6 +22,16 @@ message RepoSettings {
22 // Never empty once stored.22 // Never empty once stored.
23 repeated MergeMode allowed_merge_modes = 1;23 repeated MergeMode allowed_merge_modes = 1;
24 bool failing_checks_block = 2;24 bool failing_checks_block = 2;
25+ // Who may write issues; unspecified reads as off.
26+ IssuesMode issues = 3;
27+}
28+
29+// IssuesMode says who may write issues in a repository.
30+enum IssuesMode {
31+ ISSUES_MODE_UNSPECIFIED = 0;
32+ ISSUES_MODE_OFF = 1;
33+ ISSUES_MODE_OWNER = 2;
34+ ISSUES_MODE_PUBLIC = 3;
25 }35 }
26 36
27 // MergeRequestState is where a merge request is in its life.37 // MergeRequestState is where a merge request is in its life.
M skills/ermite/CLI.md +15 -0
@@ -42,6 +42,7 @@ Scopes:
42 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |42 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |
44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
45+| `issue:write` | open, comment on, close and reopen issues |
45 | `token:admin` | create, list and revoke tokens |46 | `token:admin` | create, list and revoke tokens |
46 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |47 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
47 48
@@ -71,6 +72,20 @@ Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `descriptio
71 72
72 Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N.73 Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N.
73 74
75+## Issues
76+
77+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.
78+
79+| Command | Scope | Notes |
80+|---|---|---|
81+| `issue create <repo> --title T [--body TEXT\|-] [--body-file F\|-]` | `issue:write` | title of 120 characters max |
82+| `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 |
83+| `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 |
84+| `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |
85+| `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |
86+
87+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`.
88+
74 ## Workflows89 ## Workflows
75 90
76 | Command | Scope | Notes |91 | 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 2

Tags 45

↑↓ moveenter openesc close