feat(domain): add issues, the issues mode, text parsers and the issue:write scope
- commit
- b41adf0d1d5b365dfe554f826cb289730069f142
- author
- brice · · 2026-10-19 05:26 UTC
- parent
- 6815c6c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +398 -5
M go.mod +1 -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 | |
| @@ -32,6 +32,7 @@ require ( | |||||
| 32 | golang.org/x/crypto v0.57.0 | 32 | golang.org/x/crypto v0.57.0 | ||
| 33 | golang.org/x/sys v0.49.0 | 33 | golang.org/x/sys v0.49.0 | ||
| 34 | golang.org/x/term v0.46.0 | 34 | golang.org/x/term v0.46.0 | ||
| 35 | + | golang.org/x/text v0.42.0 | |||
| 35 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | 36 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | ||
| 36 | google.golang.org/protobuf v1.36.12 | 37 | google.golang.org/protobuf v1.36.12 | ||
| 37 | ) | 38 | ) | ||
| @@ -164,7 +165,6 @@ require ( | |||||
| 164 | golang.org/x/mod v0.41.0 // indirect | 165 | golang.org/x/mod v0.41.0 // indirect | ||
| 165 | golang.org/x/net v0.59.0 // indirect | 166 | golang.org/x/net v0.59.0 // indirect | ||
| 166 | golang.org/x/sync v0.23.0 // indirect | 167 | golang.org/x/sync v0.23.0 // indirect | ||
| 167 | - | golang.org/x/text v0.42.0 // indirect | |||
| 168 | golang.org/x/tools v0.51.0 // indirect | 168 | golang.org/x/tools v0.51.0 // indirect | ||
| 169 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | 169 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | ||
| 170 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | 170 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | ||
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") | |
| @@ -25,6 +25,7 @@ func TestScopeWireNames(t *testing.T) { | |||||
| 25 | ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write", | 25 | ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write", | ||
| 26 | ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register", | 26 | ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register", | ||
| 27 | ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin", | 27 | ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin", | ||
| 28 | + | ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write", | |||
| 28 | } | 29 | } | ||
| 29 | for s, name := range want { | 30 | for s, name := range want { | ||
| 30 | assert.Equal(t, name, client.ScopeName(s)) | 31 | assert.Equal(t, name, client.ScopeName(s)) | ||
| @@ -43,6 +44,7 @@ func TestPresetExpansion(t *testing.T) { | |||||
| 43 | 44 | ||||
| 44 | agent, err := client.PresetScopes("agent") | 45 | agent, err := client.PresetScopes("agent") | ||
| 45 | require.NoError(t, err) | 46 | require.NoError(t, err) | ||
| 47 | + | assert.NotContains(t, agent, ermite.Scope_SCOPE_ISSUE_WRITE, "agents get issue:write only when asked") | |||
| 46 | assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent) | 48 | assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent) | ||
| 47 | 49 | ||||
| 48 | ci, err := client.PresetScopes("CI") | 50 | ci, err := client.PresetScopes("CI") | ||
M internal/adapters/rpc/convert.go +1 -0
| @@ -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) { | |
| @@ -133,6 +133,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{ | |||||
| 133 | domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE, | 133 | domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE, | ||
| 134 | domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER, | 134 | domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER, | ||
| 135 | domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN, | 135 | domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN, | ||
| 136 | + | domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE, | |||
| 136 | } | 137 | } | ||
| 137 | 138 | ||||
| 138 | func scopeFromPB(s pb.Scope) (domain.Scope, error) { | 139 | func scopeFromPB(s pb.Scope) (domain.Scope, error) { | ||
M internal/domain/event.go +48 -0
| @@ -173,6 +173,54 @@ 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 | + | ||
| 176 | 224 | // EventGap tells a subscriber that it missed events because it was too slow; | |
| 177 | 225 | // it must refresh its whole view. | |
| 178 | 226 | type EventGap struct{} | |
| @@ -173,6 +173,54 @@ type CommentAdded struct { | |||||
| 173 | // EventKind implements Event. | 173 | // EventKind implements Event. | ||
| 174 | func (CommentAdded) EventKind() string { return "mr.comment_added" } | 174 | func (CommentAdded) EventKind() string { return "mr.comment_added" } | ||
| 175 | 175 | ||||
| 176 | + | // IssueOpened is published when an issue is opened. | |||
| 177 | + | type IssueOpened struct { | |||
| 178 | + | Issue Issue | |||
| 179 | + | At time.Time | |||
| 180 | + | // Visibility and CreatedAt are those of the repository when the event | |||
| 181 | + | // was published, like PushEvent's. | |||
| 182 | + | Visibility Visibility | |||
| 183 | + | CreatedAt time.Time | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // EventKind implements Event. | |||
| 187 | + | func (IssueOpened) EventKind() string { return "issue.opened" } | |||
| 188 | + | ||||
| 189 | + | // IssueClosed is published when an issue was closed. | |||
| 190 | + | type IssueClosed struct { | |||
| 191 | + | Issue Issue | |||
| 192 | + | At time.Time | |||
| 193 | + | Visibility Visibility | |||
| 194 | + | CreatedAt time.Time | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // EventKind implements Event. | |||
| 198 | + | func (IssueClosed) EventKind() string { return "issue.closed" } | |||
| 199 | + | ||||
| 200 | + | // IssueReopened is published when a closed issue was reopened. | |||
| 201 | + | type IssueReopened struct { | |||
| 202 | + | Issue Issue | |||
| 203 | + | At time.Time | |||
| 204 | + | Visibility Visibility | |||
| 205 | + | CreatedAt time.Time | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // EventKind implements Event. | |||
| 209 | + | func (IssueReopened) EventKind() string { return "issue.reopened" } | |||
| 210 | + | ||||
| 211 | + | // IssueCommented is published when a comment was added to an issue. | |||
| 212 | + | type IssueCommented struct { | |||
| 213 | + | Repo RepoName | |||
| 214 | + | Number int64 | |||
| 215 | + | Comment IssueComment | |||
| 216 | + | At time.Time | |||
| 217 | + | Visibility Visibility | |||
| 218 | + | CreatedAt time.Time | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // EventKind implements Event. | |||
| 222 | + | func (IssueCommented) EventKind() string { return "issue.commented" } | |||
| 223 | + | ||||
| 176 | // EventGap tells a subscriber that it missed events because it was too slow; | 224 | // EventGap tells a subscriber that it missed events because it was too slow; | ||
| 177 | // it must refresh its whole view. | 225 | // it must refresh its whole view. | ||
| 178 | type EventGap struct{} | 226 | type EventGap struct{} | ||
A internal/domain/issue.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 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 | + | // IssueStatePending awaits moderation. Nothing creates it yet. | |
| 53 | + | IssueStatePending IssueState = "pending" | |
| 54 | + | // IssueStateOpen is being worked on. | |
| 55 | + | IssueStateOpen IssueState = "open" | |
| 56 | + | // IssueStateClosed is done with; it can be reopened. | |
| 57 | + | IssueStateClosed IssueState = "closed" | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | // Issue is a problem or a request about a repository. It shares its number | |
| 61 | + | // sequence with the merge requests of the repository. | |
| 62 | + | type Issue struct { | |
| 63 | + | Repo RepoName | |
| 64 | + | Number int64 | |
| 65 | + | Title IssueTitle | |
| 66 | + | // Body may be empty. | |
| 67 | + | Body IssueBody | |
| 68 | + | State IssueState | |
| 69 | + | // Author is the label of the token that opened it, "root" or "owner". | |
| 70 | + | Author string | |
| 71 | + | CreatedAt time.Time | |
| 72 | + | UpdatedAt time.Time | |
| 73 | + | // CommentSeq is the id of the last comment of the issue. | |
| 74 | + | CommentSeq int64 | |
| 75 | + | // Version is set by the store on read: Update fails with ErrConflict when | |
| 76 | + | // the stored issue changed since. Opaque to everyone else. | |
| 77 | + | Version uint64 | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // IsOpen reports whether the issue is open. | |
| 81 | + | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | |
| 82 | + | ||
| 83 | + | // IssueComment is a comment on an issue. | |
| 84 | + | type IssueComment struct { | |
| 85 | + | ID int64 | |
| 86 | + | Author string | |
| 87 | + | Text IssueBody | |
| 88 | + | CreatedAt time.Time | |
| 89 | + | // Version is set by the store on read, like Issue's. | |
| 90 | + | Version uint64 | |
| 91 | + | } | |
| @@ -0,0 +1,91 @@ | |||||
| 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 | + | // IssueStatePending awaits moderation. Nothing creates it yet. | |||
| 53 | + | IssueStatePending IssueState = "pending" | |||
| 54 | + | // IssueStateOpen is being worked on. | |||
| 55 | + | IssueStateOpen IssueState = "open" | |||
| 56 | + | // IssueStateClosed is done with; it can be reopened. | |||
| 57 | + | IssueStateClosed IssueState = "closed" | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | // Issue is a problem or a request about a repository. It shares its number | |||
| 61 | + | // sequence with the merge requests of the repository. | |||
| 62 | + | type Issue struct { | |||
| 63 | + | Repo RepoName | |||
| 64 | + | Number int64 | |||
| 65 | + | Title IssueTitle | |||
| 66 | + | // Body may be empty. | |||
| 67 | + | Body IssueBody | |||
| 68 | + | State IssueState | |||
| 69 | + | // Author is the label of the token that opened it, "root" or "owner". | |||
| 70 | + | Author string | |||
| 71 | + | CreatedAt time.Time | |||
| 72 | + | UpdatedAt time.Time | |||
| 73 | + | // CommentSeq is the id of the last comment of the issue. | |||
| 74 | + | CommentSeq int64 | |||
| 75 | + | // Version is set by the store on read: Update fails with ErrConflict when | |||
| 76 | + | // the stored issue changed since. Opaque to everyone else. | |||
| 77 | + | Version uint64 | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // IsOpen reports whether the issue is open. | |||
| 81 | + | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | |||
| 82 | + | ||||
| 83 | + | // IssueComment is a comment on an issue. | |||
| 84 | + | type IssueComment struct { | |||
| 85 | + | ID int64 | |||
| 86 | + | Author string | |||
| 87 | + | Text IssueBody | |||
| 88 | + | CreatedAt time.Time | |||
| 89 | + | // Version is set by the store on read, like Issue's. | |||
| 90 | + | Version uint64 | |||
| 91 | + | } | |||
A internal/domain/issue_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestParseIssueTitle(t *testing.T) { | |
| 17 | + | t.Parallel() | |
| 18 | + | tests := []struct { | |
| 19 | + | name, in, want, reason string // reason "" when valid | |
| 20 | + | }{ | |
| 21 | + | {"plain", "Crash on start", "Crash on start", ""}, | |
| 22 | + | {"max length in code points", strings.Repeat("é", 120), strings.Repeat("é", 120), ""}, | |
| 23 | + | {"too long", strings.Repeat("a", 121), "", "longer than 120"}, | |
| 24 | + | {"emoji count as one", strings.Repeat("🎉", 120), strings.Repeat("🎉", 120), ""}, | |
| 25 | + | {"empty", "", "", "is empty"}, | |
| 26 | + | {"blank", " \t ", "", "is empty"}, | |
| 27 | + | {"only controls", "\x01\x02", "", "is empty"}, | |
| 28 | + | {"newline", "a\nb", "", "one line"}, | |
| 29 | + | {"carriage return", "a\rb", "", "one line"}, | |
| 30 | + | {"nfc", "e\u0301", "\u00e9", ""}, | |
| 31 | + | {"c0 stripped", "a\x00b\x1bc\tz", "abcz", ""}, | |
| 32 | + | {"c1 stripped", "a\u0085b\u009fc", "abc", ""}, | |
| 33 | + | {"del stripped", "a\x7fb", "ab", ""}, | |
| 34 | + | {"bidi stripped", "a\u202ab\u202ec\u2066d\u2069e\u200ef\u200fg\u061ch", "abcdefgh", ""}, | |
| 35 | + | {"not utf-8", "a\xffb", "", "UTF-8"}, | |
| 36 | + | } | |
| 37 | + | for _, tt := range tests { | |
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |
| 39 | + | t.Parallel() | |
| 40 | + | got, err := domain.ParseIssueTitle(tt.in) | |
| 41 | + | if tt.reason == "" { | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.Equal(t, tt.want, got.String()) | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | var te *domain.TextError | |
| 47 | + | require.True(t, errors.As(err, &te), "%v", err) | |
| 48 | + | assert.ErrorIs(t, err, domain.ErrInvalidText) | |
| 49 | + | assert.Contains(t, te.Reason, tt.reason) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestParseIssueBody(t *testing.T) { | |
| 55 | + | t.Parallel() | |
| 56 | + | tests := []struct { | |
| 57 | + | name, in, want, reason string | |
| 58 | + | }{ | |
| 59 | + | {"empty allowed", "", "", ""}, | |
| 60 | + | {"multiline and tabs kept", "a\n\tb\n", "a\n\tb\n", ""}, | |
| 61 | + | {"crlf becomes lf", "a\r\nb", "a\nb", ""}, | |
| 62 | + | {"max length", strings.Repeat("é", 10000), strings.Repeat("é", 10000), ""}, | |
| 63 | + | {"too long", strings.Repeat("a", 10001), "", "longer than 10000"}, | |
| 64 | + | {"nfc", "e\u0301\n", "\u00e9\n", ""}, | |
| 65 | + | {"controls stripped", "a\x00\x08b\u0085c\u009fd\x7f", "abcd", ""}, | |
| 66 | + | {"bidi stripped", "\u202etxt\u202c \u2067x\u2069", "txt x", ""}, | |
| 67 | + | {"separators stripped", "a\u2028b\u2029c", "abc", ""}, | |
| 68 | + | {"not utf-8", "\xc3\x28", "", "UTF-8"}, | |
| 69 | + | } | |
| 70 | + | for _, tt := range tests { | |
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |
| 72 | + | t.Parallel() | |
| 73 | + | got, err := domain.ParseIssueBody(tt.in) | |
| 74 | + | if tt.reason == "" { | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | assert.Equal(t, tt.want, got.String()) | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | var te *domain.TextError | |
| 80 | + | require.True(t, errors.As(err, &te), "%v", err) | |
| 81 | + | assert.Contains(t, te.Reason, tt.reason) | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestParseAuthorLabel(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | tests := []struct { | |
| 89 | + | name, in, want, reason string | |
| 90 | + | }{ | |
| 91 | + | {"plain", "Ada", "Ada", ""}, | |
| 92 | + | {"empty allowed", "", "", ""}, | |
| 93 | + | {"max length", strings.Repeat("é", 40), strings.Repeat("é", 40), ""}, | |
| 94 | + | {"too long", strings.Repeat("a", 41), "", "longer than 40"}, | |
| 95 | + | {"newline", "a\nb", "", "one line"}, | |
| 96 | + | {"nfc", "e\u0301", "\u00e9", ""}, | |
| 97 | + | {"stripped", "A\u202ed\x00a", "Ada", ""}, | |
| 98 | + | {"trimmed", " Ada ", "Ada", ""}, | |
| 99 | + | {"not utf-8", "\xff", "", "UTF-8"}, | |
| 100 | + | } | |
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | t.Parallel() | |
| 104 | + | got, err := domain.ParseAuthorLabel(tt.in) | |
| 105 | + | if tt.reason == "" { | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | assert.Equal(t, tt.want, got) | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | var te *domain.TextError | |
| 111 | + | require.True(t, errors.As(err, &te), "%v", err) | |
| 112 | + | assert.Contains(t, te.Reason, tt.reason) | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestIssuesMode(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | for _, m := range domain.IssuesModes() { | |
| 120 | + | got, err := domain.ParseIssuesMode(m.String()) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | assert.Equal(t, m, got) | |
| 123 | + | } | |
| 124 | + | _, err := domain.ParseIssuesMode("all") | |
| 125 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 126 | + | assert.Equal(t, domain.IssuesOff, domain.DefaultRepoSettings().Issues) | |
| 127 | + | assert.False(t, domain.DefaultRepoSettings().IsZero()) | |
| 128 | + | assert.True(t, domain.RepoSettings{}.IsZero()) | |
| 129 | + | assert.Equal(t, domain.IssuesOff, domain.IssuesMode("").OrDefault()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestIssueEventKinds(t *testing.T) { | |
| 133 | + | t.Parallel() | |
| 134 | + | assert.Equal(t, "issue.opened", domain.IssueOpened{}.EventKind()) | |
| 135 | + | assert.Equal(t, "issue.closed", domain.IssueClosed{}.EventKind()) | |
| 136 | + | assert.Equal(t, "issue.reopened", domain.IssueReopened{}.EventKind()) | |
| 137 | + | assert.Equal(t, "issue.commented", domain.IssueCommented{}.EventKind()) | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestParseIssueTitle(t *testing.T) { | |||
| 17 | + | t.Parallel() | |||
| 18 | + | tests := []struct { | |||
| 19 | + | name, in, want, reason string // reason "" when valid | |||
| 20 | + | }{ | |||
| 21 | + | {"plain", "Crash on start", "Crash on start", ""}, | |||
| 22 | + | {"max length in code points", strings.Repeat("é", 120), strings.Repeat("é", 120), ""}, | |||
| 23 | + | {"too long", strings.Repeat("a", 121), "", "longer than 120"}, | |||
| 24 | + | {"emoji count as one", strings.Repeat("🎉", 120), strings.Repeat("🎉", 120), ""}, | |||
| 25 | + | {"empty", "", "", "is empty"}, | |||
| 26 | + | {"blank", " \t ", "", "is empty"}, | |||
| 27 | + | {"only controls", "\x01\x02", "", "is empty"}, | |||
| 28 | + | {"newline", "a\nb", "", "one line"}, | |||
| 29 | + | {"carriage return", "a\rb", "", "one line"}, | |||
| 30 | + | {"nfc", "e\u0301", "\u00e9", ""}, | |||
| 31 | + | {"c0 stripped", "a\x00b\x1bc\tz", "abcz", ""}, | |||
| 32 | + | {"c1 stripped", "a\u0085b\u009fc", "abc", ""}, | |||
| 33 | + | {"del stripped", "a\x7fb", "ab", ""}, | |||
| 34 | + | {"bidi stripped", "a\u202ab\u202ec\u2066d\u2069e\u200ef\u200fg\u061ch", "abcdefgh", ""}, | |||
| 35 | + | {"not utf-8", "a\xffb", "", "UTF-8"}, | |||
| 36 | + | } | |||
| 37 | + | for _, tt := range tests { | |||
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 39 | + | t.Parallel() | |||
| 40 | + | got, err := domain.ParseIssueTitle(tt.in) | |||
| 41 | + | if tt.reason == "" { | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | assert.Equal(t, tt.want, got.String()) | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | var te *domain.TextError | |||
| 47 | + | require.True(t, errors.As(err, &te), "%v", err) | |||
| 48 | + | assert.ErrorIs(t, err, domain.ErrInvalidText) | |||
| 49 | + | assert.Contains(t, te.Reason, tt.reason) | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestParseIssueBody(t *testing.T) { | |||
| 55 | + | t.Parallel() | |||
| 56 | + | tests := []struct { | |||
| 57 | + | name, in, want, reason string | |||
| 58 | + | }{ | |||
| 59 | + | {"empty allowed", "", "", ""}, | |||
| 60 | + | {"multiline and tabs kept", "a\n\tb\n", "a\n\tb\n", ""}, | |||
| 61 | + | {"crlf becomes lf", "a\r\nb", "a\nb", ""}, | |||
| 62 | + | {"max length", strings.Repeat("é", 10000), strings.Repeat("é", 10000), ""}, | |||
| 63 | + | {"too long", strings.Repeat("a", 10001), "", "longer than 10000"}, | |||
| 64 | + | {"nfc", "e\u0301\n", "\u00e9\n", ""}, | |||
| 65 | + | {"controls stripped", "a\x00\x08b\u0085c\u009fd\x7f", "abcd", ""}, | |||
| 66 | + | {"bidi stripped", "\u202etxt\u202c \u2067x\u2069", "txt x", ""}, | |||
| 67 | + | {"separators stripped", "a\u2028b\u2029c", "abc", ""}, | |||
| 68 | + | {"not utf-8", "\xc3\x28", "", "UTF-8"}, | |||
| 69 | + | } | |||
| 70 | + | for _, tt := range tests { | |||
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 72 | + | t.Parallel() | |||
| 73 | + | got, err := domain.ParseIssueBody(tt.in) | |||
| 74 | + | if tt.reason == "" { | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | assert.Equal(t, tt.want, got.String()) | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | var te *domain.TextError | |||
| 80 | + | require.True(t, errors.As(err, &te), "%v", err) | |||
| 81 | + | assert.Contains(t, te.Reason, tt.reason) | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestParseAuthorLabel(t *testing.T) { | |||
| 87 | + | t.Parallel() | |||
| 88 | + | tests := []struct { | |||
| 89 | + | name, in, want, reason string | |||
| 90 | + | }{ | |||
| 91 | + | {"plain", "Ada", "Ada", ""}, | |||
| 92 | + | {"empty allowed", "", "", ""}, | |||
| 93 | + | {"max length", strings.Repeat("é", 40), strings.Repeat("é", 40), ""}, | |||
| 94 | + | {"too long", strings.Repeat("a", 41), "", "longer than 40"}, | |||
| 95 | + | {"newline", "a\nb", "", "one line"}, | |||
| 96 | + | {"nfc", "e\u0301", "\u00e9", ""}, | |||
| 97 | + | {"stripped", "A\u202ed\x00a", "Ada", ""}, | |||
| 98 | + | {"trimmed", " Ada ", "Ada", ""}, | |||
| 99 | + | {"not utf-8", "\xff", "", "UTF-8"}, | |||
| 100 | + | } | |||
| 101 | + | for _, tt := range tests { | |||
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 103 | + | t.Parallel() | |||
| 104 | + | got, err := domain.ParseAuthorLabel(tt.in) | |||
| 105 | + | if tt.reason == "" { | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | assert.Equal(t, tt.want, got) | |||
| 108 | + | return | |||
| 109 | + | } | |||
| 110 | + | var te *domain.TextError | |||
| 111 | + | require.True(t, errors.As(err, &te), "%v", err) | |||
| 112 | + | assert.Contains(t, te.Reason, tt.reason) | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestIssuesMode(t *testing.T) { | |||
| 118 | + | t.Parallel() | |||
| 119 | + | for _, m := range domain.IssuesModes() { | |||
| 120 | + | got, err := domain.ParseIssuesMode(m.String()) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | assert.Equal(t, m, got) | |||
| 123 | + | } | |||
| 124 | + | _, err := domain.ParseIssuesMode("all") | |||
| 125 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |||
| 126 | + | assert.Equal(t, domain.IssuesOff, domain.DefaultRepoSettings().Issues) | |||
| 127 | + | assert.False(t, domain.DefaultRepoSettings().IsZero()) | |||
| 128 | + | assert.True(t, domain.RepoSettings{}.IsZero()) | |||
| 129 | + | assert.Equal(t, domain.IssuesOff, domain.IssuesMode("").OrDefault()) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestIssueEventKinds(t *testing.T) { | |||
| 133 | + | t.Parallel() | |||
| 134 | + | assert.Equal(t, "issue.opened", domain.IssueOpened{}.EventKind()) | |||
| 135 | + | assert.Equal(t, "issue.closed", domain.IssueClosed{}.EventKind()) | |||
| 136 | + | assert.Equal(t, "issue.reopened", domain.IssueReopened{}.EventKind()) | |||
| 137 | + | assert.Equal(t, "issue.commented", domain.IssueCommented{}.EventKind()) | |||
| 138 | + | } | |||
M internal/domain/mergerequest.go +3 -1
| @@ -43,12 +43,14 @@ type RepoSettings struct { | |||
| 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. | |
| @@ -43,12 +43,14 @@ type RepoSettings struct { | |||||
| 43 | AllowedMergeModes []MergeMode | 43 | AllowedMergeModes []MergeMode | ||
| 44 | // FailingChecksBlock says that a failing check prevents the merge. | 44 | // FailingChecksBlock says that a failing check prevents the merge. | ||
| 45 | FailingChecksBlock bool | 45 | FailingChecksBlock bool | ||
| 46 | + | // Issues says who may write issues; the empty mode means IssuesOff. | |||
| 47 | + | Issues IssuesMode | |||
| 46 | } | 48 | } | ||
| 47 | 49 | ||||
| 48 | // DefaultRepoSettings are the settings of a new repository: both modes, and | 50 | // DefaultRepoSettings are the settings of a new repository: both modes, and | ||
| 49 | // failing checks block the merge. | 51 | // failing checks block the merge. | ||
| 50 | func DefaultRepoSettings() RepoSettings { | 52 | func DefaultRepoSettings() RepoSettings { | ||
| 51 | - | return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true} | 53 | + | return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff} |
| 52 | } | 54 | } | ||
| 53 | 55 | ||||
| 54 | // IsZero reports whether s holds no mode, which only the zero value does. | 56 | // IsZero reports whether s holds no mode, which only the zero value does. | ||
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 | + | } | |
| @@ -5,8 +5,11 @@ package domain | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "strings" | |||
| 8 | "unicode" | 9 | "unicode" | ||
| 9 | "unicode/utf8" | 10 | "unicode/utf8" | ||
| 11 | + | ||||
| 12 | + | "golang.org/x/text/unicode/norm" | |||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| 12 | // Limits of the plain text of a merge request (ADR 0017), in characters. | 15 | // Limits of the plain text of a merge request (ADR 0017), in characters. | ||
| @@ -178,3 +181,102 @@ func (v *Violations) Err() error { | |||||
| 178 | } | 181 | } | ||
| 179 | return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)} | 182 | return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)} | ||
| 180 | } | 183 | } | ||
| 184 | + | ||||
| 185 | + | // Limits of the text of an issue, in code points. | |||
| 186 | + | const ( | |||
| 187 | + | // MaxIssueTitleLen is the longest issue title. | |||
| 188 | + | MaxIssueTitleLen = 120 | |||
| 189 | + | // MaxIssueBodyLen is the longest issue body or issue comment. | |||
| 190 | + | MaxIssueBodyLen = 10000 | |||
| 191 | + | // MaxAuthorLabelLen is the longest author label. | |||
| 192 | + | MaxAuthorLabelLen = 40 | |||
| 193 | + | ) | |||
| 194 | + | ||||
| 195 | + | // IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code | |||
| 196 | + | // points, normalized. Only ParseIssueTitle builds a non-zero value. | |||
| 197 | + | type IssueTitle struct{ s string } | |||
| 198 | + | ||||
| 199 | + | // String returns the title. | |||
| 200 | + | func (t IssueTitle) String() string { return t.s } | |||
| 201 | + | ||||
| 202 | + | // IsZero reports whether t is the zero value. | |||
| 203 | + | func (t IssueTitle) IsZero() bool { return t.s == "" } | |||
| 204 | + | ||||
| 205 | + | // IssueBody is a validated issue body or issue comment: at most | |||
| 206 | + | // MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The | |||
| 207 | + | // zero value is the empty text. | |||
| 208 | + | type IssueBody struct{ s string } | |||
| 209 | + | ||||
| 210 | + | // String returns the text. | |||
| 211 | + | func (b IssueBody) String() string { return b.s } | |||
| 212 | + | ||||
| 213 | + | // IsBlank reports whether the text is empty or only white space. | |||
| 214 | + | func (b IssueBody) IsBlank() bool { return isBlank(b.s) } | |||
| 215 | + | ||||
| 216 | + | // ParseIssueTitle cleans s (see cleanText) and validates it: one line, not | |||
| 217 | + | // blank, at most MaxIssueTitleLen code points. A line break is refused, not | |||
| 218 | + | // removed, so that two lines are never glued into one. | |||
| 219 | + | func ParseIssueTitle(s string) (IssueTitle, error) { | |||
| 220 | + | if !utf8.ValidString(s) { | |||
| 221 | + | return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"} | |||
| 222 | + | } | |||
| 223 | + | if strings.ContainsAny(s, "\r\n\u2028\u2029") { | |||
| 224 | + | return IssueTitle{}, &TextError{Reason: "must be one line"} | |||
| 225 | + | } | |||
| 226 | + | c := strings.TrimSpace(cleanText(s, false)) | |||
| 227 | + | if c == "" { | |||
| 228 | + | return IssueTitle{}, &TextError{Reason: "is empty"} | |||
| 229 | + | } | |||
| 230 | + | if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen { | |||
| 231 | + | return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)} | |||
| 232 | + | } | |||
| 233 | + | return IssueTitle{s: c}, nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and | |||
| 237 | + | // validates it: at most MaxIssueBodyLen code points. The empty text is valid. | |||
| 238 | + | func ParseIssueBody(s string) (IssueBody, error) { | |||
| 239 | + | if !utf8.ValidString(s) { | |||
| 240 | + | return IssueBody{}, &TextError{Reason: "is not valid UTF-8"} | |||
| 241 | + | } | |||
| 242 | + | c := cleanText(s, true) | |||
| 243 | + | if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen { | |||
| 244 | + | return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)} | |||
| 245 | + | } | |||
| 246 | + | return IssueBody{s: c}, nil | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // ParseAuthorLabel cleans s like a title (one line) and validates it: at most | |||
| 250 | + | // MaxAuthorLabelLen code points. The empty label is valid. | |||
| 251 | + | func ParseAuthorLabel(s string) (string, error) { | |||
| 252 | + | if !utf8.ValidString(s) { | |||
| 253 | + | return "", &TextError{Reason: "is not valid UTF-8"} | |||
| 254 | + | } | |||
| 255 | + | if strings.ContainsAny(s, "\r\n\u2028\u2029") { | |||
| 256 | + | return "", &TextError{Reason: "must be one line"} | |||
| 257 | + | } | |||
| 258 | + | c := strings.TrimSpace(cleanText(s, false)) | |||
| 259 | + | if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen { | |||
| 260 | + | return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)} | |||
| 261 | + | } | |||
| 262 | + | return c, nil | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // cleanText normalizes s to NFC and removes the control characters (C0, C1 and | |||
| 266 | + | // DEL; with multiline, line feeds and tabs stay) and the bidirectional marks, | |||
| 267 | + | // embeddings, overrides and isolates. s must be valid UTF-8. | |||
| 268 | + | func cleanText(s string, multiline bool) string { | |||
| 269 | + | s = norm.NFC.String(s) | |||
| 270 | + | if multiline { | |||
| 271 | + | s = strings.ReplaceAll(s, "\r\n", "\n") | |||
| 272 | + | } | |||
| 273 | + | return strings.Map(func(r rune) rune { | |||
| 274 | + | switch { | |||
| 275 | + | case multiline && (r == '\n' || r == '\t'): | |||
| 276 | + | return r | |||
| 277 | + | case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029': | |||
| 278 | + | return -1 | |||
| 279 | + | } | |||
| 280 | + | return r | |||
| 281 | + | }, s) | |||
| 282 | + | } | |||
M internal/domain/token.go +5 -1
| @@ -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 | ||
| @@ -38,15 +38,19 @@ const ( | |||||
| 38 | ScopeRunnerRegister Scope = "runner:register" | 38 | ScopeRunnerRegister Scope = "runner:register" | ||
| 39 | // ScopeTokenAdmin creates, lists and revokes stored tokens. | 39 | // ScopeTokenAdmin creates, lists and revokes stored tokens. | ||
| 40 | ScopeTokenAdmin Scope = "token:admin" | 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 | func AllScopes() []Scope { | 47 | func AllScopes() []Scope { | ||
| 45 | return []Scope{ | 48 | return []Scope{ | ||
| 46 | ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin, | 49 | ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin, | ||
| 47 | ScopeMRWrite, ScopeMRMerge, | 50 | ScopeMRWrite, ScopeMRMerge, | ||
| 48 | ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister, | 51 | ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister, | ||
| 49 | ScopeTokenAdmin, | 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 | ||
| @@ -16,7 +16,11 @@ import ( | |||||
| 16 | func TestScopes(t *testing.T) { | 16 | func TestScopes(t *testing.T) { | ||
| 17 | t.Parallel() | 17 | t.Parallel() | ||
| 18 | all := domain.AllScopes() | 18 | all := domain.AllScopes() | ||
| 19 | - | assert.Len(t, all, 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 | for _, s := range all { | 24 | for _, s := range all { | ||
| 21 | got, err := domain.ParseScope(s.String()) | 25 | got, err := domain.ParseScope(s.String()) | ||
| 22 | require.NoError(t, err) | 26 | require.NoError(t, err) | ||
| @@ -110,6 +114,7 @@ func TestHasOwnerRights(t *testing.T) { | |||||
| 110 | assert.False(t, domain.Anonymous().HasOwnerRights()) | 114 | assert.False(t, domain.Anonymous().HasOwnerRights()) | ||
| 111 | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights()) | 115 | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights()) | ||
| 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | 116 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | ||
| 117 | + | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:9]}).HasOwnerRights(), "without issue:write") | |||
| 113 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | 118 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | ||
| 114 | } | 119 | } | ||
| 115 | 120 | ||||
M internal/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) | |
| @@ -1199,7 +1199,7 @@ func TestUpdateRepoSettings(t *testing.T) { | |||||
| 1199 | off := false | 1199 | off := false | ||
| 1200 | got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off}) | 1200 | got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off}) | ||
| 1201 | require.NoError(t, err) | 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 | again, err := r.repos.Get(r.ctx, root, r.repo) | 1203 | again, err := r.repos.Get(r.ctx, root, r.repo) | ||
| 1204 | require.NoError(t, err) | 1204 | require.NoError(t, err) | ||
| 1205 | assert.Equal(t, got.Settings, again.Settings) | 1205 | assert.Equal(t, got.Settings, again.Settings) | ||