detached 99db356

feat(usecase): allow public issues

Accept issues=public, store Issue.External for approved submissions,
and share a proof-of-work vector with the browser solver.
commit
99db3566b9665e6cb88f2d51c3ffb53d06e081a2
author
brice · · 2026-10-19 07:21 UTC
parent
a26798b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +78 -16
M gen/ermite/storage/issue.pb.go +14 -3 generated file: collapsed
M internal/adapters/kvstore/issues.go +2 -0
@@ -333,6 +333,7 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
333333 CreatedAt: encodeTime(issue.CreatedAt),
334334 UpdatedAt: encodeTime(issue.UpdatedAt),
335335 CommentSeq: issue.CommentSeq,
336+ External: issue.External,
336337 }), nil
337338 }
338339
@@ -362,6 +363,7 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
362363 Author: pb.GetAuthor(),
363364 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
364365 CommentSeq: pb.GetCommentSeq(),
366+ External: pb.GetExternal(),
365367 Version: item.Version,
366368 }, nil
367369 }
@@ -333,6 +333,7 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
333 CreatedAt: encodeTime(issue.CreatedAt),333 CreatedAt: encodeTime(issue.CreatedAt),
334 UpdatedAt: encodeTime(issue.UpdatedAt),334 UpdatedAt: encodeTime(issue.UpdatedAt),
335 CommentSeq: issue.CommentSeq,335 CommentSeq: issue.CommentSeq,
336+ External: issue.External,
336 }), nil337 }), nil
337 }338 }
338 339
@@ -362,6 +363,7 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
362 Author: pb.GetAuthor(),363 Author: pb.GetAuthor(),
363 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),364 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
364 CommentSeq: pb.GetCommentSeq(),365 CommentSeq: pb.GetCommentSeq(),
366+ External: pb.GetExternal(),
365 Version: item.Version,367 Version: item.Version,
366 }, nil368 }, nil
367 }369 }
M internal/adapters/kvstore/issues_pending.go +1 -1
@@ -238,7 +238,7 @@ func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id do
238238 }
239239 issue := domain.Issue{
240240 Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen,
241- Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt,
241+ Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt, External: true,
242242 }
243243 val, err := encodeIssue(issue)
244244 if err != nil {
@@ -238,7 +238,7 @@ func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id do
238 }238 }
239 issue := domain.Issue{239 issue := domain.Issue{
240 Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen,240 Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen,
241- Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt,241+ Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt, External: true,
242 }242 }
243 val, err := encodeIssue(issue)243 val, err := encodeIssue(issue)
244 if err != nil {244 if err != nil {
M internal/adapters/kvstore/issues_pending_test.go +3 -0
@@ -120,6 +120,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) {
120120 assert.Equal(t, domain.IssueStateOpen, issue.State)
121121 assert.Equal(t, "Alex", issue.Author)
122122 assert.Equal(t, "named", issue.Title.String())
123+ assert.True(t, issue.External, "an approved submission is external")
123124
124125 _, err = ps.Get(ctx, n, named.ID)
125126 assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
@@ -129,6 +130,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) {
129130 got, err := s.Issues().Get(ctx, n, 2)
130131 require.NoError(t, err)
131132 assert.Equal(t, issue.Title, got.Title)
133+ assert.True(t, got.External, "the flag is stored")
132134 open, err := s.Issues().ListOpen(ctx, n)
133135 require.NoError(t, err)
134136 require.Len(t, open, 1)
@@ -142,6 +144,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) {
142144 next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
143145 require.NoError(t, err)
144146 assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
147+ assert.False(t, next.External, "an issue of the owner is not external")
145148 }
146149
147150 func TestPendingIssueApproveConcurrent(t *testing.T) {
@@ -120,6 +120,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) {
120 assert.Equal(t, domain.IssueStateOpen, issue.State)120 assert.Equal(t, domain.IssueStateOpen, issue.State)
121 assert.Equal(t, "Alex", issue.Author)121 assert.Equal(t, "Alex", issue.Author)
122 assert.Equal(t, "named", issue.Title.String())122 assert.Equal(t, "named", issue.Title.String())
123+ assert.True(t, issue.External, "an approved submission is external")
123 124
124 _, err = ps.Get(ctx, n, named.ID)125 _, err = ps.Get(ctx, n, named.ID)
125 assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")126 assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
@@ -129,6 +130,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) {
129 got, err := s.Issues().Get(ctx, n, 2)130 got, err := s.Issues().Get(ctx, n, 2)
130 require.NoError(t, err)131 require.NoError(t, err)
131 assert.Equal(t, issue.Title, got.Title)132 assert.Equal(t, issue.Title, got.Title)
133+ assert.True(t, got.External, "the flag is stored")
132 open, err := s.Issues().ListOpen(ctx, n)134 open, err := s.Issues().ListOpen(ctx, n)
133 require.NoError(t, err)135 require.NoError(t, err)
134 require.Len(t, open, 1)136 require.Len(t, open, 1)
@@ -142,6 +144,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) {
142 next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))144 next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
143 require.NoError(t, err)145 require.NoError(t, err)
144 assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")146 assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
147+ assert.False(t, next.External, "an issue of the owner is not external")
145 }148 }
146 149
147 func TestPendingIssueApproveConcurrent(t *testing.T) {150 func TestPendingIssueApproveConcurrent(t *testing.T) {
M internal/app/issue_moderation_api_test.go +2 -2
@@ -115,10 +115,10 @@ func TestIssueModerationOverTheAPI(t *testing.T) {
115115 require.Len(t, ov.Pending, 1)
116116 assert.Nil(t, ov.Pending[0].IPHash)
117117
118- // The public mode is still refused.
118+ // The public mode is accepted.
119119 pub := domain.IssuesPublic
120120 _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
121- assert.Error(t, err)
121+ assert.NoError(t, err)
122122 }
123123
124124 // clientlimit may not import the domain: its copies of the limits must match.
@@ -115,10 +115,10 @@ func TestIssueModerationOverTheAPI(t *testing.T) {
115 require.Len(t, ov.Pending, 1)115 require.Len(t, ov.Pending, 1)
116 assert.Nil(t, ov.Pending[0].IPHash)116 assert.Nil(t, ov.Pending[0].IPHash)
117 117
118- // The public mode is still refused.118+ // The public mode is accepted.
119 pub := domain.IssuesPublic119 pub := domain.IssuesPublic
120 _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})120 _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
121- assert.Error(t, err)121+ assert.NoError(t, err)
122 }122 }
123 123
124 // clientlimit may not import the domain: its copies of the limits must match.124 // clientlimit may not import the domain: its copies of the limits must match.
M internal/domain/issue.go +3 -0
@@ -72,6 +72,9 @@ type Issue struct {
7272 UpdatedAt time.Time
7373 // CommentSeq is the id of the last comment of the issue.
7474 CommentSeq int64
75+ // External is true for an issue approved from the public form: its text
76+ // comes from an anonymous visitor and is rendered as untrusted.
77+ External bool
7578 // Version is set by the store on read: Update fails with ErrConflict when
7679 // the stored issue changed since. Opaque to everyone else.
7780 Version uint64
@@ -72,6 +72,9 @@ type Issue struct {
72 UpdatedAt time.Time72 UpdatedAt time.Time
73 // CommentSeq is the id of the last comment of the issue.73 // CommentSeq is the id of the last comment of the issue.
74 CommentSeq int6474 CommentSeq int64
75+ // External is true for an issue approved from the public form: its text
76+ // comes from an anonymous visitor and is rendered as untrusted.
77+ External bool
75 // Version is set by the store on read: Update fails with ErrConflict when78 // Version is set by the store on read: Update fails with ErrConflict when
76 // the stored issue changed since. Opaque to everyone else.79 // the stored issue changed since. Opaque to everyone else.
77 Version uint6480 Version uint64
M internal/ports/dto.go +1 -1
@@ -49,7 +49,7 @@ type UpdateRepoParams struct {
4949 AllowedMergeModes *[]domain.MergeMode
5050 // FailingChecksBlock replaces the setting of that name.
5151 FailingChecksBlock *bool
52- // Issues replaces the issues mode. IssuesPublic is refused.
52+ // Issues replaces the issues mode.
5353 Issues *domain.IssuesMode
5454 }
5555
@@ -49,7 +49,7 @@ type UpdateRepoParams struct {
49 AllowedMergeModes *[]domain.MergeMode49 AllowedMergeModes *[]domain.MergeMode
50 // FailingChecksBlock replaces the setting of that name.50 // FailingChecksBlock replaces the setting of that name.
51 FailingChecksBlock *bool51 FailingChecksBlock *bool
52- // Issues replaces the issues mode. IssuesPublic is refused.52+ // Issues replaces the issues mode.
53 Issues *domain.IssuesMode53 Issues *domain.IssuesMode
54 }54 }
55 55
A internal/usecase/issue_pow_vector_test.go +39 -0
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "encoding/base64"
7+ "strconv"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// The proof-of-work vector shared with the browser solver: the same nonce,
15+// difficulty and smallest solution are asserted in
16+// internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both.
17+const (
18+ powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding
19+ powVectorDifficulty = 12
20+ powVectorSolution = "2491"
21+)
22+
23+func TestPowVector(t *testing.T) {
24+ nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce)
25+ require.NoError(t, err)
26+ require.Len(t, nonce, nonceLen)
27+
28+ first := ""
29+ for n := 0; n < 1<<22; n++ {
30+ s := strconv.Itoa(n)
31+ if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty {
32+ first = s
33+ break
34+ }
35+ }
36+ require.NotEmpty(t, first)
37+ assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector")
38+ assert.True(t, validSolution(first))
39+}
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "encoding/base64"
7+ "strconv"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// The proof-of-work vector shared with the browser solver: the same nonce,
15+// difficulty and smallest solution are asserted in
16+// internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both.
17+const (
18+ powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding
19+ powVectorDifficulty = 12
20+ powVectorSolution = "2491"
21+)
22+
23+func TestPowVector(t *testing.T) {
24+ nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce)
25+ require.NoError(t, err)
26+ require.Len(t, nonce, nonceLen)
27+
28+ first := ""
29+ for n := 0; n < 1<<22; n++ {
30+ s := strconv.Itoa(n)
31+ if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty {
32+ first = s
33+ break
34+ }
35+ }
36+ require.NotEmpty(t, first)
37+ assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector")
38+ assert.True(t, validSolution(first))
39+}
M internal/usecase/repos_write.go +0 -3
@@ -77,9 +77,6 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
7777 if err != nil {
7878 return domain.Repo{}, err
7979 }
80- if mode == domain.IssuesPublic {
81- return domain.Repo{}, fmt.Errorf("%w: public issues are not available yet", domain.ErrInvalid)
82- }
8380 params.Issues = &mode
8481 }
8582 if params.Description != nil {
@@ -77,9 +77,6 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
77 if err != nil {77 if err != nil {
78 return domain.Repo{}, err78 return domain.Repo{}, err
79 }79 }
80- if mode == domain.IssuesPublic {
81- return domain.Repo{}, fmt.Errorf("%w: public issues are not available yet", domain.ErrInvalid)
82- }
83 params.Issues = &mode80 params.Issues = &mode
84 }81 }
85 if params.Description != nil {82 if params.Description != nil {
M internal/usecase/repos_write_test.go +10 -6
@@ -247,13 +247,17 @@ func TestUpdateIssuesMode(t *testing.T) {
247247 n := name(t, "tools")
248248 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
249249
250- t.Run("public is refused before anything is written", func(t *testing.T) {
250+ t.Run("public is stored and published", func(t *testing.T) {
251251 t.Parallel()
252- f := newFixture(t) // no Update, no Publish
253- f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
254- _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
255- assert.ErrorIs(t, err, domain.ErrInvalid)
256- assert.Contains(t, err.Error(), "public issues are not available yet")
252+ f := newFixture(t)
253+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
254+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
255+ return r.Settings.Issues == domain.IssuesPublic
256+ })).Return(nil)
257+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259+ require.NoError(t, err)
260+ assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues)
257261 })
258262
259263 t.Run("an unknown mode is invalid", func(t *testing.T) {
@@ -247,13 +247,17 @@ func TestUpdateIssuesMode(t *testing.T) {
247 n := name(t, "tools")247 n := name(t, "tools")
248 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}248 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
249 249
250- t.Run("public is refused before anything is written", func(t *testing.T) {250+ t.Run("public is stored and published", func(t *testing.T) {
251 t.Parallel()251 t.Parallel()
252- f := newFixture(t) // no Update, no Publish252+ f := newFixture(t)
253- f.meta.EXPECT().Get(ctx, n).Return(priv, nil)253+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
254- _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})254+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
255- assert.ErrorIs(t, err, domain.ErrInvalid)255+ return r.Settings.Issues == domain.IssuesPublic
256- assert.Contains(t, err.Error(), "public issues are not available yet")256+ })).Return(nil)
257+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259+ require.NoError(t, err)
260+ assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues)
257 })261 })
258 262
259 t.Run("an unknown mode is invalid", func(t *testing.T) {263 t.Run("an unknown mode is invalid", func(t *testing.T) {
M proto/ermite/storage/issue.proto +3 -0
@@ -33,6 +33,9 @@ message Issue {
3333 google.protobuf.Timestamp updated_at = 7;
3434 // Last comment id handed out for this issue.
3535 int64 comment_seq = 8;
36+ // True for an issue approved from the public form: its text comes from
37+ // an anonymous visitor and is rendered as untrusted.
38+ bool external = 9;
3639 }
3740
3841 // IssueComment is stored under the key
@@ -33,6 +33,9 @@ message Issue {
33 google.protobuf.Timestamp updated_at = 7;33 google.protobuf.Timestamp updated_at = 7;
34 // Last comment id handed out for this issue.34 // Last comment id handed out for this issue.
35 int64 comment_seq = 8;35 int64 comment_seq = 8;
36+ // True for an issue approved from the public form: its text comes from
37+ // an anonymous visitor and is rendered as untrusted.
38+ bool external = 9;
36 }39 }
37 40
38 // IssueComment is stored under the key41 // IssueComment is stored under the key

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 43

↑↓ moveenter openesc close