detached 784ea9f

feat(domain): add pending issues, the issue submitted event and the form limits

commit
784ea9feea0c3ccc9fe9ea28e9c7d46e09f091bf
author
brice · · 2026-10-19 06:53 UTC
parent
babd8c2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +175 -0
M internal/domain/event.go +12 -0
@@ -221,6 +221,18 @@ type IssueCommented struct {
221221 // EventKind implements Event.
222222 func (IssueCommented) EventKind() string { return "issue.commented" }
223223
224+// IssueSubmitted is published when an anonymous submission awaits moderation.
225+// It carries no content, and is delivered to the owner topic only: never to
226+// home or to a repository topic.
227+type IssueSubmitted struct {
228+ Repo RepoName
229+ ID PendingIssueID
230+ At time.Time
231+}
232+
233+// EventKind implements Event.
234+func (IssueSubmitted) EventKind() string { return "issue.submitted" }
235+
224236 // EventGap tells a subscriber that it missed events because it was too slow;
225237 // it must refresh its whole view.
226238 type EventGap struct{}
@@ -221,6 +221,18 @@ type IssueCommented struct {
221 // EventKind implements Event.221 // EventKind implements Event.
222 func (IssueCommented) EventKind() string { return "issue.commented" }222 func (IssueCommented) EventKind() string { return "issue.commented" }
223 223
224+// IssueSubmitted is published when an anonymous submission awaits moderation.
225+// It carries no content, and is delivered to the owner topic only: never to
226+// home or to a repository topic.
227+type IssueSubmitted struct {
228+ Repo RepoName
229+ ID PendingIssueID
230+ At time.Time
231+}
232+
233+// EventKind implements Event.
234+func (IssueSubmitted) EventKind() string { return "issue.submitted" }
235+
224 // EventGap tells a subscriber that it missed events because it was too slow;236 // EventGap tells a subscriber that it missed events because it was too slow;
225 // it must refresh its whole view.237 // it must refresh its whole view.
226 type EventGap struct{}238 type EventGap struct{}
A internal/domain/pending_issue.go +104 -0
@@ -0,0 +1,104 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/rand"
7+ "encoding/base32"
8+ "fmt"
9+ "strings"
10+ "time"
11+ "unicode/utf8"
12+)
13+
14+// Limits of the public issue form (ADR 0031).
15+const (
16+ // MaxPendingIssues is the most issues awaiting moderation, over every
17+ // repository. Past it submissions are refused.
18+ MaxPendingIssues = 200
19+ // SubmitPerHour and SubmitPerDay are the submissions one client may make
20+ // in a sliding hour and a sliding day.
21+ SubmitPerHour = 3
22+ SubmitPerDay = 10
23+ // BanDuration is how long a rejected submitter stays banned.
24+ BanDuration = 30 * 24 * time.Hour
25+ // MaxPendingAuthorLen is the longest display name of a submitter, in
26+ // code points.
27+ MaxPendingAuthorLen = 40
28+ // PendingIssueIDLen is the length of a pending issue id: 16 random bytes
29+ // in unpadded lowercase base32.
30+ PendingIssueIDLen = 26
31+ // IPHashLen is the size of the HMAC of a client key.
32+ IPHashLen = 32
33+)
34+
35+var pendingIDEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
36+
37+// PendingIssueID identifies an issue awaiting moderation. It is random and
38+// opaque, not a number: a pending issue has none yet.
39+type PendingIssueID string
40+
41+// NewPendingIssueID returns a fresh random id.
42+func NewPendingIssueID() (PendingIssueID, error) {
43+ var b [16]byte
44+ if _, err := rand.Read(b[:]); err != nil {
45+ return "", fmt.Errorf("generate pending issue id: %w", err)
46+ }
47+ return PendingIssueID(strings.ToLower(pendingIDEncoding.EncodeToString(b[:]))), nil
48+}
49+
50+// ParsePendingIssueID accepts exactly what NewPendingIssueID produces: 26
51+// characters of a-z and 2-7. Anything else gives an error wrapping ErrInvalid.
52+func ParsePendingIssueID(s string) (PendingIssueID, error) {
53+ if len(s) != PendingIssueIDLen {
54+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
55+ }
56+ for i := range len(s) {
57+ c := s[i]
58+ if (c < 'a' || c > 'z') && (c < '2' || c > '7') {
59+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
60+ }
61+ }
62+ // 26 base32 characters carry 130 bits; the last one must not set the two
63+ // spare bits, or the id would not round-trip.
64+ raw, err := pendingIDEncoding.DecodeString(strings.ToUpper(s))
65+ if err != nil || len(raw) != 16 || strings.ToLower(pendingIDEncoding.EncodeToString(raw)) != s {
66+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
67+ }
68+ return PendingIssueID(s), nil
69+}
70+
71+// String returns the id as text.
72+func (id PendingIssueID) String() string { return string(id) }
73+
74+// ParsePendingAuthor validates the optional display name of a submitter: valid
75+// UTF-8, one line, at most MaxPendingAuthorLen code points, normalized.
76+// The empty name is valid.
77+func ParsePendingAuthor(s string) (string, error) {
78+ if !utf8.ValidString(s) {
79+ return "", &TextError{Reason: "is not valid UTF-8"}
80+ }
81+ c := cleanText(s, false)
82+ if n := utf8.RuneCountInString(c); n > MaxPendingAuthorLen {
83+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxPendingAuthorLen, n)}
84+ }
85+ return c, nil
86+}
87+
88+// PendingIssue is an issue submitted through the public form, awaiting the
89+// owner's decision. It has no number: approval gives it one.
90+type PendingIssue struct {
91+ ID PendingIssueID
92+ Repo RepoName
93+ Title IssueTitle
94+ Body IssueBody
95+ // Author is the display name the submitter gave; it may be empty and is
96+ // not verified.
97+ Author string
98+ CreatedAt time.Time
99+ // IPHash is the HMAC-SHA256 of the client key, IPHashLen bytes. It lets
100+ // the owner ban a submitter without the server keeping an address.
101+ IPHash []byte
102+ // Version is set by the store on read; opaque to everyone else.
103+ Version uint64
104+}
@@ -0,0 +1,104 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/rand"
7+ "encoding/base32"
8+ "fmt"
9+ "strings"
10+ "time"
11+ "unicode/utf8"
12+)
13+
14+// Limits of the public issue form (ADR 0031).
15+const (
16+ // MaxPendingIssues is the most issues awaiting moderation, over every
17+ // repository. Past it submissions are refused.
18+ MaxPendingIssues = 200
19+ // SubmitPerHour and SubmitPerDay are the submissions one client may make
20+ // in a sliding hour and a sliding day.
21+ SubmitPerHour = 3
22+ SubmitPerDay = 10
23+ // BanDuration is how long a rejected submitter stays banned.
24+ BanDuration = 30 * 24 * time.Hour
25+ // MaxPendingAuthorLen is the longest display name of a submitter, in
26+ // code points.
27+ MaxPendingAuthorLen = 40
28+ // PendingIssueIDLen is the length of a pending issue id: 16 random bytes
29+ // in unpadded lowercase base32.
30+ PendingIssueIDLen = 26
31+ // IPHashLen is the size of the HMAC of a client key.
32+ IPHashLen = 32
33+)
34+
35+var pendingIDEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
36+
37+// PendingIssueID identifies an issue awaiting moderation. It is random and
38+// opaque, not a number: a pending issue has none yet.
39+type PendingIssueID string
40+
41+// NewPendingIssueID returns a fresh random id.
42+func NewPendingIssueID() (PendingIssueID, error) {
43+ var b [16]byte
44+ if _, err := rand.Read(b[:]); err != nil {
45+ return "", fmt.Errorf("generate pending issue id: %w", err)
46+ }
47+ return PendingIssueID(strings.ToLower(pendingIDEncoding.EncodeToString(b[:]))), nil
48+}
49+
50+// ParsePendingIssueID accepts exactly what NewPendingIssueID produces: 26
51+// characters of a-z and 2-7. Anything else gives an error wrapping ErrInvalid.
52+func ParsePendingIssueID(s string) (PendingIssueID, error) {
53+ if len(s) != PendingIssueIDLen {
54+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
55+ }
56+ for i := range len(s) {
57+ c := s[i]
58+ if (c < 'a' || c > 'z') && (c < '2' || c > '7') {
59+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
60+ }
61+ }
62+ // 26 base32 characters carry 130 bits; the last one must not set the two
63+ // spare bits, or the id would not round-trip.
64+ raw, err := pendingIDEncoding.DecodeString(strings.ToUpper(s))
65+ if err != nil || len(raw) != 16 || strings.ToLower(pendingIDEncoding.EncodeToString(raw)) != s {
66+ return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
67+ }
68+ return PendingIssueID(s), nil
69+}
70+
71+// String returns the id as text.
72+func (id PendingIssueID) String() string { return string(id) }
73+
74+// ParsePendingAuthor validates the optional display name of a submitter: valid
75+// UTF-8, one line, at most MaxPendingAuthorLen code points, normalized.
76+// The empty name is valid.
77+func ParsePendingAuthor(s string) (string, error) {
78+ if !utf8.ValidString(s) {
79+ return "", &TextError{Reason: "is not valid UTF-8"}
80+ }
81+ c := cleanText(s, false)
82+ if n := utf8.RuneCountInString(c); n > MaxPendingAuthorLen {
83+ return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxPendingAuthorLen, n)}
84+ }
85+ return c, nil
86+}
87+
88+// PendingIssue is an issue submitted through the public form, awaiting the
89+// owner's decision. It has no number: approval gives it one.
90+type PendingIssue struct {
91+ ID PendingIssueID
92+ Repo RepoName
93+ Title IssueTitle
94+ Body IssueBody
95+ // Author is the display name the submitter gave; it may be empty and is
96+ // not verified.
97+ Author string
98+ CreatedAt time.Time
99+ // IPHash is the HMAC-SHA256 of the client key, IPHashLen bytes. It lets
100+ // the owner ban a submitter without the server keeping an address.
101+ IPHash []byte
102+ // Version is set by the store on read; opaque to everyone else.
103+ Version uint64
104+}
A internal/domain/pending_issue_test.go +59 -0
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestPendingIssueID(t *testing.T) {
16+ t.Parallel()
17+ id, err := domain.NewPendingIssueID()
18+ require.NoError(t, err)
19+ assert.Len(t, id.String(), domain.PendingIssueIDLen)
20+ got, err := domain.ParsePendingIssueID(id.String())
21+ require.NoError(t, err)
22+ assert.Equal(t, id, got)
23+ other, err := domain.NewPendingIssueID()
24+ require.NoError(t, err)
25+ assert.NotEqual(t, id, other)
26+
27+ for _, bad := range []string{
28+ "", "short", strings.ToUpper(id.String()), id.String() + "a", id.String()[:25] + "1",
29+ strings.Repeat("a", 25) + "b", // spare bits set
30+ strings.Repeat("a", 24) + "=a", "../" + strings.Repeat("a", 23),
31+ } {
32+ _, err := domain.ParsePendingIssueID(bad)
33+ assert.ErrorIs(t, err, domain.ErrInvalid, "%q", bad)
34+ }
35+ _, err = domain.ParsePendingIssueID(strings.Repeat("a", 26))
36+ assert.NoError(t, err)
37+}
38+
39+func TestParsePendingAuthor(t *testing.T) {
40+ t.Parallel()
41+ got, err := domain.ParsePendingAuthor("")
42+ require.NoError(t, err)
43+ assert.Empty(t, got)
44+ got, err = domain.ParsePendingAuthor("Al\u202ee\x01x")
45+ require.NoError(t, err)
46+ assert.Equal(t, "Alex", got)
47+ _, err = domain.ParsePendingAuthor(strings.Repeat("a", 41))
48+ assert.Error(t, err)
49+ _, err = domain.ParsePendingAuthor("\xff")
50+ assert.Error(t, err)
51+ got, err = domain.ParsePendingAuthor(strings.Repeat("é", 40))
52+ require.NoError(t, err)
53+ assert.Len(t, []rune(got), 40)
54+}
55+
56+func TestIssueSubmittedKind(t *testing.T) {
57+ t.Parallel()
58+ assert.Equal(t, "issue.submitted", domain.IssueSubmitted{}.EventKind())
59+}
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestPendingIssueID(t *testing.T) {
16+ t.Parallel()
17+ id, err := domain.NewPendingIssueID()
18+ require.NoError(t, err)
19+ assert.Len(t, id.String(), domain.PendingIssueIDLen)
20+ got, err := domain.ParsePendingIssueID(id.String())
21+ require.NoError(t, err)
22+ assert.Equal(t, id, got)
23+ other, err := domain.NewPendingIssueID()
24+ require.NoError(t, err)
25+ assert.NotEqual(t, id, other)
26+
27+ for _, bad := range []string{
28+ "", "short", strings.ToUpper(id.String()), id.String() + "a", id.String()[:25] + "1",
29+ strings.Repeat("a", 25) + "b", // spare bits set
30+ strings.Repeat("a", 24) + "=a", "../" + strings.Repeat("a", 23),
31+ } {
32+ _, err := domain.ParsePendingIssueID(bad)
33+ assert.ErrorIs(t, err, domain.ErrInvalid, "%q", bad)
34+ }
35+ _, err = domain.ParsePendingIssueID(strings.Repeat("a", 26))
36+ assert.NoError(t, err)
37+}
38+
39+func TestParsePendingAuthor(t *testing.T) {
40+ t.Parallel()
41+ got, err := domain.ParsePendingAuthor("")
42+ require.NoError(t, err)
43+ assert.Empty(t, got)
44+ got, err = domain.ParsePendingAuthor("Al\u202ee\x01x")
45+ require.NoError(t, err)
46+ assert.Equal(t, "Alex", got)
47+ _, err = domain.ParsePendingAuthor(strings.Repeat("a", 41))
48+ assert.Error(t, err)
49+ _, err = domain.ParsePendingAuthor("\xff")
50+ assert.Error(t, err)
51+ got, err = domain.ParsePendingAuthor(strings.Repeat("é", 40))
52+ require.NoError(t, err)
53+ assert.Len(t, []rune(got), 40)
54+}
55+
56+func TestIssueSubmittedKind(t *testing.T) {
57+ t.Parallel()
58+ assert.Equal(t, "issue.submitted", domain.IssueSubmitted{}.EventKind())
59+}

Keyboard shortcuts

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

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

Branches 2

Tags 41

↑↓ moveenter openesc close