Merge 346e37f5c6d7b838423d30ea0c9b7a5f83162b65 into eba40da6c2f55746079d8f7bf90538ee1c211b4f
174 files · +18065 -742
M README.md +1 -0
| @@ -134,6 +134,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t | |||
| 134 | 134 | - [Git over HTTP](docs/git-http.md): URLs and authentication | |
| 135 | 135 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | |
| 136 | 136 | - [Merge requests](docs/merge-requests.md) | |
| 137 | + | - [Issues](docs/issues.md) | |
| 137 | 138 | - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys | |
| 138 | 139 | - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links | |
| 139 | 140 | - [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 | 49 | ```sh | |
| 50 | 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 | 52 | ermite repo delete <name> [--yes] | |
| 53 | 53 | ||
| 54 | 54 | ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d] | |
| @@ -58,6 +58,9 @@ ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo | |||
| 58 | 58 | ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment> | |
| 59 | 59 | ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n> | |
| 60 | 60 | ||
| 61 | + | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | |
| 62 | + | ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n> | |
| 63 | + | ||
| 61 | 64 | ermite run list <repo> [--workflow …] [--branch …] [--status …] | |
| 62 | 65 | ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary] | |
| 63 | 66 | ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed] | |
| @@ -81,9 +84,11 @@ ermite version # client and server versions | |||
| 81 | 84 | ermite completion bash|zsh|fish | |
| 82 | 85 | ``` | |
| 83 | 86 | ||
| 87 | + | Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md). | |
| 88 | + | ||
| 84 | 89 | ## Output | |
| 85 | 90 | ||
| 86 | - | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`. | |
| 91 | + | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`. | |
| 87 | 92 | - `watch --json` prints one object per line. | |
| 88 | 93 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| 89 | 94 | - 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 | 67 | ## 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 | 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). | |
| 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). | |
| 10 | 10 | ||
| 11 | 11 | | Preset | For | | |
| 12 | 12 | |---|---| | |
| @@ -29,6 +29,7 @@ The token **can**: | |||
| 29 | 29 | ||
| 30 | 30 | - push, force-push and delete branches under its prefixes; | |
| 31 | 31 | - open, comment on and close merge requests; | |
| 32 | + | - with `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled; | |
| 32 | 33 | - with `mr:merge`, merge into a branch under its prefixes; | |
| 33 | 34 | - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches; | |
| 34 | 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 +102 -31 generated file: collapsed
M go.mod +3 -1
| @@ -32,6 +32,7 @@ require ( | |||
| 32 | 32 | golang.org/x/crypto v0.57.0 | |
| 33 | 33 | golang.org/x/sys v0.49.0 | |
| 34 | 34 | golang.org/x/term v0.46.0 | |
| 35 | + | golang.org/x/text v0.42.0 | |
| 35 | 36 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | |
| 36 | 37 | google.golang.org/protobuf v1.36.12 | |
| 37 | 38 | ) | |
| @@ -164,7 +165,6 @@ require ( | |||
| 164 | 165 | golang.org/x/mod v0.41.0 // indirect | |
| 165 | 166 | golang.org/x/net v0.59.0 // indirect | |
| 166 | 167 | golang.org/x/sync v0.23.0 // indirect | |
| 167 | - | golang.org/x/text v0.42.0 // indirect | |
| 168 | 168 | golang.org/x/tools v0.51.0 // indirect | |
| 169 | 169 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | |
| 170 | 170 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| @@ -172,3 +172,5 @@ require ( | |||
| 172 | 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | |
| 173 | 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | |
| 174 | 174 | ) | |
| 175 | + | ||
| 176 | + | replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/feat-issue-form | |
M go.sum +0 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
| @@ -95,6 +95,11 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient { | |||
| 95 | 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 | 103 | // Secrets returns the SecretService client. | |
| 99 | 104 | func (c *Client) Secrets() ermiteconnect.SecretServiceClient { | |
| 100 | 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 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 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 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 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 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | |
| 147 | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | |
| 148 | 148 | })) | |
| 149 | 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 | 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
M internal/adapters/client/features.go +4 -1
| @@ -18,6 +18,9 @@ const ( | |||
| 18 | 18 | // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch | |
| 19 | 19 | // (`ermite mr merge --delete-branch`). | |
| 20 | 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 | 24 | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | |
| 22 | 25 | FeatureSigningKey = "signing_key" | |
| 23 | 26 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| @@ -55,7 +58,7 @@ const ( | |||
| 55 | 58 | ||
| 56 | 59 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 57 | 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 | 64 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
M internal/adapters/client/scopes.go +5 -3
| @@ -35,12 +35,14 @@ func ScopeName(s ermite.Scope) string { | |||
| 35 | 35 | return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1) | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | - | // AllScopes lists every scope the contract defines, in enum order. Deriving it | |
| 39 | - | // keeps the `cli` preset ("every scope") true when the contract grows. | |
| 38 | + | // AllScopes lists every scope the contract defines that this server accepts, in | |
| 39 | + | // enum order. Deriving it keeps the `cli` preset ("every scope") true when the | |
| 40 | + | // contract grows. SCOPE_MR_REVIEW is in the contract but not yet implemented | |
| 41 | + | // here, and the server rejects it like any unknown scope, so it is left out. | |
| 40 | 42 | func AllScopes() []ermite.Scope { | |
| 41 | 43 | var scopes []ermite.Scope | |
| 42 | 44 | for n := range ermite.Scope_name { | |
| 43 | - | if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED { | |
| 45 | + | if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED && s != ermite.Scope_SCOPE_MR_REVIEW { | |
| 44 | 46 | scopes = append(scopes, s) | |
| 45 | 47 | } | |
| 46 | 48 | } | |
M internal/adapters/client/scopes_test.go +2 -0
| @@ -25,6 +25,7 @@ func TestScopeWireNames(t *testing.T) { | |||
| 25 | 25 | ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write", | |
| 26 | 26 | ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register", | |
| 27 | 27 | ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin", | |
| 28 | + | ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write", | |
| 28 | 29 | } | |
| 29 | 30 | for s, name := range want { | |
| 30 | 31 | assert.Equal(t, name, client.ScopeName(s)) | |
| @@ -43,6 +44,7 @@ func TestPresetExpansion(t *testing.T) { | |||
| 43 | 44 | ||
| 44 | 45 | agent, err := client.PresetScopes("agent") | |
| 45 | 46 | require.NoError(t, err) | |
| 47 | + | assert.NotContains(t, agent, ermite.Scope_SCOPE_ISSUE_WRITE, "agents get issue:write only when asked") | |
| 46 | 48 | assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent) | |
| 47 | 49 | ||
| 48 | 50 | ci, err := client.PresetScopes("CI") | |
M internal/adapters/kvstore/export_test.go +41 -1
| @@ -2,7 +2,13 @@ | |||
| 2 | 2 | ||
| 3 | 3 | 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 | 13 | // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of | |
| 8 | 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 | 31 | item, ok := c.db.Get(ctx, runnerKey(id)) | |
| 26 | 32 | 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 +532 -0
| @@ -0,0 +1,532 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sort" | |
| 8 | + | "strconv" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | "google.golang.org/protobuf/proto" | |
| 15 | + | ||
| 16 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 17 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue { | |
| 23 | + | t.Helper() | |
| 24 | + | ti, err := domain.ParseIssueTitle(title) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | body, err := domain.ParseIssueBody("what\nhappened") | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | return domain.Issue{ | |
| 29 | + | Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen, | |
| 30 | + | Author: "agent", CreatedAt: created, UpdatedAt: created, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment { | |
| 35 | + | t.Helper() | |
| 36 | + | body, err := domain.ParseIssueBody(text) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // countKeys counts the keys of the store under prefix. | |
| 42 | + | func countKeys(t *testing.T, s *kvstore.Store, prefix string) int { | |
| 43 | + | t.Helper() | |
| 44 | + | return s.CountKeysForTest(context.Background(), prefix) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestIssueRoundTripAndPaging(t *testing.T) { | |
| 48 | + | ctx := context.Background() | |
| 49 | + | s, _, n := repoWithMRs(t) | |
| 50 | + | is := s.Issues() | |
| 51 | + | ||
| 52 | + | first, err := is.Create(ctx, newIssue(t, n, "one")) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | assert.Equal(t, int64(1), first.Number) | |
| 55 | + | assert.NotZero(t, first.Version) | |
| 56 | + | got, err := is.Get(ctx, n, 1) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | assert.Equal(t, first, got) | |
| 59 | + | _, err = is.Get(ctx, n, 2) | |
| 60 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 61 | + | ||
| 62 | + | for _, title := range []string{"two", "three", "four"} { | |
| 63 | + | _, err := is.Create(ctx, newIssue(t, n, title)) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | } | |
| 66 | + | closed, err := is.Get(ctx, n, 2) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | closed.State = domain.IssueStateClosed | |
| 69 | + | require.NoError(t, is.Update(ctx, closed)) | |
| 70 | + | ||
| 71 | + | page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3}) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.Len(t, page.Issues, 3) | |
| 74 | + | assert.Equal(t, int64(4), page.Issues[0].Number) | |
| 75 | + | require.NotEmpty(t, page.NextPageToken) | |
| 76 | + | page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken}) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.Len(t, page.Issues, 1) | |
| 79 | + | assert.Equal(t, int64(1), page.Issues[0].Number) | |
| 80 | + | assert.Empty(t, page.NextPageToken) | |
| 81 | + | ||
| 82 | + | page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed}) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | require.Len(t, page.Issues, 1) | |
| 85 | + | assert.Equal(t, int64(2), page.Issues[0].Number) | |
| 86 | + | ||
| 87 | + | _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"}) | |
| 88 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 89 | + | ||
| 90 | + | _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x")) | |
| 91 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) { | |
| 95 | + | ctx := context.Background() | |
| 96 | + | s, _, n := repoWithMRs(t) | |
| 97 | + | is := s.Issues() | |
| 98 | + | first, err := is.Create(ctx, newIssue(t, n, "t")) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | a, b := first, first | |
| 101 | + | a.State = domain.IssueStateClosed | |
| 102 | + | require.NoError(t, is.Update(ctx, a)) | |
| 103 | + | b.State = domain.IssueStateClosed | |
| 104 | + | assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) { | |
| 108 | + | ctx := context.Background() | |
| 109 | + | s, _, n := repoWithMRs(t) | |
| 110 | + | is := s.Issues() | |
| 111 | + | issue, err := is.Create(ctx, newIssue(t, n, "t")) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | ||
| 114 | + | issue.CommentSeq = 1 | |
| 115 | + | require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first"))) | |
| 116 | + | // The issue moved on: the same version is refused. | |
| 117 | + | issue.CommentSeq = 2 | |
| 118 | + | assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict) | |
| 119 | + | issue, err = is.Get(ctx, n, issue.Number) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | issue.CommentSeq = 2 | |
| 122 | + | require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second"))) | |
| 123 | + | ||
| 124 | + | comments, err := is.ListComments(ctx, n, issue.Number) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | require.Len(t, comments, 2) | |
| 127 | + | assert.Equal(t, "first", comments[0].Text.String()) | |
| 128 | + | assert.Equal(t, int64(2), comments[1].ID) | |
| 129 | + | ||
| 130 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 131 | + | assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) { | |
| 135 | + | ctx := context.Background() | |
| 136 | + | s, mrs, n := repoWithMRs(t) | |
| 137 | + | is := s.Issues() | |
| 138 | + | const workers = 16 | |
| 139 | + | var ( | |
| 140 | + | wg sync.WaitGroup | |
| 141 | + | mu sync.Mutex | |
| 142 | + | numbers []int64 | |
| 143 | + | ) | |
| 144 | + | for w := range workers { | |
| 145 | + | wg.Go(func() { | |
| 146 | + | var number int64 | |
| 147 | + | if w%2 == 0 { | |
| 148 | + | got, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 149 | + | if !assert.NoError(t, err) { | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | number = got.Number | |
| 153 | + | } else { | |
| 154 | + | got, err := is.Create(ctx, newIssue(t, n, "t")) | |
| 155 | + | if !assert.NoError(t, err) { | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | number = got.Number | |
| 159 | + | } | |
| 160 | + | mu.Lock() | |
| 161 | + | numbers = append(numbers, number) | |
| 162 | + | mu.Unlock() | |
| 163 | + | }) | |
| 164 | + | } | |
| 165 | + | wg.Wait() | |
| 166 | + | want := make([]int64, workers) | |
| 167 | + | for i := range want { | |
| 168 | + | want[i] = int64(i + 1) | |
| 169 | + | } | |
| 170 | + | assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) { | |
| 174 | + | ctx := context.Background() | |
| 175 | + | s, _, n := repoWithMRs(t) | |
| 176 | + | sib := name(t, "proj-2") | |
| 177 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |
| 178 | + | is := s.Issues() | |
| 179 | + | ||
| 180 | + | for _, repo := range []domain.RepoName{n, sib} { | |
| 181 | + | issue, err := is.Create(ctx, newIssue(t, repo, "t")) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | issue.CommentSeq = 1 | |
| 184 | + | require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note"))) | |
| 185 | + | } | |
| 186 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 187 | + | ||
| 188 | + | page, err := is.List(ctx, n, ports.ListIssuesParams{}) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | assert.Empty(t, page.Issues) | |
| 191 | + | comments, err := is.ListComments(ctx, n, 1) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | assert.Empty(t, comments) | |
| 194 | + | assert.Empty(t, openOf(t, is, n)) | |
| 195 | + | assert.Zero(t, countKeys(t, s, "is/proj/")) | |
| 196 | + | assert.Zero(t, countKeys(t, s, "isc/proj/")) | |
| 197 | + | assert.Zero(t, countKeys(t, s, "isopen/proj/")) | |
| 198 | + | ||
| 199 | + | open := openOf(t, is, sib) | |
| 200 | + | assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues") | |
| 201 | + | comments, err = is.ListComments(ctx, sib, 1) | |
| 202 | + | require.NoError(t, err) | |
| 203 | + | assert.Len(t, comments, 1) | |
| 204 | + | ||
| 205 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate})) | |
| 206 | + | again, err := is.Create(ctx, newIssue(t, n, "fresh")) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | assert.Equal(t, int64(1), again.Number) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestOpenIssueIndexFollowsUpdates(t *testing.T) { | |
| 212 | + | ctx := context.Background() | |
| 213 | + | s, _, n := repoWithMRs(t) | |
| 214 | + | is := s.Issues() | |
| 215 | + | a, err := is.Create(ctx, newIssue(t, n, "a")) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | b, err := is.Create(ctx, newIssue(t, n, "b")) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | ||
| 220 | + | numbers := func() []int64 { | |
| 221 | + | open := openOf(t, is, n) | |
| 222 | + | var out []int64 | |
| 223 | + | for _, i := range open { | |
| 224 | + | out = append(out, i.Number) | |
| 225 | + | } | |
| 226 | + | return out | |
| 227 | + | } | |
| 228 | + | assert.Equal(t, []int64{2, 1}, numbers()) | |
| 229 | + | assert.Equal(t, 2, countKeys(t, s, "isopen/proj/")) | |
| 230 | + | ||
| 231 | + | a.State = domain.IssueStateClosed | |
| 232 | + | require.NoError(t, is.Update(ctx, a)) | |
| 233 | + | assert.Equal(t, []int64{2}, numbers()) | |
| 234 | + | assert.Equal(t, 1, countKeys(t, s, "isopen/proj/")) | |
| 235 | + | ||
| 236 | + | a, err = is.Get(ctx, n, a.Number) | |
| 237 | + | require.NoError(t, err) | |
| 238 | + | a.State = domain.IssueStateOpen | |
| 239 | + | require.NoError(t, is.Update(ctx, a)) | |
| 240 | + | assert.Equal(t, []int64{2, 1}, numbers()) | |
| 241 | + | ||
| 242 | + | // A comment keeps the index as the issue says. | |
| 243 | + | b.State = domain.IssueStateClosed | |
| 244 | + | b.CommentSeq = 1 | |
| 245 | + | require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing"))) | |
| 246 | + | assert.Equal(t, []int64{1}, numbers()) | |
| 247 | + | assert.Equal(t, 1, countKeys(t, s, "isopen/proj/")) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestRepoSettingsKeepTheIssuesMode(t *testing.T) { | |
| 251 | + | ctx := context.Background() | |
| 252 | + | s := openStore(t, t.TempDir()) | |
| 253 | + | n := name(t, "proj") | |
| 254 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate})) | |
| 255 | + | got, err := s.Get(ctx, n) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | assert.Equal(t, domain.IssuesOff, got.Settings.Issues) | |
| 258 | + | for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} { | |
| 259 | + | got.Settings.Issues = m | |
| 260 | + | require.NoError(t, s.Update(ctx, got)) | |
| 261 | + | again, err := s.Get(ctx, n) | |
| 262 | + | require.NoError(t, err) | |
| 263 | + | assert.Equal(t, m, again.Settings.Issues) | |
| 264 | + | } | |
| 265 | + | got.Settings.Issues = "everyone" | |
| 266 | + | assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope | |
| 270 | + | // before issue:write existed keep their owner rights; no other token changes. | |
| 271 | + | func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) { | |
| 272 | + | ctx := context.Background() | |
| 273 | + | dir := t.TempDir() | |
| 274 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3})) | |
| 275 | + | ||
| 276 | + | all9 := []string{ | |
| 277 | + | "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge", | |
| 278 | + | "ci:write", "secrets:write", "runner:register", "token:admin", | |
| 279 | + | } | |
| 280 | + | tokens := map[string]*storagepb.Token{ | |
| 281 | + | "full": {Id: "full", Scopes: all9}, | |
| 282 | + | "missing": {Id: "missing", Scopes: all9[:8]}, | |
| 283 | + | "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}}, | |
| 284 | + | "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}}, | |
| 285 | + | "small": {Id: "small", Scopes: []string{"repo:read"}}, | |
| 286 | + | "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)}, | |
| 287 | + | } | |
| 288 | + | for id, tok := range tokens { | |
| 289 | + | tok.Hash = make([]byte, 32) | |
| 290 | + | tok.Hash[0] = byte(len(id)) | |
| 291 | + | putRaw(t, dir, "token/"+id, mustProto(t, tok)) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | s, err := kvstore.Open(ctx, dir) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | t.Cleanup(func() { _ = s.Close(ctx) }) | |
| 297 | + | get := func(id string) domain.Token { | |
| 298 | + | tok, err := s.Tokens().Get(ctx, id) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | return tok | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | full := get("full") | |
| 304 | + | assert.Contains(t, full.Scopes, domain.ScopeIssueWrite) | |
| 305 | + | assert.True(t, domain.TokenPrincipal(full).HasOwnerRights()) | |
| 306 | + | assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter") | |
| 307 | + | ||
| 308 | + | assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite) | |
| 309 | + | assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights()) | |
| 310 | + | assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite) | |
| 311 | + | assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite) | |
| 312 | + | assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes) | |
| 313 | + | assert.Len(t, get("missing").Scopes, 8) | |
| 314 | + | assert.Len(t, get("repos").Scopes, 9) | |
| 315 | + | assert.Len(t, get("prefix").Scopes, 9) | |
| 316 | + | assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes) | |
| 317 | + | assert.Equal(t, "full", full.ID) | |
| 318 | + | ||
| 319 | + | require.NoError(t, s.Close(ctx)) | |
| 320 | + | assert.Equal(t, uint32(4), readSchema(t, dir)) | |
| 321 | + | ||
| 322 | + | // A second open changes nothing. | |
| 323 | + | s2, err := kvstore.Open(ctx, dir) | |
| 324 | + | require.NoError(t, err) | |
| 325 | + | tok, err := s2.Tokens().Get(ctx, "full") | |
| 326 | + | require.NoError(t, err) | |
| 327 | + | assert.Equal(t, full.Scopes, tok.Scopes) | |
| 328 | + | require.NoError(t, s2.Close(ctx)) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) { | |
| 332 | + | ctx := context.Background() | |
| 333 | + | dir := t.TempDir() | |
| 334 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | |
| 335 | + | _, err := kvstore.Open(ctx, dir) | |
| 336 | + | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | |
| 337 | + | assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion)) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // A reopen racing the delete of the repository either lands before the delete | |
| 341 | + | // removes it or fails; it never leaves an isopen/ key of a repository that is | |
| 342 | + | // gone. | |
| 343 | + | func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) { | |
| 344 | + | ctx := context.Background() | |
| 345 | + | for range 30 { | |
| 346 | + | s, _, n := repoWithMRs(t) | |
| 347 | + | is := s.Issues() | |
| 348 | + | issue, err := is.Create(ctx, newIssue(t, n, "t")) | |
| 349 | + | require.NoError(t, err) | |
| 350 | + | issue.State = domain.IssueStateClosed | |
| 351 | + | require.NoError(t, is.Update(ctx, issue)) | |
| 352 | + | closed, err := is.Get(ctx, n, issue.Number) | |
| 353 | + | require.NoError(t, err) | |
| 354 | + | closed.State = domain.IssueStateOpen | |
| 355 | + | ||
| 356 | + | var wg sync.WaitGroup | |
| 357 | + | start := make(chan struct{}) | |
| 358 | + | wg.Add(2) | |
| 359 | + | go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }() | |
| 360 | + | go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }() | |
| 361 | + | close(start) | |
| 362 | + | wg.Wait() | |
| 363 | + | ||
| 364 | + | for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} { | |
| 365 | + | assert.Zero(t, countKeys(t, s, prefix), prefix) | |
| 366 | + | } | |
| 367 | + | assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func TestListOpenAllSpansRepositoriesThroughTheIndex(t *testing.T) { | |
| 372 | + | ctx := context.Background() | |
| 373 | + | s, mrs, n := repoWithMRs(t) | |
| 374 | + | other := name(t, "other") | |
| 375 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |
| 376 | + | is := s.Issues() | |
| 377 | + | ||
| 378 | + | i1, err := is.Create(ctx, newIssue(t, n, "one")) | |
| 379 | + | require.NoError(t, err) | |
| 380 | + | i2, err := is.Create(ctx, newIssue(t, other, "two")) | |
| 381 | + | require.NoError(t, err) | |
| 382 | + | i3, err := is.Create(ctx, newIssue(t, other, "three")) | |
| 383 | + | require.NoError(t, err) | |
| 384 | + | i3.State = domain.IssueStateClosed | |
| 385 | + | require.NoError(t, is.Update(ctx, i3)) | |
| 386 | + | _, err = mrs.Create(ctx, newMR(t, n, "mr1")) | |
| 387 | + | require.NoError(t, err) | |
| 388 | + | m2, err := mrs.Create(ctx, newMR(t, other, "mr2")) | |
| 389 | + | require.NoError(t, err) | |
| 390 | + | m3, err := mrs.Create(ctx, newMR(t, other, "mr3")) | |
| 391 | + | require.NoError(t, err) | |
| 392 | + | m3.State = domain.MRStateClosed | |
| 393 | + | require.NoError(t, mrs.Update(ctx, m3)) | |
| 394 | + | ||
| 395 | + | issues, err := is.ListOpenAll(ctx) | |
| 396 | + | require.NoError(t, err) | |
| 397 | + | var got []string | |
| 398 | + | for _, i := range issues { | |
| 399 | + | got = append(got, i.Repo.String()+"#"+strconv.FormatInt(i.Number, 10)) | |
| 400 | + | } | |
| 401 | + | assert.ElementsMatch(t, []string{ | |
| 402 | + | i1.Repo.String() + "#" + strconv.FormatInt(i1.Number, 10), | |
| 403 | + | i2.Repo.String() + "#" + strconv.FormatInt(i2.Number, 10), | |
| 404 | + | }, got) | |
| 405 | + | ||
| 406 | + | open, err := mrs.ListOpenAll(ctx) | |
| 407 | + | require.NoError(t, err) | |
| 408 | + | got = nil | |
| 409 | + | for _, m := range open { | |
| 410 | + | got = append(got, m.Repo.String()+"#"+strconv.FormatInt(m.Number, 10)) | |
| 411 | + | } | |
| 412 | + | assert.ElementsMatch(t, []string{"proj#2", m2.Repo.String() + "#" + strconv.FormatInt(m2.Number, 10)}, got) | |
| 413 | + | assert.Len(t, open, 2) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) { | |
| 417 | + | ctx := context.Background() | |
| 418 | + | s, mrs, _ := repoWithMRs(t) | |
| 419 | + | issues, err := s.Issues().ListOpenAll(ctx) | |
| 420 | + | require.NoError(t, err) | |
| 421 | + | assert.Empty(t, issues) | |
| 422 | + | open, err := mrs.ListOpenAll(ctx) | |
| 423 | + | require.NoError(t, err) | |
| 424 | + | assert.Empty(t, open) | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) { | |
| 428 | + | ctx := context.Background() | |
| 429 | + | s, mrs, n := repoWithMRs(t) | |
| 430 | + | is := s.Issues() | |
| 431 | + | keepI, err := is.Create(ctx, newIssue(t, n, "keep")) | |
| 432 | + | require.NoError(t, err) | |
| 433 | + | goneI, err := is.Create(ctx, newIssue(t, n, "gone")) | |
| 434 | + | require.NoError(t, err) | |
| 435 | + | keepM, err := mrs.Create(ctx, newMR(t, n, "keep")) | |
| 436 | + | require.NoError(t, err) | |
| 437 | + | goneM, err := mrs.Create(ctx, newMR(t, n, "gone")) | |
| 438 | + | require.NoError(t, err) | |
| 439 | + | ||
| 440 | + | require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number)) | |
| 441 | + | require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number)) | |
| 442 | + | require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there") | |
| 443 | + | require.Positive(t, countKeys(t, s, "mropen/proj/")) | |
| 444 | + | ||
| 445 | + | issues, err := is.ListOpenAll(ctx) | |
| 446 | + | require.NoError(t, err) | |
| 447 | + | require.Len(t, issues, 1) | |
| 448 | + | assert.Equal(t, keepI.Number, issues[0].Number) | |
| 449 | + | open, err := mrs.ListOpenAll(ctx) | |
| 450 | + | require.NoError(t, err) | |
| 451 | + | require.Len(t, open, 1) | |
| 452 | + | assert.Equal(t, keepM.Number, open[0].Number) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func TestIssueListPagesThroughManyIssues(t *testing.T) { | |
| 456 | + | ctx := context.Background() | |
| 457 | + | s, _, n := repoWithMRs(t) | |
| 458 | + | is := s.Issues() | |
| 459 | + | const total = 53 | |
| 460 | + | for i := 1; i <= total; i++ { | |
| 461 | + | _, err := is.Create(ctx, newIssue(t, n, "issue "+strconv.Itoa(i))) | |
| 462 | + | require.NoError(t, err) | |
| 463 | + | } | |
| 464 | + | for i := 2; i <= total; i += 3 { // 2, 5, 8, ... | |
| 465 | + | is, err := is.Get(ctx, n, int64(i)) | |
| 466 | + | require.NoError(t, err) | |
| 467 | + | is.State = domain.IssueStateClosed | |
| 468 | + | require.NoError(t, s.Issues().Update(ctx, is)) | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | walk := func(state domain.IssueState, size int) []int64 { | |
| 472 | + | var got []int64 | |
| 473 | + | token := "" | |
| 474 | + | for { | |
| 475 | + | page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{State: state, PageSize: size, PageToken: token}) | |
| 476 | + | require.NoError(t, err) | |
| 477 | + | require.LessOrEqual(t, len(page.Issues), size) | |
| 478 | + | for _, is := range page.Issues { | |
| 479 | + | got = append(got, is.Number) | |
| 480 | + | } | |
| 481 | + | if page.NextPageToken == "" { | |
| 482 | + | return got | |
| 483 | + | } | |
| 484 | + | token = page.NextPageToken | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | for _, size := range []int{1, 7, 10, 53, 100} { | |
| 488 | + | var all, closed []int64 | |
| 489 | + | for i := int64(total); i >= 1; i-- { | |
| 490 | + | all = append(all, i) | |
| 491 | + | if i%3 == 2 { | |
| 492 | + | closed = append(closed, i) | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | assert.Equal(t, all, walk("", size), "size %d", size) | |
| 496 | + | assert.Equal(t, closed, walk(domain.IssueStateClosed, size), "closed, size %d", size) | |
| 497 | + | } | |
| 498 | + | page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{PageSize: 10, PageToken: "1"}) | |
| 499 | + | require.NoError(t, err) | |
| 500 | + | assert.Empty(t, page.Issues) | |
| 501 | + | assert.Empty(t, page.NextPageToken) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | // openOf lists the open issues of one repository through the isopen/ index. | |
| 505 | + | func openOf(t *testing.T, is *kvstore.Issues, repo domain.RepoName) []domain.Issue { | |
| 506 | + | t.Helper() | |
| 507 | + | all, err := is.ListOpenAll(context.Background()) | |
| 508 | + | require.NoError(t, err) | |
| 509 | + | var out []domain.Issue | |
| 510 | + | for _, i := range all { | |
| 511 | + | if i.Repo == repo { | |
| 512 | + | out = append(out, i) | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | sort.Slice(out, func(a, b int) bool { return out[a].Number > out[b].Number }) | |
| 516 | + | return out | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func TestStoredPendingIssueStateIsCorrupt(t *testing.T) { | |
| 520 | + | ctx := context.Background() | |
| 521 | + | s, _, n := repoWithMRs(t) | |
| 522 | + | is := s.Issues() | |
| 523 | + | raw, err := proto.Marshal(&storagepb.Issue{Number: 1, Title: "x", State: storagepb.IssueState_ISSUE_STATE_PENDING}) | |
| 524 | + | require.NoError(t, err) | |
| 525 | + | require.NoError(t, is.PutRawIssueForTest(ctx, n, 1, raw)) | |
| 526 | + | _, err = is.Get(ctx, n, 1) | |
| 527 | + | assert.ErrorContains(t, err, "pending") | |
| 528 | + | _, err = is.List(ctx, n, ports.ListIssuesParams{}) | |
| 529 | + | assert.ErrorContains(t, err, "pending") | |
| 530 | + | _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen}) | |
| 531 | + | assert.ErrorContains(t, err, "pending") | |
| 532 | + | } | |
M internal/adapters/kvstore/mergerequests.go +123 -26
| @@ -6,10 +6,12 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "hash/fnv" | |
| 9 | 10 | "net/url" | |
| 10 | 11 | "slices" | |
| 11 | 12 | "strconv" | |
| 12 | 13 | "strings" | |
| 14 | + | "sync" | |
| 13 | 15 | "unicode/utf8" | |
| 14 | 16 | ||
| 15 | 17 | kv "bnema.dev/ermite-kv" | |
| @@ -44,14 +46,15 @@ const ( | |||
| 44 | 46 | // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the | |
| 45 | 47 | // database of the Store it comes from. | |
| 46 | 48 | type MergeRequests struct { | |
| 47 | - | db *kv.DB | |
| 49 | + | db *kv.DB | |
| 50 | + | locks *numberLocks | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 50 | 53 | var _ ports.MergeRequestStore = (*MergeRequests)(nil) | |
| 51 | 54 | ||
| 52 | 55 | // MergeRequests returns the merge request store over the same database. It is | |
| 53 | 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 | 59 | func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" } | |
| 57 | 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 | 202 | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |
| 200 | 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 | 235 | for range createTries { | |
| 202 | - | repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo)) | |
| 236 | + | repoItem, repoOK := db.Get(ctx, repoKey(repo)) | |
| 203 | 237 | if !repoOK { | |
| 204 | - | return domain.MergeRequest{}, domain.ErrNotFound | |
| 238 | + | return domain.ErrNotFound | |
| 205 | 239 | } | |
| 206 | 240 | var last int64 | |
| 207 | - | item, ok := m.db.Get(ctx, seqKey(mr.Repo)) | |
| 241 | + | item, ok := db.Get(ctx, seqKey(repo)) | |
| 208 | 242 | if ok { | |
| 209 | 243 | var seq storagepb.Sequence | |
| 210 | 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 | 247 | last = seq.GetLast() | |
| 214 | 248 | } | |
| 215 | - | mr.Number = last + 1 | |
| 216 | - | val, err := encodeMergeRequest(mr) | |
| 217 | - | if err != nil { | |
| 218 | - | return domain.MergeRequest{}, err | |
| 219 | - | } | |
| 249 | + | number := last + 1 | |
| 220 | 250 | var b kv.Batch | |
| 221 | - | b.RequireVersion(repoKey(mr.Repo), repoItem.Version) | |
| 222 | - | b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo | |
| 251 | + | b.RequireVersion(repoKey(repo), repoItem.Version) | |
| 252 | + | b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey | |
| 223 | 253 | if ok { | |
| 224 | - | b.RequireVersion(seqKey(mr.Repo), item.Version) | |
| 254 | + | b.RequireVersion(seqKey(repo), item.Version) | |
| 225 | 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})) | |
| 229 | - | b.RequireAbsent(mrKey(mr.Repo, mr.Number)) | |
| 230 | - | b.Put(mrKey(mr.Repo, mr.Number), val) | |
| 231 | - | indexOpen(&b, nil, mr) | |
| 232 | - | _, err = m.db.Apply(ctx, &b) | |
| 258 | + | b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number})) | |
| 259 | + | if err := add(&b, number); err != nil { | |
| 260 | + | return err | |
| 261 | + | } | |
| 262 | + | _, err := db.Apply(ctx, &b) | |
| 233 | 263 | if errors.Is(err, kv.ErrConflict) { | |
| 234 | 264 | continue | |
| 235 | 265 | } | |
| 236 | - | if err != nil { | |
| 237 | - | return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err) | |
| 238 | - | } | |
| 239 | - | return m.Get(ctx, mr.Repo, mr.Number) | |
| 266 | + | return err | |
| 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 | 290 | // Get reads mr/<repo>/<number>. | |
| @@ -344,6 +390,52 @@ func (m *MergeRequests) ListOpen(ctx context.Context, repo domain.RepoName) ([]d | |||
| 344 | 390 | 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 | 439 | // CountOpen counts the open merge requests of the repository from the keys of | |
| 348 | 440 | // the index: no request is decoded. | |
| 349 | 441 | func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) { | |
| @@ -683,6 +775,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |||
| 683 | 775 | } | |
| 684 | 776 | pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode) | |
| 685 | 777 | } | |
| 778 | + | issues, err := encodeIssuesMode(s.Issues) | |
| 779 | + | if err != nil { | |
| 780 | + | return nil, err | |
| 781 | + | } | |
| 782 | + | pb.Issues = issues | |
| 686 | 783 | return pb, nil | |
| 687 | 784 | } | |
| 688 | 785 | ||
| @@ -707,5 +804,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |||
| 707 | 804 | if err != nil { | |
| 708 | 805 | return domain.DefaultRepoSettings() | |
| 709 | 806 | } | |
| 710 | - | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()} | |
| 807 | + | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())} | |
| 711 | 808 | } | |
M internal/adapters/kvstore/mergerequests_test.go +1 -1
| @@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) { | |||
| 441 | 441 | require.NoError(t, err) | |
| 442 | 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 | 445 | require.NoError(t, s.Update(ctx, got)) | |
| 446 | 446 | again, err := s.Get(ctx, n) | |
| 447 | 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 | 144 | for range deleteTries { | |
| 145 | 145 | snap := s.db.Snapshot(ctx) | |
| 146 | 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 | 150 | keys = append(keys, settingsKeys(ctx, snap, name)...) | |
| 149 | 151 | for len(keys) > deleteChunk { | |
| 150 | 152 | var b kv.Batch | |
M internal/adapters/kvstore/store.go +65 -4
| @@ -8,12 +8,14 @@ import ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "errors" | |
| 10 | 10 | "fmt" | |
| 11 | + | "slices" | |
| 11 | 12 | ||
| 12 | 13 | kv "bnema.dev/ermite-kv" | |
| 13 | 14 | "github.com/bnema/zerowrap" | |
| 14 | 15 | "google.golang.org/protobuf/proto" | |
| 15 | 16 | ||
| 16 | 17 | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | 19 | "bnema.dev/ermite/internal/ports" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -27,8 +29,17 @@ import ( | |||
| 27 | 29 | // (CIRun.head_sha: it would attach their checks to the merge commit, where | |
| 28 | 30 | // nothing looks for them), and the index of the open merge requests by source | |
| 29 | 31 | // branch (mropen/: an older build would not keep it up to date; the upgrade | |
| 30 | - | // builds it once, see BuildOpenIndex). | |
| 31 | - | const SchemaVersion = 3 | |
| 32 | + | // builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/, | |
| 33 | + | // isopen/, numbered from the same mrseq/ sequence as the merge requests, and | |
| 34 | + | // RepoSettings.issues): an older build would not delete them with their | |
| 35 | + | // repository, nor keep the numbers apart, and it would misread the issue:write | |
| 36 | + | // scope, so it must refuse the data. It also adds the pending issues of the | |
| 37 | + | // public form (ispend/<repo>/<id>, removed with their repository), the bans of | |
| 38 | + | // their submitters (isban/<hex hash>) and the form's server key | |
| 39 | + | // (meta/issuekey). The upgrade also adds issue:write to the | |
| 40 | + | // stored tokens that held all nine earlier scopes with no repository list and | |
| 41 | + | // no branch prefix, so that they keep the owner rights (see upgradeTokens). | |
| 42 | + | const SchemaVersion = 4 | |
| 32 | 43 | ||
| 33 | 44 | const schemaKey = "meta/schema" | |
| 34 | 45 | ||
| @@ -37,7 +48,8 @@ var ErrSchemaTooNew = errors.New("storage schema is newer than this ermite suppo | |||
| 37 | 48 | ||
| 38 | 49 | // Store is the ermite-kv backed metadata store. | |
| 39 | 50 | type Store struct { | |
| 40 | - | db *kv.DB | |
| 51 | + | db *kv.DB | |
| 52 | + | locks *numberLocks // shared by the merge request and issue stores | |
| 41 | 53 | } | |
| 42 | 54 | ||
| 43 | 55 | var _ ports.RepoMetaStore = (*Store)(nil) | |
| @@ -50,7 +62,7 @@ func Open(ctx context.Context, dir string) (*Store, error) { | |||
| 50 | 62 | if err != nil { | |
| 51 | 63 | return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err) | |
| 52 | 64 | } | |
| 53 | - | s := &Store{db: db} | |
| 65 | + | s := &Store{db: db, locks: new(numberLocks)} | |
| 54 | 66 | if err := s.checkSchema(ctx); err != nil { | |
| 55 | 67 | _ = db.Close(context.WithoutCancel(ctx)) | |
| 56 | 68 | return nil, err | |
| @@ -125,8 +137,15 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 125 | 137 | if sv.GetVersion() < SchemaVersion { | |
| 126 | 138 | // Older data stays readable as it is (the changes are additive); | |
| 127 | 139 | // the new version is recorded so that older builds refuse it. | |
| 140 | + | // The tokens are upgraded in the same batch, so that a crash | |
| 141 | + | // leaves the old version and the upgrade runs again. | |
| 128 | 142 | var b kv.Batch | |
| 129 | 143 | b.RequireVersion(key, item.Version) | |
| 144 | + | if sv.GetVersion() < 4 { | |
| 145 | + | if err := s.upgradeTokens(ctx, &b); err != nil { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | } | |
| 130 | 149 | b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion})) | |
| 131 | 150 | _, err := s.db.Apply(ctx, &b) | |
| 132 | 151 | if errors.Is(err, kv.ErrConflict) { | |
| @@ -151,3 +170,45 @@ func mustMarshal(m proto.Message) []byte { | |||
| 151 | 170 | } | |
| 152 | 171 | return b | |
| 153 | 172 | } | |
| 173 | + | ||
| 174 | + | // preIssueScopes are the nine scopes that existed before schema 4. | |
| 175 | + | var preIssueScopes = []domain.Scope{ | |
| 176 | + | domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin, | |
| 177 | + | domain.ScopeMRWrite, domain.ScopeMRMerge, | |
| 178 | + | domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister, | |
| 179 | + | domain.ScopeTokenAdmin, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that | |
| 183 | + | // holds all of preIssueScopes, limited to no repository and no branch prefix, | |
| 184 | + | // had the owner rights, which AllScopes now needs issue:write for, so it gets | |
| 185 | + | // the scope (this is no escalation). Every other token is left as it is. The | |
| 186 | + | // batch requires each token to be at the version it was read at. | |
| 187 | + | // | |
| 188 | + | //hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy | |
| 189 | + | func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error { | |
| 190 | + | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) { | |
| 191 | + | pb := &storagepb.Token{} | |
| 192 | + | if err := proto.Unmarshal(item.Value, pb); err != nil { | |
| 193 | + | return fmt.Errorf("upgrade token %s: %w", key, err) | |
| 194 | + | } | |
| 195 | + | if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 || | |
| 196 | + | slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) { | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | full := true | |
| 200 | + | for _, sc := range preIssueScopes { | |
| 201 | + | if !slices.Contains(pb.GetScopes(), sc.String()) { | |
| 202 | + | full = false | |
| 203 | + | break | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | if !full { | |
| 207 | + | continue | |
| 208 | + | } | |
| 209 | + | pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String()) | |
| 210 | + | b.RequireVersion(slices.Clone(key), item.Version) | |
| 211 | + | b.Put(slices.Clone(key), mustMarshal(pb)) | |
| 212 | + | } | |
| 213 | + | return nil | |
| 214 | + | } | |
M internal/adapters/kvstore/store_test.go +4 -4
| @@ -230,7 +230,7 @@ func TestSchema(t *testing.T) { | |||
| 230 | 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 | 234 | dir := t.TempDir() | |
| 235 | 235 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |
| 236 | 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 | 261 | assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha) | |
| 262 | 262 | assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head") | |
| 263 | 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 | 268 | dir := t.TempDir() | |
| 269 | 269 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |
| 270 | 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 | 273 | _, err = s.Get(ctx, name(t, "old")) | |
| 274 | 274 | require.NoError(t, err) | |
| 275 | 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 | 279 | // 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 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 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 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | |
| @@ -55,7 +55,11 @@ type Config struct { | |||
| 55 | 55 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | |
| 56 | 56 | MergeRequests ports.MergeRequestService | |
| 57 | 57 | SigningKey ports.SigningKeyService | |
| 58 | - | Version string | |
| 58 | + | // Issues serves IssueService. | |
| 59 | + | Issues ports.IssueService | |
| 60 | + | // IssueModeration serves the moderation calls of IssueService. | |
| 61 | + | IssueModeration ports.IssueModerationService | |
| 62 | + | Version string | |
| 59 | 63 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 60 | 64 | TrustedProxies []netip.Prefix | |
| 61 | 65 | // Limiter counts failed authentications; nil builds a default one. Share | |
| @@ -141,7 +145,8 @@ func New(cfg Config) *API { | |||
| 141 | 145 | features = append(features, FeatureCommitSignatures) | |
| 142 | 146 | } | |
| 143 | 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 | 150 | if cfg.Sessions != nil { | |
| 146 | 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 | 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 | 79 | for _, m := range s.AllowedMergeModes { | |
| 80 | 80 | out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m)) | |
| 81 | 81 | } | |
| @@ -133,6 +133,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{ | |||
| 133 | 133 | domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE, | |
| 134 | 134 | domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER, | |
| 135 | 135 | domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN, | |
| 136 | + | domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE, | |
| 136 | 137 | } | |
| 137 | 138 | ||
| 138 | 139 | func scopeFromPB(s pb.Scope) (domain.Scope, error) { | |
| @@ -471,6 +472,21 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 471 | 472 | case domain.CommentAdded: | |
| 472 | 473 | out.Time = timestamp(ev.At) | |
| 473 | 474 | out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 475 | + | case domain.IssueOpened: | |
| 476 | + | out.Time = timestamp(ev.At) | |
| 477 | + | out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}} | |
| 478 | + | case domain.IssueClosed: | |
| 479 | + | out.Time = timestamp(ev.At) | |
| 480 | + | out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}} | |
| 481 | + | case domain.IssueReopened: | |
| 482 | + | out.Time = timestamp(ev.At) | |
| 483 | + | out.Kind = &pb.Event_IssueReopened{IssueReopened: &pb.IssueReopened{Issue: issueToPB(ev.Issue)}} | |
| 484 | + | case domain.IssueCommented: | |
| 485 | + | out.Time = timestamp(ev.At) | |
| 486 | + | out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}} | |
| 487 | + | case domain.IssueSubmitted: | |
| 488 | + | out.Time = timestamp(ev.At) | |
| 489 | + | out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}} | |
| 474 | 490 | case domain.RunUpdated: | |
| 475 | 491 | out.Time = timestamp(ev.At) | |
| 476 | 492 | out.Kind = runEventToPB(ev) | |
| @@ -478,6 +494,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 478 | 494 | out.Time = timestamp(ev.At) | |
| 479 | 495 | out.Kind = jobEventToPB(ev) | |
| 480 | 496 | default: | |
| 497 | + | // Web-only events, such as domain.IssuePendingRemoved (the API has | |
| 498 | + | // no message for it; watchers list pending issues on demand). | |
| 481 | 499 | return nil | |
| 482 | 500 | } | |
| 483 | 501 | return out | |
M internal/adapters/rpc/convert_test.go +46 -1
| @@ -50,12 +50,17 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 50 | 50 | } | |
| 51 | 51 | _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED) | |
| 52 | 52 | assert.ErrorIs(t, err, domain.ErrInvalidScope) | |
| 53 | - | // Every scope of the contract is a scope of the domain. | |
| 53 | + | // Every scope of the contract is a scope of the domain, except mr:review. | |
| 54 | 54 | for v := range pb.Scope_name { | |
| 55 | 55 | if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED { | |
| 56 | 56 | continue | |
| 57 | 57 | } | |
| 58 | 58 | _, err := scopeFromPB(pb.Scope(v)) | |
| 59 | + | if pb.Scope(v) == pb.Scope_SCOPE_MR_REVIEW { | |
| 60 | + | // In the contract, not implemented here yet: rejected like an unknown scope. | |
| 61 | + | assert.ErrorIs(t, err, domain.ErrInvalidScope, pb.Scope(v)) | |
| 62 | + | continue | |
| 63 | + | } | |
| 59 | 64 | assert.NoError(t, err, pb.Scope(v)) | |
| 60 | 65 | } | |
| 61 | 66 | }) | |
| @@ -336,3 +341,43 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | |||
| 336 | 341 | assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide()) | |
| 337 | 342 | assert.EqualValues(t, 3, added.GetComment().GetLine()) | |
| 338 | 343 | } | |
| 344 | + | ||
| 345 | + | func TestIssueMappers(t *testing.T) { | |
| 346 | + | t.Parallel() | |
| 347 | + | for _, m := range domain.IssuesModes() { | |
| 348 | + | got, err := issuesModeFromPB(issuesModeToPB(m)) | |
| 349 | + | require.NoError(t, err) | |
| 350 | + | assert.Equal(t, m, got) | |
| 351 | + | } | |
| 352 | + | assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off") | |
| 353 | + | _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED) | |
| 354 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 355 | + | ||
| 356 | + | s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED) | |
| 357 | + | require.NoError(t, err) | |
| 358 | + | assert.Empty(t, s) | |
| 359 | + | _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING) | |
| 360 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 361 | + | ||
| 362 | + | n, err := domain.ParseRepoName("proj") | |
| 363 | + | require.NoError(t, err) | |
| 364 | + | ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}}) | |
| 365 | + | require.NotNil(t, ev.GetIssueClosed()) | |
| 366 | + | assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber()) | |
| 367 | + | assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState()) | |
| 368 | + | ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}}) | |
| 369 | + | assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId()) | |
| 370 | + | assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened()) | |
| 371 | + | assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened()) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) { | |
| 375 | + | t.Parallel() | |
| 376 | + | assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}})) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func TestIssueStateToPBNeverPending(t *testing.T) { | |
| 380 | + | t.Parallel() | |
| 381 | + | assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending"))) | |
| 382 | + | assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen)) | |
| 383 | + | } | |
A internal/adapters/rpc/issue.go +262 -0
| @@ -0,0 +1,262 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "strconv" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | ||
| 12 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // issueService implements ermiteconnect.IssueServiceHandler. | |
| 18 | + | type issueService struct { | |
| 19 | + | issues ports.IssueService | |
| 20 | + | moderation ports.IssueModerationService | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func issueStateToPB(s domain.IssueState) pb.IssueState { | |
| 24 | + | switch s { | |
| 25 | + | case domain.IssueStateOpen: | |
| 26 | + | return pb.IssueState_ISSUE_STATE_OPEN | |
| 27 | + | case domain.IssueStateClosed: | |
| 28 | + | return pb.IssueState_ISSUE_STATE_CLOSED | |
| 29 | + | } | |
| 30 | + | return pb.IssueState_ISSUE_STATE_UNSPECIFIED | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // issueStateFromPB maps the state filter of a listing; unspecified is the | |
| 34 | + | // empty state, which means every state. Pending is not listable (InvalidArgument): a domain Issue is never pending. | |
| 35 | + | func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) { | |
| 36 | + | switch s { | |
| 37 | + | case pb.IssueState_ISSUE_STATE_UNSPECIFIED: | |
| 38 | + | return "", nil | |
| 39 | + | case pb.IssueState_ISSUE_STATE_OPEN: | |
| 40 | + | return domain.IssueStateOpen, nil | |
| 41 | + | case pb.IssueState_ISSUE_STATE_CLOSED: | |
| 42 | + | return domain.IssueStateClosed, nil | |
| 43 | + | } | |
| 44 | + | return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode { | |
| 48 | + | switch m.OrDefault() { | |
| 49 | + | case domain.IssuesOff: | |
| 50 | + | return pb.IssuesMode_ISSUES_MODE_OFF | |
| 51 | + | case domain.IssuesOwner: | |
| 52 | + | return pb.IssuesMode_ISSUES_MODE_OWNER | |
| 53 | + | case domain.IssuesPublic: | |
| 54 | + | return pb.IssuesMode_ISSUES_MODE_PUBLIC | |
| 55 | + | } | |
| 56 | + | return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) { | |
| 60 | + | switch m { | |
| 61 | + | case pb.IssuesMode_ISSUES_MODE_OFF: | |
| 62 | + | return domain.IssuesOff, nil | |
| 63 | + | case pb.IssuesMode_ISSUES_MODE_OWNER: | |
| 64 | + | return domain.IssuesOwner, nil | |
| 65 | + | case pb.IssuesMode_ISSUES_MODE_PUBLIC: | |
| 66 | + | return domain.IssuesPublic, nil | |
| 67 | + | } | |
| 68 | + | return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func issueToPB(i domain.Issue) *pb.Issue { | |
| 72 | + | return &pb.Issue{ | |
| 73 | + | Repo: i.Repo.String(), | |
| 74 | + | Number: i.Number, | |
| 75 | + | Title: i.Title.String(), | |
| 76 | + | Body: i.Body.String(), | |
| 77 | + | State: issueStateToPB(i.State), | |
| 78 | + | Author: i.Author, | |
| 79 | + | CreatedAt: timestamp(i.CreatedAt), | |
| 80 | + | UpdatedAt: timestamp(i.UpdatedAt), | |
| 81 | + | CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func issueCommentToPB(c domain.IssueComment) *pb.IssueComment { | |
| 86 | + | return &pb.IssueComment{ | |
| 87 | + | Id: strconv.FormatInt(c.ID, 10), | |
| 88 | + | Author: c.Author, | |
| 89 | + | Text: c.Text.String(), | |
| 90 | + | CreatedAt: timestamp(c.CreatedAt), | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) { | |
| 95 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 96 | + | if err != nil { | |
| 97 | + | return nil, connectError(ctx, err) | |
| 98 | + | } | |
| 99 | + | issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()}) | |
| 100 | + | if err != nil { | |
| 101 | + | return nil, connectError(ctx, err) | |
| 102 | + | } | |
| 103 | + | return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) { | |
| 107 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, connectError(ctx, err) | |
| 110 | + | } | |
| 111 | + | state, err := issueStateFromPB(req.Msg.GetState()) | |
| 112 | + | if err != nil { | |
| 113 | + | return nil, connectError(ctx, err) | |
| 114 | + | } | |
| 115 | + | page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{ | |
| 116 | + | State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | |
| 117 | + | }) | |
| 118 | + | if err != nil { | |
| 119 | + | return nil, connectError(ctx, err) | |
| 120 | + | } | |
| 121 | + | out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))} | |
| 122 | + | for _, i := range page.Issues { | |
| 123 | + | out.Issues = append(out.Issues, issueToPB(i)) | |
| 124 | + | } | |
| 125 | + | return connect.NewResponse(out), nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) { | |
| 129 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, connectError(ctx, err) | |
| 132 | + | } | |
| 133 | + | d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber()) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, connectError(ctx, err) | |
| 136 | + | } | |
| 137 | + | out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))} | |
| 138 | + | for _, c := range d.Comments { | |
| 139 | + | out.Comments = append(out.Comments, issueCommentToPB(c)) | |
| 140 | + | } | |
| 141 | + | return connect.NewResponse(out), nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) { | |
| 145 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 146 | + | if err != nil { | |
| 147 | + | return nil, connectError(ctx, err) | |
| 148 | + | } | |
| 149 | + | c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText()) | |
| 150 | + | if err != nil { | |
| 151 | + | return nil, connectError(ctx, err) | |
| 152 | + | } | |
| 153 | + | return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) { | |
| 157 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 158 | + | if err != nil { | |
| 159 | + | return nil, connectError(ctx, err) | |
| 160 | + | } | |
| 161 | + | issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber()) | |
| 162 | + | if err != nil { | |
| 163 | + | return nil, connectError(ctx, err) | |
| 164 | + | } | |
| 165 | + | return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) { | |
| 169 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 170 | + | if err != nil { | |
| 171 | + | return nil, connectError(ctx, err) | |
| 172 | + | } | |
| 173 | + | issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber()) | |
| 174 | + | if err != nil { | |
| 175 | + | return nil, connectError(ctx, err) | |
| 176 | + | } | |
| 177 | + | return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue { | |
| 181 | + | return &pb.PendingIssue{ | |
| 182 | + | Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(), | |
| 183 | + | Author: p.Author, CreatedAt: timestamp(p.CreatedAt), | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // pendingRef parses the repository and the id of a moderation request. A | |
| 188 | + | // malformed id is reported as not found, like an unknown one. | |
| 189 | + | func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) { | |
| 190 | + | name, err := domain.ParseRepoName(repo) | |
| 191 | + | if err != nil { | |
| 192 | + | return domain.RepoName{}, "", err | |
| 193 | + | } | |
| 194 | + | pid, err := domain.ParsePendingIssueID(id) | |
| 195 | + | if err != nil { | |
| 196 | + | return domain.RepoName{}, "", domain.ErrNotFound | |
| 197 | + | } | |
| 198 | + | return name, pid, nil | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // requireOwner answers NotFound to anyone without owner rights, before the | |
| 202 | + | // request is parsed: a malformed repository must not tell a non-owner more | |
| 203 | + | // than a well-formed one does. | |
| 204 | + | func requireOwner(ctx context.Context) error { | |
| 205 | + | if !caller(ctx).HasOwnerRights() { | |
| 206 | + | return connectError(ctx, domain.ErrNotFound) | |
| 207 | + | } | |
| 208 | + | return nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) { | |
| 212 | + | if err := requireOwner(ctx); err != nil { | |
| 213 | + | return nil, err | |
| 214 | + | } | |
| 215 | + | var name domain.RepoName | |
| 216 | + | if req.Msg.GetRepo() != "" { | |
| 217 | + | var err error | |
| 218 | + | if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil { | |
| 219 | + | return nil, connectError(ctx, err) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{ | |
| 223 | + | PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | |
| 224 | + | }) | |
| 225 | + | if err != nil { | |
| 226 | + | return nil, connectError(ctx, err) | |
| 227 | + | } | |
| 228 | + | out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))} | |
| 229 | + | for _, p := range page.Issues { | |
| 230 | + | out.Issues = append(out.Issues, pendingIssueToPB(p)) | |
| 231 | + | } | |
| 232 | + | return connect.NewResponse(out), nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) { | |
| 236 | + | if err := requireOwner(ctx); err != nil { | |
| 237 | + | return nil, err | |
| 238 | + | } | |
| 239 | + | name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId()) | |
| 240 | + | if err != nil { | |
| 241 | + | return nil, connectError(ctx, err) | |
| 242 | + | } | |
| 243 | + | issue, err := s.moderation.Approve(ctx, caller(ctx), name, id) | |
| 244 | + | if err != nil { | |
| 245 | + | return nil, connectError(ctx, err) | |
| 246 | + | } | |
| 247 | + | return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) { | |
| 251 | + | if err := requireOwner(ctx); err != nil { | |
| 252 | + | return nil, err | |
| 253 | + | } | |
| 254 | + | name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId()) | |
| 255 | + | if err != nil { | |
| 256 | + | return nil, connectError(ctx, err) | |
| 257 | + | } | |
| 258 | + | if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil { | |
| 259 | + | return nil, connectError(ctx, err) | |
| 260 | + | } | |
| 261 | + | return connect.NewResponse(&pb.RejectIssueResponse{}), nil | |
| 262 | + | } | |
M internal/adapters/rpc/mergerequest.go +7 -1
| @@ -10,12 +10,18 @@ import ( | |||
| 10 | 10 | "connectrpc.com/connect" | |
| 11 | 11 | ||
| 12 | 12 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 13 | 14 | "bnema.dev/ermite/internal/domain" | |
| 14 | 15 | "bnema.dev/ermite/internal/ports" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler. | |
| 18 | - | type mergeRequestService struct{ mrs ports.MergeRequestService } | |
| 19 | + | type mergeRequestService struct { | |
| 20 | + | // The note methods (edit, delete, verdicts) are implemented by the merge | |
| 21 | + | // request notes work; until then they answer CodeUnimplemented. | |
| 22 | + | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 23 | + | mrs ports.MergeRequestService | |
| 24 | + | } | |
| 19 | 25 | ||
| 20 | 26 | func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState { | |
| 21 | 27 | switch s { | |
M internal/adapters/rpc/mergerequest_test.go +11 -11
| @@ -41,7 +41,7 @@ func TestMergeRequestServiceCreate(t *testing.T) { | |||
| 41 | 41 | Title: "t", Description: "d", Source: "feat", Target: "main", | |
| 42 | 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 | 45 | Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main", | |
| 46 | 46 | })) | |
| 47 | 47 | if tt.wantCode != 0 { | |
| @@ -57,7 +57,7 @@ func TestMergeRequestServiceCreate(t *testing.T) { | |||
| 57 | 57 | ||
| 58 | 58 | func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) { | |
| 59 | 59 | t.Parallel() | |
| 60 | - | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(), | |
| 60 | + | _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(), | |
| 61 | 61 | connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)})) | |
| 62 | 62 | requireCode(t, connect.CodeInvalidArgument, err) | |
| 63 | 63 | } | |
| @@ -68,7 +68,7 @@ func TestMergeRequestServiceListFiltersByState(t *testing.T) { | |||
| 68 | 68 | mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{ | |
| 69 | 69 | State: domain.MRStateMerged, PageSize: 3, PageToken: "tok", | |
| 70 | 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 | 72 | Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok", | |
| 73 | 73 | })) | |
| 74 | 74 | require.NoError(t, err) | |
| @@ -99,7 +99,7 @@ func TestMergeRequestServiceAddComment(t *testing.T) { | |||
| 99 | 99 | mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything). | |
| 100 | 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 | 103 | Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi", | |
| 104 | 104 | })) | |
| 105 | 105 | if tt.wantCode != 0 { | |
| @@ -120,7 +120,7 @@ func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) { | |||
| 120 | 120 | mrs := mocks.NewMergeRequestService(t) | |
| 121 | 121 | mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true). | |
| 122 | 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 | 124 | Repo: "demo", Number: 1, CommentId: id, Resolved: true, | |
| 125 | 125 | })) | |
| 126 | 126 | requireCode(t, connect.CodeNotFound, err) | |
| @@ -154,7 +154,7 @@ func TestMergeRequestServiceMerge(t *testing.T) { | |||
| 154 | 154 | return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero() | |
| 155 | 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 | 158 | if tt.wantCode != 0 { | |
| 159 | 159 | requireCode(t, tt.wantCode, err) | |
| 160 | 160 | return | |
| @@ -171,20 +171,20 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) { | |||
| 171 | 171 | t.Parallel() | |
| 172 | 172 | mrs := mocks.NewMergeRequestService(t) | |
| 173 | 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 | 175 | requireCode(t, connect.CodeNotFound, err) | |
| 176 | 176 | }) | |
| 177 | 177 | t.Run("close ok", func(t *testing.T) { | |
| 178 | 178 | t.Parallel() | |
| 179 | 179 | mrs := mocks.NewMergeRequestService(t) | |
| 180 | 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 | 182 | require.NoError(t, err) | |
| 183 | 183 | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState()) | |
| 184 | 184 | }) | |
| 185 | 185 | t.Run("mark read rejects a short head", func(t *testing.T) { | |
| 186 | 186 | t.Parallel() | |
| 187 | - | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(), | |
| 187 | + | _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(), | |
| 188 | 188 | connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"})) | |
| 189 | 189 | requireCode(t, connect.CodeInvalidArgument, err) | |
| 190 | 190 | }) | |
| @@ -192,7 +192,7 @@ func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) { | |||
| 192 | 192 | t.Parallel() | |
| 193 | 193 | mrs := mocks.NewMergeRequestService(t) | |
| 194 | 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 | 196 | requireCode(t, connect.CodeUnauthenticated, err) | |
| 197 | 197 | }) | |
| 198 | 198 | } | |
| @@ -214,7 +214,7 @@ func TestMergeRequestServiceGet(t *testing.T) { | |||
| 214 | 214 | mrs := mocks.NewMergeRequestService(t) | |
| 215 | 215 | mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}). | |
| 216 | 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 | 218 | if tt.wantCode != 0 { | |
| 219 | 219 | requireCode(t, tt.wantCode, err) | |
| 220 | 220 | 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 | 81 | // updateParams maps the field mask of an update. The mask must name at least | |
| 82 | 82 | // one of "description", "topics", "visibility", "default_branch", "settings", | |
| 83 | 83 | // "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 | 87 | //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 | 88 | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |
| 88 | 89 | var params ports.UpdateRepoParams | |
| 89 | 90 | paths := msg.GetUpdateMask().GetPaths() | |
| 90 | - | if len(paths) == 0 { | |
| 91 | + | if len(paths) == 0 && msg.Issues == nil { | |
| 91 | 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 | 101 | for _, path := range paths { | |
| 94 | 102 | switch path { | |
| 95 | 103 | case "description": | |
M internal/adapters/web/ASSETS.md +2 -0
| @@ -13,6 +13,8 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 13 | 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 | 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 | 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 | 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 | 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 | 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 | 24 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 25 | 25 | // the content, so they can be cached for ever. | |
| 26 | 26 | type Assets struct { | |
| 27 | - | CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string | |
| 27 | + | CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string | |
| 28 | 28 | // Manifest is the web app manifest (install on a phone's home screen), | |
| 29 | 29 | // AppleIcon the home screen icon iOS reads from a link instead. | |
| 30 | 30 | Manifest, AppleIcon string | |
| @@ -65,6 +65,8 @@ var scripts = []struct { | |||
| 65 | 65 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | |
| 66 | 66 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | |
| 67 | 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 | 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 | 27 | // the HTML fragments the page shows, sent as Server-Sent Events: | |
| 28 | 28 | // | |
| 29 | 29 | // GET /-/events?topic=home | |
| 30 | + | // GET /-/events?topic=owner&view=inbox[&repo=<name>] (the signed-in owner only) | |
| 30 | 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | |
| 31 | 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 32 | 33 | // GET /-/events?topic=repo:<name>&view=mrs | |
| 33 | 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 | 37 | // GET /-/events?topic=repo:<name>&view=actions | |
| 35 | 38 | // GET /-/events?topic=repo:<name>&view=run&n=<run id> | |
| 36 | 39 | // GET /-/events?topic=repo:<name>&view=job&job=<job id> | |
| @@ -51,7 +54,7 @@ import ( | |||
| 51 | 54 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| 52 | 55 | // <dl id="refs-kv">…</dl>, the facts of the rail | |
| 53 | 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 | 58 | // something changed, with the link that reloads the page | |
| 56 | 59 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs), | |
| 57 | 60 | // <section id="recent-actions">…</section> (the rail of the summary) or | |
| @@ -82,9 +85,14 @@ const ( | |||
| 82 | 85 | 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 | 94 | func parseTopic(topic string) (repo string, ok bool) { | |
| 87 | - | if topic == "home" { | |
| 95 | + | if topic == "home" || topic == ownerTopic { | |
| 88 | 96 | return "", true | |
| 89 | 97 | } | |
| 90 | 98 | if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" { | |
| @@ -93,6 +101,21 @@ func parseTopic(topic string) (repo string, ok bool) { | |||
| 93 | 101 | 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 | 119 | // events is GET /-/events. | |
| 97 | 120 | func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |
| 98 | 121 | if s.cfg.Events == nil { | |
| @@ -102,6 +125,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 102 | 125 | q := r.URL.Query() | |
| 103 | 126 | topic := q.Get("topic") | |
| 104 | 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 | 133 | if !ok { | |
| 106 | 134 | denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>") | |
| 107 | 135 | return | |
| @@ -114,6 +142,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 114 | 142 | // A repository the visitor cannot see is a missing one (AC2). | |
| 115 | 143 | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | |
| 116 | 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 | 150 | ccancel() | |
| 118 | 151 | if err != nil { | |
| 119 | 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 | 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 | 259 | handle := func(m recvd) bool { | |
| 224 | 260 | if m.err != nil { | |
| 225 | 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 | 334 | 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 | 357 | // denyStream answers a request that gets no stream. | |
| 302 | 358 | func denyStream(w http.ResponseWriter, status int, msg string) { | |
| 303 | 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 | 37 | func TestParseTopic(t *testing.T) { | |
| 38 | 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 | 40 | repo, ok := parseTopic(in) | |
| 41 | 41 | assert.True(t, ok, in) | |
| 42 | 42 | assert.Equal(t, want, repo, in) | |
| @@ -428,3 +428,40 @@ func TestEventsInvalidCookieStream(t *testing.T) { | |||
| 428 | 428 | assert.Contains(t, resp.Header.Values("Vary"), "Cookie") | |
| 429 | 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 | 21 | type liveStream struct { | |
| 22 | 22 | s *Server | |
| 23 | 23 | repo string // "" on the home page | |
| 24 | - | view string // "summary", "refs" or "commits" | |
| 25 | - | ref string // commits: the branch listed | |
| 26 | - | head string // commits: the commit listed first | |
| 27 | - | n string // mr: the number of the merge request shown; run: the run id | |
| 28 | - | job string // job: the id of the job shown | |
| 24 | + | // filter is the repository the inbox is filtered on, "" for all. | |
| 25 | + | filter string | |
| 26 | + | view string // "summary", "refs" or "commits" | |
| 27 | + | ref string // commits: the branch listed | |
| 28 | + | head string // commits: the commit listed first | |
| 29 | + | n string // mr: the number of the merge request shown; run: the run id | |
| 30 | + | job string // job: the id of the job shown | |
| 29 | 31 | // sha is the commit of the run of the job shown, read once when the stream | |
| 30 | 32 | // is set up: the annotations link to files at it, and it never changes. | |
| 31 | 33 | sha string | |
| @@ -59,7 +61,8 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 59 | 61 | switch we.Event.(type) { | |
| 60 | 62 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 61 | 63 | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, | |
| 62 | - | domain.RunUpdated, domain.JobUpdated: | |
| 64 | + | domain.RunUpdated, domain.JobUpdated, | |
| 65 | + | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | |
| 63 | 66 | return true | |
| 64 | 67 | } | |
| 65 | 68 | return false | |
| @@ -74,6 +77,9 @@ func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragmen | |||
| 74 | 77 | if we.Event == nil { | |
| 75 | 78 | return nil, nil | |
| 76 | 79 | } | |
| 80 | + | if l.view == "inbox" { | |
| 81 | + | return l.inbox(ctx, we) | |
| 82 | + | } | |
| 77 | 83 | if l.repo == "" { | |
| 78 | 84 | return l.home(ctx, we) | |
| 79 | 85 | } | |
| @@ -166,6 +172,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 166 | 172 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | |
| 167 | 173 | case domain.CommentAdded: | |
| 168 | 174 | return l.mrNotice(ctx, ev.Number, "a comment was added — reload") | |
| 175 | + | case domain.IssueOpened: | |
| 176 | + | return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload") | |
| 177 | + | case domain.IssueClosed: | |
| 178 | + | return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload") | |
| 179 | + | case domain.IssueReopened: | |
| 180 | + | return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload") | |
| 181 | + | case domain.IssueCommented: | |
| 182 | + | return l.issueNotice(ctx, ev.Number, "a comment was added — reload") | |
| 169 | 183 | case domain.PushEvent: | |
| 170 | 184 | switch l.view { | |
| 171 | 185 | case "mr": | |
| @@ -400,6 +414,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([ | |||
| 400 | 414 | return nil, nil | |
| 401 | 415 | } | |
| 402 | 416 | ||
| 417 | + | // issueNotice tells an issue page that something happened to the issue it | |
| 418 | + | // shows, or the list that its content changed. Like mrNotice it sends a fixed | |
| 419 | + | // sentence only, in the same slot. | |
| 420 | + | func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) { | |
| 421 | + | switch l.view { | |
| 422 | + | case "issues": | |
| 423 | + | return l.notice(ctx, "issues changed — reload", issuesBase(l.repo)) | |
| 424 | + | case "issue": | |
| 425 | + | if strconv.FormatInt(number, 10) != l.n { | |
| 426 | + | return nil, nil | |
| 427 | + | } | |
| 428 | + | return l.notice(ctx, text, issueHref(l.repo, number)) | |
| 429 | + | } | |
| 430 | + | return nil, nil | |
| 431 | + | } | |
| 432 | + | ||
| 403 | 433 | // mrPushed announces a push to the source branch of the merge request shown. | |
| 404 | 434 | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 405 | 435 | if l.ref == "" { | |
M internal/adapters/web/fragments.templ +1 -1
| @@ -102,7 +102,7 @@ templ Live(l Layout) { | |||
| 102 | 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 103 | 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 104 | 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 | 106 | gap -> ermiteLive.reload()" | |
| 107 | 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 | 121 | p.Sorts = append(p.Sorts, sl) | |
| 122 | 122 | } | |
| 123 | 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 | 128 | l := s.base() | |
| 126 | 129 | l.Title = s.instance | |
A internal/adapters/web/inbox.go +294 -0
| @@ -0,0 +1,294 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/url" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/a-h/templ" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and | |
| 20 | + | // issues of every repository, newest activity first, with a filter by | |
| 21 | + | // repository (?repo=<name>, a plain link, so it works without scripts). Only a | |
| 22 | + | // signed-in owner sees it: for anyone else the path is as missing as any other. | |
| 23 | + | // It follows the stream "owner" with view=inbox: each event of a merge request | |
| 24 | + | // or an issue sends the counters, the two lists and the filter again. | |
| 25 | + | ||
| 26 | + | // inboxPath is the page of the inbox. | |
| 27 | + | const inboxPath = "/-/inbox" | |
| 28 | + | ||
| 29 | + | // inboxEvent is the name of the live events of the inbox. | |
| 30 | + | const inboxEvent = "inbox" | |
| 31 | + | ||
| 32 | + | // InboxRow is one open item of the inbox. | |
| 33 | + | type InboxRow struct { | |
| 34 | + | Repo string | |
| 35 | + | RepoHref string | |
| 36 | + | Number string | |
| 37 | + | Href string | |
| 38 | + | Title string | |
| 39 | + | When string | |
| 40 | + | WhenAt string | |
| 41 | + | Cursor bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // PendingRow is one issue awaiting moderation. Everything in it is plain text: | |
| 45 | + | // it comes from an anonymous visitor and is never rendered as Markdown. | |
| 46 | + | type PendingRow struct { | |
| 47 | + | Repo string | |
| 48 | + | RepoHref string | |
| 49 | + | Author string | |
| 50 | + | Title string | |
| 51 | + | // Preview is the start of the body, at most pendingPreviewRunes characters. | |
| 52 | + | Preview string | |
| 53 | + | When string | |
| 54 | + | WhenAt string | |
| 55 | + | // Approve, Reject and Ban are the URLs the buttons post to; empty without | |
| 56 | + | // the moderation use case. | |
| 57 | + | Approve, Reject, Ban string | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // pendingPreviewRunes bounds the preview of a pending body. | |
| 61 | + | const pendingPreviewRunes = 300 | |
| 62 | + | ||
| 63 | + | // InboxRepoLink is an entry of the repository filter. | |
| 64 | + | type InboxRepoLink struct { | |
| 65 | + | Label string | |
| 66 | + | Href string | |
| 67 | + | Current bool | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // InboxPage is the inbox: the counters, the filter and the two lists. | |
| 71 | + | type InboxPage struct { | |
| 72 | + | // MergeRequests and Issues count every open item, whatever the filter. | |
| 73 | + | MergeRequests, Issues int | |
| 74 | + | // PendingCount counts every issue awaiting moderation. | |
| 75 | + | PendingCount int | |
| 76 | + | Pending []PendingRow | |
| 77 | + | Filter string // the repository filtered on, "" for all | |
| 78 | + | Repos []InboxRepoLink | |
| 79 | + | MRs []InboxRow | |
| 80 | + | IssueRows []InboxRow | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // inboxHref is the inbox page, filtered on repo when it is not "". | |
| 84 | + | func inboxHref(repo string) string { | |
| 85 | + | if repo == "" { | |
| 86 | + | return inboxPath | |
| 87 | + | } | |
| 88 | + | return inboxPath + "?repo=" + url.QueryEscape(repo) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // inboxFilter reads ?repo=. A name that is not valid is a page that is not | |
| 92 | + | // there. | |
| 93 | + | func inboxFilter(r *http.Request) (domain.RepoName, error) { | |
| 94 | + | raw := r.URL.Query().Get("repo") | |
| 95 | + | if raw == "" { | |
| 96 | + | return domain.RepoName{}, nil | |
| 97 | + | } | |
| 98 | + | name, err := domain.ParseRepoName(raw) | |
| 99 | + | if err != nil { | |
| 100 | + | return domain.RepoName{}, errNotFound | |
| 101 | + | } | |
| 102 | + | return name, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // inboxPage builds the page from the overview. | |
| 106 | + | func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage { | |
| 107 | + | now := s.cfg.Now() | |
| 108 | + | p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()} | |
| 109 | + | for _, pi := range ov.Pending { | |
| 110 | + | p.Pending = append(p.Pending, s.pendingRow(now, pi, filter)) | |
| 111 | + | } | |
| 112 | + | row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow { | |
| 113 | + | ts := updated | |
| 114 | + | if ts.IsZero() { | |
| 115 | + | ts = created | |
| 116 | + | } | |
| 117 | + | r := InboxRow{ | |
| 118 | + | Repo: repo.String(), RepoHref: repoHref(repo.String()), | |
| 119 | + | Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title), | |
| 120 | + | } | |
| 121 | + | r.When, r.WhenAt = whenOf(now, ts) | |
| 122 | + | return r | |
| 123 | + | } | |
| 124 | + | for _, m := range ov.MergeRequests { | |
| 125 | + | p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt)) | |
| 126 | + | } | |
| 127 | + | for _, i := range ov.Issues { | |
| 128 | + | p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt)) | |
| 129 | + | } | |
| 130 | + | switch { | |
| 131 | + | case len(p.MRs) > 0: | |
| 132 | + | p.MRs[0].Cursor = true | |
| 133 | + | case len(p.IssueRows) > 0: | |
| 134 | + | p.IssueRows[0].Cursor = true | |
| 135 | + | } | |
| 136 | + | p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()}) | |
| 137 | + | for _, r := range ov.Repos { | |
| 138 | + | p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter}) | |
| 139 | + | } | |
| 140 | + | return p | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // pendingRow is the row of a pending issue: the texts as plain strings, the | |
| 144 | + | // invisible characters marked, the body cut. | |
| 145 | + | func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow { | |
| 146 | + | repo := pi.Repo.String() | |
| 147 | + | row := PendingRow{ | |
| 148 | + | Repo: repo, RepoHref: repoHref(repo), Author: "anonymous", | |
| 149 | + | Title: visibleText(pi.Title.String()), | |
| 150 | + | } | |
| 151 | + | if a := strings.TrimSpace(visibleText(pi.Author)); a != "" { | |
| 152 | + | row.Author = a | |
| 153 | + | } | |
| 154 | + | body := strings.TrimSpace(visibleText(pi.Body.String())) | |
| 155 | + | row.Preview = cutRunes(body, pendingPreviewRunes) | |
| 156 | + | if len(row.Preview) < len(body) { | |
| 157 | + | row.Preview += "…" | |
| 158 | + | } | |
| 159 | + | row.When, row.WhenAt = whenOf(now, pi.CreatedAt) | |
| 160 | + | if s.cfg.Moderation != nil { | |
| 161 | + | base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/" | |
| 162 | + | var q string | |
| 163 | + | if !filter.IsZero() { | |
| 164 | + | q = "?repo=" + url.QueryEscape(filter.String()) | |
| 165 | + | } | |
| 166 | + | row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q | |
| 167 | + | } | |
| 168 | + | return row | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // pendingBack is where a moderation action returns: the inbox, on the same | |
| 172 | + | // repository filter. | |
| 173 | + | func pendingBack(r *http.Request) string { | |
| 174 | + | if f, err := inboxFilter(r); err == nil { | |
| 175 | + | return inboxHref(f.String()) | |
| 176 | + | } | |
| 177 | + | return inboxPath | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // pendingID reads the repository and the id of the path. | |
| 181 | + | func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) { | |
| 182 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 183 | + | if err != nil { | |
| 184 | + | return domain.RepoName{}, "", err | |
| 185 | + | } | |
| 186 | + | id, err := domain.ParsePendingIssueID(r.PathValue("id")) | |
| 187 | + | if err != nil { | |
| 188 | + | return domain.RepoName{}, "", domain.ErrNotFound | |
| 189 | + | } | |
| 190 | + | return name, id, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve. | |
| 194 | + | func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) { | |
| 195 | + | back := pendingBack(r) | |
| 196 | + | name, id, err := pendingID(r) | |
| 197 | + | if err != nil { | |
| 198 | + | return "", back, err | |
| 199 | + | } | |
| 200 | + | if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil { | |
| 201 | + | return "", back, err | |
| 202 | + | } | |
| 203 | + | return back, back, nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which | |
| 207 | + | // also bans the submitter. | |
| 208 | + | func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) { | |
| 209 | + | return func(ctx context.Context, r *http.Request) (string, string, error) { | |
| 210 | + | back := pendingBack(r) | |
| 211 | + | name, id, err := pendingID(r) | |
| 212 | + | if err != nil { | |
| 213 | + | return "", back, err | |
| 214 | + | } | |
| 215 | + | if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil { | |
| 216 | + | return "", back, err | |
| 217 | + | } | |
| 218 | + | return back, back, nil | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // inbox is GET /-/inbox. | |
| 223 | + | func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 224 | + | if !SignedIn(ctx) { | |
| 225 | + | return nil, 0, errNotFound | |
| 226 | + | } | |
| 227 | + | filter, err := inboxFilter(r) | |
| 228 | + | if err != nil { | |
| 229 | + | return nil, 0, err | |
| 230 | + | } | |
| 231 | + | ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter) | |
| 232 | + | if err != nil { | |
| 233 | + | return nil, 0, err | |
| 234 | + | } | |
| 235 | + | l := s.base() | |
| 236 | + | l.Title = "inbox · " + s.instance | |
| 237 | + | l.Crumbs = []Crumb{{Label: "inbox", Current: true}} | |
| 238 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |
| 239 | + | if s.cfg.Events != nil { | |
| 240 | + | q := "view=inbox" | |
| 241 | + | if !filter.IsZero() { | |
| 242 | + | q += "&repo=" + url.QueryEscape(filter.String()) | |
| 243 | + | } | |
| 244 | + | l.Live, l.LiveURL = true, liveURL("owner", q) | |
| 245 | + | } | |
| 246 | + | return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // inboxCount is the number of open items the home button shows, and whether | |
| 250 | + | // there is a button: only for the signed-in owner, and only when the inbox is | |
| 251 | + | // there. A failed read leaves the button without a number. | |
| 252 | + | func (s *Server) inboxCount(ctx context.Context) (n int, show bool) { | |
| 253 | + | if s.cfg.Inbox == nil || !SignedIn(ctx) { | |
| 254 | + | return 0, false | |
| 255 | + | } | |
| 256 | + | ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{}) | |
| 257 | + | if err != nil { | |
| 258 | + | return -1, true | |
| 259 | + | } | |
| 260 | + | return ov.Counts.MergeRequests + ov.Counts.Issues, true | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // inbox renders the fragments an event of a merge request, an issue or a repository changes | |
| 264 | + | // on the inbox page: the counters, the filter and the two lists. | |
| 265 | + | func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 266 | + | switch we.Event.(type) { | |
| 267 | + | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, | |
| 268 | + | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, | |
| 269 | + | domain.RepoUpdated, domain.RepoDeleted: | |
| 270 | + | default: | |
| 271 | + | return nil, nil | |
| 272 | + | } | |
| 273 | + | if l.s.cfg.Inbox == nil { | |
| 274 | + | return nil, nil | |
| 275 | + | } | |
| 276 | + | var filter domain.RepoName | |
| 277 | + | if l.filter != "" { | |
| 278 | + | filter, _ = domain.ParseRepoName(l.filter) | |
| 279 | + | } | |
| 280 | + | ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter) | |
| 281 | + | if err != nil { | |
| 282 | + | return nil, err | |
| 283 | + | } | |
| 284 | + | p := l.s.inboxPage(ov, filter) | |
| 285 | + | var out []fragment | |
| 286 | + | for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} { | |
| 287 | + | f, err := renderFragment(ctx, inboxEvent, c) | |
| 288 | + | if err != nil { | |
| 289 | + | return nil, err | |
| 290 | + | } | |
| 291 | + | out = append(out, f) | |
| 292 | + | } | |
| 293 | + | return out, nil | |
| 294 | + | } | |
A internal/adapters/web/inbox.templ +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Inbox is the owner's inbox: what is open across every repository. The | |
| 6 | + | // counters, the filter and the lists are fragments with an id that the live | |
| 7 | + | // stream replaces as a whole (ermiteLive.swap). | |
| 8 | + | templ Inbox(l Layout, p InboxPage) { | |
| 9 | + | @Shell(l, nil) { | |
| 10 | + | <h1 class="sr">Inbox</h1> | |
| 11 | + | <div class="inbox"> | |
| 12 | + | @InboxCounters(p) | |
| 13 | + | @InboxRepos(p) | |
| 14 | + | @InboxPending(p) | |
| 15 | + | @InboxMRs(p) | |
| 16 | + | @InboxIssues(p) | |
| 17 | + | </div> | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | templ InboxCounters(p InboxPage) { | |
| 22 | + | <p id="inbox-counts" class="inbox-counts"> | |
| 23 | + | <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation | |
| 24 | + | </p> | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | templ InboxRepos(p InboxPage) { | |
| 28 | + | <nav id="inbox-repos" class="since" aria-label="Repository"> | |
| 29 | + | for _, r := range p.Repos { | |
| 30 | + | <a | |
| 31 | + | href={ templ.SafeURL(r.Href) } | |
| 32 | + | if r.Current { | |
| 33 | + | aria-current="page" | |
| 34 | + | } | |
| 35 | + | >{ r.Label }</a> | |
| 36 | + | } | |
| 37 | + | </nav> | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // InboxPending is the issues awaiting moderation, first on the page. Their | |
| 41 | + | // texts come from anonymous visitors: they are shown as plain text, escaped, | |
| 42 | + | // never as Markdown. | |
| 43 | + | templ InboxPending(p InboxPage) { | |
| 44 | + | <section id="inbox-pending" aria-labelledby="inbox-pending-h"> | |
| 45 | + | <h2 id="inbox-pending-h" class="rule-h"><span>Awaiting moderation</span><span class="count">{ itoa(p.PendingCount) }</span></h2> | |
| 46 | + | if len(p.Pending) > 0 { | |
| 47 | + | <ul class="pending-list"> | |
| 48 | + | for _, m := range p.Pending { | |
| 49 | + | <li class="pending"> | |
| 50 | + | <div class="p-head"> | |
| 51 | + | <a href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a> | |
| 52 | + | <span>{ m.Author }</span> | |
| 53 | + | if m.WhenAt != "" { | |
| 54 | + | <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time> | |
| 55 | + | } | |
| 56 | + | </div> | |
| 57 | + | <p class="p-title">{ m.Title }</p> | |
| 58 | + | if m.Preview != "" { | |
| 59 | + | <p class="p-body">{ m.Preview }</p> | |
| 60 | + | } | |
| 61 | + | if m.Approve != "" { | |
| 62 | + | <div class="p-btns"> | |
| 63 | + | <form method="post" action={ templ.SafeURL(m.Approve) }><button type="submit" class="btn primary">Approve</button></form> | |
| 64 | + | <form method="post" action={ templ.SafeURL(m.Reject) }><button type="submit" class="btn">Reject</button></form> | |
| 65 | + | <form method="post" action={ templ.SafeURL(m.Ban) }><button type="submit" class="btn danger">Reject and ban</button></form> | |
| 66 | + | </div> | |
| 67 | + | } | |
| 68 | + | </li> | |
| 69 | + | } | |
| 70 | + | </ul> | |
| 71 | + | } else { | |
| 72 | + | <p class="lede dim">nothing awaits moderation.</p> | |
| 73 | + | } | |
| 74 | + | </section> | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | templ InboxMRs(p InboxPage) { | |
| 78 | + | <section id="inbox-mrs" aria-labelledby="inbox-mrs-h"> | |
| 79 | + | <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2> | |
| 80 | + | @inboxRows(p.MRs, "Open merge requests") | |
| 81 | + | if len(p.MRs) == 0 { | |
| 82 | + | <p class="lede dim">no open merge requests.</p> | |
| 83 | + | } | |
| 84 | + | </section> | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | templ InboxIssues(p InboxPage) { | |
| 88 | + | <section id="inbox-issues" aria-labelledby="inbox-issues-h"> | |
| 89 | + | <h2 id="inbox-issues-h" class="rule-h"><span>Open issues</span><span class="count">{ itoa(len(p.IssueRows)) }</span></h2> | |
| 90 | + | @inboxRows(p.IssueRows, "Open issues") | |
| 91 | + | if len(p.IssueRows) == 0 { | |
| 92 | + | <p class="lede dim">no open issues.</p> | |
| 93 | + | } | |
| 94 | + | </section> | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | templ inboxRows(rows []InboxRow, label string) { | |
| 98 | + | if len(rows) > 0 { | |
| 99 | + | <table class="list mrs" aria-label={ label }> | |
| 100 | + | <tbody data-rows> | |
| 101 | + | for _, m := range rows { | |
| 102 | + | <tr data-cursor?={ m.Cursor } data-state="open"> | |
| 103 | + | <td class="c-n mute">{ m.Number }</td> | |
| 104 | + | <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <a class="mute" href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a></td> | |
| 105 | + | <td class="c-when nowrap dim"> | |
| 106 | + | if m.WhenAt != "" { | |
| 107 | + | <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time> | |
| 108 | + | } | |
| 109 | + | </td> | |
| 110 | + | </tr> | |
| 111 | + | } | |
| 112 | + | </tbody> | |
| 113 | + | </table> | |
| 114 | + | } | |
| 115 | + | } | |
A internal/adapters/web/inbox_moderation_test.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const testPendingID = "aaaaaaaaaaaaaaaaaaaaaaaaaa" | |
| 22 | + | ||
| 23 | + | func testPendingIssue(t testing.TB) domain.PendingIssue { | |
| 24 | + | t.Helper() | |
| 25 | + | id, err := domain.NewPendingIssueID() | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | title, err := domain.ParseIssueTitle("<script>alert(1)</script> crash") | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | body, err := domain.ParseIssueBody("**not bold** [x](javascript:alert(1)) <img src=x onerror=y>\n" + strings.Repeat("long line ", 60)) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | return domain.PendingIssue{ID: id, Repo: repoName(t, "alpha"), Title: title, Body: body, Author: "Eve <i>", CreatedAt: time.Now().Add(-3 * time.Hour)} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func moderationServer(t *testing.T, inbox ports.InboxService, mod ports.IssueModerationService) *Server { | |
| 35 | + | t.Helper() | |
| 36 | + | return newTestServer(t, Config{ | |
| 37 | + | Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, Moderation: mod, | |
| 38 | + | Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestInboxShowsPendingIssuesAsPlainTextFirst(t *testing.T) { | |
| 43 | + | t.Parallel() | |
| 44 | + | p := testPendingIssue(t) | |
| 45 | + | ov := inboxOverview(t) | |
| 46 | + | ov.Pending = []domain.PendingIssue{p, {ID: p.ID, Repo: repoName(t, "beta"), Title: p.Title, CreatedAt: time.Now()}} | |
| 47 | + | ov.Counts.Pending = 2 | |
| 48 | + | svc := mocks.NewInboxService(t) | |
| 49 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil) | |
| 50 | + | srv := moderationServer(t, svc, mocks.NewIssueModerationService(t)) | |
| 51 | + | ||
| 52 | + | rec := inboxGet(srv, "/-/inbox", true) | |
| 53 | + | require.Equal(t, 200, rec.Code) | |
| 54 | + | body := rec.Body.String() | |
| 55 | + | assert.Contains(t, body, "<strong>2</strong> awaiting moderation") | |
| 56 | + | assert.Less(t, strings.Index(body, `id="inbox-pending"`), strings.Index(body, `id="inbox-mrs"`), "first section") | |
| 57 | + | assert.Contains(t, body, "<script>alert(1)</script> 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 <i>") | |
| 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 <b>search</b>") | |
| 74 | + | assert.Contains(t, body, `href="/beta/issues/7"`) | |
| 75 | + | assert.Contains(t, body, "#7") | |
| 76 | + | assert.Contains(t, body, "2h ago") | |
| 77 | + | assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter") | |
| 78 | + | assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestInboxPageIsMissingForEveryoneElse(t *testing.T) { | |
| 82 | + | t.Parallel() | |
| 83 | + | svc := mocks.NewInboxService(t) // never asked | |
| 84 | + | srv := inboxServer(t, svc) | |
| 85 | + | missing := inboxGet(srv, "/-/nothing-here", false) | |
| 86 | + | require.Equal(t, 404, missing.Code) | |
| 87 | + | ||
| 88 | + | anon := inboxGet(srv, "/-/inbox", false) | |
| 89 | + | assert.Equal(t, 404, anon.Code) | |
| 90 | + | assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one") | |
| 91 | + | assert.NotContains(t, anon.Body.String(), "Open merge requests") | |
| 92 | + | ||
| 93 | + | // A bad cookie is anonymous as well. | |
| 94 | + | req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil) | |
| 95 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"}) | |
| 96 | + | rec := httptest.NewRecorder() | |
| 97 | + | srv.ServeHTTP(rec, req) | |
| 98 | + | assert.Equal(t, 404, rec.Code) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestInboxNotMountedWithoutTheService(t *testing.T) { | |
| 102 | + | t.Parallel() | |
| 103 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}}) | |
| 104 | + | assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestInboxFilterAndEmptyStates(t *testing.T) { | |
| 108 | + | t.Parallel() | |
| 109 | + | svc := mocks.NewInboxService(t) | |
| 110 | + | ov := inboxOverview(t) | |
| 111 | + | filtered := ov | |
| 112 | + | filtered.MergeRequests, filtered.Issues = nil, nil | |
| 113 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil) | |
| 114 | + | srv := inboxServer(t, svc) | |
| 115 | + | ||
| 116 | + | rec := inboxGet(srv, "/-/inbox?repo=gamma", true) | |
| 117 | + | require.Equal(t, 200, rec.Code) | |
| 118 | + | body := rec.Body.String() | |
| 119 | + | assert.Contains(t, body, "no open merge requests.") | |
| 120 | + | assert.Contains(t, body, "no open issues.") | |
| 121 | + | assert.Contains(t, body, `href="/-/inbox"`, "the link back to all") | |
| 122 | + | ||
| 123 | + | assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) { | |
| 127 | + | t.Parallel() | |
| 128 | + | svc := mocks.NewInboxService(t) | |
| 129 | + | ov := inboxOverview(t) | |
| 130 | + | ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2} | |
| 131 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only | |
| 132 | + | srv := inboxServer(t, svc) | |
| 133 | + | ||
| 134 | + | anon := inboxGet(srv, "/", false) | |
| 135 | + | require.Equal(t, 200, anon.Code) | |
| 136 | + | assert.NotContains(t, anon.Body.String(), "/-/inbox") | |
| 137 | + | assert.NotContains(t, anon.Body.String(), "Inbox") | |
| 138 | + | ||
| 139 | + | owner := inboxGet(srv, "/", true) | |
| 140 | + | require.Equal(t, 200, owner.Code) | |
| 141 | + | assert.Contains(t, owner.Body.String(), `href="/-/inbox"`) | |
| 142 | + | assert.Contains(t, owner.Body.String(), `<span class="count">5</span>`) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestInboxStreamIsTheOwnersAlone(t *testing.T) { | |
| 146 | + | t.Parallel() | |
| 147 | + | f := &watchProbe{release: make(chan struct{})} | |
| 148 | + | svc := mocks.NewInboxService(t) | |
| 149 | + | srv := newTestServer(t, Config{ | |
| 150 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |
| 151 | + | Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 152 | + | }) | |
| 153 | + | ts := httptest.NewServer(srv) | |
| 154 | + | t.Cleanup(ts.Close) | |
| 155 | + | ||
| 156 | + | unknown, _ := open(t, ts, "topic=nope&view=inbox", nil) | |
| 157 | + | want, _ := io.ReadAll(unknown.Body) | |
| 158 | + | for name, hdr := range map[string]map[string]string{ | |
| 159 | + | "anonymous": nil, | |
| 160 | + | "another token": {"Cookie": CookieName + "=other"}, | |
| 161 | + | } { | |
| 162 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", hdr) | |
| 163 | + | body, _ := io.ReadAll(resp.Body) | |
| 164 | + | assert.Equal(t, unknown.StatusCode, resp.StatusCode, name) | |
| 165 | + | assert.Equal(t, string(want), string(body), name) | |
| 166 | + | assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name) | |
| 167 | + | } | |
| 168 | + | assert.Zero(t, f.opened.Load(), "no watch is opened for them") | |
| 169 | + | ||
| 170 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"}) | |
| 171 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 172 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestInboxLiveFragments(t *testing.T) { | |
| 176 | + | t.Parallel() | |
| 177 | + | svc := mocks.NewInboxService(t) | |
| 178 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil) | |
| 179 | + | srv := inboxServer(t, svc) | |
| 180 | + | ls := &liveStream{s: srv, view: "inbox"} | |
| 181 | + | frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | require.Len(t, frags, 5) | |
| 184 | + | assert.Contains(t, frags[0].html, `id="inbox-counts"`) | |
| 185 | + | assert.Contains(t, frags[1].html, `id="inbox-repos"`) | |
| 186 | + | assert.Contains(t, frags[2].html, `id="inbox-pending"`) | |
| 187 | + | assert.Contains(t, frags[3].html, `id="inbox-mrs"`) | |
| 188 | + | assert.Contains(t, frags[4].html, `id="inbox-issues"`) | |
| 189 | + | ||
| 190 | + | none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}}) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | assert.Empty(t, none, "a push is not inbox news") | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // withOwner is a context as the session middleware leaves it for the owner. | |
| 196 | + | func withOwner(ctx context.Context) context.Context { | |
| 197 | + | return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner()) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // A bearer token is not a web session: the inbox answers it as it answers | |
| 201 | + | // anyone else, with the unknown-page and unknown-topic responses. | |
| 202 | + | func TestInboxRefusesABearerToken(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 204 | + | f := &watchProbe{release: make(chan struct{})} | |
| 205 | + | svc := mocks.NewInboxService(t) // never asked | |
| 206 | + | srv := newTestServer(t, Config{ | |
| 207 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |
| 208 | + | Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 209 | + | }) | |
| 210 | + | bearer := map[string]string{"Authorization": "Bearer tok"} | |
| 211 | + | ||
| 212 | + | // The page. | |
| 213 | + | missing := inboxGet(srv, "/-/nothing-here", false) | |
| 214 | + | req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil) | |
| 215 | + | req.Header.Set("Authorization", "Bearer tok") | |
| 216 | + | rec := httptest.NewRecorder() | |
| 217 | + | srv.ServeHTTP(rec, req) | |
| 218 | + | assert.Equal(t, missing.Code, rec.Code) | |
| 219 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 220 | + | assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response") | |
| 221 | + | ||
| 222 | + | // The stream. | |
| 223 | + | ts := httptest.NewServer(srv) | |
| 224 | + | t.Cleanup(ts.Close) | |
| 225 | + | unknown, _ := open(t, ts, "topic=nope&view=inbox", nil) | |
| 226 | + | want, _ := io.ReadAll(unknown.Body) | |
| 227 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", bearer) | |
| 228 | + | body, _ := io.ReadAll(resp.Body) | |
| 229 | + | assert.Equal(t, unknown.StatusCode, resp.StatusCode) | |
| 230 | + | assert.Equal(t, string(want), string(body), "the unknown-topic response") | |
| 231 | + | assert.Zero(t, f.opened.Load(), "no watch is opened") | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // The owner's inbox stream ends once the session is invalid: the next event is | |
| 235 | + | // not sent. | |
| 236 | + | func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) { | |
| 237 | + | t.Parallel() | |
| 238 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} | |
| 239 | + | sess, ended := newRevocable(t, "tok") | |
| 240 | + | svc := mocks.NewInboxService(t) | |
| 241 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe() | |
| 242 | + | srv := newTestServer(t, Config{ | |
| 243 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |
| 244 | + | Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{}, | |
| 245 | + | }) | |
| 246 | + | ts := httptest.NewServer(srv) | |
| 247 | + | t.Cleanup(ts.Close) | |
| 248 | + | resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"}) | |
| 249 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 250 | + | defer time.AfterFunc(5*time.Second, cancel).Stop() | |
| 251 | + | rd := bufio.NewReader(resp.Body) | |
| 252 | + | line := func() string { | |
| 253 | + | s, err := rd.ReadString('\n') | |
| 254 | + | if err != nil { | |
| 255 | + | return "EOF" | |
| 256 | + | } | |
| 257 | + | return strings.TrimRight(s, "\n") | |
| 258 | + | } | |
| 259 | + | require.Equal(t, ": connected", line()) | |
| 260 | + | line() | |
| 261 | + | ev := func(id string) ports.WatchEvent { | |
| 262 | + | return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}} | |
| 263 | + | } | |
| 264 | + | f.push <- ev("b-1") | |
| 265 | + | var seen []string | |
| 266 | + | for l := line(); !strings.HasPrefix(l, "data:"); l = line() { | |
| 267 | + | seen = append(seen, l) | |
| 268 | + | require.NotEqual(t, "EOF", l) | |
| 269 | + | } | |
| 270 | + | assert.Contains(t, seen, "id: b-1") | |
| 271 | + | ||
| 272 | + | ended.Store(true) | |
| 273 | + | select { | |
| 274 | + | case f.push <- ev("b-2"): | |
| 275 | + | case <-time.After(5 * time.Second): | |
| 276 | + | t.Fatal("the event was not delivered to the web") | |
| 277 | + | } | |
| 278 | + | rest, _ := io.ReadAll(rd) | |
| 279 | + | assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended") | |
| 280 | + | require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed") | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // A repository event refreshes the inbox: issues turned off or a deletion | |
| 284 | + | // remove its items from the fragments. | |
| 285 | + | func TestInboxRefreshesOnRepoEvents(t *testing.T) { | |
| 286 | + | t.Parallel() | |
| 287 | + | for name, ev := range map[string]domain.Event{ | |
| 288 | + | "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")}, | |
| 289 | + | "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")}, | |
| 290 | + | } { | |
| 291 | + | t.Run(name, func(t *testing.T) { | |
| 292 | + | t.Parallel() | |
| 293 | + | after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}} | |
| 294 | + | svc := mocks.NewInboxService(t) | |
| 295 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil) | |
| 296 | + | srv := inboxServer(t, svc) | |
| 297 | + | ls := &liveStream{s: srv, view: "inbox"} | |
| 298 | + | frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev}) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | require.Len(t, frags, 5) | |
| 301 | + | all := "" | |
| 302 | + | for _, f := range frags { | |
| 303 | + | all += f.html | |
| 304 | + | } | |
| 305 | + | assert.Contains(t, all, `id="inbox-issues"`) | |
| 306 | + | assert.NotContains(t, all, "crash on start", "the issue is gone") | |
| 307 | + | assert.NotContains(t, all, "/beta", "the repository is gone from the filter") | |
| 308 | + | }) | |
| 309 | + | } | |
| 310 | + | } | |
A internal/adapters/web/issues.go +363 -0
| @@ -0,0 +1,363 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "html/template" | |
| 9 | + | "net/http" | |
| 10 | + | "net/url" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | ||
| 14 | + | "github.com/a-h/templ" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // The issue pages (ADR 0031). They follow the merge request pages: they read | |
| 21 | + | // through IssueService as the visitor of the request, anonymous sees the | |
| 22 | + | // issues of public repositories, and only the signed-in owner gets the forms | |
| 23 | + | // (issues_post.go handles them). A repository whose issues mode is off, a | |
| 24 | + | // missing one and a private one for an anonymous visitor are the same 404. | |
| 25 | + | // | |
| 26 | + | // Routes: | |
| 27 | + | // | |
| 28 | + | // GET /<repo>/issues the list, open or closed (?state=), paged (?page=) | |
| 29 | + | // GET /<repo>/issues/new the form that opens one (owner) | |
| 30 | + | // GET /<repo>/issues/<n> the issue with its comments | |
| 31 | + | // | |
| 32 | + | // Issues and merge requests share their numbers, so "#n" may name either: with | |
| 33 | + | // an IssueNumberResolver the page of the wrong kind redirects to the right one. | |
| 34 | + | ||
| 35 | + | // issuesBase is the URL of the issue pages of a repository. | |
| 36 | + | func issuesBase(repo string) string { return repoHref(repo, "issues") } | |
| 37 | + | ||
| 38 | + | func issueHref(repo string, n int64, sub ...string) string { | |
| 39 | + | return repoHref(repo, append([]string{"issues", strconv.FormatInt(n, 10)}, sub...)...) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // IssueRow is one issue in the list. | |
| 43 | + | type IssueRow struct { | |
| 44 | + | Number string | |
| 45 | + | Href string | |
| 46 | + | Title string | |
| 47 | + | Author string | |
| 48 | + | State string | |
| 49 | + | When string | |
| 50 | + | WhenAt string | |
| 51 | + | Cursor bool | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // IssueListPage is the list of the issues of a repository. | |
| 55 | + | type IssueListPage struct { | |
| 56 | + | Rows []IssueRow | |
| 57 | + | // Closed is true on the tab of the closed issues. | |
| 58 | + | Closed bool | |
| 59 | + | OpenHref, ClosedHref string | |
| 60 | + | Older, Newest string | |
| 61 | + | NewHref string // the form that opens an issue; "" unless the owner | |
| 62 | + | Live bool | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // IssueActions are the buttons of the owner on an issue. | |
| 66 | + | type IssueActions struct { | |
| 67 | + | Action string // the URL that closes or reopens | |
| 68 | + | Label string | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // IssuePage is an issue with its comments. | |
| 72 | + | type IssuePage struct { | |
| 73 | + | Number string | |
| 74 | + | Title string | |
| 75 | + | State string | |
| 76 | + | Open bool | |
| 77 | + | Opened Person | |
| 78 | + | Updated string | |
| 79 | + | UpdatedAt string | |
| 80 | + | // Desc is the body rendered as Markdown, "" when it is empty. | |
| 81 | + | Desc template.HTML | |
| 82 | + | Comments []CommentView | |
| 83 | + | Form *CommentForm | |
| 84 | + | Transition *IssueActions | |
| 85 | + | Live bool | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // IssueNewPage is the form that opens an issue. | |
| 89 | + | type IssueNewPage struct { | |
| 90 | + | Action, Cancel string | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // issueRepo reads the repository of an issue page: one with issues off is | |
| 94 | + | // as missing as one the caller cannot see. | |
| 95 | + | func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, error) { | |
| 96 | + | d, err := s.loadRepo(ctx, rawName) | |
| 97 | + | if err != nil { | |
| 98 | + | return nil, err | |
| 99 | + | } | |
| 100 | + | if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 101 | + | return nil, errNotFound | |
| 102 | + | } | |
| 103 | + | return d, nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // externalAuthor is the label a visitor gave, as plain text, or "anonymous". | |
| 107 | + | func (s *Server) externalAuthor(label string) string { | |
| 108 | + | if a := strings.TrimSpace(visibleText(label)); a != "" { | |
| 109 | + | return a | |
| 110 | + | } | |
| 111 | + | return "anonymous" | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (s *Server) issueRow(repo string, i domain.Issue) IssueRow { | |
| 115 | + | row := IssueRow{ | |
| 116 | + | Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number), | |
| 117 | + | Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author), | |
| 118 | + | } | |
| 119 | + | if i.External { | |
| 120 | + | row.Author = s.externalAuthor(i.Author) | |
| 121 | + | } | |
| 122 | + | ts := i.UpdatedAt | |
| 123 | + | if ts.IsZero() { | |
| 124 | + | ts = i.CreatedAt | |
| 125 | + | } | |
| 126 | + | row.When, row.WhenAt = whenOf(s.cfg.Now(), ts) | |
| 127 | + | return row | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // issueList lists the issues of one state, a page at a time. | |
| 131 | + | // | |
| 132 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode | |
| 133 | + | func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 134 | + | d, err := s.issueRepo(ctx, r.PathValue("repo")) | |
| 135 | + | if err != nil { | |
| 136 | + | return nil, 0, err | |
| 137 | + | } | |
| 138 | + | state := domain.IssueStateOpen | |
| 139 | + | switch r.URL.Query().Get("state") { | |
| 140 | + | case "", "open": | |
| 141 | + | case "closed": | |
| 142 | + | state = domain.IssueStateClosed | |
| 143 | + | default: | |
| 144 | + | return nil, 0, errNotFound | |
| 145 | + | } | |
| 146 | + | token := r.URL.Query().Get("page") | |
| 147 | + | base := issuesBase(d.name()) | |
| 148 | + | closed := state == domain.IssueStateClosed | |
| 149 | + | link := func(page string) string { | |
| 150 | + | q := url.Values{} | |
| 151 | + | if closed { | |
| 152 | + | q.Set("state", "closed") | |
| 153 | + | } | |
| 154 | + | if page != "" { | |
| 155 | + | q.Set("page", page) | |
| 156 | + | } | |
| 157 | + | if len(q) == 0 { | |
| 158 | + | return base | |
| 159 | + | } | |
| 160 | + | return base + "?" + q.Encode() | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{ | |
| 164 | + | State: state, PageSize: pageSize, PageToken: token, | |
| 165 | + | }) | |
| 166 | + | if err != nil { | |
| 167 | + | return nil, 0, err | |
| 168 | + | } | |
| 169 | + | p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"} | |
| 170 | + | for _, i := range res.Issues { | |
| 171 | + | p.Rows = append(p.Rows, s.issueRow(d.name(), i)) | |
| 172 | + | } | |
| 173 | + | if len(p.Rows) > 0 { | |
| 174 | + | p.Rows[0].Cursor = true | |
| 175 | + | } | |
| 176 | + | if res.NextPageToken != "" { | |
| 177 | + | p.Older = link(res.NextPageToken) | |
| 178 | + | } | |
| 179 | + | if token != "" { | |
| 180 | + | p.Newest = link("") | |
| 181 | + | } | |
| 182 | + | if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) { | |
| 183 | + | p.NewHref = issuesBase(d.name()) + "/new" | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | l := s.repoLayout(ctx, d, "issues") | |
| 187 | + | l.Title = "issues · " + d.name() + " · " + s.instance | |
| 188 | + | l.Crumbs[0].Current = false | |
| 189 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true}) | |
| 190 | + | if token == "" { | |
| 191 | + | p.Live = true | |
| 192 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues") | |
| 193 | + | } | |
| 194 | + | l.HasRail = true | |
| 195 | + | return IssueList(l, p), http.StatusOK, nil | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // issueNew is the form that opens an issue; it is the owner's. | |
| 199 | + | func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 200 | + | d, err := s.issueRepo(ctx, r.PathValue("repo")) | |
| 201 | + | if err != nil { | |
| 202 | + | return nil, 0, err | |
| 203 | + | } | |
| 204 | + | if !SignedIn(ctx) { | |
| 205 | + | return nil, 0, errNotFound | |
| 206 | + | } | |
| 207 | + | l := s.repoLayout(ctx, d, "issues") | |
| 208 | + | l.Title = "new issue · " + d.name() + " · " + s.instance | |
| 209 | + | l.Crumbs[0].Current = false | |
| 210 | + | l.Crumbs = append(l.Crumbs, | |
| 211 | + | Crumb{Label: "issues", Href: issuesBase(d.name())}, | |
| 212 | + | Crumb{Label: "new", Current: true}, | |
| 213 | + | ) | |
| 214 | + | l.HasRail = true | |
| 215 | + | return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // issueShow shows an issue with its comments. | |
| 219 | + | // | |
| 220 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here | |
| 221 | + | func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 222 | + | n, err := mrNumber(r) | |
| 223 | + | if err != nil { | |
| 224 | + | return nil, 0, err | |
| 225 | + | } | |
| 226 | + | d, err := s.issueRepo(ctx, r.PathValue("repo")) | |
| 227 | + | if err != nil { | |
| 228 | + | return nil, 0, err | |
| 229 | + | } | |
| 230 | + | res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n) | |
| 231 | + | if err != nil { | |
| 232 | + | return nil, 0, err | |
| 233 | + | } | |
| 234 | + | i := res.Issue | |
| 235 | + | now := s.cfg.Now() | |
| 236 | + | signed := SignedIn(ctx) | |
| 237 | + | self := issueHref(d.name(), n) | |
| 238 | + | ||
| 239 | + | p := IssuePage{ | |
| 240 | + | Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()), | |
| 241 | + | State: string(i.State), Open: i.IsOpen(), | |
| 242 | + | } | |
| 243 | + | if body := i.Body.String(); strings.TrimSpace(body) != "" { | |
| 244 | + | var h template.HTML | |
| 245 | + | var err error | |
| 246 | + | if i.External { | |
| 247 | + | // Written by an anonymous visitor: the untrusted policy. | |
| 248 | + | h, err = s.md.renderUntrusted([]byte(body), "issue-desc-") | |
| 249 | + | } else { | |
| 250 | + | h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-") | |
| 251 | + | } | |
| 252 | + | if err != nil { | |
| 253 | + | return nil, 0, err | |
| 254 | + | } | |
| 255 | + | p.Desc = h | |
| 256 | + | } | |
| 257 | + | p.Opened.Name = s.who(i.Author) | |
| 258 | + | if i.External { | |
| 259 | + | // The label is the visitor's, not the owner's: shown as plain text. | |
| 260 | + | p.Opened.Name = s.externalAuthor(i.Author) | |
| 261 | + | } | |
| 262 | + | p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt) | |
| 263 | + | if !i.CreatedAt.IsZero() { | |
| 264 | + | p.Opened.Abs = absolute(i.CreatedAt) | |
| 265 | + | } | |
| 266 | + | p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt) | |
| 267 | + | for _, c := range res.Comments { | |
| 268 | + | v := CommentView{ | |
| 269 | + | ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()), | |
| 270 | + | } | |
| 271 | + | v.When, v.WhenAt = whenOf(now, c.CreatedAt) | |
| 272 | + | if !c.CreatedAt.IsZero() { | |
| 273 | + | v.Abs = absolute(c.CreatedAt) | |
| 274 | + | } | |
| 275 | + | p.Comments = append(p.Comments, v) | |
| 276 | + | } | |
| 277 | + | if signed { | |
| 278 | + | p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue"} | |
| 279 | + | switch i.State { | |
| 280 | + | case domain.IssueStateOpen: | |
| 281 | + | p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"} | |
| 282 | + | case domain.IssueStateClosed: | |
| 283 | + | p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"} | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | l := s.repoLayout(ctx, d, "issues") | |
| 288 | + | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | |
| 289 | + | l.Crumbs[0].Current = false | |
| 290 | + | l.Crumbs = append(l.Crumbs, | |
| 291 | + | Crumb{Label: "issues", Href: issuesBase(d.name())}, | |
| 292 | + | Crumb{Label: p.Number, Href: self, Current: true}, | |
| 293 | + | ) | |
| 294 | + | p.Live = true | |
| 295 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10)) | |
| 296 | + | l.HasRail = true | |
| 297 | + | return IssueShow(l, p), http.StatusOK, nil | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // pageOrRedirect is page(), except that a page that is not found may be the | |
| 301 | + | // page of the other kind of the number: target then names where to send the | |
| 302 | + | // visitor ("" when there is none). | |
| 303 | + | func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc { | |
| 304 | + | plain := s.page(h) | |
| 305 | + | if s.cfg.IssueNumbers == nil { | |
| 306 | + | return plain | |
| 307 | + | } | |
| 308 | + | return func(w http.ResponseWriter, r *http.Request) { | |
| 309 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 310 | + | defer cancel() | |
| 311 | + | c, status, err := h(ctx, r) | |
| 312 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 313 | + | to, terr := target(ctx, r) | |
| 314 | + | switch { | |
| 315 | + | case terr != nil && !errors.Is(terr, domain.ErrNotFound): | |
| 316 | + | s.fail(ctx, w, r, terr) | |
| 317 | + | return | |
| 318 | + | case terr == nil && to != "": | |
| 319 | + | seeOther(w, r, to) | |
| 320 | + | return | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | if err != nil { | |
| 324 | + | s.fail(ctx, w, r, err) | |
| 325 | + | return | |
| 326 | + | } | |
| 327 | + | s.write(ctx, w, r, status, c) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // numberKind asks what the number of the path names. | |
| 332 | + | func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) { | |
| 333 | + | n, err := mrNumber(r) | |
| 334 | + | if err != nil { | |
| 335 | + | return domain.NumberNone, 0, err | |
| 336 | + | } | |
| 337 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 338 | + | if err != nil { | |
| 339 | + | return domain.NumberNone, 0, err | |
| 340 | + | } | |
| 341 | + | kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n) | |
| 342 | + | return kind, n, err | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // issueToMR is where the number of an issue URL goes when it names a merge | |
| 346 | + | // request. | |
| 347 | + | func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) { | |
| 348 | + | kind, n, err := s.numberKind(ctx, r) | |
| 349 | + | if err != nil || kind != domain.NumberMergeRequest { | |
| 350 | + | return "", err | |
| 351 | + | } | |
| 352 | + | return mrHref(r.PathValue("repo"), n), nil | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // mrToIssue is where the number of a merge request URL goes when it names an | |
| 356 | + | // issue. | |
| 357 | + | func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) { | |
| 358 | + | kind, n, err := s.numberKind(ctx, r) | |
| 359 | + | if err != nil || kind != domain.NumberIssue { | |
| 360 | + | return "", err | |
| 361 | + | } | |
| 362 | + | return issueHref(r.PathValue("repo"), n), nil | |
| 363 | + | } | |
A internal/adapters/web/issues.templ +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // IssueList is the list of the issues of a repository, open or closed. | |
| 6 | + | templ IssueList(l Layout, p IssueListPage) { | |
| 7 | + | @Shell(l, issueListRail(p)) { | |
| 8 | + | if p.Live { | |
| 9 | + | @LiveSlot("mr-notice") | |
| 10 | + | } | |
| 11 | + | <nav class="since" aria-label="State"> | |
| 12 | + | <a | |
| 13 | + | href={ templ.SafeURL(p.OpenHref) } | |
| 14 | + | if !p.Closed { | |
| 15 | + | aria-current="page" | |
| 16 | + | } | |
| 17 | + | >open</a> | |
| 18 | + | <a | |
| 19 | + | href={ templ.SafeURL(p.ClosedHref) } | |
| 20 | + | if p.Closed { | |
| 21 | + | aria-current="page" | |
| 22 | + | } | |
| 23 | + | >closed</a> | |
| 24 | + | </nav> | |
| 25 | + | <div class="filter"> | |
| 26 | + | <label for="issue-filter" class="sr">filter</label> | |
| 27 | + | <input | |
| 28 | + | id="issue-filter" | |
| 29 | + | type="search" | |
| 30 | + | data-filter=".mrs tbody tr" | |
| 31 | + | placeholder="Filter by title, author or state…" | |
| 32 | + | autocomplete="off" | |
| 33 | + | hx-on:input="ermite.filter(this)" | |
| 34 | + | /> | |
| 35 | + | </div> | |
| 36 | + | <h2 class="rule-h"> | |
| 37 | + | if p.Closed { | |
| 38 | + | <span>Closed</span> | |
| 39 | + | } else { | |
| 40 | + | <span>Open</span> | |
| 41 | + | } | |
| 42 | + | <span class="count">{ itoa(len(p.Rows)) }</span> | |
| 43 | + | </h2> | |
| 44 | + | @issueRows(p.Rows) | |
| 45 | + | if len(p.Rows) == 0 { | |
| 46 | + | if p.Closed { | |
| 47 | + | <p class="lede dim">no closed issues.</p> | |
| 48 | + | } else { | |
| 49 | + | <p class="lede dim">no open issues.</p> | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if p.Older != "" || p.Newest != "" { | |
| 53 | + | <nav class="pager" aria-label="Pages"> | |
| 54 | + | if p.Newest != "" { | |
| 55 | + | <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a> | |
| 56 | + | } else { | |
| 57 | + | <span class="btn" aria-disabled="true">← Newest</span> | |
| 58 | + | } | |
| 59 | + | if p.Older != "" { | |
| 60 | + | <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a> | |
| 61 | + | } else { | |
| 62 | + | <span class="btn" aria-disabled="true">Older →</span> | |
| 63 | + | } | |
| 64 | + | </nav> | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | templ issueRows(rows []IssueRow) { | |
| 70 | + | if len(rows) > 0 { | |
| 71 | + | <table class="list mrs" aria-label="Issues"> | |
| 72 | + | <tbody data-rows> | |
| 73 | + | for _, i := range rows { | |
| 74 | + | <tr data-cursor?={ i.Cursor } data-state={ i.State }> | |
| 75 | + | <td class="c-n mute">{ i.Number }</td> | |
| 76 | + | <td class="c-state">{ i.State }</td> | |
| 77 | + | <td class="c-subject"><a href={ templ.SafeURL(i.Href) }>{ i.Title }</a></td> | |
| 78 | + | <td class="c-who dim">{ i.Author }</td> | |
| 79 | + | <td class="c-when nowrap dim"> | |
| 80 | + | if i.WhenAt != "" { | |
| 81 | + | <time datetime={ i.WhenAt } title={ i.WhenAt }>{ i.When }</time> | |
| 82 | + | } | |
| 83 | + | </td> | |
| 84 | + | </tr> | |
| 85 | + | } | |
| 86 | + | </tbody> | |
| 87 | + | </table> | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | templ issueListRail(p IssueListPage) { | |
| 92 | + | <section aria-labelledby="r-states"> | |
| 93 | + | <h2 id="r-states">States</h2> | |
| 94 | + | <dl class="kv"> | |
| 95 | + | <dt>open</dt> | |
| 96 | + | <dd>being worked on</dd> | |
| 97 | + | <dt>closed</dt> | |
| 98 | + | <dd>done with, can be reopened</dd> | |
| 99 | + | </dl> | |
| 100 | + | </section> | |
| 101 | + | if p.NewHref != "" { | |
| 102 | + | <section aria-labelledby="r-new"> | |
| 103 | + | <h2 id="r-new">Open one</h2> | |
| 104 | + | <a class="btn primary" href={ templ.SafeURL(p.NewHref) }>New issue</a> | |
| 105 | + | </section> | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // IssueNew is the form that opens an issue. | |
| 110 | + | templ IssueNew(l Layout, p IssueNewPage) { | |
| 111 | + | @Shell(l, issueNewRail()) { | |
| 112 | + | <header class="show mr-h"> | |
| 113 | + | <h1>New issue</h1> | |
| 114 | + | </header> | |
| 115 | + | <form class="cform create" method="post" action={ templ.SafeURL(p.Action) }> | |
| 116 | + | <label for="issue-title">title</label> | |
| 117 | + | <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" autofocus/> | |
| 118 | + | <label for="issue-body">description</label> | |
| 119 | + | <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional"></textarea> | |
| 120 | + | <div class="f-btns"> | |
| 121 | + | <button type="submit" class="btn primary">Create issue</button> | |
| 122 | + | <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a> | |
| 123 | + | </div> | |
| 124 | + | </form> | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | templ issueNewRail() { | |
| 129 | + | <section aria-labelledby="r-about"> | |
| 130 | + | <h2 id="r-about">Issues</h2> | |
| 131 | + | <p class="dim">issues share their numbers with the merge requests of the repository.</p> | |
| 132 | + | </section> | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // IssueShow is an issue: its facts, its body, its comments and, for the | |
| 136 | + | // owner, the form to comment and the button that closes or reopens it. | |
| 137 | + | templ IssueShow(l Layout, p IssuePage) { | |
| 138 | + | @Shell(l, issueRail(p)) { | |
| 139 | + | <header class="show mr-h"> | |
| 140 | + | <h1><span class="mute">{ p.Number }</span> { p.Title }</h1> | |
| 141 | + | <dl class="kv show-kv"> | |
| 142 | + | <dt>state</dt> | |
| 143 | + | <dd class="mr-state" data-state={ p.State }>{ p.State }</dd> | |
| 144 | + | <dt>opened</dt> | |
| 145 | + | <dd> | |
| 146 | + | { p.Opened.Name } | |
| 147 | + | if p.Opened.WhenAt != "" { | |
| 148 | + | { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time> | |
| 149 | + | } | |
| 150 | + | </dd> | |
| 151 | + | if p.UpdatedAt != "" { | |
| 152 | + | <dt>updated</dt> | |
| 153 | + | <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd> | |
| 154 | + | } | |
| 155 | + | </dl> | |
| 156 | + | </header> | |
| 157 | + | if p.Live { | |
| 158 | + | @LiveSlot("mr-notice") | |
| 159 | + | } | |
| 160 | + | if p.Desc != "" { | |
| 161 | + | <article class="mr-desc prose" aria-label="description" hx-ignore> | |
| 162 | + | @templ.Raw(string(p.Desc)) | |
| 163 | + | </article> | |
| 164 | + | } | |
| 165 | + | <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2> | |
| 166 | + | for _, c := range p.Comments { | |
| 167 | + | @commentView(c, "") | |
| 168 | + | } | |
| 169 | + | if len(p.Comments) == 0 { | |
| 170 | + | <p class="dim">no comments yet.</p> | |
| 171 | + | } | |
| 172 | + | if p.Form != nil { | |
| 173 | + | @commentForm(*p.Form, "") | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | templ issueRail(p IssuePage) { | |
| 179 | + | if p.Transition != nil { | |
| 180 | + | <section aria-labelledby="r-act"> | |
| 181 | + | <h2 id="r-act">Issue</h2> | |
| 182 | + | <form method="post" action={ templ.SafeURL(p.Transition.Action) }> | |
| 183 | + | <button type="submit" class="btn">{ p.Transition.Label }</button> | |
| 184 | + | </form> | |
| 185 | + | </section> | |
| 186 | + | } | |
| 187 | + | } | |
A internal/adapters/web/issues_form.go +276 -0
| @@ -0,0 +1,276 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "net/http" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/a-h/templ" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // The public issue form (ADR 0031). A visitor of a public repository whose | |
| 22 | + | // issues mode is public writes an issue that awaits the owner's moderation. | |
| 23 | + | // | |
| 24 | + | // GET /<repo>/issues/new the form, with a proof of work solved by script | |
| 25 | + | // POST /<repo>/issues/new the submission (the one write a visitor may make) | |
| 26 | + | // GET /<repo>/issues/submitted the thanks page | |
| 27 | + | // | |
| 28 | + | // Every answer is "Cache-Control: no-store". The challenge token is never | |
| 29 | + | // echoed back, and neither the address of the client nor the text of the | |
| 30 | + | // submission is logged. A refusal for a bot-related reason is one generic page. | |
| 31 | + | ||
| 32 | + | // maxIssueFormBytes bounds the body of the submission: 10,000 characters are | |
| 33 | + | // at most 40,000 bytes, plus the title, the name and the tokens. | |
| 34 | + | const maxIssueFormBytes = 64 << 10 | |
| 35 | + | ||
| 36 | + | // The fields of the form, and so the only ones read. | |
| 37 | + | const ( | |
| 38 | + | fieldTitle = "title" | |
| 39 | + | fieldBody = "body" | |
| 40 | + | fieldAuthor = "author" | |
| 41 | + | fieldToken = "token" | |
| 42 | + | fieldFormToken = "form_token" | |
| 43 | + | fieldSolution = "solution" | |
| 44 | + | fieldHoneypot = "website" | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot} | |
| 48 | + | ||
| 49 | + | // IssueFormPage is the public form. | |
| 50 | + | type IssueFormPage struct { | |
| 51 | + | Action, Cancel string | |
| 52 | + | Token string | |
| 53 | + | FormToken string | |
| 54 | + | Nonce string | |
| 55 | + | Difficulty int | |
| 56 | + | MinSubmitAt int64 // unix milliseconds | |
| 57 | + | Worker string | |
| 58 | + | // Title, Body and Author are what the visitor already wrote. | |
| 59 | + | Title, Body, Author string | |
| 60 | + | // ErrorField is "title", "body" or "author" and Error the message to show | |
| 61 | + | // next to it; both empty without an error. | |
| 62 | + | ErrorField, Error string | |
| 63 | + | // Max lengths, in characters. | |
| 64 | + | MaxTitle, MaxBody, MaxAuthor int | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in, | |
| 68 | + | // else the public form, else the page that is not there. | |
| 69 | + | func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) { | |
| 70 | + | if SignedIn(r.Context()) || s.cfg.IssueForm == nil { | |
| 71 | + | s.page(s.issueNew)(w, r) | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 75 | + | defer cancel() | |
| 76 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 77 | + | if err != nil { | |
| 78 | + | s.fail(ctx, w, r, err) | |
| 79 | + | return | |
| 80 | + | } | |
| 81 | + | page := IssueFormPage{} | |
| 82 | + | status, err := s.fillIssueForm(ctx, r, name, &page) | |
| 83 | + | if err != nil { | |
| 84 | + | s.formFailed(ctx, w, r, err, page.Cancel) | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | s.writeIssueForm(ctx, w, r, status, name, page) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // fillIssueForm asks a challenge for the client and fills what the form needs | |
| 91 | + | // from it, leaving the text of the visitor as it is. | |
| 92 | + | func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) { | |
| 93 | + | base := issuesBase(name.String()) | |
| 94 | + | p.Cancel = base | |
| 95 | + | ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r)) | |
| 96 | + | if err != nil { | |
| 97 | + | return 0, err | |
| 98 | + | } | |
| 99 | + | p.Action = base + "/new" | |
| 100 | + | p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty | |
| 101 | + | p.MinSubmitAt = ch.MinSubmitAt.UnixMilli() | |
| 102 | + | p.Worker = s.assets.urls.PowWorker | |
| 103 | + | p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen | |
| 104 | + | return http.StatusOK, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // formClient is the key of the client for the limits of the submissions. | |
| 108 | + | func (s *Server) formClient(r *http.Request) string { | |
| 109 | + | return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // writeIssueForm renders the form in the layout of the issues of the repository. | |
| 113 | + | func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) { | |
| 114 | + | d, err := s.issueRepo(ctx, name.String()) | |
| 115 | + | if err != nil { | |
| 116 | + | s.fail(ctx, w, r, err) | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | l := s.repoLayout(ctx, d, "issues") | |
| 120 | + | l.Title = "new issue · " + d.name() + " · " + s.instance | |
| 121 | + | l.Crumbs[0].Current = false | |
| 122 | + | l.Crumbs = append(l.Crumbs, | |
| 123 | + | Crumb{Label: "issues", Href: issuesBase(d.name())}, | |
| 124 | + | Crumb{Label: "new", Current: true}, | |
| 125 | + | ) | |
| 126 | + | l.HasRail = true | |
| 127 | + | l.IssueForm = true | |
| 128 | + | w.Header().Set("Cache-Control", noStore) | |
| 129 | + | s.write(ctx, w, r, status, IssueForm(l, p)) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // formFailed answers an error of the challenge or of the submission. | |
| 133 | + | func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | |
| 134 | + | var retry *domain.SubmissionRetryError | |
| 135 | + | switch { | |
| 136 | + | case errors.Is(err, context.Canceled): | |
| 137 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText): | |
| 138 | + | // The same page, with the same headers, as every other page that is | |
| 139 | + | // not there (a POST has set no-store already). | |
| 140 | + | s.fail(ctx, w, r, domain.ErrNotFound) | |
| 141 | + | case errors.As(err, &retry): | |
| 142 | + | secs := int(retry.After.Round(time.Second).Seconds()) | |
| 143 | + | if retry.After > time.Duration(secs)*time.Second { | |
| 144 | + | secs++ | |
| 145 | + | } | |
| 146 | + | secs = max(secs, 1) | |
| 147 | + | w.Header().Set("Retry-After", strconv.Itoa(secs)) | |
| 148 | + | mins := (secs + 59) / 60 | |
| 149 | + | unit := " minutes." | |
| 150 | + | if mins == 1 { | |
| 151 | + | unit = " minute." | |
| 152 | + | } | |
| 153 | + | s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions", | |
| 154 | + | "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back) | |
| 155 | + | case errors.Is(err, domain.ErrSubmissionsPaused): | |
| 156 | + | s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back) | |
| 157 | + | case errors.Is(err, domain.ErrSubmissionRefused): | |
| 158 | + | s.refusal(ctx, w, r, http.StatusBadRequest, "not verified", | |
| 159 | + | "The submission could not be verified. Reload the page and try again.", back) | |
| 160 | + | default: | |
| 161 | + | s.internalFailure(ctx, w, r, err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // refusal writes a state page for a visitor, never cached. | |
| 166 | + | func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) { | |
| 167 | + | if back == "" { | |
| 168 | + | back = "/" | |
| 169 | + | } | |
| 170 | + | l := s.stateLayout(title, []Crumb{{Label: title, Current: true}}) | |
| 171 | + | w.Header().Set("Cache-Control", noStore) | |
| 172 | + | s.write(ctx, w, r, status, State(l, StatePage{ | |
| 173 | + | Code: itoa(status), Title: title, Lines: []string{line}, | |
| 174 | + | Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}}, | |
| 175 | + | })) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // issueFormPost is POST /<repo>/issues/new. | |
| 179 | + | func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) { | |
| 180 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 181 | + | defer cancel() | |
| 182 | + | w.Header().Set("Cache-Control", noStore) | |
| 183 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 184 | + | if err != nil { | |
| 185 | + | s.formFailed(ctx, w, r, err, "") | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | base := issuesBase(name.String()) | |
| 189 | + | r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes) | |
| 190 | + | if err := r.ParseForm(); err != nil { | |
| 191 | + | var tooBig *http.MaxBytesError | |
| 192 | + | if errors.As(err, &tooBig) { | |
| 193 | + | s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new") | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new") | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | for _, f := range issueFormFields { | |
| 200 | + | if len(r.PostForm[f]) > 1 { | |
| 201 | + | s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new") | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | in := ports.IssueSubmission{ | |
| 206 | + | Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution), | |
| 207 | + | FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot), | |
| 208 | + | Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)), | |
| 209 | + | Author: r.PostForm.Get(fieldAuthor), | |
| 210 | + | } | |
| 211 | + | err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in) | |
| 212 | + | if err == nil { | |
| 213 | + | seeOther(w, r, base+"/submitted") | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | var te *domain.TextError | |
| 217 | + | if errors.As(err, &te) { | |
| 218 | + | s.formAgain(ctx, w, r, name, in, err, te) | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | s.formFailed(ctx, w, r, err, base+"/new") | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // formAgain shows the form again after a text the visitor can fix: a new | |
| 225 | + | // challenge (the first one is spent), the text kept, the message at its field. | |
| 226 | + | func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) { | |
| 227 | + | p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author} | |
| 228 | + | field, _, _ := strings.Cut(err.Error(), ": ") | |
| 229 | + | switch field { | |
| 230 | + | case fieldTitle, fieldBody, fieldAuthor: | |
| 231 | + | default: | |
| 232 | + | field = fieldBody | |
| 233 | + | } | |
| 234 | + | label := field | |
| 235 | + | if field == fieldAuthor { | |
| 236 | + | label = "name" | |
| 237 | + | } | |
| 238 | + | p.ErrorField = field | |
| 239 | + | p.Error = "The " + label + " " + visibleText(te.Reason) + "." | |
| 240 | + | if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil { | |
| 241 | + | s.formFailed(ctx, w, r, cerr, p.Cancel) | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // issueSubmittedPage is GET /<repo>/issues/submitted. | |
| 248 | + | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |
| 249 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 250 | + | defer cancel() | |
| 251 | + | d, err := s.issueRepo(ctx, r.PathValue("repo")) | |
| 252 | + | if err != nil { | |
| 253 | + | s.fail(ctx, w, r, err) | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic { | |
| 257 | + | s.fail(ctx, w, r, errNotFound) | |
| 258 | + | return | |
| 259 | + | } | |
| 260 | + | w.Header().Set("Cache-Control", noStore) | |
| 261 | + | s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name())) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (s *Server) submittedPage(repo string) templ.Component { | |
| 265 | + | l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}}) | |
| 266 | + | return State(l, StatePage{ | |
| 267 | + | Code: "ok", Title: "thanks", | |
| 268 | + | Lines: []string{"Thanks, your issue is waiting for moderation."}, | |
| 269 | + | Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}}, | |
| 270 | + | }) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func i64(n int64) string { return strconv.FormatInt(n, 10) } | |
| 274 | + | ||
| 275 | + | // runeCount is the length of s in characters, like the limits of the form. | |
| 276 | + | func runeCount(s string) int { return utf8.RuneCountInString(s) } | |
A internal/adapters/web/issues_form.templ +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // IssueForm is the public form that proposes an issue to the owner. It needs | |
| 6 | + | // JavaScript: the script (issue-form.js) solves a proof of work in a worker, | |
| 7 | + | // so the form stays hidden until it runs, and the page says why without it. | |
| 8 | + | templ IssueForm(l Layout, p IssueFormPage) { | |
| 9 | + | @Shell(l, issueFormRail()) { | |
| 10 | + | <header class="show mr-h"> | |
| 11 | + | <h1>New issue</h1> | |
| 12 | + | </header> | |
| 13 | + | <noscript> | |
| 14 | + | <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p> | |
| 15 | + | </noscript> | |
| 16 | + | <form | |
| 17 | + | class="cform create" | |
| 18 | + | method="post" | |
| 19 | + | action={ templ.SafeURL(p.Action) } | |
| 20 | + | hidden | |
| 21 | + | data-issue-form | |
| 22 | + | data-nonce={ p.Nonce } | |
| 23 | + | data-difficulty={ itoa(p.Difficulty) } | |
| 24 | + | data-min-submit-at={ i64(p.MinSubmitAt) } | |
| 25 | + | data-worker={ p.Worker } | |
| 26 | + | autocomplete="off" | |
| 27 | + | > | |
| 28 | + | <input type="hidden" name="token" value={ p.Token }/> | |
| 29 | + | <input type="hidden" name="form_token" value={ p.FormToken }/> | |
| 30 | + | <input type="hidden" name="solution" value=""/> | |
| 31 | + | <label for="issue-title">title</label> | |
| 32 | + | <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/> | |
| 33 | + | <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output> | |
| 34 | + | if p.ErrorField == "title" { | |
| 35 | + | <p class="f-error" role="alert">{ p.Error }</p> | |
| 36 | + | } | |
| 37 | + | <label for="issue-body">description</label> | |
| 38 | + | <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea> | |
| 39 | + | <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output> | |
| 40 | + | if p.ErrorField == "body" { | |
| 41 | + | <p class="f-error" role="alert">{ p.Error }</p> | |
| 42 | + | } | |
| 43 | + | <label for="issue-author">name (optional, not verified)</label> | |
| 44 | + | <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/> | |
| 45 | + | <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output> | |
| 46 | + | if p.ErrorField == "author" { | |
| 47 | + | <p class="f-error" role="alert">{ p.Error }</p> | |
| 48 | + | } | |
| 49 | + | <div class="hp" aria-hidden="true"> | |
| 50 | + | <label for="issue-website">Leave this field empty</label> | |
| 51 | + | <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/> | |
| 52 | + | </div> | |
| 53 | + | <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p> | |
| 54 | + | <div class="f-btns"> | |
| 55 | + | <button type="submit" class="btn primary" disabled>Submit for moderation</button> | |
| 56 | + | <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a> | |
| 57 | + | </div> | |
| 58 | + | </form> | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | templ issueFormRail() { | |
| 63 | + | <section aria-labelledby="r-about"> | |
| 64 | + | <h2 id="r-about">Before you write</h2> | |
| 65 | + | <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p> | |
| 66 | + | </section> | |
| 67 | + | } | |
A internal/adapters/web/issues_form_templ.go +421 -0 generated file: collapsed
A internal/adapters/web/issues_form_test.go +345 -0
| @@ -0,0 +1,345 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/url" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | type formRig struct { | |
| 24 | + | *issueRig | |
| 25 | + | form *mocks.IssueSubmissionService | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig { | |
| 29 | + | t.Helper() | |
| 30 | + | r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)} | |
| 31 | + | browse := mocks.NewBrowseService(t) | |
| 32 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| 33 | + | r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe() | |
| 34 | + | r.srv = newTestServer(t, Config{ | |
| 35 | + | Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues, | |
| 36 | + | IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 37 | + | }) | |
| 38 | + | return r | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func challenge(n int) ports.IssueChallenge { | |
| 42 | + | return ports.IssueChallenge{ | |
| 43 | + | Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18, | |
| 44 | + | FormToken: fmt.Sprintf("form-%d", n), | |
| 45 | + | IssuedAt: time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000), | |
| 46 | + | ExpiresAt: time.UnixMilli(1_700_000_600_000), | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // post sends a submission as a browser of this site would. | |
| 51 | + | func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder { | |
| 52 | + | req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) | |
| 53 | + | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 54 | + | for k, v := range hdr { | |
| 55 | + | req.Header.Set(k, v) | |
| 56 | + | } | |
| 57 | + | rec := httptest.NewRecorder() | |
| 58 | + | r.srv.ServeHTTP(rec, req) | |
| 59 | + | return rec | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | var fromSite = map[string]string{"Origin": testPublicURL} | |
| 63 | + | ||
| 64 | + | func goodForm() url.Values { | |
| 65 | + | return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestIssueFormGet(t *testing.T) { | |
| 69 | + | t.Parallel() | |
| 70 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 71 | + | r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once() | |
| 72 | + | ||
| 73 | + | rec := r.do("GET", "/proj/issues/new", false, nil) | |
| 74 | + | require.Equal(t, 200, rec.Code, rec.Body.String()) | |
| 75 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 76 | + | body := rec.Body.String() | |
| 77 | + | for _, want := range []string{ | |
| 78 | + | `<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.", | |
| 79 | + | `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`, | |
| 80 | + | `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`, | |
| 81 | + | `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`, | |
| 82 | + | `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`, | |
| 83 | + | `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`, | |
| 84 | + | `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`, | |
| 85 | + | `/-/static/js/issue-form.`, | |
| 86 | + | } { | |
| 87 | + | assert.Contains(t, body, want) | |
| 88 | + | } | |
| 89 | + | assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units") | |
| 90 | + | assert.NotContains(t, body, `name="website" type="hidden"`) | |
| 91 | + | assert.NotContains(t, body, `type="hidden" name="website"`) | |
| 92 | + | noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")] | |
| 93 | + | assert.NotContains(t, noscript, "<form", "no usable form without JavaScript") | |
| 94 | + | assert.NotContains(t, body, "style=", "no inline style") | |
| 95 | + | for _, tag := range strings.Split(body, "<script")[1:] { | |
| 96 | + | head, _, _ := strings.Cut(tag, ">") | |
| 97 | + | assert.Contains(t, head, `src="`, "no inline script") | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestIssueFormIsNotThereUnlessPublic(t *testing.T) { | |
| 102 | + | t.Parallel() | |
| 103 | + | want := "" | |
| 104 | + | for i, tc := range []struct { | |
| 105 | + | name string | |
| 106 | + | mode domain.IssuesMode | |
| 107 | + | err error | |
| 108 | + | }{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} { | |
| 109 | + | r := newFormRig(t, tc.mode) | |
| 110 | + | r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe() | |
| 111 | + | path := "/proj/issues/new" | |
| 112 | + | if tc.name == "unknown" { | |
| 113 | + | path = "/nope/issues/new" | |
| 114 | + | } | |
| 115 | + | rec := r.do("GET", path, false, nil) | |
| 116 | + | assert.Equal(t, 404, rec.Code, tc.name) | |
| 117 | + | if i == 0 { | |
| 118 | + | want = rec.Body.String() | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | assert.Equal(t, want, rec.Body.String(), tc.name+": the same page") | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestIssueFormOwnerKeepsHisForm(t *testing.T) { | |
| 126 | + | t.Parallel() | |
| 127 | + | r := newFormRig(t, domain.IssuesPublic) // no Challenge expected | |
| 128 | + | rec := r.do("GET", "/proj/issues/new", true, nil) | |
| 129 | + | require.Equal(t, 200, rec.Code) | |
| 130 | + | assert.Contains(t, rec.Body.String(), `action="/proj/issues"`) | |
| 131 | + | assert.NotContains(t, rec.Body.String(), "data-issue-form") | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestIssueFormGetPausedAndLimited(t *testing.T) { | |
| 135 | + | t.Parallel() | |
| 136 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 137 | + | r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once() | |
| 138 | + | rec := r.do("GET", "/proj/issues/new", false, nil) | |
| 139 | + | assert.Equal(t, 503, rec.Code) | |
| 140 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 141 | + | assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestIssueFormPostOutcomes(t *testing.T) { | |
| 145 | + | t.Parallel() | |
| 146 | + | generic := "The submission could not be verified. Reload the page and try again." | |
| 147 | + | tests := []struct { | |
| 148 | + | name string | |
| 149 | + | err error | |
| 150 | + | status int | |
| 151 | + | body string | |
| 152 | + | hdr map[string]string | |
| 153 | + | }{ | |
| 154 | + | {"not found", domain.ErrNotFound, 404, "no such page", nil}, | |
| 155 | + | {"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}}, | |
| 156 | + | {"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}}, | |
| 157 | + | {"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil}, | |
| 158 | + | {"invalid", domain.ErrSubmissionInvalid, 400, generic, nil}, | |
| 159 | + | {"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil}, | |
| 160 | + | } | |
| 161 | + | for _, tc := range tests { | |
| 162 | + | t.Run(tc.name, func(t *testing.T) { | |
| 163 | + | t.Parallel() | |
| 164 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 165 | + | r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once() | |
| 166 | + | rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()) | |
| 167 | + | assert.Equal(t, tc.status, rec.Code, rec.Body.String()) | |
| 168 | + | assert.Contains(t, rec.Body.String(), tc.body) | |
| 169 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 170 | + | for k, v := range tc.hdr { | |
| 171 | + | assert.Equal(t, v, rec.Header().Get(k)) | |
| 172 | + | } | |
| 173 | + | assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed") | |
| 174 | + | for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} { | |
| 175 | + | assert.NotContains(t, rec.Body.String(), leak, "no detail") | |
| 176 | + | } | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // TestIssueFormPostInvalidTokenLooksTheSame: the use case answers an invalid | |
| 182 | + | // submission the same whatever the client's ban, limit or the queue; the page | |
| 183 | + | // must not differ either. | |
| 184 | + | func TestIssueFormPostInvalidTokenLooksTheSame(t *testing.T) { | |
| 185 | + | t.Parallel() | |
| 186 | + | var first *httptest.ResponseRecorder | |
| 187 | + | for _, state := range []string{"banned", "rate limited", "queue full"} { | |
| 188 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 189 | + | r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(domain.ErrSubmissionInvalid).Once() | |
| 190 | + | f := goodForm() | |
| 191 | + | f.Set("token", "garbage") | |
| 192 | + | rec := r.post("/proj/issues/new", fromSite, f.Encode()) | |
| 193 | + | assert.Equal(t, 400, rec.Code, state) | |
| 194 | + | if first == nil { | |
| 195 | + | first = rec | |
| 196 | + | continue | |
| 197 | + | } | |
| 198 | + | assert.Equal(t, first.Body.String(), rec.Body.String(), state) | |
| 199 | + | assert.Equal(t, first.Header().Get("Retry-After"), rec.Header().Get("Retry-After"), state) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestIssueFormPostSuccess(t *testing.T) { | |
| 204 | + | t.Parallel() | |
| 205 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 206 | + | r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{ | |
| 207 | + | Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex", | |
| 208 | + | }).Return(nil).Once() | |
| 209 | + | rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()) | |
| 210 | + | assert.Equal(t, 303, rec.Code) | |
| 211 | + | assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location")) | |
| 212 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 213 | + | ||
| 214 | + | thanks := r.do("GET", "/proj/issues/submitted", false, nil) | |
| 215 | + | assert.Equal(t, 200, thanks.Code) | |
| 216 | + | assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control")) | |
| 217 | + | assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.") | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestIssueSubmittedPageOnlyForPublic(t *testing.T) { | |
| 221 | + | t.Parallel() | |
| 222 | + | r := newFormRig(t, domain.IssuesOwner) | |
| 223 | + | assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) { | |
| 227 | + | t.Parallel() | |
| 228 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 229 | + | r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 230 | + | Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once() | |
| 231 | + | r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once() | |
| 232 | + | f := goodForm() | |
| 233 | + | f.Set("title", "<b>mine</b>") | |
| 234 | + | rec := r.post("/proj/issues/new", fromSite, f.Encode()) | |
| 235 | + | require.Equal(t, 422, rec.Code, rec.Body.String()) | |
| 236 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 237 | + | body := rec.Body.String() | |
| 238 | + | assert.Contains(t, body, "<b>mine</b>", "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 <b>") | |
| 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 <b>on</b> 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, "<script>x</script>") | |
| 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 | 27 | k-c from:body -> ermite.tab('c'); | |
| 28 | 28 | k-r from:body -> ermite.tab('r'); | |
| 29 | 29 | k-m from:body -> ermite.tab('m'); | |
| 30 | + | k-i from:body -> ermite.tab('i'); | |
| 30 | 31 | k-next from:body -> ermite.nextNote(); | |
| 31 | 32 | k-split from:body -> ermite.layout('split'); | |
| 32 | 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 +74 -4
| @@ -62,9 +62,25 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, | |||
| 62 | 62 | // #fragment links chosen, so that several documents on one page (the summaries | |
| 63 | 63 | // of a run) do not share ids. | |
| 64 | 64 | func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) { | |
| 65 | - | pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()})) | |
| 65 | + | return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | |
| 69 | + | // approved from the public form). On top of the policy of render, which already | |
| 70 | + | // drops raw HTML and never loads an image: | |
| 71 | + | // - only http, https and mailto links are kept, every one with | |
| 72 | + | // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and | |
| 73 | + | // every other scheme lose their link and keep the text; | |
| 74 | + | // - nothing points into the repository. | |
| 75 | + | func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) { | |
| 76 | + | h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}) | |
| 77 | + | return h, err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (m *markdown) renderPolicy(source []byte, pol linkPolicy) (template.HTML, []Heading, error) { | |
| 81 | + | pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()})) | |
| 66 | 82 | doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc)) | |
| 67 | - | pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix} | |
| 83 | + | pol.source = source | |
| 68 | 84 | pol.apply(doc) | |
| 69 | 85 | ||
| 70 | 86 | var buf bytes.Buffer | |
| @@ -131,6 +147,20 @@ type linkPolicy struct { | |||
| 131 | 147 | source []byte | |
| 132 | 148 | // idPrefix is the prefix of the heading ids that #fragment links point at. | |
| 133 | 149 | idPrefix string | |
| 150 | + | // untrusted is the policy of text from an anonymous visitor. | |
| 151 | + | untrusted bool | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | const ( | |
| 155 | + | relExternal = "nofollow noopener noreferrer" | |
| 156 | + | relUntrusted = "nofollow noopener noreferrer ugc" | |
| 157 | + | ) | |
| 158 | + | ||
| 159 | + | func (p linkPolicy) rel() []byte { | |
| 160 | + | if p.untrusted { | |
| 161 | + | return []byte(relUntrusted) | |
| 162 | + | } | |
| 163 | + | return []byte(relExternal) | |
| 134 | 164 | } | |
| 135 | 165 | ||
| 136 | 166 | func (p linkPolicy) apply(doc ast.Node) { | |
| @@ -163,13 +193,17 @@ func (p linkPolicy) apply(doc ast.Node) { | |||
| 163 | 193 | l.Destination = []byte(dest) | |
| 164 | 194 | l.Title = nil | |
| 165 | 195 | if kind == external { | |
| 166 | - | l.SetAttributeString("rel", []byte("nofollow noopener noreferrer")) | |
| 196 | + | l.SetAttributeString("rel", p.rel()) | |
| 167 | 197 | } | |
| 168 | 198 | } | |
| 169 | 199 | for _, n := range autos { | |
| 170 | 200 | a := n.(*ast.AutoLink) | |
| 171 | 201 | if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) { | |
| 172 | - | a.SetAttributeString("rel", []byte("nofollow noopener noreferrer")) | |
| 202 | + | a.SetAttributeString("rel", p.rel()) | |
| 203 | + | continue | |
| 204 | + | } | |
| 205 | + | if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) { | |
| 206 | + | a.SetAttributeString("rel", p.rel()) | |
| 173 | 207 | continue | |
| 174 | 208 | } | |
| 175 | 209 | // An email address or another scheme: the label as plain text. | |
| @@ -209,6 +243,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) { | |||
| 209 | 243 | if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") { | |
| 210 | 244 | return "", dropped | |
| 211 | 245 | } | |
| 246 | + | if p.untrusted { | |
| 247 | + | return untrustedTarget(dest) | |
| 248 | + | } | |
| 212 | 249 | if strings.HasPrefix(dest, "#") { | |
| 213 | 250 | frag := strings.ToLower(strings.TrimPrefix(dest, "#")) | |
| 214 | 251 | if frag == "" || strings.ContainsAny(frag, " \"'<>") { | |
| @@ -256,6 +293,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) { | |||
| 256 | 293 | return href, internal | |
| 257 | 294 | } | |
| 258 | 295 | ||
| 296 | + | // untrustedTarget keeps absolute http and https links with a host, and mailto | |
| 297 | + | // links to one plain address. | |
| 298 | + | func untrustedTarget(dest string) (string, linkKind) { | |
| 299 | + | if strings.HasPrefix(dest, "//") { | |
| 300 | + | return "", dropped | |
| 301 | + | } | |
| 302 | + | u, err := url.Parse(dest) | |
| 303 | + | if err != nil { | |
| 304 | + | return "", dropped | |
| 305 | + | } | |
| 306 | + | switch strings.ToLower(u.Scheme) { | |
| 307 | + | case "http", "https": | |
| 308 | + | if u.Host == "" || u.User != nil { | |
| 309 | + | return "", dropped | |
| 310 | + | } | |
| 311 | + | return u.String(), external | |
| 312 | + | case "mailto": | |
| 313 | + | if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) { | |
| 314 | + | return "", dropped | |
| 315 | + | } | |
| 316 | + | return "mailto:" + u.Opaque, external | |
| 317 | + | } | |
| 318 | + | return "", dropped | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // validMailbox tells whether s is a bare address: no space, quote, angle | |
| 322 | + | // bracket, percent sign, comma or second at sign. | |
| 323 | + | func validMailbox(s string) bool { | |
| 324 | + | local, host, ok := strings.Cut(s, "@") | |
| 325 | + | return ok && local != "" && host != "" && len(s) <= 254 && | |
| 326 | + | !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@") | |
| 327 | + | } | |
| 328 | + | ||
| 259 | 329 | func isWebURL(s string) bool { | |
| 260 | 330 | u, err := url.Parse(s) | |
| 261 | 331 | if err != nil || u.Host == "" { | |
M internal/adapters/web/markdown_test.go +67 -0
| @@ -136,3 +136,70 @@ func TestMarkdownGFM(t *testing.T) { | |||
| 136 | 136 | assert.Contains(t, out, "<del>gone</del>") | |
| 137 | 137 | assert.Contains(t, out, `type="checkbox"`) | |
| 138 | 138 | } | |
| 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", "\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](javascript:alert(1))\n", []string{"avascript:", "href"}, nil}, | |
| 174 | + | } | |
| 175 | + | for _, tt := range tests { | |
| 176 | + | t.Run(tt.name, func(t *testing.T) { | |
| 177 | + | t.Parallel() | |
| 178 | + | out := renderUntrustedMD(t, tt.src) | |
| 179 | + | for _, a := range tt.absent { | |
| 180 | + | assert.NotContains(t, out, a) | |
| 181 | + | } | |
| 182 | + | for _, p := range tt.present { | |
| 183 | + | assert.Contains(t, out, p) | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestMarkdownUntrustedBigInputs(t *testing.T) { | |
| 190 | + | t.Parallel() | |
| 191 | + | long := strings.Repeat("a", 10000) | |
| 192 | + | out := renderUntrustedMD(t, long) | |
| 193 | + | assert.Contains(t, out, long) | |
| 194 | + | ||
| 195 | + | rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500) | |
| 196 | + | out = renderUntrustedMD(t, rows) | |
| 197 | + | assert.NotContains(t, out, "<b>") | |
| 198 | + | assert.NotContains(t, out, "javascript") | |
| 199 | + | assert.NotContains(t, out, "style=") | |
| 200 | + | ||
| 201 | + | deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n" | |
| 202 | + | assert.NotContains(t, renderUntrustedMD(t, deep), "javascript") | |
| 203 | + | ||
| 204 | + | assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"") | |
| 205 | + | } | |
M internal/adapters/web/mr_post.go +8 -1
| @@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10 | |||
| 31 | 31 | func (s *Server) form(w http.ResponseWriter, r *http.Request) bool { | |
| 32 | 32 | r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) | |
| 33 | 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 | 42 | return false | |
| 36 | 43 | } | |
| 37 | 44 | return true | |
M internal/adapters/web/overlays.templ +1 -1
| @@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) { | |||
| 26 | 26 | <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div> | |
| 27 | 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 | 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 & 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 & tags, merge requests, issues</dd></div> | |
| 30 | 30 | <div><dt><kbd>g h</kbd></dt><dd>home</dd></div> | |
| 31 | 31 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | |
| 32 | 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 | 9 | <div class="home" data-layout="cards"> | |
| 10 | 10 | <section class="home-repos" aria-label="Repositories"> | |
| 11 | 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 | 20 | <div class="filter"> | |
| 13 | 21 | <label for="home-filter" class="sr">filter</label> | |
| 14 | 22 | <input | |
M internal/adapters/web/pages_templ.go +409 -363
| @@ -44,89 +44,135 @@ func Home(l Layout, p HomePage) templ.Component { | |||
| 44 | 44 | }() | |
| 45 | 45 | } | |
| 46 | 46 | ctx = templ.InitializeContext(ctx) | |
| 47 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ") | |
| 47 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\">") | |
| 48 | 48 | if templ_7745c5c3_Err != nil { | |
| 49 | 49 | return templ_7745c5c3_Err | |
| 50 | 50 | } | |
| 51 | - | for _, s := range p.Sorts { | |
| 52 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"") | |
| 51 | + | if p.Inbox != nil { | |
| 52 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a class=\"btn inbox-btn\" href=\"") | |
| 53 | 53 | if templ_7745c5c3_Err != nil { | |
| 54 | 54 | return templ_7745c5c3_Err | |
| 55 | 55 | } | |
| 56 | 56 | var templ_7745c5c3_Var3 templ.SafeURL | |
| 57 | - | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 57 | + | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Inbox.Href)) | |
| 58 | 58 | if templ_7745c5c3_Err != nil { | |
| 59 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 27, Col: 36} | |
| 59 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 13, Col: 65} | |
| 60 | 60 | } | |
| 61 | 61 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) | |
| 62 | 62 | if templ_7745c5c3_Err != nil { | |
| 63 | 63 | return templ_7745c5c3_Err | |
| 64 | 64 | } | |
| 65 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"") | |
| 65 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" title=\"inbox\">Inbox ") | |
| 66 | + | if templ_7745c5c3_Err != nil { | |
| 67 | + | return templ_7745c5c3_Err | |
| 68 | + | } | |
| 69 | + | if p.Inbox.Count >= 0 { | |
| 70 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"count\">") | |
| 71 | + | if templ_7745c5c3_Err != nil { | |
| 72 | + | return templ_7745c5c3_Err | |
| 73 | + | } | |
| 74 | + | var templ_7745c5c3_Var4 string | |
| 75 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Inbox.Count)) | |
| 76 | + | if templ_7745c5c3_Err != nil { | |
| 77 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 16, Col: 49} | |
| 78 | + | } | |
| 79 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 80 | + | if templ_7745c5c3_Err != nil { | |
| 81 | + | return templ_7745c5c3_Err | |
| 82 | + | } | |
| 83 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span>") | |
| 84 | + | if templ_7745c5c3_Err != nil { | |
| 85 | + | return templ_7745c5c3_Err | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>") | |
| 89 | + | if templ_7745c5c3_Err != nil { | |
| 90 | + | return templ_7745c5c3_Err | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ") | |
| 94 | + | if templ_7745c5c3_Err != nil { | |
| 95 | + | return templ_7745c5c3_Err | |
| 96 | + | } | |
| 97 | + | for _, s := range p.Sorts { | |
| 98 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"") | |
| 99 | + | if templ_7745c5c3_Err != nil { | |
| 100 | + | return templ_7745c5c3_Err | |
| 101 | + | } | |
| 102 | + | var templ_7745c5c3_Var5 templ.SafeURL | |
| 103 | + | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 104 | + | if templ_7745c5c3_Err != nil { | |
| 105 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 35, Col: 36} | |
| 106 | + | } | |
| 107 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |
| 108 | + | if templ_7745c5c3_Err != nil { | |
| 109 | + | return templ_7745c5c3_Err | |
| 110 | + | } | |
| 111 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"") | |
| 66 | 112 | if templ_7745c5c3_Err != nil { | |
| 67 | 113 | return templ_7745c5c3_Err | |
| 68 | 114 | } | |
| 69 | 115 | if s.Mark != "" { | |
| 70 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " aria-current=\"true\"") | |
| 116 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"true\"") | |
| 71 | 117 | if templ_7745c5c3_Err != nil { | |
| 72 | 118 | return templ_7745c5c3_Err | |
| 73 | 119 | } | |
| 74 | 120 | } | |
| 75 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">") | |
| 121 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">") | |
| 76 | 122 | if templ_7745c5c3_Err != nil { | |
| 77 | 123 | return templ_7745c5c3_Err | |
| 78 | 124 | } | |
| 79 | - | var templ_7745c5c3_Var4 string | |
| 80 | - | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 125 | + | var templ_7745c5c3_Var6 string | |
| 126 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 81 | 127 | if templ_7745c5c3_Err != nil { | |
| 82 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 32, Col: 17} | |
| 128 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 40, Col: 17} | |
| 83 | 129 | } | |
| 84 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 130 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 85 | 131 | if templ_7745c5c3_Err != nil { | |
| 86 | 132 | return templ_7745c5c3_Err | |
| 87 | 133 | } | |
| 88 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") | |
| 134 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") | |
| 89 | 135 | if templ_7745c5c3_Err != nil { | |
| 90 | 136 | return templ_7745c5c3_Err | |
| 91 | 137 | } | |
| 92 | 138 | if s.Mark != "" { | |
| 93 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span aria-hidden=\"true\">") | |
| 139 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span aria-hidden=\"true\">") | |
| 94 | 140 | if templ_7745c5c3_Err != nil { | |
| 95 | 141 | return templ_7745c5c3_Err | |
| 96 | 142 | } | |
| 97 | - | var templ_7745c5c3_Var5 string | |
| 98 | - | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark) | |
| 143 | + | var templ_7745c5c3_Var7 string | |
| 144 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark) | |
| 99 | 145 | if templ_7745c5c3_Err != nil { | |
| 100 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 34, Col: 42} | |
| 146 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 42, Col: 42} | |
| 101 | 147 | } | |
| 102 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |
| 148 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |
| 103 | 149 | if templ_7745c5c3_Err != nil { | |
| 104 | 150 | return templ_7745c5c3_Err | |
| 105 | 151 | } | |
| 106 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> <span class=\"sr\">") | |
| 152 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span class=\"sr\">") | |
| 107 | 153 | if templ_7745c5c3_Err != nil { | |
| 108 | 154 | return templ_7745c5c3_Err | |
| 109 | 155 | } | |
| 110 | - | var templ_7745c5c3_Var6 string | |
| 111 | - | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State) | |
| 156 | + | var templ_7745c5c3_Var8 string | |
| 157 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State) | |
| 112 | 158 | if templ_7745c5c3_Err != nil { | |
| 113 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 35, Col: 42} | |
| 159 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 43, Col: 42} | |
| 114 | 160 | } | |
| 115 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 161 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 116 | 162 | if templ_7745c5c3_Err != nil { | |
| 117 | 163 | return templ_7745c5c3_Err | |
| 118 | 164 | } | |
| 119 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>") | |
| 165 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>") | |
| 120 | 166 | if templ_7745c5c3_Err != nil { | |
| 121 | 167 | return templ_7745c5c3_Err | |
| 122 | 168 | } | |
| 123 | 169 | } | |
| 124 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</a>") | |
| 170 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a>") | |
| 125 | 171 | if templ_7745c5c3_Err != nil { | |
| 126 | 172 | return templ_7745c5c3_Err | |
| 127 | 173 | } | |
| 128 | 174 | } | |
| 129 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>") | |
| 175 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>") | |
| 130 | 176 | if templ_7745c5c3_Err != nil { | |
| 131 | 177 | return templ_7745c5c3_Err | |
| 132 | 178 | } | |
| @@ -136,17 +182,17 @@ func Home(l Layout, p HomePage) templ.Component { | |||
| 136 | 182 | return templ_7745c5c3_Err | |
| 137 | 183 | } | |
| 138 | 184 | } | |
| 139 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ul>") | |
| 185 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</ul>") | |
| 140 | 186 | if templ_7745c5c3_Err != nil { | |
| 141 | 187 | return templ_7745c5c3_Err | |
| 142 | 188 | } | |
| 143 | 189 | if len(p.Rows) == 0 { | |
| 144 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"lede dim\">no repositories yet.</p>") | |
| 190 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no repositories yet.</p>") | |
| 145 | 191 | if templ_7745c5c3_Err != nil { | |
| 146 | 192 | return templ_7745c5c3_Err | |
| 147 | 193 | } | |
| 148 | 194 | } | |
| 149 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</section></div>") | |
| 195 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section></div>") | |
| 150 | 196 | if templ_7745c5c3_Err != nil { | |
| 151 | 197 | return templ_7745c5c3_Err | |
| 152 | 198 | } | |
| @@ -178,12 +224,12 @@ func Summary(l Layout, p SummaryPage) templ.Component { | |||
| 178 | 224 | }() | |
| 179 | 225 | } | |
| 180 | 226 | ctx = templ.InitializeContext(ctx) | |
| 181 | - | templ_7745c5c3_Var7 := templ.GetChildren(ctx) | |
| 182 | - | if templ_7745c5c3_Var7 == nil { | |
| 183 | - | templ_7745c5c3_Var7 = templ.NopComponent | |
| 227 | + | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | |
| 228 | + | if templ_7745c5c3_Var9 == nil { | |
| 229 | + | templ_7745c5c3_Var9 = templ.NopComponent | |
| 184 | 230 | } | |
| 185 | 231 | ctx = templ.ClearChildren(ctx) | |
| 186 | - | templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 232 | + | templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 187 | 233 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 188 | 234 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 189 | 235 | if !templ_7745c5c3_IsBuffer { | |
| @@ -203,7 +249,7 @@ func Summary(l Layout, p SummaryPage) templ.Component { | |||
| 203 | 249 | } | |
| 204 | 250 | return nil | |
| 205 | 251 | }) | |
| 206 | - | templ_7745c5c3_Err = Shell(l, summaryRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer) | |
| 252 | + | templ_7745c5c3_Err = Shell(l, summaryRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) | |
| 207 | 253 | if templ_7745c5c3_Err != nil { | |
| 208 | 254 | return templ_7745c5c3_Err | |
| 209 | 255 | } | |
| @@ -230,158 +276,158 @@ func latestTagFact(t LatestTag) templ.Component { | |||
| 230 | 276 | }() | |
| 231 | 277 | } | |
| 232 | 278 | ctx = templ.InitializeContext(ctx) | |
| 233 | - | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | |
| 234 | - | if templ_7745c5c3_Var9 == nil { | |
| 235 | - | templ_7745c5c3_Var9 = templ.NopComponent | |
| 279 | + | templ_7745c5c3_Var11 := templ.GetChildren(ctx) | |
| 280 | + | if templ_7745c5c3_Var11 == nil { | |
| 281 | + | templ_7745c5c3_Var11 = templ.NopComponent | |
| 236 | 282 | } | |
| 237 | 283 | ctx = templ.ClearChildren(ctx) | |
| 238 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<dt>latest tag</dt><dd class=\"latest-tag\">") | |
| 284 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>latest tag</dt><dd class=\"latest-tag\">") | |
| 239 | 285 | if templ_7745c5c3_Err != nil { | |
| 240 | 286 | return templ_7745c5c3_Err | |
| 241 | 287 | } | |
| 242 | 288 | if t.Name != "" { | |
| 243 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a class=\"tag-name\" href=\"") | |
| 289 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"tag-name\" href=\"") | |
| 244 | 290 | if templ_7745c5c3_Err != nil { | |
| 245 | 291 | return templ_7745c5c3_Err | |
| 246 | 292 | } | |
| 247 | - | var templ_7745c5c3_Var10 templ.SafeURL | |
| 248 | - | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href)) | |
| 293 | + | var templ_7745c5c3_Var12 templ.SafeURL | |
| 294 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href)) | |
| 249 | 295 | if templ_7745c5c3_Err != nil { | |
| 250 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 51} | |
| 296 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 51} | |
| 251 | 297 | } | |
| 252 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |
| 298 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 253 | 299 | if templ_7745c5c3_Err != nil { | |
| 254 | 300 | return templ_7745c5c3_Err | |
| 255 | 301 | } | |
| 256 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" title=\"") | |
| 302 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" title=\"") | |
| 257 | 303 | if templ_7745c5c3_Err != nil { | |
| 258 | 304 | return templ_7745c5c3_Err | |
| 259 | 305 | } | |
| 260 | - | var templ_7745c5c3_Var11 string | |
| 261 | - | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject) | |
| 306 | + | var templ_7745c5c3_Var13 string | |
| 307 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject) | |
| 262 | 308 | if templ_7745c5c3_Err != nil { | |
| 263 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 71} | |
| 309 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 71} | |
| 264 | 310 | } | |
| 265 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) | |
| 311 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) | |
| 266 | 312 | if templ_7745c5c3_Err != nil { | |
| 267 | 313 | return templ_7745c5c3_Err | |
| 268 | 314 | } | |
| 269 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">") | |
| 315 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">") | |
| 270 | 316 | if templ_7745c5c3_Err != nil { | |
| 271 | 317 | return templ_7745c5c3_Err | |
| 272 | 318 | } | |
| 273 | - | var templ_7745c5c3_Var12 string | |
| 274 | - | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name) | |
| 319 | + | var templ_7745c5c3_Var14 string | |
| 320 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name) | |
| 275 | 321 | if templ_7745c5c3_Err != nil { | |
| 276 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 82} | |
| 322 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 82} | |
| 277 | 323 | } | |
| 278 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 324 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 279 | 325 | if templ_7745c5c3_Err != nil { | |
| 280 | 326 | return templ_7745c5c3_Err | |
| 281 | 327 | } | |
| 282 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> ") | |
| 328 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</a> ") | |
| 283 | 329 | if templ_7745c5c3_Err != nil { | |
| 284 | 330 | return templ_7745c5c3_Err | |
| 285 | 331 | } | |
| 286 | 332 | if t.WhenAt != "" { | |
| 287 | - | var templ_7745c5c3_Var13 string | |
| 288 | - | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 333 | + | var templ_7745c5c3_Var15 string | |
| 334 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 289 | 335 | if templ_7745c5c3_Err != nil { | |
| 290 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 12} | |
| 336 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 12} | |
| 291 | 337 | } | |
| 292 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |
| 338 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 293 | 339 | if templ_7745c5c3_Err != nil { | |
| 294 | 340 | return templ_7745c5c3_Err | |
| 295 | 341 | } | |
| 296 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<time datetime=\"") | |
| 342 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<time datetime=\"") | |
| 297 | 343 | if templ_7745c5c3_Err != nil { | |
| 298 | 344 | return templ_7745c5c3_Err | |
| 299 | 345 | } | |
| 300 | - | var templ_7745c5c3_Var14 string | |
| 301 | - | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt) | |
| 346 | + | var templ_7745c5c3_Var16 string | |
| 347 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt) | |
| 302 | 348 | if templ_7745c5c3_Err != nil { | |
| 303 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 39} | |
| 349 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 39} | |
| 304 | 350 | } | |
| 305 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |
| 351 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |
| 306 | 352 | if templ_7745c5c3_Err != nil { | |
| 307 | 353 | return templ_7745c5c3_Err | |
| 308 | 354 | } | |
| 309 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" title=\"") | |
| 355 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"") | |
| 310 | 356 | if templ_7745c5c3_Err != nil { | |
| 311 | 357 | return templ_7745c5c3_Err | |
| 312 | 358 | } | |
| 313 | - | var templ_7745c5c3_Var15 string | |
| 314 | - | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt) | |
| 359 | + | var templ_7745c5c3_Var17 string | |
| 360 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt) | |
| 315 | 361 | if templ_7745c5c3_Err != nil { | |
| 316 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 58} | |
| 362 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 58} | |
| 317 | 363 | } | |
| 318 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |
| 364 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) | |
| 319 | 365 | if templ_7745c5c3_Err != nil { | |
| 320 | 366 | return templ_7745c5c3_Err | |
| 321 | 367 | } | |
| 322 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">") | |
| 368 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">") | |
| 323 | 369 | if templ_7745c5c3_Err != nil { | |
| 324 | 370 | return templ_7745c5c3_Err | |
| 325 | 371 | } | |
| 326 | - | var templ_7745c5c3_Var16 string | |
| 327 | - | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t.When) | |
| 372 | + | var templ_7745c5c3_Var18 string | |
| 373 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(t.When) | |
| 328 | 374 | if templ_7745c5c3_Err != nil { | |
| 329 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 69} | |
| 375 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 69} | |
| 330 | 376 | } | |
| 331 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |
| 377 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 332 | 378 | if templ_7745c5c3_Err != nil { | |
| 333 | 379 | return templ_7745c5c3_Err | |
| 334 | 380 | } | |
| 335 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</time>") | |
| 381 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</time>") | |
| 336 | 382 | if templ_7745c5c3_Err != nil { | |
| 337 | 383 | return templ_7745c5c3_Err | |
| 338 | 384 | } | |
| 339 | 385 | } | |
| 340 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") | |
| 386 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") | |
| 341 | 387 | if templ_7745c5c3_Err != nil { | |
| 342 | 388 | return templ_7745c5c3_Err | |
| 343 | 389 | } | |
| 344 | - | var templ_7745c5c3_Var17 string | |
| 345 | - | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 390 | + | var templ_7745c5c3_Var19 string | |
| 391 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 346 | 392 | if templ_7745c5c3_Err != nil { | |
| 347 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 11} | |
| 393 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 94, Col: 11} | |
| 348 | 394 | } | |
| 349 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 395 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 350 | 396 | if templ_7745c5c3_Err != nil { | |
| 351 | 397 | return templ_7745c5c3_Err | |
| 352 | 398 | } | |
| 353 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ") | |
| 399 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") | |
| 354 | 400 | if templ_7745c5c3_Err != nil { | |
| 355 | 401 | return templ_7745c5c3_Err | |
| 356 | 402 | } | |
| 357 | 403 | } | |
| 358 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"all-tags\" href=\"") | |
| 404 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"all-tags\" href=\"") | |
| 359 | 405 | if templ_7745c5c3_Err != nil { | |
| 360 | 406 | return templ_7745c5c3_Err | |
| 361 | 407 | } | |
| 362 | - | var templ_7745c5c3_Var18 templ.SafeURL | |
| 363 | - | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref)) | |
| 408 | + | var templ_7745c5c3_Var20 templ.SafeURL | |
| 409 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref)) | |
| 364 | 410 | if templ_7745c5c3_Err != nil { | |
| 365 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 54} | |
| 411 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 54} | |
| 366 | 412 | } | |
| 367 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 413 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 368 | 414 | if templ_7745c5c3_Err != nil { | |
| 369 | 415 | return templ_7745c5c3_Err | |
| 370 | 416 | } | |
| 371 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">") | |
| 417 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">") | |
| 372 | 418 | if templ_7745c5c3_Err != nil { | |
| 373 | 419 | return templ_7745c5c3_Err | |
| 374 | 420 | } | |
| 375 | - | var templ_7745c5c3_Var19 string | |
| 376 | - | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags")) | |
| 421 | + | var templ_7745c5c3_Var21 string | |
| 422 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags")) | |
| 377 | 423 | if templ_7745c5c3_Err != nil { | |
| 378 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 89} | |
| 424 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 89} | |
| 379 | 425 | } | |
| 380 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 426 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 381 | 427 | if templ_7745c5c3_Err != nil { | |
| 382 | 428 | return templ_7745c5c3_Err | |
| 383 | 429 | } | |
| 384 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a></dd>") | |
| 430 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a></dd>") | |
| 385 | 431 | if templ_7745c5c3_Err != nil { | |
| 386 | 432 | return templ_7745c5c3_Err | |
| 387 | 433 | } | |
| @@ -405,12 +451,12 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 405 | 451 | }() | |
| 406 | 452 | } | |
| 407 | 453 | ctx = templ.InitializeContext(ctx) | |
| 408 | - | templ_7745c5c3_Var20 := templ.GetChildren(ctx) | |
| 409 | - | if templ_7745c5c3_Var20 == nil { | |
| 410 | - | templ_7745c5c3_Var20 = templ.NopComponent | |
| 454 | + | templ_7745c5c3_Var22 := templ.GetChildren(ctx) | |
| 455 | + | if templ_7745c5c3_Var22 == nil { | |
| 456 | + | templ_7745c5c3_Var22 = templ.NopComponent | |
| 411 | 457 | } | |
| 412 | 458 | ctx = templ.ClearChildren(ctx) | |
| 413 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>") | |
| 459 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>") | |
| 414 | 460 | if templ_7745c5c3_Err != nil { | |
| 415 | 461 | return templ_7745c5c3_Err | |
| 416 | 462 | } | |
| @@ -418,25 +464,25 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 418 | 464 | if templ_7745c5c3_Err != nil { | |
| 419 | 465 | return templ_7745c5c3_Err | |
| 420 | 466 | } | |
| 421 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">") | |
| 467 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">") | |
| 422 | 468 | if templ_7745c5c3_Err != nil { | |
| 423 | 469 | return templ_7745c5c3_Err | |
| 424 | 470 | } | |
| 425 | 471 | for _, f := range p.Facts { | |
| 426 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<dt>") | |
| 472 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<dt>") | |
| 427 | 473 | if templ_7745c5c3_Err != nil { | |
| 428 | 474 | return templ_7745c5c3_Err | |
| 429 | 475 | } | |
| 430 | - | var templ_7745c5c3_Var21 string | |
| 431 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key) | |
| 476 | + | var templ_7745c5c3_Var23 string | |
| 477 | + | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key) | |
| 432 | 478 | if templ_7745c5c3_Err != nil { | |
| 433 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 99, Col: 15} | |
| 479 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 107, Col: 15} | |
| 434 | 480 | } | |
| 435 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 481 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 436 | 482 | if templ_7745c5c3_Err != nil { | |
| 437 | 483 | return templ_7745c5c3_Err | |
| 438 | 484 | } | |
| 439 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</dt>") | |
| 485 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</dt>") | |
| 440 | 486 | if templ_7745c5c3_Err != nil { | |
| 441 | 487 | return templ_7745c5c3_Err | |
| 442 | 488 | } | |
| @@ -451,25 +497,25 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 451 | 497 | return templ_7745c5c3_Err | |
| 452 | 498 | } | |
| 453 | 499 | } | |
| 454 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</dl></section>") | |
| 500 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</dl></section>") | |
| 455 | 501 | if templ_7745c5c3_Err != nil { | |
| 456 | 502 | return templ_7745c5c3_Err | |
| 457 | 503 | } | |
| 458 | 504 | if len(p.Recent) > 0 { | |
| 459 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"") | |
| 505 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"") | |
| 460 | 506 | if templ_7745c5c3_Err != nil { | |
| 461 | 507 | return templ_7745c5c3_Err | |
| 462 | 508 | } | |
| 463 | - | var templ_7745c5c3_Var22 templ.SafeURL | |
| 464 | - | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref)) | |
| 509 | + | var templ_7745c5c3_Var24 templ.SafeURL | |
| 510 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref)) | |
| 465 | 511 | if templ_7745c5c3_Err != nil { | |
| 466 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 108, Col: 105} | |
| 512 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 116, Col: 105} | |
| 467 | 513 | } | |
| 468 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 514 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 469 | 515 | if templ_7745c5c3_Err != nil { | |
| 470 | 516 | return templ_7745c5c3_Err | |
| 471 | 517 | } | |
| 472 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">View all</a></h2>") | |
| 518 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">View all</a></h2>") | |
| 473 | 519 | if templ_7745c5c3_Err != nil { | |
| 474 | 520 | return templ_7745c5c3_Err | |
| 475 | 521 | } | |
| @@ -477,7 +523,7 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 477 | 523 | if templ_7745c5c3_Err != nil { | |
| 478 | 524 | return templ_7745c5c3_Err | |
| 479 | 525 | } | |
| 480 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section>") | |
| 526 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</section>") | |
| 481 | 527 | if templ_7745c5c3_Err != nil { | |
| 482 | 528 | return templ_7745c5c3_Err | |
| 483 | 529 | } | |
| @@ -489,7 +535,7 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 489 | 535 | } | |
| 490 | 536 | } | |
| 491 | 537 | if len(p.Outline) > 0 && p.Readme != nil { | |
| 492 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>") | |
| 538 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>") | |
| 493 | 539 | if templ_7745c5c3_Err != nil { | |
| 494 | 540 | return templ_7745c5c3_Err | |
| 495 | 541 | } | |
| @@ -497,7 +543,7 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 497 | 543 | if templ_7745c5c3_Err != nil { | |
| 498 | 544 | return templ_7745c5c3_Err | |
| 499 | 545 | } | |
| 500 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section>") | |
| 546 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>") | |
| 501 | 547 | if templ_7745c5c3_Err != nil { | |
| 502 | 548 | return templ_7745c5c3_Err | |
| 503 | 549 | } | |
| @@ -523,12 +569,12 @@ func Refs(l Layout, p RefsPage) templ.Component { | |||
| 523 | 569 | }() | |
| 524 | 570 | } | |
| 525 | 571 | ctx = templ.InitializeContext(ctx) | |
| 526 | - | templ_7745c5c3_Var23 := templ.GetChildren(ctx) | |
| 527 | - | if templ_7745c5c3_Var23 == nil { | |
| 528 | - | templ_7745c5c3_Var23 = templ.NopComponent | |
| 572 | + | templ_7745c5c3_Var25 := templ.GetChildren(ctx) | |
| 573 | + | if templ_7745c5c3_Var25 == nil { | |
| 574 | + | templ_7745c5c3_Var25 = templ.NopComponent | |
| 529 | 575 | } | |
| 530 | 576 | ctx = templ.ClearChildren(ctx) | |
| 531 | - | templ_7745c5c3_Var24 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 577 | + | templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 532 | 578 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 533 | 579 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 534 | 580 | if !templ_7745c5c3_IsBuffer { | |
| @@ -540,7 +586,7 @@ func Refs(l Layout, p RefsPage) templ.Component { | |||
| 540 | 586 | }() | |
| 541 | 587 | } | |
| 542 | 588 | ctx = templ.InitializeContext(ctx) | |
| 543 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>") | |
| 589 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>") | |
| 544 | 590 | if templ_7745c5c3_Err != nil { | |
| 545 | 591 | return templ_7745c5c3_Err | |
| 546 | 592 | } | |
| @@ -548,32 +594,32 @@ func Refs(l Layout, p RefsPage) templ.Component { | |||
| 548 | 594 | if templ_7745c5c3_Err != nil { | |
| 549 | 595 | return templ_7745c5c3_Err | |
| 550 | 596 | } | |
| 551 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</table>") | |
| 597 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</table>") | |
| 552 | 598 | if templ_7745c5c3_Err != nil { | |
| 553 | 599 | return templ_7745c5c3_Err | |
| 554 | 600 | } | |
| 555 | 601 | if p.Note != "" { | |
| 556 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"note\">") | |
| 602 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<p class=\"note\">") | |
| 557 | 603 | if templ_7745c5c3_Err != nil { | |
| 558 | 604 | return templ_7745c5c3_Err | |
| 559 | 605 | } | |
| 560 | - | var templ_7745c5c3_Var25 string | |
| 561 | - | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 606 | + | var templ_7745c5c3_Var27 string | |
| 607 | + | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 562 | 608 | if templ_7745c5c3_Err != nil { | |
| 563 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 141, Col: 27} | |
| 609 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 149, Col: 27} | |
| 564 | 610 | } | |
| 565 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | |
| 611 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 566 | 612 | if templ_7745c5c3_Err != nil { | |
| 567 | 613 | return templ_7745c5c3_Err | |
| 568 | 614 | } | |
| 569 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>") | |
| 615 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p>") | |
| 570 | 616 | if templ_7745c5c3_Err != nil { | |
| 571 | 617 | return templ_7745c5c3_Err | |
| 572 | 618 | } | |
| 573 | 619 | } | |
| 574 | 620 | return nil | |
| 575 | 621 | }) | |
| 576 | - | templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var24), templ_7745c5c3_Buffer) | |
| 622 | + | templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer) | |
| 577 | 623 | if templ_7745c5c3_Err != nil { | |
| 578 | 624 | return templ_7745c5c3_Err | |
| 579 | 625 | } | |
| @@ -597,112 +643,112 @@ func refRow(kind string, r RefRow) templ.Component { | |||
| 597 | 643 | }() | |
| 598 | 644 | } | |
| 599 | 645 | ctx = templ.InitializeContext(ctx) | |
| 600 | - | templ_7745c5c3_Var26 := templ.GetChildren(ctx) | |
| 601 | - | if templ_7745c5c3_Var26 == nil { | |
| 602 | - | templ_7745c5c3_Var26 = templ.NopComponent | |
| 646 | + | templ_7745c5c3_Var28 := templ.GetChildren(ctx) | |
| 647 | + | if templ_7745c5c3_Var28 == nil { | |
| 648 | + | templ_7745c5c3_Var28 = templ.NopComponent | |
| 603 | 649 | } | |
| 604 | 650 | ctx = templ.ClearChildren(ctx) | |
| 605 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<tr") | |
| 651 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<tr") | |
| 606 | 652 | if templ_7745c5c3_Err != nil { | |
| 607 | 653 | return templ_7745c5c3_Err | |
| 608 | 654 | } | |
| 609 | 655 | if r.Cursor { | |
| 610 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " data-cursor") | |
| 656 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " data-cursor") | |
| 611 | 657 | if templ_7745c5c3_Err != nil { | |
| 612 | 658 | return templ_7745c5c3_Err | |
| 613 | 659 | } | |
| 614 | 660 | } | |
| 615 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "><td class=\"c-kind mute\">") | |
| 661 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><td class=\"c-kind mute\">") | |
| 616 | 662 | if templ_7745c5c3_Err != nil { | |
| 617 | 663 | return templ_7745c5c3_Err | |
| 618 | 664 | } | |
| 619 | - | var templ_7745c5c3_Var27 string | |
| 620 | - | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind) | |
| 665 | + | var templ_7745c5c3_Var29 string | |
| 666 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(kind) | |
| 621 | 667 | if templ_7745c5c3_Err != nil { | |
| 622 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 32} | |
| 668 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 32} | |
| 623 | 669 | } | |
| 624 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 670 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 625 | 671 | if templ_7745c5c3_Err != nil { | |
| 626 | 672 | return templ_7745c5c3_Err | |
| 627 | 673 | } | |
| 628 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</td><td class=\"c-name\"><a href=\"") | |
| 674 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td class=\"c-name\"><a href=\"") | |
| 629 | 675 | if templ_7745c5c3_Err != nil { | |
| 630 | 676 | return templ_7745c5c3_Err | |
| 631 | 677 | } | |
| 632 | - | var templ_7745c5c3_Var28 templ.SafeURL | |
| 633 | - | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 678 | + | var templ_7745c5c3_Var30 templ.SafeURL | |
| 679 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 634 | 680 | if templ_7745c5c3_Err != nil { | |
| 635 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 34} | |
| 681 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 34} | |
| 636 | 682 | } | |
| 637 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) | |
| 683 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 638 | 684 | if templ_7745c5c3_Err != nil { | |
| 639 | 685 | return templ_7745c5c3_Err | |
| 640 | 686 | } | |
| 641 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">") | |
| 687 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">") | |
| 642 | 688 | if templ_7745c5c3_Err != nil { | |
| 643 | 689 | return templ_7745c5c3_Err | |
| 644 | 690 | } | |
| 645 | - | var templ_7745c5c3_Var29 string | |
| 646 | - | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 691 | + | var templ_7745c5c3_Var31 string | |
| 692 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 647 | 693 | if templ_7745c5c3_Err != nil { | |
| 648 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 45} | |
| 694 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 45} | |
| 649 | 695 | } | |
| 650 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 696 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 651 | 697 | if templ_7745c5c3_Err != nil { | |
| 652 | 698 | return templ_7745c5c3_Err | |
| 653 | 699 | } | |
| 654 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a> ") | |
| 700 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> ") | |
| 655 | 701 | if templ_7745c5c3_Err != nil { | |
| 656 | 702 | return templ_7745c5c3_Err | |
| 657 | 703 | } | |
| 658 | 704 | if r.Default { | |
| 659 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"pill\">default</span>") | |
| 705 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"pill\">default</span>") | |
| 660 | 706 | if templ_7745c5c3_Err != nil { | |
| 661 | 707 | return templ_7745c5c3_Err | |
| 662 | 708 | } | |
| 663 | 709 | } | |
| 664 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-sha\">") | |
| 710 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"c-sha\">") | |
| 665 | 711 | if templ_7745c5c3_Err != nil { | |
| 666 | 712 | return templ_7745c5c3_Err | |
| 667 | 713 | } | |
| 668 | 714 | if r.CHref != "" { | |
| 669 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<a href=\"") | |
| 715 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a href=\"") | |
| 670 | 716 | if templ_7745c5c3_Err != nil { | |
| 671 | 717 | return templ_7745c5c3_Err | |
| 672 | 718 | } | |
| 673 | - | var templ_7745c5c3_Var30 templ.SafeURL | |
| 674 | - | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref)) | |
| 719 | + | var templ_7745c5c3_Var32 templ.SafeURL | |
| 720 | + | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref)) | |
| 675 | 721 | if templ_7745c5c3_Err != nil { | |
| 676 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 36} | |
| 722 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 36} | |
| 677 | 723 | } | |
| 678 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 724 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 679 | 725 | if templ_7745c5c3_Err != nil { | |
| 680 | 726 | return templ_7745c5c3_Err | |
| 681 | 727 | } | |
| 682 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">") | |
| 728 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">") | |
| 683 | 729 | if templ_7745c5c3_Err != nil { | |
| 684 | 730 | return templ_7745c5c3_Err | |
| 685 | 731 | } | |
| 686 | - | var templ_7745c5c3_Var31 string | |
| 687 | - | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 732 | + | var templ_7745c5c3_Var33 string | |
| 733 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 688 | 734 | if templ_7745c5c3_Err != nil { | |
| 689 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 48} | |
| 735 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 48} | |
| 690 | 736 | } | |
| 691 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 737 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 692 | 738 | if templ_7745c5c3_Err != nil { | |
| 693 | 739 | return templ_7745c5c3_Err | |
| 694 | 740 | } | |
| 695 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>") | |
| 741 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</a>") | |
| 696 | 742 | if templ_7745c5c3_Err != nil { | |
| 697 | 743 | return templ_7745c5c3_Err | |
| 698 | 744 | } | |
| 699 | 745 | } else { | |
| 700 | - | var templ_7745c5c3_Var32 string | |
| 701 | - | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 746 | + | var templ_7745c5c3_Var34 string | |
| 747 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 702 | 748 | if templ_7745c5c3_Err != nil { | |
| 703 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 13} | |
| 749 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 167, Col: 13} | |
| 704 | 750 | } | |
| 705 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 751 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 706 | 752 | if templ_7745c5c3_Err != nil { | |
| 707 | 753 | return templ_7745c5c3_Err | |
| 708 | 754 | } | |
| @@ -711,69 +757,69 @@ func refRow(kind string, r RefRow) templ.Component { | |||
| 711 | 757 | if templ_7745c5c3_Err != nil { | |
| 712 | 758 | return templ_7745c5c3_Err | |
| 713 | 759 | } | |
| 714 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-sub\">") | |
| 760 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td><td class=\"c-sub\">") | |
| 715 | 761 | if templ_7745c5c3_Err != nil { | |
| 716 | 762 | return templ_7745c5c3_Err | |
| 717 | 763 | } | |
| 718 | - | var templ_7745c5c3_Var33 string | |
| 719 | - | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject) | |
| 764 | + | var templ_7745c5c3_Var35 string | |
| 765 | + | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject) | |
| 720 | 766 | if templ_7745c5c3_Err != nil { | |
| 721 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 31} | |
| 767 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 31} | |
| 722 | 768 | } | |
| 723 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 769 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 724 | 770 | if templ_7745c5c3_Err != nil { | |
| 725 | 771 | return templ_7745c5c3_Err | |
| 726 | 772 | } | |
| 727 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"c-when dim\">") | |
| 773 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"c-when dim\">") | |
| 728 | 774 | if templ_7745c5c3_Err != nil { | |
| 729 | 775 | return templ_7745c5c3_Err | |
| 730 | 776 | } | |
| 731 | 777 | if r.WhenAt != "" { | |
| 732 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<time datetime=\"") | |
| 778 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<time datetime=\"") | |
| 733 | 779 | if templ_7745c5c3_Err != nil { | |
| 734 | 780 | return templ_7745c5c3_Err | |
| 735 | 781 | } | |
| 736 | - | var templ_7745c5c3_Var34 string | |
| 737 | - | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 782 | + | var templ_7745c5c3_Var36 string | |
| 783 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 738 | 784 | if templ_7745c5c3_Err != nil { | |
| 739 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 29} | |
| 785 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 29} | |
| 740 | 786 | } | |
| 741 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) | |
| 787 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) | |
| 742 | 788 | if templ_7745c5c3_Err != nil { | |
| 743 | 789 | return templ_7745c5c3_Err | |
| 744 | 790 | } | |
| 745 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" title=\"") | |
| 791 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" title=\"") | |
| 746 | 792 | if templ_7745c5c3_Err != nil { | |
| 747 | 793 | return templ_7745c5c3_Err | |
| 748 | 794 | } | |
| 749 | - | var templ_7745c5c3_Var35 string | |
| 750 | - | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 795 | + | var templ_7745c5c3_Var37 string | |
| 796 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 751 | 797 | if templ_7745c5c3_Err != nil { | |
| 752 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 48} | |
| 798 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 48} | |
| 753 | 799 | } | |
| 754 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) | |
| 800 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) | |
| 755 | 801 | if templ_7745c5c3_Err != nil { | |
| 756 | 802 | return templ_7745c5c3_Err | |
| 757 | 803 | } | |
| 758 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">") | |
| 804 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">") | |
| 759 | 805 | if templ_7745c5c3_Err != nil { | |
| 760 | 806 | return templ_7745c5c3_Err | |
| 761 | 807 | } | |
| 762 | - | var templ_7745c5c3_Var36 string | |
| 763 | - | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When) | |
| 808 | + | var templ_7745c5c3_Var38 string | |
| 809 | + | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.When) | |
| 764 | 810 | if templ_7745c5c3_Err != nil { | |
| 765 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 59} | |
| 811 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 59} | |
| 766 | 812 | } | |
| 767 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 813 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 768 | 814 | if templ_7745c5c3_Err != nil { | |
| 769 | 815 | return templ_7745c5c3_Err | |
| 770 | 816 | } | |
| 771 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</time>") | |
| 817 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</time>") | |
| 772 | 818 | if templ_7745c5c3_Err != nil { | |
| 773 | 819 | return templ_7745c5c3_Err | |
| 774 | 820 | } | |
| 775 | 821 | } | |
| 776 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td></tr>") | |
| 822 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td></tr>") | |
| 777 | 823 | if templ_7745c5c3_Err != nil { | |
| 778 | 824 | return templ_7745c5c3_Err | |
| 779 | 825 | } | |
| @@ -797,12 +843,12 @@ func refsRail(p RefsPage) templ.Component { | |||
| 797 | 843 | }() | |
| 798 | 844 | } | |
| 799 | 845 | ctx = templ.InitializeContext(ctx) | |
| 800 | - | templ_7745c5c3_Var37 := templ.GetChildren(ctx) | |
| 801 | - | if templ_7745c5c3_Var37 == nil { | |
| 802 | - | templ_7745c5c3_Var37 = templ.NopComponent | |
| 846 | + | templ_7745c5c3_Var39 := templ.GetChildren(ctx) | |
| 847 | + | if templ_7745c5c3_Var39 == nil { | |
| 848 | + | templ_7745c5c3_Var39 = templ.NopComponent | |
| 803 | 849 | } | |
| 804 | 850 | ctx = templ.ClearChildren(ctx) | |
| 805 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>") | |
| 851 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>") | |
| 806 | 852 | if templ_7745c5c3_Err != nil { | |
| 807 | 853 | return templ_7745c5c3_Err | |
| 808 | 854 | } | |
| @@ -810,7 +856,7 @@ func refsRail(p RefsPage) templ.Component { | |||
| 810 | 856 | if templ_7745c5c3_Err != nil { | |
| 811 | 857 | return templ_7745c5c3_Err | |
| 812 | 858 | } | |
| 813 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</section>") | |
| 859 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</section>") | |
| 814 | 860 | if templ_7745c5c3_Err != nil { | |
| 815 | 861 | return templ_7745c5c3_Err | |
| 816 | 862 | } | |
| @@ -835,12 +881,12 @@ func State(l Layout, p StatePage) templ.Component { | |||
| 835 | 881 | }() | |
| 836 | 882 | } | |
| 837 | 883 | ctx = templ.InitializeContext(ctx) | |
| 838 | - | templ_7745c5c3_Var38 := templ.GetChildren(ctx) | |
| 839 | - | if templ_7745c5c3_Var38 == nil { | |
| 840 | - | templ_7745c5c3_Var38 = templ.NopComponent | |
| 884 | + | templ_7745c5c3_Var40 := templ.GetChildren(ctx) | |
| 885 | + | if templ_7745c5c3_Var40 == nil { | |
| 886 | + | templ_7745c5c3_Var40 = templ.NopComponent | |
| 841 | 887 | } | |
| 842 | 888 | ctx = templ.ClearChildren(ctx) | |
| 843 | - | templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 889 | + | templ_7745c5c3_Var41 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 844 | 890 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 845 | 891 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 846 | 892 | if !templ_7745c5c3_IsBuffer { | |
| @@ -852,51 +898,51 @@ func State(l Layout, p StatePage) templ.Component { | |||
| 852 | 898 | }() | |
| 853 | 899 | } | |
| 854 | 900 | ctx = templ.InitializeContext(ctx) | |
| 855 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">") | |
| 901 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">") | |
| 856 | 902 | if templ_7745c5c3_Err != nil { | |
| 857 | 903 | return templ_7745c5c3_Err | |
| 858 | 904 | } | |
| 859 | - | var templ_7745c5c3_Var40 string | |
| 860 | - | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code) | |
| 905 | + | var templ_7745c5c3_Var42 string | |
| 906 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code) | |
| 861 | 907 | if templ_7745c5c3_Err != nil { | |
| 862 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 30} | |
| 908 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 30} | |
| 863 | 909 | } | |
| 864 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 910 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 865 | 911 | if templ_7745c5c3_Err != nil { | |
| 866 | 912 | return templ_7745c5c3_Err | |
| 867 | 913 | } | |
| 868 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</p><h1 id=\"state-title\">") | |
| 914 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</p><h1 id=\"state-title\">") | |
| 869 | 915 | if templ_7745c5c3_Err != nil { | |
| 870 | 916 | return templ_7745c5c3_Err | |
| 871 | 917 | } | |
| 872 | - | var templ_7745c5c3_Var41 string | |
| 873 | - | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 918 | + | var templ_7745c5c3_Var43 string | |
| 919 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 874 | 920 | if templ_7745c5c3_Err != nil { | |
| 875 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 33} | |
| 921 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 192, Col: 33} | |
| 876 | 922 | } | |
| 877 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 923 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 878 | 924 | if templ_7745c5c3_Err != nil { | |
| 879 | 925 | return templ_7745c5c3_Err | |
| 880 | 926 | } | |
| 881 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</h1>") | |
| 927 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</h1>") | |
| 882 | 928 | if templ_7745c5c3_Err != nil { | |
| 883 | 929 | return templ_7745c5c3_Err | |
| 884 | 930 | } | |
| 885 | 931 | for _, line := range p.Lines { | |
| 886 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<p class=\"dim\">") | |
| 932 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<p class=\"dim\">") | |
| 887 | 933 | if templ_7745c5c3_Err != nil { | |
| 888 | 934 | return templ_7745c5c3_Err | |
| 889 | 935 | } | |
| 890 | - | var templ_7745c5c3_Var42 string | |
| 891 | - | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 936 | + | var templ_7745c5c3_Var44 string | |
| 937 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 892 | 938 | if templ_7745c5c3_Err != nil { | |
| 893 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 186, Col: 25} | |
| 939 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 25} | |
| 894 | 940 | } | |
| 895 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 941 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 896 | 942 | if templ_7745c5c3_Err != nil { | |
| 897 | 943 | return templ_7745c5c3_Err | |
| 898 | 944 | } | |
| 899 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</p>") | |
| 945 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</p>") | |
| 900 | 946 | if templ_7745c5c3_Err != nil { | |
| 901 | 947 | return templ_7745c5c3_Err | |
| 902 | 948 | } | |
| @@ -907,49 +953,49 @@ func State(l Layout, p StatePage) templ.Component { | |||
| 907 | 953 | return templ_7745c5c3_Err | |
| 908 | 954 | } | |
| 909 | 955 | } | |
| 910 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"state-back\">") | |
| 956 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<p class=\"state-back\">") | |
| 911 | 957 | if templ_7745c5c3_Err != nil { | |
| 912 | 958 | return templ_7745c5c3_Err | |
| 913 | 959 | } | |
| 914 | 960 | for _, b := range p.Back { | |
| 915 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<a href=\"") | |
| 961 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<a href=\"") | |
| 916 | 962 | if templ_7745c5c3_Err != nil { | |
| 917 | 963 | return templ_7745c5c3_Err | |
| 918 | 964 | } | |
| 919 | - | var templ_7745c5c3_Var43 templ.SafeURL | |
| 920 | - | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href)) | |
| 965 | + | var templ_7745c5c3_Var45 templ.SafeURL | |
| 966 | + | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href)) | |
| 921 | 967 | if templ_7745c5c3_Err != nil { | |
| 922 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 36} | |
| 968 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 36} | |
| 923 | 969 | } | |
| 924 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 970 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 925 | 971 | if templ_7745c5c3_Err != nil { | |
| 926 | 972 | return templ_7745c5c3_Err | |
| 927 | 973 | } | |
| 928 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">") | |
| 974 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\">") | |
| 929 | 975 | if templ_7745c5c3_Err != nil { | |
| 930 | 976 | return templ_7745c5c3_Err | |
| 931 | 977 | } | |
| 932 | - | var templ_7745c5c3_Var44 string | |
| 933 | - | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label) | |
| 978 | + | var templ_7745c5c3_Var46 string | |
| 979 | + | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label) | |
| 934 | 980 | if templ_7745c5c3_Err != nil { | |
| 935 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 48} | |
| 981 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 48} | |
| 936 | 982 | } | |
| 937 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 983 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |
| 938 | 984 | if templ_7745c5c3_Err != nil { | |
| 939 | 985 | return templ_7745c5c3_Err | |
| 940 | 986 | } | |
| 941 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</a>") | |
| 987 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</a>") | |
| 942 | 988 | if templ_7745c5c3_Err != nil { | |
| 943 | 989 | return templ_7745c5c3_Err | |
| 944 | 990 | } | |
| 945 | 991 | } | |
| 946 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</p></section>") | |
| 992 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</p></section>") | |
| 947 | 993 | if templ_7745c5c3_Err != nil { | |
| 948 | 994 | return templ_7745c5c3_Err | |
| 949 | 995 | } | |
| 950 | 996 | return nil | |
| 951 | 997 | }) | |
| 952 | - | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer) | |
| 998 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var41), templ_7745c5c3_Buffer) | |
| 953 | 999 | if templ_7745c5c3_Err != nil { | |
| 954 | 1000 | return templ_7745c5c3_Err | |
| 955 | 1001 | } | |
| @@ -978,12 +1024,12 @@ func Login(l Layout, p LoginPage) templ.Component { | |||
| 978 | 1024 | }() | |
| 979 | 1025 | } | |
| 980 | 1026 | ctx = templ.InitializeContext(ctx) | |
| 981 | - | templ_7745c5c3_Var45 := templ.GetChildren(ctx) | |
| 982 | - | if templ_7745c5c3_Var45 == nil { | |
| 983 | - | templ_7745c5c3_Var45 = templ.NopComponent | |
| 1027 | + | templ_7745c5c3_Var47 := templ.GetChildren(ctx) | |
| 1028 | + | if templ_7745c5c3_Var47 == nil { | |
| 1029 | + | templ_7745c5c3_Var47 = templ.NopComponent | |
| 984 | 1030 | } | |
| 985 | 1031 | ctx = templ.ClearChildren(ctx) | |
| 986 | - | templ_7745c5c3_Var46 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1032 | + | templ_7745c5c3_Var48 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 987 | 1033 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 988 | 1034 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 989 | 1035 | if !templ_7745c5c3_IsBuffer { | |
| @@ -995,75 +1041,75 @@ func Login(l Layout, p LoginPage) templ.Component { | |||
| 995 | 1041 | }() | |
| 996 | 1042 | } | |
| 997 | 1043 | ctx = templ.InitializeContext(ctx) | |
| 998 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 1044 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 999 | 1045 | if templ_7745c5c3_Err != nil { | |
| 1000 | 1046 | return templ_7745c5c3_Err | |
| 1001 | 1047 | } | |
| 1002 | - | var templ_7745c5c3_Var47 string | |
| 1003 | - | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 1048 | + | var templ_7745c5c3_Var49 string | |
| 1049 | + | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 1004 | 1050 | if templ_7745c5c3_Err != nil { | |
| 1005 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 33} | |
| 1051 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 33} | |
| 1006 | 1052 | } | |
| 1007 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |
| 1053 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 1008 | 1054 | if templ_7745c5c3_Err != nil { | |
| 1009 | 1055 | return templ_7745c5c3_Err | |
| 1010 | 1056 | } | |
| 1011 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</h1>") | |
| 1057 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</h1>") | |
| 1012 | 1058 | if templ_7745c5c3_Err != nil { | |
| 1013 | 1059 | return templ_7745c5c3_Err | |
| 1014 | 1060 | } | |
| 1015 | 1061 | for _, line := range p.Lines { | |
| 1016 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"dim\">") | |
| 1062 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<p class=\"dim\">") | |
| 1017 | 1063 | if templ_7745c5c3_Err != nil { | |
| 1018 | 1064 | return templ_7745c5c3_Err | |
| 1019 | 1065 | } | |
| 1020 | - | var templ_7745c5c3_Var48 string | |
| 1021 | - | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 1066 | + | var templ_7745c5c3_Var50 string | |
| 1067 | + | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 1022 | 1068 | if templ_7745c5c3_Err != nil { | |
| 1023 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 210, Col: 25} | |
| 1069 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 25} | |
| 1024 | 1070 | } | |
| 1025 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 1071 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 1026 | 1072 | if templ_7745c5c3_Err != nil { | |
| 1027 | 1073 | return templ_7745c5c3_Err | |
| 1028 | 1074 | } | |
| 1029 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</p>") | |
| 1075 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</p>") | |
| 1030 | 1076 | if templ_7745c5c3_Err != nil { | |
| 1031 | 1077 | return templ_7745c5c3_Err | |
| 1032 | 1078 | } | |
| 1033 | 1079 | } | |
| 1034 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<form class=\"state-act\" method=\"post\" action=\"") | |
| 1080 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<form class=\"state-act\" method=\"post\" action=\"") | |
| 1035 | 1081 | if templ_7745c5c3_Err != nil { | |
| 1036 | 1082 | return templ_7745c5c3_Err | |
| 1037 | 1083 | } | |
| 1038 | - | var templ_7745c5c3_Var49 templ.SafeURL | |
| 1039 | - | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action)) | |
| 1084 | + | var templ_7745c5c3_Var51 templ.SafeURL | |
| 1085 | + | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action)) | |
| 1040 | 1086 | if templ_7745c5c3_Err != nil { | |
| 1041 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 212, Col: 73} | |
| 1087 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 73} | |
| 1042 | 1088 | } | |
| 1043 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 1089 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 1044 | 1090 | if templ_7745c5c3_Err != nil { | |
| 1045 | 1091 | return templ_7745c5c3_Err | |
| 1046 | 1092 | } | |
| 1047 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">") | |
| 1093 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">") | |
| 1048 | 1094 | if templ_7745c5c3_Err != nil { | |
| 1049 | 1095 | return templ_7745c5c3_Err | |
| 1050 | 1096 | } | |
| 1051 | - | var templ_7745c5c3_Var50 string | |
| 1052 | - | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 1097 | + | var templ_7745c5c3_Var52 string | |
| 1098 | + | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 1053 | 1099 | if templ_7745c5c3_Err != nil { | |
| 1054 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 213, Col: 56} | |
| 1100 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 221, Col: 56} | |
| 1055 | 1101 | } | |
| 1056 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 1102 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) | |
| 1057 | 1103 | if templ_7745c5c3_Err != nil { | |
| 1058 | 1104 | return templ_7745c5c3_Err | |
| 1059 | 1105 | } | |
| 1060 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1106 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1061 | 1107 | if templ_7745c5c3_Err != nil { | |
| 1062 | 1108 | return templ_7745c5c3_Err | |
| 1063 | 1109 | } | |
| 1064 | 1110 | return nil | |
| 1065 | 1111 | }) | |
| 1066 | - | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var46), templ_7745c5c3_Buffer) | |
| 1112 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var48), templ_7745c5c3_Buffer) | |
| 1067 | 1113 | if templ_7745c5c3_Err != nil { | |
| 1068 | 1114 | return templ_7745c5c3_Err | |
| 1069 | 1115 | } | |
| @@ -1089,12 +1135,12 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||
| 1089 | 1135 | }() | |
| 1090 | 1136 | } | |
| 1091 | 1137 | ctx = templ.InitializeContext(ctx) | |
| 1092 | - | templ_7745c5c3_Var51 := templ.GetChildren(ctx) | |
| 1093 | - | if templ_7745c5c3_Var51 == nil { | |
| 1094 | - | templ_7745c5c3_Var51 = templ.NopComponent | |
| 1138 | + | templ_7745c5c3_Var53 := templ.GetChildren(ctx) | |
| 1139 | + | if templ_7745c5c3_Var53 == nil { | |
| 1140 | + | templ_7745c5c3_Var53 = templ.NopComponent | |
| 1095 | 1141 | } | |
| 1096 | 1142 | ctx = templ.ClearChildren(ctx) | |
| 1097 | - | templ_7745c5c3_Var52 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1143 | + | templ_7745c5c3_Var54 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1098 | 1144 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1099 | 1145 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1100 | 1146 | if !templ_7745c5c3_IsBuffer { | |
| @@ -1106,88 +1152,88 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||
| 1106 | 1152 | }() | |
| 1107 | 1153 | } | |
| 1108 | 1154 | ctx = templ.InitializeContext(ctx) | |
| 1109 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 1155 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 1110 | 1156 | if templ_7745c5c3_Err != nil { | |
| 1111 | 1157 | return templ_7745c5c3_Err | |
| 1112 | 1158 | } | |
| 1113 | - | var templ_7745c5c3_Var53 string | |
| 1114 | - | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 1159 | + | var templ_7745c5c3_Var55 string | |
| 1160 | + | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 1115 | 1161 | if templ_7745c5c3_Err != nil { | |
| 1116 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 33} | |
| 1162 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 33} | |
| 1117 | 1163 | } | |
| 1118 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) | |
| 1164 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1119 | 1165 | if templ_7745c5c3_Err != nil { | |
| 1120 | 1166 | return templ_7745c5c3_Err | |
| 1121 | 1167 | } | |
| 1122 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"") | |
| 1168 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"") | |
| 1123 | 1169 | if templ_7745c5c3_Err != nil { | |
| 1124 | 1170 | return templ_7745c5c3_Err | |
| 1125 | 1171 | } | |
| 1126 | - | var templ_7745c5c3_Var54 string | |
| 1127 | - | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | |
| 1172 | + | var templ_7745c5c3_Var56 string | |
| 1173 | + | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | |
| 1128 | 1174 | if templ_7745c5c3_Err != nil { | |
| 1129 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 87} | |
| 1175 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 87} | |
| 1130 | 1176 | } | |
| 1131 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) | |
| 1177 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) | |
| 1132 | 1178 | if templ_7745c5c3_Err != nil { | |
| 1133 | 1179 | return templ_7745c5c3_Err | |
| 1134 | 1180 | } | |
| 1135 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" data-finish=\"") | |
| 1181 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-finish=\"") | |
| 1136 | 1182 | if templ_7745c5c3_Err != nil { | |
| 1137 | 1183 | return templ_7745c5c3_Err | |
| 1138 | 1184 | } | |
| 1139 | - | var templ_7745c5c3_Var55 string | |
| 1140 | - | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | |
| 1185 | + | var templ_7745c5c3_Var57 string | |
| 1186 | + | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | |
| 1141 | 1187 | if templ_7745c5c3_Err != nil { | |
| 1142 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 112} | |
| 1188 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 112} | |
| 1143 | 1189 | } | |
| 1144 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55) | |
| 1190 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57) | |
| 1145 | 1191 | if templ_7745c5c3_Err != nil { | |
| 1146 | 1192 | return templ_7745c5c3_Err | |
| 1147 | 1193 | } | |
| 1148 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\">") | |
| 1194 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\">") | |
| 1149 | 1195 | if templ_7745c5c3_Err != nil { | |
| 1150 | 1196 | return templ_7745c5c3_Err | |
| 1151 | 1197 | } | |
| 1152 | - | var templ_7745c5c3_Var56 string | |
| 1153 | - | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 1198 | + | var templ_7745c5c3_Var58 string | |
| 1199 | + | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 1154 | 1200 | if templ_7745c5c3_Err != nil { | |
| 1155 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 125} | |
| 1201 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 125} | |
| 1156 | 1202 | } | |
| 1157 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1203 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1158 | 1204 | if templ_7745c5c3_Err != nil { | |
| 1159 | 1205 | return templ_7745c5c3_Err | |
| 1160 | 1206 | } | |
| 1161 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1207 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1162 | 1208 | if templ_7745c5c3_Err != nil { | |
| 1163 | 1209 | return templ_7745c5c3_Err | |
| 1164 | 1210 | } | |
| 1165 | 1211 | for _, line := range p.Lines { | |
| 1166 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"dim\">") | |
| 1212 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<p class=\"dim\">") | |
| 1167 | 1213 | if templ_7745c5c3_Err != nil { | |
| 1168 | 1214 | return templ_7745c5c3_Err | |
| 1169 | 1215 | } | |
| 1170 | - | var templ_7745c5c3_Var57 string | |
| 1171 | - | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 1216 | + | var templ_7745c5c3_Var59 string | |
| 1217 | + | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 1172 | 1218 | if templ_7745c5c3_Err != nil { | |
| 1173 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25} | |
| 1219 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 239, Col: 25} | |
| 1174 | 1220 | } | |
| 1175 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1221 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1176 | 1222 | if templ_7745c5c3_Err != nil { | |
| 1177 | 1223 | return templ_7745c5c3_Err | |
| 1178 | 1224 | } | |
| 1179 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</p>") | |
| 1225 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p>") | |
| 1180 | 1226 | if templ_7745c5c3_Err != nil { | |
| 1181 | 1227 | return templ_7745c5c3_Err | |
| 1182 | 1228 | } | |
| 1183 | 1229 | } | |
| 1184 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1230 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1185 | 1231 | if templ_7745c5c3_Err != nil { | |
| 1186 | 1232 | return templ_7745c5c3_Err | |
| 1187 | 1233 | } | |
| 1188 | 1234 | return nil | |
| 1189 | 1235 | }) | |
| 1190 | - | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var52), templ_7745c5c3_Buffer) | |
| 1236 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var54), templ_7745c5c3_Buffer) | |
| 1191 | 1237 | if templ_7745c5c3_Err != nil { | |
| 1192 | 1238 | return templ_7745c5c3_Err | |
| 1193 | 1239 | } | |
| @@ -1212,12 +1258,12 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component { | |||
| 1212 | 1258 | }() | |
| 1213 | 1259 | } | |
| 1214 | 1260 | ctx = templ.InitializeContext(ctx) | |
| 1215 | - | templ_7745c5c3_Var58 := templ.GetChildren(ctx) | |
| 1216 | - | if templ_7745c5c3_Var58 == nil { | |
| 1217 | - | templ_7745c5c3_Var58 = templ.NopComponent | |
| 1261 | + | templ_7745c5c3_Var60 := templ.GetChildren(ctx) | |
| 1262 | + | if templ_7745c5c3_Var60 == nil { | |
| 1263 | + | templ_7745c5c3_Var60 = templ.NopComponent | |
| 1218 | 1264 | } | |
| 1219 | 1265 | ctx = templ.ClearChildren(ctx) | |
| 1220 | - | templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1266 | + | templ_7745c5c3_Var61 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1221 | 1267 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1222 | 1268 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1223 | 1269 | if !templ_7745c5c3_IsBuffer { | |
| @@ -1229,231 +1275,231 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component { | |||
| 1229 | 1275 | }() | |
| 1230 | 1276 | } | |
| 1231 | 1277 | ctx = templ.InitializeContext(ctx) | |
| 1232 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">") | |
| 1278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">") | |
| 1233 | 1279 | if templ_7745c5c3_Err != nil { | |
| 1234 | 1280 | return templ_7745c5c3_Err | |
| 1235 | 1281 | } | |
| 1236 | - | var templ_7745c5c3_Var60 string | |
| 1237 | - | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows))) | |
| 1282 | + | var templ_7745c5c3_Var62 string | |
| 1283 | + | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows))) | |
| 1238 | 1284 | if templ_7745c5c3_Err != nil { | |
| 1239 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 241, Col: 81} | |
| 1285 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 81} | |
| 1240 | 1286 | } | |
| 1241 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) | |
| 1287 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1242 | 1288 | if templ_7745c5c3_Err != nil { | |
| 1243 | 1289 | return templ_7745c5c3_Err | |
| 1244 | 1290 | } | |
| 1245 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>") | |
| 1291 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>") | |
| 1246 | 1292 | if templ_7745c5c3_Err != nil { | |
| 1247 | 1293 | return templ_7745c5c3_Err | |
| 1248 | 1294 | } | |
| 1249 | 1295 | if len(p.Rows) > 0 { | |
| 1250 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>") | |
| 1296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>") | |
| 1251 | 1297 | if templ_7745c5c3_Err != nil { | |
| 1252 | 1298 | return templ_7745c5c3_Err | |
| 1253 | 1299 | } | |
| 1254 | 1300 | for _, r := range p.Rows { | |
| 1255 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<tr><td class=\"c-name\">") | |
| 1301 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<tr><td class=\"c-name\">") | |
| 1256 | 1302 | if templ_7745c5c3_Err != nil { | |
| 1257 | 1303 | return templ_7745c5c3_Err | |
| 1258 | 1304 | } | |
| 1259 | - | var templ_7745c5c3_Var61 string | |
| 1260 | - | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 1305 | + | var templ_7745c5c3_Var63 string | |
| 1306 | + | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 1261 | 1307 | if templ_7745c5c3_Err != nil { | |
| 1262 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 34} | |
| 1308 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 34} | |
| 1263 | 1309 | } | |
| 1264 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) | |
| 1310 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1265 | 1311 | if templ_7745c5c3_Err != nil { | |
| 1266 | 1312 | return templ_7745c5c3_Err | |
| 1267 | 1313 | } | |
| 1268 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</td><td class=\"c-when nowrap\"><time") | |
| 1314 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</td><td class=\"c-when nowrap\"><time") | |
| 1269 | 1315 | if templ_7745c5c3_Err != nil { | |
| 1270 | 1316 | return templ_7745c5c3_Err | |
| 1271 | 1317 | } | |
| 1272 | 1318 | if r.CreatedAt != "" { | |
| 1273 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " datetime=\"") | |
| 1319 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " datetime=\"") | |
| 1274 | 1320 | if templ_7745c5c3_Err != nil { | |
| 1275 | 1321 | return templ_7745c5c3_Err | |
| 1276 | 1322 | } | |
| 1277 | - | var templ_7745c5c3_Var62 string | |
| 1278 | - | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt) | |
| 1323 | + | var templ_7745c5c3_Var64 string | |
| 1324 | + | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt) | |
| 1279 | 1325 | if templ_7745c5c3_Err != nil { | |
| 1280 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 261, Col: 32} | |
| 1326 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 32} | |
| 1281 | 1327 | } | |
| 1282 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62) | |
| 1328 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) | |
| 1283 | 1329 | if templ_7745c5c3_Err != nil { | |
| 1284 | 1330 | return templ_7745c5c3_Err | |
| 1285 | 1331 | } | |
| 1286 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\"") | |
| 1332 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"") | |
| 1287 | 1333 | if templ_7745c5c3_Err != nil { | |
| 1288 | 1334 | return templ_7745c5c3_Err | |
| 1289 | 1335 | } | |
| 1290 | 1336 | } | |
| 1291 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, ">") | |
| 1337 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, ">") | |
| 1292 | 1338 | if templ_7745c5c3_Err != nil { | |
| 1293 | 1339 | return templ_7745c5c3_Err | |
| 1294 | 1340 | } | |
| 1295 | - | var templ_7745c5c3_Var63 string | |
| 1296 | - | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created) | |
| 1341 | + | var templ_7745c5c3_Var65 string | |
| 1342 | + | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created) | |
| 1297 | 1343 | if templ_7745c5c3_Err != nil { | |
| 1298 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 263, Col: 20} | |
| 1344 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 271, Col: 20} | |
| 1299 | 1345 | } | |
| 1300 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1346 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) | |
| 1301 | 1347 | if templ_7745c5c3_Err != nil { | |
| 1302 | 1348 | return templ_7745c5c3_Err | |
| 1303 | 1349 | } | |
| 1304 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</time></td><td class=\"c-when nowrap\"><time") | |
| 1350 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</time></td><td class=\"c-when nowrap\"><time") | |
| 1305 | 1351 | if templ_7745c5c3_Err != nil { | |
| 1306 | 1352 | return templ_7745c5c3_Err | |
| 1307 | 1353 | } | |
| 1308 | 1354 | if r.LastUsedAt != "" { | |
| 1309 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " datetime=\"") | |
| 1355 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " datetime=\"") | |
| 1310 | 1356 | if templ_7745c5c3_Err != nil { | |
| 1311 | 1357 | return templ_7745c5c3_Err | |
| 1312 | 1358 | } | |
| 1313 | - | var templ_7745c5c3_Var64 string | |
| 1314 | - | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt) | |
| 1359 | + | var templ_7745c5c3_Var66 string | |
| 1360 | + | templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt) | |
| 1315 | 1361 | if templ_7745c5c3_Err != nil { | |
| 1316 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 268, Col: 33} | |
| 1362 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 276, Col: 33} | |
| 1317 | 1363 | } | |
| 1318 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) | |
| 1364 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66) | |
| 1319 | 1365 | if templ_7745c5c3_Err != nil { | |
| 1320 | 1366 | return templ_7745c5c3_Err | |
| 1321 | 1367 | } | |
| 1322 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"") | |
| 1368 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"") | |
| 1323 | 1369 | if templ_7745c5c3_Err != nil { | |
| 1324 | 1370 | return templ_7745c5c3_Err | |
| 1325 | 1371 | } | |
| 1326 | 1372 | } | |
| 1327 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, ">") | |
| 1373 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, ">") | |
| 1328 | 1374 | if templ_7745c5c3_Err != nil { | |
| 1329 | 1375 | return templ_7745c5c3_Err | |
| 1330 | 1376 | } | |
| 1331 | - | var templ_7745c5c3_Var65 string | |
| 1332 | - | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed) | |
| 1377 | + | var templ_7745c5c3_Var67 string | |
| 1378 | + | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed) | |
| 1333 | 1379 | if templ_7745c5c3_Err != nil { | |
| 1334 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 21} | |
| 1380 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 278, Col: 21} | |
| 1335 | 1381 | } | |
| 1336 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) | |
| 1382 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) | |
| 1337 | 1383 | if templ_7745c5c3_Err != nil { | |
| 1338 | 1384 | return templ_7745c5c3_Err | |
| 1339 | 1385 | } | |
| 1340 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</time></td><td class=\"c-sync\">") | |
| 1386 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</time></td><td class=\"c-sync\">") | |
| 1341 | 1387 | if templ_7745c5c3_Err != nil { | |
| 1342 | 1388 | return templ_7745c5c3_Err | |
| 1343 | 1389 | } | |
| 1344 | 1390 | if r.Synced { | |
| 1345 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "yes") | |
| 1391 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "yes") | |
| 1346 | 1392 | if templ_7745c5c3_Err != nil { | |
| 1347 | 1393 | return templ_7745c5c3_Err | |
| 1348 | 1394 | } | |
| 1349 | 1395 | } else { | |
| 1350 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "no") | |
| 1396 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "no") | |
| 1351 | 1397 | if templ_7745c5c3_Err != nil { | |
| 1352 | 1398 | return templ_7745c5c3_Err | |
| 1353 | 1399 | } | |
| 1354 | 1400 | } | |
| 1355 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</td><td class=\"c-act\"><form method=\"post\" action=\"") | |
| 1401 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td><td class=\"c-act\"><form method=\"post\" action=\"") | |
| 1356 | 1402 | if templ_7745c5c3_Err != nil { | |
| 1357 | 1403 | return templ_7745c5c3_Err | |
| 1358 | 1404 | } | |
| 1359 | - | var templ_7745c5c3_Var66 templ.SafeURL | |
| 1360 | - | templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove)) | |
| 1405 | + | var templ_7745c5c3_Var68 templ.SafeURL | |
| 1406 | + | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove)) | |
| 1361 | 1407 | if templ_7745c5c3_Err != nil { | |
| 1362 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 280, Col: 60} | |
| 1408 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 288, Col: 60} | |
| 1363 | 1409 | } | |
| 1364 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) | |
| 1410 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) | |
| 1365 | 1411 | if templ_7745c5c3_Err != nil { | |
| 1366 | 1412 | return templ_7745c5c3_Err | |
| 1367 | 1413 | } | |
| 1368 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"") | |
| 1414 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"") | |
| 1369 | 1415 | if templ_7745c5c3_Err != nil { | |
| 1370 | 1416 | return templ_7745c5c3_Err | |
| 1371 | 1417 | } | |
| 1372 | - | var templ_7745c5c3_Var67 string | |
| 1373 | - | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name) | |
| 1418 | + | var templ_7745c5c3_Var69 string | |
| 1419 | + | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name) | |
| 1374 | 1420 | if templ_7745c5c3_Err != nil { | |
| 1375 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 89} | |
| 1421 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 89} | |
| 1376 | 1422 | } | |
| 1377 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67) | |
| 1423 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) | |
| 1378 | 1424 | if templ_7745c5c3_Err != nil { | |
| 1379 | 1425 | return templ_7745c5c3_Err | |
| 1380 | 1426 | } | |
| 1381 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\">Remove</button></form></td></tr>") | |
| 1427 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Remove</button></form></td></tr>") | |
| 1382 | 1428 | if templ_7745c5c3_Err != nil { | |
| 1383 | 1429 | return templ_7745c5c3_Err | |
| 1384 | 1430 | } | |
| 1385 | 1431 | } | |
| 1386 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</tbody></table>") | |
| 1432 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</tbody></table>") | |
| 1387 | 1433 | if templ_7745c5c3_Err != nil { | |
| 1388 | 1434 | return templ_7745c5c3_Err | |
| 1389 | 1435 | } | |
| 1390 | 1436 | } else { | |
| 1391 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<p class=\"lede dim\">no passkeys yet.</p>") | |
| 1437 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<p class=\"lede dim\">no passkeys yet.</p>") | |
| 1392 | 1438 | if templ_7745c5c3_Err != nil { | |
| 1393 | 1439 | return templ_7745c5c3_Err | |
| 1394 | 1440 | } | |
| 1395 | 1441 | } | |
| 1396 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ") | |
| 1442 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " ") | |
| 1397 | 1443 | if templ_7745c5c3_Err != nil { | |
| 1398 | 1444 | return templ_7745c5c3_Err | |
| 1399 | 1445 | } | |
| 1400 | 1446 | if p.Full { | |
| 1401 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<p class=\"dim\">") | |
| 1447 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">") | |
| 1402 | 1448 | if templ_7745c5c3_Err != nil { | |
| 1403 | 1449 | return templ_7745c5c3_Err | |
| 1404 | 1450 | } | |
| 1405 | - | var templ_7745c5c3_Var68 string | |
| 1406 | - | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max)) | |
| 1451 | + | var templ_7745c5c3_Var70 string | |
| 1452 | + | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max)) | |
| 1407 | 1453 | if templ_7745c5c3_Err != nil { | |
| 1408 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 292, Col: 31} | |
| 1454 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 300, Col: 31} | |
| 1409 | 1455 | } | |
| 1410 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) | |
| 1456 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) | |
| 1411 | 1457 | if templ_7745c5c3_Err != nil { | |
| 1412 | 1458 | return templ_7745c5c3_Err | |
| 1413 | 1459 | } | |
| 1414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " passkeys are registered, the most there can be. Remove one to add another.</p>") | |
| 1460 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " passkeys are registered, the most there can be. Remove one to add another.</p>") | |
| 1415 | 1461 | if templ_7745c5c3_Err != nil { | |
| 1416 | 1462 | return templ_7745c5c3_Err | |
| 1417 | 1463 | } | |
| 1418 | 1464 | } else { | |
| 1419 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"") | |
| 1465 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"") | |
| 1420 | 1466 | if templ_7745c5c3_Err != nil { | |
| 1421 | 1467 | return templ_7745c5c3_Err | |
| 1422 | 1468 | } | |
| 1423 | - | var templ_7745c5c3_Var69 string | |
| 1424 | - | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | |
| 1469 | + | var templ_7745c5c3_Var71 string | |
| 1470 | + | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | |
| 1425 | 1471 | if templ_7745c5c3_Err != nil { | |
| 1426 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 90} | |
| 1472 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 90} | |
| 1427 | 1473 | } | |
| 1428 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) | |
| 1474 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71) | |
| 1429 | 1475 | if templ_7745c5c3_Err != nil { | |
| 1430 | 1476 | return templ_7745c5c3_Err | |
| 1431 | 1477 | } | |
| 1432 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" data-finish=\"") | |
| 1478 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" data-finish=\"") | |
| 1433 | 1479 | if templ_7745c5c3_Err != nil { | |
| 1434 | 1480 | return templ_7745c5c3_Err | |
| 1435 | 1481 | } | |
| 1436 | - | var templ_7745c5c3_Var70 string | |
| 1437 | - | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | |
| 1482 | + | var templ_7745c5c3_Var72 string | |
| 1483 | + | templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | |
| 1438 | 1484 | if templ_7745c5c3_Err != nil { | |
| 1439 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 115} | |
| 1485 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 115} | |
| 1440 | 1486 | } | |
| 1441 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70) | |
| 1487 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72) | |
| 1442 | 1488 | if templ_7745c5c3_Err != nil { | |
| 1443 | 1489 | return templ_7745c5c3_Err | |
| 1444 | 1490 | } | |
| 1445 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Add a passkey</button></form>") | |
| 1491 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">Add a passkey</button></form>") | |
| 1446 | 1492 | if templ_7745c5c3_Err != nil { | |
| 1447 | 1493 | return templ_7745c5c3_Err | |
| 1448 | 1494 | } | |
| 1449 | 1495 | } | |
| 1450 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1496 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1451 | 1497 | if templ_7745c5c3_Err != nil { | |
| 1452 | 1498 | return templ_7745c5c3_Err | |
| 1453 | 1499 | } | |
| 1454 | 1500 | return nil | |
| 1455 | 1501 | }) | |
| 1456 | - | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer) | |
| 1502 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var61), templ_7745c5c3_Buffer) | |
| 1457 | 1503 | if templ_7745c5c3_Err != nil { | |
| 1458 | 1504 | return templ_7745c5c3_Err | |
| 1459 | 1505 | } | |
M internal/adapters/web/repo.go +5 -0
| @@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string { | |||
| 131 | 131 | // layout is the Layout of the pages of a repository: the sections, the ref | |
| 132 | 132 | // switcher on the head ref and the ":" targets. The caller sets the title, | |
| 133 | 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 | 136 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | |
| 135 | 137 | l := s.base() | |
| 136 | 138 | l.Repo = d.name() | |
| @@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 155 | 157 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | |
| 156 | 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 | 163 | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| 159 | 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 | 196 | if r.Method != http.MethodPost { | |
| 197 | 197 | 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 | 217 | // 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 | 33 | if l.MR { | |
| 34 | 34 | <script src={ l.Assets.MR } defer></script> | |
| 35 | 35 | } | |
| 36 | + | if l.IssueForm { | |
| 37 | + | <script src={ l.Assets.IssueForm } defer></script> | |
| 38 | + | } | |
| 36 | 39 | </head> | |
| 37 | 40 | <body | |
| 38 | 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
| @@ -173,6 +173,22 @@ | |||
| 173 | 173 | & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); } | |
| 174 | 174 | } | |
| 175 | 175 | ||
| 176 | + | /* the public issue form: counters, field errors, the honeypot out of sight */ | |
| 177 | + | .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); } | |
| 178 | + | .cform output.warn { color: var(--ink-hi); font-weight: 700; } | |
| 179 | + | .cform output.over { color: var(--red); font-weight: 700; } | |
| 180 | + | .cform .f-error { color: var(--red); margin: 0; } | |
| 181 | + | .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; } | |
| 182 | + | ||
| 183 | + | /* the inbox: items awaiting moderation, shown as text */ | |
| 184 | + | .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; } | |
| 185 | + | .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); } | |
| 186 | + | .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); } | |
| 187 | + | .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; } | |
| 188 | + | .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 189 | + | .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; } | |
| 190 | + | .pending .p-btns form { margin: 0; } | |
| 191 | + | ||
| 176 | 192 | .list.mrs { | |
| 177 | 193 | & .c-n { width: 64px; } | |
| 178 | 194 | & .c-state { width: 110px; } | |
| @@ -186,6 +202,15 @@ | |||
| 186 | 202 | } | |
| 187 | 203 | #mr-notice:empty { display: none; } | |
| 188 | 204 | ||
| 205 | + | /* the owner's inbox: counters, repository filter, then the two lists */ | |
| 206 | + | .inbox { | |
| 207 | + | display: flex; flex-direction: column; gap: var(--s4); | |
| 208 | + | & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); } | |
| 209 | + | & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); } | |
| 210 | + | & .since { align-self: flex-start; max-width: 100%; } | |
| 211 | + | } | |
| 212 | + | .inbox-btn { gap: var(--s2); } | |
| 213 | + | ||
| 189 | 214 | @media (max-width: 700px) { | |
| 190 | 215 | .list.mrs .c-who { display: none; } | |
| 191 | 216 | } | |
M internal/adapters/web/static/js/ermite.js +2 -2
| @@ -555,11 +555,11 @@ | |||
| 555 | 555 | ||
| 556 | 556 | // 1. Keys. | |
| 557 | 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 | 559 | // the merge request page (mr.js): the merge menu, mark read | |
| 560 | 560 | M: 'merge', R: 'read' }; | |
| 561 | 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 | 563 | let pending = 0; | |
| 564 | 564 | let pendingTimer = 0; | |
| 565 | 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 | 69 | Passkeys bool | |
| 70 | 70 | // MR loads the script of the merge request page (mr.js). | |
| 71 | 71 | MR bool | |
| 72 | + | // IssueForm loads the script of the public issue form (issue-form.js). | |
| 73 | + | IssueForm bool | |
| 72 | 74 | } | |
| 73 | 75 | ||
| 74 | 76 | // Link is a plain label + href pair. | |
| @@ -135,6 +137,16 @@ type SortLink struct { | |||
| 135 | 137 | type HomePage struct { | |
| 136 | 138 | Sorts []SortLink | |
| 137 | 139 | 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 | 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 | 54 | // there are no such pages, nor section for them. Leave it unset rather | |
| 55 | 55 | // than assigning a typed nil pointer. | |
| 56 | 56 | 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 | 79 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |
| 58 | 80 | // commits and merge requests. Without it there are no such pages. Leave it | |
| 59 | 81 | // unset rather than assigning a typed nil pointer. | |
| @@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux { | |||
| 166 | 188 | if s.passkeysOn() { | |
| 167 | 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 | 199 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 170 | 200 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 171 | 201 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| @@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 182 | 212 | if s.cfg.MergeRequests != nil { | |
| 183 | 213 | mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList)) | |
| 184 | 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 | 216 | mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince)) | |
| 187 | 217 | mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles)) | |
| 188 | 218 | mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare)) | |
| @@ -195,6 +225,21 @@ func (s *Server) routes() *http.ServeMux { | |||
| 195 | 225 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady)) | |
| 196 | 226 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead)) | |
| 197 | 227 | } | |
| 228 | + | if s.cfg.Issues != nil { | |
| 229 | + | mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList)) | |
| 230 | + | mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList)) | |
| 231 | + | mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute) | |
| 232 | + | mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR)) | |
| 233 | + | // The writes: POST only, behind the gate (ServeHTTP). | |
| 234 | + | mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate)) | |
| 235 | + | mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment)) | |
| 236 | + | mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose)) | |
| 237 | + | mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen)) | |
| 238 | + | } | |
| 239 | + | if s.cfg.IssueForm != nil { | |
| 240 | + | mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage) | |
| 241 | + | mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost) | |
| 242 | + | } | |
| 198 | 243 | if s.cfg.Runs != nil { | |
| 199 | 244 | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 200 | 245 | 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 | 31 | event ermiteconnect.EventServiceClient | |
| 32 | 32 | server ermiteconnect.ServerServiceClient | |
| 33 | 33 | mr ermiteconnect.MergeRequestServiceClient | |
| 34 | + | issue ermiteconnect.IssueServiceClient | |
| 34 | 35 | ||
| 35 | 36 | session ermiteconnect.SessionServiceClient | |
| 36 | 37 | runner ermiteconnect.RunnerServiceClient | |
| @@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti | |||
| 85 | 86 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | |
| 86 | 87 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | |
| 87 | 88 | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | |
| 89 | + | issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...), | |
| 88 | 90 | ||
| 89 | 91 | session: ermiteconnect.NewSessionServiceClient(hc, base, opts...), | |
| 90 | 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 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 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 | 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 | 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 | 196 | // AC6: the CLI end to end against a real forge. | |
| 197 | 197 | func TestAC6CLIEndToEnd(t *testing.T) { | |
| @@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 338 | 338 | ver := decode[versionOut](t, res.stdout) | |
| 339 | 339 | assert.Equal(t, c.url, ver.Server.URL) | |
| 340 | 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 | 342 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 343 | 343 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 344 | 344 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_fake_test.go +27 -0
| @@ -31,6 +31,7 @@ type fakeAPI struct { | |||
| 31 | 31 | ermiteconnect.UnimplementedServerServiceHandler | |
| 32 | 32 | ermiteconnect.UnimplementedEventServiceHandler | |
| 33 | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 34 | + | ermiteconnect.UnimplementedIssueServiceHandler | |
| 34 | 35 | ermiteconnect.UnimplementedSessionServiceHandler | |
| 35 | 36 | ermiteconnect.UnimplementedRunServiceHandler | |
| 36 | 37 | ermiteconnect.UnimplementedTrustedKeyServiceHandler | |
| @@ -61,6 +62,12 @@ type fakeAPI struct { | |||
| 61 | 62 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | |
| 62 | 63 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |
| 63 | 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 | 71 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | |
| 65 | 72 | ||
| 66 | 73 | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | |
| @@ -148,6 +155,24 @@ func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite | |||
| 148 | 155 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | |
| 149 | 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 | 176 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | |
| 152 | 177 | return respond(f, "GetSigningKey", r, f.getSigningKey) | |
| 153 | 178 | } | |
| @@ -189,6 +214,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 189 | 214 | mount(p, h) | |
| 190 | 215 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | |
| 191 | 216 | mount(p, h) | |
| 217 | + | p, h = ermiteconnect.NewIssueServiceHandler(f) | |
| 218 | + | mount(p, h) | |
| 192 | 219 | p, h = ermiteconnect.NewSessionServiceHandler(f) | |
| 193 | 220 | mount(p, h) | |
| 194 | 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 | 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | |
| 37 | 37 | MergeModes []string `json:"merge_modes"` | |
| 38 | 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 | 43 | func toJSONRepo(r *ermite.Repo) jsonRepo { | |
| @@ -46,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||
| 46 | 48 | return jsonRepo{ | |
| 47 | 49 | MergeModes: modes, | |
| 48 | 50 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | |
| 51 | + | Issues: issuesModeName(r.GetSettings().GetIssues()), | |
| 49 | 52 | Name: r.GetName(), | |
| 50 | 53 | Description: r.GetDescription(), | |
| 51 | 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 | 92 | func visibilityName(v ermite.Visibility) string { | |
| 60 | 93 | switch v { | |
| 61 | 94 | case ermite.Visibility_VISIBILITY_PUBLIC: | |
| @@ -82,7 +115,8 @@ const jsonRepoHelp = ` | |||
| 82 | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 83 | 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 84 | 117 | 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 | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| 88 | 122 | repo := &cobra.Command{ | |
| @@ -195,6 +229,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 195 | 229 | {"last push", shortTime(r.GetPushedAt(), "never")}, | |
| 196 | 230 | {"merge modes", orDash(strings.Join(modes, ", "))}, | |
| 197 | 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 | 309 | var description, visibility, defaultBranch string | |
| 275 | 310 | var topics, mergeModes []string | |
| 276 | 311 | var failingChecksBlock bool | |
| 312 | + | var issues string | |
| 277 | 313 | cmd := &cobra.Command{ | |
| 278 | 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 | 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | |
| 281 | 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | |
| 282 | 318 | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 283 | 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 | 322 | "Needs the repo:admin scope." + jsonRepoHelp, | |
| 285 | 323 | Args: cobra.ExactArgs(1), | |
| 286 | 324 | ValidArgsFunction: completeRepoNames(env), | |
| @@ -291,13 +329,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 291 | 329 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 292 | 330 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | |
| 293 | 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 | 336 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | |
| 295 | 337 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 296 | 338 | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | |
| 297 | 339 | }) | |
| 298 | 340 | asJSON := addJSONFlag(cmd) | |
| 299 | 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 | 343 | if err != nil { | |
| 302 | 344 | return err | |
| 303 | 345 | } | |
| @@ -305,6 +347,11 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 305 | 347 | if err != nil { | |
| 306 | 348 | 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 | 355 | res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req)) | |
| 309 | 356 | if err != nil { | |
| 310 | 357 | return c.Describe(err) | |
| @@ -316,7 +363,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 316 | 363 | ||
| 317 | 364 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | |
| 318 | 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 | 367 | req := &ermite.UpdateRepoRequest{Repo: name} | |
| 321 | 368 | var paths []string | |
| 322 | 369 | if cmd.Flags().Changed("description") { | |
| @@ -365,8 +412,16 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 365 | 412 | req.Settings.FailingChecksBlock = failingChecksBlock | |
| 366 | 413 | paths = append(paths, "settings.failing_checks_block") | |
| 367 | 414 | } | |
| 368 | - | if len(paths) == 0 { | |
| 369 | - | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block") | |
| 415 | + | if cmd.Flags().Changed("issues") { | |
| 416 | + | mode, err := parseIssuesMode(issues) | |
| 417 | + | if err != nil { | |
| 418 | + | return nil, err | |
| 419 | + | } | |
| 420 | + | // The contract keeps issues out of the update mask. | |
| 421 | + | req.Issues = &mode | |
| 422 | + | } | |
| 423 | + | if len(paths) == 0 && req.Issues == nil { | |
| 424 | + | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues") | |
| 370 | 425 | } | |
| 371 | 426 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | |
| 372 | 427 | return req, nil | |
M internal/app/cli_repo_test.go +6 -5
| @@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) { | |||
| 54 | 54 | { | |
| 55 | 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | 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}, | |
| 58 | - | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | |
| 57 | + | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}, | |
| 58 | + | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"} | |
| 59 | 59 | ]`, | |
| 60 | 60 | }, | |
| 61 | 61 | { | |
| @@ -115,12 +115,13 @@ func TestRepoShow(t *testing.T) { | |||
| 115 | 115 | "created 2026-01-02 03:04\n"+ | |
| 116 | 116 | "last push 2026-03-04 05:06\n"+ | |
| 117 | 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 | 120 | _, reqs := f.snapshot() | |
| 120 | 121 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | |
| 121 | 122 | ||
| 122 | 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 | 127 | func TestRepoShowNeedsOneArg(t *testing.T) { | |
| @@ -176,7 +177,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||
| 176 | 177 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | |
| 177 | 178 | }} | |
| 178 | 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 | 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 | 90 | " agent repo:write, mr:write\n" + | |
| 91 | 91 | " runner runner:register (a CI runner host)\n" + | |
| 92 | 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 | 95 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | |
| 94 | 96 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | |
| 95 | 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 | 133 | 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 | 148 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | |
| 135 | 149 | if err != nil { | |
| 136 | 150 | return c.Describe(err) | |
| @@ -143,6 +157,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 143 | 157 | 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 | 168 | // requireBranchPrefixes refuses, before anything is created, to ask a server | |
| 147 | 169 | // for a branch-limited token when it does not list token.branch_prefixes: such | |
| 148 | 170 | // 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 | 174 | "cli": client.AllScopes(), | |
| 175 | 175 | } { | |
| 176 | 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 | 178 | return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil | |
| 179 | 179 | }} | |
| 180 | 180 | runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t) | |
| 181 | - | _, reqs := f.snapshot() | |
| 182 | - | got := reqs[0].(*ermite.CreateTokenRequest) | |
| 181 | + | got := lastCreateToken(t, f) | |
| 183 | 182 | assert.ElementsMatch(t, want, got.GetScopes()) | |
| 184 | 183 | assert.Empty(t, got.GetRepos()) | |
| 185 | 184 | assert.Nil(t, got.GetExpiresAt()) | |
M internal/app/cli_version.go +1 -0
| @@ -113,6 +113,7 @@ var featureUse = map[string]string{ | |||
| 113 | 113 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 114 | 114 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 115 | 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 | 117 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 117 | 118 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 118 | 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 | 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 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 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 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 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 | 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 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 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 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
M internal/app/cli_watch_test.go +1 -1
| @@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) { | |||
| 95 | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | |
| 96 | 96 | } | |
| 97 | 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 | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | |
| 100 | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | |
| 101 | 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 | 75 | // oidcKey generates the signing keys of the ID tokens; nil generates RSA | |
| 76 | 76 | // keys (tests give prepared ones: a 3072-bit key takes a while). | |
| 77 | 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 | 84 | // remote returns the lister of github.com refs: the test's, else def. | |
| @@ -115,7 +119,21 @@ type forge struct { | |||
| 115 | 119 | // MergeRequests and SigningKey serve MergeRequestService and | |
| 116 | 120 | // ServerService.GetSigningKey. | |
| 117 | 121 | MergeRequests *usecase.MergeRequests | |
| 118 | - | SigningKey *usecase.SigningKey | |
| 122 | + | // Issues serves IssueService, and tells what a number names. | |
| 123 | + | Issues *usecase.Issues | |
| 124 | + | // IssueModeration serves the moderation calls of IssueService. | |
| 125 | + | IssueModeration *usecase.IssueModeration | |
| 126 | + | // IssueKeys are the subkeys of the public issue form, and the stores and | |
| 127 | + | // the limiter its submissions use (the form itself comes later). | |
| 128 | + | IssueKeys *usecase.IssueKeys | |
| 129 | + | // IssueSubmissions serves the anonymous submission of issues. | |
| 130 | + | IssueSubmissions *usecase.IssueSubmissions | |
| 131 | + | SubmissionLimiter *clientlimit.SubmissionLimiter | |
| 132 | + | PendingIssues ports.PendingIssueStore | |
| 133 | + | IssueBans ports.IssueBanStore | |
| 134 | + | // Inbox serves the owner's inbox of the web. | |
| 135 | + | Inbox *usecase.Inbox | |
| 136 | + | SigningKey *usecase.SigningKey | |
| 119 | 137 | // Signatures are the trusted keys and the commit signature checks | |
| 120 | 138 | // (ADR 0030). | |
| 121 | 139 | Signatures *usecase.Signatures | |
| @@ -316,6 +334,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 316 | 334 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), | |
| 317 | 335 | }) | |
| 318 | 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 | 357 | f.CI.SetPullGate(f.MergeRequests) | |
| 320 | 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 | 394 | jobArtifacts, artifacts = f.CI, f.CI | |
| 357 | 395 | } | |
| 358 | 396 | f.API = rpc.New(rpc.Config{ | |
| 359 | - | Auth: f.Auth, | |
| 360 | - | Repos: f.Repos, | |
| 361 | - | Tokens: f.Tokens, | |
| 362 | - | Browse: f.Browse, | |
| 363 | - | Events: f.EventLog, | |
| 364 | - | MergeRequests: f.MergeRequests, | |
| 365 | - | SigningKey: f.SigningKey, | |
| 366 | - | TrustedKeys: f.Signatures, | |
| 367 | - | Sessions: f.Sessions, | |
| 368 | - | Passkeys: apiPasskeys, | |
| 369 | - | Runner: f.CI, | |
| 370 | - | Runs: f.CI, | |
| 371 | - | Secrets: f.Settings, | |
| 372 | - | Variables: f.Settings, | |
| 373 | - | Environments: f.Settings, | |
| 374 | - | OIDCKeys: apiOIDC, | |
| 375 | - | JobArtifacts: jobArtifacts, | |
| 376 | - | Artifacts: artifacts, | |
| 377 | - | Version: serverVersion(), | |
| 378 | - | TrustedProxies: cfg.TrustedProxies, | |
| 379 | - | Limiter: limiter, | |
| 397 | + | Auth: f.Auth, | |
| 398 | + | Repos: f.Repos, | |
| 399 | + | Tokens: f.Tokens, | |
| 400 | + | Browse: f.Browse, | |
| 401 | + | Events: f.EventLog, | |
| 402 | + | MergeRequests: f.MergeRequests, | |
| 403 | + | Issues: f.Issues, | |
| 404 | + | IssueModeration: f.IssueModeration, | |
| 405 | + | SigningKey: f.SigningKey, | |
| 406 | + | TrustedKeys: f.Signatures, | |
| 407 | + | Sessions: f.Sessions, | |
| 408 | + | Passkeys: apiPasskeys, | |
| 409 | + | Runner: f.CI, | |
| 410 | + | Runs: f.CI, | |
| 411 | + | Secrets: f.Settings, | |
| 412 | + | Variables: f.Settings, | |
| 413 | + | Environments: f.Settings, | |
| 414 | + | OIDCKeys: apiOIDC, | |
| 415 | + | JobArtifacts: jobArtifacts, | |
| 416 | + | Artifacts: artifacts, | |
| 417 | + | Version: serverVersion(), | |
| 418 | + | TrustedProxies: cfg.TrustedProxies, | |
| 419 | + | Limiter: limiter, | |
| 380 | 420 | }) | |
| 381 | 421 | ||
| 382 | 422 | gh := githttp.New(githttp.Config{ | |
| @@ -536,3 +576,12 @@ func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key do | |||
| 536 | 576 | f.Browse.WithVerifier(sigs) | |
| 537 | 577 | 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 | 31 | root.AddCommand( | |
| 32 | 32 | newServeCmd(), newHookCmd(), | |
| 33 | 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 | 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | |
| 36 | 36 | ) | |
| 37 | 37 | return root | |
M internal/app/testdata/web/blob-binary.html +3 -1
| @@ -227,8 +227,9 @@ | |||
| 227 | 227 | <kbd>c</kbd> | |
| 228 | 228 | <kbd>r</kbd> | |
| 229 | 229 | <kbd>m</kbd> | |
| 230 | + | <kbd>i</kbd> | |
| 230 | 231 | </dt> | |
| 231 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 232 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 232 | 233 | </div> | |
| 233 | 234 | <div> | |
| 234 | 235 | <dt> | |
| @@ -410,6 +411,7 @@ | |||
| 410 | 411 | k-c from:body -> ermite.tab('c'); | |
| 411 | 412 | k-r from:body -> ermite.tab('r'); | |
| 412 | 413 | k-m from:body -> ermite.tab('m'); | |
| 414 | + | k-i from:body -> ermite.tab('i'); | |
| 413 | 415 | k-next from:body -> ermite.nextNote(); | |
| 414 | 416 | k-split from:body -> ermite.layout('split'); | |
| 415 | 417 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-code.html +3 -1
| @@ -297,8 +297,9 @@ | |||
| 297 | 297 | <kbd>c</kbd> | |
| 298 | 298 | <kbd>r</kbd> | |
| 299 | 299 | <kbd>m</kbd> | |
| 300 | + | <kbd>i</kbd> | |
| 300 | 301 | </dt> | |
| 301 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 302 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 302 | 303 | </div> | |
| 303 | 304 | <div> | |
| 304 | 305 | <dt> | |
| @@ -480,6 +481,7 @@ | |||
| 480 | 481 | k-c from:body -> ermite.tab('c'); | |
| 481 | 482 | k-r from:body -> ermite.tab('r'); | |
| 482 | 483 | k-m from:body -> ermite.tab('m'); | |
| 484 | + | k-i from:body -> ermite.tab('i'); | |
| 483 | 485 | k-next from:body -> ermite.nextNote(); | |
| 484 | 486 | k-split from:body -> ermite.layout('split'); | |
| 485 | 487 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-empty.html +3 -1
| @@ -227,8 +227,9 @@ | |||
| 227 | 227 | <kbd>c</kbd> | |
| 228 | 228 | <kbd>r</kbd> | |
| 229 | 229 | <kbd>m</kbd> | |
| 230 | + | <kbd>i</kbd> | |
| 230 | 231 | </dt> | |
| 231 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 232 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 232 | 233 | </div> | |
| 233 | 234 | <div> | |
| 234 | 235 | <dt> | |
| @@ -410,6 +411,7 @@ | |||
| 410 | 411 | k-c from:body -> ermite.tab('c'); | |
| 411 | 412 | k-r from:body -> ermite.tab('r'); | |
| 412 | 413 | k-m from:body -> ermite.tab('m'); | |
| 414 | + | k-i from:body -> ermite.tab('i'); | |
| 413 | 415 | k-next from:body -> ermite.nextNote(); | |
| 414 | 416 | k-split from:body -> ermite.layout('split'); | |
| 415 | 417 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-image.html +3 -1
| @@ -229,8 +229,9 @@ | |||
| 229 | 229 | <kbd>c</kbd> | |
| 230 | 230 | <kbd>r</kbd> | |
| 231 | 231 | <kbd>m</kbd> | |
| 232 | + | <kbd>i</kbd> | |
| 232 | 233 | </dt> | |
| 233 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 234 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 234 | 235 | </div> | |
| 235 | 236 | <div> | |
| 236 | 237 | <dt> | |
| @@ -412,6 +413,7 @@ | |||
| 412 | 413 | k-c from:body -> ermite.tab('c'); | |
| 413 | 414 | k-r from:body -> ermite.tab('r'); | |
| 414 | 415 | k-m from:body -> ermite.tab('m'); | |
| 416 | + | k-i from:body -> ermite.tab('i'); | |
| 415 | 417 | k-next from:body -> ermite.nextNote(); | |
| 416 | 418 | k-split from:body -> ermite.layout('split'); | |
| 417 | 419 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-markdown.html +3 -1
| @@ -294,8 +294,9 @@ | |||
| 294 | 294 | <kbd>c</kbd> | |
| 295 | 295 | <kbd>r</kbd> | |
| 296 | 296 | <kbd>m</kbd> | |
| 297 | + | <kbd>i</kbd> | |
| 297 | 298 | </dt> | |
| 298 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 299 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 299 | 300 | </div> | |
| 300 | 301 | <div> | |
| 301 | 302 | <dt> | |
| @@ -477,6 +478,7 @@ | |||
| 477 | 478 | k-c from:body -> ermite.tab('c'); | |
| 478 | 479 | k-r from:body -> ermite.tab('r'); | |
| 479 | 480 | k-m from:body -> ermite.tab('m'); | |
| 481 | + | k-i from:body -> ermite.tab('i'); | |
| 480 | 482 | k-next from:body -> ermite.nextNote(); | |
| 481 | 483 | k-split from:body -> ermite.layout('split'); | |
| 482 | 484 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-svg.html +3 -1
| @@ -252,8 +252,9 @@ | |||
| 252 | 252 | <kbd>c</kbd> | |
| 253 | 253 | <kbd>r</kbd> | |
| 254 | 254 | <kbd>m</kbd> | |
| 255 | + | <kbd>i</kbd> | |
| 255 | 256 | </dt> | |
| 256 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 257 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 257 | 258 | </div> | |
| 258 | 259 | <div> | |
| 259 | 260 | <dt> | |
| @@ -435,6 +436,7 @@ | |||
| 435 | 436 | k-c from:body -> ermite.tab('c'); | |
| 436 | 437 | k-r from:body -> ermite.tab('r'); | |
| 437 | 438 | k-m from:body -> ermite.tab('m'); | |
| 439 | + | k-i from:body -> ermite.tab('i'); | |
| 438 | 440 | k-next from:body -> ermite.nextNote(); | |
| 439 | 441 | k-split from:body -> ermite.layout('split'); | |
| 440 | 442 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-text.html +3 -1
| @@ -247,8 +247,9 @@ | |||
| 247 | 247 | <kbd>c</kbd> | |
| 248 | 248 | <kbd>r</kbd> | |
| 249 | 249 | <kbd>m</kbd> | |
| 250 | + | <kbd>i</kbd> | |
| 250 | 251 | </dt> | |
| 251 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 252 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 252 | 253 | </div> | |
| 253 | 254 | <div> | |
| 254 | 255 | <dt> | |
| @@ -430,6 +431,7 @@ | |||
| 430 | 431 | k-c from:body -> ermite.tab('c'); | |
| 431 | 432 | k-r from:body -> ermite.tab('r'); | |
| 432 | 433 | k-m from:body -> ermite.tab('m'); | |
| 434 | + | k-i from:body -> ermite.tab('i'); | |
| 433 | 435 | k-next from:body -> ermite.nextNote(); | |
| 434 | 436 | k-split from:body -> ermite.layout('split'); | |
| 435 | 437 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-too-large.html +3 -1
| @@ -227,8 +227,9 @@ | |||
| 227 | 227 | <kbd>c</kbd> | |
| 228 | 228 | <kbd>r</kbd> | |
| 229 | 229 | <kbd>m</kbd> | |
| 230 | + | <kbd>i</kbd> | |
| 230 | 231 | </dt> | |
| 231 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 232 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 232 | 233 | </div> | |
| 233 | 234 | <div> | |
| 234 | 235 | <dt> | |
| @@ -410,6 +411,7 @@ | |||
| 410 | 411 | k-c from:body -> ermite.tab('c'); | |
| 411 | 412 | k-r from:body -> ermite.tab('r'); | |
| 412 | 413 | k-m from:body -> ermite.tab('m'); | |
| 414 | + | k-i from:body -> ermite.tab('i'); | |
| 413 | 415 | k-next from:body -> ermite.nextNote(); | |
| 414 | 416 | k-split from:body -> ermite.layout('split'); | |
| 415 | 417 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/blob-trojan.html +3 -1
| @@ -294,8 +294,9 @@ | |||
| 294 | 294 | <kbd>c</kbd> | |
| 295 | 295 | <kbd>r</kbd> | |
| 296 | 296 | <kbd>m</kbd> | |
| 297 | + | <kbd>i</kbd> | |
| 297 | 298 | </dt> | |
| 298 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 299 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 299 | 300 | </div> | |
| 300 | 301 | <div> | |
| 301 | 302 | <dt> | |
| @@ -477,6 +478,7 @@ | |||
| 477 | 478 | k-c from:body -> ermite.tab('c'); | |
| 478 | 479 | k-r from:body -> ermite.tab('r'); | |
| 479 | 480 | k-m from:body -> ermite.tab('m'); | |
| 481 | + | k-i from:body -> ermite.tab('i'); | |
| 480 | 482 | k-next from:body -> ermite.nextNote(); | |
| 481 | 483 | k-split from:body -> ermite.layout('split'); | |
| 482 | 484 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-big.html +3 -1
| @@ -269,8 +269,9 @@ | |||
| 269 | 269 | <kbd>c</kbd> | |
| 270 | 270 | <kbd>r</kbd> | |
| 271 | 271 | <kbd>m</kbd> | |
| 272 | + | <kbd>i</kbd> | |
| 272 | 273 | </dt> | |
| 273 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 274 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 274 | 275 | </div> | |
| 275 | 276 | <div> | |
| 276 | 277 | <dt> | |
| @@ -452,6 +453,7 @@ | |||
| 452 | 453 | k-c from:body -> ermite.tab('c'); | |
| 453 | 454 | k-r from:body -> ermite.tab('r'); | |
| 454 | 455 | k-m from:body -> ermite.tab('m'); | |
| 456 | + | k-i from:body -> ermite.tab('i'); | |
| 455 | 457 | k-next from:body -> ermite.nextNote(); | |
| 456 | 458 | k-split from:body -> ermite.layout('split'); | |
| 457 | 459 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-binary.html +3 -1
| @@ -261,8 +261,9 @@ | |||
| 261 | 261 | <kbd>c</kbd> | |
| 262 | 262 | <kbd>r</kbd> | |
| 263 | 263 | <kbd>m</kbd> | |
| 264 | + | <kbd>i</kbd> | |
| 264 | 265 | </dt> | |
| 265 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 266 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 266 | 267 | </div> | |
| 267 | 268 | <div> | |
| 268 | 269 | <dt> | |
| @@ -444,6 +445,7 @@ | |||
| 444 | 445 | k-c from:body -> ermite.tab('c'); | |
| 445 | 446 | k-r from:body -> ermite.tab('r'); | |
| 446 | 447 | k-m from:body -> ermite.tab('m'); | |
| 448 | + | k-i from:body -> ermite.tab('i'); | |
| 447 | 449 | k-next from:body -> ermite.nextNote(); | |
| 448 | 450 | k-split from:body -> ermite.layout('split'); | |
| 449 | 451 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-merge.html +3 -1
| @@ -303,8 +303,9 @@ | |||
| 303 | 303 | <kbd>c</kbd> | |
| 304 | 304 | <kbd>r</kbd> | |
| 305 | 305 | <kbd>m</kbd> | |
| 306 | + | <kbd>i</kbd> | |
| 306 | 307 | </dt> | |
| 307 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 308 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 308 | 309 | </div> | |
| 309 | 310 | <div> | |
| 310 | 311 | <dt> | |
| @@ -486,6 +487,7 @@ | |||
| 486 | 487 | k-c from:body -> ermite.tab('c'); | |
| 487 | 488 | k-r from:body -> ermite.tab('r'); | |
| 488 | 489 | k-m from:body -> ermite.tab('m'); | |
| 490 | + | k-i from:body -> ermite.tab('i'); | |
| 489 | 491 | k-next from:body -> ermite.nextNote(); | |
| 490 | 492 | k-split from:body -> ermite.layout('split'); | |
| 491 | 493 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-modify-split.html +3 -1
| @@ -426,8 +426,9 @@ And <b>html</b> & "quotes".</pre> | |||
| 426 | 426 | <kbd>c</kbd> | |
| 427 | 427 | <kbd>r</kbd> | |
| 428 | 428 | <kbd>m</kbd> | |
| 429 | + | <kbd>i</kbd> | |
| 429 | 430 | </dt> | |
| 430 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 431 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 431 | 432 | </div> | |
| 432 | 433 | <div> | |
| 433 | 434 | <dt> | |
| @@ -609,6 +610,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 609 | 610 | k-c from:body -> ermite.tab('c'); | |
| 610 | 611 | k-r from:body -> ermite.tab('r'); | |
| 611 | 612 | k-m from:body -> ermite.tab('m'); | |
| 613 | + | k-i from:body -> ermite.tab('i'); | |
| 612 | 614 | k-next from:body -> ermite.nextNote(); | |
| 613 | 615 | k-split from:body -> ermite.layout('split'); | |
| 614 | 616 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-modify.html +3 -1
| @@ -426,8 +426,9 @@ And <b>html</b> & "quotes".</pre> | |||
| 426 | 426 | <kbd>c</kbd> | |
| 427 | 427 | <kbd>r</kbd> | |
| 428 | 428 | <kbd>m</kbd> | |
| 429 | + | <kbd>i</kbd> | |
| 429 | 430 | </dt> | |
| 430 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 431 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 431 | 432 | </div> | |
| 432 | 433 | <div> | |
| 433 | 434 | <dt> | |
| @@ -609,6 +610,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 609 | 610 | k-c from:body -> ermite.tab('c'); | |
| 610 | 611 | k-r from:body -> ermite.tab('r'); | |
| 611 | 612 | k-m from:body -> ermite.tab('m'); | |
| 613 | + | k-i from:body -> ermite.tab('i'); | |
| 612 | 614 | k-next from:body -> ermite.nextNote(); | |
| 613 | 615 | k-split from:body -> ermite.layout('split'); | |
| 614 | 616 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-rename.html +3 -1
| @@ -330,8 +330,9 @@ | |||
| 330 | 330 | <kbd>c</kbd> | |
| 331 | 331 | <kbd>r</kbd> | |
| 332 | 332 | <kbd>m</kbd> | |
| 333 | + | <kbd>i</kbd> | |
| 333 | 334 | </dt> | |
| 334 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 335 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 335 | 336 | </div> | |
| 336 | 337 | <div> | |
| 337 | 338 | <dt> | |
| @@ -513,6 +514,7 @@ | |||
| 513 | 514 | k-c from:body -> ermite.tab('c'); | |
| 514 | 515 | k-r from:body -> ermite.tab('r'); | |
| 515 | 516 | k-m from:body -> ermite.tab('m'); | |
| 517 | + | k-i from:body -> ermite.tab('i'); | |
| 516 | 518 | k-next from:body -> ermite.nextNote(); | |
| 517 | 519 | k-split from:body -> ermite.layout('split'); | |
| 518 | 520 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-root.html +3 -1
| @@ -649,8 +649,9 @@ | |||
| 649 | 649 | <kbd>c</kbd> | |
| 650 | 650 | <kbd>r</kbd> | |
| 651 | 651 | <kbd>m</kbd> | |
| 652 | + | <kbd>i</kbd> | |
| 652 | 653 | </dt> | |
| 653 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 654 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 654 | 655 | </div> | |
| 655 | 656 | <div> | |
| 656 | 657 | <dt> | |
| @@ -832,6 +833,7 @@ | |||
| 832 | 833 | k-c from:body -> ermite.tab('c'); | |
| 833 | 834 | k-r from:body -> ermite.tab('r'); | |
| 834 | 835 | k-m from:body -> ermite.tab('m'); | |
| 836 | + | k-i from:body -> ermite.tab('i'); | |
| 835 | 837 | k-next from:body -> ermite.nextNote(); | |
| 836 | 838 | k-split from:body -> ermite.layout('split'); | |
| 837 | 839 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commit-trojan.html +3 -1
| @@ -478,8 +478,9 @@ | |||
| 478 | 478 | <kbd>c</kbd> | |
| 479 | 479 | <kbd>r</kbd> | |
| 480 | 480 | <kbd>m</kbd> | |
| 481 | + | <kbd>i</kbd> | |
| 481 | 482 | </dt> | |
| 482 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 483 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 483 | 484 | </div> | |
| 484 | 485 | <div> | |
| 485 | 486 | <dt> | |
| @@ -661,6 +662,7 @@ | |||
| 661 | 662 | k-c from:body -> ermite.tab('c'); | |
| 662 | 663 | k-r from:body -> ermite.tab('r'); | |
| 663 | 664 | k-m from:body -> ermite.tab('m'); | |
| 665 | + | k-i from:body -> ermite.tab('i'); | |
| 664 | 666 | k-next from:body -> ermite.nextNote(); | |
| 665 | 667 | k-split from:body -> ermite.layout('split'); | |
| 666 | 668 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commits-page2.html +3 -1
| @@ -254,8 +254,9 @@ | |||
| 254 | 254 | <kbd>c</kbd> | |
| 255 | 255 | <kbd>r</kbd> | |
| 256 | 256 | <kbd>m</kbd> | |
| 257 | + | <kbd>i</kbd> | |
| 257 | 258 | </dt> | |
| 258 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 259 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 259 | 260 | </div> | |
| 260 | 261 | <div> | |
| 261 | 262 | <dt> | |
| @@ -423,6 +424,7 @@ | |||
| 423 | 424 | k-c from:body -> ermite.tab('c'); | |
| 424 | 425 | k-r from:body -> ermite.tab('r'); | |
| 425 | 426 | k-m from:body -> ermite.tab('m'); | |
| 427 | + | k-i from:body -> ermite.tab('i'); | |
| 426 | 428 | k-next from:body -> ermite.nextNote(); | |
| 427 | 429 | k-split from:body -> ermite.layout('split'); | |
| 428 | 430 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commits-path.html +3 -1
| @@ -269,8 +269,9 @@ | |||
| 269 | 269 | <kbd>c</kbd> | |
| 270 | 270 | <kbd>r</kbd> | |
| 271 | 271 | <kbd>m</kbd> | |
| 272 | + | <kbd>i</kbd> | |
| 272 | 273 | </dt> | |
| 273 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 274 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 274 | 275 | </div> | |
| 275 | 276 | <div> | |
| 276 | 277 | <dt> | |
| @@ -438,6 +439,7 @@ | |||
| 438 | 439 | k-c from:body -> ermite.tab('c'); | |
| 439 | 440 | k-r from:body -> ermite.tab('r'); | |
| 440 | 441 | k-m from:body -> ermite.tab('m'); | |
| 442 | + | k-i from:body -> ermite.tab('i'); | |
| 441 | 443 | k-next from:body -> ermite.nextNote(); | |
| 442 | 444 | k-split from:body -> ermite.layout('split'); | |
| 443 | 445 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commits-root.html +4 -2
| @@ -972,7 +972,7 @@ | |||
| 972 | 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 973 | 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 974 | 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 | 976 | gap -> ermiteLive.reload()"> | |
| 977 | 977 | </span> | |
| 978 | 978 | </div> | |
| @@ -1058,8 +1058,9 @@ | |||
| 1058 | 1058 | <kbd>c</kbd> | |
| 1059 | 1059 | <kbd>r</kbd> | |
| 1060 | 1060 | <kbd>m</kbd> | |
| 1061 | + | <kbd>i</kbd> | |
| 1061 | 1062 | </dt> | |
| 1062 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 1063 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 1063 | 1064 | </div> | |
| 1064 | 1065 | <div> | |
| 1065 | 1066 | <dt> | |
| @@ -1227,6 +1228,7 @@ | |||
| 1227 | 1228 | k-c from:body -> ermite.tab('c'); | |
| 1228 | 1229 | k-r from:body -> ermite.tab('r'); | |
| 1229 | 1230 | k-m from:body -> ermite.tab('m'); | |
| 1231 | + | k-i from:body -> ermite.tab('i'); | |
| 1230 | 1232 | k-next from:body -> ermite.nextNote(); | |
| 1231 | 1233 | k-split from:body -> ermite.layout('split'); | |
| 1232 | 1234 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/commits-small.html +4 -2
| @@ -317,7 +317,7 @@ | |||
| 317 | 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 318 | 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 319 | 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 | 321 | gap -> ermiteLive.reload()"> | |
| 322 | 322 | </span> | |
| 323 | 323 | </div> | |
| @@ -403,8 +403,9 @@ | |||
| 403 | 403 | <kbd>c</kbd> | |
| 404 | 404 | <kbd>r</kbd> | |
| 405 | 405 | <kbd>m</kbd> | |
| 406 | + | <kbd>i</kbd> | |
| 406 | 407 | </dt> | |
| 407 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 408 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 408 | 409 | </div> | |
| 409 | 410 | <div> | |
| 410 | 411 | <dt> | |
| @@ -592,6 +593,7 @@ | |||
| 592 | 593 | k-c from:body -> ermite.tab('c'); | |
| 593 | 594 | k-r from:body -> ermite.tab('r'); | |
| 594 | 595 | k-m from:body -> ermite.tab('m'); | |
| 596 | + | k-i from:body -> ermite.tab('i'); | |
| 595 | 597 | k-next from:body -> ermite.nextNote(); | |
| 596 | 598 | k-split from:body -> ermite.layout('split'); | |
| 597 | 599 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/empty.html +3 -1
| @@ -176,8 +176,9 @@ | |||
| 176 | 176 | <kbd>c</kbd> | |
| 177 | 177 | <kbd>r</kbd> | |
| 178 | 178 | <kbd>m</kbd> | |
| 179 | + | <kbd>i</kbd> | |
| 179 | 180 | </dt> | |
| 180 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 181 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 181 | 182 | </div> | |
| 182 | 183 | <div> | |
| 183 | 184 | <dt> | |
| @@ -276,6 +277,7 @@ | |||
| 276 | 277 | k-c from:body -> ermite.tab('c'); | |
| 277 | 278 | k-r from:body -> ermite.tab('r'); | |
| 278 | 279 | k-m from:body -> ermite.tab('m'); | |
| 280 | + | k-i from:body -> ermite.tab('i'); | |
| 279 | 281 | k-next from:body -> ermite.nextNote(); | |
| 280 | 282 | k-split from:body -> ermite.layout('split'); | |
| 281 | 283 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/home.html +4 -2
| @@ -191,7 +191,7 @@ | |||
| 191 | 191 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 192 | 192 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 193 | 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 | 195 | gap -> ermiteLive.reload()"> | |
| 196 | 196 | </span> | |
| 197 | 197 | </div> | |
| @@ -277,8 +277,9 @@ | |||
| 277 | 277 | <kbd>c</kbd> | |
| 278 | 278 | <kbd>r</kbd> | |
| 279 | 279 | <kbd>m</kbd> | |
| 280 | + | <kbd>i</kbd> | |
| 280 | 281 | </dt> | |
| 281 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 282 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 282 | 283 | </div> | |
| 283 | 284 | <div> | |
| 284 | 285 | <dt> | |
| @@ -383,6 +384,7 @@ | |||
| 383 | 384 | k-c from:body -> ermite.tab('c'); | |
| 384 | 385 | k-r from:body -> ermite.tab('r'); | |
| 385 | 386 | k-m from:body -> ermite.tab('m'); | |
| 387 | + | k-i from:body -> ermite.tab('i'); | |
| 386 | 388 | k-next from:body -> ermite.nextNote(); | |
| 387 | 389 | k-split from:body -> ermite.layout('split'); | |
| 388 | 390 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/not-found-page.html +3 -1
| @@ -165,8 +165,9 @@ | |||
| 165 | 165 | <kbd>c</kbd> | |
| 166 | 166 | <kbd>r</kbd> | |
| 167 | 167 | <kbd>m</kbd> | |
| 168 | + | <kbd>i</kbd> | |
| 168 | 169 | </dt> | |
| 169 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 170 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 170 | 171 | </div> | |
| 171 | 172 | <div> | |
| 172 | 173 | <dt> | |
| @@ -259,6 +260,7 @@ | |||
| 259 | 260 | k-c from:body -> ermite.tab('c'); | |
| 260 | 261 | k-r from:body -> ermite.tab('r'); | |
| 261 | 262 | k-m from:body -> ermite.tab('m'); | |
| 263 | + | k-i from:body -> ermite.tab('i'); | |
| 262 | 264 | k-next from:body -> ermite.nextNote(); | |
| 263 | 265 | k-split from:body -> ermite.layout('split'); | |
| 264 | 266 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/not-found.html +3 -1
| @@ -165,8 +165,9 @@ | |||
| 165 | 165 | <kbd>c</kbd> | |
| 166 | 166 | <kbd>r</kbd> | |
| 167 | 167 | <kbd>m</kbd> | |
| 168 | + | <kbd>i</kbd> | |
| 168 | 169 | </dt> | |
| 169 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 170 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 170 | 171 | </div> | |
| 171 | 172 | <div> | |
| 172 | 173 | <dt> | |
| @@ -259,6 +260,7 @@ | |||
| 259 | 260 | k-c from:body -> ermite.tab('c'); | |
| 260 | 261 | k-r from:body -> ermite.tab('r'); | |
| 261 | 262 | k-m from:body -> ermite.tab('m'); | |
| 263 | + | k-i from:body -> ermite.tab('i'); | |
| 262 | 264 | k-next from:body -> ermite.nextNote(); | |
| 263 | 265 | k-split from:body -> ermite.layout('split'); | |
| 264 | 266 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/refs.html +4 -2
| @@ -199,7 +199,7 @@ | |||
| 199 | 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 200 | 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 201 | 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 | 203 | gap -> ermiteLive.reload()"> | |
| 204 | 204 | </span> | |
| 205 | 205 | </div> | |
| @@ -285,8 +285,9 @@ | |||
| 285 | 285 | <kbd>c</kbd> | |
| 286 | 286 | <kbd>r</kbd> | |
| 287 | 287 | <kbd>m</kbd> | |
| 288 | + | <kbd>i</kbd> | |
| 288 | 289 | </dt> | |
| 289 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 290 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 290 | 291 | </div> | |
| 291 | 292 | <div> | |
| 292 | 293 | <dt> | |
| @@ -484,6 +485,7 @@ | |||
| 484 | 485 | k-c from:body -> ermite.tab('c'); | |
| 485 | 486 | k-r from:body -> ermite.tab('r'); | |
| 486 | 487 | k-m from:body -> ermite.tab('m'); | |
| 488 | + | k-i from:body -> ermite.tab('i'); | |
| 487 | 489 | k-next from:body -> ermite.nextNote(); | |
| 488 | 490 | k-split from:body -> ermite.layout('split'); | |
| 489 | 491 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/summary.html +4 -2
| @@ -225,7 +225,7 @@ | |||
| 225 | 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 226 | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 227 | 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 | 229 | gap -> ermiteLive.reload()"> | |
| 230 | 230 | </span> | |
| 231 | 231 | </div> | |
| @@ -311,8 +311,9 @@ | |||
| 311 | 311 | <kbd>c</kbd> | |
| 312 | 312 | <kbd>r</kbd> | |
| 313 | 313 | <kbd>m</kbd> | |
| 314 | + | <kbd>i</kbd> | |
| 314 | 315 | </dt> | |
| 315 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 316 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 316 | 317 | </div> | |
| 317 | 318 | <div> | |
| 318 | 319 | <dt> | |
| @@ -510,6 +511,7 @@ | |||
| 510 | 511 | k-c from:body -> ermite.tab('c'); | |
| 511 | 512 | k-r from:body -> ermite.tab('r'); | |
| 512 | 513 | k-m from:body -> ermite.tab('m'); | |
| 514 | + | k-i from:body -> ermite.tab('i'); | |
| 513 | 515 | k-next from:body -> ermite.nextNote(); | |
| 514 | 516 | k-split from:body -> ermite.layout('split'); | |
| 515 | 517 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/tree-nested.html +3 -1
| @@ -242,8 +242,9 @@ | |||
| 242 | 242 | <kbd>c</kbd> | |
| 243 | 243 | <kbd>r</kbd> | |
| 244 | 244 | <kbd>m</kbd> | |
| 245 | + | <kbd>i</kbd> | |
| 245 | 246 | </dt> | |
| 246 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 247 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 247 | 248 | </div> | |
| 248 | 249 | <div> | |
| 249 | 250 | <dt> | |
| @@ -425,6 +426,7 @@ | |||
| 425 | 426 | k-c from:body -> ermite.tab('c'); | |
| 426 | 427 | k-r from:body -> ermite.tab('r'); | |
| 427 | 428 | k-m from:body -> ermite.tab('m'); | |
| 429 | + | k-i from:body -> ermite.tab('i'); | |
| 428 | 430 | k-next from:body -> ermite.nextNote(); | |
| 429 | 431 | k-split from:body -> ermite.layout('split'); | |
| 430 | 432 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/tree-root.html +3 -1
| @@ -463,8 +463,9 @@ | |||
| 463 | 463 | <kbd>c</kbd> | |
| 464 | 464 | <kbd>r</kbd> | |
| 465 | 465 | <kbd>m</kbd> | |
| 466 | + | <kbd>i</kbd> | |
| 466 | 467 | </dt> | |
| 467 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 468 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 468 | 469 | </div> | |
| 469 | 470 | <div> | |
| 470 | 471 | <dt> | |
| @@ -646,6 +647,7 @@ | |||
| 646 | 647 | k-c from:body -> ermite.tab('c'); | |
| 647 | 648 | k-r from:body -> ermite.tab('r'); | |
| 648 | 649 | k-m from:body -> ermite.tab('m'); | |
| 650 | + | k-i from:body -> ermite.tab('i'); | |
| 649 | 651 | k-next from:body -> ermite.nextNote(); | |
| 650 | 652 | k-split from:body -> ermite.layout('split'); | |
| 651 | 653 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/tree-slash-ref.html +3 -1
| @@ -239,8 +239,9 @@ | |||
| 239 | 239 | <kbd>c</kbd> | |
| 240 | 240 | <kbd>r</kbd> | |
| 241 | 241 | <kbd>m</kbd> | |
| 242 | + | <kbd>i</kbd> | |
| 242 | 243 | </dt> | |
| 243 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 244 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 244 | 245 | </div> | |
| 245 | 246 | <div> | |
| 246 | 247 | <dt> | |
| @@ -422,6 +423,7 @@ | |||
| 422 | 423 | k-c from:body -> ermite.tab('c'); | |
| 423 | 424 | k-r from:body -> ermite.tab('r'); | |
| 424 | 425 | k-m from:body -> ermite.tab('m'); | |
| 426 | + | k-i from:body -> ermite.tab('i'); | |
| 425 | 427 | k-next from:body -> ermite.nextNote(); | |
| 426 | 428 | k-split from:body -> ermite.layout('split'); | |
| 427 | 429 | k-unified from:body -> ermite.layout('unified'); | |
M internal/app/testdata/web/tree-subdir.html +3 -1
| @@ -255,8 +255,9 @@ | |||
| 255 | 255 | <kbd>c</kbd> | |
| 256 | 256 | <kbd>r</kbd> | |
| 257 | 257 | <kbd>m</kbd> | |
| 258 | + | <kbd>i</kbd> | |
| 258 | 259 | </dt> | |
| 259 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 260 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 260 | 261 | </div> | |
| 261 | 262 | <div> | |
| 262 | 263 | <dt> | |
| @@ -438,6 +439,7 @@ | |||
| 438 | 439 | k-c from:body -> ermite.tab('c'); | |
| 439 | 440 | k-r from:body -> ermite.tab('r'); | |
| 440 | 441 | k-m from:body -> ermite.tab('m'); | |
| 442 | + | k-i from:body -> ermite.tab('i'); | |
| 441 | 443 | k-next from:body -> ermite.nextNote(); | |
| 442 | 444 | k-split from:body -> ermite.layout('split'); | |
| 443 | 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 | 45 | Repos: f.Repos, | |
| 46 | 46 | Browse: f.Browse, | |
| 47 | 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 | 53 | Runs: f.CI, | |
| 49 | 54 | Artifacts: artifacts, | |
| 50 | 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 <b>slow</b>") | |
| 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 <b>slow</b>") | |
| 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 | 11 | defer l.mu.Unlock() | |
| 12 | 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 | 173 | // EventKind implements Event. | |
| 174 | 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 | 248 | // EventGap tells a subscriber that it missed events because it was too slow; | |
| 177 | 249 | // it must refresh its whole view. | |
| 178 | 250 | type EventGap struct{} | |
A internal/domain/issue.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // IssuesMode says who may write issues in a repository. | |
| 11 | + | type IssuesMode string | |
| 12 | + | ||
| 13 | + | // Issues modes. | |
| 14 | + | const ( | |
| 15 | + | // IssuesOff turns issues off: the default. | |
| 16 | + | IssuesOff IssuesMode = "off" | |
| 17 | + | // IssuesOwner lets the owner and tokens with ScopeIssueWrite write issues. | |
| 18 | + | IssuesOwner IssuesMode = "owner" | |
| 19 | + | // IssuesPublic also opens the repository to submissions from anyone. | |
| 20 | + | IssuesPublic IssuesMode = "public" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // IssuesModes returns the three modes in their canonical order. | |
| 24 | + | func IssuesModes() []IssuesMode { return []IssuesMode{IssuesOff, IssuesOwner, IssuesPublic} } | |
| 25 | + | ||
| 26 | + | // ParseIssuesMode parses "off", "owner" or "public". | |
| 27 | + | func ParseIssuesMode(s string) (IssuesMode, error) { | |
| 28 | + | for _, m := range IssuesModes() { | |
| 29 | + | if string(m) == s { | |
| 30 | + | return m, nil | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | return "", fmt.Errorf("%w: issues mode %q (want off, owner or public)", ErrInvalid, s) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // String returns the mode as text. | |
| 37 | + | func (m IssuesMode) String() string { return string(m) } | |
| 38 | + | ||
| 39 | + | // OrDefault returns IssuesOff for the empty mode, else m. | |
| 40 | + | func (m IssuesMode) OrDefault() IssuesMode { | |
| 41 | + | if m == "" { | |
| 42 | + | return IssuesOff | |
| 43 | + | } | |
| 44 | + | return m | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // IssueState is where an issue is in its life. | |
| 48 | + | type IssueState string | |
| 49 | + | ||
| 50 | + | // Issue states. | |
| 51 | + | const ( | |
| 52 | + | // IssueStateOpen is being worked on. | |
| 53 | + | IssueStateOpen IssueState = "open" | |
| 54 | + | // IssueStateClosed is done with; it can be reopened. | |
| 55 | + | IssueStateClosed IssueState = "closed" | |
| 56 | + | ) | |
| 57 | + | ||
| 58 | + | // Issue is a problem or a request about a repository. It shares its number | |
| 59 | + | // sequence with the merge requests of the repository. | |
| 60 | + | type Issue struct { | |
| 61 | + | Repo RepoName | |
| 62 | + | Number int64 | |
| 63 | + | Title IssueTitle | |
| 64 | + | // Body may be empty. | |
| 65 | + | Body IssueBody | |
| 66 | + | State IssueState | |
| 67 | + | // Author is the label of the token that opened it, "root" or "owner". | |
| 68 | + | Author string | |
| 69 | + | CreatedAt time.Time | |
| 70 | + | UpdatedAt time.Time | |
| 71 | + | // CommentSeq is the id of the last comment of the issue. | |
| 72 | + | CommentSeq int64 | |
| 73 | + | // External is true for an issue approved from the public form: its text | |
| 74 | + | // comes from an anonymous visitor and is rendered as untrusted. | |
| 75 | + | External bool | |
| 76 | + | // Version is set by the store on read: Update fails with ErrConflict when | |
| 77 | + | // the stored issue changed since. Opaque to everyone else. | |
| 78 | + | Version uint64 | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // IsOpen reports whether the issue is open. | |
| 82 | + | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | |
| 83 | + | ||
| 84 | + | // IssueComment is a comment on an issue. | |
| 85 | + | type IssueComment struct { | |
| 86 | + | ID int64 | |
| 87 | + | Author string | |
| 88 | + | Text IssueBody | |
| 89 | + | CreatedAt time.Time | |
| 90 | + | // Version is set by the store on read, like Issue's. | |
| 91 | + | Version uint64 | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // NumberKind says what a number of a repository names. Issues and merge | |
| 95 | + | // requests share one sequence, so a number names at most one of them. | |
| 96 | + | type NumberKind int | |
| 97 | + | ||
| 98 | + | // Number kinds. | |
| 99 | + | const ( | |
| 100 | + | // NumberNone: no issue or merge request has this number. | |
| 101 | + | NumberNone NumberKind = iota | |
| 102 | + | // NumberIssue: the number names an issue. | |
| 103 | + | NumberIssue | |
| 104 | + | // NumberMergeRequest: the number names a merge request. | |
| 105 | + | NumberMergeRequest | |
| 106 | + | ) | |
A internal/domain/issue_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestParseIssueTitle(t *testing.T) { | |
| 17 | + | t.Parallel() | |
| 18 | + | tests := []struct { | |
| 19 | + | name, in, want, reason string // reason "" when valid | |
| 20 | + | }{ | |
| 21 | + | {"plain", "Crash on start", "Crash on start", ""}, | |
| 22 | + | {"max length in code points", strings.Repeat("é", 120), strings.Repeat("é", 120), ""}, | |
| 23 | + | {"too long", strings.Repeat("a", 121), "", "longer than 120"}, | |
| 24 | + | {"emoji count as one", strings.Repeat("🎉", 120), strings.Repeat("🎉", 120), ""}, | |
| 25 | + | {"empty", "", "", "is empty"}, | |
| 26 | + | {"blank", " \t ", "", "is empty"}, | |
| 27 | + | {"only controls", "\x01\x02", "", "is empty"}, | |
| 28 | + | {"newline", "a\nb", "", "one line"}, | |
| 29 | + | {"carriage return", "a\rb", "", "one line"}, | |
| 30 | + | {"nfc", "e\u0301", "\u00e9", ""}, | |
| 31 | + | {"c0 stripped", "a\x00b\x1bc\tz", "abcz", ""}, | |
| 32 | + | {"c1 stripped", "a\u0085b\u009fc", "abc", ""}, | |
| 33 | + | {"del stripped", "a\x7fb", "ab", ""}, | |
| 34 | + | {"bidi stripped", "a\u202ab\u202ec\u2066d\u2069e\u200ef\u200fg\u061ch", "abcdefgh", ""}, | |
| 35 | + | {"not utf-8", "a\xffb", "", "UTF-8"}, | |
| 36 | + | } | |
| 37 | + | for _, tt := range tests { | |
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |
| 39 | + | t.Parallel() | |
| 40 | + | got, err := domain.ParseIssueTitle(tt.in) | |
| 41 | + | if tt.reason == "" { | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.Equal(t, tt.want, got.String()) | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | var te *domain.TextError | |
| 47 | + | require.True(t, errors.As(err, &te), "%v", err) | |
| 48 | + | assert.ErrorIs(t, err, domain.ErrInvalidText) | |
| 49 | + | assert.Contains(t, te.Reason, tt.reason) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestParseIssueBody(t *testing.T) { | |
| 55 | + | t.Parallel() | |
| 56 | + | tests := []struct { | |
| 57 | + | name, in, want, reason string | |
| 58 | + | }{ | |
| 59 | + | {"empty allowed", "", "", ""}, | |
| 60 | + | {"multiline and tabs kept", "a\n\tb\n", "a\n\tb\n", ""}, | |
| 61 | + | {"crlf becomes lf", "a\r\nb", "a\nb", ""}, | |
| 62 | + | {"max length", strings.Repeat("é", 10000), strings.Repeat("é", 10000), ""}, | |
| 63 | + | {"too long", strings.Repeat("a", 10001), "", "longer than 10000"}, | |
| 64 | + | {"nfc", "e\u0301\n", "\u00e9\n", ""}, | |
| 65 | + | {"controls stripped", "a\x00\x08b\u0085c\u009fd\x7f", "abcd", ""}, | |
| 66 | + | {"bidi stripped", "\u202etxt\u202c \u2067x\u2069", "txt x", ""}, | |
| 67 | + | {"separators stripped", "a\u2028b\u2029c", "abc", ""}, | |
| 68 | + | {"not utf-8", "\xc3\x28", "", "UTF-8"}, | |
| 69 | + | } | |
| 70 | + | for _, tt := range tests { | |
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |
| 72 | + | t.Parallel() | |
| 73 | + | got, err := domain.ParseIssueBody(tt.in) | |
| 74 | + | if tt.reason == "" { | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | assert.Equal(t, tt.want, got.String()) | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | var te *domain.TextError | |
| 80 | + | require.True(t, errors.As(err, &te), "%v", err) | |
| 81 | + | assert.Contains(t, te.Reason, tt.reason) | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestParseAuthorLabel(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | tests := []struct { | |
| 89 | + | name, in, want, reason string | |
| 90 | + | }{ | |
| 91 | + | {"plain", "Ada", "Ada", ""}, | |
| 92 | + | {"empty allowed", "", "", ""}, | |
| 93 | + | {"max length", strings.Repeat("é", 40), strings.Repeat("é", 40), ""}, | |
| 94 | + | {"too long", strings.Repeat("a", 41), "", "longer than 40"}, | |
| 95 | + | {"newline", "a\nb", "", "one line"}, | |
| 96 | + | {"nfc", "e\u0301", "\u00e9", ""}, | |
| 97 | + | {"stripped", "A\u202ed\x00a", "Ada", ""}, | |
| 98 | + | {"trimmed", " Ada ", "Ada", ""}, | |
| 99 | + | {"not utf-8", "\xff", "", "UTF-8"}, | |
| 100 | + | } | |
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | t.Parallel() | |
| 104 | + | got, err := domain.ParseAuthorLabel(tt.in) | |
| 105 | + | if tt.reason == "" { | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | assert.Equal(t, tt.want, got) | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | var te *domain.TextError | |
| 111 | + | require.True(t, errors.As(err, &te), "%v", err) | |
| 112 | + | assert.Contains(t, te.Reason, tt.reason) | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestIssuesMode(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | for _, m := range domain.IssuesModes() { | |
| 120 | + | got, err := domain.ParseIssuesMode(m.String()) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | assert.Equal(t, m, got) | |
| 123 | + | } | |
| 124 | + | _, err := domain.ParseIssuesMode("all") | |
| 125 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 126 | + | assert.Equal(t, domain.IssuesOff, domain.DefaultRepoSettings().Issues) | |
| 127 | + | assert.False(t, domain.DefaultRepoSettings().IsZero()) | |
| 128 | + | assert.True(t, domain.RepoSettings{}.IsZero()) | |
| 129 | + | assert.Equal(t, domain.IssuesOff, domain.IssuesMode("").OrDefault()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestIssueEventKinds(t *testing.T) { | |
| 133 | + | t.Parallel() | |
| 134 | + | assert.Equal(t, "issue.opened", domain.IssueOpened{}.EventKind()) | |
| 135 | + | assert.Equal(t, "issue.closed", domain.IssueClosed{}.EventKind()) | |
| 136 | + | assert.Equal(t, "issue.reopened", domain.IssueReopened{}.EventKind()) | |
| 137 | + | assert.Equal(t, "issue.commented", domain.IssueCommented{}.EventKind()) | |
| 138 | + | } | |
M internal/domain/mergerequest.go +3 -1
| @@ -43,12 +43,14 @@ type RepoSettings struct { | |||
| 43 | 43 | AllowedMergeModes []MergeMode | |
| 44 | 44 | // FailingChecksBlock says that a failing check prevents the merge. | |
| 45 | 45 | FailingChecksBlock bool | |
| 46 | + | // Issues says who may write issues; the empty mode means IssuesOff. | |
| 47 | + | Issues IssuesMode | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | 50 | // DefaultRepoSettings are the settings of a new repository: both modes, and | |
| 49 | 51 | // failing checks block the merge. | |
| 50 | 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 | 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 | 5 | import ( | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "strings" | |
| 8 | 9 | "unicode" | |
| 9 | 10 | "unicode/utf8" | |
| 11 | + | ||
| 12 | + | "golang.org/x/text/unicode/norm" | |
| 10 | 13 | ) | |
| 11 | 14 | ||
| 12 | 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 | 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
| @@ -38,15 +38,19 @@ const ( | |||
| 38 | 38 | ScopeRunnerRegister Scope = "runner:register" | |
| 39 | 39 | // ScopeTokenAdmin creates, lists and revokes stored tokens. | |
| 40 | 40 | ScopeTokenAdmin Scope = "token:admin" | |
| 41 | + | // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation | |
| 42 | + | // is not part of it: it stays with the owner. | |
| 43 | + | ScopeIssueWrite Scope = "issue:write" | |
| 41 | 44 | ) | |
| 42 | 45 | ||
| 43 | - | // AllScopes returns the nine scopes in a stable order. | |
| 46 | + | // AllScopes returns the ten scopes in a stable order. | |
| 44 | 47 | func AllScopes() []Scope { | |
| 45 | 48 | return []Scope{ | |
| 46 | 49 | ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin, | |
| 47 | 50 | ScopeMRWrite, ScopeMRMerge, | |
| 48 | 51 | ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister, | |
| 49 | 52 | ScopeTokenAdmin, | |
| 53 | + | ScopeIssueWrite, | |
| 50 | 54 | } | |
| 51 | 55 | } | |
| 52 | 56 | ||
M internal/domain/token_test.go +6 -1
| @@ -16,7 +16,11 @@ import ( | |||
| 16 | 16 | func TestScopes(t *testing.T) { | |
| 17 | 17 | t.Parallel() | |
| 18 | 18 | all := domain.AllScopes() | |
| 19 | - | assert.Len(t, all, 9) | |
| 19 | + | assert.Len(t, all, 10) | |
| 20 | + | assert.Contains(t, all, domain.ScopeIssueWrite) | |
| 21 | + | got, err := domain.ParseScope("issue:write") | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | assert.Equal(t, domain.ScopeIssueWrite, got) | |
| 20 | 24 | for _, s := range all { | |
| 21 | 25 | got, err := domain.ParseScope(s.String()) | |
| 22 | 26 | require.NoError(t, err) | |
| @@ -110,6 +114,7 @@ func TestHasOwnerRights(t *testing.T) { | |||
| 110 | 114 | assert.False(t, domain.Anonymous().HasOwnerRights()) | |
| 111 | 115 | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights()) | |
| 112 | 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 | 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 | 49 | AllowedMergeModes *[]domain.MergeMode | |
| 50 | 50 | // FailingChecksBlock replaces the setting of that name. | |
| 51 | 51 | FailingChecksBlock *bool | |
| 52 | + | // Issues replaces the issues mode. | |
| 53 | + | Issues *domain.IssuesMode | |
| 52 | 54 | } | |
| 53 | 55 | ||
| 54 | 56 | // TokenPage is a page of stored tokens, newest first. | |
| @@ -198,6 +200,52 @@ type MergeRequestPage struct { | |||
| 198 | 200 | 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 | 249 | // MergeRequestDetail is a merge request with its review data. | |
| 202 | 250 | type MergeRequestDetail struct { | |
| 203 | 251 | MergeRequest domain.MergeRequest | |
| @@ -274,3 +322,24 @@ type LoginLink struct { | |||
| 274 | 322 | URL string | |
| 275 | 323 | ExpiresAt time.Time | |
| 276 | 324 | } | |
| 325 | + | ||
| 326 | + | // InboxCounts are the numbers of open items across every repository, whatever | |
| 327 | + | // the filter. | |
| 328 | + | type InboxCounts struct{ MergeRequests, Issues, Pending int } | |
| 329 | + | ||
| 330 | + | // InboxOverview is what the owner has to look at across every repository: the | |
| 331 | + | // open merge requests and the open issues, most recently updated first. A | |
| 332 | + | // section for the items awaiting moderation can be added next to them. | |
| 333 | + | type InboxOverview struct { | |
| 334 | + | Counts InboxCounts | |
| 335 | + | // MergeRequests and Issues are those of the filtered repository, or of | |
| 336 | + | // every repository when there is no filter. Counts always counts all. | |
| 337 | + | MergeRequests []domain.MergeRequest | |
| 338 | + | Issues []domain.Issue | |
| 339 | + | // Pending are the issues awaiting moderation, in every repository | |
| 340 | + | // whatever the filter, oldest first. At most domain.MaxPendingIssues. | |
| 341 | + | Pending []domain.PendingIssue | |
| 342 | + | // Repos are the repositories that have at least one open item, by name, | |
| 343 | + | // whatever the filter: they feed the filter itself. | |
| 344 | + | Repos []domain.RepoName | |
| 345 | + | } | |
A internal/ports/mocks/InboxService.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewInboxService creates a new instance of InboxService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewInboxService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *InboxService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &InboxService{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // InboxService is an autogenerated mock type for the InboxService type | |
| 39 | + | type InboxService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type InboxService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *InboxService) EXPECT() *InboxService_Expecter { | |
| 48 | + | return &InboxService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Overview provides a mock function for the type InboxService | |
| 52 | + | func (_mock *InboxService) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, filter) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Overview") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 ports.InboxOverview | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.InboxOverview, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, filter) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.InboxOverview); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, filter) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(ports.InboxOverview) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, filter) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // InboxService_Overview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Overview' | |
| 78 | + | type InboxService_Overview_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Overview is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - filter domain.RepoName | |
| 86 | + | func (_e *InboxService_Expecter) Overview(ctx any, p any, filter any) *InboxService_Overview_Call { | |
| 87 | + | return &InboxService_Overview_Call{Call: _e.mock.On("Overview", ctx, p, filter)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *InboxService_Overview_Call) Run(run func(ctx context.Context, p domain.Principal, filter domain.RepoName)) *InboxService_Overview_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 domain.Principal | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(domain.Principal) | |
| 99 | + | } | |
| 100 | + | var arg2 domain.RepoName | |
| 101 | + | if args[2] != nil { | |
| 102 | + | arg2 = args[2].(domain.RepoName) | |
| 103 | + | } | |
| 104 | + | run( | |
| 105 | + | arg0, | |
| 106 | + | arg1, | |
| 107 | + | arg2, | |
| 108 | + | ) | |
| 109 | + | }) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *InboxService_Overview_Call) Return(inboxOverview ports.InboxOverview, err error) *InboxService_Overview_Call { | |
| 114 | + | _c.Call.Return(inboxOverview, err) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *InboxService_Overview_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error)) *InboxService_Overview_Call { | |
| 119 | + | _c.Call.Return(run) | |
| 120 | + | return _c | |
| 121 | + | } | |
A internal/ports/mocks/IssueBanStore.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewIssueBanStore creates a new instance of IssueBanStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewIssueBanStore(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *IssueBanStore { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &IssueBanStore{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // IssueBanStore is an autogenerated mock type for the IssueBanStore type | |
| 38 | + | type IssueBanStore struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type IssueBanStore_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *IssueBanStore) EXPECT() *IssueBanStore_Expecter { | |
| 47 | + | return &IssueBanStore_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Ban provides a mock function for the type IssueBanStore | |
| 51 | + | func (_mock *IssueBanStore) Ban(ctx context.Context, hash []byte, until time.Time) error { | |
| 52 | + | ret := _mock.Called(ctx, hash, until) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Ban") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, hash, until) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // IssueBanStore_Ban_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ban' | |
| 68 | + | type IssueBanStore_Ban_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Ban is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - hash []byte | |
| 75 | + | // - until time.Time | |
| 76 | + | func (_e *IssueBanStore_Expecter) Ban(ctx any, hash any, until any) *IssueBanStore_Ban_Call { | |
| 77 | + | return &IssueBanStore_Ban_Call{Call: _e.mock.On("Ban", ctx, hash, until)} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (_c *IssueBanStore_Ban_Call) Run(run func(ctx context.Context, hash []byte, until time.Time)) *IssueBanStore_Ban_Call { | |
| 81 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 82 | + | var arg0 context.Context | |
| 83 | + | if args[0] != nil { | |
| 84 | + | arg0 = args[0].(context.Context) | |
| 85 | + | } | |
| 86 | + | var arg1 []byte | |
| 87 | + | if args[1] != nil { | |
| 88 | + | arg1 = args[1].([]byte) | |
| 89 | + | } | |
| 90 | + | var arg2 time.Time | |
| 91 | + | if args[2] != nil { | |
| 92 | + | arg2 = args[2].(time.Time) | |
| 93 | + | } | |
| 94 | + | run( | |
| 95 | + | arg0, | |
| 96 | + | arg1, | |
| 97 | + | arg2, | |
| 98 | + | ) | |
| 99 | + | }) | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (_c *IssueBanStore_Ban_Call) Return(err error) *IssueBanStore_Ban_Call { | |
| 104 | + | _c.Call.Return(err) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (_c *IssueBanStore_Ban_Call) RunAndReturn(run func(ctx context.Context, hash []byte, until time.Time) error) *IssueBanStore_Ban_Call { | |
| 109 | + | _c.Call.Return(run) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Banned provides a mock function for the type IssueBanStore | |
| 114 | + | func (_mock *IssueBanStore) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) { | |
| 115 | + | ret := _mock.Called(ctx, hash, now) | |
| 116 | + | ||
| 117 | + | if len(ret) == 0 { | |
| 118 | + | panic("no return value specified for Banned") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | var r0 bool | |
| 122 | + | var r1 error | |
| 123 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) (bool, error)); ok { | |
| 124 | + | return returnFunc(ctx, hash, now) | |
| 125 | + | } | |
| 126 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time) bool); ok { | |
| 127 | + | r0 = returnFunc(ctx, hash, now) | |
| 128 | + | } else { | |
| 129 | + | r0 = ret.Get(0).(bool) | |
| 130 | + | } | |
| 131 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, []byte, time.Time) error); ok { | |
| 132 | + | r1 = returnFunc(ctx, hash, now) | |
| 133 | + | } else { | |
| 134 | + | r1 = ret.Error(1) | |
| 135 | + | } | |
| 136 | + | return r0, r1 | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // IssueBanStore_Banned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Banned' | |
| 140 | + | type IssueBanStore_Banned_Call struct { | |
| 141 | + | *mock.Call | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Banned is a helper method to define mock.On call | |
| 145 | + | // - ctx context.Context | |
| 146 | + | // - hash []byte | |
| 147 | + | // - now time.Time | |
| 148 | + | func (_e *IssueBanStore_Expecter) Banned(ctx any, hash any, now any) *IssueBanStore_Banned_Call { | |
| 149 | + | return &IssueBanStore_Banned_Call{Call: _e.mock.On("Banned", ctx, hash, now)} | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (_c *IssueBanStore_Banned_Call) Run(run func(ctx context.Context, hash []byte, now time.Time)) *IssueBanStore_Banned_Call { | |
| 153 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 154 | + | var arg0 context.Context | |
| 155 | + | if args[0] != nil { | |
| 156 | + | arg0 = args[0].(context.Context) | |
| 157 | + | } | |
| 158 | + | var arg1 []byte | |
| 159 | + | if args[1] != nil { | |
| 160 | + | arg1 = args[1].([]byte) | |
| 161 | + | } | |
| 162 | + | var arg2 time.Time | |
| 163 | + | if args[2] != nil { | |
| 164 | + | arg2 = args[2].(time.Time) | |
| 165 | + | } | |
| 166 | + | run( | |
| 167 | + | arg0, | |
| 168 | + | arg1, | |
| 169 | + | arg2, | |
| 170 | + | ) | |
| 171 | + | }) | |
| 172 | + | return _c | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (_c *IssueBanStore_Banned_Call) Return(b bool, err error) *IssueBanStore_Banned_Call { | |
| 176 | + | _c.Call.Return(b, err) | |
| 177 | + | return _c | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (_c *IssueBanStore_Banned_Call) RunAndReturn(run func(ctx context.Context, hash []byte, now time.Time) (bool, error)) *IssueBanStore_Banned_Call { | |
| 181 | + | _c.Call.Return(run) | |
| 182 | + | return _c | |
| 183 | + | } | |
A internal/ports/mocks/IssueKeyStore.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewIssueKeyStore creates a new instance of IssueKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewIssueKeyStore(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *IssueKeyStore { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &IssueKeyStore{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // IssueKeyStore is an autogenerated mock type for the IssueKeyStore type | |
| 37 | + | type IssueKeyStore struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type IssueKeyStore_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *IssueKeyStore) EXPECT() *IssueKeyStore_Expecter { | |
| 46 | + | return &IssueKeyStore_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Key provides a mock function for the type IssueKeyStore | |
| 50 | + | func (_mock *IssueKeyStore) Key(ctx context.Context) ([]byte, error) { | |
| 51 | + | ret := _mock.Called(ctx) | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for Key") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 []byte | |
| 58 | + | var r1 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok { | |
| 60 | + | return returnFunc(ctx) | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok { | |
| 63 | + | r0 = returnFunc(ctx) | |
| 64 | + | } else { | |
| 65 | + | if ret.Get(0) != nil { | |
| 66 | + | r0 = ret.Get(0).([]byte) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 70 | + | r1 = returnFunc(ctx) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // IssueKeyStore_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key' | |
| 78 | + | type IssueKeyStore_Key_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Key is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | func (_e *IssueKeyStore_Expecter) Key(ctx any) *IssueKeyStore_Key_Call { | |
| 85 | + | return &IssueKeyStore_Key_Call{Call: _e.mock.On("Key", ctx)} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *IssueKeyStore_Key_Call) Run(run func(ctx context.Context)) *IssueKeyStore_Key_Call { | |
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 90 | + | var arg0 context.Context | |
| 91 | + | if args[0] != nil { | |
| 92 | + | arg0 = args[0].(context.Context) | |
| 93 | + | } | |
| 94 | + | run( | |
| 95 | + | arg0, | |
| 96 | + | ) | |
| 97 | + | }) | |
| 98 | + | return _c | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (_c *IssueKeyStore_Key_Call) Return(bytes []byte, err error) *IssueKeyStore_Key_Call { | |
| 102 | + | _c.Call.Return(bytes, err) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *IssueKeyStore_Key_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *IssueKeyStore_Key_Call { | |
| 107 | + | _c.Call.Return(run) | |
| 108 | + | return _c | |
| 109 | + | } | |
A internal/ports/mocks/IssueModerationService.go +280 -0
| @@ -0,0 +1,280 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewIssueModerationService creates a new instance of IssueModerationService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewIssueModerationService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *IssueModerationService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &IssueModerationService{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // IssueModerationService is an autogenerated mock type for the IssueModerationService type | |
| 39 | + | type IssueModerationService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type IssueModerationService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *IssueModerationService) EXPECT() *IssueModerationService_Expecter { | |
| 48 | + | return &IssueModerationService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Approve provides a mock function for the type IssueModerationService | |
| 52 | + | func (_mock *IssueModerationService) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, repo, id) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Approve") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 domain.Issue | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) (domain.Issue, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, repo, id) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) domain.Issue); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, repo, id) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(domain.Issue) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, repo, id) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // IssueModerationService_Approve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Approve' | |
| 78 | + | type IssueModerationService_Approve_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Approve is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - id domain.PendingIssueID | |
| 87 | + | func (_e *IssueModerationService_Expecter) Approve(ctx any, p any, repo any, id any) *IssueModerationService_Approve_Call { | |
| 88 | + | return &IssueModerationService_Approve_Call{Call: _e.mock.On("Approve", ctx, p, repo, id)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *IssueModerationService_Approve_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID)) *IssueModerationService_Approve_Call { | |
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 93 | + | var arg0 context.Context | |
| 94 | + | if args[0] != nil { | |
| 95 | + | arg0 = args[0].(context.Context) | |
| 96 | + | } | |
| 97 | + | var arg1 domain.Principal | |
| 98 | + | if args[1] != nil { | |
| 99 | + | arg1 = args[1].(domain.Principal) | |
| 100 | + | } | |
| 101 | + | var arg2 domain.RepoName | |
| 102 | + | if args[2] != nil { | |
| 103 | + | arg2 = args[2].(domain.RepoName) | |
| 104 | + | } | |
| 105 | + | var arg3 domain.PendingIssueID | |
| 106 | + | if args[3] != nil { | |
| 107 | + | arg3 = args[3].(domain.PendingIssueID) | |
| 108 | + | } | |
| 109 | + | run( | |
| 110 | + | arg0, | |
| 111 | + | arg1, | |
| 112 | + | arg2, | |
| 113 | + | arg3, | |
| 114 | + | ) | |
| 115 | + | }) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *IssueModerationService_Approve_Call) Return(issue domain.Issue, err error) *IssueModerationService_Approve_Call { | |
| 120 | + | _c.Call.Return(issue, err) | |
| 121 | + | return _c | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (_c *IssueModerationService_Approve_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error)) *IssueModerationService_Approve_Call { | |
| 125 | + | _c.Call.Return(run) | |
| 126 | + | return _c | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // ListPending provides a mock function for the type IssueModerationService | |
| 130 | + | func (_mock *IssueModerationService) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) { | |
| 131 | + | ret := _mock.Called(ctx, p, repo, page) | |
| 132 | + | ||
| 133 | + | if len(ret) == 0 { | |
| 134 | + | panic("no return value specified for ListPending") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | var r0 ports.PendingIssuePage | |
| 138 | + | var r1 error | |
| 139 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) (ports.PendingIssuePage, error)); ok { | |
| 140 | + | return returnFunc(ctx, p, repo, page) | |
| 141 | + | } | |
| 142 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) ports.PendingIssuePage); ok { | |
| 143 | + | r0 = returnFunc(ctx, p, repo, page) | |
| 144 | + | } else { | |
| 145 | + | r0 = ret.Get(0).(ports.PendingIssuePage) | |
| 146 | + | } | |
| 147 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListPendingParams) error); ok { | |
| 148 | + | r1 = returnFunc(ctx, p, repo, page) | |
| 149 | + | } else { | |
| 150 | + | r1 = ret.Error(1) | |
| 151 | + | } | |
| 152 | + | return r0, r1 | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // IssueModerationService_ListPending_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPending' | |
| 156 | + | type IssueModerationService_ListPending_Call struct { | |
| 157 | + | *mock.Call | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // ListPending is a helper method to define mock.On call | |
| 161 | + | // - ctx context.Context | |
| 162 | + | // - p domain.Principal | |
| 163 | + | // - repo domain.RepoName | |
| 164 | + | // - page ports.ListPendingParams | |
| 165 | + | func (_e *IssueModerationService_Expecter) ListPending(ctx any, p any, repo any, page any) *IssueModerationService_ListPending_Call { | |
| 166 | + | return &IssueModerationService_ListPending_Call{Call: _e.mock.On("ListPending", ctx, p, repo, page)} | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (_c *IssueModerationService_ListPending_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams)) *IssueModerationService_ListPending_Call { | |
| 170 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 171 | + | var arg0 context.Context | |
| 172 | + | if args[0] != nil { | |
| 173 | + | arg0 = args[0].(context.Context) | |
| 174 | + | } | |
| 175 | + | var arg1 domain.Principal | |
| 176 | + | if args[1] != nil { | |
| 177 | + | arg1 = args[1].(domain.Principal) | |
| 178 | + | } | |
| 179 | + | var arg2 domain.RepoName | |
| 180 | + | if args[2] != nil { | |
| 181 | + | arg2 = args[2].(domain.RepoName) | |
| 182 | + | } | |
| 183 | + | var arg3 ports.ListPendingParams | |
| 184 | + | if args[3] != nil { | |
| 185 | + | arg3 = args[3].(ports.ListPendingParams) | |
| 186 | + | } | |
| 187 | + | run( | |
| 188 | + | arg0, | |
| 189 | + | arg1, | |
| 190 | + | arg2, | |
| 191 | + | arg3, | |
| 192 | + | ) | |
| 193 | + | }) | |
| 194 | + | return _c | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (_c *IssueModerationService_ListPending_Call) Return(pendingIssuePage ports.PendingIssuePage, err error) *IssueModerationService_ListPending_Call { | |
| 198 | + | _c.Call.Return(pendingIssuePage, err) | |
| 199 | + | return _c | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (_c *IssueModerationService_ListPending_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error)) *IssueModerationService_ListPending_Call { | |
| 203 | + | _c.Call.Return(run) | |
| 204 | + | return _c | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Reject provides a mock function for the type IssueModerationService | |
| 208 | + | func (_mock *IssueModerationService) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error { | |
| 209 | + | ret := _mock.Called(ctx, p, repo, id, ban) | |
| 210 | + | ||
| 211 | + | if len(ret) == 0 { | |
| 212 | + | panic("no return value specified for Reject") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | var r0 error | |
| 216 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.PendingIssueID, bool) error); ok { | |
| 217 | + | r0 = returnFunc(ctx, p, repo, id, ban) | |
| 218 | + | } else { | |
| 219 | + | r0 = ret.Error(0) | |
| 220 | + | } | |
| 221 | + | return r0 | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // IssueModerationService_Reject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reject' | |
| 225 | + | type IssueModerationService_Reject_Call struct { | |
| 226 | + | *mock.Call | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // Reject is a helper method to define mock.On call | |
| 230 | + | // - ctx context.Context | |
| 231 | + | // - p domain.Principal | |
| 232 | + | // - repo domain.RepoName | |
| 233 | + | // - id domain.PendingIssueID | |
| 234 | + | // - ban bool | |
| 235 | + | func (_e *IssueModerationService_Expecter) Reject(ctx any, p any, repo any, id any, ban any) *IssueModerationService_Reject_Call { | |
| 236 | + | return &IssueModerationService_Reject_Call{Call: _e.mock.On("Reject", ctx, p, repo, id, ban)} | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func (_c *IssueModerationService_Reject_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool)) *IssueModerationService_Reject_Call { | |
| 240 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 241 | + | var arg0 context.Context | |
| 242 | + | if args[0] != nil { | |
| 243 | + | arg0 = args[0].(context.Context) | |
| 244 | + | } | |
| 245 | + | var arg1 domain.Principal | |
| 246 | + | if args[1] != nil { | |
| 247 | + | arg1 = args[1].(domain.Principal) | |
| 248 | + | } | |
| 249 | + | var arg2 domain.RepoName | |
| 250 | + | if args[2] != nil { | |
| 251 | + | arg2 = args[2].(domain.RepoName) | |
| 252 | + | } | |
| 253 | + | var arg3 domain.PendingIssueID | |
| 254 | + | if args[3] != nil { | |
| 255 | + | arg3 = args[3].(domain.PendingIssueID) | |
| 256 | + | } | |
| 257 | + | var arg4 bool | |
| 258 | + | if args[4] != nil { | |
| 259 | + | arg4 = args[4].(bool) | |
| 260 | + | } | |
| 261 | + | run( | |
| 262 | + | arg0, | |
| 263 | + | arg1, | |
| 264 | + | arg2, | |
| 265 | + | arg3, | |
| 266 | + | arg4, | |
| 267 | + | ) | |
| 268 | + | }) | |
| 269 | + | return _c | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (_c *IssueModerationService_Reject_Call) Return(err error) *IssueModerationService_Reject_Call { | |
| 273 | + | _c.Call.Return(err) | |
| 274 | + | return _c | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (_c *IssueModerationService_Reject_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error) *IssueModerationService_Reject_Call { | |
| 278 | + | _c.Call.Return(run) | |
| 279 | + | return _c | |
| 280 | + | } | |
A internal/ports/mocks/IssueNumberResolver.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewIssueNumberResolver creates a new instance of IssueNumberResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewIssueNumberResolver(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *IssueNumberResolver { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &IssueNumberResolver{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // IssueNumberResolver is an autogenerated mock type for the IssueNumberResolver type | |
| 38 | + | type IssueNumberResolver struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type IssueNumberResolver_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *IssueNumberResolver) EXPECT() *IssueNumberResolver_Expecter { | |
| 47 | + | return &IssueNumberResolver_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Kind provides a mock function for the type IssueNumberResolver | |
| 51 | + | func (_mock *IssueNumberResolver) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | |
| 52 | + | ret := _mock.Called(ctx, p, name, n) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Kind") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.NumberKind | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.NumberKind, error)); ok { | |
| 61 | + | return returnFunc(ctx, p, name, n) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.NumberKind); ok { | |
| 64 | + | r0 = returnFunc(ctx, p, name, n) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(domain.NumberKind) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 69 | + | r1 = returnFunc(ctx, p, name, n) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Error(1) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // IssueNumberResolver_Kind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Kind' | |
| 77 | + | type IssueNumberResolver_Kind_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Kind is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - p domain.Principal | |
| 84 | + | // - name domain.RepoName | |
| 85 | + | // - n int64 | |
| 86 | + | func (_e *IssueNumberResolver_Expecter) Kind(ctx any, p any, name any, n any) *IssueNumberResolver_Kind_Call { | |
| 87 | + | return &IssueNumberResolver_Kind_Call{Call: _e.mock.On("Kind", ctx, p, name, n)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *IssueNumberResolver_Kind_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64)) *IssueNumberResolver_Kind_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 domain.Principal | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(domain.Principal) | |
| 99 | + | } | |
| 100 | + | var arg2 domain.RepoName | |
| 101 | + | if args[2] != nil { | |
| 102 | + | arg2 = args[2].(domain.RepoName) | |
| 103 | + | } | |
| 104 | + | var arg3 int64 | |
| 105 | + | if args[3] != nil { | |
| 106 | + | arg3 = args[3].(int64) | |
| 107 | + | } | |
| 108 | + | run( | |
| 109 | + | arg0, | |
| 110 | + | arg1, | |
| 111 | + | arg2, | |
| 112 | + | arg3, | |
| 113 | + | ) | |
| 114 | + | }) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *IssueNumberResolver_Kind_Call) Return(numberKind domain.NumberKind, err error) *IssueNumberResolver_Kind_Call { | |
| 119 | + | _c.Call.Return(numberKind, err) | |
| 120 | + | return _c | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (_c *IssueNumberResolver_Kind_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)) *IssueNumberResolver_Kind_Call { | |
| 124 | + | _c.Call.Return(run) | |
| 125 | + | return _c | |
| 126 | + | } | |
A internal/ports/mocks/IssueService.go +523 -0
| @@ -0,0 +1,523 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewIssueService creates a new instance of IssueService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewIssueService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *IssueService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &IssueService{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // IssueService is an autogenerated mock type for the IssueService type | |
| 39 | + | type IssueService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type IssueService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *IssueService) EXPECT() *IssueService_Expecter { | |
| 48 | + | return &IssueService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // AddComment provides a mock function for the type IssueService | |
| 52 | + | func (_mock *IssueService) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, name, number, text) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for AddComment") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 domain.IssueComment | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) (domain.IssueComment, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, name, number, text) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) domain.IssueComment); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, name, number, text) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(domain.IssueComment) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, string) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, name, number, text) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // IssueService_AddComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddComment' | |
| 78 | + | type IssueService_AddComment_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // AddComment is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - name domain.RepoName | |
| 86 | + | // - number int64 | |
| 87 | + | // - text string | |
| 88 | + | func (_e *IssueService_Expecter) AddComment(ctx any, p any, name any, number any, text any) *IssueService_AddComment_Call { | |
| 89 | + | return &IssueService_AddComment_Call{Call: _e.mock.On("AddComment", ctx, p, name, number, text)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *IssueService_AddComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string)) *IssueService_AddComment_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 context.Context | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(context.Context) | |
| 97 | + | } | |
| 98 | + | var arg1 domain.Principal | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.Principal) | |
| 101 | + | } | |
| 102 | + | var arg2 domain.RepoName | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(domain.RepoName) | |
| 105 | + | } | |
| 106 | + | var arg3 int64 | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(int64) | |
| 109 | + | } | |
| 110 | + | var arg4 string | |
| 111 | + | if args[4] != nil { | |
| 112 | + | arg4 = args[4].(string) | |
| 113 | + | } | |
| 114 | + | run( | |
| 115 | + | arg0, | |
| 116 | + | arg1, | |
| 117 | + | arg2, | |
| 118 | + | arg3, | |
| 119 | + | arg4, | |
| 120 | + | ) | |
| 121 | + | }) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *IssueService_AddComment_Call) Return(issueComment domain.IssueComment, err error) *IssueService_AddComment_Call { | |
| 126 | + | _c.Call.Return(issueComment, err) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *IssueService_AddComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)) *IssueService_AddComment_Call { | |
| 131 | + | _c.Call.Return(run) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Close provides a mock function for the type IssueService | |
| 136 | + | func (_mock *IssueService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) { | |
| 137 | + | ret := _mock.Called(ctx, p, name, number) | |
| 138 | + | ||
| 139 | + | if len(ret) == 0 { | |
| 140 | + | panic("no return value specified for Close") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | var r0 domain.Issue | |
| 144 | + | var r1 error | |
| 145 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok { | |
| 146 | + | return returnFunc(ctx, p, name, number) | |
| 147 | + | } | |
| 148 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok { | |
| 149 | + | r0 = returnFunc(ctx, p, name, number) | |
| 150 | + | } else { | |
| 151 | + | r0 = ret.Get(0).(domain.Issue) | |
| 152 | + | } | |
| 153 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 154 | + | r1 = returnFunc(ctx, p, name, number) | |
| 155 | + | } else { | |
| 156 | + | r1 = ret.Error(1) | |
| 157 | + | } | |
| 158 | + | return r0, r1 | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // IssueService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 162 | + | type IssueService_Close_Call struct { | |
| 163 | + | *mock.Call | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Close is a helper method to define mock.On call | |
| 167 | + | // - ctx context.Context | |
| 168 | + | // - p domain.Principal | |
| 169 | + | // - name domain.RepoName | |
| 170 | + | // - number int64 | |
| 171 | + | func (_e *IssueService_Expecter) Close(ctx any, p any, name any, number any) *IssueService_Close_Call { | |
| 172 | + | return &IssueService_Close_Call{Call: _e.mock.On("Close", ctx, p, name, number)} | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (_c *IssueService_Close_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Close_Call { | |
| 176 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 177 | + | var arg0 context.Context | |
| 178 | + | if args[0] != nil { | |
| 179 | + | arg0 = args[0].(context.Context) | |
| 180 | + | } | |
| 181 | + | var arg1 domain.Principal | |
| 182 | + | if args[1] != nil { | |
| 183 | + | arg1 = args[1].(domain.Principal) | |
| 184 | + | } | |
| 185 | + | var arg2 domain.RepoName | |
| 186 | + | if args[2] != nil { | |
| 187 | + | arg2 = args[2].(domain.RepoName) | |
| 188 | + | } | |
| 189 | + | var arg3 int64 | |
| 190 | + | if args[3] != nil { | |
| 191 | + | arg3 = args[3].(int64) | |
| 192 | + | } | |
| 193 | + | run( | |
| 194 | + | arg0, | |
| 195 | + | arg1, | |
| 196 | + | arg2, | |
| 197 | + | arg3, | |
| 198 | + | ) | |
| 199 | + | }) | |
| 200 | + | return _c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *IssueService_Close_Call) Return(issue domain.Issue, err error) *IssueService_Close_Call { | |
| 204 | + | _c.Call.Return(issue, err) | |
| 205 | + | return _c | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *IssueService_Close_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Close_Call { | |
| 209 | + | _c.Call.Return(run) | |
| 210 | + | return _c | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // Create provides a mock function for the type IssueService | |
| 214 | + | func (_mock *IssueService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) { | |
| 215 | + | ret := _mock.Called(ctx, p, name, params) | |
| 216 | + | ||
| 217 | + | if len(ret) == 0 { | |
| 218 | + | panic("no return value specified for Create") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | var r0 domain.Issue | |
| 222 | + | var r1 error | |
| 223 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) (domain.Issue, error)); ok { | |
| 224 | + | return returnFunc(ctx, p, name, params) | |
| 225 | + | } | |
| 226 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) domain.Issue); ok { | |
| 227 | + | r0 = returnFunc(ctx, p, name, params) | |
| 228 | + | } else { | |
| 229 | + | r0 = ret.Get(0).(domain.Issue) | |
| 230 | + | } | |
| 231 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) error); ok { | |
| 232 | + | r1 = returnFunc(ctx, p, name, params) | |
| 233 | + | } else { | |
| 234 | + | r1 = ret.Error(1) | |
| 235 | + | } | |
| 236 | + | return r0, r1 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // IssueService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |
| 240 | + | type IssueService_Create_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Create is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - p domain.Principal | |
| 247 | + | // - name domain.RepoName | |
| 248 | + | // - params ports.CreateIssueParams | |
| 249 | + | func (_e *IssueService_Expecter) Create(ctx any, p any, name any, params any) *IssueService_Create_Call { | |
| 250 | + | return &IssueService_Create_Call{Call: _e.mock.On("Create", ctx, p, name, params)} | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (_c *IssueService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams)) *IssueService_Create_Call { | |
| 254 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 255 | + | var arg0 context.Context | |
| 256 | + | if args[0] != nil { | |
| 257 | + | arg0 = args[0].(context.Context) | |
| 258 | + | } | |
| 259 | + | var arg1 domain.Principal | |
| 260 | + | if args[1] != nil { | |
| 261 | + | arg1 = args[1].(domain.Principal) | |
| 262 | + | } | |
| 263 | + | var arg2 domain.RepoName | |
| 264 | + | if args[2] != nil { | |
| 265 | + | arg2 = args[2].(domain.RepoName) | |
| 266 | + | } | |
| 267 | + | var arg3 ports.CreateIssueParams | |
| 268 | + | if args[3] != nil { | |
| 269 | + | arg3 = args[3].(ports.CreateIssueParams) | |
| 270 | + | } | |
| 271 | + | run( | |
| 272 | + | arg0, | |
| 273 | + | arg1, | |
| 274 | + | arg2, | |
| 275 | + | arg3, | |
| 276 | + | ) | |
| 277 | + | }) | |
| 278 | + | return _c | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func (_c *IssueService_Create_Call) Return(issue domain.Issue, err error) *IssueService_Create_Call { | |
| 282 | + | _c.Call.Return(issue, err) | |
| 283 | + | return _c | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (_c *IssueService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error)) *IssueService_Create_Call { | |
| 287 | + | _c.Call.Return(run) | |
| 288 | + | return _c | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Get provides a mock function for the type IssueService | |
| 292 | + | func (_mock *IssueService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) { | |
| 293 | + | ret := _mock.Called(ctx, p, name, number) | |
| 294 | + | ||
| 295 | + | if len(ret) == 0 { | |
| 296 | + | panic("no return value specified for Get") | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | var r0 ports.IssueDetail | |
| 300 | + | var r1 error | |
| 301 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (ports.IssueDetail, error)); ok { | |
| 302 | + | return returnFunc(ctx, p, name, number) | |
| 303 | + | } | |
| 304 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) ports.IssueDetail); ok { | |
| 305 | + | r0 = returnFunc(ctx, p, name, number) | |
| 306 | + | } else { | |
| 307 | + | r0 = ret.Get(0).(ports.IssueDetail) | |
| 308 | + | } | |
| 309 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 310 | + | r1 = returnFunc(ctx, p, name, number) | |
| 311 | + | } else { | |
| 312 | + | r1 = ret.Error(1) | |
| 313 | + | } | |
| 314 | + | return r0, r1 | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // IssueService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 318 | + | type IssueService_Get_Call struct { | |
| 319 | + | *mock.Call | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Get is a helper method to define mock.On call | |
| 323 | + | // - ctx context.Context | |
| 324 | + | // - p domain.Principal | |
| 325 | + | // - name domain.RepoName | |
| 326 | + | // - number int64 | |
| 327 | + | func (_e *IssueService_Expecter) Get(ctx any, p any, name any, number any) *IssueService_Get_Call { | |
| 328 | + | return &IssueService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number)} | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func (_c *IssueService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Get_Call { | |
| 332 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 333 | + | var arg0 context.Context | |
| 334 | + | if args[0] != nil { | |
| 335 | + | arg0 = args[0].(context.Context) | |
| 336 | + | } | |
| 337 | + | var arg1 domain.Principal | |
| 338 | + | if args[1] != nil { | |
| 339 | + | arg1 = args[1].(domain.Principal) | |
| 340 | + | } | |
| 341 | + | var arg2 domain.RepoName | |
| 342 | + | if args[2] != nil { | |
| 343 | + | arg2 = args[2].(domain.RepoName) | |
| 344 | + | } | |
| 345 | + | var arg3 int64 | |
| 346 | + | if args[3] != nil { | |
| 347 | + | arg3 = args[3].(int64) | |
| 348 | + | } | |
| 349 | + | run( | |
| 350 | + | arg0, | |
| 351 | + | arg1, | |
| 352 | + | arg2, | |
| 353 | + | arg3, | |
| 354 | + | ) | |
| 355 | + | }) | |
| 356 | + | return _c | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | func (_c *IssueService_Get_Call) Return(issueDetail ports.IssueDetail, err error) *IssueService_Get_Call { | |
| 360 | + | _c.Call.Return(issueDetail, err) | |
| 361 | + | return _c | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func (_c *IssueService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error)) *IssueService_Get_Call { | |
| 365 | + | _c.Call.Return(run) | |
| 366 | + | return _c | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // List provides a mock function for the type IssueService | |
| 370 | + | func (_mock *IssueService) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) { | |
| 371 | + | ret := _mock.Called(ctx, p, name, params) | |
| 372 | + | ||
| 373 | + | if len(ret) == 0 { | |
| 374 | + | panic("no return value specified for List") | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | var r0 ports.IssuePage | |
| 378 | + | var r1 error | |
| 379 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok { | |
| 380 | + | return returnFunc(ctx, p, name, params) | |
| 381 | + | } | |
| 382 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok { | |
| 383 | + | r0 = returnFunc(ctx, p, name, params) | |
| 384 | + | } else { | |
| 385 | + | r0 = ret.Get(0).(ports.IssuePage) | |
| 386 | + | } | |
| 387 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) error); ok { | |
| 388 | + | r1 = returnFunc(ctx, p, name, params) | |
| 389 | + | } else { | |
| 390 | + | r1 = ret.Error(1) | |
| 391 | + | } | |
| 392 | + | return r0, r1 | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // IssueService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 396 | + | type IssueService_List_Call struct { | |
| 397 | + | *mock.Call | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // List is a helper method to define mock.On call | |
| 401 | + | // - ctx context.Context | |
| 402 | + | // - p domain.Principal | |
| 403 | + | // - name domain.RepoName | |
| 404 | + | // - params ports.ListIssuesParams | |
| 405 | + | func (_e *IssueService_Expecter) List(ctx any, p any, name any, params any) *IssueService_List_Call { | |
| 406 | + | return &IssueService_List_Call{Call: _e.mock.On("List", ctx, p, name, params)} | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func (_c *IssueService_List_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams)) *IssueService_List_Call { | |
| 410 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 411 | + | var arg0 context.Context | |
| 412 | + | if args[0] != nil { | |
| 413 | + | arg0 = args[0].(context.Context) | |
| 414 | + | } | |
| 415 | + | var arg1 domain.Principal | |
| 416 | + | if args[1] != nil { | |
| 417 | + | arg1 = args[1].(domain.Principal) | |
| 418 | + | } | |
| 419 | + | var arg2 domain.RepoName | |
| 420 | + | if args[2] != nil { | |
| 421 | + | arg2 = args[2].(domain.RepoName) | |
| 422 | + | } | |
| 423 | + | var arg3 ports.ListIssuesParams | |
| 424 | + | if args[3] != nil { | |
| 425 | + | arg3 = args[3].(ports.ListIssuesParams) | |
| 426 | + | } | |
| 427 | + | run( | |
| 428 | + | arg0, | |
| 429 | + | arg1, | |
| 430 | + | arg2, | |
| 431 | + | arg3, | |
| 432 | + | ) | |
| 433 | + | }) | |
| 434 | + | return _c | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func (_c *IssueService_List_Call) Return(issuePage ports.IssuePage, err error) *IssueService_List_Call { | |
| 438 | + | _c.Call.Return(issuePage, err) | |
| 439 | + | return _c | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | func (_c *IssueService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueService_List_Call { | |
| 443 | + | _c.Call.Return(run) | |
| 444 | + | return _c | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // Reopen provides a mock function for the type IssueService | |
| 448 | + | func (_mock *IssueService) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) { | |
| 449 | + | ret := _mock.Called(ctx, p, name, number) | |
| 450 | + | ||
| 451 | + | if len(ret) == 0 { | |
| 452 | + | panic("no return value specified for Reopen") | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | var r0 domain.Issue | |
| 456 | + | var r1 error | |
| 457 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok { | |
| 458 | + | return returnFunc(ctx, p, name, number) | |
| 459 | + | } | |
| 460 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok { | |
| 461 | + | r0 = returnFunc(ctx, p, name, number) | |
| 462 | + | } else { | |
| 463 | + | r0 = ret.Get(0).(domain.Issue) | |
| 464 | + | } | |
| 465 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 466 | + | r1 = returnFunc(ctx, p, name, number) | |
| 467 | + | } else { | |
| 468 | + | r1 = ret.Error(1) | |
| 469 | + | } | |
| 470 | + | return r0, r1 | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // IssueService_Reopen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reopen' | |
| 474 | + | type IssueService_Reopen_Call struct { | |
| 475 | + | *mock.Call | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // Reopen is a helper method to define mock.On call | |
| 479 | + | // - ctx context.Context | |
| 480 | + | // - p domain.Principal | |
| 481 | + | // - name domain.RepoName | |
| 482 | + | // - number int64 | |
| 483 | + | func (_e *IssueService_Expecter) Reopen(ctx any, p any, name any, number any) *IssueService_Reopen_Call { | |
| 484 | + | return &IssueService_Reopen_Call{Call: _e.mock.On("Reopen", ctx, p, name, number)} | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func (_c *IssueService_Reopen_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Reopen_Call { | |
| 488 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 489 | + | var arg0 context.Context | |
| 490 | + | if args[0] != nil { | |
| 491 | + | arg0 = args[0].(context.Context) | |
| 492 | + | } | |
| 493 | + | var arg1 domain.Principal | |
| 494 | + | if args[1] != nil { | |
| 495 | + | arg1 = args[1].(domain.Principal) | |
| 496 | + | } | |
| 497 | + | var arg2 domain.RepoName | |
| 498 | + | if args[2] != nil { | |
| 499 | + | arg2 = args[2].(domain.RepoName) | |
| 500 | + | } | |
| 501 | + | var arg3 int64 | |
| 502 | + | if args[3] != nil { | |
| 503 | + | arg3 = args[3].(int64) | |
| 504 | + | } | |
| 505 | + | run( | |
| 506 | + | arg0, | |
| 507 | + | arg1, | |
| 508 | + | arg2, | |
| 509 | + | arg3, | |
| 510 | + | ) | |
| 511 | + | }) | |
| 512 | + | return _c | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | func (_c *IssueService_Reopen_Call) Return(issue domain.Issue, err error) *IssueService_Reopen_Call { | |
| 516 | + | _c.Call.Return(issue, err) | |
| 517 | + | return _c | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | func (_c *IssueService_Reopen_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Reopen_Call { | |
| 521 | + | _c.Call.Return(run) | |
| 522 | + | return _c | |
| 523 | + | } | |
A internal/ports/mocks/IssueStore.go +515 -0
| @@ -0,0 +1,515 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewIssueStore creates a new instance of IssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewIssueStore(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *IssueStore { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &IssueStore{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // IssueStore is an autogenerated mock type for the IssueStore type | |
| 39 | + | type IssueStore struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type IssueStore_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *IssueStore) EXPECT() *IssueStore_Expecter { | |
| 48 | + | return &IssueStore_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Create provides a mock function for the type IssueStore | |
| 52 | + | func (_mock *IssueStore) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) { | |
| 53 | + | ret := _mock.Called(ctx, issue) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Create") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 domain.Issue | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) (domain.Issue, error)); ok { | |
| 62 | + | return returnFunc(ctx, issue) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) domain.Issue); ok { | |
| 65 | + | r0 = returnFunc(ctx, issue) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(domain.Issue) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Issue) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, issue) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // IssueStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |
| 78 | + | type IssueStore_Create_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Create is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - issue domain.Issue | |
| 85 | + | func (_e *IssueStore_Expecter) Create(ctx any, issue any) *IssueStore_Create_Call { | |
| 86 | + | return &IssueStore_Create_Call{Call: _e.mock.On("Create", ctx, issue)} | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (_c *IssueStore_Create_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Create_Call { | |
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 91 | + | var arg0 context.Context | |
| 92 | + | if args[0] != nil { | |
| 93 | + | arg0 = args[0].(context.Context) | |
| 94 | + | } | |
| 95 | + | var arg1 domain.Issue | |
| 96 | + | if args[1] != nil { | |
| 97 | + | arg1 = args[1].(domain.Issue) | |
| 98 | + | } | |
| 99 | + | run( | |
| 100 | + | arg0, | |
| 101 | + | arg1, | |
| 102 | + | ) | |
| 103 | + | }) | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (_c *IssueStore_Create_Call) Return(issue1 domain.Issue, err error) *IssueStore_Create_Call { | |
| 108 | + | _c.Call.Return(issue1, err) | |
| 109 | + | return _c | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (_c *IssueStore_Create_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) (domain.Issue, error)) *IssueStore_Create_Call { | |
| 113 | + | _c.Call.Return(run) | |
| 114 | + | return _c | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Get provides a mock function for the type IssueStore | |
| 118 | + | func (_mock *IssueStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) { | |
| 119 | + | ret := _mock.Called(ctx, repo, number) | |
| 120 | + | ||
| 121 | + | if len(ret) == 0 { | |
| 122 | + | panic("no return value specified for Get") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | var r0 domain.Issue | |
| 126 | + | var r1 error | |
| 127 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Issue, error)); ok { | |
| 128 | + | return returnFunc(ctx, repo, number) | |
| 129 | + | } | |
| 130 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Issue); ok { | |
| 131 | + | r0 = returnFunc(ctx, repo, number) | |
| 132 | + | } else { | |
| 133 | + | r0 = ret.Get(0).(domain.Issue) | |
| 134 | + | } | |
| 135 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 136 | + | r1 = returnFunc(ctx, repo, number) | |
| 137 | + | } else { | |
| 138 | + | r1 = ret.Error(1) | |
| 139 | + | } | |
| 140 | + | return r0, r1 | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // IssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 144 | + | type IssueStore_Get_Call struct { | |
| 145 | + | *mock.Call | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Get is a helper method to define mock.On call | |
| 149 | + | // - ctx context.Context | |
| 150 | + | // - repo domain.RepoName | |
| 151 | + | // - number int64 | |
| 152 | + | func (_e *IssueStore_Expecter) Get(ctx any, repo any, number any) *IssueStore_Get_Call { | |
| 153 | + | return &IssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, number)} | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (_c *IssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_Get_Call { | |
| 157 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 158 | + | var arg0 context.Context | |
| 159 | + | if args[0] != nil { | |
| 160 | + | arg0 = args[0].(context.Context) | |
| 161 | + | } | |
| 162 | + | var arg1 domain.RepoName | |
| 163 | + | if args[1] != nil { | |
| 164 | + | arg1 = args[1].(domain.RepoName) | |
| 165 | + | } | |
| 166 | + | var arg2 int64 | |
| 167 | + | if args[2] != nil { | |
| 168 | + | arg2 = args[2].(int64) | |
| 169 | + | } | |
| 170 | + | run( | |
| 171 | + | arg0, | |
| 172 | + | arg1, | |
| 173 | + | arg2, | |
| 174 | + | ) | |
| 175 | + | }) | |
| 176 | + | return _c | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (_c *IssueStore_Get_Call) Return(issue domain.Issue, err error) *IssueStore_Get_Call { | |
| 180 | + | _c.Call.Return(issue, err) | |
| 181 | + | return _c | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (_c *IssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)) *IssueStore_Get_Call { | |
| 185 | + | _c.Call.Return(run) | |
| 186 | + | return _c | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // List provides a mock function for the type IssueStore | |
| 190 | + | func (_mock *IssueStore) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) { | |
| 191 | + | ret := _mock.Called(ctx, repo, params) | |
| 192 | + | ||
| 193 | + | if len(ret) == 0 { | |
| 194 | + | panic("no return value specified for List") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | var r0 ports.IssuePage | |
| 198 | + | var r1 error | |
| 199 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok { | |
| 200 | + | return returnFunc(ctx, repo, params) | |
| 201 | + | } | |
| 202 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok { | |
| 203 | + | r0 = returnFunc(ctx, repo, params) | |
| 204 | + | } else { | |
| 205 | + | r0 = ret.Get(0).(ports.IssuePage) | |
| 206 | + | } | |
| 207 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListIssuesParams) error); ok { | |
| 208 | + | r1 = returnFunc(ctx, repo, params) | |
| 209 | + | } else { | |
| 210 | + | r1 = ret.Error(1) | |
| 211 | + | } | |
| 212 | + | return r0, r1 | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // IssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 216 | + | type IssueStore_List_Call struct { | |
| 217 | + | *mock.Call | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // List is a helper method to define mock.On call | |
| 221 | + | // - ctx context.Context | |
| 222 | + | // - repo domain.RepoName | |
| 223 | + | // - params ports.ListIssuesParams | |
| 224 | + | func (_e *IssueStore_Expecter) List(ctx any, repo any, params any) *IssueStore_List_Call { | |
| 225 | + | return &IssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, params)} | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (_c *IssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams)) *IssueStore_List_Call { | |
| 229 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 230 | + | var arg0 context.Context | |
| 231 | + | if args[0] != nil { | |
| 232 | + | arg0 = args[0].(context.Context) | |
| 233 | + | } | |
| 234 | + | var arg1 domain.RepoName | |
| 235 | + | if args[1] != nil { | |
| 236 | + | arg1 = args[1].(domain.RepoName) | |
| 237 | + | } | |
| 238 | + | var arg2 ports.ListIssuesParams | |
| 239 | + | if args[2] != nil { | |
| 240 | + | arg2 = args[2].(ports.ListIssuesParams) | |
| 241 | + | } | |
| 242 | + | run( | |
| 243 | + | arg0, | |
| 244 | + | arg1, | |
| 245 | + | arg2, | |
| 246 | + | ) | |
| 247 | + | }) | |
| 248 | + | return _c | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (_c *IssueStore_List_Call) Return(issuePage ports.IssuePage, err error) *IssueStore_List_Call { | |
| 252 | + | _c.Call.Return(issuePage, err) | |
| 253 | + | return _c | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (_c *IssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueStore_List_Call { | |
| 257 | + | _c.Call.Return(run) | |
| 258 | + | return _c | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // ListComments provides a mock function for the type IssueStore | |
| 262 | + | func (_mock *IssueStore) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) { | |
| 263 | + | ret := _mock.Called(ctx, repo, number) | |
| 264 | + | ||
| 265 | + | if len(ret) == 0 { | |
| 266 | + | panic("no return value specified for ListComments") | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | var r0 []domain.IssueComment | |
| 270 | + | var r1 error | |
| 271 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueComment, error)); ok { | |
| 272 | + | return returnFunc(ctx, repo, number) | |
| 273 | + | } | |
| 274 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueComment); ok { | |
| 275 | + | r0 = returnFunc(ctx, repo, number) | |
| 276 | + | } else { | |
| 277 | + | if ret.Get(0) != nil { | |
| 278 | + | r0 = ret.Get(0).([]domain.IssueComment) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 282 | + | r1 = returnFunc(ctx, repo, number) | |
| 283 | + | } else { | |
| 284 | + | r1 = ret.Error(1) | |
| 285 | + | } | |
| 286 | + | return r0, r1 | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // IssueStore_ListComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListComments' | |
| 290 | + | type IssueStore_ListComments_Call struct { | |
| 291 | + | *mock.Call | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // ListComments is a helper method to define mock.On call | |
| 295 | + | // - ctx context.Context | |
| 296 | + | // - repo domain.RepoName | |
| 297 | + | // - number int64 | |
| 298 | + | func (_e *IssueStore_Expecter) ListComments(ctx any, repo any, number any) *IssueStore_ListComments_Call { | |
| 299 | + | return &IssueStore_ListComments_Call{Call: _e.mock.On("ListComments", ctx, repo, number)} | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func (_c *IssueStore_ListComments_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_ListComments_Call { | |
| 303 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 304 | + | var arg0 context.Context | |
| 305 | + | if args[0] != nil { | |
| 306 | + | arg0 = args[0].(context.Context) | |
| 307 | + | } | |
| 308 | + | var arg1 domain.RepoName | |
| 309 | + | if args[1] != nil { | |
| 310 | + | arg1 = args[1].(domain.RepoName) | |
| 311 | + | } | |
| 312 | + | var arg2 int64 | |
| 313 | + | if args[2] != nil { | |
| 314 | + | arg2 = args[2].(int64) | |
| 315 | + | } | |
| 316 | + | run( | |
| 317 | + | arg0, | |
| 318 | + | arg1, | |
| 319 | + | arg2, | |
| 320 | + | ) | |
| 321 | + | }) | |
| 322 | + | return _c | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func (_c *IssueStore_ListComments_Call) Return(issueComments []domain.IssueComment, err error) *IssueStore_ListComments_Call { | |
| 326 | + | _c.Call.Return(issueComments, err) | |
| 327 | + | return _c | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func (_c *IssueStore_ListComments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)) *IssueStore_ListComments_Call { | |
| 331 | + | _c.Call.Return(run) | |
| 332 | + | return _c | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | // ListOpenAll provides a mock function for the type IssueStore | |
| 336 | + | func (_mock *IssueStore) ListOpenAll(ctx context.Context) ([]domain.Issue, error) { | |
| 337 | + | ret := _mock.Called(ctx) | |
| 338 | + | ||
| 339 | + | if len(ret) == 0 { | |
| 340 | + | panic("no return value specified for ListOpenAll") | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | var r0 []domain.Issue | |
| 344 | + | var r1 error | |
| 345 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Issue, error)); ok { | |
| 346 | + | return returnFunc(ctx) | |
| 347 | + | } | |
| 348 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Issue); ok { | |
| 349 | + | r0 = returnFunc(ctx) | |
| 350 | + | } else { | |
| 351 | + | if ret.Get(0) != nil { | |
| 352 | + | r0 = ret.Get(0).([]domain.Issue) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 356 | + | r1 = returnFunc(ctx) | |
| 357 | + | } else { | |
| 358 | + | r1 = ret.Error(1) | |
| 359 | + | } | |
| 360 | + | return r0, r1 | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // IssueStore_ListOpenAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenAll' | |
| 364 | + | type IssueStore_ListOpenAll_Call struct { | |
| 365 | + | *mock.Call | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // ListOpenAll is a helper method to define mock.On call | |
| 369 | + | // - ctx context.Context | |
| 370 | + | func (_e *IssueStore_Expecter) ListOpenAll(ctx any) *IssueStore_ListOpenAll_Call { | |
| 371 | + | return &IssueStore_ListOpenAll_Call{Call: _e.mock.On("ListOpenAll", ctx)} | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func (_c *IssueStore_ListOpenAll_Call) Run(run func(ctx context.Context)) *IssueStore_ListOpenAll_Call { | |
| 375 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 376 | + | var arg0 context.Context | |
| 377 | + | if args[0] != nil { | |
| 378 | + | arg0 = args[0].(context.Context) | |
| 379 | + | } | |
| 380 | + | run( | |
| 381 | + | arg0, | |
| 382 | + | ) | |
| 383 | + | }) | |
| 384 | + | return _c | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func (_c *IssueStore_ListOpenAll_Call) Return(issues []domain.Issue, err error) *IssueStore_ListOpenAll_Call { | |
| 388 | + | _c.Call.Return(issues, err) | |
| 389 | + | return _c | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (_c *IssueStore_ListOpenAll_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Issue, error)) *IssueStore_ListOpenAll_Call { | |
| 393 | + | _c.Call.Return(run) | |
| 394 | + | return _c | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // SaveComment provides a mock function for the type IssueStore | |
| 398 | + | func (_mock *IssueStore) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error { | |
| 399 | + | ret := _mock.Called(ctx, issue, c) | |
| 400 | + | ||
| 401 | + | if len(ret) == 0 { | |
| 402 | + | panic("no return value specified for SaveComment") | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | var r0 error | |
| 406 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue, domain.IssueComment) error); ok { | |
| 407 | + | r0 = returnFunc(ctx, issue, c) | |
| 408 | + | } else { | |
| 409 | + | r0 = ret.Error(0) | |
| 410 | + | } | |
| 411 | + | return r0 | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // IssueStore_SaveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveComment' | |
| 415 | + | type IssueStore_SaveComment_Call struct { | |
| 416 | + | *mock.Call | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // SaveComment is a helper method to define mock.On call | |
| 420 | + | // - ctx context.Context | |
| 421 | + | // - issue domain.Issue | |
| 422 | + | // - c domain.IssueComment | |
| 423 | + | func (_e *IssueStore_Expecter) SaveComment(ctx any, issue any, c any) *IssueStore_SaveComment_Call { | |
| 424 | + | return &IssueStore_SaveComment_Call{Call: _e.mock.On("SaveComment", ctx, issue, c)} | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func (_c *IssueStore_SaveComment_Call) Run(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment)) *IssueStore_SaveComment_Call { | |
| 428 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 429 | + | var arg0 context.Context | |
| 430 | + | if args[0] != nil { | |
| 431 | + | arg0 = args[0].(context.Context) | |
| 432 | + | } | |
| 433 | + | var arg1 domain.Issue | |
| 434 | + | if args[1] != nil { | |
| 435 | + | arg1 = args[1].(domain.Issue) | |
| 436 | + | } | |
| 437 | + | var arg2 domain.IssueComment | |
| 438 | + | if args[2] != nil { | |
| 439 | + | arg2 = args[2].(domain.IssueComment) | |
| 440 | + | } | |
| 441 | + | run( | |
| 442 | + | arg0, | |
| 443 | + | arg1, | |
| 444 | + | arg2, | |
| 445 | + | ) | |
| 446 | + | }) | |
| 447 | + | return _c | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (_c *IssueStore_SaveComment_Call) Return(err error) *IssueStore_SaveComment_Call { | |
| 451 | + | _c.Call.Return(err) | |
| 452 | + | return _c | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func (_c *IssueStore_SaveComment_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment) error) *IssueStore_SaveComment_Call { | |
| 456 | + | _c.Call.Return(run) | |
| 457 | + | return _c | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // Update provides a mock function for the type IssueStore | |
| 461 | + | func (_mock *IssueStore) Update(ctx context.Context, issue domain.Issue) error { | |
| 462 | + | ret := _mock.Called(ctx, issue) | |
| 463 | + | ||
| 464 | + | if len(ret) == 0 { | |
| 465 | + | panic("no return value specified for Update") | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | var r0 error | |
| 469 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) error); ok { | |
| 470 | + | r0 = returnFunc(ctx, issue) | |
| 471 | + | } else { | |
| 472 | + | r0 = ret.Error(0) | |
| 473 | + | } | |
| 474 | + | return r0 | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | // IssueStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' | |
| 478 | + | type IssueStore_Update_Call struct { | |
| 479 | + | *mock.Call | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | // Update is a helper method to define mock.On call | |
| 483 | + | // - ctx context.Context | |
| 484 | + | // - issue domain.Issue | |
| 485 | + | func (_e *IssueStore_Expecter) Update(ctx any, issue any) *IssueStore_Update_Call { | |
| 486 | + | return &IssueStore_Update_Call{Call: _e.mock.On("Update", ctx, issue)} | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | func (_c *IssueStore_Update_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Update_Call { | |
| 490 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 491 | + | var arg0 context.Context | |
| 492 | + | if args[0] != nil { | |
| 493 | + | arg0 = args[0].(context.Context) | |
| 494 | + | } | |
| 495 | + | var arg1 domain.Issue | |
| 496 | + | if args[1] != nil { | |
| 497 | + | arg1 = args[1].(domain.Issue) | |
| 498 | + | } | |
| 499 | + | run( | |
| 500 | + | arg0, | |
| 501 | + | arg1, | |
| 502 | + | ) | |
| 503 | + | }) | |
| 504 | + | return _c | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func (_c *IssueStore_Update_Call) Return(err error) *IssueStore_Update_Call { | |
| 508 | + | _c.Call.Return(err) | |
| 509 | + | return _c | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func (_c *IssueStore_Update_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) error) *IssueStore_Update_Call { | |
| 513 | + | _c.Call.Return(run) | |
| 514 | + | return _c | |
| 515 | + | } | |
A internal/ports/mocks/IssueSubmissionService.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewIssueSubmissionService creates a new instance of IssueSubmissionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewIssueSubmissionService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *IssueSubmissionService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &IssueSubmissionService{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // IssueSubmissionService is an autogenerated mock type for the IssueSubmissionService type | |
| 39 | + | type IssueSubmissionService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type IssueSubmissionService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *IssueSubmissionService) EXPECT() *IssueSubmissionService_Expecter { | |
| 48 | + | return &IssueSubmissionService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Challenge provides a mock function for the type IssueSubmissionService | |
| 52 | + | func (_mock *IssueSubmissionService) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) { | |
| 53 | + | ret := _mock.Called(ctx, repo, addr) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Challenge") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 ports.IssueChallenge | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (ports.IssueChallenge, error)); ok { | |
| 62 | + | return returnFunc(ctx, repo, addr) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) ports.IssueChallenge); ok { | |
| 65 | + | r0 = returnFunc(ctx, repo, addr) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(ports.IssueChallenge) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, repo, addr) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // IssueSubmissionService_Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Challenge' | |
| 78 | + | type IssueSubmissionService_Challenge_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Challenge is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - repo domain.RepoName | |
| 85 | + | // - addr string | |
| 86 | + | func (_e *IssueSubmissionService_Expecter) Challenge(ctx any, repo any, addr any) *IssueSubmissionService_Challenge_Call { | |
| 87 | + | return &IssueSubmissionService_Challenge_Call{Call: _e.mock.On("Challenge", ctx, repo, addr)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *IssueSubmissionService_Challenge_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string)) *IssueSubmissionService_Challenge_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 domain.RepoName | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(domain.RepoName) | |
| 99 | + | } | |
| 100 | + | var arg2 string | |
| 101 | + | if args[2] != nil { | |
| 102 | + | arg2 = args[2].(string) | |
| 103 | + | } | |
| 104 | + | run( | |
| 105 | + | arg0, | |
| 106 | + | arg1, | |
| 107 | + | arg2, | |
| 108 | + | ) | |
| 109 | + | }) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *IssueSubmissionService_Challenge_Call) Return(issueChallenge ports.IssueChallenge, err error) *IssueSubmissionService_Challenge_Call { | |
| 114 | + | _c.Call.Return(issueChallenge, err) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *IssueSubmissionService_Challenge_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error)) *IssueSubmissionService_Challenge_Call { | |
| 119 | + | _c.Call.Return(run) | |
| 120 | + | return _c | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Submit provides a mock function for the type IssueSubmissionService | |
| 124 | + | func (_mock *IssueSubmissionService) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error { | |
| 125 | + | ret := _mock.Called(ctx, repo, addr, f) | |
| 126 | + | ||
| 127 | + | if len(ret) == 0 { | |
| 128 | + | panic("no return value specified for Submit") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | var r0 error | |
| 132 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, ports.IssueSubmission) error); ok { | |
| 133 | + | r0 = returnFunc(ctx, repo, addr, f) | |
| 134 | + | } else { | |
| 135 | + | r0 = ret.Error(0) | |
| 136 | + | } | |
| 137 | + | return r0 | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // IssueSubmissionService_Submit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Submit' | |
| 141 | + | type IssueSubmissionService_Submit_Call struct { | |
| 142 | + | *mock.Call | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Submit is a helper method to define mock.On call | |
| 146 | + | // - ctx context.Context | |
| 147 | + | // - repo domain.RepoName | |
| 148 | + | // - addr string | |
| 149 | + | // - f ports.IssueSubmission | |
| 150 | + | func (_e *IssueSubmissionService_Expecter) Submit(ctx any, repo any, addr any, f any) *IssueSubmissionService_Submit_Call { | |
| 151 | + | return &IssueSubmissionService_Submit_Call{Call: _e.mock.On("Submit", ctx, repo, addr, f)} | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (_c *IssueSubmissionService_Submit_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission)) *IssueSubmissionService_Submit_Call { | |
| 155 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 156 | + | var arg0 context.Context | |
| 157 | + | if args[0] != nil { | |
| 158 | + | arg0 = args[0].(context.Context) | |
| 159 | + | } | |
| 160 | + | var arg1 domain.RepoName | |
| 161 | + | if args[1] != nil { | |
| 162 | + | arg1 = args[1].(domain.RepoName) | |
| 163 | + | } | |
| 164 | + | var arg2 string | |
| 165 | + | if args[2] != nil { | |
| 166 | + | arg2 = args[2].(string) | |
| 167 | + | } | |
| 168 | + | var arg3 ports.IssueSubmission | |
| 169 | + | if args[3] != nil { | |
| 170 | + | arg3 = args[3].(ports.IssueSubmission) | |
| 171 | + | } | |
| 172 | + | run( | |
| 173 | + | arg0, | |
| 174 | + | arg1, | |
| 175 | + | arg2, | |
| 176 | + | arg3, | |
| 177 | + | ) | |
| 178 | + | }) | |
| 179 | + | return _c | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (_c *IssueSubmissionService_Submit_Call) Return(err error) *IssueSubmissionService_Submit_Call { | |
| 183 | + | _c.Call.Return(err) | |
| 184 | + | return _c | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (_c *IssueSubmissionService_Submit_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error) *IssueSubmissionService_Submit_Call { | |
| 188 | + | _c.Call.Return(run) | |
| 189 | + | return _c | |
| 190 | + | } | |
M internal/ports/mocks/MergeRequestStore.go +62 -0
| @@ -611,6 +611,68 @@ func (_c *MergeRequestStore_ListOpen_Call) RunAndReturn(run func(ctx context.Con | |||
| 611 | 611 | 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 | 676 | // OpenBySource provides a mock function for the type MergeRequestStore | |
| 615 | 677 | func (_mock *MergeRequestStore) OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) ([]int64, bool, error) { | |
| 616 | 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 | 241 | 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 | 321 | // MergeRequestStore keeps the merge requests of the repositories, their | |
| 245 | 322 | // comments and the read position of the reviewer. A repository has no entry | |
| 246 | 323 | // until its first merge request. Create, SaveComment and SetRead return | |
| @@ -260,6 +337,10 @@ type MergeRequestStore interface { | |||
| 260 | 337 | // ListOpen returns the open merge requests of the repository, highest | |
| 261 | 338 | // number first, reading only the open ones (through an index). | |
| 262 | 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 | 344 | // CountOpen returns the number of open merge requests of the repository | |
| 264 | 345 | // from the index of keys: no request is read. | |
| 265 | 346 | CountOpen(ctx context.Context, repo domain.RepoName) (int, error) | |
M internal/ports/services.go +94 -0
| @@ -124,6 +124,87 @@ type MergeRequestService interface { | |||
| 124 | 124 | CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | + | // IssueService is the issue use cases. *usecase.Issues implements it. | |
| 128 | + | type IssueService interface { | |
| 129 | + | Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateIssueParams) (domain.Issue, error) | |
| 130 | + | List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListIssuesParams) (IssuePage, error) | |
| 131 | + | // Get returns the issue with its comments, oldest first. | |
| 132 | + | Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (IssueDetail, error) | |
| 133 | + | AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) | |
| 134 | + | Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) | |
| 135 | + | Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // IssueModerationService is the owner's moderation of the pending issues. | |
| 139 | + | // Every call needs owner rights and answers domain.ErrNotFound otherwise. | |
| 140 | + | // *usecase.IssueModeration implements it. | |
| 141 | + | type IssueModerationService interface { | |
| 142 | + | // ListPending lists the pending issues of a repository, or of every | |
| 143 | + | // repository when repo is zero, oldest first. | |
| 144 | + | ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ListPendingParams) (PendingIssuePage, error) | |
| 145 | + | // Approve opens the pending issue under a new number. | |
| 146 | + | Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) | |
| 147 | + | // Reject deletes the pending issue and, with ban, bans its submitter. | |
| 148 | + | Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // IssueChallenge is what the public form needs to submit: a signed token, the | |
| 152 | + | // proof-of-work to solve and the timing. | |
| 153 | + | type IssueChallenge struct { | |
| 154 | + | // Token is the signed challenge, sent back untouched. | |
| 155 | + | Token string | |
| 156 | + | // Nonce is the base64url of the 16 random bytes the browser hashes: | |
| 157 | + | // SHA-256(nonce bytes || ":" || decimal counter) must have Difficulty | |
| 158 | + | // leading zero bits. | |
| 159 | + | Nonce string | |
| 160 | + | // Difficulty is the number of leading zero bits required. | |
| 161 | + | Difficulty int | |
| 162 | + | // FormToken is a hidden field of the form, sent back untouched. | |
| 163 | + | FormToken string | |
| 164 | + | // IssuedAt, MinSubmitAt and ExpiresAt bound when the form may be sent. | |
| 165 | + | IssuedAt, MinSubmitAt, ExpiresAt time.Time | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // IssueSubmission is what the browser sends back. | |
| 169 | + | type IssueSubmission struct { | |
| 170 | + | Token string | |
| 171 | + | // Solution is the decimal counter that solves the proof of work. | |
| 172 | + | Solution string | |
| 173 | + | FormToken string | |
| 174 | + | // Honeypot must be empty: a field only a bot fills. | |
| 175 | + | Honeypot string | |
| 176 | + | Title, Body, Author string | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // IssueSubmissionService is the anonymous issue submission. Both calls answer | |
| 180 | + | // domain.ErrNotFound for a repository that is unknown, not public or without | |
| 181 | + | // public issues. Submit refuses with an error matching | |
| 182 | + | // domain.ErrSubmissionRefused (ErrSubmissionRateLimited, ErrSubmissionsPaused, | |
| 183 | + | // ErrSubmissionInvalid) or with a *domain.TextError the user can fix. | |
| 184 | + | // addr is the client key (clientlimit.Key of the address). | |
| 185 | + | // *usecase.IssueSubmissions implements it. | |
| 186 | + | type IssueSubmissionService interface { | |
| 187 | + | Challenge(ctx context.Context, repo domain.RepoName, addr string) (IssueChallenge, error) | |
| 188 | + | Submit(ctx context.Context, repo domain.RepoName, addr string, f IssueSubmission) error | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // IssueNumberResolver tells whether a number of a repository names an issue or | |
| 192 | + | // a merge request, for the callers that follow a "#n" reference. *usecase.Issues | |
| 193 | + | // implements it. | |
| 194 | + | type IssueNumberResolver interface { | |
| 195 | + | // Kind needs the access of reading the repository and answers | |
| 196 | + | // domain.ErrNotFound otherwise, also when issues are off. | |
| 197 | + | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // InboxService is the owner's inbox: what is open across every repository. | |
| 201 | + | // *usecase.Inbox implements it. | |
| 202 | + | type InboxService interface { | |
| 203 | + | // Overview needs owner rights and answers domain.ErrNotFound to anyone | |
| 204 | + | // else. The filter, when not zero, restricts the lists to that repository. | |
| 205 | + | Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (InboxOverview, error) | |
| 206 | + | } | |
| 207 | + | ||
| 127 | 208 | // SigningKeyService tells which key signs the commits of the forge. | |
| 128 | 209 | // *usecase.SigningKey implements it. | |
| 129 | 210 | type SigningKeyService interface { | |
| @@ -237,6 +318,19 @@ type FailureLimiter interface { | |||
| 237 | 318 | Fail(ctx context.Context, addr string) | |
| 238 | 319 | } | |
| 239 | 320 | ||
| 321 | + | // SubmissionLimiter limits the submissions of the public issue form per | |
| 322 | + | // client. *clientlimit.SubmissionLimiter implements it. | |
| 323 | + | type SubmissionLimiter interface { | |
| 324 | + | // Reserve atomically checks the limits for addr and, if it may submit, | |
| 325 | + | // counts a submission. When it may not, retryAfter says when to retry. | |
| 326 | + | // release refunds the reservation (only when the submission was not | |
| 327 | + | // stored); it is never nil and safe to call once. | |
| 328 | + | Reserve(ctx context.Context, addr string) (ok bool, retryAfter time.Duration, release func()) | |
| 329 | + | // Recent returns the submissions of the last hour from addr and from all | |
| 330 | + | // clients. | |
| 331 | + | Recent(ctx context.Context, addr string) (perAddr, global int) | |
| 332 | + | } | |
| 333 | + | ||
| 240 | 334 | // SecretPage is one page of secrets. | |
| 241 | 335 | type SecretPage struct { | |
| 242 | 336 | Secrets []domain.Secret | |
M internal/usecase/events.go +96 -3
| @@ -38,6 +38,10 @@ const ( | |||
| 38 | 38 | const ( | |
| 39 | 39 | // TopicHome follows every repository the caller may see. | |
| 40 | 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 | 45 | // topicRepoPrefix starts the topic of one repository: "repo:<name>". | |
| 42 | 46 | topicRepoPrefix = "repo:" | |
| 43 | 47 | ) | |
| @@ -178,6 +182,16 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 178 | 182 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 179 | 183 | case domain.CommentAdded: | |
| 180 | 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 | 195 | case domain.RunUpdated: | |
| 182 | 196 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 183 | 197 | case domain.JobUpdated: | |
| @@ -258,6 +272,7 @@ func (e *Events) parseID(id string) (uint64, bool) { | |||
| 258 | 272 | type watch struct { | |
| 259 | 273 | p domain.Principal | |
| 260 | 274 | home bool | |
| 275 | + | owner bool // the owner topic: merge request, issue, repo update and deletion events of every repository | |
| 261 | 276 | repos map[domain.RepoName]bool | |
| 262 | 277 | ||
| 263 | 278 | checked time.Time // when the token store last confirmed the principal | |
| @@ -265,6 +280,12 @@ type watch struct { | |||
| 265 | 280 | ||
| 266 | 281 | func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] } | |
| 267 | 282 | ||
| 283 | + | // wantsEvent is wants for an event: the owner topic also follows the events | |
| 284 | + | // about merge requests and issues of every repository. | |
| 285 | + | func (w *watch) wantsEvent(name domain.RepoName, ev domain.Event) bool { | |
| 286 | + | return w.wants(name) || (w.owner && (isItemEvent(ev) || isRepoLifecycleEvent(ev))) | |
| 287 | + | } | |
| 288 | + | ||
| 268 | 289 | // parseTopics validates the topics of a watch. A repository topic needs a | |
| 269 | 290 | // repository the principal may read (else domain.ErrNotFound); an unknown | |
| 270 | 291 | // topic, or more than MaxWatchTopics, is domain.ErrInvalid. | |
| @@ -281,8 +302,14 @@ func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []s | |||
| 281 | 302 | w.home = true | |
| 282 | 303 | continue | |
| 283 | 304 | } | |
| 305 | + | if t == TopicOwner && p.HasOwnerRights() { | |
| 306 | + | w.owner = true | |
| 307 | + | continue | |
| 308 | + | } | |
| 284 | 309 | raw, ok := strings.CutPrefix(t, topicRepoPrefix) | |
| 285 | 310 | if !ok { | |
| 311 | + | // Includes "owner" for anyone but the owner: the same error as | |
| 312 | + | // any unknown topic. | |
| 286 | 313 | return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome) | |
| 287 | 314 | } | |
| 288 | 315 | name, err := domain.ParseRepoName(raw) | |
| @@ -419,7 +446,10 @@ func (e *Events) idleCheck() time.Duration { | |||
| 419 | 446 | ||
| 420 | 447 | // stillAllowed checks that the holder of a stored token may still watch: the | |
| 421 | 448 | // token has not expired and, at most every few seconds, still exists. Root and | |
| 422 | - | // anonymous callers have nothing to lose. | |
| 449 | + | // anonymous callers have nothing to lose. The owner topic needs nothing more: | |
| 450 | + | // a stored token with owner rights loses them only by ceasing to exist, which | |
| 451 | + | // is checked here, and the owner's web session, which this use case cannot see, | |
| 452 | + | // is checked by the web adapter before it sends anything. | |
| 423 | 453 | func (e *Events) stillAllowed(ctx context.Context, w *watch) error { | |
| 424 | 454 | if w.p.TokenID() == "" { | |
| 425 | 455 | return nil | |
| @@ -467,10 +497,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 467 | 497 | return send(ports.WatchEvent{Gap: true}) | |
| 468 | 498 | case domain.RepoDeleted: | |
| 469 | 499 | // The repository is gone: use the visibility it had. | |
| 470 | - | if !w.wants(ev.Repo) || !canReadAs(w.p, ev.Repo, ev.Visibility) { | |
| 500 | + | if !w.wantsEvent(ev.Repo, ev) || !canReadAs(w.p, ev.Repo, ev.Visibility) { | |
| 471 | 501 | return nil | |
| 472 | 502 | } | |
| 473 | 503 | return send(out) | |
| 504 | + | case domain.IssueSubmitted: | |
| 505 | + | return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send) | |
| 506 | + | case domain.IssuePendingRemoved: | |
| 507 | + | return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send) | |
| 474 | 508 | case domain.RepoCreated: | |
| 475 | 509 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | |
| 476 | 510 | case domain.RepoUpdated: | |
| @@ -487,6 +521,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 487 | 521 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 488 | 522 | case domain.CommentAdded: | |
| 489 | 523 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 524 | + | case domain.IssueOpened: | |
| 525 | + | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | |
| 526 | + | case domain.IssueClosed: | |
| 527 | + | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | |
| 528 | + | case domain.IssueReopened: | |
| 529 | + | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | |
| 530 | + | case domain.IssueCommented: | |
| 531 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 490 | 532 | case domain.RunUpdated: | |
| 491 | 533 | // Home shows the CI state of each repository. | |
| 492 | 534 | return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send) | |
| @@ -502,6 +544,25 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 502 | 544 | return nil // an event this version does not stream | |
| 503 | 545 | } | |
| 504 | 546 | ||
| 547 | + | // deliverOwnerOnly sends an owner-topic event: never home, never a repository | |
| 548 | + | // topic, not even to an owner who watches the repository. No content travels. | |
| 549 | + | func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.RepoName, out ports.WatchEvent, send func(ports.WatchEvent) error) error { | |
| 550 | + | if !w.owner { | |
| 551 | + | return nil | |
| 552 | + | } | |
| 553 | + | repo, err := lookupRepo(ctx, e.repos.meta, name) | |
| 554 | + | switch { | |
| 555 | + | case errors.Is(err, domain.ErrNotFound), ctx.Err() != nil: | |
| 556 | + | return nil | |
| 557 | + | case err != nil: | |
| 558 | + | return fmt.Errorf("check access to %q: %w", name, err) | |
| 559 | + | } | |
| 560 | + | if repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 561 | + | return nil | |
| 562 | + | } | |
| 563 | + | return send(out) | |
| 564 | + | } | |
| 565 | + | ||
| 505 | 566 | // deliverRepo sends an event about a repository if the watcher could read the | |
| 506 | 567 | // repository when the event happened and can read it now, and the repository | |
| 507 | 568 | // is still the one the event is about. A repository that no longer exists is | |
| @@ -511,7 +572,7 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 511 | 572 | // read it before are told once, with its name and its new visibility only, | |
| 512 | 573 | // so that what they show can be removed. | |
| 513 | 574 | func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error { | |
| 514 | - | if !w.wants(name) || !be.known { | |
| 575 | + | if !w.wantsEvent(name, be.ev) || !be.known { | |
| 515 | 576 | return nil | |
| 516 | 577 | } | |
| 517 | 578 | repo, err := lookupRepo(ctx, e.repos.meta, name) | |
| @@ -536,6 +597,9 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||
| 536 | 597 | if !canRead(w.p, repo) { | |
| 537 | 598 | return nil | |
| 538 | 599 | } | |
| 600 | + | if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 601 | + | return nil // issues were turned off since: a replay must not show them | |
| 602 | + | } | |
| 539 | 603 | if withRepo { | |
| 540 | 604 | view, err := e.repos.View(ctx, repo) | |
| 541 | 605 | if err != nil { | |
| @@ -545,3 +609,32 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||
| 545 | 609 | } | |
| 546 | 610 | return send(out) | |
| 547 | 611 | } | |
| 612 | + | ||
| 613 | + | // isIssueEvent reports whether ev is about an issue. | |
| 614 | + | func isIssueEvent(ev domain.Event) bool { | |
| 615 | + | switch ev.(type) { | |
| 616 | + | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented: | |
| 617 | + | return true | |
| 618 | + | } | |
| 619 | + | return false | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | // isRepoLifecycleEvent reports whether ev is a repository update or deletion: | |
| 623 | + | // the owner topic follows them, for they change what the inbox lists. | |
| 624 | + | func isRepoLifecycleEvent(ev domain.Event) bool { | |
| 625 | + | switch ev.(type) { | |
| 626 | + | case domain.RepoUpdated, domain.RepoDeleted: | |
| 627 | + | return true | |
| 628 | + | } | |
| 629 | + | return false | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // isItemEvent reports whether ev is about a merge request or an issue. | |
| 633 | + | func isItemEvent(ev domain.Event) bool { | |
| 634 | + | switch ev.(type) { | |
| 635 | + | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | |
| 636 | + | domain.MergeRequestReady, domain.CommentAdded: | |
| 637 | + | return true | |
| 638 | + | } | |
| 639 | + | return isIssueEvent(ev) | |
| 640 | + | } | |
A internal/usecase/events_owner_test.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "testing/synctest" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func TestWatchOwnerTopicIsUnknownToEveryoneElse(t *testing.T) { | |
| 22 | + | t.Parallel() | |
| 23 | + | r := newEventRig(t, 0) | |
| 24 | + | noop := func(ports.WatchEvent) error { return nil } | |
| 25 | + | unknown := r.events.Watch(context.Background(), domain.Anonymous(), ports.WatchParams{Topics: []string{"nope"}}, noop) | |
| 26 | + | require.ErrorIs(t, unknown, domain.ErrInvalid) | |
| 27 | + | ||
| 28 | + | partial := tokenWith("t1", now.Add(0), "pub") // limited to a repository | |
| 29 | + | for _, p := range []domain.Principal{domain.Anonymous(), partial, issuePrincipal(domain.ScopeIssueWrite)} { | |
| 30 | + | err := r.events.Watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}}, noop) | |
| 31 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 32 | + | assert.Equal(t, strings.ReplaceAll(unknown.Error(), "nope", "owner"), err.Error(), "same answer as an unknown topic") | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestWatchOwnerTopicSpansRepositoriesAndHonoursIssuesOff(t *testing.T) { | |
| 37 | + | t.Parallel() | |
| 38 | + | synctest.Test(t, func(t *testing.T) { | |
| 39 | + | r := newEventRig(t, 16) | |
| 40 | + | a, b := name(t, "aaa"), name(t, "bbb") | |
| 41 | + | var mu sync.Mutex | |
| 42 | + | bMode := domain.IssuesOwner | |
| 43 | + | r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil) | |
| 44 | + | r.meta.EXPECT().Get(mock.Anything, b).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |
| 45 | + | mu.Lock() | |
| 46 | + | defer mu.Unlock() | |
| 47 | + | return domain.Repo{Name: b, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(bMode)}, nil | |
| 48 | + | }) | |
| 49 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 50 | + | defer cancel() | |
| 51 | + | w := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 52 | + | synctest.Wait() | |
| 53 | + | ||
| 54 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 55 | + | _, ok := w.next(t).Event.(domain.IssueOpened) | |
| 56 | + | require.True(t, ok) | |
| 57 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 58 | + | _, ok = w.next(t).Event.(domain.MergeRequestOpened) | |
| 59 | + | require.True(t, ok, "a second repository") | |
| 60 | + | ||
| 61 | + | // Pushes are not inbox news. | |
| 62 | + | r.feed <- r.pushEvent(a, sentinelOID, "refs/heads/x") | |
| 63 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 3}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 64 | + | _, ok = w.next(t).Event.(domain.IssueOpened) | |
| 65 | + | require.True(t, ok, "the push was skipped") | |
| 66 | + | ||
| 67 | + | mu.Lock() | |
| 68 | + | bMode = domain.IssuesOff | |
| 69 | + | mu.Unlock() | |
| 70 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: b, Number: 4}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 71 | + | r.feed <- domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 72 | + | e := w.next(t) | |
| 73 | + | _, ok = e.Event.(domain.MergeRequestClosed) | |
| 74 | + | require.True(t, ok, "the issue event of the issues-off repository is not delivered, got %T", e.Event) | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func withIssues(m domain.IssuesMode) domain.RepoSettings { | |
| 79 | + | st := domain.DefaultRepoSettings() | |
| 80 | + | st.Issues = m | |
| 81 | + | return st | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func allScopesToken(mutate func(*domain.Token)) domain.Principal { | |
| 85 | + | tok := domain.Token{ID: "tok", Scopes: domain.AllScopes()} | |
| 86 | + | if mutate != nil { | |
| 87 | + | mutate(&tok) | |
| 88 | + | } | |
| 89 | + | return domain.TokenPrincipal(tok) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestWatchOwnerTopicAccessMatrix(t *testing.T) { | |
| 93 | + | t.Parallel() | |
| 94 | + | missingOne := domain.AllScopes()[1:] | |
| 95 | + | cases := []struct { | |
| 96 | + | name string | |
| 97 | + | p domain.Principal | |
| 98 | + | allowed bool | |
| 99 | + | }{ | |
| 100 | + | {"root", domain.Root(), true}, | |
| 101 | + | {"owner session", domain.Owner(), true}, | |
| 102 | + | {"unrestricted all-scope token", allScopesToken(nil), true}, | |
| 103 | + | {"token missing one scope", allScopesToken(func(tk *domain.Token) { tk.Scopes = missingOne }), false}, | |
| 104 | + | {"repo-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{name(t, "aaa")} }), false}, | |
| 105 | + | {"branch-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }), false}, | |
| 106 | + | {"anonymous", domain.Anonymous(), false}, | |
| 107 | + | } | |
| 108 | + | for _, tc := range cases { | |
| 109 | + | t.Run(tc.name, func(t *testing.T) { | |
| 110 | + | t.Parallel() | |
| 111 | + | synctest.Test(t, func(t *testing.T) { | |
| 112 | + | r := newEventRig(t, 16) | |
| 113 | + | a, b := name(t, "aaa"), name(t, "bbb") | |
| 114 | + | for _, n := range []domain.RepoName{a, b} { | |
| 115 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil).Maybe() | |
| 116 | + | } | |
| 117 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 118 | + | defer cancel() | |
| 119 | + | noop := func(ports.WatchEvent) error { return nil } | |
| 120 | + | if !tc.allowed { | |
| 121 | + | err := r.events.Watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}, noop) | |
| 122 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 123 | + | assert.EqualError(t, err, domain.ErrInvalid.Error()+`: unknown topic "owner" (want "home" or "repo:<name>")`) | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | w := r.watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}) | |
| 127 | + | synctest.Wait() | |
| 128 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 129 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 130 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 131 | + | assert.Equal(t, a, w.next(t).Event.(domain.IssueOpened).Issue.Repo) | |
| 132 | + | assert.Equal(t, b, w.next(t).Event.(domain.MergeRequestOpened).MergeRequest.Repo) | |
| 133 | + | }) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestWatchOwnerTopicFollowsRepoUpdatedAndDeleted(t *testing.T) { | |
| 139 | + | t.Parallel() | |
| 140 | + | synctest.Test(t, func(t *testing.T) { | |
| 141 | + | r := newEventRig(t, 16) | |
| 142 | + | a := r.repo("aaa", domain.VisibilityPrivate) | |
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 144 | + | defer cancel() | |
| 145 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 146 | + | synctest.Wait() | |
| 147 | + | r.feed <- domain.RepoUpdated{Repo: a, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now} | |
| 148 | + | r.feed <- domain.RepoDeleted{Repo: a, Visibility: domain.VisibilityPrivate, At: now} | |
| 149 | + | _, ok := owner.next(t).Event.(domain.RepoUpdated) | |
| 150 | + | require.True(t, ok) | |
| 151 | + | _, ok = owner.next(t).Event.(domain.RepoDeleted) | |
| 152 | + | require.True(t, ok) | |
| 153 | + | }) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestWatchOwnerTopicEndsWhenTheTokenIsRevoked(t *testing.T) { | |
| 157 | + | t.Parallel() | |
| 158 | + | synctest.Test(t, func(t *testing.T) { | |
| 159 | + | r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1}) | |
| 160 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 161 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 162 | + | defer cancel() | |
| 163 | + | w := r.watch(ctx, allScopesToken(nil), ports.WatchParams{Topics: []string{"owner"}}) | |
| 164 | + | synctest.Wait() | |
| 165 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 166 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 167 | + | w.next(t) | |
| 168 | + | ||
| 169 | + | r.revoked.Store("tok", true) | |
| 170 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 171 | + | synctest.Wait() | |
| 172 | + | select { | |
| 173 | + | case err := <-w.done: | |
| 174 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 175 | + | default: | |
| 176 | + | t.Fatal("the owner watch of a revoked token did not end") | |
| 177 | + | } | |
| 178 | + | assert.Empty(t, w.got, "nothing was sent after the revocation") | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestWatchOwnerTopicEndsWhenTheTokenExpires(t *testing.T) { | |
| 183 | + | t.Parallel() | |
| 184 | + | synctest.Test(t, func(t *testing.T) { | |
| 185 | + | r := newEventRig(t, 0) | |
| 186 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 187 | + | // The rig's clock stands at now: this token expired at now. | |
| 188 | + | p := allScopesToken(func(tk *domain.Token) { tk.ExpiresAt = now }) | |
| 189 | + | w := r.watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}}) | |
| 190 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 191 | + | synctest.Wait() | |
| 192 | + | select { | |
| 193 | + | case err := <-w.done: | |
| 194 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 195 | + | default: | |
| 196 | + | t.Fatal("the owner watch of an expired token did not end") | |
| 197 | + | } | |
| 198 | + | assert.Empty(t, w.got) | |
| 199 | + | }) | |
| 200 | + | } | |
M internal/usecase/events_test.go +43 -0
| @@ -206,6 +206,49 @@ func TestWatchRechecksAccessPerEvent(t *testing.T) { | |||
| 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 | 252 | func TestWatchResumeAndGap(t *testing.T) { | |
| 210 | 253 | t.Parallel() | |
| 211 | 254 | const size = 8 | |
M internal/usecase/export_test.go +34 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "crypto/rsa" | |
| 8 | 8 | "slices" | |
| 9 | + | "strconv" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 12 | "bnema.dev/ermite-actions/expr" | |
| @@ -130,3 +131,36 @@ func (o *OIDC) SignForTest(claims map[string]any) (string, error) { | |||
| 130 | 131 | } | |
| 131 | 132 | return o.sign(out) | |
| 132 | 133 | } | |
| 134 | + | ||
| 135 | + | // DifficultyFor exposes the adaptive difficulty with the production base and | |
| 136 | + | // cap to the tests. | |
| 137 | + | func DifficultyFor(perAddr, global int) int { | |
| 138 | + | return difficultyFor(baseDifficulty, maxDifficulty, perAddr, global) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Production parameters of the submission, for the tests. | |
| 142 | + | const ( | |
| 143 | + | BaseDifficulty = baseDifficulty | |
| 144 | + | MaxDifficulty = maxDifficulty | |
| 145 | + | MinFillTime = minFillTime | |
| 146 | + | ChallengeTTL = challengeTTL | |
| 147 | + | ) | |
| 148 | + | ||
| 149 | + | // SpentLen is the number of nonces the submission remembers as spent. | |
| 150 | + | func (s *IssueSubmissions) SpentLen() int { | |
| 151 | + | s.mu.Lock() | |
| 152 | + | defer s.mu.Unlock() | |
| 153 | + | return len(s.spent) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // FillSpent fills the spent set with n unexpired nonces. | |
| 157 | + | func (s *IssueSubmissions) FillSpent(n int, exp time.Time) { | |
| 158 | + | s.mu.Lock() | |
| 159 | + | defer s.mu.Unlock() | |
| 160 | + | for i := range n { | |
| 161 | + | s.spent["fill"+strconv.Itoa(i)] = exp | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // MaxSpent is the bound of the spent set. | |
| 166 | + | const MaxSpent = maxSpent | |
A internal/usecase/inbox.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of | |
| 18 | + | // every repository in one place. Only the owner may read it; anyone else | |
| 19 | + | // gets domain.ErrNotFound, as for a page that does not exist. The issues of a | |
| 20 | + | // repository whose issues mode is off are left out. | |
| 21 | + | type Inbox struct { | |
| 22 | + | meta ports.RepoMetaStore | |
| 23 | + | mrs ports.MergeRequestStore | |
| 24 | + | issues ports.IssueStore | |
| 25 | + | // pending is nil when the public form is not wired. | |
| 26 | + | pending ports.PendingIssueStore | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | var _ ports.InboxService = (*Inbox)(nil) | |
| 30 | + | ||
| 31 | + | // InboxConfig holds what the inbox needs. Every field is required. | |
| 32 | + | type InboxConfig struct { | |
| 33 | + | Meta ports.RepoMetaStore | |
| 34 | + | MergeRequests ports.MergeRequestStore | |
| 35 | + | Issues ports.IssueStore | |
| 36 | + | // Pending lists the issues awaiting moderation. Optional: without it the | |
| 37 | + | // inbox has no pending section. | |
| 38 | + | Pending ports.PendingIssueStore | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // NewInbox builds the inbox use case. | |
| 42 | + | func NewInbox(cfg InboxConfig) *Inbox { | |
| 43 | + | return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Overview lists what is open, through the indexes of open items. The counts | |
| 47 | + | // and the list of repositories do not depend on the filter, the lists do. | |
| 48 | + | func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) { | |
| 49 | + | var out ports.InboxOverview | |
| 50 | + | if !p.HasOwnerRights() { | |
| 51 | + | return out, domain.ErrNotFound | |
| 52 | + | } | |
| 53 | + | mrs, err := in.mrs.ListOpenAll(ctx) | |
| 54 | + | if err != nil { | |
| 55 | + | return out, fmt.Errorf("list open merge requests: %w", err) | |
| 56 | + | } | |
| 57 | + | issues, err := in.issues.ListOpenAll(ctx) | |
| 58 | + | if err != nil { | |
| 59 | + | return out, fmt.Errorf("list open issues: %w", err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // exists and issuesOn cache what is read of each repository. | |
| 63 | + | issuesOn := make(map[domain.RepoName]bool) | |
| 64 | + | exists := make(map[domain.RepoName]bool) | |
| 65 | + | look := func(name domain.RepoName) error { | |
| 66 | + | if _, ok := exists[name]; ok { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | repo, err := lookupRepo(ctx, in.meta, name) | |
| 70 | + | switch { | |
| 71 | + | case errors.Is(err, domain.ErrNotFound): | |
| 72 | + | exists[name] = false | |
| 73 | + | case err != nil: | |
| 74 | + | return fmt.Errorf("read repository %q: %w", name, err) | |
| 75 | + | default: | |
| 76 | + | exists[name] = true | |
| 77 | + | issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | repos := make(map[domain.RepoName]bool) | |
| 83 | + | for _, m := range mrs { | |
| 84 | + | if err := look(m.Repo); err != nil { | |
| 85 | + | return out, err | |
| 86 | + | } | |
| 87 | + | if !exists[m.Repo] { | |
| 88 | + | continue | |
| 89 | + | } | |
| 90 | + | repos[m.Repo] = true | |
| 91 | + | out.Counts.MergeRequests++ | |
| 92 | + | if filter.IsZero() || m.Repo == filter { | |
| 93 | + | out.MergeRequests = append(out.MergeRequests, m) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | for _, i := range issues { | |
| 97 | + | if err := look(i.Repo); err != nil { | |
| 98 | + | return out, err | |
| 99 | + | } | |
| 100 | + | if !exists[i.Repo] || !issuesOn[i.Repo] { | |
| 101 | + | continue | |
| 102 | + | } | |
| 103 | + | repos[i.Repo] = true | |
| 104 | + | out.Counts.Issues++ | |
| 105 | + | if filter.IsZero() || i.Repo == filter { | |
| 106 | + | out.Issues = append(out.Issues, i) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | if in.pending != nil { | |
| 111 | + | page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues}) | |
| 112 | + | if err != nil { | |
| 113 | + | return out, fmt.Errorf("list pending issues: %w", err) | |
| 114 | + | } | |
| 115 | + | for _, pi := range page.Issues { | |
| 116 | + | if err := look(pi.Repo); err != nil { | |
| 117 | + | return out, err | |
| 118 | + | } | |
| 119 | + | if !exists[pi.Repo] || !issuesOn[pi.Repo] { | |
| 120 | + | continue | |
| 121 | + | } | |
| 122 | + | pi.IPHash = nil // the hash is for bans, the page never needs it | |
| 123 | + | out.Pending = append(out.Pending, pi) | |
| 124 | + | repos[pi.Repo] = true | |
| 125 | + | } | |
| 126 | + | out.Counts.Pending = len(out.Pending) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int { | |
| 130 | + | return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number) | |
| 131 | + | }) | |
| 132 | + | slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int { | |
| 133 | + | return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number) | |
| 134 | + | }) | |
| 135 | + | for r := range repos { | |
| 136 | + | out.Repos = append(out.Repos, r) | |
| 137 | + | } | |
| 138 | + | slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) }) | |
| 139 | + | return out, nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // compareRecent orders by update time, most recent first (the creation time | |
| 143 | + | // stands in for a missing one), then by repository and number. | |
| 144 | + | func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int { | |
| 145 | + | if aUp.IsZero() { | |
| 146 | + | aUp = aCr | |
| 147 | + | } | |
| 148 | + | if bUp.IsZero() { | |
| 149 | + | bUp = bCr | |
| 150 | + | } | |
| 151 | + | if c := bUp.Compare(aUp); c != 0 { | |
| 152 | + | return c | |
| 153 | + | } | |
| 154 | + | if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 { | |
| 155 | + | return c | |
| 156 | + | } | |
| 157 | + | switch { | |
| 158 | + | case aN > bN: | |
| 159 | + | return -1 | |
| 160 | + | case aN < bN: | |
| 161 | + | return 1 | |
| 162 | + | } | |
| 163 | + | return 0 | |
| 164 | + | } | |
A internal/usecase/inbox_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func inboxMR(t *testing.T, repo string, n int64, up time.Time) domain.MergeRequest { | |
| 21 | + | t.Helper() | |
| 22 | + | ti, err := domain.ParseMRTitle("mr") | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | return domain.MergeRequest{Repo: name(t, repo), Number: n, Title: ti, State: domain.MRStateOpen, UpdatedAt: up} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func inboxIssue(t *testing.T, repo string, n int64, up time.Time) domain.Issue { | |
| 28 | + | t.Helper() | |
| 29 | + | ti, err := domain.ParseIssueTitle("issue") | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | return domain.Issue{Repo: name(t, repo), Number: n, Title: ti, State: domain.IssueStateOpen, UpdatedAt: up} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newInboxUnit(t *testing.T) (*usecase.Inbox, *mocks.MergeRequestStore, *mocks.IssueStore, *mocks.RepoMetaStore) { | |
| 35 | + | t.Helper() | |
| 36 | + | mrs, is, meta := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t) | |
| 37 | + | return usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is}), mrs, is, meta | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func repoWithIssues(t *testing.T, n string, mode domain.IssuesMode) domain.Repo { | |
| 41 | + | s := domain.DefaultRepoSettings() | |
| 42 | + | s.Issues = mode | |
| 43 | + | return domain.Repo{Name: name(t, n), Visibility: domain.VisibilityPrivate, Settings: s} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestInboxIsOwnerOnly(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | in, _, _, _ := newInboxUnit(t) // the stores are never read | |
| 49 | + | for _, p := range []domain.Principal{domain.Anonymous(), issuePrincipal(domain.ScopeIssueWrite)} { | |
| 50 | + | _, err := in.Overview(context.Background(), p, domain.RepoName{}) | |
| 51 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | ctx := context.Background() | |
| 58 | + | t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | |
| 59 | + | in, mrs, is, meta := newInboxUnit(t) | |
| 60 | + | mrs.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{ | |
| 61 | + | inboxMR(t, "alpha", 1, t0), inboxMR(t, "beta", 1, t0.Add(time.Hour)), inboxMR(t, "gone", 1, t0), | |
| 62 | + | }, nil) | |
| 63 | + | is.EXPECT().ListOpenAll(mock.Anything).Return([]domain.Issue{ | |
| 64 | + | inboxIssue(t, "alpha", 2, t0), inboxIssue(t, "beta", 2, t0.Add(2*time.Hour)), inboxIssue(t, "quiet", 2, t0), | |
| 65 | + | }, nil) | |
| 66 | + | meta.EXPECT().Get(mock.Anything, name(t, "alpha")).Return(repoWithIssues(t, "alpha", domain.IssuesOwner), nil) | |
| 67 | + | meta.EXPECT().Get(mock.Anything, name(t, "beta")).Return(repoWithIssues(t, "beta", domain.IssuesPublic), nil) | |
| 68 | + | meta.EXPECT().Get(mock.Anything, name(t, "quiet")).Return(repoWithIssues(t, "quiet", domain.IssuesOff), nil) | |
| 69 | + | meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound) | |
| 70 | + | ||
| 71 | + | got, err := in.Overview(ctx, domain.Owner(), domain.RepoName{}) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts) | |
| 74 | + | require.Len(t, got.MergeRequests, 2) | |
| 75 | + | assert.Equal(t, "beta", got.MergeRequests[0].Repo.String(), "most recently updated first") | |
| 76 | + | require.Len(t, got.Issues, 2) | |
| 77 | + | assert.Equal(t, "beta", got.Issues[0].Repo.String()) | |
| 78 | + | assert.Equal(t, []domain.RepoName{name(t, "alpha"), name(t, "beta")}, got.Repos) | |
| 79 | + | ||
| 80 | + | got, err = in.Overview(ctx, domain.Root(), name(t, "alpha")) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts, "counts ignore the filter") | |
| 83 | + | require.Len(t, got.MergeRequests, 1) | |
| 84 | + | require.Len(t, got.Issues, 1) | |
| 85 | + | assert.Equal(t, "alpha", got.Issues[0].Repo.String()) | |
| 86 | + | assert.Len(t, got.Repos, 2, "the filter list keeps every repository") | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) { | |
| 90 | + | t.Parallel() | |
| 91 | + | ctx := context.Background() | |
| 92 | + | mrs, is, meta, pending := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewPendingIssueStore(t) | |
| 93 | + | in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is, Pending: pending}) | |
| 94 | + | mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil) | |
| 95 | + | is.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil) | |
| 96 | + | meta.EXPECT().Get(mock.Anything, name(t, "on")).Return(repoWithIssues(t, "on", domain.IssuesPublic), nil) | |
| 97 | + | meta.EXPECT().Get(mock.Anything, name(t, "off")).Return(repoWithIssues(t, "off", domain.IssuesOff), nil) | |
| 98 | + | ti, err := domain.ParseIssueTitle("hi") | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | pending.EXPECT().List(mock.Anything, domain.RepoName{}, mock.Anything).Return(ports.PendingIssuePage{Issues: []domain.PendingIssue{ | |
| 101 | + | {ID: "a", Repo: name(t, "on"), Title: ti, IPHash: []byte("secret")}, | |
| 102 | + | {ID: "b", Repo: name(t, "off"), Title: ti}, | |
| 103 | + | }}, nil) | |
| 104 | + | ov, err := in.Overview(ctx, domain.Owner(), name(t, "other")) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | assert.Equal(t, 1, ov.Counts.Pending, "issues off hides pending ones too") | |
| 107 | + | require.Len(t, ov.Pending, 1) | |
| 108 | + | assert.Nil(t, ov.Pending[0].IPHash) | |
| 109 | + | assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos) | |
| 110 | + | } | |
A internal/usecase/issue_keys.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/hkdf" | |
| 8 | + | "crypto/hmac" | |
| 9 | + | "crypto/sha256" | |
| 10 | + | "fmt" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // HKDF info strings of the subkeys of the issue form key. Each purpose has its | |
| 17 | + | // own key, so that a value signed for one can never pass for another. | |
| 18 | + | const ( | |
| 19 | + | infoIPHash = "ermite issue ip-hash" | |
| 20 | + | infoPoW = "ermite issue pow" | |
| 21 | + | infoFormToken = "ermite issue form-token" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // IssueKeys holds the subkeys of the public issue form, derived with | |
| 25 | + | // HKDF-SHA256 from the server key of the IssueKeyStore. | |
| 26 | + | type IssueKeys struct { | |
| 27 | + | ipHash, pow, formToken []byte | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // NewIssueKeys loads the server key (generated on the first call) and derives | |
| 31 | + | // the subkeys. | |
| 32 | + | func NewIssueKeys(ctx context.Context, store ports.IssueKeyStore) (*IssueKeys, error) { | |
| 33 | + | master, err := store.Key(ctx) | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, fmt.Errorf("load issue key: %w", err) | |
| 36 | + | } | |
| 37 | + | k := &IssueKeys{} | |
| 38 | + | for _, d := range []struct { | |
| 39 | + | dst *[]byte | |
| 40 | + | info string | |
| 41 | + | }{{&k.ipHash, infoIPHash}, {&k.pow, infoPoW}, {&k.formToken, infoFormToken}} { | |
| 42 | + | if *d.dst, err = hkdf.Key(sha256.New, master, nil, d.info, sha256.Size); err != nil { | |
| 43 | + | return nil, fmt.Errorf("derive %q: %w", d.info, err) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | return k, nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // IPHash returns HMAC-SHA256 of the client key (clientlimit.Key of the | |
| 50 | + | // address) under the ip-hash subkey: domain.IPHashLen bytes. It is stable | |
| 51 | + | // across restarts and says nothing about the address without the key. | |
| 52 | + | func (k *IssueKeys) IPHash(addrKey string) []byte { | |
| 53 | + | m := hmac.New(sha256.New, k.ipHash) | |
| 54 | + | m.Write([]byte(addrKey)) | |
| 55 | + | return m.Sum(nil)[:domain.IPHashLen] | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // PoWKey returns a copy of the subkey that signs the proof-of-work challenges. | |
| 59 | + | func (k *IssueKeys) PoWKey() []byte { return append([]byte(nil), k.pow...) } | |
| 60 | + | ||
| 61 | + | // FormTokenKey returns a copy of the subkey that signs the form tokens. | |
| 62 | + | func (k *IssueKeys) FormTokenKey() []byte { return append([]byte(nil), k.formToken...) } | |
A internal/usecase/issue_moderation.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // IssueModeration is the owner's moderation of the issues submitted through | |
| 17 | + | // the public form (ADR 0031): list, approve and reject, with an optional ban of | |
| 18 | + | // the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as | |
| 19 | + | // for a page that does not exist. | |
| 20 | + | type IssueModeration struct { | |
| 21 | + | meta ports.RepoMetaStore | |
| 22 | + | pending ports.PendingIssueStore | |
| 23 | + | bans ports.IssueBanStore | |
| 24 | + | bus ports.EventBus | |
| 25 | + | clock ports.Clock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | var _ ports.IssueModerationService = (*IssueModeration)(nil) | |
| 29 | + | ||
| 30 | + | // IssueModerationConfig holds what the moderation needs. Every field is | |
| 31 | + | // required. | |
| 32 | + | type IssueModerationConfig struct { | |
| 33 | + | Meta ports.RepoMetaStore | |
| 34 | + | Pending ports.PendingIssueStore | |
| 35 | + | Bans ports.IssueBanStore | |
| 36 | + | Bus ports.EventBus | |
| 37 | + | Clock ports.Clock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // NewIssueModeration builds the moderation use case. | |
| 41 | + | func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration { | |
| 42 | + | return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock} | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ListPending lists the pending issues of a repository, or of every repository | |
| 46 | + | // when repo is zero, oldest first. | |
| 47 | + | func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) { | |
| 48 | + | if !p.HasOwnerRights() { | |
| 49 | + | return ports.PendingIssuePage{}, domain.ErrNotFound | |
| 50 | + | } | |
| 51 | + | size, err := pageSize(page.PageSize) | |
| 52 | + | if err != nil { | |
| 53 | + | return ports.PendingIssuePage{}, err | |
| 54 | + | } | |
| 55 | + | page.PageSize = size | |
| 56 | + | out, err := m.pending.List(ctx, repo, page) | |
| 57 | + | if err != nil { | |
| 58 | + | return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err) | |
| 59 | + | } | |
| 60 | + | return out, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Approve gives the pending issue the next number of its repository and opens | |
| 64 | + | // it, then publishes IssueOpened. The repository must exist with issues on | |
| 65 | + | // (else domain.ErrNotFound), and so must the pending issue. | |
| 66 | + | func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) { | |
| 67 | + | if !p.HasOwnerRights() { | |
| 68 | + | return domain.Issue{}, domain.ErrNotFound | |
| 69 | + | } | |
| 70 | + | r, err := lookupRepo(ctx, m.meta, repo) | |
| 71 | + | if err != nil { | |
| 72 | + | return domain.Issue{}, err | |
| 73 | + | } | |
| 74 | + | if r.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 75 | + | return domain.Issue{}, domain.ErrNotFound | |
| 76 | + | } | |
| 77 | + | now := m.clock.Now().UTC() | |
| 78 | + | issue, err := m.pending.Approve(ctx, repo, id, now) | |
| 79 | + | if err != nil { | |
| 80 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 81 | + | return domain.Issue{}, err | |
| 82 | + | } | |
| 83 | + | return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err) | |
| 84 | + | } | |
| 85 | + | log := zerowrap.FromCtx(ctx) | |
| 86 | + | log.Info().Str("repo", repo.String()).Int64("number", issue.Number). | |
| 87 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved") | |
| 88 | + | m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt}) | |
| 89 | + | return issue, nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Reject deletes the pending issue. With ban, its submitter is first banned | |
| 93 | + | // for domain.BanDuration. | |
| 94 | + | func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error { | |
| 95 | + | if !p.HasOwnerRights() { | |
| 96 | + | return domain.ErrNotFound | |
| 97 | + | } | |
| 98 | + | pending, err := m.pending.Get(ctx, repo, id) | |
| 99 | + | if err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | if ban { | |
| 103 | + | until := m.clock.Now().UTC().Add(domain.BanDuration) | |
| 104 | + | if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil { | |
| 105 | + | return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if err := m.pending.Delete(ctx, repo, id); err != nil { | |
| 109 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | return fmt.Errorf("delete pending issue of %q: %w", repo, err) | |
| 113 | + | } | |
| 114 | + | log := zerowrap.FromCtx(ctx) | |
| 115 | + | log.Info().Str("repo", repo.String()).Bool("ban", ban). | |
| 116 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected") | |
| 117 | + | m.bus.Publish(ctx, domain.IssuePendingRemoved{Repo: repo, ID: id, At: m.clock.Now().UTC()}) | |
| 118 | + | return nil | |
| 119 | + | } | |
A internal/usecase/issue_moderation_test.go +244 -0
| @@ -0,0 +1,244 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "testing" | |
| 9 | + | "testing/synctest" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type moderationUnit struct { | |
| 22 | + | meta *mocks.RepoMetaStore | |
| 23 | + | pending *mocks.PendingIssueStore | |
| 24 | + | bans *mocks.IssueBanStore | |
| 25 | + | bus *mocks.EventBus | |
| 26 | + | mod *usecase.IssueModeration | |
| 27 | + | repo domain.Repo | |
| 28 | + | id domain.PendingIssueID | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newModerationUnit(t *testing.T, mode domain.IssuesMode) *moderationUnit { | |
| 32 | + | t.Helper() | |
| 33 | + | id, err := domain.NewPendingIssueID() | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | u := &moderationUnit{ | |
| 36 | + | meta: mocks.NewRepoMetaStore(t), | |
| 37 | + | pending: mocks.NewPendingIssueStore(t), | |
| 38 | + | bans: mocks.NewIssueBanStore(t), | |
| 39 | + | bus: mocks.NewEventBus(t), | |
| 40 | + | repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(mode)}, | |
| 41 | + | id: id, | |
| 42 | + | } | |
| 43 | + | clock := mocks.NewClock(t) | |
| 44 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 45 | + | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe() | |
| 46 | + | u.mod = usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: u.meta, Pending: u.pending, Bans: u.bans, Bus: u.bus, Clock: clock}) | |
| 47 | + | return u | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestIssueModerationIsOwnerOnly(t *testing.T) { | |
| 51 | + | t.Parallel() | |
| 52 | + | u := newModerationUnit(t, domain.IssuesPublic) // no store is ever called | |
| 53 | + | ctx := context.Background() | |
| 54 | + | notOwners := map[string]domain.Principal{ | |
| 55 | + | "anonymous": domain.Anonymous(), | |
| 56 | + | "issue writer": issuePrincipal(domain.ScopeIssueWrite), | |
| 57 | + | "one scope short": allScopesToken(func(tk *domain.Token) { | |
| 58 | + | tk.Scopes = tk.Scopes[1:] | |
| 59 | + | }), | |
| 60 | + | "repo limited": allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{u.repo.Name} }), | |
| 61 | + | "branch limited": allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }), | |
| 62 | + | } | |
| 63 | + | for who, p := range notOwners { | |
| 64 | + | _, err := u.mod.ListPending(ctx, p, domain.RepoName{}, ports.ListPendingParams{}) | |
| 65 | + | assert.ErrorIs(t, err, domain.ErrNotFound, who) | |
| 66 | + | _, err = u.mod.Approve(ctx, p, u.repo.Name, u.id) | |
| 67 | + | assert.ErrorIs(t, err, domain.ErrNotFound, who) | |
| 68 | + | assert.ErrorIs(t, u.mod.Reject(ctx, p, u.repo.Name, u.id, true), domain.ErrNotFound, who) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestIssueModerationListAppliesPageLimits(t *testing.T) { | |
| 73 | + | t.Parallel() | |
| 74 | + | u := newModerationUnit(t, domain.IssuesPublic) | |
| 75 | + | u.pending.EXPECT().List(mock.Anything, domain.RepoName{}, ports.ListPendingParams{PageSize: usecase.DefaultPageSize, PageToken: "t"}). | |
| 76 | + | Return(ports.PendingIssuePage{NextPageToken: "n"}, nil).Once() | |
| 77 | + | got, err := u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageToken: "t"}) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | assert.Equal(t, "n", got.NextPageToken) | |
| 80 | + | _, err = u.mod.ListPending(context.Background(), domain.Owner(), domain.RepoName{}, ports.ListPendingParams{PageSize: -1}) | |
| 81 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestIssueModerationApprove(t *testing.T) { | |
| 85 | + | t.Parallel() | |
| 86 | + | ctx := context.Background() | |
| 87 | + | t.Run("publishes IssueOpened after the commit", func(t *testing.T) { | |
| 88 | + | u := newModerationUnit(t, domain.IssuesPublic) | |
| 89 | + | issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen} | |
| 90 | + | u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once() | |
| 91 | + | u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once() | |
| 92 | + | got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | assert.Equal(t, int64(7), got.Number) | |
| 95 | + | }) | |
| 96 | + | t.Run("issues off hides the repository", func(t *testing.T) { | |
| 97 | + | for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} { | |
| 98 | + | u := newModerationUnit(t, mode) | |
| 99 | + | _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id) | |
| 100 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 101 | + | } | |
| 102 | + | }) | |
| 103 | + | t.Run("no event when the store refuses", func(t *testing.T) { | |
| 104 | + | u := newModerationUnit(t, domain.IssuesOwner) | |
| 105 | + | u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(domain.Issue{}, domain.ErrNotFound).Once() | |
| 106 | + | _, err := u.mod.Approve(ctx, domain.Root(), u.repo.Name, u.id) | |
| 107 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestIssueModerationReject(t *testing.T) { | |
| 112 | + | t.Parallel() | |
| 113 | + | ctx := context.Background() | |
| 114 | + | hash := bytes.Repeat([]byte{3}, domain.IPHashLen) | |
| 115 | + | t.Run("with a ban", func(t *testing.T) { | |
| 116 | + | u := newModerationUnit(t, domain.IssuesPublic) | |
| 117 | + | u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, Repo: u.repo.Name, IPHash: hash}, nil).Once() | |
| 118 | + | u.bans.EXPECT().Ban(mock.Anything, hash, now.Add(domain.BanDuration)).Return(nil).Once() | |
| 119 | + | u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once() | |
| 120 | + | u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once() | |
| 121 | + | require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true)) | |
| 122 | + | }) | |
| 123 | + | t.Run("without a ban", func(t *testing.T) { | |
| 124 | + | u := newModerationUnit(t, domain.IssuesPublic) | |
| 125 | + | u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once() | |
| 126 | + | u.pending.EXPECT().Delete(mock.Anything, u.repo.Name, u.id).Return(nil).Once() | |
| 127 | + | u.bus.EXPECT().Publish(mock.Anything, domain.IssuePendingRemoved{Repo: u.repo.Name, ID: u.id, At: now}).Once() | |
| 128 | + | require.NoError(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, false)) | |
| 129 | + | }) | |
| 130 | + | t.Run("unknown id", func(t *testing.T) { | |
| 131 | + | u := newModerationUnit(t, domain.IssuesPublic) | |
| 132 | + | u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{}, domain.ErrNotFound).Once() | |
| 133 | + | assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), domain.ErrNotFound) | |
| 134 | + | }) | |
| 135 | + | t.Run("a failed ban keeps the pending issue", func(t *testing.T) { | |
| 136 | + | u := newModerationUnit(t, domain.IssuesPublic) | |
| 137 | + | u.pending.EXPECT().Get(mock.Anything, u.repo.Name, u.id).Return(domain.PendingIssue{ID: u.id, IPHash: hash}, nil).Once() | |
| 138 | + | u.bans.EXPECT().Ban(mock.Anything, hash, mock.Anything).Return(assert.AnError).Once() | |
| 139 | + | assert.ErrorIs(t, u.mod.Reject(ctx, domain.Owner(), u.repo.Name, u.id, true), assert.AnError) | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestIssueKeysSubkeys(t *testing.T) { | |
| 144 | + | t.Parallel() | |
| 145 | + | ctx := context.Background() | |
| 146 | + | master := bytes.Repeat([]byte{0xAB}, 32) | |
| 147 | + | store := mocks.NewIssueKeyStore(t) | |
| 148 | + | store.EXPECT().Key(mock.Anything).Return(master, nil) | |
| 149 | + | k1, err := usecase.NewIssueKeys(ctx, store) | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | k2, err := usecase.NewIssueKeys(ctx, store) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | ||
| 154 | + | assert.Equal(t, k1.PoWKey(), k2.PoWKey(), "stable") | |
| 155 | + | assert.Equal(t, k1.FormTokenKey(), k2.FormTokenKey()) | |
| 156 | + | assert.Equal(t, k1.IPHash("1.2.3.4"), k2.IPHash("1.2.3.4")) | |
| 157 | + | ||
| 158 | + | assert.Len(t, k1.PoWKey(), 32) | |
| 159 | + | assert.NotEqual(t, k1.PoWKey(), k1.FormTokenKey(), "one key per purpose") | |
| 160 | + | assert.NotEqual(t, master, k1.PoWKey()) | |
| 161 | + | assert.NotEqual(t, master, k1.FormTokenKey()) | |
| 162 | + | ||
| 163 | + | h := k1.IPHash("1.2.3.4") | |
| 164 | + | assert.Len(t, h, domain.IPHashLen) | |
| 165 | + | assert.NotEqual(t, h, k1.IPHash("1.2.3.5")) | |
| 166 | + | assert.NotContains(t, string(h), "1.2.3.4") | |
| 167 | + | ||
| 168 | + | other := mocks.NewIssueKeyStore(t) | |
| 169 | + | other.EXPECT().Key(mock.Anything).Return(bytes.Repeat([]byte{0xCD}, 32), nil) | |
| 170 | + | k3, err := usecase.NewIssueKeys(ctx, other) | |
| 171 | + | require.NoError(t, err) | |
| 172 | + | assert.NotEqual(t, h, k3.IPHash("1.2.3.4"), "another server key, another hash") | |
| 173 | + | ||
| 174 | + | // The returned keys are copies. | |
| 175 | + | pow := k1.PoWKey() | |
| 176 | + | pow[0] ^= 0xFF | |
| 177 | + | assert.NotEqual(t, pow, k1.PoWKey()) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestIssueSubmittedReachesOnlyTheOwnerTopic(t *testing.T) { | |
| 181 | + | t.Parallel() | |
| 182 | + | synctest.Test(t, func(t *testing.T) { | |
| 183 | + | r := newEventRig(t, 16) | |
| 184 | + | a := r.repo("aaa", domain.VisibilityPublic) | |
| 185 | + | r.meta.EXPECT().Get(mock.Anything, a).Unset() | |
| 186 | + | r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe() | |
| 187 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 188 | + | defer cancel() | |
| 189 | + | ||
| 190 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 191 | + | home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}}) | |
| 192 | + | repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}}) | |
| 193 | + | rootRepo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}}) | |
| 194 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}}) | |
| 195 | + | synctest.Wait() | |
| 196 | + | ||
| 197 | + | r.feed <- domain.IssueSubmitted{Repo: a, ID: "x", At: now} | |
| 198 | + | // A sentinel every watcher sees, to prove the first one was skipped. | |
| 199 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now} | |
| 200 | + | ||
| 201 | + | sub, ok := owner.next(t).Event.(domain.IssueSubmitted) | |
| 202 | + | require.True(t, ok, "the owner topic gets it") | |
| 203 | + | assert.Equal(t, a, sub.Repo) | |
| 204 | + | _, ok = owner.next(t).Event.(domain.IssueOpened) | |
| 205 | + | require.True(t, ok) | |
| 206 | + | for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "root repo+home": rootRepo, "anonymous": anon} { | |
| 207 | + | e, ok := w.next(t).Event.(domain.IssueOpened) | |
| 208 | + | require.True(t, ok, "%s: the first event is the sentinel, not issue.submitted (got %T)", name, e) | |
| 209 | + | } | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestIssuePendingRemovedReachesOnlyOwnerTopics(t *testing.T) { | |
| 214 | + | t.Parallel() | |
| 215 | + | synctest.Test(t, func(t *testing.T) { | |
| 216 | + | r := newEventRig(t, 16) | |
| 217 | + | a := r.repo("aaa", domain.VisibilityPublic) | |
| 218 | + | r.meta.EXPECT().Get(mock.Anything, a).Unset() | |
| 219 | + | r.meta.EXPECT().Get(mock.Anything, a).Return(domain.Repo{Name: a, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe() | |
| 220 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 221 | + | defer cancel() | |
| 222 | + | ||
| 223 | + | owner1 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 224 | + | owner2 := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 225 | + | home := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"home"}}) | |
| 226 | + | repoTopic := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"repo:aaa"}}) | |
| 227 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"repo:aaa", "home"}}) | |
| 228 | + | synctest.Wait() | |
| 229 | + | ||
| 230 | + | r.feed <- domain.IssuePendingRemoved{Repo: a, ID: "x", At: now} | |
| 231 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now} | |
| 232 | + | ||
| 233 | + | for name, w := range map[string]*watcher{"owner 1": owner1, "owner 2": owner2} { | |
| 234 | + | _, ok := w.next(t).Event.(domain.IssuePendingRemoved) | |
| 235 | + | require.True(t, ok, "%s gets the refresh", name) | |
| 236 | + | _, ok = w.next(t).Event.(domain.IssueOpened) | |
| 237 | + | require.True(t, ok) | |
| 238 | + | } | |
| 239 | + | for name, w := range map[string]*watcher{"home": home, "repo topic": repoTopic, "anonymous": anon} { | |
| 240 | + | e, ok := w.next(t).Event.(domain.IssueOpened) | |
| 241 | + | require.True(t, ok, "%s: the first event is the sentinel (got %T)", name, e) | |
| 242 | + | } | |
| 243 | + | }) | |
| 244 | + | } | |
A internal/usecase/issue_pow_vector_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/base64" | |
| 7 | + | "strconv" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // The proof-of-work vector shared with the browser solver: the same nonce, | |
| 15 | + | // difficulty and smallest solution are asserted in | |
| 16 | + | // internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both. | |
| 17 | + | const ( | |
| 18 | + | powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding | |
| 19 | + | powVectorDifficulty = 12 | |
| 20 | + | powVectorSolution = "2491" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func TestPowVector(t *testing.T) { | |
| 24 | + | nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.Len(t, nonce, nonceLen) | |
| 27 | + | ||
| 28 | + | first := "" | |
| 29 | + | for n := 0; n < 1<<22; n++ { | |
| 30 | + | s := strconv.Itoa(n) | |
| 31 | + | if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty { | |
| 32 | + | first = s | |
| 33 | + | break | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | require.NotEmpty(t, first) | |
| 37 | + | assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector") | |
| 38 | + | assert.True(t, validSolution(first)) | |
| 39 | + | } | |
A internal/usecase/issue_submissions.go +383 -0
| @@ -0,0 +1,383 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/hmac" | |
| 8 | + | "crypto/rand" | |
| 9 | + | "crypto/sha256" | |
| 10 | + | "encoding/base64" | |
| 11 | + | "encoding/json" | |
| 12 | + | "errors" | |
| 13 | + | "fmt" | |
| 14 | + | "math/bits" | |
| 15 | + | "strings" | |
| 16 | + | "sync" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/zerowrap" | |
| 20 | + | ||
| 21 | + | "bnema.dev/ermite/internal/domain" | |
| 22 | + | "bnema.dev/ermite/internal/ports" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Parameters of the anonymous submission (ADR 0031). | |
| 26 | + | const ( | |
| 27 | + | // baseDifficulty is the proof-of-work of a client that has not submitted | |
| 28 | + | // lately, in leading zero bits of SHA-256: 2^18 hashes on average, about | |
| 29 | + | // one second for a SHA-256 in JavaScript on a mid-range phone. | |
| 30 | + | baseDifficulty = 18 | |
| 31 | + | // maxDifficulty caps the adaptive proof-of-work: about 8 to 10 seconds. | |
| 32 | + | maxDifficulty = 21 | |
| 33 | + | // perAddrStep and globalStep: one more bit per that many submissions of the | |
| 34 | + | // last hour from the client, and from everyone. | |
| 35 | + | perAddrStep = 2 | |
| 36 | + | globalStep = 20 | |
| 37 | + | ||
| 38 | + | // challengeTTL is how long a challenge may be solved and sent. | |
| 39 | + | challengeTTL = 10 * time.Minute | |
| 40 | + | // minFillTime is the least time between the challenge and the submission: | |
| 41 | + | // a human cannot write an issue faster. | |
| 42 | + | minFillTime = 3 * time.Second | |
| 43 | + | // maxSpent bounds the nonces remembered as spent. Past it submissions are | |
| 44 | + | // paused rather than a live nonce forgotten. | |
| 45 | + | maxSpent = 100_000 | |
| 46 | + | ||
| 47 | + | tokenVersion = 1 | |
| 48 | + | nonceLen = 16 | |
| 49 | + | maxChallengeLen = 1024 | |
| 50 | + | maxSolutionLen = 20 | |
| 51 | + | spentSweep = time.Minute | |
| 52 | + | ) | |
| 53 | + | ||
| 54 | + | // difficultyFor is the proof-of-work for a client with perAddr submissions in | |
| 55 | + | // the last hour among global ones: base, plus a bit per perAddrStep and per | |
| 56 | + | // globalStep, at most limit. It decays as the sliding window empties. | |
| 57 | + | func difficultyFor(base, limit, perAddr, global int) int { | |
| 58 | + | return min(base+max(perAddr, 0)/perAddrStep+max(global, 0)/globalStep, limit) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // leadingZeroBits counts the zero bits at the start of sum. | |
| 62 | + | func leadingZeroBits(sum []byte) int { | |
| 63 | + | n := 0 | |
| 64 | + | for _, b := range sum { | |
| 65 | + | if b == 0 { | |
| 66 | + | n += 8 | |
| 67 | + | continue | |
| 68 | + | } | |
| 69 | + | return n + bits.LeadingZeros8(b) | |
| 70 | + | } | |
| 71 | + | return n | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // powHash is SHA-256(nonce || ":" || solution). | |
| 75 | + | func powHash(nonce []byte, solution string) []byte { | |
| 76 | + | h := sha256.New() | |
| 77 | + | h.Write(nonce) | |
| 78 | + | h.Write([]byte(":")) | |
| 79 | + | h.Write([]byte(solution)) | |
| 80 | + | return h.Sum(nil) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // challengeClaims is the signed payload of a challenge token. | |
| 84 | + | type challengeClaims struct { | |
| 85 | + | V int `json:"v"` | |
| 86 | + | Repo string `json:"repo"` | |
| 87 | + | Nonce []byte `json:"nonce"` | |
| 88 | + | Difficulty int `json:"d"` | |
| 89 | + | IssuedAt int64 `json:"iat"` | |
| 90 | + | ExpiresAt int64 `json:"exp"` | |
| 91 | + | IP []byte `json:"ip"` | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // IssueSubmissions is the anonymous submission of issues: a signed challenge | |
| 95 | + | // with an adaptive proof of work, and every server-side check before the issue | |
| 96 | + | // awaits moderation. | |
| 97 | + | type IssueSubmissions struct { | |
| 98 | + | meta ports.RepoMetaStore | |
| 99 | + | pending ports.PendingIssueStore | |
| 100 | + | bans ports.IssueBanStore | |
| 101 | + | limiter ports.SubmissionLimiter | |
| 102 | + | keys *IssueKeys | |
| 103 | + | bus ports.EventBus | |
| 104 | + | clock ports.Clock | |
| 105 | + | base int | |
| 106 | + | limit int | |
| 107 | + | minFill time.Duration | |
| 108 | + | ||
| 109 | + | admitMu sync.Mutex // serializes the pending cap check and the store | |
| 110 | + | ||
| 111 | + | mu sync.Mutex | |
| 112 | + | spent map[string]time.Time // nonce -> expiry of its challenge | |
| 113 | + | nextSweep time.Time | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | var _ ports.IssueSubmissionService = (*IssueSubmissions)(nil) | |
| 117 | + | ||
| 118 | + | // IssueSubmissionsConfig holds what the submission needs. Every field but the | |
| 119 | + | // difficulties is required. | |
| 120 | + | type IssueSubmissionsConfig struct { | |
| 121 | + | Meta ports.RepoMetaStore | |
| 122 | + | Pending ports.PendingIssueStore | |
| 123 | + | Bans ports.IssueBanStore | |
| 124 | + | Limiter ports.SubmissionLimiter | |
| 125 | + | Keys *IssueKeys | |
| 126 | + | Bus ports.EventBus | |
| 127 | + | Clock ports.Clock | |
| 128 | + | // BaseDifficulty and MaxDifficulty replace the production constants; for | |
| 129 | + | // tests. Zero keeps the constants. | |
| 130 | + | BaseDifficulty, MaxDifficulty int | |
| 131 | + | // MinFillTime replaces minFillTime when positive; for tests. | |
| 132 | + | MinFillTime time.Duration | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // NewIssueSubmissions builds the submission use case. | |
| 136 | + | func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions { | |
| 137 | + | s := &IssueSubmissions{ | |
| 138 | + | meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter, | |
| 139 | + | keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock, | |
| 140 | + | base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime, | |
| 141 | + | spent: make(map[string]time.Time), | |
| 142 | + | } | |
| 143 | + | if cfg.BaseDifficulty > 0 { | |
| 144 | + | s.base = cfg.BaseDifficulty | |
| 145 | + | } | |
| 146 | + | if cfg.MaxDifficulty > 0 { | |
| 147 | + | s.limit = cfg.MaxDifficulty | |
| 148 | + | } | |
| 149 | + | if cfg.MinFillTime > 0 { | |
| 150 | + | s.minFill = cfg.MinFillTime | |
| 151 | + | } | |
| 152 | + | return s | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // openRepo returns the repository if anyone may submit to it, else | |
| 156 | + | // domain.ErrNotFound. | |
| 157 | + | func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) error { | |
| 158 | + | r, err := lookupRepo(ctx, s.meta, repo) | |
| 159 | + | if err != nil { | |
| 160 | + | if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrInvalidName) { | |
| 161 | + | return domain.ErrNotFound | |
| 162 | + | } | |
| 163 | + | return err | |
| 164 | + | } | |
| 165 | + | if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic { | |
| 166 | + | return domain.ErrNotFound | |
| 167 | + | } | |
| 168 | + | return nil | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (s *IssueSubmissions) sign(key, msg []byte) []byte { | |
| 172 | + | m := hmac.New(sha256.New, key) | |
| 173 | + | m.Write(msg) | |
| 174 | + | return m.Sum(nil) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Challenge issues a challenge for a client of the repository. | |
| 178 | + | func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) { | |
| 179 | + | if err := ctx.Err(); err != nil { | |
| 180 | + | return ports.IssueChallenge{}, err | |
| 181 | + | } | |
| 182 | + | if err := s.openRepo(ctx, repo); err != nil { | |
| 183 | + | return ports.IssueChallenge{}, err | |
| 184 | + | } | |
| 185 | + | nonce := make([]byte, nonceLen) | |
| 186 | + | if _, err := rand.Read(nonce); err != nil { | |
| 187 | + | return ports.IssueChallenge{}, fmt.Errorf("random nonce: %w", err) | |
| 188 | + | } | |
| 189 | + | perAddr, global := s.limiter.Recent(ctx, addr) | |
| 190 | + | d := difficultyFor(s.base, s.limit, perAddr, global) | |
| 191 | + | now := s.clock.Now().UTC() | |
| 192 | + | claims := challengeClaims{ | |
| 193 | + | V: tokenVersion, Repo: repo.String(), Nonce: nonce, Difficulty: d, | |
| 194 | + | IssuedAt: now.Unix(), ExpiresAt: now.Add(challengeTTL).Unix(), IP: s.keys.IPHash(addr), | |
| 195 | + | } | |
| 196 | + | payload, err := json.Marshal(claims) | |
| 197 | + | if err != nil { | |
| 198 | + | return ports.IssueChallenge{}, fmt.Errorf("encode challenge: %w", err) | |
| 199 | + | } | |
| 200 | + | return ports.IssueChallenge{ | |
| 201 | + | Token: enc64(payload) + "." + enc64(s.sign(s.keys.PoWKey(), payload)), | |
| 202 | + | Nonce: enc64(nonce), | |
| 203 | + | Difficulty: d, | |
| 204 | + | FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)), | |
| 205 | + | IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(), | |
| 206 | + | MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(s.minFill), | |
| 207 | + | ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(), | |
| 208 | + | }, nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // parseToken verifies the signature and returns the claims, or false. | |
| 212 | + | func (s *IssueSubmissions) parseToken(tok string) (challengeClaims, bool) { | |
| 213 | + | var c challengeClaims | |
| 214 | + | if len(tok) > maxChallengeLen { | |
| 215 | + | return c, false | |
| 216 | + | } | |
| 217 | + | p, sig, ok := strings.Cut(tok, ".") | |
| 218 | + | if !ok { | |
| 219 | + | return c, false | |
| 220 | + | } | |
| 221 | + | payload, err := base64.RawURLEncoding.DecodeString(p) | |
| 222 | + | if err != nil { | |
| 223 | + | return c, false | |
| 224 | + | } | |
| 225 | + | got, err := base64.RawURLEncoding.DecodeString(sig) | |
| 226 | + | if err != nil || !hmac.Equal(got, s.sign(s.keys.PoWKey(), payload)) { | |
| 227 | + | return c, false | |
| 228 | + | } | |
| 229 | + | if json.Unmarshal(payload, &c) != nil { | |
| 230 | + | return c, false | |
| 231 | + | } | |
| 232 | + | return c, true | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func validSolution(sol string) bool { | |
| 236 | + | if len(sol) < 1 || len(sol) > maxSolutionLen { | |
| 237 | + | return false | |
| 238 | + | } | |
| 239 | + | for i := 0; i < len(sol); i++ { | |
| 240 | + | if sol[i] < '0' || sol[i] > '9' { | |
| 241 | + | return false | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | return true | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // spend marks the nonce spent. It returns domain.ErrSubmissionInvalid for a | |
| 248 | + | // nonce already spent and domain.ErrSubmissionsPaused when the set is full. | |
| 249 | + | func (s *IssueSubmissions) spend(nonce []byte, exp, now time.Time) error { | |
| 250 | + | s.mu.Lock() | |
| 251 | + | defer s.mu.Unlock() | |
| 252 | + | if len(s.spent) >= maxSpent || !now.Before(s.nextSweep) { | |
| 253 | + | for k, e := range s.spent { | |
| 254 | + | if now.After(e) { // an expired challenge is refused anyway | |
| 255 | + | delete(s.spent, k) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | s.nextSweep = now.Add(spentSweep) | |
| 259 | + | } | |
| 260 | + | key := string(nonce) | |
| 261 | + | if _, dup := s.spent[key]; dup { | |
| 262 | + | return domain.ErrSubmissionInvalid | |
| 263 | + | } | |
| 264 | + | if len(s.spent) >= maxSpent { | |
| 265 | + | return domain.ErrSubmissionsPaused | |
| 266 | + | } | |
| 267 | + | s.spent[key] = exp | |
| 268 | + | return nil | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // Submit checks the submission and stores it as a pending issue. The checks | |
| 272 | + | // run in a fixed order and stop at the first failure; every bot-related | |
| 273 | + | // failure answers domain.ErrSubmissionInvalid, and the precise reason is only | |
| 274 | + | // logged (without address, hash or text). | |
| 275 | + | func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error { | |
| 276 | + | if err := ctx.Err(); err != nil { | |
| 277 | + | return err | |
| 278 | + | } | |
| 279 | + | log := zerowrap.FromCtx(ctx) | |
| 280 | + | refuse := func(reason string, err error) error { | |
| 281 | + | log.Info().Str("repo", repo.String()).Str("reason", reason).Msg("issue submission refused") | |
| 282 | + | return err | |
| 283 | + | } | |
| 284 | + | if err := s.openRepo(ctx, repo); err != nil { | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | ipHash := s.keys.IPHash(addr) | |
| 288 | + | now := s.clock.Now().UTC() | |
| 289 | + | if f.Honeypot != "" { | |
| 290 | + | return refuse("honeypot", domain.ErrSubmissionInvalid) | |
| 291 | + | } | |
| 292 | + | c, ok := s.parseToken(f.Token) | |
| 293 | + | if !ok { | |
| 294 | + | return refuse("bad_token", domain.ErrSubmissionInvalid) | |
| 295 | + | } | |
| 296 | + | exp := time.Unix(c.ExpiresAt, 0) | |
| 297 | + | switch { | |
| 298 | + | case c.V != tokenVersion || len(c.Nonce) != nonceLen: | |
| 299 | + | return refuse("bad_token", domain.ErrSubmissionInvalid) | |
| 300 | + | case c.Repo != repo.String(): | |
| 301 | + | return refuse("token_other_repo", domain.ErrSubmissionInvalid) | |
| 302 | + | case now.After(exp): | |
| 303 | + | return refuse("token_expired", domain.ErrSubmissionInvalid) | |
| 304 | + | case !hmac.Equal(c.IP, ipHash): | |
| 305 | + | return refuse("token_other_client", domain.ErrSubmissionInvalid) | |
| 306 | + | } | |
| 307 | + | if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) { | |
| 308 | + | return refuse("bad_form_token", domain.ErrSubmissionInvalid) | |
| 309 | + | } | |
| 310 | + | if now.Before(time.Unix(c.IssuedAt, 0).Add(s.minFill)) { | |
| 311 | + | return refuse("too_fast", domain.ErrSubmissionInvalid) | |
| 312 | + | } | |
| 313 | + | if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size || | |
| 314 | + | leadingZeroBits(powHash(c.Nonce, f.Solution)) < c.Difficulty { | |
| 315 | + | return refuse("bad_pow", domain.ErrSubmissionInvalid) | |
| 316 | + | } | |
| 317 | + | if err := s.spend(c.Nonce, exp, now); err != nil { | |
| 318 | + | if errors.Is(err, domain.ErrSubmissionsPaused) { | |
| 319 | + | return refuse("spent_full", err) | |
| 320 | + | } | |
| 321 | + | return refuse("replayed", err) | |
| 322 | + | } | |
| 323 | + | banned, err := s.bans.Banned(ctx, ipHash, now) | |
| 324 | + | if err != nil { | |
| 325 | + | return fmt.Errorf("check ban: %w", err) | |
| 326 | + | } | |
| 327 | + | if banned { | |
| 328 | + | return refuse("banned", domain.ErrSubmissionInvalid) | |
| 329 | + | } | |
| 330 | + | title, err := domain.ParseIssueTitle(f.Title) | |
| 331 | + | if err != nil { | |
| 332 | + | return refuse("text_title", fmt.Errorf("title: %w", err)) | |
| 333 | + | } | |
| 334 | + | body, err := domain.ParseIssueBody(f.Body) | |
| 335 | + | if err != nil { | |
| 336 | + | return refuse("text_body", fmt.Errorf("body: %w", err)) | |
| 337 | + | } | |
| 338 | + | author, err := domain.ParseAuthorLabel(f.Author) | |
| 339 | + | if err != nil { | |
| 340 | + | return refuse("text_author", fmt.Errorf("author: %w", err)) | |
| 341 | + | } | |
| 342 | + | id, err := domain.NewPendingIssueID() | |
| 343 | + | if err != nil { | |
| 344 | + | return fmt.Errorf("new pending issue id: %w", err) | |
| 345 | + | } | |
| 346 | + | if err := s.admit(ctx, addr, refuse, domain.PendingIssue{ | |
| 347 | + | ID: id, Repo: repo, Title: title, Body: body, Author: author, CreatedAt: now, IPHash: ipHash, | |
| 348 | + | }); err != nil { | |
| 349 | + | return err | |
| 350 | + | } | |
| 351 | + | log.Info().Str("repo", repo.String()).Msg("issue submitted for moderation") | |
| 352 | + | s.bus.Publish(ctx, domain.IssueSubmitted{Repo: repo, ID: id, At: now}) | |
| 353 | + | return nil | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // admit reserves the client's quota, checks the global queue cap and stores | |
| 357 | + | // the issue. Ermite is a single process (ADR 0001), so admitMu serializes the | |
| 358 | + | // Count -> Put section: concurrent submissions cannot exceed | |
| 359 | + | // domain.MaxPendingIssues. It would not hold with several processes. | |
| 360 | + | func (s *IssueSubmissions) admit(ctx context.Context, addr string, refuse func(string, error) error, p domain.PendingIssue) error { | |
| 361 | + | s.admitMu.Lock() | |
| 362 | + | defer s.admitMu.Unlock() | |
| 363 | + | ok, after, release := s.limiter.Reserve(ctx, addr) | |
| 364 | + | if !ok { | |
| 365 | + | return refuse("rate_limited", &domain.SubmissionRetryError{After: after}) | |
| 366 | + | } | |
| 367 | + | n, err := s.pending.Count(ctx) | |
| 368 | + | if err != nil { | |
| 369 | + | release() | |
| 370 | + | return fmt.Errorf("count pending issues: %w", err) | |
| 371 | + | } | |
| 372 | + | if n >= domain.MaxPendingIssues { | |
| 373 | + | release() | |
| 374 | + | return refuse("pending_full", domain.ErrSubmissionsPaused) | |
| 375 | + | } | |
| 376 | + | if err := s.pending.Put(ctx, p); err != nil { | |
| 377 | + | release() | |
| 378 | + | return fmt.Errorf("store pending issue of %q: %w", p.Repo, err) | |
| 379 | + | } | |
| 380 | + | return nil | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func enc64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } | |
A internal/usecase/issue_submissions_test.go +670 -0
| @@ -0,0 +1,670 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "encoding/base64" | |
| 9 | + | "errors" | |
| 10 | + | "math/bits" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | "sync/atomic" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ||
| 22 | + | "bnema.dev/ermite/internal/domain" | |
| 23 | + | "bnema.dev/ermite/internal/ports" | |
| 24 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 25 | + | "bnema.dev/ermite/internal/usecase" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | const testBits = 8 | |
| 29 | + | ||
| 30 | + | type subFixture struct { | |
| 31 | + | t *testing.T | |
| 32 | + | meta *mocks.RepoMetaStore | |
| 33 | + | pending *mocks.PendingIssueStore | |
| 34 | + | bans *mocks.IssueBanStore | |
| 35 | + | limiter *mocks.SubmissionLimiter | |
| 36 | + | bus *mocks.EventBus | |
| 37 | + | uc *usecase.IssueSubmissions | |
| 38 | + | repo domain.Repo | |
| 39 | + | addr string | |
| 40 | + | ||
| 41 | + | mu sync.Mutex | |
| 42 | + | now time.Time | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (f *subFixture) advance(d time.Duration) { | |
| 46 | + | f.mu.Lock() | |
| 47 | + | defer f.mu.Unlock() | |
| 48 | + | f.now = f.now.Add(d) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func newSubFixture(t *testing.T) *subFixture { | |
| 52 | + | t.Helper() | |
| 53 | + | f := &subFixture{ | |
| 54 | + | t: t, meta: mocks.NewRepoMetaStore(t), pending: mocks.NewPendingIssueStore(t), | |
| 55 | + | bans: mocks.NewIssueBanStore(t), limiter: mocks.NewSubmissionLimiter(t), bus: mocks.NewEventBus(t), | |
| 56 | + | addr: "ip:203.0.113.7", now: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC), | |
| 57 | + | } | |
| 58 | + | f.repo = domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: f.now, Settings: withIssues(domain.IssuesPublic)} | |
| 59 | + | f.meta.EXPECT().Get(mock.Anything, f.repo.Name).Return(f.repo, nil).Maybe() | |
| 60 | + | clock := mocks.NewClock(t) | |
| 61 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |
| 62 | + | f.mu.Lock() | |
| 63 | + | defer f.mu.Unlock() | |
| 64 | + | return f.now | |
| 65 | + | }).Maybe() | |
| 66 | + | keyStore := mocks.NewIssueKeyStore(t) | |
| 67 | + | keyStore.EXPECT().Key(mock.Anything).Return([]byte(strings.Repeat("k", 32)), nil) | |
| 68 | + | keys, err := usecase.NewIssueKeys(context.Background(), keyStore) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{ | |
| 71 | + | Meta: f.meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Keys: keys, Bus: f.bus, Clock: clock, | |
| 72 | + | BaseDifficulty: testBits, MaxDifficulty: testBits + 3, | |
| 73 | + | }) | |
| 74 | + | return f | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // open expects the checks after the proof of work: not banned, quota | |
| 78 | + | // available, room. It returns the number of reservations released. | |
| 79 | + | func (f *subFixture) open(pendingCount int) *atomic.Int32 { | |
| 80 | + | released := new(atomic.Int32) | |
| 81 | + | f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe() | |
| 82 | + | f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) { | |
| 83 | + | return true, 0, func() { released.Add(1) } | |
| 84 | + | }).Maybe() | |
| 85 | + | f.pending.EXPECT().Count(mock.Anything).Return(pendingCount, nil).Maybe() | |
| 86 | + | return released | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (f *subFixture) challenge(addr string) ports.IssueChallenge { | |
| 90 | + | f.t.Helper() | |
| 91 | + | f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once() | |
| 92 | + | ch, err := f.uc.Challenge(context.Background(), f.repo.Name, addr) | |
| 93 | + | require.NoError(f.t, err) | |
| 94 | + | return ch | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func solve(t *testing.T, ch ports.IssueChallenge) string { | |
| 98 | + | t.Helper() | |
| 99 | + | nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | for i := 0; ; i++ { | |
| 102 | + | s := strconv.Itoa(i) | |
| 103 | + | sum := sha256.Sum256(append(append(append([]byte(nil), nonce...), ':'), s...)) | |
| 104 | + | if zeroBits(sum[:]) >= ch.Difficulty { | |
| 105 | + | return s | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func zeroBits(b []byte) int { | |
| 111 | + | n := 0 | |
| 112 | + | for _, x := range b { | |
| 113 | + | if x == 0 { | |
| 114 | + | n += 8 | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | return n + bits.LeadingZeros8(x) | |
| 118 | + | } | |
| 119 | + | return n | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // valid is a submission that passes every check once the fill time is over. | |
| 123 | + | func (f *subFixture) valid(ch ports.IssueChallenge) ports.IssueSubmission { | |
| 124 | + | return ports.IssueSubmission{ | |
| 125 | + | Token: ch.Token, Solution: solve(f.t, ch), FormToken: ch.FormToken, | |
| 126 | + | Title: "A bug", Body: "It breaks.", Author: "me", | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestIssueChallenge(t *testing.T) { | |
| 131 | + | t.Parallel() | |
| 132 | + | f := newSubFixture(t) | |
| 133 | + | ch := f.challenge(f.addr) | |
| 134 | + | assert.Equal(t, testBits, ch.Difficulty) | |
| 135 | + | assert.Equal(t, f.now, ch.IssuedAt) | |
| 136 | + | assert.Equal(t, f.now.Add(usecase.MinFillTime), ch.MinSubmitAt) | |
| 137 | + | assert.Equal(t, f.now.Add(usecase.ChallengeTTL), ch.ExpiresAt) | |
| 138 | + | assert.Contains(t, ch.Token, ".") | |
| 139 | + | nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | assert.Len(t, nonce, 16) | |
| 142 | + | assert.NotEmpty(t, ch.FormToken) | |
| 143 | + | ||
| 144 | + | f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(4, 40).Once() // +2 +2 | |
| 145 | + | ch, err = f.uc.Challenge(context.Background(), f.repo.Name, f.addr) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | assert.Equal(t, testBits+3, ch.Difficulty) // capped at base+3 | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestIssueSubmissionRepoAccess(t *testing.T) { | |
| 151 | + | t.Parallel() | |
| 152 | + | for name_, repo := range map[string]domain.Repo{ | |
| 153 | + | "private": {Visibility: domain.VisibilityPrivate, Settings: withIssues(domain.IssuesPublic)}, | |
| 154 | + | "owner issues": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOwner)}, | |
| 155 | + | "issues off": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOff)}, | |
| 156 | + | } { | |
| 157 | + | t.Run(name_, func(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | f := newSubFixture(t) | |
| 160 | + | meta := mocks.NewRepoMetaStore(t) | |
| 161 | + | repo.Name = f.repo.Name | |
| 162 | + | meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil) | |
| 163 | + | f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Bus: f.bus, Clock: mocks.NewClock(t)}) | |
| 164 | + | _, err := f.uc.Challenge(context.Background(), repo.Name, f.addr) | |
| 165 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 166 | + | err = f.uc.Submit(context.Background(), repo.Name, f.addr, ports.IssueSubmission{}) | |
| 167 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 168 | + | }) | |
| 169 | + | } | |
| 170 | + | t.Run("unknown", func(t *testing.T) { | |
| 171 | + | t.Parallel() | |
| 172 | + | f := newSubFixture(t) | |
| 173 | + | other := name(t, "other") | |
| 174 | + | f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{}, domain.ErrNotFound) | |
| 175 | + | assert.ErrorIs(t, f.uc.Submit(context.Background(), other, f.addr, ports.IssueSubmission{}), domain.ErrNotFound) | |
| 176 | + | _, err := f.uc.Challenge(context.Background(), other, f.addr) | |
| 177 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 178 | + | }) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestIssueSubmitSuccess(t *testing.T) { | |
| 182 | + | t.Parallel() | |
| 183 | + | f := newSubFixture(t) | |
| 184 | + | f.open(0) | |
| 185 | + | ch := f.challenge(f.addr) | |
| 186 | + | sub := f.valid(ch) | |
| 187 | + | f.advance(usecase.MinFillTime) | |
| 188 | + | var put domain.PendingIssue | |
| 189 | + | f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.PendingIssue) error { | |
| 190 | + | put = p | |
| 191 | + | return nil | |
| 192 | + | }).Once() | |
| 193 | + | f.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { | |
| 194 | + | e, ok := ev.(domain.IssueSubmitted) | |
| 195 | + | return ok && e.Repo == f.repo.Name && e.ID == put.ID | |
| 196 | + | })).Once() | |
| 197 | + | require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)) | |
| 198 | + | assert.Equal(t, "A bug", put.Title.String()) | |
| 199 | + | assert.Equal(t, "It breaks.", put.Body.String()) | |
| 200 | + | assert.Equal(t, "me", put.Author) | |
| 201 | + | assert.Equal(t, f.repo.Name, put.Repo) | |
| 202 | + | assert.Len(t, put.IPHash, domain.IPHashLen) | |
| 203 | + | assert.False(t, strings.Contains(string(put.IPHash), f.addr)) | |
| 204 | + | assert.Equal(t, f.now, put.CreatedAt) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // TestIssueSubmitRefusals: each failing check returns its sentinel; the | |
| 208 | + | // strict mocks fail the test on any Put, Record or Publish. | |
| 209 | + | func TestIssueSubmitRefusals(t *testing.T) { | |
| 210 | + | t.Parallel() | |
| 211 | + | type tc struct { | |
| 212 | + | name string | |
| 213 | + | want error | |
| 214 | + | arrange func(f *subFixture, ch *ports.IssueChallenge, sub *ports.IssueSubmission) (addr string) | |
| 215 | + | wait time.Duration | |
| 216 | + | setup func(f *subFixture) // replaces f.open | |
| 217 | + | } | |
| 218 | + | wait := usecase.MinFillTime | |
| 219 | + | cases := []tc{ | |
| 220 | + | {name: "banned", want: domain.ErrSubmissionInvalid, wait: wait, setup: func(f *subFixture) { | |
| 221 | + | f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil) | |
| 222 | + | }}, | |
| 223 | + | {name: "rate limited", want: domain.ErrSubmissionRateLimited, wait: wait, setup: func(f *subFixture) { | |
| 224 | + | f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil) | |
| 225 | + | f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(false, 5*time.Minute, func() {}) | |
| 226 | + | }}, | |
| 227 | + | {name: "paused at 200", want: domain.ErrSubmissionsPaused, wait: wait, setup: func(f *subFixture) { | |
| 228 | + | f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil) | |
| 229 | + | f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}) | |
| 230 | + | f.pending.EXPECT().Count(mock.Anything).Return(domain.MaxPendingIssues, nil) | |
| 231 | + | }}, | |
| 232 | + | {name: "honeypot", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 233 | + | s.Honeypot = "http://spam" | |
| 234 | + | return "" | |
| 235 | + | }}, | |
| 236 | + | {name: "forged signature", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 237 | + | p, sig, _ := strings.Cut(s.Token, ".") | |
| 238 | + | b := []byte(sig) | |
| 239 | + | b[0] ^= 1 // still base64url? flip between 'A'/'B' | |
| 240 | + | if b[0] > 'z' || b[0] < '0' { | |
| 241 | + | b[0] = 'A' | |
| 242 | + | } | |
| 243 | + | if string(b) == sig { | |
| 244 | + | b[0] = 'B' | |
| 245 | + | } | |
| 246 | + | s.Token = p + "." + string(b) | |
| 247 | + | return "" | |
| 248 | + | }}, | |
| 249 | + | {name: "forged payload", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 250 | + | _, sig, _ := strings.Cut(s.Token, ".") | |
| 251 | + | s.Token = base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"d":1}`)) + "." + sig | |
| 252 | + | return "" | |
| 253 | + | }}, | |
| 254 | + | {name: "token of another repo", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 255 | + | // a valid token for "other", sent to "proj" | |
| 256 | + | other := name(f.t, "other") | |
| 257 | + | f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil) | |
| 258 | + | f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(0, 0).Once() | |
| 259 | + | c2, err := f.uc.Challenge(context.Background(), other, f.addr) | |
| 260 | + | require.NoError(f.t, err) | |
| 261 | + | *s = f.valid(c2) | |
| 262 | + | return "" | |
| 263 | + | }}, | |
| 264 | + | {name: "expired", want: domain.ErrSubmissionInvalid, wait: usecase.ChallengeTTL + time.Second}, | |
| 265 | + | {name: "other ip", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, _ *ports.IssueChallenge, _ *ports.IssueSubmission) string { | |
| 266 | + | return "ip:198.51.100.9" | |
| 267 | + | }}, | |
| 268 | + | {name: "too fast", want: domain.ErrSubmissionInvalid, wait: wait - time.Second}, | |
| 269 | + | {name: "wrong form token", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 270 | + | s.FormToken = "AAAA" | |
| 271 | + | return "" | |
| 272 | + | }}, | |
| 273 | + | {name: "insufficient pow", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 274 | + | // a counter that does not solve it | |
| 275 | + | nonce, _ := base64.RawURLEncoding.DecodeString(ch.Nonce) | |
| 276 | + | for i := 0; ; i++ { | |
| 277 | + | sum := sha256.Sum256(append(append(nonce, ':'), strconv.Itoa(i)...)) | |
| 278 | + | if zeroBits(sum[:]) < ch.Difficulty { | |
| 279 | + | s.Solution = strconv.Itoa(i) | |
| 280 | + | return "" | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | }}, | |
| 284 | + | {name: "non digit solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 285 | + | s.Solution = "12a" | |
| 286 | + | return "" | |
| 287 | + | }}, | |
| 288 | + | {name: "empty solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 289 | + | s.Solution = "" | |
| 290 | + | return "" | |
| 291 | + | }}, | |
| 292 | + | {name: "huge solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string { | |
| 293 | + | s.Solution = strings.Repeat("1", 21) | |
| 294 | + | return "" | |
| 295 | + | }}, | |
| 296 | + | } | |
| 297 | + | for _, c := range cases { | |
| 298 | + | t.Run(c.name, func(t *testing.T) { | |
| 299 | + | t.Parallel() | |
| 300 | + | f := newSubFixture(t) | |
| 301 | + | if c.setup != nil { | |
| 302 | + | c.setup(f) | |
| 303 | + | } else { | |
| 304 | + | f.open(0) | |
| 305 | + | } | |
| 306 | + | ch := f.challenge(f.addr) | |
| 307 | + | sub := f.valid(ch) | |
| 308 | + | addr := f.addr | |
| 309 | + | if c.arrange != nil { | |
| 310 | + | if a := c.arrange(f, &ch, &sub); a != "" { | |
| 311 | + | addr = a | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | f.advance(c.wait) | |
| 315 | + | err := f.uc.Submit(context.Background(), f.repo.Name, addr, sub) | |
| 316 | + | require.ErrorIs(t, err, c.want) | |
| 317 | + | assert.ErrorIs(t, err, domain.ErrSubmissionRefused) | |
| 318 | + | if c.want == domain.ErrSubmissionRateLimited { | |
| 319 | + | var re *domain.SubmissionRetryError | |
| 320 | + | require.ErrorAs(t, err, &re) | |
| 321 | + | assert.Equal(t, 5*time.Minute, re.After) | |
| 322 | + | } | |
| 323 | + | }) | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestIssueSubmitReplayedNonce(t *testing.T) { | |
| 328 | + | t.Parallel() | |
| 329 | + | f := newSubFixture(t) | |
| 330 | + | f.open(0) | |
| 331 | + | ch := f.challenge(f.addr) | |
| 332 | + | sub := f.valid(ch) | |
| 333 | + | f.advance(usecase.MinFillTime) | |
| 334 | + | // first: text refused, nonce is spent anyway (nothing reserved) | |
| 335 | + | bad := sub | |
| 336 | + | bad.Title = "" | |
| 337 | + | var te *domain.TextError | |
| 338 | + | require.ErrorAs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, bad), &te) | |
| 339 | + | assert.Equal(t, 1, f.uc.SpentLen()) | |
| 340 | + | err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub) | |
| 341 | + | assert.ErrorIs(t, err, domain.ErrSubmissionInvalid) | |
| 342 | + | assert.False(t, errors.Is(err, domain.ErrInvalidText)) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func TestIssueSubmitReplayAfterSuccess(t *testing.T) { | |
| 346 | + | t.Parallel() | |
| 347 | + | f := newSubFixture(t) | |
| 348 | + | f.open(0) | |
| 349 | + | ch := f.challenge(f.addr) | |
| 350 | + | sub := f.valid(ch) | |
| 351 | + | f.advance(usecase.MinFillTime) | |
| 352 | + | f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once() | |
| 353 | + | f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once() | |
| 354 | + | require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)) | |
| 355 | + | assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionInvalid) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func TestIssueSubmitTextErrors(t *testing.T) { | |
| 359 | + | t.Parallel() | |
| 360 | + | for name_, mod := range map[string]func(*ports.IssueSubmission){ | |
| 361 | + | "title": func(s *ports.IssueSubmission) { s.Title = "two\nlines" }, | |
| 362 | + | "body": func(s *ports.IssueSubmission) { s.Body = strings.Repeat("x", domain.MaxIssueBodyLen+1) }, | |
| 363 | + | "author": func(s *ports.IssueSubmission) { s.Author = strings.Repeat("x", domain.MaxAuthorLabelLen+1) }, | |
| 364 | + | } { | |
| 365 | + | t.Run(name_, func(t *testing.T) { | |
| 366 | + | t.Parallel() | |
| 367 | + | f := newSubFixture(t) | |
| 368 | + | f.open(0) | |
| 369 | + | ch := f.challenge(f.addr) | |
| 370 | + | sub := f.valid(ch) | |
| 371 | + | mod(&sub) | |
| 372 | + | f.advance(usecase.MinFillTime) | |
| 373 | + | err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub) | |
| 374 | + | var te *domain.TextError | |
| 375 | + | require.ErrorAs(t, err, &te) | |
| 376 | + | assert.NotErrorIs(t, err, domain.ErrSubmissionRefused) | |
| 377 | + | }) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestIssueSubmitSpentSetFull(t *testing.T) { | |
| 382 | + | t.Parallel() | |
| 383 | + | f := newSubFixture(t) | |
| 384 | + | f.open(0) | |
| 385 | + | ch := f.challenge(f.addr) | |
| 386 | + | sub := f.valid(ch) | |
| 387 | + | f.advance(usecase.MinFillTime) | |
| 388 | + | f.uc.FillSpent(usecase.MaxSpent, f.now.Add(time.Hour)) | |
| 389 | + | assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused) | |
| 390 | + | assert.Equal(t, usecase.MaxSpent, f.uc.SpentLen()) // nothing live evicted | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func TestIssueSubmitSpentSetExpiry(t *testing.T) { | |
| 394 | + | t.Parallel() | |
| 395 | + | f := newSubFixture(t) | |
| 396 | + | f.open(0) | |
| 397 | + | f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 398 | + | f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe() | |
| 399 | + | ch := f.challenge(f.addr) | |
| 400 | + | sub := f.valid(ch) | |
| 401 | + | f.advance(usecase.MinFillTime) | |
| 402 | + | require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)) | |
| 403 | + | assert.Equal(t, 1, f.uc.SpentLen()) | |
| 404 | + | f.advance(usecase.ChallengeTTL + 2*time.Minute) | |
| 405 | + | ch2 := f.challenge(f.addr) | |
| 406 | + | sub2 := f.valid(ch2) | |
| 407 | + | f.advance(usecase.MinFillTime) | |
| 408 | + | require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub2)) | |
| 409 | + | assert.Equal(t, 1, f.uc.SpentLen(), "the expired nonce was swept") | |
| 410 | + | ||
| 411 | + | // a full set of expired nonces is swept, not a reason to pause | |
| 412 | + | f.uc.FillSpent(usecase.MaxSpent, f.now.Add(-time.Second)) | |
| 413 | + | ch3 := f.challenge(f.addr) | |
| 414 | + | sub3 := f.valid(ch3) | |
| 415 | + | f.advance(usecase.MinFillTime) | |
| 416 | + | require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub3)) | |
| 417 | + | assert.Equal(t, 2, f.uc.SpentLen(), "expired fillers swept, live nonces kept") | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func TestIssueSubmitGarbageDoesNotPanic(t *testing.T) { | |
| 421 | + | t.Parallel() | |
| 422 | + | f := newSubFixture(t) | |
| 423 | + | f.open(0) | |
| 424 | + | ch := f.challenge(f.addr) | |
| 425 | + | p, sig, _ := strings.Cut(ch.Token, ".") | |
| 426 | + | huge := strings.Repeat("A", 1<<20) | |
| 427 | + | tokens := []string{ | |
| 428 | + | "", ".", "..", "a", "a.b", "!!!.???", huge, huge + "." + huge, p, p + ".", "." + sig, | |
| 429 | + | p + "." + sig + "." + sig, p + "." + sig[:len(sig)-3], p[:len(p)/2] + "." + sig, | |
| 430 | + | base64.RawURLEncoding.EncodeToString([]byte("null")) + "." + sig, | |
| 431 | + | base64.StdEncoding.EncodeToString([]byte("{}")) + "==." + sig, "\x00\xff.\x00", | |
| 432 | + | } | |
| 433 | + | f.advance(usecase.MinFillTime) | |
| 434 | + | for _, tok := range tokens { | |
| 435 | + | for _, s := range []ports.IssueSubmission{ | |
| 436 | + | {Token: tok}, | |
| 437 | + | {Token: tok, Solution: huge, FormToken: huge, Title: huge, Body: huge, Author: huge}, | |
| 438 | + | {Token: tok, Solution: "0", FormToken: "\xff"}, | |
| 439 | + | } { | |
| 440 | + | err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s) | |
| 441 | + | assert.ErrorIs(t, err, domain.ErrSubmissionInvalid) | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | // the genuine token with garbage around it | |
| 445 | + | for _, s := range []ports.IssueSubmission{ | |
| 446 | + | {Token: ch.Token, FormToken: huge, Solution: "1"}, | |
| 447 | + | {Token: ch.Token, FormToken: ch.FormToken, Solution: huge}, | |
| 448 | + | } { | |
| 449 | + | assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, s), domain.ErrSubmissionInvalid) | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func TestIssueSubmissionHonoursContext(t *testing.T) { | |
| 454 | + | t.Parallel() | |
| 455 | + | f := newSubFixture(t) | |
| 456 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 457 | + | cancel() | |
| 458 | + | assert.ErrorIs(t, f.uc.Submit(ctx, f.repo.Name, f.addr, ports.IssueSubmission{}), context.Canceled) | |
| 459 | + | _, err := f.uc.Challenge(ctx, f.repo.Name, f.addr) | |
| 460 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func TestDifficulty(t *testing.T) { | |
| 464 | + | t.Parallel() | |
| 465 | + | for _, c := range []struct{ perAddr, global, want int }{ | |
| 466 | + | {0, 0, 18}, {1, 0, 18}, {2, 0, 19}, {3, 0, 19}, {4, 0, 20}, {6, 0, 21}, {100, 0, 21}, | |
| 467 | + | {0, 19, 18}, {0, 20, 19}, {0, 40, 20}, {0, 60, 21}, {0, 1000, 21}, | |
| 468 | + | {2, 20, 20}, {4, 20, 21}, {-5, -5, 18}, | |
| 469 | + | } { | |
| 470 | + | assert.Equal(t, c.want, usecase.DifficultyFor(c.perAddr, c.global), "%d/%d", c.perAddr, c.global) | |
| 471 | + | } | |
| 472 | + | assert.Equal(t, 18, usecase.BaseDifficulty) | |
| 473 | + | assert.Equal(t, 21, usecase.MaxDifficulty) | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // submitters builds n valid, distinct submissions (one challenge each) for the | |
| 477 | + | // addresses addrOf(i) and the repositories repoOf(i), ready to be sent. | |
| 478 | + | type prepared struct { | |
| 479 | + | repo domain.RepoName | |
| 480 | + | addr string | |
| 481 | + | sub ports.IssueSubmission | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func (f *subFixture) prepare(n int, addrOf func(int) string, repoOf func(int) domain.RepoName) []prepared { | |
| 485 | + | out := make([]prepared, n) | |
| 486 | + | for i := range n { | |
| 487 | + | addr := addrOf(i) | |
| 488 | + | repo := repoOf(i) | |
| 489 | + | f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once() | |
| 490 | + | ch, err := f.uc.Challenge(context.Background(), repo, addr) | |
| 491 | + | require.NoError(f.t, err) | |
| 492 | + | out[i] = prepared{repo: repo, addr: addr, sub: f.valid(ch)} | |
| 493 | + | } | |
| 494 | + | f.advance(usecase.MinFillTime) | |
| 495 | + | return out | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // race sends every prepared submission at once and returns the errors. | |
| 499 | + | func (f *subFixture) race(ps []prepared) []error { | |
| 500 | + | errs := make([]error, len(ps)) | |
| 501 | + | var wg sync.WaitGroup | |
| 502 | + | start := make(chan struct{}) | |
| 503 | + | for i, p := range ps { | |
| 504 | + | wg.Add(1) | |
| 505 | + | go func() { | |
| 506 | + | defer wg.Done() | |
| 507 | + | <-start | |
| 508 | + | errs[i] = f.uc.Submit(context.Background(), p.repo, p.addr, p.sub) | |
| 509 | + | }() | |
| 510 | + | } | |
| 511 | + | close(start) | |
| 512 | + | wg.Wait() | |
| 513 | + | return errs | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | func TestIssueSubmitConcurrentSameAddrRespectsQuota(t *testing.T) { | |
| 517 | + | t.Parallel() | |
| 518 | + | f := newSubFixture(t) | |
| 519 | + | f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe() | |
| 520 | + | f.pending.EXPECT().Count(mock.Anything).Return(0, nil).Maybe() | |
| 521 | + | // a real, atomic limiter semantics: PerHour grants then refusals | |
| 522 | + | var granted atomic.Int32 | |
| 523 | + | var rlMu sync.Mutex | |
| 524 | + | f.limiter.EXPECT().Reserve(mock.Anything, f.addr).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) { | |
| 525 | + | rlMu.Lock() | |
| 526 | + | defer rlMu.Unlock() | |
| 527 | + | if granted.Load() >= 3 { | |
| 528 | + | return false, time.Minute, func() {} | |
| 529 | + | } | |
| 530 | + | granted.Add(1) | |
| 531 | + | return true, 0, func() {} | |
| 532 | + | }).Maybe() | |
| 533 | + | var puts atomic.Int32 | |
| 534 | + | f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error { | |
| 535 | + | puts.Add(1) | |
| 536 | + | return nil | |
| 537 | + | }).Maybe() | |
| 538 | + | f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe() | |
| 539 | + | ps := f.prepare(20, func(int) string { return f.addr }, func(int) domain.RepoName { return f.repo.Name }) | |
| 540 | + | var ok, limited int | |
| 541 | + | for _, err := range f.race(ps) { | |
| 542 | + | var re *domain.SubmissionRetryError | |
| 543 | + | switch { | |
| 544 | + | case err == nil: | |
| 545 | + | ok++ | |
| 546 | + | case errors.As(err, &re): | |
| 547 | + | limited++ | |
| 548 | + | default: | |
| 549 | + | t.Fatalf("unexpected error: %v", err) | |
| 550 | + | } | |
| 551 | + | } | |
| 552 | + | assert.Equal(t, 3, ok) | |
| 553 | + | assert.Equal(t, 17, limited) | |
| 554 | + | assert.Equal(t, int32(3), puts.Load()) | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | func TestIssueSubmitConcurrentRespectsPendingCap(t *testing.T) { | |
| 558 | + | t.Parallel() | |
| 559 | + | f := newSubFixture(t) | |
| 560 | + | f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe() | |
| 561 | + | f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe() | |
| 562 | + | other := name(t, "other") | |
| 563 | + | f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe() | |
| 564 | + | var count atomic.Int32 | |
| 565 | + | count.Store(domain.MaxPendingIssues - 1) | |
| 566 | + | f.pending.EXPECT().Count(mock.Anything).RunAndReturn(func(context.Context) (int, error) { | |
| 567 | + | return int(count.Load()), nil | |
| 568 | + | }).Maybe() | |
| 569 | + | var puts atomic.Int32 | |
| 570 | + | f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error { | |
| 571 | + | puts.Add(1) | |
| 572 | + | count.Add(1) | |
| 573 | + | return nil | |
| 574 | + | }).Maybe() | |
| 575 | + | f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe() | |
| 576 | + | ps := f.prepare(20, func(i int) string { return "ip:198.51.100." + strconv.Itoa(i+1) }, func(i int) domain.RepoName { | |
| 577 | + | if i%2 == 0 { | |
| 578 | + | return f.repo.Name | |
| 579 | + | } | |
| 580 | + | return other | |
| 581 | + | }) | |
| 582 | + | var ok, paused int | |
| 583 | + | for _, err := range f.race(ps) { | |
| 584 | + | switch { | |
| 585 | + | case err == nil: | |
| 586 | + | ok++ | |
| 587 | + | case errors.Is(err, domain.ErrSubmissionsPaused): | |
| 588 | + | paused++ | |
| 589 | + | default: | |
| 590 | + | t.Fatalf("unexpected error: %v", err) | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | assert.Equal(t, 1, ok) | |
| 594 | + | assert.Equal(t, 19, paused) | |
| 595 | + | assert.Equal(t, int32(1), puts.Load()) | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func TestIssueSubmitReleasesTheReservationWhenStoreFails(t *testing.T) { | |
| 599 | + | t.Parallel() | |
| 600 | + | f := newSubFixture(t) | |
| 601 | + | released := f.open(0) | |
| 602 | + | boom := errors.New("disk full") | |
| 603 | + | f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(boom).Once() | |
| 604 | + | ch := f.challenge(f.addr) | |
| 605 | + | sub := f.valid(ch) | |
| 606 | + | f.advance(usecase.MinFillTime) | |
| 607 | + | require.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), boom) | |
| 608 | + | assert.Equal(t, int32(1), released.Load()) | |
| 609 | + | ||
| 610 | + | f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once() | |
| 611 | + | f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once() | |
| 612 | + | ch = f.challenge(f.addr) | |
| 613 | + | sub = f.valid(ch) | |
| 614 | + | f.advance(usecase.MinFillTime) | |
| 615 | + | require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)) | |
| 616 | + | assert.Equal(t, int32(1), released.Load(), "a stored submission keeps its reservation") | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | func TestIssueSubmitReleasesTheReservationWhenPaused(t *testing.T) { | |
| 620 | + | t.Parallel() | |
| 621 | + | f := newSubFixture(t) | |
| 622 | + | released := f.open(domain.MaxPendingIssues) | |
| 623 | + | ch := f.challenge(f.addr) | |
| 624 | + | sub := f.valid(ch) | |
| 625 | + | f.advance(usecase.MinFillTime) | |
| 626 | + | assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused) | |
| 627 | + | assert.Equal(t, int32(1), released.Load()) | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // TestIssueSubmitNoStateOracleBeforeProofOfWork: a banned client, a limited | |
| 631 | + | // one, or a full queue all get the same answer to an invalid submission, and | |
| 632 | + | // no limiter, count or store is touched (the mocks have no expectation). | |
| 633 | + | func TestIssueSubmitNoStateOracleBeforeProofOfWork(t *testing.T) { | |
| 634 | + | t.Parallel() | |
| 635 | + | f := newSubFixture(t) | |
| 636 | + | ch := f.challenge(f.addr) | |
| 637 | + | valid := f.valid(ch) | |
| 638 | + | f.advance(usecase.MinFillTime) | |
| 639 | + | bad := []ports.IssueSubmission{ | |
| 640 | + | {}, | |
| 641 | + | {Token: "garbage", Solution: "x", FormToken: "y", Title: "", Body: ""}, | |
| 642 | + | {Token: ch.Token, Honeypot: "x"}, | |
| 643 | + | {Token: ch.Token, FormToken: ch.FormToken, Solution: "nope"}, | |
| 644 | + | {Token: valid.Token, FormToken: valid.FormToken, Solution: "abc", Title: "two\nlines"}, // text error + bad pow | |
| 645 | + | } | |
| 646 | + | for _, s := range bad { | |
| 647 | + | err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s) | |
| 648 | + | require.ErrorIs(t, err, domain.ErrSubmissionInvalid) | |
| 649 | + | assert.NotErrorIs(t, err, domain.ErrInvalidText) | |
| 650 | + | } | |
| 651 | + | // a fully valid submission from a banned client: still invalid, not | |
| 652 | + | // limited, paused or a text error, even with bad text. | |
| 653 | + | f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil).Once() | |
| 654 | + | bannedBadText := valid | |
| 655 | + | bannedBadText.Title = "" | |
| 656 | + | err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, bannedBadText) | |
| 657 | + | require.ErrorIs(t, err, domain.ErrSubmissionInvalid) | |
| 658 | + | assert.NotErrorIs(t, err, domain.ErrInvalidText) | |
| 659 | + | var re *domain.SubmissionRetryError | |
| 660 | + | assert.False(t, errors.As(err, &re)) | |
| 661 | + | assert.NotErrorIs(t, err, domain.ErrSubmissionsPaused) | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | func TestIssueChallengeRevealsNoState(t *testing.T) { | |
| 665 | + | t.Parallel() | |
| 666 | + | f := newSubFixture(t) // no Banned, Reserve or Count expectation | |
| 667 | + | f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(50, 500).Once() | |
| 668 | + | _, err := f.uc.Challenge(context.Background(), f.repo.Name, f.addr) | |
| 669 | + | require.NoError(t, err) | |
| 670 | + | } | |
A internal/usecase/issues.go +291 -0
| @@ -0,0 +1,291 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // MaxCommentsPerIssue is the most comments an issue holds. Past it AddComment | |
| 17 | + | // fails with domain.ErrResourceExhausted. Comments are never deleted, so the | |
| 18 | + | // limit counts the ones ever added. | |
| 19 | + | const MaxCommentsPerIssue = 1000 | |
| 20 | + | ||
| 21 | + | // Issues are the issue use cases (ADR 0031): open, comment on, close and | |
| 22 | + | // reopen an issue. Issues share their numbers with the merge requests of the | |
| 23 | + | // repository. A repository whose issues mode is off answers domain.ErrNotFound | |
| 24 | + | // to everyone, the owner included, like a repository that does not exist. | |
| 25 | + | // Reading needs the access of reading the repository (canRead), writing needs | |
| 26 | + | // issue:write on it, which the branch prefixes of a token do not restrict: | |
| 27 | + | // an issue writes no ref. | |
| 28 | + | type Issues struct { | |
| 29 | + | meta ports.RepoMetaStore | |
| 30 | + | store ports.IssueStore | |
| 31 | + | mrs ports.MergeRequestStore | |
| 32 | + | bus ports.EventBus | |
| 33 | + | clock ports.Clock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | var ( | |
| 37 | + | _ ports.IssueService = (*Issues)(nil) | |
| 38 | + | _ ports.IssueNumberResolver = (*Issues)(nil) | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // IssuesConfig holds what the issue use cases need. Every field is required. | |
| 42 | + | type IssuesConfig struct { | |
| 43 | + | Meta ports.RepoMetaStore | |
| 44 | + | Store ports.IssueStore | |
| 45 | + | // MergeRequests tells which numbers name a merge request (Kind). | |
| 46 | + | MergeRequests ports.MergeRequestStore | |
| 47 | + | Bus ports.EventBus | |
| 48 | + | Clock ports.Clock | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // NewIssues builds the issue use cases. | |
| 52 | + | func NewIssues(cfg IssuesConfig) *Issues { | |
| 53 | + | return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // enabled looks the repository up and hides it when issues are off. | |
| 57 | + | func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) { | |
| 58 | + | repo, err := lookupRepo(ctx, i.meta, name) | |
| 59 | + | if err != nil { | |
| 60 | + | return domain.Repo{}, err | |
| 61 | + | } | |
| 62 | + | if repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 63 | + | return domain.Repo{}, domain.ErrNotFound | |
| 64 | + | } | |
| 65 | + | return repo, nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // readable applies the read rule: the access of reading the repository. | |
| 69 | + | func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 70 | + | repo, err := i.enabled(ctx, name) | |
| 71 | + | if err != nil { | |
| 72 | + | return domain.Repo{}, err | |
| 73 | + | } | |
| 74 | + | if !canRead(p, repo) { | |
| 75 | + | return domain.Repo{}, domain.ErrNotFound | |
| 76 | + | } | |
| 77 | + | return repo, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // writable is the write rule, as MergeRequests.writable: a principal that can | |
| 81 | + | // neither read the repository nor holds the scope on it gets | |
| 82 | + | // domain.ErrNotFound, one that cannot write the error of scopeAccess. | |
| 83 | + | func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 84 | + | repo, err := i.enabled(ctx, name) | |
| 85 | + | if err != nil { | |
| 86 | + | return domain.Repo{}, err | |
| 87 | + | } | |
| 88 | + | if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) { | |
| 89 | + | return domain.Repo{}, domain.ErrNotFound | |
| 90 | + | } | |
| 91 | + | if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil { | |
| 92 | + | return domain.Repo{}, err | |
| 93 | + | } | |
| 94 | + | return repo, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Create opens an issue and publishes IssueOpened. The principal needs | |
| 98 | + | // issue:write. Invalid texts give an *domain.InvalidFieldsError. | |
| 99 | + | func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) { | |
| 100 | + | repo, err := i.writable(ctx, p, name) | |
| 101 | + | if err != nil { | |
| 102 | + | return domain.Issue{}, err | |
| 103 | + | } | |
| 104 | + | var v domain.Violations | |
| 105 | + | title, err := domain.ParseIssueTitle(params.Title) | |
| 106 | + | if err != nil { | |
| 107 | + | v.AddErr("title", err) | |
| 108 | + | } | |
| 109 | + | body, err := domain.ParseIssueBody(params.Body) | |
| 110 | + | if err != nil { | |
| 111 | + | v.AddErr("body", err) | |
| 112 | + | } | |
| 113 | + | if err := v.Err(); err != nil { | |
| 114 | + | return domain.Issue{}, err | |
| 115 | + | } | |
| 116 | + | now := i.clock.Now().UTC() | |
| 117 | + | issue, err := i.store.Create(ctx, domain.Issue{ | |
| 118 | + | Repo: name, Title: title, Body: body, State: domain.IssueStateOpen, | |
| 119 | + | Author: authorOf(p), CreatedAt: now, UpdatedAt: now, | |
| 120 | + | }) | |
| 121 | + | if err != nil { | |
| 122 | + | return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err) | |
| 123 | + | } | |
| 124 | + | log := zerowrap.FromCtx(ctx) | |
| 125 | + | log.Info().Str("repo", name.String()).Int64("number", issue.Number). | |
| 126 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened") | |
| 127 | + | i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 128 | + | return issue, nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // List returns a page of the issues of a repository, highest number first. | |
| 132 | + | func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) { | |
| 133 | + | size, err := pageSize(params.PageSize) | |
| 134 | + | if err != nil { | |
| 135 | + | return ports.IssuePage{}, err | |
| 136 | + | } | |
| 137 | + | if _, err := i.readable(ctx, p, name); err != nil { | |
| 138 | + | return ports.IssuePage{}, err | |
| 139 | + | } | |
| 140 | + | params.PageSize = size | |
| 141 | + | page, err := i.store.List(ctx, name, params) | |
| 142 | + | if err != nil { | |
| 143 | + | return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err) | |
| 144 | + | } | |
| 145 | + | return page, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Get returns an issue with its comments. | |
| 149 | + | func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) { | |
| 150 | + | if _, err := i.readable(ctx, p, name); err != nil { | |
| 151 | + | return ports.IssueDetail{}, err | |
| 152 | + | } | |
| 153 | + | issue, err := i.store.Get(ctx, name, number) | |
| 154 | + | if err != nil { | |
| 155 | + | return ports.IssueDetail{}, err | |
| 156 | + | } | |
| 157 | + | comments, err := i.store.ListComments(ctx, name, number) | |
| 158 | + | if err != nil { | |
| 159 | + | return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err) | |
| 160 | + | } | |
| 161 | + | return ports.IssueDetail{Issue: issue, Comments: comments}, nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // AddComment adds a comment and publishes IssueCommented. The principal needs | |
| 165 | + | // issue:write. The text must not be blank. A comment may follow a closed | |
| 166 | + | // issue; one that already holds MaxCommentsPerIssue comments gives | |
| 167 | + | // domain.ErrResourceExhausted. | |
| 168 | + | func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) { | |
| 169 | + | repo, err := i.writable(ctx, p, name) | |
| 170 | + | if err != nil { | |
| 171 | + | return domain.IssueComment{}, err | |
| 172 | + | } | |
| 173 | + | issue, err := i.store.Get(ctx, name, number) | |
| 174 | + | if err != nil { | |
| 175 | + | return domain.IssueComment{}, err | |
| 176 | + | } | |
| 177 | + | var v domain.Violations | |
| 178 | + | body, err := domain.ParseIssueBody(text) | |
| 179 | + | switch { | |
| 180 | + | case err != nil: | |
| 181 | + | v.AddErr("text", err) | |
| 182 | + | case body.IsBlank(): | |
| 183 | + | v.Add("text", "is required") | |
| 184 | + | } | |
| 185 | + | if err := v.Err(); err != nil { | |
| 186 | + | return domain.IssueComment{}, err | |
| 187 | + | } | |
| 188 | + | var c domain.IssueComment | |
| 189 | + | saved := false | |
| 190 | + | for range maxWriteRetries { | |
| 191 | + | if issue.CommentSeq >= MaxCommentsPerIssue { | |
| 192 | + | return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue) | |
| 193 | + | } | |
| 194 | + | c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: authorOf(p), Text: body, CreatedAt: i.clock.Now().UTC()} | |
| 195 | + | issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt | |
| 196 | + | err = i.store.SaveComment(ctx, issue, c) | |
| 197 | + | if err == nil { | |
| 198 | + | saved = true | |
| 199 | + | break | |
| 200 | + | } | |
| 201 | + | if !errors.Is(err, domain.ErrConflict) { | |
| 202 | + | return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err) | |
| 203 | + | } | |
| 204 | + | if issue, err = i.store.Get(ctx, name, number); err != nil { | |
| 205 | + | return domain.IssueComment{}, err | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | if !saved { | |
| 209 | + | return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict) | |
| 210 | + | } | |
| 211 | + | log := zerowrap.FromCtx(ctx) | |
| 212 | + | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | |
| 213 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added") | |
| 214 | + | i.bus.Publish(ctx, domain.IssueCommented{ | |
| 215 | + | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 216 | + | }) | |
| 217 | + | return c, nil | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // Close closes an open issue and publishes IssueClosed. The principal needs | |
| 221 | + | // issue:write. An issue that is not open gives domain.ErrPreconditionFailed. | |
| 222 | + | func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) { | |
| 223 | + | return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // Reopen reopens a closed issue and publishes IssueReopened. The principal | |
| 227 | + | // needs issue:write. An issue that is not closed gives | |
| 228 | + | // domain.ErrPreconditionFailed. | |
| 229 | + | func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) { | |
| 230 | + | return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // transition moves an issue from one state to the other, retrying when another | |
| 234 | + | // write changes it in between. | |
| 235 | + | func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) { | |
| 236 | + | repo, err := i.writable(ctx, p, name) | |
| 237 | + | if err != nil { | |
| 238 | + | return domain.Issue{}, err | |
| 239 | + | } | |
| 240 | + | verb := "close" | |
| 241 | + | if to == domain.IssueStateOpen { | |
| 242 | + | verb = "reopen" | |
| 243 | + | } | |
| 244 | + | for range maxWriteRetries { | |
| 245 | + | issue, err := i.store.Get(ctx, name, number) | |
| 246 | + | if err != nil { | |
| 247 | + | return domain.Issue{}, err | |
| 248 | + | } | |
| 249 | + | if issue.State != from { | |
| 250 | + | return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State) | |
| 251 | + | } | |
| 252 | + | issue.State, issue.UpdatedAt = to, i.clock.Now().UTC() | |
| 253 | + | err = i.store.Update(ctx, issue) | |
| 254 | + | if errors.Is(err, domain.ErrConflict) { | |
| 255 | + | continue | |
| 256 | + | } | |
| 257 | + | if err != nil { | |
| 258 | + | return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err) | |
| 259 | + | } | |
| 260 | + | log := zerowrap.FromCtx(ctx) | |
| 261 | + | log.Info().Str("repo", name.String()).Int64("number", number). | |
| 262 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d") | |
| 263 | + | if to == domain.IssueStateOpen { | |
| 264 | + | i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 265 | + | } else { | |
| 266 | + | i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 267 | + | } | |
| 268 | + | return issue, nil | |
| 269 | + | } | |
| 270 | + | return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Kind tells what number n of the repository names. The principal needs the | |
| 274 | + | // access of reading the repository, and issues must be on there: otherwise it | |
| 275 | + | // is domain.ErrNotFound. | |
| 276 | + | func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | |
| 277 | + | if _, err := i.readable(ctx, p, name); err != nil { | |
| 278 | + | return domain.NumberNone, err | |
| 279 | + | } | |
| 280 | + | if _, err := i.store.Get(ctx, name, n); err == nil { | |
| 281 | + | return domain.NumberIssue, nil | |
| 282 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 283 | + | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | |
| 284 | + | } | |
| 285 | + | if _, err := i.mrs.Get(ctx, name, n); err == nil { | |
| 286 | + | return domain.NumberMergeRequest, nil | |
| 287 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 288 | + | return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err) | |
| 289 | + | } | |
| 290 | + | return domain.NumberNone, nil | |
| 291 | + | } | |
A internal/usecase/issues_integration_test.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 14 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Issues over the real store: shared numbers with merge requests, the life of | |
| 21 | + | // an issue and its events. | |
| 22 | + | func TestIssuesOverTheStore(t *testing.T) { | |
| 23 | + | ctx := context.Background() | |
| 24 | + | store, err := kvstore.Open(ctx, filepath.Join(t.TempDir(), "kv")) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) }) | |
| 27 | + | bus := eventbus.NewWithBuffer(64) | |
| 28 | + | events := bus.Subscribe(ctx) | |
| 29 | + | n := name(t, "proj") | |
| 30 | + | settings := domain.DefaultRepoSettings() | |
| 31 | + | settings.Issues = domain.IssuesOwner | |
| 32 | + | require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings})) | |
| 33 | + | ||
| 34 | + | issues := usecase.NewIssues(usecase.IssuesConfig{Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: bus, Clock: fixedClock{}}) | |
| 35 | + | agent := domain.TokenPrincipal(domain.Token{ID: "t", Label: "bot", Scopes: []domain.Scope{domain.ScopeIssueWrite}, BranchPrefixes: []string{"x/"}}) | |
| 36 | + | ||
| 37 | + | mrTitle, err := domain.ParseMRTitle("Change") | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | mr, err := store.MergeRequests().Create(ctx, domain.MergeRequest{ | |
| 40 | + | Repo: n, Title: mrTitle, State: domain.MRStateOpen, Source: "feat", Target: "main", Author: "a", CreatedAt: now, UpdatedAt: now, | |
| 41 | + | }) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.EqualValues(t, 1, mr.Number) | |
| 44 | + | ||
| 45 | + | issue, err := issues.Create(ctx, agent, n, ports.CreateIssueParams{Title: "Crash", Body: "details"}) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | assert.EqualValues(t, 2, issue.Number, "the number follows the merge request") | |
| 48 | + | assert.Equal(t, "bot", issue.Author) | |
| 49 | + | ||
| 50 | + | kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | assert.Equal(t, domain.NumberMergeRequest, kind) | |
| 53 | + | kind, err = issues.Kind(ctx, domain.Anonymous(), n, 2) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | assert.Equal(t, domain.NumberIssue, kind) | |
| 56 | + | kind, err = issues.Kind(ctx, domain.Anonymous(), n, 3) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | assert.Equal(t, domain.NumberNone, kind) | |
| 59 | + | ||
| 60 | + | _, err = issues.AddComment(ctx, agent, n, 2, "me too") | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | _, err = issues.Close(ctx, agent, n, 2) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | _, err = issues.AddComment(ctx, agent, n, 2, "after close") | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | _, err = issues.Close(ctx, agent, n, 2) | |
| 67 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 68 | + | _, err = issues.Reopen(ctx, agent, n, 2) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | d, err := issues.Get(ctx, domain.Anonymous(), n, 2) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | assert.Equal(t, domain.IssueStateOpen, d.Issue.State) | |
| 74 | + | require.Len(t, d.Comments, 2) | |
| 75 | + | assert.Equal(t, "me too", d.Comments[0].Text.String()) | |
| 76 | + | assert.EqualValues(t, 2, d.Issue.CommentSeq) | |
| 77 | + | ||
| 78 | + | page, err := issues.List(ctx, domain.Anonymous(), n, ports.ListIssuesParams{State: domain.IssueStateClosed}) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Empty(t, page.Issues) | |
| 81 | + | ||
| 82 | + | var kinds []string | |
| 83 | + | for len(events) > 0 { | |
| 84 | + | kinds = append(kinds, (<-events).EventKind()) | |
| 85 | + | } | |
| 86 | + | assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds) | |
| 87 | + | } | |
A internal/usecase/issues_test.go +276 -0
| @@ -0,0 +1,276 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | "bnema.dev/ermite/internal/usecase" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type issueUnit struct { | |
| 20 | + | meta *mocks.RepoMetaStore | |
| 21 | + | store *mocks.IssueStore | |
| 22 | + | mrs *mocks.MergeRequestStore | |
| 23 | + | bus *mocks.EventBus | |
| 24 | + | issues *usecase.Issues | |
| 25 | + | repo domain.Repo | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *issueUnit { | |
| 29 | + | t.Helper() | |
| 30 | + | settings := domain.DefaultRepoSettings() | |
| 31 | + | settings.Issues = mode | |
| 32 | + | u := &issueUnit{ | |
| 33 | + | meta: mocks.NewRepoMetaStore(t), | |
| 34 | + | store: mocks.NewIssueStore(t), | |
| 35 | + | mrs: mocks.NewMergeRequestStore(t), | |
| 36 | + | bus: mocks.NewEventBus(t), | |
| 37 | + | repo: domain.Repo{Name: name(t, "proj"), Visibility: vis, Settings: settings}, | |
| 38 | + | } | |
| 39 | + | clock := mocks.NewClock(t) | |
| 40 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 41 | + | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe() | |
| 42 | + | u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock}) | |
| 43 | + | return u | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func issuePrincipal(scopes ...domain.Scope) domain.Principal { | |
| 47 | + | return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes, BranchPrefixes: []string{"agent/"}}) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestIssuesOffHidesEverything(t *testing.T) { | |
| 51 | + | t.Parallel() | |
| 52 | + | for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} { | |
| 53 | + | u := newIssueUnit(t, domain.VisibilityPublic, mode) | |
| 54 | + | ctx, n := context.Background(), u.repo.Name | |
| 55 | + | _, err := u.issues.Create(ctx, domain.Root(), n, ports.CreateIssueParams{Title: "t"}) | |
| 56 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 57 | + | _, err = u.issues.List(ctx, domain.Root(), n, ports.ListIssuesParams{}) | |
| 58 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 59 | + | _, err = u.issues.Get(ctx, domain.Owner(), n, 1) | |
| 60 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 61 | + | _, err = u.issues.AddComment(ctx, domain.Root(), n, 1, "x") | |
| 62 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 63 | + | _, err = u.issues.Close(ctx, domain.Root(), n, 1) | |
| 64 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 65 | + | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | |
| 66 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 67 | + | _, err = u.issues.Kind(ctx, domain.Root(), n, 1) | |
| 68 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestIssueAccessRules(t *testing.T) { | |
| 73 | + | t.Parallel() | |
| 74 | + | ctx := context.Background() | |
| 75 | + | t.Run("private repository hides reads", func(t *testing.T) { | |
| 76 | + | t.Parallel() | |
| 77 | + | u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner) | |
| 78 | + | _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{}) | |
| 79 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 80 | + | _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1) | |
| 81 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read") | |
| 82 | + | }) | |
| 83 | + | t.Run("writes", func(t *testing.T) { | |
| 84 | + | t.Parallel() | |
| 85 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 86 | + | p := ports.CreateIssueParams{Title: "t"} | |
| 87 | + | _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p) | |
| 88 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 89 | + | _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p) | |
| 90 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 91 | + | }) | |
| 92 | + | t.Run("private write without the scope", func(t *testing.T) { | |
| 93 | + | t.Parallel() | |
| 94 | + | u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner) | |
| 95 | + | _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"}) | |
| 96 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 97 | + | }) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestIssueCreateAndTransitions(t *testing.T) { | |
| 101 | + | t.Parallel() | |
| 102 | + | ctx := context.Background() | |
| 103 | + | p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref | |
| 104 | + | ||
| 105 | + | t.Run("create", func(t *testing.T) { | |
| 106 | + | t.Parallel() | |
| 107 | + | u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner) | |
| 108 | + | u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { | |
| 109 | + | return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" && | |
| 110 | + | i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now) | |
| 111 | + | })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil }) | |
| 112 | + | u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { | |
| 113 | + | o, ok := ev.(domain.IssueOpened) | |
| 114 | + | return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate | |
| 115 | + | })).Return() | |
| 116 | + | got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"}) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | assert.EqualValues(t, 7, got.Number) | |
| 119 | + | }) | |
| 120 | + | ||
| 121 | + | t.Run("invalid text stores nothing", func(t *testing.T) { | |
| 122 | + | t.Parallel() | |
| 123 | + | u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner) | |
| 124 | + | _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""}) | |
| 125 | + | var fe *domain.InvalidFieldsError | |
| 126 | + | require.ErrorAs(t, err, &fe) | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1} | |
| 130 | + | ||
| 131 | + | t.Run("close with every update conflicting is an error and publishes nothing", func(t *testing.T) { | |
| 132 | + | t.Parallel() | |
| 133 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 134 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil) | |
| 135 | + | u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict) | |
| 136 | + | _, err := u.issues.Close(ctx, p, u.repo.Name, 3) | |
| 137 | + | assert.ErrorIs(t, err, domain.ErrConflict) | |
| 138 | + | }) | |
| 139 | + | ||
| 140 | + | t.Run("close retries a conflict then publishes", func(t *testing.T) { | |
| 141 | + | t.Parallel() | |
| 142 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 143 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice() | |
| 144 | + | u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict).Once() | |
| 145 | + | u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).Return(nil).Once() | |
| 146 | + | u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { | |
| 147 | + | c, ok := ev.(domain.IssueClosed) | |
| 148 | + | return ok && c.Issue.State == domain.IssueStateClosed | |
| 149 | + | })).Return() | |
| 150 | + | got, err := u.issues.Close(ctx, p, u.repo.Name, 3) | |
| 151 | + | require.NoError(t, err) | |
| 152 | + | assert.Equal(t, domain.IssueStateClosed, got.State) | |
| 153 | + | }) | |
| 154 | + | ||
| 155 | + | t.Run("close of a closed issue", func(t *testing.T) { | |
| 156 | + | t.Parallel() | |
| 157 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 158 | + | closed := open | |
| 159 | + | closed.State = domain.IssueStateClosed | |
| 160 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil) | |
| 161 | + | _, err := u.issues.Close(ctx, p, u.repo.Name, 3) | |
| 162 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 163 | + | }) | |
| 164 | + | ||
| 165 | + | t.Run("reopen", func(t *testing.T) { | |
| 166 | + | t.Parallel() | |
| 167 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 168 | + | closed := open | |
| 169 | + | closed.State = domain.IssueStateClosed | |
| 170 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil) | |
| 171 | + | u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil) | |
| 172 | + | u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return() | |
| 173 | + | got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | assert.Equal(t, domain.IssueStateOpen, got.State) | |
| 176 | + | }) | |
| 177 | + | ||
| 178 | + | t.Run("reopen of an open issue", func(t *testing.T) { | |
| 179 | + | t.Parallel() | |
| 180 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 181 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil) | |
| 182 | + | _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3) | |
| 183 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func TestIssueAddComment(t *testing.T) { | |
| 188 | + | t.Parallel() | |
| 189 | + | ctx := context.Background() | |
| 190 | + | p := issuePrincipal(domain.ScopeIssueWrite) | |
| 191 | + | issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1} | |
| 192 | + | ||
| 193 | + | t.Run("every save conflicting is an error and publishes nothing", func(t *testing.T) { | |
| 194 | + | t.Parallel() | |
| 195 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 196 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil) | |
| 197 | + | u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict) | |
| 198 | + | _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi") // the bus mock fails on any Publish | |
| 199 | + | assert.ErrorIs(t, err, domain.ErrConflict) | |
| 200 | + | }) | |
| 201 | + | ||
| 202 | + | t.Run("on a closed issue, with a retry", func(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 204 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 205 | + | fresh := issue | |
| 206 | + | fresh.CommentSeq = 5 | |
| 207 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once() | |
| 208 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once() | |
| 209 | + | u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once() | |
| 210 | + | u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }), | |
| 211 | + | mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once() | |
| 212 | + | u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { | |
| 213 | + | c, ok := ev.(domain.IssueCommented) | |
| 214 | + | return ok && c.Number == 3 && c.Comment.ID == 6 | |
| 215 | + | })).Return() | |
| 216 | + | c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi") | |
| 217 | + | require.NoError(t, err) | |
| 218 | + | assert.EqualValues(t, 6, c.ID) | |
| 219 | + | }) | |
| 220 | + | ||
| 221 | + | t.Run("blank", func(t *testing.T) { | |
| 222 | + | t.Parallel() | |
| 223 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 224 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil) | |
| 225 | + | _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ") | |
| 226 | + | var fe *domain.InvalidFieldsError | |
| 227 | + | require.ErrorAs(t, err, &fe) | |
| 228 | + | }) | |
| 229 | + | ||
| 230 | + | t.Run("full", func(t *testing.T) { | |
| 231 | + | t.Parallel() | |
| 232 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 233 | + | full := issue | |
| 234 | + | full.CommentSeq = usecase.MaxCommentsPerIssue | |
| 235 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil) | |
| 236 | + | _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x") | |
| 237 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 238 | + | }) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestIssueKind(t *testing.T) { | |
| 242 | + | t.Parallel() | |
| 243 | + | ctx := context.Background() | |
| 244 | + | t.Run("issue", func(t *testing.T) { | |
| 245 | + | t.Parallel() | |
| 246 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 247 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil) | |
| 248 | + | k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1) | |
| 249 | + | require.NoError(t, err) | |
| 250 | + | assert.Equal(t, domain.NumberIssue, k) | |
| 251 | + | }) | |
| 252 | + | t.Run("merge request", func(t *testing.T) { | |
| 253 | + | t.Parallel() | |
| 254 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 255 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound) | |
| 256 | + | u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil) | |
| 257 | + | k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | assert.Equal(t, domain.NumberMergeRequest, k) | |
| 260 | + | }) | |
| 261 | + | t.Run("none", func(t *testing.T) { | |
| 262 | + | t.Parallel() | |
| 263 | + | u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner) | |
| 264 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound) | |
| 265 | + | u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound) | |
| 266 | + | k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1) | |
| 267 | + | require.NoError(t, err) | |
| 268 | + | assert.Equal(t, domain.NumberNone, k) | |
| 269 | + | }) | |
| 270 | + | t.Run("private repository", func(t *testing.T) { | |
| 271 | + | t.Parallel() | |
| 272 | + | u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner) | |
| 273 | + | _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1) | |
| 274 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 275 | + | }) | |
| 276 | + | } | |
M internal/usecase/mergerequests_integration_test.go +1 -1
| @@ -1199,7 +1199,7 @@ func TestUpdateRepoSettings(t *testing.T) { | |||
| 1199 | 1199 | off := false | |
| 1200 | 1200 | got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off}) | |
| 1201 | 1201 | require.NoError(t, err) | |
| 1202 | - | assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings) | |
| 1202 | + | assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings) | |
| 1203 | 1203 | again, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1204 | 1204 | require.NoError(t, err) | |
| 1205 | 1205 | 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 | 63 | before := repo | |
| 64 | 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 | 66 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | |
| 67 | 67 | } | |
| 68 | 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 | 73 | 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 | 82 | if params.Description != nil { | |
| 76 | 83 | if err := validateDescription(*params.Description); err != nil { | |
| 77 | 84 | return domain.Repo{}, err | |
| @@ -109,7 +116,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 109 | 116 | // branch was checked above, so the remaining causes are a vanished | |
| 110 | 117 | // repository or an I/O failure. | |
| 111 | 118 | 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 | 120 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | |
| 114 | 121 | return domain.Repo{}, err | |
| 115 | 122 | } | |
| @@ -132,7 +139,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 132 | 139 | Bool("topics", params.Topics != nil). | |
| 133 | 140 | Bool("visibility", params.Visibility != nil). | |
| 134 | 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 | 143 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | |
| 137 | 144 | Msg("repository updated") | |
| 138 | 145 | r.bus.Publish(ctx, domain.RepoUpdated{ | |
| @@ -171,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 171 | 178 | if params.FailingChecksBlock != nil { | |
| 172 | 179 | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | |
| 173 | 180 | } | |
| 181 | + | if params.Issues != nil { | |
| 182 | + | repo.Settings.Issues = *params.Issues | |
| 183 | + | } | |
| 174 | 184 | err = r.meta.Update(ctx, repo) | |
| 175 | 185 | if errors.Is(err, domain.ErrConflict) { | |
| 176 | 186 | continue | |
M internal/usecase/repos_write_test.go +42 -0
| @@ -215,6 +215,7 @@ func TestUpdate(t *testing.T) { | |||
| 215 | 215 | "description": {Description: ptr("x")}, | |
| 216 | 216 | "merge modes": {AllowedMergeModes: &modes}, | |
| 217 | 217 | "checks rule": {FailingChecksBlock: &block}, | |
| 218 | + | "issues": {Issues: ptr(domain.IssuesOwner)}, | |
| 218 | 219 | } { | |
| 219 | 220 | f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish | |
| 220 | 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 | 285 | func TestDelete(t *testing.T) { | |
| 244 | 286 | t.Parallel() | |
| 245 | 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 | 22 | // Never empty once stored. | |
| 23 | 23 | repeated MergeMode allowed_merge_modes = 1; | |
| 24 | 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 | 37 | // MergeRequestState is where a merge request is in its life. | |
M skills/ermite/CLI.md +15 -0
| @@ -41,6 +41,7 @@ Scopes: | |||
| 41 | 41 | | `repo:admin` | delete a repository, change visibility, description, default branch, merge settings | | |
| 42 | 42 | | `mr:write` | open, comment on, resolve, close, mark read | | |
| 43 | 43 | | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` | | |
| 44 | + | | `issue:write` | open, comment on, close and reopen issues | | |
| 44 | 45 | | `token:admin` | create, list and revoke tokens | | |
| 45 | 46 | | `ci:write`, `secrets:write`, `runner:register` | reserved for CI | | |
| 46 | 47 | ||
| @@ -66,6 +67,20 @@ merge request web URL on its own line. With `--json`, merge request objects | |||
| 66 | 67 | ||
| 67 | 68 | Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | |
| 68 | 69 | ||
| 70 | + | ## Issues | |
| 71 | + | ||
| 72 | + | Issues share the numbering of merge requests and must be enabled per repository: `repo edit <repo> --issues off|owner|public` (`repo:admin`; default `off`; `public` also opens a moderated form to visitors of a public repository). Reading and writing needs a server with the `issues` feature. | |
| 73 | + | ||
| 74 | + | | Command | Scope | Notes | | |
| 75 | + | |---|---|---| | |
| 76 | + | | `issue create <repo> --title T [--body TEXT\|-] [--body-file F\|-]` | `issue:write` | title of 120 characters max | | |
| 77 | + | | `issue list <repo> [--state open\|closed\|all] [--json]` | read access (public repository, or `repo:read` on a private one) | default `open`; `issue:write` alone does not grant reading | | |
| 78 | + | | `issue show <repo> <n> [--json]` | read access (public repository, or `repo:read` on a private one) | issue and comments; `issue:write` alone does not grant reading | | |
| 79 | + | | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | | | |
| 80 | + | | `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | | | |
| 81 | + | ||
| 82 | + | Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`. | |
| 83 | + | ||
| 69 | 84 | ## Workflows | |
| 70 | 85 | ||
| 71 | 86 | | Command | Scope | Notes | | |