detached b41adf0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +398 -5
M go.mod +1 -1
@@ -32,6 +32,7 @@ require (
3232 golang.org/x/crypto v0.57.0
3333 golang.org/x/sys v0.49.0
3434 golang.org/x/term v0.46.0
35+ golang.org/x/text v0.42.0
3536 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
3637 google.golang.org/protobuf v1.36.12
3738 )
@@ -164,7 +165,6 @@ require (
164165 golang.org/x/mod v0.41.0 // indirect
165166 golang.org/x/net v0.59.0 // indirect
166167 golang.org/x/sync v0.23.0 // indirect
167- golang.org/x/text v0.42.0 // indirect
168168 golang.org/x/tools v0.51.0 // indirect
169169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
170170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
@@ -32,6 +32,7 @@ require (
32 golang.org/x/crypto v0.57.032 golang.org/x/crypto v0.57.0
33 golang.org/x/sys v0.49.033 golang.org/x/sys v0.49.0
34 golang.org/x/term v0.46.034 golang.org/x/term v0.46.0
35+ golang.org/x/text v0.42.0
35 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a436 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
36 google.golang.org/protobuf v1.36.1237 google.golang.org/protobuf v1.36.12
37 )38 )
@@ -164,7 +165,6 @@ require (
164 golang.org/x/mod v0.41.0 // indirect165 golang.org/x/mod v0.41.0 // indirect
165 golang.org/x/net v0.59.0 // indirect166 golang.org/x/net v0.59.0 // indirect
166 golang.org/x/sync v0.23.0 // indirect167 golang.org/x/sync v0.23.0 // indirect
167- golang.org/x/text v0.42.0 // indirect
168 golang.org/x/tools v0.51.0 // indirect168 golang.org/x/tools v0.51.0 // indirect
169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
M internal/adapters/client/scopes_test.go +2 -0
@@ -25,6 +25,7 @@ func TestScopeWireNames(t *testing.T) {
2525 ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write",
2626 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
2727 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
28+ ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
2829 }
2930 for s, name := range want {
3031 assert.Equal(t, name, client.ScopeName(s))
@@ -43,6 +44,7 @@ func TestPresetExpansion(t *testing.T) {
4344
4445 agent, err := client.PresetScopes("agent")
4546 require.NoError(t, err)
47+ assert.NotContains(t, agent, ermite.Scope_SCOPE_ISSUE_WRITE, "agents get issue:write only when asked")
4648 assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent)
4749
4850 ci, err := client.PresetScopes("CI")
@@ -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{
133133 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
134134 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
135135 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
136+ domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
136137 }
137138
138139 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -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 {
173173 // EventKind implements Event.
174174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175175
176+// IssueOpened is published when an issue is opened.
177+type IssueOpened struct {
178+ Issue Issue
179+ At time.Time
180+ // Visibility and CreatedAt are those of the repository when the event
181+ // was published, like PushEvent's.
182+ Visibility Visibility
183+ CreatedAt time.Time
184+}
185+
186+// EventKind implements Event.
187+func (IssueOpened) EventKind() string { return "issue.opened" }
188+
189+// IssueClosed is published when an issue was closed.
190+type IssueClosed struct {
191+ Issue Issue
192+ At time.Time
193+ Visibility Visibility
194+ CreatedAt time.Time
195+}
196+
197+// EventKind implements Event.
198+func (IssueClosed) EventKind() string { return "issue.closed" }
199+
200+// IssueReopened is published when a closed issue was reopened.
201+type IssueReopened struct {
202+ Issue Issue
203+ At time.Time
204+ Visibility Visibility
205+ CreatedAt time.Time
206+}
207+
208+// EventKind implements Event.
209+func (IssueReopened) EventKind() string { return "issue.reopened" }
210+
211+// IssueCommented is published when a comment was added to an issue.
212+type IssueCommented struct {
213+ Repo RepoName
214+ Number int64
215+ Comment IssueComment
216+ At time.Time
217+ Visibility Visibility
218+ CreatedAt time.Time
219+}
220+
221+// EventKind implements Event.
222+func (IssueCommented) EventKind() string { return "issue.commented" }
223+
176224 // EventGap tells a subscriber that it missed events because it was too slow;
177225 // it must refresh its whole view.
178226 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 {
4343 AllowedMergeModes []MergeMode
4444 // FailingChecksBlock says that a failing check prevents the merge.
4545 FailingChecksBlock bool
46+ // Issues says who may write issues; the empty mode means IssuesOff.
47+ Issues IssuesMode
4648 }
4749
4850 // DefaultRepoSettings are the settings of a new repository: both modes, and
4951 // failing checks block the merge.
5052 func DefaultRepoSettings() RepoSettings {
51- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true}
53+ return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
5254 }
5355
5456 // IsZero reports whether s holds no mode, which only the zero value does.
@@ -43,12 +43,14 @@ type RepoSettings struct {
43 AllowedMergeModes []MergeMode43 AllowedMergeModes []MergeMode
44 // FailingChecksBlock says that a failing check prevents the merge.44 // FailingChecksBlock says that a failing check prevents the merge.
45 FailingChecksBlock bool45 FailingChecksBlock bool
46+ // Issues says who may write issues; the empty mode means IssuesOff.
47+ Issues IssuesMode
46 }48 }
47 49
48 // DefaultRepoSettings are the settings of a new repository: both modes, and50 // DefaultRepoSettings are the settings of a new repository: both modes, and
49 // failing checks block the merge.51 // failing checks block the merge.
50 func DefaultRepoSettings() RepoSettings {52 func DefaultRepoSettings() RepoSettings {
51- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true}53+ return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
52 }54 }
53 55
54 // IsZero reports whether s holds no mode, which only the zero value does.56 // IsZero reports whether s holds no mode, which only the zero value does.
M internal/domain/text.go +102 -0
@@ -5,8 +5,11 @@ package domain
55 import (
66 "errors"
77 "fmt"
8+ "strings"
89 "unicode"
910 "unicode/utf8"
11+
12+ "golang.org/x/text/unicode/norm"
1013 )
1114
1215 // Limits of the plain text of a merge request (ADR 0017), in characters.
@@ -178,3 +181,102 @@ func (v *Violations) Err() error {
178181 }
179182 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
180183 }
184+
185+// Limits of the text of an issue, in code points.
186+const (
187+ // MaxIssueTitleLen is the longest issue title.
188+ MaxIssueTitleLen = 120
189+ // MaxIssueBodyLen is the longest issue body or issue comment.
190+ MaxIssueBodyLen = 10000
191+ // MaxAuthorLabelLen is the longest author label.
192+ MaxAuthorLabelLen = 40
193+)
194+
195+// IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code
196+// points, normalized. Only ParseIssueTitle builds a non-zero value.
197+type IssueTitle struct{ s string }
198+
199+// String returns the title.
200+func (t IssueTitle) String() string { return t.s }
201+
202+// IsZero reports whether t is the zero value.
203+func (t IssueTitle) IsZero() bool { return t.s == "" }
204+
205+// IssueBody is a validated issue body or issue comment: at most
206+// MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The
207+// zero value is the empty text.
208+type IssueBody struct{ s string }
209+
210+// String returns the text.
211+func (b IssueBody) String() string { return b.s }
212+
213+// IsBlank reports whether the text is empty or only white space.
214+func (b IssueBody) IsBlank() bool { return isBlank(b.s) }
215+
216+// ParseIssueTitle cleans s (see cleanText) and validates it: one line, not
217+// blank, at most MaxIssueTitleLen code points. A line break is refused, not
218+// removed, so that two lines are never glued into one.
219+func ParseIssueTitle(s string) (IssueTitle, error) {
220+ if !utf8.ValidString(s) {
221+ return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"}
222+ }
223+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
224+ return IssueTitle{}, &TextError{Reason: "must be one line"}
225+ }
226+ c := strings.TrimSpace(cleanText(s, false))
227+ if c == "" {
228+ return IssueTitle{}, &TextError{Reason: "is empty"}
229+ }
230+ if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen {
231+ return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)}
232+ }
233+ return IssueTitle{s: c}, nil
234+}
235+
236+// ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and
237+// validates it: at most MaxIssueBodyLen code points. The empty text is valid.
238+func ParseIssueBody(s string) (IssueBody, error) {
239+ if !utf8.ValidString(s) {
240+ return IssueBody{}, &TextError{Reason: "is not valid UTF-8"}
241+ }
242+ c := cleanText(s, true)
243+ if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen {
244+ return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)}
245+ }
246+ return IssueBody{s: c}, nil
247+}
248+
249+// ParseAuthorLabel cleans s like a title (one line) and validates it: at most
250+// MaxAuthorLabelLen code points. The empty label is valid.
251+func ParseAuthorLabel(s string) (string, error) {
252+ if !utf8.ValidString(s) {
253+ return "", &TextError{Reason: "is not valid UTF-8"}
254+ }
255+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
256+ return "", &TextError{Reason: "must be one line"}
257+ }
258+ c := strings.TrimSpace(cleanText(s, false))
259+ if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen {
260+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)}
261+ }
262+ return c, nil
263+}
264+
265+// cleanText normalizes s to NFC and removes the control characters (C0, C1 and
266+// DEL; with multiline, line feeds and tabs stay) and the bidirectional marks,
267+// embeddings, overrides and isolates. s must be valid UTF-8.
268+func cleanText(s string, multiline bool) string {
269+ s = norm.NFC.String(s)
270+ if multiline {
271+ s = strings.ReplaceAll(s, "\r\n", "\n")
272+ }
273+ return strings.Map(func(r rune) rune {
274+ switch {
275+ case multiline && (r == '\n' || r == '\t'):
276+ return r
277+ case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029':
278+ return -1
279+ }
280+ return r
281+ }, s)
282+}
@@ -5,8 +5,11 @@ package domain
5 import (5 import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "strings"
8 "unicode"9 "unicode"
9 "unicode/utf8"10 "unicode/utf8"
11+
12+ "golang.org/x/text/unicode/norm"
10 )13 )
11 14
12 // Limits of the plain text of a merge request (ADR 0017), in characters.15 // Limits of the plain text of a merge request (ADR 0017), in characters.
@@ -178,3 +181,102 @@ func (v *Violations) Err() error {
178 }181 }
179 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}182 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
180 }183 }
184+
185+// Limits of the text of an issue, in code points.
186+const (
187+ // MaxIssueTitleLen is the longest issue title.
188+ MaxIssueTitleLen = 120
189+ // MaxIssueBodyLen is the longest issue body or issue comment.
190+ MaxIssueBodyLen = 10000
191+ // MaxAuthorLabelLen is the longest author label.
192+ MaxAuthorLabelLen = 40
193+)
194+
195+// IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code
196+// points, normalized. Only ParseIssueTitle builds a non-zero value.
197+type IssueTitle struct{ s string }
198+
199+// String returns the title.
200+func (t IssueTitle) String() string { return t.s }
201+
202+// IsZero reports whether t is the zero value.
203+func (t IssueTitle) IsZero() bool { return t.s == "" }
204+
205+// IssueBody is a validated issue body or issue comment: at most
206+// MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The
207+// zero value is the empty text.
208+type IssueBody struct{ s string }
209+
210+// String returns the text.
211+func (b IssueBody) String() string { return b.s }
212+
213+// IsBlank reports whether the text is empty or only white space.
214+func (b IssueBody) IsBlank() bool { return isBlank(b.s) }
215+
216+// ParseIssueTitle cleans s (see cleanText) and validates it: one line, not
217+// blank, at most MaxIssueTitleLen code points. A line break is refused, not
218+// removed, so that two lines are never glued into one.
219+func ParseIssueTitle(s string) (IssueTitle, error) {
220+ if !utf8.ValidString(s) {
221+ return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"}
222+ }
223+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
224+ return IssueTitle{}, &TextError{Reason: "must be one line"}
225+ }
226+ c := strings.TrimSpace(cleanText(s, false))
227+ if c == "" {
228+ return IssueTitle{}, &TextError{Reason: "is empty"}
229+ }
230+ if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen {
231+ return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)}
232+ }
233+ return IssueTitle{s: c}, nil
234+}
235+
236+// ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and
237+// validates it: at most MaxIssueBodyLen code points. The empty text is valid.
238+func ParseIssueBody(s string) (IssueBody, error) {
239+ if !utf8.ValidString(s) {
240+ return IssueBody{}, &TextError{Reason: "is not valid UTF-8"}
241+ }
242+ c := cleanText(s, true)
243+ if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen {
244+ return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)}
245+ }
246+ return IssueBody{s: c}, nil
247+}
248+
249+// ParseAuthorLabel cleans s like a title (one line) and validates it: at most
250+// MaxAuthorLabelLen code points. The empty label is valid.
251+func ParseAuthorLabel(s string) (string, error) {
252+ if !utf8.ValidString(s) {
253+ return "", &TextError{Reason: "is not valid UTF-8"}
254+ }
255+ if strings.ContainsAny(s, "\r\n\u2028\u2029") {
256+ return "", &TextError{Reason: "must be one line"}
257+ }
258+ c := strings.TrimSpace(cleanText(s, false))
259+ if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen {
260+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)}
261+ }
262+ return c, nil
263+}
264+
265+// cleanText normalizes s to NFC and removes the control characters (C0, C1 and
266+// DEL; with multiline, line feeds and tabs stay) and the bidirectional marks,
267+// embeddings, overrides and isolates. s must be valid UTF-8.
268+func cleanText(s string, multiline bool) string {
269+ s = norm.NFC.String(s)
270+ if multiline {
271+ s = strings.ReplaceAll(s, "\r\n", "\n")
272+ }
273+ return strings.Map(func(r rune) rune {
274+ switch {
275+ case multiline && (r == '\n' || r == '\t'):
276+ return r
277+ case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029':
278+ return -1
279+ }
280+ return r
281+ }, s)
282+}
M internal/domain/token.go +5 -1
@@ -38,15 +38,19 @@ const (
3838 ScopeRunnerRegister Scope = "runner:register"
3939 // ScopeTokenAdmin creates, lists and revokes stored tokens.
4040 ScopeTokenAdmin Scope = "token:admin"
41+ // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation
42+ // is not part of it: it stays with the owner.
43+ ScopeIssueWrite Scope = "issue:write"
4144 )
4245
43-// AllScopes returns the nine scopes in a stable order.
46+// AllScopes returns the ten scopes in a stable order.
4447 func AllScopes() []Scope {
4548 return []Scope{
4649 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
4750 ScopeMRWrite, ScopeMRMerge,
4851 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
4952 ScopeTokenAdmin,
53+ ScopeIssueWrite,
5054 }
5155 }
5256
@@ -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 (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 9)
19+ assert.Len(t, all, 10)
20+ assert.Contains(t, all, domain.ScopeIssueWrite)
21+ got, err := domain.ParseScope("issue:write")
22+ require.NoError(t, err)
23+ assert.Equal(t, domain.ScopeIssueWrite, got)
2024 for _, s := range all {
2125 got, err := domain.ParseScope(s.String())
2226 require.NoError(t, err)
@@ -110,6 +114,7 @@ func TestHasOwnerRights(t *testing.T) {
110114 assert.False(t, domain.Anonymous().HasOwnerRights())
111115 assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights())
112116 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
117+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:9]}).HasOwnerRights(), "without issue:write")
113118 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
114119 }
115120
@@ -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) {
11991199 off := false
12001200 got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
12011201 require.NoError(t, err)
1202- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)
1202+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)
12031203 again, err := r.repos.Get(r.ctx, root, r.repo)
12041204 require.NoError(t, err)
12051205 assert.Equal(t, got.Settings, again.Settings)
@@ -1199,7 +1199,7 @@ func TestUpdateRepoSettings(t *testing.T) {
1199 off := false1199 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)

Keyboard shortcuts

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

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

Branches 3

Tags 44

↑↓ moveenter openesc close