detached 17f0117

feat(rpc): serve MergeRequestService, GetSigningKey and the repository settings

MergeRequestService maps to the merge request use cases. A request that
breaks a rule is invalid_argument with a BadRequest detail naming the
fields. GetSigningKey needs no authentication. Repo carries its settings and
UpdateRepo accepts settings, settings.allowed_merge_modes and
settings.failing_checks_block in the update mask. The watch stream carries
the merge request events. Features gain merge_requests and signing_key.
The forge generates the signing key on its first start and wires the use
cases.
commit
17f0117b1c24c93689a54d04b9211d5d0a311b5c
author
brice · · 2026-10-10 17:03 UTC
parent
0d1dbea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +653 -39
M cmd/ermite/api_test.go +1 -1
@@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) {
158158 for _, who := range allPrincipals {
159159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160160 require.NoError(t, err, who)
161- assert.ElementsMatch(t, []string{"repo.update", "events.resume"}, resp.Msg.GetFeatures(), who)
161+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, resp.Msg.GetFeatures(), who)
162162 assert.NotEmpty(t, resp.Msg.GetVersion())
163163 }
164164 })
@@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) {
158 for _, who := range allPrincipals {158 for _, who := range allPrincipals {
159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160 require.NoError(t, err, who)160 require.NoError(t, err, who)
161- assert.ElementsMatch(t, []string{"repo.update", "events.resume"}, resp.Msg.GetFeatures(), who)161+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, resp.Msg.GetFeatures(), who)
162 assert.NotEmpty(t, resp.Msg.GetVersion())162 assert.NotEmpty(t, resp.Msg.GetVersion())
163 }163 }
164 })164 })
M cmd/ermite/forge.go +44 -8
@@ -4,6 +4,8 @@ package main
44
55 import (
66 "context"
7+ "crypto/ed25519"
8+ "crypto/rand"
79 "errors"
810 "fmt"
911 "net"
@@ -19,10 +21,12 @@ import (
1921 "bnema.dev/ermite/internal/adapters/httpserver"
2022 "bnema.dev/ermite/internal/adapters/kvstore"
2123 "bnema.dev/ermite/internal/adapters/rpc"
24+ "bnema.dev/ermite/internal/adapters/sshsig"
2225 "bnema.dev/ermite/internal/adapters/web"
2326 "bnema.dev/ermite/internal/app"
2427 "bnema.dev/ermite/internal/clientlimit"
2528 "bnema.dev/ermite/internal/config"
29+ "bnema.dev/ermite/internal/domain"
2630 "bnema.dev/ermite/internal/ports"
2731 )
2832
@@ -56,14 +60,27 @@ type forge struct {
5660
5761 // The use cases and the API are exposed for tests and for the phases
5862 // that add the web.
59- Repos *app.Repos
60- Tokens *app.Tokens
61- Auth *app.Auth
62- Pushes *app.Pushes
63- Browse *app.Browse
64- EventLog *app.Events
65- API *rpc.API
66- Bus ports.EventBus
63+ Repos *app.Repos
64+ Tokens *app.Tokens
65+ Auth *app.Auth
66+ Pushes *app.Pushes
67+ Browse *app.Browse
68+ // MergeRequests and SigningKey serve MergeRequestService and
69+ // ServerService.GetSigningKey.
70+ MergeRequests *app.MergeRequests
71+ SigningKey *app.SigningKey
72+ EventLog *app.Events
73+ API *rpc.API
74+ Bus ports.EventBus
75+}
76+
77+// newSigningSeed makes the seed of a new ed25519 signing key.
78+func newSigningSeed() ([]byte, error) {
79+ seed := make([]byte, ed25519.SeedSize)
80+ if _, err := rand.Read(seed); err != nil {
81+ return nil, err
82+ }
83+ return seed, nil
6784 }
6885
6986 // serverVersion is the version GetVersion reports: the module version of the
@@ -153,6 +170,23 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
153170 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
154171 })
155172
173+ // The instance signing key (ADR 0022): generated on the first start.
174+ seed, err := store.SigningSeed(ctx, newSigningSeed, clock.Now().UTC())
175+ if err != nil {
176+ return nil, err
177+ }
178+ signer, err := sshsig.New(seed)
179+ if err != nil {
180+ return nil, err
181+ }
182+ committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
183+ f.SigningKey = app.NewSigningKey(signer, committer)
184+ f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{
185+ Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
186+ Committer: committer, Bus: f.Bus, Clock: clock,
187+ })
188+ log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
189+
156190 // One budget of failed authentications per client, for Git and the API.
157191 limiter := clientlimit.NewLimiter(nil)
158192 f.API = rpc.New(rpc.Config{
@@ -161,6 +195,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
161195 Tokens: f.Tokens,
162196 Browse: f.Browse,
163197 Events: f.EventLog,
198+ MergeRequests: f.MergeRequests,
199+ SigningKey: f.SigningKey,
164200 Version: serverVersion(),
165201 TrustedProxies: cfg.TrustedProxies,
166202 Limiter: limiter,
@@ -4,6 +4,8 @@ package main
4 4
5 import (5 import (
6 "context"6 "context"
7+ "crypto/ed25519"
8+ "crypto/rand"
7 "errors"9 "errors"
8 "fmt"10 "fmt"
9 "net"11 "net"
@@ -19,10 +21,12 @@ import (
19 "bnema.dev/ermite/internal/adapters/httpserver"21 "bnema.dev/ermite/internal/adapters/httpserver"
20 "bnema.dev/ermite/internal/adapters/kvstore"22 "bnema.dev/ermite/internal/adapters/kvstore"
21 "bnema.dev/ermite/internal/adapters/rpc"23 "bnema.dev/ermite/internal/adapters/rpc"
24+ "bnema.dev/ermite/internal/adapters/sshsig"
22 "bnema.dev/ermite/internal/adapters/web"25 "bnema.dev/ermite/internal/adapters/web"
23 "bnema.dev/ermite/internal/app"26 "bnema.dev/ermite/internal/app"
24 "bnema.dev/ermite/internal/clientlimit"27 "bnema.dev/ermite/internal/clientlimit"
25 "bnema.dev/ermite/internal/config"28 "bnema.dev/ermite/internal/config"
29+ "bnema.dev/ermite/internal/domain"
26 "bnema.dev/ermite/internal/ports"30 "bnema.dev/ermite/internal/ports"
27 )31 )
28 32
@@ -56,14 +60,27 @@ type forge struct {
56 60
57 // The use cases and the API are exposed for tests and for the phases61 // The use cases and the API are exposed for tests and for the phases
58 // that add the web.62 // that add the web.
59- Repos *app.Repos63+ Repos *app.Repos
60- Tokens *app.Tokens64+ Tokens *app.Tokens
61- Auth *app.Auth65+ Auth *app.Auth
62- Pushes *app.Pushes66+ Pushes *app.Pushes
63- Browse *app.Browse67+ Browse *app.Browse
64- EventLog *app.Events68+ // MergeRequests and SigningKey serve MergeRequestService and
65- API *rpc.API69+ // ServerService.GetSigningKey.
66- Bus ports.EventBus70+ MergeRequests *app.MergeRequests
71+ SigningKey *app.SigningKey
72+ EventLog *app.Events
73+ API *rpc.API
74+ Bus ports.EventBus
75+}
76+
77+// newSigningSeed makes the seed of a new ed25519 signing key.
78+func newSigningSeed() ([]byte, error) {
79+ seed := make([]byte, ed25519.SeedSize)
80+ if _, err := rand.Read(seed); err != nil {
81+ return nil, err
82+ }
83+ return seed, nil
67 }84 }
68 85
69 // serverVersion is the version GetVersion reports: the module version of the86 // serverVersion is the version GetVersion reports: the module version of the
@@ -153,6 +170,23 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
153 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,170 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
154 })171 })
155 172
173+ // The instance signing key (ADR 0022): generated on the first start.
174+ seed, err := store.SigningSeed(ctx, newSigningSeed, clock.Now().UTC())
175+ if err != nil {
176+ return nil, err
177+ }
178+ signer, err := sshsig.New(seed)
179+ if err != nil {
180+ return nil, err
181+ }
182+ committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
183+ f.SigningKey = app.NewSigningKey(signer, committer)
184+ f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{
185+ Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
186+ Committer: committer, Bus: f.Bus, Clock: clock,
187+ })
188+ log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
189+
156 // One budget of failed authentications per client, for Git and the API.190 // One budget of failed authentications per client, for Git and the API.
157 limiter := clientlimit.NewLimiter(nil)191 limiter := clientlimit.NewLimiter(nil)
158 f.API = rpc.New(rpc.Config{192 f.API = rpc.New(rpc.Config{
@@ -161,6 +195,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
161 Tokens: f.Tokens,195 Tokens: f.Tokens,
162 Browse: f.Browse,196 Browse: f.Browse,
163 Events: f.EventLog,197 Events: f.EventLog,
198+ MergeRequests: f.MergeRequests,
199+ SigningKey: f.SigningKey,
164 Version: serverVersion(),200 Version: serverVersion(),
165 TrustedProxies: cfg.TrustedProxies,201 TrustedProxies: cfg.TrustedProxies,
166 Limiter: limiter,202 Limiter: limiter,
M cmd/ermite/serve.go +2 -1
@@ -21,7 +21,8 @@ func newServeCmd() *cobra.Command {
2121 Long: "Run the server. It is configured by the environment only:\n" +
2222 config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" +
2323 config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " +
24- config.EnvLogLevel + ", " + config.EnvLogFormat + ".",
24+ config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" +
25+ config.EnvCommitterName + ", " + config.EnvCommitterEmail + ".",
2526 Args: cobra.NoArgs,
2627 RunE: func(cmd *cobra.Command, _ []string) error {
2728 return serve(cmd.Context())
@@ -21,7 +21,8 @@ func newServeCmd() *cobra.Command {
21 Long: "Run the server. It is configured by the environment only:\n" +21 Long: "Run the server. It is configured by the environment only:\n" +
22 config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" +22 config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" +
23 config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " +23 config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " +
24- config.EnvLogLevel + ", " + config.EnvLogFormat + ".",24+ config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" +
25+ config.EnvCommitterName + ", " + config.EnvCommitterEmail + ".",
25 Args: cobra.NoArgs,26 Args: cobra.NoArgs,
26 RunE: func(cmd *cobra.Command, _ []string) error {27 RunE: func(cmd *cobra.Command, _ []string) error {
27 return serve(cmd.Context())28 return serve(cmd.Context())
M internal/adapters/rpc/api.go +12 -8
@@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20
2727
2828 // Features are the optional capabilities of the contract that this server
2929 // implements, as GetVersion lists them.
30-var Features = []string{"repo.update", "events.resume"}
30+var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key"}
3131
3232 // Authenticator turns a token secret into a principal, or
3333 // domain.ErrUnauthenticated. app.Auth fits.
@@ -37,12 +37,15 @@ type Authenticator interface {
3737
3838 // Config configures the API.
3939 type Config struct {
40- Auth Authenticator
41- Repos *app.Repos
42- Tokens *app.Tokens
43- Browse *app.Browse
44- Events *app.Events
45- Version string
40+ Auth Authenticator
41+ Repos *app.Repos
42+ Tokens *app.Tokens
43+ Browse *app.Browse
44+ Events *app.Events
45+ // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
46+ MergeRequests *app.MergeRequests
47+ SigningKey *app.SigningKey
48+ Version string
4649 // TrustedProxies are the networks allowed to set X-Forwarded-For.
4750 TrustedProxies []netip.Prefix
4851 // Limiter counts failed authentications; nil builds a default one. Share
@@ -75,7 +78,8 @@ func New(cfg Config) *API {
7578 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
7679 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
7780 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
78- mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{cfg.Version}, opts...))
81+ mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))
82+ mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
7983 return &API{handler: mux}
8084 }
8185
@@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20
27 27
28 // Features are the optional capabilities of the contract that this server28 // Features are the optional capabilities of the contract that this server
29 // implements, as GetVersion lists them.29 // implements, as GetVersion lists them.
30-var Features = []string{"repo.update", "events.resume"}30+var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key"}
31 31
32 // Authenticator turns a token secret into a principal, or32 // Authenticator turns a token secret into a principal, or
33 // domain.ErrUnauthenticated. app.Auth fits.33 // domain.ErrUnauthenticated. app.Auth fits.
@@ -37,12 +37,15 @@ type Authenticator interface {
37 37
38 // Config configures the API.38 // Config configures the API.
39 type Config struct {39 type Config struct {
40- Auth Authenticator40+ Auth Authenticator
41- Repos *app.Repos41+ Repos *app.Repos
42- Tokens *app.Tokens42+ Tokens *app.Tokens
43- Browse *app.Browse43+ Browse *app.Browse
44- Events *app.Events44+ Events *app.Events
45- Version string45+ // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
46+ MergeRequests *app.MergeRequests
47+ SigningKey *app.SigningKey
48+ Version string
46 // TrustedProxies are the networks allowed to set X-Forwarded-For.49 // TrustedProxies are the networks allowed to set X-Forwarded-For.
47 TrustedProxies []netip.Prefix50 TrustedProxies []netip.Prefix
48 // Limiter counts failed authentications; nil builds a default one. Share51 // Limiter counts failed authentications; nil builds a default one. Share
@@ -75,7 +78,8 @@ func New(cfg Config) *API {
75 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))78 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
76 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))79 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
77 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))80 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
78- mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{cfg.Version}, opts...))81+ mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))
82+ mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
79 return &API{handler: mux}83 return &API{handler: mux}
80 }84 }
81 85
M internal/adapters/rpc/convert.go +85 -11
@@ -56,6 +56,10 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) {
5656 }
5757
5858 func repoToPB(v app.RepoView) *pb.Repo {
59+ settings := v.Settings
60+ if settings.IsZero() {
61+ settings = domain.DefaultRepoSettings()
62+ }
5963 return &pb.Repo{
6064 Name: v.Name.String(),
6165 Description: v.Description,
@@ -63,7 +67,57 @@ func repoToPB(v app.RepoView) *pb.Repo {
6367 DefaultBranch: v.DefaultBranch,
6468 CreatedAt: timestamp(v.CreatedAt),
6569 PushedAt: timestamp(v.PushedAt),
70+ Settings: settingsToPB(settings),
71+ }
72+}
73+
74+func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
75+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
76+ for _, m := range s.AllowedMergeModes {
77+ out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
78+ }
79+ return out
80+}
81+
82+func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
83+ switch m {
84+ case domain.MergeModeFastForward:
85+ return pb.MergeMode_MERGE_MODE_FAST_FORWARD
86+ case domain.MergeModeSquash:
87+ return pb.MergeMode_MERGE_MODE_SQUASH
88+ }
89+ return pb.MergeMode_MERGE_MODE_UNSPECIFIED
90+}
91+
92+// mergeModeFromPB maps a mode of a merge request: unspecified is the empty
93+// domain.MergeMode, which the use case reads as fast-forward.
94+func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
95+ switch m {
96+ case pb.MergeMode_MERGE_MODE_UNSPECIFIED:
97+ return "", nil
98+ case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
99+ return domain.MergeModeFastForward, nil
100+ case pb.MergeMode_MERGE_MODE_SQUASH:
101+ return domain.MergeModeSquash, nil
102+ }
103+ return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
104+}
105+
106+// mergeModesFromPB maps the allowed modes of the settings, where unspecified
107+// is rejected.
108+func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
109+ out := make([]domain.MergeMode, 0, len(in))
110+ for _, m := range in {
111+ mode, err := mergeModeFromPB(m)
112+ if err != nil {
113+ return nil, err
114+ }
115+ if mode == "" {
116+ return nil, fmt.Errorf("%w: MERGE_MODE_UNSPECIFIED is not a merge mode", domain.ErrInvalid)
117+ }
118+ out = append(out, mode)
66119 }
120+ return out, nil
67121 }
68122
69123 var scopeToPB = map[domain.Scope]pb.Scope{
@@ -265,22 +319,30 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
265319 return out
266320 }
267321
268-func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {
269- out := &pb.GetCommitResponse{
270- Commit: commitToPB(d.Commit),
271- Stats: &pb.DiffStats{
272- FilesChanged: int32(d.Stats.FilesChanged),
273- Additions: int32(d.Stats.Additions),
274- Deletions: int32(d.Stats.Deletions),
275- },
276- Diff: &pb.Diff{Truncated: d.Diff.Truncated},
322+func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
323+ return &pb.DiffStats{
324+ FilesChanged: int32(s.FilesChanged),
325+ Additions: int32(s.Additions),
326+ Deletions: int32(s.Deletions),
277327 }
278- for _, f := range d.Diff.Files {
279- out.Diff.Files = append(out.Diff.Files, fileDiffToPB(f))
328+}
329+
330+func diffToPB(d domain.Diff) *pb.Diff {
331+ out := &pb.Diff{Truncated: d.Truncated}
332+ for _, f := range d.Files {
333+ out.Files = append(out.Files, fileDiffToPB(f))
280334 }
281335 return out
282336 }
283337
338+func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {
339+ return &pb.GetCommitResponse{
340+ Commit: commitToPB(d.Commit),
341+ Stats: diffStatsToPB(d.Stats),
342+ Diff: diffToPB(d.Diff),
343+ }
344+}
345+
284346 // oidToPB turns the zero object id of a ref update (no object) into "".
285347 func oidToPB(oid string) string {
286348 if oid == domain.ZeroOID {
@@ -317,6 +379,18 @@ func watchEventToPB(we app.WatchEvent) *pb.Event {
317379 case domain.PushEvent:
318380 out.Time = timestamp(ev.At)
319381 out.Kind = &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: ev.Repo.String(), RefUpdates: refUpdatesToPB(ev.Updates)}}
382+ case domain.MergeRequestOpened:
383+ out.Time = timestamp(ev.At)
384+ out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
385+ case domain.MergeRequestMerged:
386+ out.Time = timestamp(ev.At)
387+ out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
388+ case domain.MergeRequestClosed:
389+ out.Time = timestamp(ev.At)
390+ out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
391+ case domain.CommentAdded:
392+ out.Time = timestamp(ev.At)
393+ out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
320394 default:
321395 return nil
322396 }
@@ -56,6 +56,10 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) {
56 }56 }
57 57
58 func repoToPB(v app.RepoView) *pb.Repo {58 func repoToPB(v app.RepoView) *pb.Repo {
59+ settings := v.Settings
60+ if settings.IsZero() {
61+ settings = domain.DefaultRepoSettings()
62+ }
59 return &pb.Repo{63 return &pb.Repo{
60 Name: v.Name.String(),64 Name: v.Name.String(),
61 Description: v.Description,65 Description: v.Description,
@@ -63,7 +67,57 @@ func repoToPB(v app.RepoView) *pb.Repo {
63 DefaultBranch: v.DefaultBranch,67 DefaultBranch: v.DefaultBranch,
64 CreatedAt: timestamp(v.CreatedAt),68 CreatedAt: timestamp(v.CreatedAt),
65 PushedAt: timestamp(v.PushedAt),69 PushedAt: timestamp(v.PushedAt),
70+ Settings: settingsToPB(settings),
71+ }
72+}
73+
74+func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
75+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
76+ for _, m := range s.AllowedMergeModes {
77+ out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
78+ }
79+ return out
80+}
81+
82+func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
83+ switch m {
84+ case domain.MergeModeFastForward:
85+ return pb.MergeMode_MERGE_MODE_FAST_FORWARD
86+ case domain.MergeModeSquash:
87+ return pb.MergeMode_MERGE_MODE_SQUASH
88+ }
89+ return pb.MergeMode_MERGE_MODE_UNSPECIFIED
90+}
91+
92+// mergeModeFromPB maps a mode of a merge request: unspecified is the empty
93+// domain.MergeMode, which the use case reads as fast-forward.
94+func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
95+ switch m {
96+ case pb.MergeMode_MERGE_MODE_UNSPECIFIED:
97+ return "", nil
98+ case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
99+ return domain.MergeModeFastForward, nil
100+ case pb.MergeMode_MERGE_MODE_SQUASH:
101+ return domain.MergeModeSquash, nil
102+ }
103+ return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
104+}
105+
106+// mergeModesFromPB maps the allowed modes of the settings, where unspecified
107+// is rejected.
108+func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
109+ out := make([]domain.MergeMode, 0, len(in))
110+ for _, m := range in {
111+ mode, err := mergeModeFromPB(m)
112+ if err != nil {
113+ return nil, err
114+ }
115+ if mode == "" {
116+ return nil, fmt.Errorf("%w: MERGE_MODE_UNSPECIFIED is not a merge mode", domain.ErrInvalid)
117+ }
118+ out = append(out, mode)
66 }119 }
120+ return out, nil
67 }121 }
68 122
69 var scopeToPB = map[domain.Scope]pb.Scope{123 var scopeToPB = map[domain.Scope]pb.Scope{
@@ -265,22 +319,30 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
265 return out319 return out
266 }320 }
267 321
268-func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {322+func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
269- out := &pb.GetCommitResponse{323+ return &pb.DiffStats{
270- Commit: commitToPB(d.Commit),324+ FilesChanged: int32(s.FilesChanged),
271- Stats: &pb.DiffStats{325+ Additions: int32(s.Additions),
272- FilesChanged: int32(d.Stats.FilesChanged),326+ Deletions: int32(s.Deletions),
273- Additions: int32(d.Stats.Additions),
274- Deletions: int32(d.Stats.Deletions),
275- },
276- Diff: &pb.Diff{Truncated: d.Diff.Truncated},
277 }327 }
278- for _, f := range d.Diff.Files {328+}
279- out.Diff.Files = append(out.Diff.Files, fileDiffToPB(f))329+
330+func diffToPB(d domain.Diff) *pb.Diff {
331+ out := &pb.Diff{Truncated: d.Truncated}
332+ for _, f := range d.Files {
333+ out.Files = append(out.Files, fileDiffToPB(f))
280 }334 }
281 return out335 return out
282 }336 }
283 337
338+func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {
339+ return &pb.GetCommitResponse{
340+ Commit: commitToPB(d.Commit),
341+ Stats: diffStatsToPB(d.Stats),
342+ Diff: diffToPB(d.Diff),
343+ }
344+}
345+
284 // oidToPB turns the zero object id of a ref update (no object) into "".346 // oidToPB turns the zero object id of a ref update (no object) into "".
285 func oidToPB(oid string) string {347 func oidToPB(oid string) string {
286 if oid == domain.ZeroOID {348 if oid == domain.ZeroOID {
@@ -317,6 +379,18 @@ func watchEventToPB(we app.WatchEvent) *pb.Event {
317 case domain.PushEvent:379 case domain.PushEvent:
318 out.Time = timestamp(ev.At)380 out.Time = timestamp(ev.At)
319 out.Kind = &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: ev.Repo.String(), RefUpdates: refUpdatesToPB(ev.Updates)}}381 out.Kind = &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: ev.Repo.String(), RefUpdates: refUpdatesToPB(ev.Updates)}}
382+ case domain.MergeRequestOpened:
383+ out.Time = timestamp(ev.At)
384+ out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
385+ case domain.MergeRequestMerged:
386+ out.Time = timestamp(ev.At)
387+ out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
388+ case domain.MergeRequestClosed:
389+ out.Time = timestamp(ev.At)
390+ out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
391+ case domain.CommentAdded:
392+ out.Time = timestamp(ev.At)
393+ out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
320 default:394 default:
321 return nil395 return nil
322 }396 }
M internal/adapters/rpc/convert_test.go +187 -0
@@ -3,12 +3,19 @@
33 package rpc
44
55 import (
6+ "context"
7+ "fmt"
68 "testing"
9+ "time"
710
11+ "connectrpc.com/connect"
812 "github.com/stretchr/testify/assert"
913 "github.com/stretchr/testify/require"
14+ "google.golang.org/genproto/googleapis/rpc/errdetails"
15+ "google.golang.org/protobuf/types/known/fieldmaskpb"
1016
1117 pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/app"
1219 "bnema.dev/ermite/internal/domain"
1320 )
1421
@@ -133,3 +140,183 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
133140 })
134141 })
135142 }
143+
144+func TestMergeModeMappers(t *testing.T) {
145+ for _, m := range domain.MergeModes() {
146+ got := mergeModeToPB(m)
147+ assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
148+ back, err := mergeModeFromPB(got)
149+ require.NoError(t, err, m)
150+ assert.Equal(t, m, back)
151+ }
152+ // Unspecified in a merge request is the default mode, left to the use case;
153+ // in the settings it is refused.
154+ m, err := mergeModeFromPB(pb.MergeMode_MERGE_MODE_UNSPECIFIED)
155+ require.NoError(t, err)
156+ assert.Empty(t, m)
157+ _, err = mergeModesFromPB([]pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED})
158+ assert.ErrorIs(t, err, domain.ErrInvalid)
159+ _, err = mergeModeFromPB(pb.MergeMode(99))
160+ assert.ErrorIs(t, err, domain.ErrInvalid)
161+ // Every mode of the contract is known.
162+ for v := range pb.MergeMode_name {
163+ if pb.MergeMode(v) != pb.MergeMode_MERGE_MODE_UNSPECIFIED {
164+ _, err := mergeModeFromPB(pb.MergeMode(v))
165+ assert.NoError(t, err, pb.MergeMode(v))
166+ }
167+ }
168+}
169+
170+func TestMergeRequestEnumMappers(t *testing.T) {
171+ for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
172+ got := mergeRequestStateToPB(s)
173+ assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
174+ back, err := mergeRequestStateFromPB(got)
175+ require.NoError(t, err, s)
176+ assert.Equal(t, s, back)
177+ }
178+ all, err := mergeRequestStateFromPB(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
179+ require.NoError(t, err)
180+ assert.Empty(t, all, "unspecified means every state")
181+ _, err = mergeRequestStateFromPB(99)
182+ assert.ErrorIs(t, err, domain.ErrInvalid)
183+
184+ for _, s := range []domain.DiffSide{domain.SideOld, domain.SideNew, domain.SideNone} {
185+ back, err := diffSideFromPB(diffSideToPB(s))
186+ require.NoError(t, err, s)
187+ assert.Equal(t, s, back)
188+ }
189+ assert.Equal(t, pb.DiffSide_DIFF_SIDE_UNSPECIFIED, diffSideToPB(domain.SideNone), "a general comment")
190+ _, err = diffSideFromPB(99)
191+ assert.ErrorIs(t, err, domain.ErrInvalid)
192+}
193+
194+func TestCommentID(t *testing.T) {
195+ assert.EqualValues(t, 7, commentID("7"))
196+ for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
197+ assert.Zero(t, commentID(bad), "%q names no comment", bad)
198+ }
199+}
200+
201+func TestRepoToPBAlwaysHasSettings(t *testing.T) {
202+ // A repository value without settings (never stored with them) reads as the defaults.
203+ r := repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
204+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
205+ assert.True(t, r.GetSettings().GetFailingChecksBlock())
206+
207+ custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
208+ r = repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
209+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
210+ assert.False(t, r.GetSettings().GetFailingChecksBlock())
211+}
212+
213+func mustName(t *testing.T, s string) domain.RepoName {
214+ t.Helper()
215+ n, err := domain.ParseRepoName(s)
216+ require.NoError(t, err)
217+ return n
218+}
219+
220+func TestUpdateParamsSettingsPaths(t *testing.T) {
221+ msg := func(paths ...string) *pb.UpdateRepoRequest {
222+ return &pb.UpdateRepoRequest{
223+ UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
224+ Settings: &pb.RepoSettings{
225+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
226+ FailingChecksBlock: true,
227+ },
228+ }
229+ }
230+ p, err := updateParams(msg("settings"))
231+ require.NoError(t, err)
232+ require.NotNil(t, p.AllowedMergeModes)
233+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
234+ require.NotNil(t, p.FailingChecksBlock)
235+ assert.True(t, *p.FailingChecksBlock)
236+
237+ p, err = updateParams(msg("settings.allowed_merge_modes"))
238+ require.NoError(t, err)
239+ assert.NotNil(t, p.AllowedMergeModes)
240+ assert.Nil(t, p.FailingChecksBlock, "not named: left alone")
241+
242+ p, err = updateParams(msg("settings.failing_checks_block"))
243+ require.NoError(t, err)
244+ assert.Nil(t, p.AllowedMergeModes)
245+ assert.NotNil(t, p.FailingChecksBlock)
246+
247+ // Modes named with nothing to take them from are an empty list, which the use case refuses.
248+ p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
249+ require.NoError(t, err)
250+ require.NotNil(t, p.AllowedMergeModes)
251+ assert.Empty(t, *p.AllowedMergeModes)
252+
253+ _, err = updateParams(msg("settings.other"))
254+ assert.ErrorIs(t, err, domain.ErrInvalid)
255+}
256+
257+func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
258+ var v domain.Violations
259+ v.Add("title", "must be one line")
260+ v.Add("source_branch", "does not exist")
261+ err := connectError(context.Background(), fmt.Errorf("create: %w", v.Err()))
262+ var ce *connect.Error
263+ require.ErrorAs(t, err, &ce)
264+ assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
265+ require.Len(t, ce.Details(), 1)
266+ val, derr := ce.Details()[0].Value()
267+ require.NoError(t, derr)
268+ br, ok := val.(*errdetails.BadRequest)
269+ require.True(t, ok)
270+ require.Len(t, br.GetFieldViolations(), 2)
271+ assert.Equal(t, "title", br.GetFieldViolations()[0].GetField())
272+ assert.Equal(t, "must be one line", br.GetFieldViolations()[0].GetDescription())
273+
274+ // Other errors carry no detail, and a text error alone is invalid_argument.
275+ err = connectError(context.Background(), domain.ErrNotFound)
276+ require.ErrorAs(t, err, &ce)
277+ assert.Empty(t, ce.Details())
278+ _, perr := domain.ParsePlainText("\x07")
279+ err = connectError(context.Background(), perr)
280+ require.ErrorAs(t, err, &ce)
281+ assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
282+}
283+
284+func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
285+ repo := mustName(t, "proj")
286+ title, err := domain.ParseMRTitle("Add x")
287+ require.NoError(t, err)
288+ mr := domain.MergeRequest{Repo: repo, Number: 4, Title: title, Source: "feat", Target: "main", State: domain.MRStateMerged, MergeMode: domain.MergeModeSquash}
289+ at := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
290+
291+ for name, tc := range map[string]struct {
292+ ev domain.Event
293+ pick func(*pb.Event) *pb.MergeRequest
294+ }{
295+ "opened": {domain.MergeRequestOpened{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestOpened().GetMergeRequest() }},
296+ "merged": {domain.MergeRequestMerged{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestMerged().GetMergeRequest() }},
297+ "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
298+ } {
299+ t.Run(name, func(t *testing.T) {
300+ got := watchEventToPB(app.WatchEvent{ID: "e1", Event: tc.ev})
301+ require.NotNil(t, got)
302+ assert.Equal(t, "e1", got.GetId())
303+ assert.Equal(t, at, got.GetTime().AsTime())
304+ m := tc.pick(got)
305+ require.NotNil(t, m)
306+ assert.Equal(t, "proj", m.GetRepo())
307+ assert.EqualValues(t, 4, m.GetNumber())
308+ assert.Equal(t, "Add x", m.GetTitle())
309+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, m.GetMergeMode())
310+ })
311+ }
312+
313+ c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at}
314+ got := watchEventToPB(app.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}})
315+ added := got.GetCommentAdded()
316+ require.NotNil(t, added)
317+ assert.Equal(t, "proj", added.GetRepo())
318+ assert.EqualValues(t, 4, added.GetNumber())
319+ assert.Equal(t, "9", added.GetComment().GetId())
320+ assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
321+ assert.EqualValues(t, 3, added.GetComment().GetLine())
322+}
@@ -3,12 +3,19 @@
3 package rpc3 package rpc
4 4
5 import (5 import (
6+ "context"
7+ "fmt"
6 "testing"8 "testing"
9+ "time"
7 10
11+ "connectrpc.com/connect"
8 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14+ "google.golang.org/genproto/googleapis/rpc/errdetails"
15+ "google.golang.org/protobuf/types/known/fieldmaskpb"
10 16
11 pb "bnema.dev/ermite-api/gen/ermite"17 pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/app"
12 "bnema.dev/ermite/internal/domain"19 "bnema.dev/ermite/internal/domain"
13 )20 )
14 21
@@ -133,3 +140,183 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
133 })140 })
134 })141 })
135 }142 }
143+
144+func TestMergeModeMappers(t *testing.T) {
145+ for _, m := range domain.MergeModes() {
146+ got := mergeModeToPB(m)
147+ assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
148+ back, err := mergeModeFromPB(got)
149+ require.NoError(t, err, m)
150+ assert.Equal(t, m, back)
151+ }
152+ // Unspecified in a merge request is the default mode, left to the use case;
153+ // in the settings it is refused.
154+ m, err := mergeModeFromPB(pb.MergeMode_MERGE_MODE_UNSPECIFIED)
155+ require.NoError(t, err)
156+ assert.Empty(t, m)
157+ _, err = mergeModesFromPB([]pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED})
158+ assert.ErrorIs(t, err, domain.ErrInvalid)
159+ _, err = mergeModeFromPB(pb.MergeMode(99))
160+ assert.ErrorIs(t, err, domain.ErrInvalid)
161+ // Every mode of the contract is known.
162+ for v := range pb.MergeMode_name {
163+ if pb.MergeMode(v) != pb.MergeMode_MERGE_MODE_UNSPECIFIED {
164+ _, err := mergeModeFromPB(pb.MergeMode(v))
165+ assert.NoError(t, err, pb.MergeMode(v))
166+ }
167+ }
168+}
169+
170+func TestMergeRequestEnumMappers(t *testing.T) {
171+ for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
172+ got := mergeRequestStateToPB(s)
173+ assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
174+ back, err := mergeRequestStateFromPB(got)
175+ require.NoError(t, err, s)
176+ assert.Equal(t, s, back)
177+ }
178+ all, err := mergeRequestStateFromPB(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
179+ require.NoError(t, err)
180+ assert.Empty(t, all, "unspecified means every state")
181+ _, err = mergeRequestStateFromPB(99)
182+ assert.ErrorIs(t, err, domain.ErrInvalid)
183+
184+ for _, s := range []domain.DiffSide{domain.SideOld, domain.SideNew, domain.SideNone} {
185+ back, err := diffSideFromPB(diffSideToPB(s))
186+ require.NoError(t, err, s)
187+ assert.Equal(t, s, back)
188+ }
189+ assert.Equal(t, pb.DiffSide_DIFF_SIDE_UNSPECIFIED, diffSideToPB(domain.SideNone), "a general comment")
190+ _, err = diffSideFromPB(99)
191+ assert.ErrorIs(t, err, domain.ErrInvalid)
192+}
193+
194+func TestCommentID(t *testing.T) {
195+ assert.EqualValues(t, 7, commentID("7"))
196+ for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
197+ assert.Zero(t, commentID(bad), "%q names no comment", bad)
198+ }
199+}
200+
201+func TestRepoToPBAlwaysHasSettings(t *testing.T) {
202+ // A repository value without settings (never stored with them) reads as the defaults.
203+ r := repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
204+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
205+ assert.True(t, r.GetSettings().GetFailingChecksBlock())
206+
207+ custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
208+ r = repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
209+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
210+ assert.False(t, r.GetSettings().GetFailingChecksBlock())
211+}
212+
213+func mustName(t *testing.T, s string) domain.RepoName {
214+ t.Helper()
215+ n, err := domain.ParseRepoName(s)
216+ require.NoError(t, err)
217+ return n
218+}
219+
220+func TestUpdateParamsSettingsPaths(t *testing.T) {
221+ msg := func(paths ...string) *pb.UpdateRepoRequest {
222+ return &pb.UpdateRepoRequest{
223+ UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
224+ Settings: &pb.RepoSettings{
225+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
226+ FailingChecksBlock: true,
227+ },
228+ }
229+ }
230+ p, err := updateParams(msg("settings"))
231+ require.NoError(t, err)
232+ require.NotNil(t, p.AllowedMergeModes)
233+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
234+ require.NotNil(t, p.FailingChecksBlock)
235+ assert.True(t, *p.FailingChecksBlock)
236+
237+ p, err = updateParams(msg("settings.allowed_merge_modes"))
238+ require.NoError(t, err)
239+ assert.NotNil(t, p.AllowedMergeModes)
240+ assert.Nil(t, p.FailingChecksBlock, "not named: left alone")
241+
242+ p, err = updateParams(msg("settings.failing_checks_block"))
243+ require.NoError(t, err)
244+ assert.Nil(t, p.AllowedMergeModes)
245+ assert.NotNil(t, p.FailingChecksBlock)
246+
247+ // Modes named with nothing to take them from are an empty list, which the use case refuses.
248+ p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
249+ require.NoError(t, err)
250+ require.NotNil(t, p.AllowedMergeModes)
251+ assert.Empty(t, *p.AllowedMergeModes)
252+
253+ _, err = updateParams(msg("settings.other"))
254+ assert.ErrorIs(t, err, domain.ErrInvalid)
255+}
256+
257+func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
258+ var v domain.Violations
259+ v.Add("title", "must be one line")
260+ v.Add("source_branch", "does not exist")
261+ err := connectError(context.Background(), fmt.Errorf("create: %w", v.Err()))
262+ var ce *connect.Error
263+ require.ErrorAs(t, err, &ce)
264+ assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
265+ require.Len(t, ce.Details(), 1)
266+ val, derr := ce.Details()[0].Value()
267+ require.NoError(t, derr)
268+ br, ok := val.(*errdetails.BadRequest)
269+ require.True(t, ok)
270+ require.Len(t, br.GetFieldViolations(), 2)
271+ assert.Equal(t, "title", br.GetFieldViolations()[0].GetField())
272+ assert.Equal(t, "must be one line", br.GetFieldViolations()[0].GetDescription())
273+
274+ // Other errors carry no detail, and a text error alone is invalid_argument.
275+ err = connectError(context.Background(), domain.ErrNotFound)
276+ require.ErrorAs(t, err, &ce)
277+ assert.Empty(t, ce.Details())
278+ _, perr := domain.ParsePlainText("\x07")
279+ err = connectError(context.Background(), perr)
280+ require.ErrorAs(t, err, &ce)
281+ assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
282+}
283+
284+func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
285+ repo := mustName(t, "proj")
286+ title, err := domain.ParseMRTitle("Add x")
287+ require.NoError(t, err)
288+ mr := domain.MergeRequest{Repo: repo, Number: 4, Title: title, Source: "feat", Target: "main", State: domain.MRStateMerged, MergeMode: domain.MergeModeSquash}
289+ at := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
290+
291+ for name, tc := range map[string]struct {
292+ ev domain.Event
293+ pick func(*pb.Event) *pb.MergeRequest
294+ }{
295+ "opened": {domain.MergeRequestOpened{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestOpened().GetMergeRequest() }},
296+ "merged": {domain.MergeRequestMerged{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestMerged().GetMergeRequest() }},
297+ "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
298+ } {
299+ t.Run(name, func(t *testing.T) {
300+ got := watchEventToPB(app.WatchEvent{ID: "e1", Event: tc.ev})
301+ require.NotNil(t, got)
302+ assert.Equal(t, "e1", got.GetId())
303+ assert.Equal(t, at, got.GetTime().AsTime())
304+ m := tc.pick(got)
305+ require.NotNil(t, m)
306+ assert.Equal(t, "proj", m.GetRepo())
307+ assert.EqualValues(t, 4, m.GetNumber())
308+ assert.Equal(t, "Add x", m.GetTitle())
309+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, m.GetMergeMode())
310+ })
311+ }
312+
313+ c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at}
314+ got := watchEventToPB(app.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}})
315+ added := got.GetCommentAdded()
316+ require.NotNil(t, added)
317+ assert.Equal(t, "proj", added.GetRepo())
318+ assert.EqualValues(t, 4, added.GetNumber())
319+ assert.Equal(t, "9", added.GetComment().GetId())
320+ assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
321+ assert.EqualValues(t, 3, added.GetComment().GetLine())
322+}
M internal/adapters/rpc/errors.go +21 -2
@@ -8,6 +8,7 @@ import (
88
99 "connectrpc.com/connect"
1010 "github.com/bnema/zerowrap"
11+ "google.golang.org/genproto/googleapis/rpc/errdetails"
1112
1213 "bnema.dev/ermite/internal/domain"
1314 )
@@ -24,7 +25,9 @@ func connectError(ctx context.Context, err error) error {
2425 return err
2526 }
2627 if code, ok := codeOf(err); ok {
27- return connect.NewError(code, err)
28+ ce := connect.NewError(code, err)
29+ addFieldDetail(ce, err)
30+ return ce
2831 }
2932 log := zerowrap.FromCtx(ctx)
3033 log.Error().Err(err).Msg("api request failed")
@@ -67,5 +70,21 @@ var invalidErrors = []error{
6770 domain.ErrInvalidScope, domain.ErrInvalidToken,
6871 domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath,
6972 domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind,
70- domain.ErrNotDirectory, domain.ErrNotFile,
73+ domain.ErrNotDirectory, domain.ErrNotFile, domain.ErrInvalidText,
74+}
75+
76+// addFieldDetail attaches the fields a request was rejected for as a
77+// BadRequest detail, when err carries them.
78+func addFieldDetail(ce *connect.Error, err error) {
79+ var fe *domain.InvalidFieldsError
80+ if !errors.As(err, &fe) {
81+ return
82+ }
83+ br := &errdetails.BadRequest{}
84+ for _, f := range fe.Fields {
85+ br.FieldViolations = append(br.FieldViolations, &errdetails.BadRequest_FieldViolation{Field: f.Field, Description: f.Reason})
86+ }
87+ if d, derr := connect.NewErrorDetail(br); derr == nil {
88+ ce.AddDetail(d)
89+ }
7190 }
@@ -8,6 +8,7 @@ import (
8 8
9 "connectrpc.com/connect"9 "connectrpc.com/connect"
10 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
11+ "google.golang.org/genproto/googleapis/rpc/errdetails"
11 12
12 "bnema.dev/ermite/internal/domain"13 "bnema.dev/ermite/internal/domain"
13 )14 )
@@ -24,7 +25,9 @@ func connectError(ctx context.Context, err error) error {
24 return err25 return err
25 }26 }
26 if code, ok := codeOf(err); ok {27 if code, ok := codeOf(err); ok {
27- return connect.NewError(code, err)28+ ce := connect.NewError(code, err)
29+ addFieldDetail(ce, err)
30+ return ce
28 }31 }
29 log := zerowrap.FromCtx(ctx)32 log := zerowrap.FromCtx(ctx)
30 log.Error().Err(err).Msg("api request failed")33 log.Error().Err(err).Msg("api request failed")
@@ -67,5 +70,21 @@ var invalidErrors = []error{
67 domain.ErrInvalidScope, domain.ErrInvalidToken,70 domain.ErrInvalidScope, domain.ErrInvalidToken,
68 domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath,71 domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath,
69 domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind,72 domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind,
70- domain.ErrNotDirectory, domain.ErrNotFile,73+ domain.ErrNotDirectory, domain.ErrNotFile, domain.ErrInvalidText,
74+}
75+
76+// addFieldDetail attaches the fields a request was rejected for as a
77+// BadRequest detail, when err carries them.
78+func addFieldDetail(ce *connect.Error, err error) {
79+ var fe *domain.InvalidFieldsError
80+ if !errors.As(err, &fe) {
81+ return
82+ }
83+ br := &errdetails.BadRequest{}
84+ for _, f := range fe.Fields {
85+ br.FieldViolations = append(br.FieldViolations, &errdetails.BadRequest_FieldViolation{Field: f.Field, Description: f.Reason})
86+ }
87+ if d, derr := connect.NewErrorDetail(br); derr == nil {
88+ ce.AddDetail(d)
89+ }
71 }90 }
M internal/adapters/rpc/interceptor.go +4 -3
@@ -56,9 +56,10 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
5656 // Whatever the context carried, the caller is anonymous until proven
5757 // otherwise.
5858 ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
59- if procedure == ermiteconnect.ServerServiceGetVersionProcedure {
60- // It needs no authentication, so that a client can check compatibility
61- // first, even with a token that has expired.
59+ if procedure == ermiteconnect.ServerServiceGetVersionProcedure || procedure == ermiteconnect.ServerServiceGetSigningKeyProcedure {
60+ // GetVersion needs no authentication, so that a client can check
61+ // compatibility first, even with a token that has expired. The signing
62+ // key is public: whoever verifies a commit needs it.
6263 return ctx, nil
6364 }
6465 log := zerowrap.FromCtx(ctx)
@@ -56,9 +56,10 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
56 // Whatever the context carried, the caller is anonymous until proven56 // Whatever the context carried, the caller is anonymous until proven
57 // otherwise.57 // otherwise.
58 ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())58 ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
59- if procedure == ermiteconnect.ServerServiceGetVersionProcedure {59+ if procedure == ermiteconnect.ServerServiceGetVersionProcedure || procedure == ermiteconnect.ServerServiceGetSigningKeyProcedure {
60- // It needs no authentication, so that a client can check compatibility60+ // GetVersion needs no authentication, so that a client can check
61- // first, even with a token that has expired.61+ // compatibility first, even with a token that has expired. The signing
62+ // key is public: whoever verifies a commit needs it.
62 return ctx, nil63 return ctx, nil
63 }64 }
64 log := zerowrap.FromCtx(ctx)65 log := zerowrap.FromCtx(ctx)
A internal/adapters/rpc/mergerequest.go +268 -0
@@ -0,0 +1,268 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+
10+ "connectrpc.com/connect"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/app"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18+type mergeRequestService struct{ mrs *app.MergeRequests }
19+
20+func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
21+ switch s {
22+ case domain.MRStateOpen:
23+ return pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
24+ case domain.MRStateMerged:
25+ return pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED
26+ case domain.MRStateClosed:
27+ return pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
28+ }
29+ return pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED
30+}
31+
32+// mergeRequestStateFromPB maps the state filter of a listing; unspecified is
33+// the empty state, which means every state.
34+func mergeRequestStateFromPB(s pb.MergeRequestState) (domain.MRState, error) {
35+ switch s {
36+ case pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED:
37+ return "", nil
38+ case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
39+ return domain.MRStateOpen, nil
40+ case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
41+ return domain.MRStateMerged, nil
42+ case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
43+ return domain.MRStateClosed, nil
44+ }
45+ return "", fmt.Errorf("%w: merge request state %v", domain.ErrInvalid, s)
46+}
47+
48+func diffSideToPB(s domain.DiffSide) pb.DiffSide {
49+ switch s {
50+ case domain.SideOld:
51+ return pb.DiffSide_DIFF_SIDE_OLD
52+ case domain.SideNew:
53+ return pb.DiffSide_DIFF_SIDE_NEW
54+ }
55+ return pb.DiffSide_DIFF_SIDE_UNSPECIFIED
56+}
57+
58+func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
59+ switch s {
60+ case pb.DiffSide_DIFF_SIDE_UNSPECIFIED:
61+ return domain.SideNone, nil
62+ case pb.DiffSide_DIFF_SIDE_OLD:
63+ return domain.SideOld, nil
64+ case pb.DiffSide_DIFF_SIDE_NEW:
65+ return domain.SideNew, nil
66+ }
67+ return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
68+}
69+
70+func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
71+ return &pb.MergeRequest{
72+ Repo: mr.Repo.String(),
73+ Number: mr.Number,
74+ Title: mr.Title.String(),
75+ Description: mr.Description.String(),
76+ SourceBranch: mr.Source,
77+ TargetBranch: mr.Target,
78+ State: mergeRequestStateToPB(mr.State),
79+ Author: mr.Author,
80+ CreatedAt: timestamp(mr.CreatedAt),
81+ UpdatedAt: timestamp(mr.UpdatedAt),
82+ HeadId: mr.HeadID.String(),
83+ MergedCommitId: mr.MergedCommitID.String(),
84+ MergeMode: mergeModeToPB(mr.MergeMode),
85+ }
86+}
87+
88+func commentToPB(c domain.Comment) *pb.Comment {
89+ return &pb.Comment{
90+ Id: strconv.FormatInt(c.ID, 10),
91+ Author: c.Author,
92+ Path: c.Path,
93+ Line: int32(c.Line), //nolint:gosec // a line of a diff, validated against it
94+ Side: diffSideToPB(c.Side),
95+ CommitId: c.CommitID.String(),
96+ Text: c.Text.String(),
97+ Resolved: c.Resolved,
98+ CreatedAt: timestamp(c.CreatedAt),
99+ }
100+}
101+
102+// commentID parses the opaque id of a comment. One that this server never
103+// issued is 0, which names no comment.
104+func commentID(s string) int64 {
105+ n, err := strconv.ParseInt(s, 10, 64)
106+ if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
107+ return 0
108+ }
109+ return n
110+}
111+
112+// headID parses a commit id of a request; the empty string is the zero id.
113+//
114+//hexcheck:ignore suspicious-business-logic-in-adapter parses a request field into the domain id and names the field in the error; no rule is decided here
115+func headID(s string) (domain.ObjectID, error) {
116+ if s == "" {
117+ return domain.ObjectID{}, nil
118+ }
119+ var v domain.Violations
120+ id, err := domain.ParseObjectID(s)
121+ if err != nil {
122+ v.Add("head_id", "is not a full commit id")
123+ }
124+ return id, v.Err()
125+}
126+
127+func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *connect.Request[pb.CreateMergeRequestRequest]) (*connect.Response[pb.CreateMergeRequestResponse], error) {
128+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
129+ if err != nil {
130+ return nil, connectError(ctx, err)
131+ }
132+ mr, err := s.mrs.Create(ctx, caller(ctx), name, app.CreateMergeRequestParams{
133+ Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(),
134+ Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(),
135+ })
136+ if err != nil {
137+ return nil, connectError(ctx, err)
138+ }
139+ return connect.NewResponse(&pb.CreateMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
140+}
141+
142+func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connect.Request[pb.ListMergeRequestsRequest]) (*connect.Response[pb.ListMergeRequestsResponse], error) {
143+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
144+ if err != nil {
145+ return nil, connectError(ctx, err)
146+ }
147+ state, err := mergeRequestStateFromPB(req.Msg.GetState())
148+ if err != nil {
149+ return nil, connectError(ctx, err)
150+ }
151+ page, err := s.mrs.List(ctx, caller(ctx), name, app.ListMergeRequestsParams{
152+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
153+ })
154+ if err != nil {
155+ return nil, connectError(ctx, err)
156+ }
157+ out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
158+ for _, mr := range page.MergeRequests {
159+ out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))
160+ }
161+ return connect.NewResponse(out), nil
162+}
163+
164+func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.Request[pb.GetMergeRequestRequest]) (*connect.Response[pb.GetMergeRequestResponse], error) {
165+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
166+ if err != nil {
167+ return nil, connectError(ctx, err)
168+ }
169+ d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetSinceLastRead())
170+ if err != nil {
171+ return nil, connectError(ctx, err)
172+ }
173+ out := &pb.GetMergeRequestResponse{
174+ MergeRequest: mergeRequestToPB(d.MergeRequest),
175+ CommitsTruncated: d.CommitsTruncated,
176+ DiffBaseId: d.DiffBaseID.String(),
177+ Stats: diffStatsToPB(d.Stats),
178+ Diff: diffToPB(d.Diff),
179+ LastReadId: d.LastReadID.String(),
180+ LastReadLost: d.LastReadLost,
181+ }
182+ for _, c := range d.Commits {
183+ out.Commits = append(out.Commits, commitToPB(c))
184+ }
185+ for _, c := range d.Comments {
186+ out.Comments = append(out.Comments, commentToPB(c))
187+ }
188+ return connect.NewResponse(out), nil
189+}
190+
191+func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Request[pb.AddCommentRequest]) (*connect.Response[pb.AddCommentResponse], error) {
192+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
193+ if err != nil {
194+ return nil, connectError(ctx, err)
195+ }
196+ side, err := diffSideFromPB(req.Msg.GetSide())
197+ if err != nil {
198+ return nil, connectError(ctx, err)
199+ }
200+ c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), app.AddCommentParams{
201+ Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
202+ })
203+ if err != nil {
204+ return nil, connectError(ctx, err)
205+ }
206+ return connect.NewResponse(&pb.AddCommentResponse{Comment: commentToPB(c)}), nil
207+}
208+
209+func (s *mergeRequestService) ResolveComment(ctx context.Context, req *connect.Request[pb.ResolveCommentRequest]) (*connect.Response[pb.ResolveCommentResponse], error) {
210+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
211+ if err != nil {
212+ return nil, connectError(ctx, err)
213+ }
214+ // An id this server never issued is 0: the use case answers not found, after
215+ // the repository and the scope were checked, as for any unknown comment.
216+ c, err := s.mrs.ResolveComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetResolved())
217+ if err != nil {
218+ return nil, connectError(ctx, err)
219+ }
220+ return connect.NewResponse(&pb.ResolveCommentResponse{Comment: commentToPB(c)}), nil
221+}
222+
223+func (s *mergeRequestService) CloseMergeRequest(ctx context.Context, req *connect.Request[pb.CloseMergeRequestRequest]) (*connect.Response[pb.CloseMergeRequestResponse], error) {
224+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
225+ if err != nil {
226+ return nil, connectError(ctx, err)
227+ }
228+ mr, err := s.mrs.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
229+ if err != nil {
230+ return nil, connectError(ctx, err)
231+ }
232+ return connect.NewResponse(&pb.CloseMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
233+}
234+
235+func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connect.Request[pb.MergeMergeRequestRequest]) (*connect.Response[pb.MergeMergeRequestResponse], error) {
236+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
237+ if err != nil {
238+ return nil, connectError(ctx, err)
239+ }
240+ mode, err := mergeModeFromPB(req.Msg.GetMode())
241+ if err != nil {
242+ return nil, connectError(ctx, err)
243+ }
244+ head, err := headID(req.Msg.GetHeadId())
245+ if err != nil {
246+ return nil, connectError(ctx, err)
247+ }
248+ mr, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{Mode: mode, HeadID: head})
249+ if err != nil {
250+ return nil, connectError(ctx, err)
251+ }
252+ return connect.NewResponse(&pb.MergeMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
253+}
254+
255+func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
256+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
257+ if err != nil {
258+ return nil, connectError(ctx, err)
259+ }
260+ head, err := headID(req.Msg.GetHeadId())
261+ if err != nil {
262+ return nil, connectError(ctx, err)
263+ }
264+ if _, err := s.mrs.MarkRead(ctx, caller(ctx), name, req.Msg.GetNumber(), head); err != nil {
265+ return nil, connectError(ctx, err)
266+ }
267+ return connect.NewResponse(&pb.MarkReadResponse{}), nil
268+}
@@ -0,0 +1,268 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+
10+ "connectrpc.com/connect"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/app"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18+type mergeRequestService struct{ mrs *app.MergeRequests }
19+
20+func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
21+ switch s {
22+ case domain.MRStateOpen:
23+ return pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
24+ case domain.MRStateMerged:
25+ return pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED
26+ case domain.MRStateClosed:
27+ return pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
28+ }
29+ return pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED
30+}
31+
32+// mergeRequestStateFromPB maps the state filter of a listing; unspecified is
33+// the empty state, which means every state.
34+func mergeRequestStateFromPB(s pb.MergeRequestState) (domain.MRState, error) {
35+ switch s {
36+ case pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED:
37+ return "", nil
38+ case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
39+ return domain.MRStateOpen, nil
40+ case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
41+ return domain.MRStateMerged, nil
42+ case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
43+ return domain.MRStateClosed, nil
44+ }
45+ return "", fmt.Errorf("%w: merge request state %v", domain.ErrInvalid, s)
46+}
47+
48+func diffSideToPB(s domain.DiffSide) pb.DiffSide {
49+ switch s {
50+ case domain.SideOld:
51+ return pb.DiffSide_DIFF_SIDE_OLD
52+ case domain.SideNew:
53+ return pb.DiffSide_DIFF_SIDE_NEW
54+ }
55+ return pb.DiffSide_DIFF_SIDE_UNSPECIFIED
56+}
57+
58+func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
59+ switch s {
60+ case pb.DiffSide_DIFF_SIDE_UNSPECIFIED:
61+ return domain.SideNone, nil
62+ case pb.DiffSide_DIFF_SIDE_OLD:
63+ return domain.SideOld, nil
64+ case pb.DiffSide_DIFF_SIDE_NEW:
65+ return domain.SideNew, nil
66+ }
67+ return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
68+}
69+
70+func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
71+ return &pb.MergeRequest{
72+ Repo: mr.Repo.String(),
73+ Number: mr.Number,
74+ Title: mr.Title.String(),
75+ Description: mr.Description.String(),
76+ SourceBranch: mr.Source,
77+ TargetBranch: mr.Target,
78+ State: mergeRequestStateToPB(mr.State),
79+ Author: mr.Author,
80+ CreatedAt: timestamp(mr.CreatedAt),
81+ UpdatedAt: timestamp(mr.UpdatedAt),
82+ HeadId: mr.HeadID.String(),
83+ MergedCommitId: mr.MergedCommitID.String(),
84+ MergeMode: mergeModeToPB(mr.MergeMode),
85+ }
86+}
87+
88+func commentToPB(c domain.Comment) *pb.Comment {
89+ return &pb.Comment{
90+ Id: strconv.FormatInt(c.ID, 10),
91+ Author: c.Author,
92+ Path: c.Path,
93+ Line: int32(c.Line), //nolint:gosec // a line of a diff, validated against it
94+ Side: diffSideToPB(c.Side),
95+ CommitId: c.CommitID.String(),
96+ Text: c.Text.String(),
97+ Resolved: c.Resolved,
98+ CreatedAt: timestamp(c.CreatedAt),
99+ }
100+}
101+
102+// commentID parses the opaque id of a comment. One that this server never
103+// issued is 0, which names no comment.
104+func commentID(s string) int64 {
105+ n, err := strconv.ParseInt(s, 10, 64)
106+ if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
107+ return 0
108+ }
109+ return n
110+}
111+
112+// headID parses a commit id of a request; the empty string is the zero id.
113+//
114+//hexcheck:ignore suspicious-business-logic-in-adapter parses a request field into the domain id and names the field in the error; no rule is decided here
115+func headID(s string) (domain.ObjectID, error) {
116+ if s == "" {
117+ return domain.ObjectID{}, nil
118+ }
119+ var v domain.Violations
120+ id, err := domain.ParseObjectID(s)
121+ if err != nil {
122+ v.Add("head_id", "is not a full commit id")
123+ }
124+ return id, v.Err()
125+}
126+
127+func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *connect.Request[pb.CreateMergeRequestRequest]) (*connect.Response[pb.CreateMergeRequestResponse], error) {
128+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
129+ if err != nil {
130+ return nil, connectError(ctx, err)
131+ }
132+ mr, err := s.mrs.Create(ctx, caller(ctx), name, app.CreateMergeRequestParams{
133+ Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(),
134+ Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(),
135+ })
136+ if err != nil {
137+ return nil, connectError(ctx, err)
138+ }
139+ return connect.NewResponse(&pb.CreateMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
140+}
141+
142+func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connect.Request[pb.ListMergeRequestsRequest]) (*connect.Response[pb.ListMergeRequestsResponse], error) {
143+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
144+ if err != nil {
145+ return nil, connectError(ctx, err)
146+ }
147+ state, err := mergeRequestStateFromPB(req.Msg.GetState())
148+ if err != nil {
149+ return nil, connectError(ctx, err)
150+ }
151+ page, err := s.mrs.List(ctx, caller(ctx), name, app.ListMergeRequestsParams{
152+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
153+ })
154+ if err != nil {
155+ return nil, connectError(ctx, err)
156+ }
157+ out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
158+ for _, mr := range page.MergeRequests {
159+ out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))
160+ }
161+ return connect.NewResponse(out), nil
162+}
163+
164+func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.Request[pb.GetMergeRequestRequest]) (*connect.Response[pb.GetMergeRequestResponse], error) {
165+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
166+ if err != nil {
167+ return nil, connectError(ctx, err)
168+ }
169+ d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetSinceLastRead())
170+ if err != nil {
171+ return nil, connectError(ctx, err)
172+ }
173+ out := &pb.GetMergeRequestResponse{
174+ MergeRequest: mergeRequestToPB(d.MergeRequest),
175+ CommitsTruncated: d.CommitsTruncated,
176+ DiffBaseId: d.DiffBaseID.String(),
177+ Stats: diffStatsToPB(d.Stats),
178+ Diff: diffToPB(d.Diff),
179+ LastReadId: d.LastReadID.String(),
180+ LastReadLost: d.LastReadLost,
181+ }
182+ for _, c := range d.Commits {
183+ out.Commits = append(out.Commits, commitToPB(c))
184+ }
185+ for _, c := range d.Comments {
186+ out.Comments = append(out.Comments, commentToPB(c))
187+ }
188+ return connect.NewResponse(out), nil
189+}
190+
191+func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Request[pb.AddCommentRequest]) (*connect.Response[pb.AddCommentResponse], error) {
192+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
193+ if err != nil {
194+ return nil, connectError(ctx, err)
195+ }
196+ side, err := diffSideFromPB(req.Msg.GetSide())
197+ if err != nil {
198+ return nil, connectError(ctx, err)
199+ }
200+ c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), app.AddCommentParams{
201+ Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
202+ })
203+ if err != nil {
204+ return nil, connectError(ctx, err)
205+ }
206+ return connect.NewResponse(&pb.AddCommentResponse{Comment: commentToPB(c)}), nil
207+}
208+
209+func (s *mergeRequestService) ResolveComment(ctx context.Context, req *connect.Request[pb.ResolveCommentRequest]) (*connect.Response[pb.ResolveCommentResponse], error) {
210+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
211+ if err != nil {
212+ return nil, connectError(ctx, err)
213+ }
214+ // An id this server never issued is 0: the use case answers not found, after
215+ // the repository and the scope were checked, as for any unknown comment.
216+ c, err := s.mrs.ResolveComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetResolved())
217+ if err != nil {
218+ return nil, connectError(ctx, err)
219+ }
220+ return connect.NewResponse(&pb.ResolveCommentResponse{Comment: commentToPB(c)}), nil
221+}
222+
223+func (s *mergeRequestService) CloseMergeRequest(ctx context.Context, req *connect.Request[pb.CloseMergeRequestRequest]) (*connect.Response[pb.CloseMergeRequestResponse], error) {
224+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
225+ if err != nil {
226+ return nil, connectError(ctx, err)
227+ }
228+ mr, err := s.mrs.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
229+ if err != nil {
230+ return nil, connectError(ctx, err)
231+ }
232+ return connect.NewResponse(&pb.CloseMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
233+}
234+
235+func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connect.Request[pb.MergeMergeRequestRequest]) (*connect.Response[pb.MergeMergeRequestResponse], error) {
236+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
237+ if err != nil {
238+ return nil, connectError(ctx, err)
239+ }
240+ mode, err := mergeModeFromPB(req.Msg.GetMode())
241+ if err != nil {
242+ return nil, connectError(ctx, err)
243+ }
244+ head, err := headID(req.Msg.GetHeadId())
245+ if err != nil {
246+ return nil, connectError(ctx, err)
247+ }
248+ mr, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{Mode: mode, HeadID: head})
249+ if err != nil {
250+ return nil, connectError(ctx, err)
251+ }
252+ return connect.NewResponse(&pb.MergeMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
253+}
254+
255+func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
256+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
257+ if err != nil {
258+ return nil, connectError(ctx, err)
259+ }
260+ head, err := headID(req.Msg.GetHeadId())
261+ if err != nil {
262+ return nil, connectError(ctx, err)
263+ }
264+ if _, err := s.mrs.MarkRead(ctx, caller(ctx), name, req.Msg.GetNumber(), head); err != nil {
265+ return nil, connectError(ctx, err)
266+ }
267+ return connect.NewResponse(&pb.MarkReadResponse{}), nil
268+}
M internal/adapters/rpc/repo.go +14 -1
@@ -79,7 +79,9 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
7979 }
8080
8181 // updateParams maps the field mask of an update. The mask must name at least
82-// one of "description", "visibility" and "default_branch" and nothing else.
82+// one of "description", "visibility", "default_branch", "settings",
83+// "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84+// nothing else.
8385 //
8486 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
8587 func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) {
@@ -103,6 +105,17 @@ func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) {
103105 params.Visibility = &vis
104106 case "default_branch":
105107 params.DefaultBranch = new(msg.GetDefaultBranch())
108+ case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block":
109+ if path != "settings.failing_checks_block" {
110+ modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
111+ if err != nil {
112+ return params, err
113+ }
114+ params.AllowedMergeModes = &modes
115+ }
116+ if path != "settings.allowed_merge_modes" {
117+ params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
118+ }
106119 default:
107120 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
108121 }
@@ -79,7 +79,9 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
79 }79 }
80 80
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82-// one of "description", "visibility" and "default_branch" and nothing else.82+// one of "description", "visibility", "default_branch", "settings",
83+// "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84+// nothing else.
83 //85 //
84 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here86 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
85 func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) {87 func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) {
@@ -103,6 +105,17 @@ func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) {
103 params.Visibility = &vis105 params.Visibility = &vis
104 case "default_branch":106 case "default_branch":
105 params.DefaultBranch = new(msg.GetDefaultBranch())107 params.DefaultBranch = new(msg.GetDefaultBranch())
108+ case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block":
109+ if path != "settings.failing_checks_block" {
110+ modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
111+ if err != nil {
112+ return params, err
113+ }
114+ params.AllowedMergeModes = &modes
115+ }
116+ if path != "settings.allowed_merge_modes" {
117+ params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
118+ }
106 default:119 default:
107 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)120 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
108 }121 }
M internal/adapters/rpc/server.go +15 -4
@@ -4,22 +4,33 @@ package rpc
44
55 import (
66 "context"
7- "errors"
87 "slices"
98
109 "connectrpc.com/connect"
1110
1211 pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/app"
1313 )
1414
1515 // serverService implements ermiteconnect.ServerServiceHandler.
16-type serverService struct{ version string }
16+type serverService struct {
17+ version string
18+ key *app.SigningKey
19+}
1720
1821 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
1922 return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil
2023 }
2124
22-// GetSigningKey is not served yet: the server does not list "signing_key".
25+// GetSigningKey returns the public key of the commits ermite signs. It needs
26+// no authentication (see the interceptor).
2327 func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSigningKeyRequest]) (*connect.Response[pb.GetSigningKeyResponse], error) {
24- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("no signing key"))
28+ info := s.key.Info()
29+ return connect.NewResponse(&pb.GetSigningKeyResponse{
30+ PublicKey: info.Key.AuthorizedKey,
31+ Fingerprint: info.Key.Fingerprint,
32+ CommitterName: info.Committer.Name,
33+ CommitterEmail: info.Committer.Email,
34+ AllowedSigners: info.AllowedSigners(),
35+ }), nil
2536 }
@@ -4,22 +4,33 @@ package rpc
4 4
5 import (5 import (
6 "context"6 "context"
7- "errors"
8 "slices"7 "slices"
9 8
10 "connectrpc.com/connect"9 "connectrpc.com/connect"
11 10
12 pb "bnema.dev/ermite-api/gen/ermite"11 pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/app"
13 )13 )
14 14
15 // serverService implements ermiteconnect.ServerServiceHandler.15 // serverService implements ermiteconnect.ServerServiceHandler.
16-type serverService struct{ version string }16+type serverService struct {
17+ version string
18+ key *app.SigningKey
19+}
17 20
18 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {21 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
19 return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil22 return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil
20 }23 }
21 24
22-// GetSigningKey is not served yet: the server does not list "signing_key".25+// GetSigningKey returns the public key of the commits ermite signs. It needs
26+// no authentication (see the interceptor).
23 func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSigningKeyRequest]) (*connect.Response[pb.GetSigningKeyResponse], error) {27 func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSigningKeyRequest]) (*connect.Response[pb.GetSigningKeyResponse], error) {
24- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("no signing key"))28+ info := s.key.Info()
29+ return connect.NewResponse(&pb.GetSigningKeyResponse{
30+ PublicKey: info.Key.AuthorizedKey,
31+ Fingerprint: info.Key.Fingerprint,
32+ CommitterName: info.Committer.Name,
33+ CommitterEmail: info.Committer.Email,
34+ AllowedSigners: info.AllowedSigners(),
35+ }), nil
25 }36 }

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