detached ee8d39c

feat(rpc): serve pending issue moderation and wire it in the forge

commit
ee8d39cbd640fd45e9fae4cf44de374b0b438e49
author
brice · · 2026-10-19 07:04 UTC
parent
51f0135
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +250 -29
M internal/adapters/rpc/api.go +5 -3
@@ -56,8 +56,10 @@ type Config struct {
5656 MergeRequests ports.MergeRequestService
5757 SigningKey ports.SigningKeyService
5858 // Issues serves IssueService.
59- Issues ports.IssueService
60- Version string
59+ Issues ports.IssueService
60+ // IssueModeration serves the moderation calls of IssueService.
61+ IssueModeration ports.IssueModerationService
62+ Version string
6163 // TrustedProxies are the networks allowed to set X-Forwarded-For.
6264 TrustedProxies []netip.Prefix
6365 // Limiter counts failed authentications; nil builds a default one. Share
@@ -144,7 +146,7 @@ func New(cfg Config) *API {
144146 }
145147 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
146148 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
147- mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{cfg.Issues}, opts...))
149+ mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{issues: cfg.Issues, moderation: cfg.IssueModeration}, opts...))
148150 if cfg.Sessions != nil {
149151 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
150152 }
@@ -56,8 +56,10 @@ type Config struct {
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57 SigningKey ports.SigningKeyService57 SigningKey ports.SigningKeyService
58 // Issues serves IssueService.58 // Issues serves IssueService.
59- Issues ports.IssueService59+ Issues ports.IssueService
60- Version string60+ // IssueModeration serves the moderation calls of IssueService.
61+ IssueModeration ports.IssueModerationService
62+ Version string
61 // TrustedProxies are the networks allowed to set X-Forwarded-For.63 // TrustedProxies are the networks allowed to set X-Forwarded-For.
62 TrustedProxies []netip.Prefix64 TrustedProxies []netip.Prefix
63 // Limiter counts failed authentications; nil builds a default one. Share65 // Limiter counts failed authentications; nil builds a default one. Share
@@ -144,7 +146,7 @@ func New(cfg Config) *API {
144 }146 }
145 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))147 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
146 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))148 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
147- mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{cfg.Issues}, opts...))149+ mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{issues: cfg.Issues, moderation: cfg.IssueModeration}, opts...))
148 if cfg.Sessions != nil {150 if cfg.Sessions != nil {
149 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))151 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
150 }152 }
M internal/adapters/rpc/convert.go +3 -0
@@ -484,6 +484,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
484484 case domain.IssueCommented:
485485 out.Time = timestamp(ev.At)
486486 out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
487+ case domain.IssueSubmitted:
488+ out.Time = timestamp(ev.At)
489+ out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
487490 case domain.RunUpdated:
488491 out.Time = timestamp(ev.At)
489492 out.Kind = runEventToPB(ev)
@@ -484,6 +484,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
484 case domain.IssueCommented:484 case domain.IssueCommented:
485 out.Time = timestamp(ev.At)485 out.Time = timestamp(ev.At)
486 out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}486 out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
487+ case domain.IssueSubmitted:
488+ out.Time = timestamp(ev.At)
489+ out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
487 case domain.RunUpdated:490 case domain.RunUpdated:
488 out.Time = timestamp(ev.At)491 out.Time = timestamp(ev.At)
489 out.Kind = runEventToPB(ev)492 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/issue.go +69 -1
@@ -15,7 +15,10 @@ import (
1515 )
1616
1717 // issueService implements ermiteconnect.IssueServiceHandler.
18-type issueService struct{ issues ports.IssueService }
18+type issueService struct {
19+ issues ports.IssueService
20+ moderation ports.IssueModerationService
21+}
1922
2023 func issueStateToPB(s domain.IssueState) pb.IssueState {
2124 switch s {
@@ -175,3 +178,68 @@ func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.
175178 }
176179 return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
177180 }
181+
182+func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
183+ return &pb.PendingIssue{
184+ Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
185+ Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
186+ }
187+}
188+
189+// pendingRef parses the repository and the id of a moderation request. A
190+// malformed id is reported as not found, like an unknown one.
191+func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
192+ name, err := domain.ParseRepoName(repo)
193+ if err != nil {
194+ return domain.RepoName{}, "", err
195+ }
196+ pid, err := domain.ParsePendingIssueID(id)
197+ if err != nil {
198+ return domain.RepoName{}, "", domain.ErrNotFound
199+ }
200+ return name, pid, nil
201+}
202+
203+func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
204+ var name domain.RepoName
205+ if req.Msg.GetRepo() != "" {
206+ var err error
207+ if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
208+ return nil, connectError(ctx, err)
209+ }
210+ }
211+ page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
212+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
213+ })
214+ if err != nil {
215+ return nil, connectError(ctx, err)
216+ }
217+ out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
218+ for _, p := range page.Issues {
219+ out.Issues = append(out.Issues, pendingIssueToPB(p))
220+ }
221+ return connect.NewResponse(out), nil
222+}
223+
224+func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
225+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
226+ if err != nil {
227+ return nil, connectError(ctx, err)
228+ }
229+ issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
230+ if err != nil {
231+ return nil, connectError(ctx, err)
232+ }
233+ return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
234+}
235+
236+func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
237+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
238+ if err != nil {
239+ return nil, connectError(ctx, err)
240+ }
241+ if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
242+ return nil, connectError(ctx, err)
243+ }
244+ return connect.NewResponse(&pb.RejectIssueResponse{}), nil
245+}
@@ -15,7 +15,10 @@ import (
15 )15 )
16 16
17 // issueService implements ermiteconnect.IssueServiceHandler.17 // issueService implements ermiteconnect.IssueServiceHandler.
18-type issueService struct{ issues ports.IssueService }18+type issueService struct {
19+ issues ports.IssueService
20+ moderation ports.IssueModerationService
21+}
19 22
20 func issueStateToPB(s domain.IssueState) pb.IssueState {23 func issueStateToPB(s domain.IssueState) pb.IssueState {
21 switch s {24 switch s {
@@ -175,3 +178,68 @@ func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.
175 }178 }
176 return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil179 return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
177 }180 }
181+
182+func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
183+ return &pb.PendingIssue{
184+ Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
185+ Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
186+ }
187+}
188+
189+// pendingRef parses the repository and the id of a moderation request. A
190+// malformed id is reported as not found, like an unknown one.
191+func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
192+ name, err := domain.ParseRepoName(repo)
193+ if err != nil {
194+ return domain.RepoName{}, "", err
195+ }
196+ pid, err := domain.ParsePendingIssueID(id)
197+ if err != nil {
198+ return domain.RepoName{}, "", domain.ErrNotFound
199+ }
200+ return name, pid, nil
201+}
202+
203+func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
204+ var name domain.RepoName
205+ if req.Msg.GetRepo() != "" {
206+ var err error
207+ if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
208+ return nil, connectError(ctx, err)
209+ }
210+ }
211+ page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
212+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
213+ })
214+ if err != nil {
215+ return nil, connectError(ctx, err)
216+ }
217+ out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
218+ for _, p := range page.Issues {
219+ out.Issues = append(out.Issues, pendingIssueToPB(p))
220+ }
221+ return connect.NewResponse(out), nil
222+}
223+
224+func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
225+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
226+ if err != nil {
227+ return nil, connectError(ctx, err)
228+ }
229+ issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
230+ if err != nil {
231+ return nil, connectError(ctx, err)
232+ }
233+ return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
234+}
235+
236+func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
237+ name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
238+ if err != nil {
239+ return nil, connectError(ctx, err)
240+ }
241+ if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
242+ return nil, connectError(ctx, err)
243+ }
244+ return connect.NewResponse(&pb.RejectIssueResponse{}), nil
245+}
M internal/adapters/web/fragments.go +1 -1
@@ -62,7 +62,7 @@ func liveEvent(we ports.WatchEvent) bool {
6262 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6363 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
6464 domain.RunUpdated, domain.JobUpdated,
65- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
65+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted:
6666 return true
6767 }
6868 return false
@@ -62,7 +62,7 @@ func liveEvent(we ports.WatchEvent) bool {
62 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,62 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
63 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,63 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
64 domain.RunUpdated, domain.JobUpdated,64 domain.RunUpdated, domain.JobUpdated,
65- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:65+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted:
66 return true66 return true
67 }67 }
68 return false68 return false
M internal/adapters/web/inbox.go +1 -1
@@ -160,7 +160,7 @@ func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
160160 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
161161 switch we.Event.(type) {
162162 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
163- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented,
163+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted,
164164 domain.RepoUpdated, domain.RepoDeleted:
165165 default:
166166 return nil, nil
@@ -160,7 +160,7 @@ func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
160 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {160 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
161 switch we.Event.(type) {161 switch we.Event.(type) {
162 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,162 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
163- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented,163+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted,
164 domain.RepoUpdated, domain.RepoDeleted:164 domain.RepoUpdated, domain.RepoDeleted:
165 default:165 default:
166 return nil, nil166 return nil, nil
M internal/app/forge.go +43 -23
@@ -117,6 +117,14 @@ type forge struct {
117117 MergeRequests *usecase.MergeRequests
118118 // Issues serves IssueService, and tells what a number names.
119119 Issues *usecase.Issues
120+ // IssueModeration serves the moderation calls of IssueService.
121+ IssueModeration *usecase.IssueModeration
122+ // IssueKeys are the subkeys of the public issue form, and the stores and
123+ // the limiter its submissions use (the form itself comes later).
124+ IssueKeys *usecase.IssueKeys
125+ SubmissionLimiter *clientlimit.SubmissionLimiter
126+ PendingIssues ports.PendingIssueStore
127+ IssueBans ports.IssueBanStore
120128 // Inbox serves the owner's inbox of the web.
121129 Inbox *usecase.Inbox
122130 SigningKey *usecase.SigningKey
@@ -323,7 +331,18 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
323331 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
324332 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
325333 })
326- f.Inbox = usecase.NewInbox(usecase.InboxConfig{Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues()})
334+ f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
335+ Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
336+ })
337+ f.SubmissionLimiter = clientlimit.NewSubmissionLimiter(nil)
338+ f.PendingIssues, f.IssueBans = store.PendingIssues(), store.IssueBans()
339+ f.IssueKeys, err = usecase.NewIssueKeys(ctx, store.IssueKeys())
340+ if err != nil {
341+ return nil, err
342+ }
343+ f.Inbox = usecase.NewInbox(usecase.InboxConfig{
344+ Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
345+ })
327346 f.CI.SetPullGate(f.MergeRequests)
328347 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
329348
@@ -364,28 +383,29 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
364383 jobArtifacts, artifacts = f.CI, f.CI
365384 }
366385 f.API = rpc.New(rpc.Config{
367- Auth: f.Auth,
368- Repos: f.Repos,
369- Tokens: f.Tokens,
370- Browse: f.Browse,
371- Events: f.EventLog,
372- MergeRequests: f.MergeRequests,
373- Issues: f.Issues,
374- SigningKey: f.SigningKey,
375- TrustedKeys: f.Signatures,
376- Sessions: f.Sessions,
377- Passkeys: apiPasskeys,
378- Runner: f.CI,
379- Runs: f.CI,
380- Secrets: f.Settings,
381- Variables: f.Settings,
382- Environments: f.Settings,
383- OIDCKeys: apiOIDC,
384- JobArtifacts: jobArtifacts,
385- Artifacts: artifacts,
386- Version: serverVersion(),
387- TrustedProxies: cfg.TrustedProxies,
388- Limiter: limiter,
386+ Auth: f.Auth,
387+ Repos: f.Repos,
388+ Tokens: f.Tokens,
389+ Browse: f.Browse,
390+ Events: f.EventLog,
391+ MergeRequests: f.MergeRequests,
392+ Issues: f.Issues,
393+ IssueModeration: f.IssueModeration,
394+ SigningKey: f.SigningKey,
395+ TrustedKeys: f.Signatures,
396+ Sessions: f.Sessions,
397+ Passkeys: apiPasskeys,
398+ Runner: f.CI,
399+ Runs: f.CI,
400+ Secrets: f.Settings,
401+ Variables: f.Settings,
402+ Environments: f.Settings,
403+ OIDCKeys: apiOIDC,
404+ JobArtifacts: jobArtifacts,
405+ Artifacts: artifacts,
406+ Version: serverVersion(),
407+ TrustedProxies: cfg.TrustedProxies,
408+ Limiter: limiter,
389409 })
390410
391411 gh := githttp.New(githttp.Config{
@@ -117,6 +117,14 @@ type forge struct {
117 MergeRequests *usecase.MergeRequests117 MergeRequests *usecase.MergeRequests
118 // Issues serves IssueService, and tells what a number names.118 // Issues serves IssueService, and tells what a number names.
119 Issues *usecase.Issues119 Issues *usecase.Issues
120+ // IssueModeration serves the moderation calls of IssueService.
121+ IssueModeration *usecase.IssueModeration
122+ // IssueKeys are the subkeys of the public issue form, and the stores and
123+ // the limiter its submissions use (the form itself comes later).
124+ IssueKeys *usecase.IssueKeys
125+ SubmissionLimiter *clientlimit.SubmissionLimiter
126+ PendingIssues ports.PendingIssueStore
127+ IssueBans ports.IssueBanStore
120 // Inbox serves the owner's inbox of the web.128 // Inbox serves the owner's inbox of the web.
121 Inbox *usecase.Inbox129 Inbox *usecase.Inbox
122 SigningKey *usecase.SigningKey130 SigningKey *usecase.SigningKey
@@ -323,7 +331,18 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
323 f.Issues = usecase.NewIssues(usecase.IssuesConfig{331 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
324 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,332 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
325 })333 })
326- f.Inbox = usecase.NewInbox(usecase.InboxConfig{Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues()})334+ f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
335+ Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
336+ })
337+ f.SubmissionLimiter = clientlimit.NewSubmissionLimiter(nil)
338+ f.PendingIssues, f.IssueBans = store.PendingIssues(), store.IssueBans()
339+ f.IssueKeys, err = usecase.NewIssueKeys(ctx, store.IssueKeys())
340+ if err != nil {
341+ return nil, err
342+ }
343+ f.Inbox = usecase.NewInbox(usecase.InboxConfig{
344+ Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
345+ })
327 f.CI.SetPullGate(f.MergeRequests)346 f.CI.SetPullGate(f.MergeRequests)
328 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")347 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
329 348
@@ -364,28 +383,29 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
364 jobArtifacts, artifacts = f.CI, f.CI383 jobArtifacts, artifacts = f.CI, f.CI
365 }384 }
366 f.API = rpc.New(rpc.Config{385 f.API = rpc.New(rpc.Config{
367- Auth: f.Auth,386+ Auth: f.Auth,
368- Repos: f.Repos,387+ Repos: f.Repos,
369- Tokens: f.Tokens,388+ Tokens: f.Tokens,
370- Browse: f.Browse,389+ Browse: f.Browse,
371- Events: f.EventLog,390+ Events: f.EventLog,
372- MergeRequests: f.MergeRequests,391+ MergeRequests: f.MergeRequests,
373- Issues: f.Issues,392+ Issues: f.Issues,
374- SigningKey: f.SigningKey,393+ IssueModeration: f.IssueModeration,
375- TrustedKeys: f.Signatures,394+ SigningKey: f.SigningKey,
376- Sessions: f.Sessions,395+ TrustedKeys: f.Signatures,
377- Passkeys: apiPasskeys,396+ Sessions: f.Sessions,
378- Runner: f.CI,397+ Passkeys: apiPasskeys,
379- Runs: f.CI,398+ Runner: f.CI,
380- Secrets: f.Settings,399+ Runs: f.CI,
381- Variables: f.Settings,400+ Secrets: f.Settings,
382- Environments: f.Settings,401+ Variables: f.Settings,
383- OIDCKeys: apiOIDC,402+ Environments: f.Settings,
384- JobArtifacts: jobArtifacts,403+ OIDCKeys: apiOIDC,
385- Artifacts: artifacts,404+ JobArtifacts: jobArtifacts,
386- Version: serverVersion(),405+ Artifacts: artifacts,
387- TrustedProxies: cfg.TrustedProxies,406+ Version: serverVersion(),
388- Limiter: limiter,407+ TrustedProxies: cfg.TrustedProxies,
408+ Limiter: limiter,
389 })409 })
390 410
391 gh := githttp.New(githttp.Config{411 gh := githttp.New(githttp.Config{
A internal/app/issue_moderation_api_test.go +128 -0
@@ -0,0 +1,128 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/rand"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+func (r *issueRig) pending(repo, title string) domain.PendingIssue {
21+ r.t.Helper()
22+ id, err := domain.NewPendingIssueID()
23+ require.NoError(r.t, err)
24+ ti, err := domain.ParseIssueTitle(title)
25+ require.NoError(r.t, err)
26+ body, err := domain.ParseIssueBody("body of " + title)
27+ require.NoError(r.t, err)
28+ hash := make([]byte, domain.IPHashLen)
29+ _, err = rand.Read(hash)
30+ require.NoError(r.t, err)
31+ p := domain.PendingIssue{
32+ ID: id, Repo: r.name(repo), Title: ti, Body: body, Author: "visitor",
33+ CreatedAt: time.Now().UTC(), IPHash: hash,
34+ }
35+ require.NoError(r.t, r.forge.PendingIssues.Put(r.ctx, p))
36+ return p
37+}
38+
39+func TestIssueModerationOverTheAPI(t *testing.T) {
40+ r := newIssueRig(t)
41+ ctx := r.ctx
42+ r.mode("pub", domain.IssuesOwner)
43+ owner := r.api(r.token(domain.AllScopes()))
44+ p1 := r.pending("pub", "first")
45+ p2 := r.pending("pub", "second")
46+
47+ // Only owner rights see anything: everyone else gets NotFound.
48+ for name, c := range map[string]*clients{"anonymous": r.anon, "reader": r.reader, "writer": r.writer, "blind": r.blind, "branch": r.branch} {
49+ _, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
50+ assert.Equal(t, "not_found", codeName(err), "%s list", name)
51+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
52+ assert.Equal(t, "not_found", codeName(err), "%s approve", name)
53+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p1.ID.String(), Ban: true}))
54+ assert.Equal(t, "not_found", codeName(err), "%s reject", name)
55+ }
56+ still, err := r.forge.PendingIssues.Count(ctx)
57+ require.NoError(t, err)
58+ assert.Equal(t, 2, still, "nothing changed")
59+
60+ for name, c := range map[string]*clients{"root": r.root, "owner token": owner} {
61+ res, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
62+ require.NoError(t, err, name)
63+ require.Len(t, res.Msg.GetIssues(), 2, name)
64+ assert.Equal(t, p1.ID.String(), res.Msg.GetIssues()[0].GetId(), "oldest first")
65+ assert.Equal(t, "visitor", res.Msg.GetIssues()[0].GetAuthor())
66+ }
67+ res, err := owner.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "priv"}))
68+ require.NoError(t, err)
69+ assert.Empty(t, res.Msg.GetIssues())
70+
71+ // A malformed or unknown id is NotFound.
72+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: "nope"}))
73+ assert.Equal(t, "not_found", codeName(err))
74+
75+ // Approval takes the next number shared with the merge requests and issues.
76+ made, err := owner.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "by owner"}))
77+ require.NoError(t, err)
78+ assert.Equal(t, int64(1), made.Msg.GetIssue().GetNumber())
79+ ap, err := owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
80+ require.NoError(t, err)
81+ assert.Equal(t, int64(2), ap.Msg.GetIssue().GetNumber())
82+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, ap.Msg.GetIssue().GetState())
83+ assert.Equal(t, "first", ap.Msg.GetIssue().GetTitle())
84+ got, err := r.reader.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 2}))
85+ require.NoError(t, err)
86+ assert.Equal(t, "visitor", got.Msg.GetIssue().GetAuthor())
87+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
88+ assert.Equal(t, "not_found", codeName(err), "approved once")
89+
90+ // Rejecting with a ban stores the ban and removes the record.
91+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p2.ID.String(), Ban: true}))
92+ require.NoError(t, err)
93+ banned, err := r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now())
94+ require.NoError(t, err)
95+ assert.True(t, banned)
96+ banned, err = r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now().Add(domain.BanDuration+time.Hour))
97+ require.NoError(t, err)
98+ assert.False(t, banned, "the ban expires")
99+ _, err = r.forge.PendingIssues.Get(ctx, p2.Repo, p2.ID)
100+ assert.ErrorIs(t, err, domain.ErrNotFound)
101+
102+ // Rejecting without a ban bans nobody.
103+ p3 := r.pending("pub", "third")
104+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p3.ID.String()}))
105+ require.NoError(t, err)
106+ banned, err = r.forge.IssueBans.Banned(ctx, p3.IPHash, time.Now())
107+ require.NoError(t, err)
108+ assert.False(t, banned)
109+
110+ // The inbox lists what awaits.
111+ r.pending("pub", "fourth")
112+ ov, err := r.forge.Inbox.Overview(ctx, domain.Root(), domain.RepoName{})
113+ require.NoError(t, err)
114+ assert.Equal(t, 1, ov.Counts.Pending)
115+ require.Len(t, ov.Pending, 1)
116+ assert.Nil(t, ov.Pending[0].IPHash)
117+
118+ // The public mode is still refused.
119+ pub := domain.IssuesPublic
120+ _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
121+ assert.Error(t, err)
122+}
123+
124+// clientlimit may not import the domain: its copies of the limits must match.
125+func TestSubmissionLimiterUsesTheDomainLimits(t *testing.T) {
126+ assert.Equal(t, domain.SubmitPerHour, clientlimit.PerHour)
127+ assert.Equal(t, domain.SubmitPerDay, clientlimit.PerDay)
128+}
@@ -0,0 +1,128 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/rand"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+func (r *issueRig) pending(repo, title string) domain.PendingIssue {
21+ r.t.Helper()
22+ id, err := domain.NewPendingIssueID()
23+ require.NoError(r.t, err)
24+ ti, err := domain.ParseIssueTitle(title)
25+ require.NoError(r.t, err)
26+ body, err := domain.ParseIssueBody("body of " + title)
27+ require.NoError(r.t, err)
28+ hash := make([]byte, domain.IPHashLen)
29+ _, err = rand.Read(hash)
30+ require.NoError(r.t, err)
31+ p := domain.PendingIssue{
32+ ID: id, Repo: r.name(repo), Title: ti, Body: body, Author: "visitor",
33+ CreatedAt: time.Now().UTC(), IPHash: hash,
34+ }
35+ require.NoError(r.t, r.forge.PendingIssues.Put(r.ctx, p))
36+ return p
37+}
38+
39+func TestIssueModerationOverTheAPI(t *testing.T) {
40+ r := newIssueRig(t)
41+ ctx := r.ctx
42+ r.mode("pub", domain.IssuesOwner)
43+ owner := r.api(r.token(domain.AllScopes()))
44+ p1 := r.pending("pub", "first")
45+ p2 := r.pending("pub", "second")
46+
47+ // Only owner rights see anything: everyone else gets NotFound.
48+ for name, c := range map[string]*clients{"anonymous": r.anon, "reader": r.reader, "writer": r.writer, "blind": r.blind, "branch": r.branch} {
49+ _, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
50+ assert.Equal(t, "not_found", codeName(err), "%s list", name)
51+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
52+ assert.Equal(t, "not_found", codeName(err), "%s approve", name)
53+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p1.ID.String(), Ban: true}))
54+ assert.Equal(t, "not_found", codeName(err), "%s reject", name)
55+ }
56+ still, err := r.forge.PendingIssues.Count(ctx)
57+ require.NoError(t, err)
58+ assert.Equal(t, 2, still, "nothing changed")
59+
60+ for name, c := range map[string]*clients{"root": r.root, "owner token": owner} {
61+ res, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
62+ require.NoError(t, err, name)
63+ require.Len(t, res.Msg.GetIssues(), 2, name)
64+ assert.Equal(t, p1.ID.String(), res.Msg.GetIssues()[0].GetId(), "oldest first")
65+ assert.Equal(t, "visitor", res.Msg.GetIssues()[0].GetAuthor())
66+ }
67+ res, err := owner.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "priv"}))
68+ require.NoError(t, err)
69+ assert.Empty(t, res.Msg.GetIssues())
70+
71+ // A malformed or unknown id is NotFound.
72+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: "nope"}))
73+ assert.Equal(t, "not_found", codeName(err))
74+
75+ // Approval takes the next number shared with the merge requests and issues.
76+ made, err := owner.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "by owner"}))
77+ require.NoError(t, err)
78+ assert.Equal(t, int64(1), made.Msg.GetIssue().GetNumber())
79+ ap, err := owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
80+ require.NoError(t, err)
81+ assert.Equal(t, int64(2), ap.Msg.GetIssue().GetNumber())
82+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, ap.Msg.GetIssue().GetState())
83+ assert.Equal(t, "first", ap.Msg.GetIssue().GetTitle())
84+ got, err := r.reader.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 2}))
85+ require.NoError(t, err)
86+ assert.Equal(t, "visitor", got.Msg.GetIssue().GetAuthor())
87+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
88+ assert.Equal(t, "not_found", codeName(err), "approved once")
89+
90+ // Rejecting with a ban stores the ban and removes the record.
91+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p2.ID.String(), Ban: true}))
92+ require.NoError(t, err)
93+ banned, err := r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now())
94+ require.NoError(t, err)
95+ assert.True(t, banned)
96+ banned, err = r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now().Add(domain.BanDuration+time.Hour))
97+ require.NoError(t, err)
98+ assert.False(t, banned, "the ban expires")
99+ _, err = r.forge.PendingIssues.Get(ctx, p2.Repo, p2.ID)
100+ assert.ErrorIs(t, err, domain.ErrNotFound)
101+
102+ // Rejecting without a ban bans nobody.
103+ p3 := r.pending("pub", "third")
104+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p3.ID.String()}))
105+ require.NoError(t, err)
106+ banned, err = r.forge.IssueBans.Banned(ctx, p3.IPHash, time.Now())
107+ require.NoError(t, err)
108+ assert.False(t, banned)
109+
110+ // The inbox lists what awaits.
111+ r.pending("pub", "fourth")
112+ ov, err := r.forge.Inbox.Overview(ctx, domain.Root(), domain.RepoName{})
113+ require.NoError(t, err)
114+ assert.Equal(t, 1, ov.Counts.Pending)
115+ require.Len(t, ov.Pending, 1)
116+ assert.Nil(t, ov.Pending[0].IPHash)
117+
118+ // The public mode is still refused.
119+ pub := domain.IssuesPublic
120+ _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
121+ assert.Error(t, err)
122+}
123+
124+// clientlimit may not import the domain: its copies of the limits must match.
125+func TestSubmissionLimiterUsesTheDomainLimits(t *testing.T) {
126+ assert.Equal(t, domain.SubmitPerHour, clientlimit.PerHour)
127+ assert.Equal(t, domain.SubmitPerDay, clientlimit.PerDay)
128+}

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