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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 158 | 158 | for _, who := range allPrincipals { | |
| 159 | 159 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 160 | 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 | 162 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 163 | 163 | } | |
| 164 | 164 | }) | |
| @@ -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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "crypto/ed25519" | |
| 8 | + | "crypto/rand" | |
| 7 | 9 | "errors" | |
| 8 | 10 | "fmt" | |
| 9 | 11 | "net" | |
| @@ -19,10 +21,12 @@ import ( | |||
| 19 | 21 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 20 | 22 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 21 | 23 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 24 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 22 | 25 | "bnema.dev/ermite/internal/adapters/web" | |
| 23 | 26 | "bnema.dev/ermite/internal/app" | |
| 24 | 27 | "bnema.dev/ermite/internal/clientlimit" | |
| 25 | 28 | "bnema.dev/ermite/internal/config" | |
| 29 | + | "bnema.dev/ermite/internal/domain" | |
| 26 | 30 | "bnema.dev/ermite/internal/ports" | |
| 27 | 31 | ) | |
| 28 | 32 | ||
| @@ -56,14 +60,27 @@ type forge struct { | |||
| 56 | 60 | ||
| 57 | 61 | // The use cases and the API are exposed for tests and for the phases | |
| 58 | 62 | // 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 | |
| 67 | 84 | } | |
| 68 | 85 | ||
| 69 | 86 | // 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 | 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 | 190 | // One budget of failed authentications per client, for Git and the API. | |
| 157 | 191 | limiter := clientlimit.NewLimiter(nil) | |
| 158 | 192 | f.API = rpc.New(rpc.Config{ | |
| @@ -161,6 +195,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 161 | 195 | Tokens: f.Tokens, | |
| 162 | 196 | Browse: f.Browse, | |
| 163 | 197 | Events: f.EventLog, | |
| 198 | + | MergeRequests: f.MergeRequests, | |
| 199 | + | SigningKey: f.SigningKey, | |
| 164 | 200 | Version: serverVersion(), | |
| 165 | 201 | TrustedProxies: cfg.TrustedProxies, | |
| 166 | 202 | 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 phases | 61 | // 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.Repos | 63 | + | Repos *app.Repos |
| 60 | - | Tokens *app.Tokens | 64 | + | Tokens *app.Tokens |
| 61 | - | Auth *app.Auth | 65 | + | Auth *app.Auth |
| 62 | - | Pushes *app.Pushes | 66 | + | Pushes *app.Pushes |
| 63 | - | Browse *app.Browse | 67 | + | Browse *app.Browse |
| 64 | - | EventLog *app.Events | 68 | + | // MergeRequests and SigningKey serve MergeRequestService and |
| 65 | - | API *rpc.API | 69 | + | // ServerService.GetSigningKey. |
| 66 | - | Bus ports.EventBus | 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 | |||
| 67 | } | 84 | } | ||
| 68 | 85 | ||||
| 69 | // serverVersion is the version GetVersion reports: the module version of the | 86 | // 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 { | |||
| 21 | 21 | Long: "Run the server. It is configured by the environment only:\n" + | |
| 22 | 22 | config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" + | |
| 23 | 23 | config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " + | |
| 24 | - | config.EnvLogLevel + ", " + config.EnvLogFormat + ".", | |
| 24 | + | config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" + | |
| 25 | + | config.EnvCommitterName + ", " + config.EnvCommitterEmail + ".", | |
| 25 | 26 | Args: cobra.NoArgs, | |
| 26 | 27 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 27 | 28 | 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 | |||
| 27 | 27 | ||
| 28 | 28 | // Features are the optional capabilities of the contract that this server | |
| 29 | 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 | 32 | // Authenticator turns a token secret into a principal, or | |
| 33 | 33 | // domain.ErrUnauthenticated. app.Auth fits. | |
| @@ -37,12 +37,15 @@ type Authenticator interface { | |||
| 37 | 37 | ||
| 38 | 38 | // Config configures the API. | |
| 39 | 39 | 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 | |
| 46 | 49 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 47 | 50 | TrustedProxies []netip.Prefix | |
| 48 | 51 | // Limiter counts failed authentications; nil builds a default one. Share | |
| @@ -75,7 +78,8 @@ func New(cfg Config) *API { | |||
| 75 | 78 | mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...)) | |
| 76 | 79 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | |
| 77 | 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 | 83 | return &API{handler: mux} | |
| 80 | 84 | } | |
| 81 | 85 | ||
| @@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20 | |||||
| 27 | 27 | ||||
| 28 | // Features are the optional capabilities of the contract that this server | 28 | // 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, or | 32 | // 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 Authenticator | 40 | + | Auth Authenticator |
| 41 | - | Repos *app.Repos | 41 | + | Repos *app.Repos |
| 42 | - | Tokens *app.Tokens | 42 | + | Tokens *app.Tokens |
| 43 | - | Browse *app.Browse | 43 | + | Browse *app.Browse |
| 44 | - | Events *app.Events | 44 | + | Events *app.Events |
| 45 | - | Version string | 45 | + | // 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.Prefix | 50 | TrustedProxies []netip.Prefix | ||
| 48 | // Limiter counts failed authentications; nil builds a default one. Share | 51 | // 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) { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | func repoToPB(v app.RepoView) *pb.Repo { | |
| 59 | + | settings := v.Settings | |
| 60 | + | if settings.IsZero() { | |
| 61 | + | settings = domain.DefaultRepoSettings() | |
| 62 | + | } | |
| 59 | 63 | return &pb.Repo{ | |
| 60 | 64 | Name: v.Name.String(), | |
| 61 | 65 | Description: v.Description, | |
| @@ -63,7 +67,57 @@ func repoToPB(v app.RepoView) *pb.Repo { | |||
| 63 | 67 | DefaultBranch: v.DefaultBranch, | |
| 64 | 68 | CreatedAt: timestamp(v.CreatedAt), | |
| 65 | 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 | 123 | var scopeToPB = map[domain.Scope]pb.Scope{ | |
| @@ -265,22 +319,30 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff { | |||
| 265 | 319 | return out | |
| 266 | 320 | } | |
| 267 | 321 | ||
| 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), | |
| 277 | 327 | } | |
| 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)) | |
| 280 | 334 | } | |
| 281 | 335 | 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 | 346 | // oidToPB turns the zero object id of a ref update (no object) into "". | |
| 285 | 347 | func oidToPB(oid string) string { | |
| 286 | 348 | if oid == domain.ZeroOID { | |
| @@ -317,6 +379,18 @@ func watchEventToPB(we app.WatchEvent) *pb.Event { | |||
| 317 | 379 | case domain.PushEvent: | |
| 318 | 380 | out.Time = timestamp(ev.At) | |
| 319 | 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 | 394 | default: | |
| 321 | 395 | return nil | |
| 322 | 396 | } | |
| @@ -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 out | 319 | 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 out | 335 | 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 nil | 395 | return nil | ||
| 322 | } | 396 | } | ||
M internal/adapters/rpc/convert_test.go +187 -0
| @@ -3,12 +3,19 @@ | |||
| 3 | 3 | package rpc | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 6 | 8 | "testing" | |
| 9 | + | "time" | |
| 7 | 10 | ||
| 11 | + | "connectrpc.com/connect" | |
| 8 | 12 | "github.com/stretchr/testify/assert" | |
| 9 | 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 | 17 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite/internal/app" | |
| 12 | 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 | + | } | |
| @@ -3,12 +3,19 @@ | |||||
| 3 | package rpc | 3 | 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "connectrpc.com/connect" | |
| 10 | 10 | "github.com/bnema/zerowrap" | |
| 11 | + | "google.golang.org/genproto/googleapis/rpc/errdetails" | |
| 11 | 12 | ||
| 12 | 13 | "bnema.dev/ermite/internal/domain" | |
| 13 | 14 | ) | |
| @@ -24,7 +25,9 @@ func connectError(ctx context.Context, err error) error { | |||
| 24 | 25 | return err | |
| 25 | 26 | } | |
| 26 | 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 | 32 | log := zerowrap.FromCtx(ctx) | |
| 30 | 33 | log.Error().Err(err).Msg("api request failed") | |
| @@ -67,5 +70,21 @@ var invalidErrors = []error{ | |||
| 67 | 70 | domain.ErrInvalidScope, domain.ErrInvalidToken, | |
| 68 | 71 | domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath, | |
| 69 | 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 | } | |
| @@ -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 err | 25 | 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 | |||
| 56 | 56 | // Whatever the context carried, the caller is anonymous until proven | |
| 57 | 57 | // otherwise. | |
| 58 | 58 | 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. | |
| 62 | 63 | return ctx, nil | |
| 63 | 64 | } | |
| 64 | 65 | 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 proven | 56 | // 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 compatibility | 60 | + | // 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, nil | 63 | 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 | |||
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | 81 | // 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 | 86 | //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 | 87 | func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) { | |
| @@ -103,6 +105,17 @@ func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) { | |||
| 103 | 105 | params.Visibility = &vis | |
| 104 | 106 | case "default_branch": | |
| 105 | 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 | 119 | default: | |
| 107 | 120 | return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path) | |
| 108 | 121 | } | |
| @@ -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 least | 81 | // 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 here | 86 | //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 = &vis | 105 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | - | "errors" | |
| 8 | 7 | "slices" | |
| 9 | 8 | ||
| 10 | 9 | "connectrpc.com/connect" | |
| 11 | 10 | ||
| 12 | 11 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | + | "bnema.dev/ermite/internal/app" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 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 | 21 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | |
| 19 | 22 | 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 | 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 | } | |
| @@ -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)}), nil | 22 | 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 | } | ||