refactor(web): read and write through the ports instead of in-process Connect clients
- web.Config takes RepoService, BrowseService, MergeRequestService and EventService; pages render from domain and ports types - each handler passes the request principal explicitly: anonymous unless the session cookie verifies as the owner - WebSessionService.Verify returns the principal instead of a context - domain errors map to the same pages and stream statuses as before; the shared invalid-error list is domain.IsInvalid - the in-process Connect transport and its interceptor trust path are removed - web tests use ports mocks; the boundary test forbids ermite-api imports in the web adapter
- commit
- 582b6208c9668a3e2e9e8afa9cfaf9e53c589ab7
- author
- brice · · 2026-10-12 05:54 UTC
- parent
- 4738f20
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
52 files · +1703 -2173
M .hexcheck.yaml +0 -1
| @@ -65,7 +65,6 @@ rules: | |||||
| 65 | mocking: | 65 | mocking: | ||
| 66 | generatedMockPaths: | 66 | generatedMockPaths: | ||
| 67 | - internal/ports/mocks/** | 67 | - internal/ports/mocks/** | ||
| 68 | - | - internal/adapters/web/mocks/** | |||
| 69 | generatedMockNamePatterns: | 68 | generatedMockNamePatterns: | ||
| 70 | - '{{Interface}}' | 69 | - '{{Interface}}' | ||
| 71 | allow: | 70 | allow: | ||
M .mockery.yml +1 -12
| @@ -1,5 +1,4 @@ | |||||
| 1 | - | # Mocks of the ports, generated in internal/ports/mocks, and of the generated | 1 | + | # Mocks of the ports, generated in internal/ports/mocks. Regenerate with: |
| 2 | - | # Connect clients the web adapter calls. Regenerate with: | |||
| 3 | # go generate ./internal/ports | 2 | # go generate ./internal/ports | ||
| 4 | template: testify | 3 | template: testify | ||
| 5 | _anchors: | 4 | _anchors: | ||
| @@ -12,13 +11,3 @@ _anchors: | |||||
| 12 | packages: | 11 | packages: | ||
| 13 | bnema.dev/ermite/internal/ports: | 12 | bnema.dev/ermite/internal/ports: | ||
| 14 | config: *mocks | 13 | config: *mocks | ||
| 15 | - | bnema.dev/ermite-api/gen/ermite/ermiteconnect: | |||
| 16 | - | config: | |||
| 17 | - | include-auto-generated: true | |||
| 18 | - | dir: internal/adapters/web/mocks | |||
| 19 | - | pkgname: mocks | |||
| 20 | - | filename: "{{.InterfaceName}}.go" | |||
| 21 | - | structname: "{{.InterfaceName}}" | |||
| 22 | - | interfaces: | |||
| 23 | - | GitServiceClient: | |||
| 24 | - | RepoServiceClient: | |||
M README.md +1 -0
| @@ -104,6 +104,7 @@ go test ./... | |||||
| 104 | 104 | ||||
| 105 | - `internal/usecase` imports only `internal/domain` and `internal/ports`. | 105 | - `internal/usecase` imports only `internal/domain` and `internal/ports`. | ||
| 106 | - Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter. | 106 | - Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter. | ||
| 107 | + | - The web adapter, like the API and Git over HTTP, calls the use cases through `internal/ports` with the principal of the request (anonymous, or the owner with a valid session); it never imports the generated API code. | |||
| 107 | - `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module. | 108 | - `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module. | ||
| 108 | - `internal/config` (environment parsing) is used by `internal/app` only. | 109 | - `internal/config` (environment parsing) is used by `internal/app` only. | ||
| 109 | 110 | ||||
M internal/adapters/githttp/handler.go +2 -0
| @@ -182,6 +182,7 @@ func parseRoute(r *http.Request) (route, int) { | |||||
| 182 | } | 182 | } | ||
| 183 | 183 | ||||
| 184 | // ServeHTTP implements http.Handler. | 184 | // ServeHTTP implements http.Handler. | ||
| 185 | + | // | |||
| 185 | //hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases' | 186 | //hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases' | ||
| 186 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 187 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 187 | var rt route | 188 | var rt route | ||
| @@ -374,6 +375,7 @@ func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route, | |||||
| 374 | } | 375 | } | ||
| 375 | 376 | ||||
| 376 | // serveBackend runs git http-backend for an authorized request. | 377 | // serveBackend runs git http-backend for an authorized request. | ||
| 378 | + | // | |||
| 377 | //hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here | 379 | //hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here | ||
| 378 | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) { | 380 | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) { | ||
| 379 | ctx := r.Context() | 381 | ctx := r.Context() | ||
M internal/adapters/githttp/hooks.go +1 -0
| @@ -175,6 +175,7 @@ func (s *HookServer) Close(ctx context.Context) { | |||||
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | // handle serves one connection: one request line, one reply line. | 177 | // handle serves one connection: one request line, one reply line. | ||
| 178 | + | // | |||
| 178 | //hexcheck:ignore suspicious-business-logic-in-adapter hook protocol dispatch: the decisions are the use cases' | 179 | //hexcheck:ignore suspicious-business-logic-in-adapter hook protocol dispatch: the decisions are the use cases' | ||
| 179 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | 180 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | ||
| 180 | log := zerowrap.FromCtx(ctx) | 181 | log := zerowrap.FromCtx(ctx) | ||
M internal/adapters/rpc/api.go +3 -4
| @@ -3,8 +3,7 @@ | |||||
| 3 | // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with | 3 | // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with | ||
| 4 | // ConnectRPC: handlers that map the messages of the contract to the use cases | 4 | // ConnectRPC: handlers that map the messages of the contract to the use cases | ||
| 5 | // of usecase and back, the interceptor that authenticates callers, and the error | 5 | // of usecase and back, the interceptor that authenticates callers, and the error | ||
| 6 | - | // mapping. The same handlers serve remote clients over HTTP and the web in | 6 | + | // mapping. |
| 7 | - | // process (see InProcessClient). | |||
| 8 | package rpc | 7 | package rpc | ||
| 9 | 8 | ||||
| 10 | import ( | 9 | import ( | ||
| @@ -90,8 +89,8 @@ func New(cfg Config) *API { | |||||
| 90 | // it under /-/api/ and strips the prefix. | 89 | // it under /-/api/ and strips the prefix. | ||
| 91 | func (a *API) Handler() http.Handler { return a.handler } | 90 | func (a *API) Handler() http.Handler { return a.handler } | ||
| 92 | 91 | ||||
| 93 | - | // caller returns the principal the interceptor (or the in-process caller) | 92 | + | // caller returns the principal the interceptor put in ctx; anonymous when |
| 94 | - | // put in ctx; anonymous when there is none. | 93 | + | // there is none. |
| 95 | func caller(ctx context.Context) domain.Principal { | 94 | func caller(ctx context.Context) domain.Principal { | ||
| 96 | p, _ := domain.PrincipalFromContext(ctx) | 95 | p, _ := domain.PrincipalFromContext(ctx) | ||
| 97 | return p | 96 | return p | ||
M internal/adapters/rpc/errors.go +2 -13
| @@ -56,23 +56,12 @@ func codeOf(err error) (connect.Code, bool) { | |||||
| 56 | case errors.Is(err, domain.ErrResourceExhausted): | 56 | case errors.Is(err, domain.ErrResourceExhausted): | ||
| 57 | return connect.CodeResourceExhausted, true | 57 | return connect.CodeResourceExhausted, true | ||
| 58 | } | 58 | } | ||
| 59 | - | for _, invalid := range invalidErrors { | 59 | + | if domain.IsInvalid(err) { |
| 60 | - | if errors.Is(err, invalid) { | 60 | + | return connect.CodeInvalidArgument, true |
| 61 | - | return connect.CodeInvalidArgument, true | |||
| 62 | - | } | |||
| 63 | } | 61 | } | ||
| 64 | return 0, false | 62 | return 0, false | ||
| 65 | } | 63 | } | ||
| 66 | 64 | ||||
| 67 | - | // invalidErrors are the domain errors that mean the request was malformed. | |||
| 68 | - | var invalidErrors = []error{ | |||
| 69 | - | domain.ErrInvalid, domain.ErrInvalidName, domain.ErrInvalidVisibility, | |||
| 70 | - | domain.ErrInvalidScope, domain.ErrInvalidToken, | |||
| 71 | - | domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath, | |||
| 72 | - | domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind, | |||
| 73 | - | domain.ErrNotDirectory, domain.ErrNotFile, domain.ErrInvalidText, | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | // addFieldDetail attaches the fields a request was rejected for as a | 65 | // addFieldDetail attaches the fields a request was rejected for as a | ||
| 77 | // BadRequest detail, when err carries them. | 66 | // BadRequest detail, when err carries them. | ||
| 78 | func addFieldDetail(ce *connect.Error, err error) { | 67 | func addFieldDetail(ce *connect.Error, err error) { | ||
D internal/adapters/rpc/inprocess.go +0 -131
| @@ -1,131 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package rpc | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "io" | |||
| 8 | - | "net/http" | |||
| 9 | - | "sync" | |||
| 10 | - | ||||
| 11 | - | "bnema.dev/ermite/internal/domain" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // inProcessHost is the host of the URLs of in-process calls. The name is | |||
| 15 | - | // never resolved. | |||
| 16 | - | const inProcessHost = "ermite.invalid" | |||
| 17 | - | ||||
| 18 | - | // InProcessURL is the base URL to give to the generated Connect clients that | |||
| 19 | - | // use InProcessClient. | |||
| 20 | - | const InProcessURL = "http://" + inProcessHost | |||
| 21 | - | ||||
| 22 | - | // InProcessClient returns an HTTP client whose requests are served by the | |||
| 23 | - | // handlers of the API in this process: no socket, no network hop. Build the | |||
| 24 | - | // generated Connect clients on it: | |||
| 25 | - | // | |||
| 26 | - | // hc := api.InProcessClient() | |||
| 27 | - | // repos := ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL) | |||
| 28 | - | // | |||
| 29 | - | // The caller is the principal found in the context of each call | |||
| 30 | - | // (domain.ContextWithPrincipal), anonymous when there is none; the | |||
| 31 | - | // Authorization header is ignored. This is the identity of the web (P5): | |||
| 32 | - | // anonymous for visitors, the owner for a session. Only code in this process | |||
| 33 | - | // can put the marker that makes the interceptor trust it. | |||
| 34 | - | // Unary and server-streaming calls work, with all the codecs; the request | |||
| 35 | - | // log and the error mapping are those of remote calls. | |||
| 36 | - | func (a *API) InProcessClient() *http.Client { | |||
| 37 | - | return &http.Client{Transport: inProcessTransport{h: a.handler}} | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | type inProcessTransport struct{ h http.Handler } | |||
| 41 | - | ||||
| 42 | - | // inProcessKey marks the context of a call made through InProcessClient. It is | |||
| 43 | - | // unexported, so that nothing outside this package, and nothing that comes | |||
| 44 | - | // from the network, can make the interceptor trust a principal. | |||
| 45 | - | type inProcessKey struct{} | |||
| 46 | - | ||||
| 47 | - | func inProcess(ctx context.Context) bool { | |||
| 48 | - | _, ok := ctx.Value(inProcessKey{}).(struct{}) | |||
| 49 | - | return ok | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | // RoundTrip serves req with the handler in a goroutine, and returns once the | |||
| 53 | - | // handler wrote its status. The body of the response is a pipe the handler | |||
| 54 | - | // writes to, so that streams are delivered as they are sent. | |||
| 55 | - | func (t inProcessTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 56 | - | ctx := req.Context() | |||
| 57 | - | p, ok := domain.PrincipalFromContext(ctx) | |||
| 58 | - | if !ok { | |||
| 59 | - | p = domain.Anonymous() | |||
| 60 | - | } | |||
| 61 | - | sreq := req.Clone(context.WithValue(domain.ContextWithPrincipal(ctx, p), inProcessKey{}, struct{}{})) | |||
| 62 | - | sreq.RemoteAddr = "in-process" | |||
| 63 | - | sreq.RequestURI = req.URL.RequestURI() | |||
| 64 | - | if sreq.Body == nil { | |||
| 65 | - | sreq.Body = http.NoBody | |||
| 66 | - | } | |||
| 67 | - | sreq.Header.Del("Authorization") | |||
| 68 | - | ||||
| 69 | - | pr, pw := io.Pipe() | |||
| 70 | - | w := &pipeResponse{header: http.Header{}, pw: pw, ready: make(chan struct{})} | |||
| 71 | - | go func() { | |||
| 72 | - | defer func() { | |||
| 73 | - | if r := recover(); r != nil { | |||
| 74 | - | w.writeHeader(http.StatusInternalServerError) | |||
| 75 | - | _ = pw.CloseWithError(io.ErrUnexpectedEOF) | |||
| 76 | - | } | |||
| 77 | - | }() | |||
| 78 | - | t.h.ServeHTTP(w, sreq) | |||
| 79 | - | w.writeHeader(http.StatusOK) // a handler that wrote nothing | |||
| 80 | - | _ = pw.Close() | |||
| 81 | - | }() | |||
| 82 | - | ||||
| 83 | - | select { | |||
| 84 | - | case <-w.ready: | |||
| 85 | - | case <-ctx.Done(): | |||
| 86 | - | _ = pr.CloseWithError(ctx.Err()) | |||
| 87 | - | return nil, ctx.Err() | |||
| 88 | - | } | |||
| 89 | - | return &http.Response{ | |||
| 90 | - | Status: http.StatusText(w.status), | |||
| 91 | - | StatusCode: w.status, | |||
| 92 | - | Proto: "HTTP/1.1", | |||
| 93 | - | ProtoMajor: 1, | |||
| 94 | - | ProtoMinor: 1, | |||
| 95 | - | Header: w.sent, | |||
| 96 | - | Body: pr, | |||
| 97 | - | ContentLength: -1, | |||
| 98 | - | Request: req, | |||
| 99 | - | }, nil | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | // pipeResponse is the http.ResponseWriter of an in-process call. | |||
| 103 | - | type pipeResponse struct { | |||
| 104 | - | header http.Header | |||
| 105 | - | pw *io.PipeWriter | |||
| 106 | - | ||||
| 107 | - | once sync.Once | |||
| 108 | - | ready chan struct{} | |||
| 109 | - | status int | |||
| 110 | - | sent http.Header // header as of WriteHeader | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | func (w *pipeResponse) Header() http.Header { return w.header } | |||
| 114 | - | ||||
| 115 | - | func (w *pipeResponse) WriteHeader(code int) { w.writeHeader(code) } | |||
| 116 | - | ||||
| 117 | - | func (w *pipeResponse) writeHeader(code int) { | |||
| 118 | - | w.once.Do(func() { | |||
| 119 | - | w.status, w.sent = code, w.header.Clone() | |||
| 120 | - | close(w.ready) | |||
| 121 | - | }) | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func (w *pipeResponse) Write(b []byte) (int, error) { | |||
| 125 | - | w.writeHeader(http.StatusOK) | |||
| 126 | - | return w.pw.Write(b) | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | // Flush implements http.Flusher, which streaming handlers require: the pipe | |||
| 130 | - | // delivers every write at once, there is nothing to flush but the header. | |||
| 131 | - | func (w *pipeResponse) Flush() { w.writeHeader(http.StatusOK) } | |||
M internal/adapters/rpc/interceptor.go +4 -22
| @@ -31,12 +31,9 @@ var errInternal = errors.New("internal error") | |||||
| 31 | // limiter; a client that used up its failures gets resource_exhausted (429 on | 31 | // limiter; a client that used up its failures gets resource_exhausted (429 on | ||
| 32 | // the Connect protocol) whatever it presents, like on the Git routes. | 32 | // the Connect protocol) whatever it presents, like on the Git routes. | ||
| 33 | // | 33 | // | ||
| 34 | - | // A call made through InProcessClient is trusted with the principal its | 34 | + | // A principal in the context of a network request, say one put there by a |
| 35 | - | // context carries: that is how the web calls the handlers in process. What | 35 | + | // server's base context, is ignored and the request is authenticated from its |
| 36 | - | // marks it is a value only the in-process transport can put in the context | 36 | + | // headers. |
| 37 | - | // (see inProcessKey); a principal in the context of a network request, say one | |||
| 38 | - | // put there by a server's base context, is ignored and the request is | |||
| 39 | - | // authenticated from its headers. | |||
| 40 | type interceptor struct { | 37 | type interceptor struct { | ||
| 41 | auth ports.AuthService | 38 | auth ports.AuthService | ||
| 42 | limiter ports.FailureLimiter | 39 | limiter ports.FailureLimiter | ||
| @@ -49,11 +46,6 @@ func newInterceptor(cfg Config) *interceptor { | |||||
| 49 | 46 | ||||
| 50 | // authenticate returns the context to run the call in. | 47 | // authenticate returns the context to run the call in. | ||
| 51 | func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) { | 48 | func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) { | ||
| 52 | - | if inProcess(ctx) { | |||
| 53 | - | if _, ok := domain.PrincipalFromContext(ctx); ok { | |||
| 54 | - | return ctx, nil | |||
| 55 | - | } | |||
| 56 | - | } | |||
| 57 | // Whatever the context carried, the caller is anonymous until proven | 49 | // Whatever the context carried, the caller is anonymous until proven | ||
| 58 | // otherwise. | 50 | // otherwise. | ||
| 59 | ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous()) | 51 | ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous()) | ||
| @@ -91,18 +83,8 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri | |||||
| 91 | 83 | ||||
| 92 | type clientKey struct{} | 84 | type clientKey struct{} | ||
| 93 | 85 | ||||
| 94 | - | // ContextWithClient attributes the in-process calls made with ctx to the | |||
| 95 | - | // client at addr (clientlimit.ClientAddr of the browser's request), so that | |||
| 96 | - | // the per-client limits of the API, such as the event streams one, apply to | |||
| 97 | - | // each visitor of the web rather than to the web as a whole. A network call | |||
| 98 | - | // is always attributed to its own peer: the interceptor overwrites it. | |||
| 99 | - | func ContextWithClient(ctx context.Context, addr string) context.Context { | |||
| 100 | - | return context.WithValue(ctx, clientKey{}, clientlimit.Key(addr)) | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | // clientFromContext returns the normalized address of the client of a call: | 86 | // clientFromContext returns the normalized address of the client of a call: | ||
| 104 | - | // its peer for a network call, the one given to ContextWithClient for an | 87 | + | // its peer, empty when there is none. |
| 105 | - | // in-process call, empty when there is none. | |||
| 106 | func clientFromContext(ctx context.Context) string { | 88 | func clientFromContext(ctx context.Context) string { | ||
| 107 | c, _ := ctx.Value(clientKey{}).(string) | 89 | c, _ := ctx.Value(clientKey{}).(string) | ||
| 108 | return c | 90 | return c | ||
M internal/adapters/web/blob.go +33 -38
| @@ -4,17 +4,18 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "html/template" | 9 | "html/template" | ||
| 10 | + | "io" | |||
| 9 | "net/http" | 11 | "net/http" | ||
| 10 | "path" | 12 | "path" | ||
| 11 | "strings" | 13 | "strings" | ||
| 12 | 14 | ||||
| 13 | - | "connectrpc.com/connect" | |||
| 14 | "github.com/a-h/templ" | 15 | "github.com/a-h/templ" | ||
| 15 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 16 | 17 | ||||
| 17 | - | pb "bnema.dev/ermite-api/gen/ermite" | 18 | + | "bnema.dev/ermite/internal/domain" |
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | const ( | 21 | const ( | ||
| @@ -45,47 +46,43 @@ type BlobPage struct { | |||||
| 45 | DirHref string | 46 | DirHref string | ||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| 48 | - | // blobStream is an open GetBlob stream whose first message has been read. | 49 | + | // blobStream is an open file: its metadata and the content, read on demand. |
| 49 | type blobStream struct { | 50 | type blobStream struct { | ||
| 50 | - | stream *connect.ServerStreamForClient[pb.GetBlobResponse] | 51 | + | body io.ReadCloser |
| 51 | - | info *pb.BlobInfo | 52 | + | info domain.BlobInfo |
| 52 | - | // buf holds the content already received and not yet handed out. | 53 | + | // buf holds the content already read and not yet handed out. |
| 53 | buf []byte | 54 | buf []byte | ||
| 55 | + | // eof is true once body has nothing more. | |||
| 56 | + | eof bool | |||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 56 | - | // openBlob starts the stream of a file and reads its metadata. | 59 | + | // openBlob opens a file of the repository. |
| 57 | - | func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) { | 60 | + | func (s *Server) openBlob(ctx context.Context, repo domain.RepoName, t target) (*blobStream, error) { |
| 58 | - | if t.path == "" { | 61 | + | info, body, err := s.openBlobAt(ctx, repo, t.rev, t.path) |
| 59 | - | return nil, errNotFound | |||
| 60 | - | } | |||
| 61 | - | st, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: t.rev, Path: t.path})) | |||
| 62 | if err != nil { | 62 | if err != nil { | ||
| 63 | return nil, err | 63 | return nil, err | ||
| 64 | } | 64 | } | ||
| 65 | - | if !st.Receive() { | 65 | + | return &blobStream{body: body, info: info}, nil |
| 66 | - | err := st.Err() | |||
| 67 | - | _ = st.Close() | |||
| 68 | - | if err == nil { | |||
| 69 | - | err = errNotFound | |||
| 70 | - | } | |||
| 71 | - | return nil, err | |||
| 72 | - | } | |||
| 73 | - | m := st.Msg() | |||
| 74 | - | return &blobStream{stream: st, info: m.GetInfo(), buf: append([]byte(nil), m.GetChunk()...)}, nil | |||
| 75 | } | 66 | } | ||
| 76 | 67 | ||||
| 77 | - | func (b *blobStream) close() { _ = b.stream.Close() } | 68 | + | func (b *blobStream) close() { _ = b.body.Close() } |
| 78 | 69 | ||||
| 79 | - | // fill reads from the stream until buf holds n bytes or the content ends. | 70 | + | // fill reads until buf holds n bytes or the content ends. |
| 80 | func (b *blobStream) fill(n int) error { | 71 | func (b *blobStream) fill(n int) error { | ||
| 72 | + | if b.eof || len(b.buf) >= n { | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 81 | // One allocation for the whole content, not the doublings of append. | 75 | // One allocation for the whole content, not the doublings of append. | ||
| 82 | if cap(b.buf) < n { | 76 | if cap(b.buf) < n { | ||
| 83 | b.buf = append(make([]byte, 0, n), b.buf...) | 77 | b.buf = append(make([]byte, 0, n), b.buf...) | ||
| 84 | } | 78 | } | ||
| 85 | - | for len(b.buf) < n && b.stream.Receive() { | 79 | + | m, err := io.ReadFull(b.body, b.buf[len(b.buf):n]) |
| 86 | - | b.buf = append(b.buf, b.stream.Msg().GetChunk()...) | 80 | + | b.buf = b.buf[:len(b.buf)+m] |
| 81 | + | if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { | |||
| 82 | + | b.eof = true | |||
| 83 | + | return nil | |||
| 87 | } | 84 | } | ||
| 88 | - | return b.stream.Err() | 85 | + | return err |
| 89 | } | 86 | } | ||
| 90 | 87 | ||||
| 91 | // image types: the only images the pages show and raw serves as images. | 88 | // image types: the only images the pages show and raw serves as images. | ||
| @@ -114,7 +111,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 114 | if !ok { | 111 | if !ok { | ||
| 115 | return nil, 0, errNotFound | 112 | return nil, 0, errNotFound | ||
| 116 | } | 113 | } | ||
| 117 | - | b, err := s.openBlob(ctx, d.name(), t) | 114 | + | b, err := s.openBlob(ctx, d.repo.Name, t) |
| 118 | if err != nil { | 115 | if err != nil { | ||
| 119 | return nil, 0, err | 116 | return nil, 0, err | ||
| 120 | } | 117 | } | ||
| @@ -125,7 +122,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 125 | if dir == "." { | 122 | if dir == "." { | ||
| 126 | dir = "" | 123 | dir = "" | ||
| 127 | } | 124 | } | ||
| 128 | - | size := b.info.GetSize() | 125 | + | size := b.info.Size |
| 129 | p := BlobPage{ | 126 | p := BlobPage{ | ||
| 130 | Name: visibleText(name), | 127 | Name: visibleText(name), | ||
| 131 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | 128 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | ||
| @@ -133,7 +130,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 133 | DirHref: repoHref(d.name(), "tree", t.name, dir), | 130 | DirHref: repoHref(d.name(), "tree", t.name, dir), | ||
| 134 | Facts: []Fact{{Key: "size", Val: humanSize(size)}}, | 131 | Facts: []Fact{{Key: "size", Val: humanSize(size)}}, | ||
| 135 | } | 132 | } | ||
| 136 | - | typ := b.info.GetMediaType() | 133 | + | typ := b.info.MediaType |
| 137 | lang := "" | 134 | lang := "" | ||
| 138 | 135 | ||||
| 139 | // An image is recognised by its bytes, whatever Git's text rule says. | 136 | // An image is recognised by its bytes, whatever Git's text rule says. | ||
| @@ -146,7 +143,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 146 | switch { | 143 | switch { | ||
| 147 | case img != "": | 144 | case img != "": | ||
| 148 | p.Image, typ = true, img | 145 | p.Image, typ = true, img | ||
| 149 | - | case b.info.GetBinary(): | 146 | + | case b.info.Binary: |
| 150 | p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size)) | 147 | p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size)) | ||
| 151 | case size > maxCodeBytes: | 148 | case size > maxCodeBytes: | ||
| 152 | p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes)) | 149 | p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes)) | ||
| @@ -184,7 +181,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 184 | } | 181 | } | ||
| 185 | p.Facts = append(p.Facts, Fact{Key: "type", Val: lang}) | 182 | p.Facts = append(p.Facts, Fact{Key: "type", Val: lang}) | ||
| 186 | 183 | ||||
| 187 | - | if last := s.lastChange(ctx, d.name(), t); last != nil { | 184 | + | if last := s.lastChange(ctx, d.repo.Name, t); last != nil { |
| 188 | p.Last = last | 185 | p.Last = last | ||
| 189 | } | 186 | } | ||
| 190 | 187 | ||||
| @@ -198,14 +195,12 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 198 | 195 | ||||
| 199 | // lastChange is the latest commit that touched the path, nil when it cannot | 196 | // lastChange is the latest commit that touched the path, nil when it cannot | ||
| 200 | // be found (the page does not depend on it). | 197 | // be found (the page does not depend on it). | ||
| 201 | - | func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow { | 198 | + | func (s *Server) lastChange(ctx context.Context, repo domain.RepoName, t target) *CommitRow { |
| 202 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 199 | + | res, err := s.commitList(ctx, repo, t.rev, t.path, 1, "") |
| 203 | - | Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1, | 200 | + | if err != nil || len(res.Commits) != 1 { |
| 204 | - | })) | |||
| 205 | - | if err != nil || len(res.Msg.GetCommits()) != 1 { | |||
| 206 | return nil | 201 | return nil | ||
| 207 | } | 202 | } | ||
| 208 | - | row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now()) | 203 | + | row := commitRow(repo.String(), res.Commits[0], s.cfg.Now()) |
| 209 | return &row | 204 | return &row | ||
| 210 | } | 205 | } | ||
| 211 | 206 | ||||
A internal/adapters/web/browse.go +71 -0
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite/internal/domain" | |||
| 10 | + | "bnema.dev/ermite/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // The reads of the pages go through BrowseService as the caller of the | |||
| 14 | + | // request. The revisions and paths the URLs carry are text: they are parsed | |||
| 15 | + | // here, and one the domain refuses is an invalid argument, which the pages | |||
| 16 | + | // answer like a missing one (fail). | |||
| 17 | + | ||||
| 18 | + | // commitList reads a page of the history that starts at rev, limited to path | |||
| 19 | + | // when it is not empty. | |||
| 20 | + | func (s *Server) commitList(ctx context.Context, repo domain.RepoName, rev, path string, size int, token string) (ports.HistoryPage, error) { | |||
| 21 | + | r, err := domain.ParseRevision(rev) | |||
| 22 | + | if err != nil { | |||
| 23 | + | return ports.HistoryPage{}, err | |||
| 24 | + | } | |||
| 25 | + | p, err := domain.ParseTreePath(path) | |||
| 26 | + | if err != nil { | |||
| 27 | + | return ports.HistoryPage{}, err | |||
| 28 | + | } | |||
| 29 | + | return s.cfg.Browse.ListCommits(ctx, principal(ctx), repo, ports.ListCommitsParams{ | |||
| 30 | + | Revision: r, Path: p, PageSize: size, PageToken: token, | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // getTree lists the directory path of the commit rev. | |||
| 35 | + | func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path string, lastCommit bool) (domain.Tree, error) { | |||
| 36 | + | r, err := domain.ParseRevision(rev) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return domain.Tree{}, err | |||
| 39 | + | } | |||
| 40 | + | p, err := domain.ParseTreePath(path) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return domain.Tree{}, err | |||
| 43 | + | } | |||
| 44 | + | return s.cfg.Browse.GetTree(ctx, principal(ctx), repo, r, p, lastCommit) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // getCommit reads a commit with its diff. | |||
| 48 | + | func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) { | |||
| 49 | + | r, err := domain.ParseRevision(rev) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return domain.CommitDetail{}, err | |||
| 52 | + | } | |||
| 53 | + | return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // openBlobAt opens the file path of the commit rev. The caller closes the | |||
| 57 | + | // body. | |||
| 58 | + | func (s *Server) openBlobAt(ctx context.Context, repo domain.RepoName, rev, path string) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 59 | + | r, err := domain.ParseRevision(rev) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return domain.BlobInfo{}, nil, err | |||
| 62 | + | } | |||
| 63 | + | if path == "" { | |||
| 64 | + | return domain.BlobInfo{}, nil, errNotFound | |||
| 65 | + | } | |||
| 66 | + | p, err := domain.ParseTreePath(path) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return domain.BlobInfo{}, nil, err | |||
| 69 | + | } | |||
| 70 | + | return s.cfg.Browse.OpenBlob(ctx, principal(ctx), repo, r, p) | |||
| 71 | + | } | |||
M internal/adapters/web/commit.go +38 -39
| @@ -9,10 +9,9 @@ import ( | |||||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | - | "connectrpc.com/connect" | |||
| 13 | "github.com/a-h/templ" | 12 | "github.com/a-h/templ" | ||
| 14 | 13 | ||||
| 15 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/domain" |
| 16 | ) | 15 | ) | ||
| 17 | 16 | ||||
| 18 | // bothViewsLines is the largest diff, in lines, that is sent in both the | 17 | // bothViewsLines is the largest diff, in lines, that is sent in both the | ||
| @@ -91,6 +90,8 @@ func diffLines(files []DiffFile) (lines int, invisible bool) { | |||||
| 91 | 90 | ||||
| 92 | // commit shows a commit: its message, its totals, the changed files and the | 91 | // commit shows a commit: its message, its totals, the changed files and the | ||
| 93 | // diff against the first parent. ?view=split selects the split layout. | 92 | // diff against the first parent. ?view=split selects the split layout. | ||
| 93 | + | // | |||
| 94 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here | |||
| 94 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 95 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 95 | sha := r.PathValue("sha") | 96 | sha := r.PathValue("sha") | ||
| 96 | if !isCommitID(sha) { | 97 | if !isCommitID(sha) { | ||
| @@ -100,57 +101,55 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 100 | if err != nil { | 101 | if err != nil { | ||
| 101 | return nil, 0, err | 102 | return nil, 0, err | ||
| 102 | } | 103 | } | ||
| 103 | - | res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: d.name(), Revision: sha})) | 104 | + | res, err := s.getCommit(ctx, d.repo.Name, sha) |
| 104 | if err != nil { | 105 | if err != nil { | ||
| 105 | return nil, 0, err | 106 | return nil, 0, err | ||
| 106 | } | 107 | } | ||
| 107 | - | c := res.Msg.GetCommit() | 108 | + | c := res.Commit |
| 108 | - | if c == nil { | 109 | + | cid := c.ID.String() |
| 109 | - | return nil, 0, errNotFound | |||
| 110 | - | } | |||
| 111 | - | stats := res.Msg.GetStats() | |||
| 112 | now := s.cfg.Now() | 110 | now := s.cfg.Now() | ||
| 113 | 111 | ||||
| 114 | p := CommitPage{ | 112 | p := CommitPage{ | ||
| 115 | - | SHA: c.GetId(), Short: short(c.GetId()), | 113 | + | SHA: cid, Short: short(cid), |
| 116 | - | Subject: visibleText(c.GetSummary()), Body: commitBody(c), | 114 | + | Subject: visibleText(c.Summary), Body: commitBody(c), |
| 117 | - | Author: person(c.GetAuthor(), now), | 115 | + | Author: person(c.Author, now), |
| 118 | - | Merge: len(c.GetParentIds()) > 1, | 116 | + | Merge: len(c.Parents) > 1, |
| 119 | - | Root: len(c.GetParentIds()) == 0, | 117 | + | Root: len(c.Parents) == 0, |
| 120 | - | NFiles: int(stats.GetFilesChanged()), | 118 | + | NFiles: res.Stats.FilesChanged, |
| 121 | - | Adds: int(stats.GetAdditions()), | 119 | + | Adds: res.Stats.Additions, |
| 122 | - | Dels: int(stats.GetDeletions()), | 120 | + | Dels: res.Stats.Deletions, |
| 123 | - | Files: diffFiles(res.Msg.GetDiff().GetFiles()), | 121 | + | Files: diffFiles(res.Diff.Files), |
| 124 | - | } | 122 | + | } |
| 125 | - | if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() { | 123 | + | if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email { |
| 126 | p.Committer = &cm | 124 | p.Committer = &cm | ||
| 127 | } | 125 | } | ||
| 128 | - | for _, id := range c.GetParentIds() { | 126 | + | for _, pid := range c.Parents { |
| 127 | + | id := pid.String() | |||
| 129 | p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)}) | 128 | p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)}) | ||
| 130 | } | 129 | } | ||
| 131 | for _, b := range d.branches { | 130 | for _, b := range d.branches { | ||
| 132 | - | if b.GetCommitId() == c.GetId() { | 131 | + | if b.CommitID.String() == cid { |
| 133 | - | p.Refs = append(p.Refs, b.GetShortName()) | 132 | + | p.Refs = append(p.Refs, b.ShortName) |
| 134 | } | 133 | } | ||
| 135 | } | 134 | } | ||
| 136 | for _, t := range d.tags { | 135 | for _, t := range d.tags { | ||
| 137 | - | if t.GetCommitId() == c.GetId() { | 136 | + | if t.CommitID.String() == cid { |
| 138 | - | p.Refs = append(p.Refs, "tag: "+t.GetShortName()) | 137 | + | p.Refs = append(p.Refs, "tag: "+t.ShortName) |
| 139 | } | 138 | } | ||
| 140 | } | 139 | } | ||
| 141 | - | p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles) | 140 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) |
| 142 | 141 | ||||
| 143 | lines, invisible := diffLines(p.Files) | 142 | lines, invisible := diffLines(p.Files) | ||
| 144 | p.Invisible = invisible | 143 | p.Invisible = invisible | ||
| 145 | - | base := repoHref(d.name(), "commit", c.GetId()) | 144 | + | base := repoHref(d.name(), "commit", cid) |
| 146 | p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split") | 145 | p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split") | ||
| 147 | 146 | ||||
| 148 | l := s.repoLayout(ctx, d, "commits") | 147 | l := s.repoLayout(ctx, d, "commits") | ||
| 149 | l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance | 148 | l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance | ||
| 150 | - | pinRef(&l, target{rev: c.GetId(), name: p.Short, detached: true}) | 149 | + | pinRef(&l, target{rev: cid, name: p.Short, detached: true}) |
| 151 | history := repoHref(d.name()) | 150 | history := repoHref(d.name()) | ||
| 152 | if d.head != nil { | 151 | if d.head != nil { | ||
| 153 | - | history = repoHref(d.name(), "commits", d.head.GetShortName()) | 152 | + | history = repoHref(d.name(), "commits", d.head.ShortName) |
| 154 | } | 153 | } | ||
| 155 | l.Crumbs = []Crumb{ | 154 | l.Crumbs = []Crumb{ | ||
| 156 | {Label: d.name(), Href: repoHref(d.name())}, | 155 | {Label: d.name(), Href: repoHref(d.name())}, | ||
| @@ -162,25 +161,25 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 162 | } | 161 | } | ||
| 163 | 162 | ||||
| 164 | // person converts a signature. | 163 | // person converts a signature. | ||
| 165 | - | func person(s *pb.Signature, now time.Time) Person { | 164 | + | func person(s domain.Signature, now time.Time) Person { |
| 166 | - | p := Person{Name: visibleText(s.GetName())} | 165 | + | p := Person{Name: visibleText(s.Name)} |
| 167 | - | p.When, p.WhenAt = whenOf(now, s.GetTime()) | 166 | + | p.When, p.WhenAt = whenOf(now, s.Time) |
| 168 | - | if t := s.GetTime(); t.IsValid() { | 167 | + | if !s.Time.IsZero() { |
| 169 | - | p.Abs = absolute(t.AsTime()) | 168 | + | p.Abs = absolute(s.Time) |
| 170 | } | 169 | } | ||
| 171 | return p | 170 | return p | ||
| 172 | } | 171 | } | ||
| 173 | 172 | ||||
| 174 | // commitBody is the message without its first line, for display. | 173 | // commitBody is the message without its first line, for display. | ||
| 175 | - | func commitBody(c *pb.Commit) string { | 174 | + | func commitBody(c domain.Commit) string { |
| 176 | - | msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n") | 175 | + | msg := strings.ReplaceAll(c.Message, "\r\n", "\n") |
| 177 | _, body, _ := strings.Cut(msg, "\n") | 176 | _, body, _ := strings.Cut(msg, "\n") | ||
| 178 | return messageText(strings.TrimSpace(body)) | 177 | return messageText(strings.TrimSpace(body)) | ||
| 179 | } | 178 | } | ||
| 180 | 179 | ||||
| 181 | // diffNote says what is missing from an incomplete diff, "" for a complete one. | 180 | // diffNote says what is missing from an incomplete diff, "" for a complete one. | ||
| 182 | - | func diffNote(d *pb.Diff, shown, total int) string { | 181 | + | func diffNote(d domain.Diff, shown, total int) string { |
| 183 | - | if !d.GetTruncated() { | 182 | + | if !d.Truncated { |
| 184 | return "" | 183 | return "" | ||
| 185 | } | 184 | } | ||
| 186 | var parts []string | 185 | var parts []string | ||
| @@ -188,8 +187,8 @@ func diffNote(d *pb.Diff, shown, total int) string { | |||||
| 188 | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | 187 | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | ||
| 189 | } | 188 | } | ||
| 190 | cut := 0 | 189 | cut := 0 | ||
| 191 | - | for _, f := range d.GetFiles() { | 190 | + | for _, f := range d.Files { |
| 192 | - | if f.GetTruncated() { | 191 | + | if f.Truncated { |
| 193 | cut++ | 192 | cut++ | ||
| 194 | } | 193 | } | ||
| 195 | } | 194 | } | ||
M internal/adapters/web/commits.go +13 -16
| @@ -8,10 +8,9 @@ import ( | |||||
| 8 | "net/url" | 8 | "net/url" | ||
| 9 | "path" | 9 | "path" | ||
| 10 | 10 | ||||
| 11 | - | "connectrpc.com/connect" | |||
| 12 | "github.com/a-h/templ" | 11 | "github.com/a-h/templ" | ||
| 13 | 12 | ||||
| 14 | - | pb "bnema.dev/ermite-api/gen/ermite" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| 17 | // commitsPerPage is how many commits one page of history lists. | 16 | // commitsPerPage is how many commits one page of history lists. | ||
| @@ -44,7 +43,7 @@ type CommitsPage struct { | |||||
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| 46 | // commits lists the history of a ref, newest first, a page at a time, with | 45 | // commits lists the history of a ref, newest first, a page at a time, with | ||
| 47 | - | // an optional path to limit it to. The page token of the API travels in the | 46 | + | // an optional path to limit it to. The page token of the use case travels in the |
| 48 | // "page" query parameter. | 47 | // "page" query parameter. | ||
| 49 | func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 48 | func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 50 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 49 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| @@ -56,13 +55,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 56 | return nil, 0, errNotFound | 55 | return nil, 0, errNotFound | ||
| 57 | } | 56 | } | ||
| 58 | token := r.URL.Query().Get("page") | 57 | token := r.URL.Query().Get("page") | ||
| 59 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 58 | + | res, err := s.commitList(ctx, d.repo.Name, sel.rev, sel.path, commitsPerPage, token) |
| 60 | - | Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token, | |||
| 61 | - | })) | |||
| 62 | if err != nil { | 59 | if err != nil { | ||
| 63 | return nil, 0, err | 60 | return nil, 0, err | ||
| 64 | } | 61 | } | ||
| 65 | - | cs := res.Msg.GetCommits() | 62 | + | cs := res.Commits |
| 66 | if len(cs) == 0 && token == "" && sel.path != "" { | 63 | if len(cs) == 0 && token == "" && sel.path != "" { | ||
| 67 | // Git answers an empty history for a path that never existed. | 64 | // Git answers an empty history for a path that never existed. | ||
| 68 | return nil, 0, errNotFound | 65 | return nil, 0, errNotFound | ||
| @@ -70,7 +67,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 70 | 67 | ||||
| 71 | p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)} | 68 | p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)} | ||
| 72 | base := repoHref(d.name(), "commits", sel.name, sel.path) | 69 | base := repoHref(d.name(), "commits", sel.name, sel.path) | ||
| 73 | - | if t := res.Msg.GetNextPageToken(); t != "" { | 70 | + | if t := res.NextPageToken; t != "" { |
| 74 | p.Older = base + "?page=" + url.QueryEscape(t) | 71 | p.Older = base + "?page=" + url.QueryEscape(t) | ||
| 75 | } | 72 | } | ||
| 76 | if token != "" { | 73 | if token != "" { | ||
| @@ -99,16 +96,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 99 | } | 96 | } | ||
| 100 | 97 | ||||
| 101 | // fillDays groups the commits of a page by day and lists the refs among them. | 98 | // fillDays groups the commits of a page by day and lists the refs among them. | ||
| 102 | - | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) { | 99 | + | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) { |
| 103 | now := s.cfg.Now() | 100 | now := s.cfg.Now() | ||
| 104 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | 101 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | ||
| 105 | for i, c := range cs { | 102 | for i, c := range cs { | ||
| 106 | row := commitRow(d.name(), c, now) | 103 | row := commitRow(d.name(), c, now) | ||
| 107 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | 104 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | ||
| 108 | - | row.Branches, row.Tags = branchesAt[c.GetId()], tagsAt[c.GetId()] | 105 | + | row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()] |
| 109 | day := "unknown date" | 106 | day := "unknown date" | ||
| 110 | - | if t := c.GetCommitter().GetTime(); t.IsValid() { | 107 | + | if t := c.Committer.Time; !t.IsZero() { |
| 111 | - | day = t.AsTime().UTC().Format("2006-01-02") | 108 | + | day = t.UTC().Format("2006-01-02") |
| 112 | } | 109 | } | ||
| 113 | if n := len(p.Days); n == 0 || p.Days[n-1].Day != day { | 110 | if n := len(p.Days); n == 0 || p.Days[n-1].Day != day { | ||
| 114 | p.Days = append(p.Days, DayGroup{Day: day}) | 111 | p.Days = append(p.Days, DayGroup{Day: day}) | ||
| @@ -132,15 +129,15 @@ func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link { | |||||
| 132 | if dir == "." { | 129 | if dir == "." { | ||
| 133 | dir = "" | 130 | dir = "" | ||
| 134 | } | 131 | } | ||
| 135 | - | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir})) | 132 | + | tree, err := s.getTree(ctx, d.repo.Name, sel.rev, dir, false) |
| 136 | if err != nil { | 133 | if err != nil { | ||
| 137 | return nil | 134 | return nil | ||
| 138 | } | 135 | } | ||
| 139 | - | for _, e := range tree.Msg.GetEntries() { | 136 | + | for _, e := range tree.Entries { |
| 140 | - | if e.GetPath() != sel.path { | 137 | + | if e.Path != sel.path { |
| 141 | continue | 138 | continue | ||
| 142 | } | 139 | } | ||
| 143 | - | if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY { | 140 | + | if e.Kind == domain.EntryDirectory { |
| 144 | return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)} | 141 | return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)} | ||
| 145 | } | 142 | } | ||
| 146 | return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)} | 143 | return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)} | ||
M internal/adapters/web/commits_test.go +19 -12
| @@ -4,14 +4,15 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | - | "net/http" | |||
| 8 | "testing" | 7 | "testing" | ||
| 9 | 8 | ||||
| 10 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 14 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 14 | + | "bnema.dev/ermite/internal/ports" |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | func TestIsCommitID(t *testing.T) { | 18 | func TestIsCommitID(t *testing.T) { | ||
| @@ -28,15 +29,21 @@ func TestIsCommitID(t *testing.T) { | |||||
| 28 | // branch other than the default one, or none for a commit. | 29 | // branch other than the default one, or none for a commit. | ||
| 29 | func TestPinRefMarksCurrent(t *testing.T) { | 30 | func TestPinRefMarksCurrent(t *testing.T) { | ||
| 30 | t.Parallel() | 31 | t.Parallel() | ||
| 31 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | 32 | + | b, tag := domain.RefKindBranch, domain.RefKindTag |
| 33 | + | name, err := domain.ParseRepoName("demo") | |||
| 34 | + | require.NoError(t, err) | |||
| 32 | d := &repoData{ | 35 | d := &repoData{ | ||
| 33 | - | repo: &pb.Repo{Name: "demo", DefaultBranch: "main"}, | 36 | + | repo: ports.RepoView{Repo: domain.Repo{Name: name}, DefaultBranch: "main"}, |
| 34 | - | branches: []*pb.Ref{ref(b, "main"), ref(b, "side")}, | 37 | + | branches: []domain.Ref{ref(b, "main"), ref(b, "side")}, |
| 35 | - | tags: []*pb.Ref{ref(tag, "v1")}, | 38 | + | tags: []domain.Ref{ref(tag, "v1")}, |
| 36 | } | 39 | } | ||
| 37 | - | d.head = d.branches[0] | 40 | + | d.head = &d.branches[0] |
| 41 | + | // The list of the other repositories is not reachable: the layout does | |||
| 42 | + | // without it. | |||
| 43 | + | repos := mocks.NewRepoService(t) | |||
| 44 | + | repos.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, errors.New("no list")) | |||
| 38 | s, err := New(Config{ | 45 | s, err := New(Config{ | ||
| 39 | - | Repos: failing{err: errors.New("no list")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 46 | + | Repos: repos, Browse: mocks.NewBrowseService(t), |
| 40 | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | 47 | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | ||
| 41 | }) | 48 | }) | ||
| 42 | require.NoError(t, err) | 49 | require.NoError(t, err) | ||
| @@ -53,9 +60,9 @@ func TestPinRefMarksCurrent(t *testing.T) { | |||||
| 53 | heads, tags []string | 60 | heads, tags []string | ||
| 54 | ref, short string | 61 | ref, short string | ||
| 55 | }{ | 62 | }{ | ||
| 56 | - | {"", []string{"main"}, nil, "main", "c-main"}, | 63 | + | {"", []string{"main"}, nil, "main", short(commitOf("main").String())}, |
| 57 | - | {"v1", nil, []string{"v1"}, "v1", "c-v1"}, | 64 | + | {"v1", nil, []string{"v1"}, "v1", short(commitOf("v1").String())}, |
| 58 | - | {"side", []string{"side"}, nil, "side", "c-side"}, | 65 | + | {"side", []string{"side"}, nil, "side", short(commitOf("side").String())}, |
| 59 | {"abcdef0123", nil, nil, "detached", "abcdef0"}, | 66 | {"abcdef0123", nil, nil, "detached", "abcdef0"}, | ||
| 60 | } { | 67 | } { | ||
| 61 | tg, ok := d.locate(tt.rest) | 68 | tg, ok := d.locate(tt.rest) | ||
M internal/adapters/web/compare.go +43 -43
| @@ -7,10 +7,10 @@ import ( | |||||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | "slices" | 8 | "slices" | ||
| 9 | 9 | ||||
| 10 | - | "connectrpc.com/connect" | |||
| 11 | "github.com/a-h/templ" | 10 | "github.com/a-h/templ" | ||
| 12 | 11 | ||||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | 12 | + | "bnema.dev/ermite/internal/domain" |
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // compareDepth bounds the commits read on each side to find where a branch | 16 | // compareDepth bounds the commits read on each side to find where a branch | ||
| @@ -27,6 +27,8 @@ const compareDepth = maxComparePages * pageSize | |||||
| 27 | // own diff is then the whole change). Otherwise it lists the commits, which | 27 | // own diff is then the whole change). Otherwise it lists the commits, which | ||
| 28 | // link to their own diffs, and says the whole diff comes with the merge | 28 | // link to their own diffs, and says the whole diff comes with the merge | ||
| 29 | // request. | 29 | // request. | ||
| 30 | + | // | |||
| 31 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the compare page from the use case results; the merge rules are the use case's | |||
| 30 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 32 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 31 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 33 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 32 | if err != nil { | 34 | if err != nil { | ||
| @@ -36,25 +38,25 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 36 | if !ok || rest != "" || d.head == nil { | 38 | if !ok || rest != "" || d.head == nil { | ||
| 37 | return nil, 0, errNotFound | 39 | return nil, 0, errNotFound | ||
| 38 | } | 40 | } | ||
| 39 | - | tgt := d.head | 41 | + | tgt := *d.head |
| 40 | name := d.name() | 42 | name := d.name() | ||
| 41 | 43 | ||||
| 42 | p := ComparePage{ | 44 | p := ComparePage{ | ||
| 43 | - | Source: visibleText(src.GetShortName()), Target: visibleText(tgt.GetShortName()), | 45 | + | Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName), |
| 44 | - | SourceHref: repoHref(name, "tree", src.GetShortName()), TargetHref: repoHref(name, "tree", tgt.GetShortName()), | 46 | + | SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName), |
| 45 | CLI: Clone{ID: "cli-create", Lines: []string{ | 47 | CLI: Clone{ID: "cli-create", Lines: []string{ | ||
| 46 | - | "ermite mr create " + shellWord(name) + " --source " + shellWord(src.GetShortName()) + " --title <title>", | 48 | + | "ermite mr create " + shellWord(name) + " --source " + shellWord(src.ShortName) + " --title <title>", |
| 47 | }}, | 49 | }}, | ||
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| 50 | - | same := src.GetShortName() == tgt.GetShortName() | 52 | + | same := src.ShortName == tgt.ShortName |
| 51 | - | var ahead []*pb.Commit | 53 | + | var ahead []domain.Commit |
| 52 | exact, behind := false, false | 54 | exact, behind := false, false | ||
| 53 | if same { | 55 | if same { | ||
| 54 | p.Note = "this is the default branch: there is nothing to compare it with." | 56 | p.Note = "this is the default branch: there is nothing to compare it with." | ||
| 55 | p.AheadText = "0 commits" | 57 | p.AheadText = "0 commits" | ||
| 56 | } else { | 58 | } else { | ||
| 57 | - | ahead, exact, behind, err = s.commitsAhead(ctx, name, src.GetCommitId(), tgt.GetCommitId()) | 59 | + | ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String()) |
| 58 | if err != nil { | 60 | if err != nil { | ||
| 59 | return nil, 0, err | 61 | return nil, 0, err | ||
| 60 | } | 62 | } | ||
| @@ -79,19 +81,19 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 79 | 81 | ||||
| 80 | // The exact diff: one commit on top of the tip of the target. | 82 | // The exact diff: one commit on top of the tip of the target. | ||
| 81 | if exact && !behind && len(ahead) == 1 { | 83 | if exact && !behind && len(ahead) == 1 { | ||
| 82 | - | res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: name, Revision: ahead[0].GetId()})) | 84 | + | res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String()) |
| 83 | if err != nil { | 85 | if err != nil { | ||
| 84 | return nil, 0, err | 86 | return nil, 0, err | ||
| 85 | } | 87 | } | ||
| 86 | p.HasDiff = true | 88 | p.HasDiff = true | ||
| 87 | - | p.Files = diffFiles(res.Msg.GetDiff().GetFiles()) | 89 | + | p.Files = diffFiles(res.Diff.Files) |
| 88 | - | p.NFiles, p.Adds, p.Dels = int(res.Msg.GetStats().GetFilesChanged()), int(res.Msg.GetStats().GetAdditions()), int(res.Msg.GetStats().GetDeletions()) | 90 | + | p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions |
| 89 | - | if n := diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles); n != "" { | 91 | + | if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" { |
| 90 | p.Note = n | 92 | p.Note = n | ||
| 91 | } | 93 | } | ||
| 92 | lines, invisible := diffLines(p.Files) | 94 | lines, invisible := diffLines(p.Files) | ||
| 93 | p.Invisible = invisible | 95 | p.Invisible = invisible | ||
| 94 | - | self := repoHref(name, "compare", src.GetShortName()) | 96 | + | self := repoHref(name, "compare", src.ShortName) |
| 95 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | 97 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | ||
| 96 | } else if len(ahead) > 1 && exact { | 98 | } else if len(ahead) > 1 && exact { | ||
| 97 | if p.Note == "" { | 99 | if p.Note == "" { | ||
| @@ -100,32 +102,32 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 100 | } | 102 | } | ||
| 101 | 103 | ||||
| 102 | // An open request for the same pair, or the form to open one. | 104 | // An open request for the same pair, or the form to open one. | ||
| 103 | - | var existing *pb.MergeRequest | 105 | + | var existing *domain.MergeRequest |
| 104 | if !same { | 106 | if !same { | ||
| 105 | - | existing, err = s.openRequestFor(ctx, name, src.GetShortName(), tgt.GetShortName()) | 107 | + | existing, err = s.openRequestFor(ctx, d.repo.Name, src.ShortName, tgt.ShortName) |
| 106 | if err != nil { | 108 | if err != nil { | ||
| 107 | return nil, 0, err | 109 | return nil, 0, err | ||
| 108 | } | 110 | } | ||
| 109 | } | 111 | } | ||
| 110 | switch { | 112 | switch { | ||
| 111 | case existing != nil: | 113 | case existing != nil: | ||
| 112 | - | p.Existing = &Link{Label: "!" + itoa(int(existing.GetNumber())) + " " + visibleText(existing.GetTitle()), Href: mrHref(name, existing.GetNumber())} | 114 | + | p.Existing = &Link{Label: "!" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)} |
| 113 | case SignedIn(ctx) && !same && len(ahead) > 0: | 115 | case SignedIn(ctx) && !same && len(ahead) > 0: | ||
| 114 | title := "" | 116 | title := "" | ||
| 115 | if len(ahead) == 1 { | 117 | if len(ahead) == 1 { | ||
| 116 | - | title = ahead[0].GetSummary() | 118 | + | title = ahead[0].Summary |
| 117 | } else { | 119 | } else { | ||
| 118 | - | title = ahead[len(ahead)-1].GetSummary() | 120 | + | title = ahead[len(ahead)-1].Summary |
| 119 | } | 121 | } | ||
| 120 | p.Create = &CreateForm{ | 122 | p.Create = &CreateForm{ | ||
| 121 | - | Action: mrBase(name), Source: src.GetShortName(), Target: tgt.GetShortName(), | 123 | + | Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName, |
| 122 | Title: cutRunes(title, 120), | 124 | Title: cutRunes(title, 120), | ||
| 123 | } | 125 | } | ||
| 124 | } | 126 | } | ||
| 125 | 127 | ||||
| 126 | l := s.repoLayout(ctx, d, "merge-requests") | 128 | l := s.repoLayout(ctx, d, "merge-requests") | ||
| 127 | l.Title = "compare " + p.Source + " · " + name + " · " + s.instance | 129 | l.Title = "compare " + p.Source + " · " + name + " · " + s.instance | ||
| 128 | - | pinRef(&l, target{rev: src.GetCommitId(), name: src.GetShortName()}) | 130 | + | pinRef(&l, target{rev: src.CommitID.String(), name: src.ShortName}) |
| 129 | l.Crumbs[0].Current = false | 131 | l.Crumbs[0].Current = false | ||
| 130 | l.Crumbs = append(l.Crumbs, | 132 | l.Crumbs = append(l.Crumbs, | ||
| 131 | Crumb{Label: "merge requests", Href: mrBase(name)}, | 133 | Crumb{Label: "merge requests", Href: mrBase(name)}, | ||
| @@ -140,67 +142,65 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 140 | // first. exact is false when the walk gave up before finding where the source | 142 | // first. exact is false when the walk gave up before finding where the source | ||
| 141 | // meets the target. behind is true when the target has commits the source | 143 | // meets the target. behind is true when the target has commits the source | ||
| 142 | // lacks (the tip of the target is not an ancestor of the source). | 144 | // lacks (the tip of the target is not an ancestor of the source). | ||
| 143 | - | func (s *Server) commitsAhead(ctx context.Context, repo, srcTip, tgtTip string) (ahead []*pb.Commit, exact, behind bool, err error) { | 145 | + | // |
| 146 | + | //hexcheck:ignore suspicious-business-logic-in-adapter walks the history pages the use case returns to find where the branch left its target; the page needs it, the forge decides nothing | |||
| 147 | + | func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) { | |||
| 144 | if srcTip == tgtTip { | 148 | if srcTip == tgtTip { | ||
| 145 | return nil, true, false, nil | 149 | return nil, true, false, nil | ||
| 146 | } | 150 | } | ||
| 147 | onTarget := map[string]bool{} | 151 | onTarget := map[string]bool{} | ||
| 148 | tokenT := "" | 152 | tokenT := "" | ||
| 149 | for len(onTarget) < compareDepth { | 153 | for len(onTarget) < compareDepth { | ||
| 150 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 154 | + | res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT) |
| 151 | - | Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT, | |||
| 152 | - | })) | |||
| 153 | if err != nil { | 155 | if err != nil { | ||
| 154 | return nil, false, false, err | 156 | return nil, false, false, err | ||
| 155 | } | 157 | } | ||
| 156 | - | for _, c := range res.Msg.GetCommits() { | 158 | + | for _, c := range res.Commits { |
| 157 | - | onTarget[c.GetId()] = true | 159 | + | onTarget[c.ID.String()] = true |
| 158 | } | 160 | } | ||
| 159 | - | if tokenT = res.Msg.GetNextPageToken(); tokenT == "" { | 161 | + | if tokenT = res.NextPageToken; tokenT == "" { |
| 160 | break | 162 | break | ||
| 161 | } | 163 | } | ||
| 162 | } | 164 | } | ||
| 163 | token := "" | 165 | token := "" | ||
| 164 | for n := 0; n < compareDepth; { | 166 | for n := 0; n < compareDepth; { | ||
| 165 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 167 | + | res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token) |
| 166 | - | Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token, | |||
| 167 | - | })) | |||
| 168 | if err != nil { | 168 | if err != nil { | ||
| 169 | return nil, false, false, err | 169 | return nil, false, false, err | ||
| 170 | } | 170 | } | ||
| 171 | - | for _, c := range res.Msg.GetCommits() { | 171 | + | for _, c := range res.Commits { |
| 172 | n++ | 172 | n++ | ||
| 173 | - | if onTarget[c.GetId()] { | 173 | + | if onTarget[c.ID.String()] { |
| 174 | // The first commit of the source that the target has: where the | 174 | // The first commit of the source that the target has: where the | ||
| 175 | // branch left it. The target moved on when this is not its tip. | 175 | // branch left it. The target moved on when this is not its tip. | ||
| 176 | - | return ahead, true, c.GetId() != tgtTip, nil | 176 | + | return ahead, true, c.ID.String() != tgtTip, nil |
| 177 | } | 177 | } | ||
| 178 | ahead = append(ahead, c) | 178 | ahead = append(ahead, c) | ||
| 179 | } | 179 | } | ||
| 180 | - | if token = res.Msg.GetNextPageToken(); token == "" { | 180 | + | if token = res.NextPageToken; token == "" { |
| 181 | // The whole history of the source is ahead: no common history. | 181 | // The whole history of the source is ahead: no common history. | ||
| 182 | return ahead, true, true, nil | 182 | return ahead, true, true, nil | ||
| 183 | } | 183 | } | ||
| 184 | } | 184 | } | ||
| 185 | - | return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil | 185 | + | return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil |
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | // openRequestFor finds the open merge request with this source and target. | 188 | // openRequestFor finds the open merge request with this source and target. | ||
| 189 | - | func (s *Server) openRequestFor(ctx context.Context, repo, source, target string) (*pb.MergeRequest, error) { | 189 | + | func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) { |
| 190 | token := "" | 190 | token := "" | ||
| 191 | for range maxOpenPages { | 191 | for range maxOpenPages { | ||
| 192 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | 192 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{ |
| 193 | - | Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token, | 193 | + | State: domain.MRStateOpen, PageSize: pageSize, PageToken: token, |
| 194 | - | })) | 194 | + | }) |
| 195 | if err != nil { | 195 | if err != nil { | ||
| 196 | return nil, err | 196 | return nil, err | ||
| 197 | } | 197 | } | ||
| 198 | - | for _, m := range res.Msg.GetMergeRequests() { | 198 | + | for i := range res.MergeRequests { |
| 199 | - | if m.GetSourceBranch() == source && m.GetTargetBranch() == target { | 199 | + | if m := &res.MergeRequests[i]; m.Source == source && m.Target == target { |
| 200 | return m, nil | 200 | return m, nil | ||
| 201 | } | 201 | } | ||
| 202 | } | 202 | } | ||
| 203 | - | if token = res.Msg.GetNextPageToken(); token == "" { | 203 | + | if token = res.NextPageToken; token == "" { |
| 204 | break | 204 | break | ||
| 205 | } | 205 | } | ||
| 206 | } | 206 | } | ||
M internal/adapters/web/compress_test.go +3 -8
| @@ -18,7 +18,7 @@ import ( | |||||
| 18 | "github.com/stretchr/testify/assert" | 18 | "github.com/stretchr/testify/assert" | ||
| 19 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 20 | 20 | ||||
| 21 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 21 | + | "bnema.dev/ermite/internal/ports/mocks" |
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| 24 | func TestAcceptsGzip(t *testing.T) { | 24 | func TestAcceptsGzip(t *testing.T) { | ||
| @@ -99,12 +99,7 @@ func TestPageETag(t *testing.T) { | |||||
| 99 | 99 | ||||
| 100 | func newBareServer(t *testing.T) *Server { | 100 | func newBareServer(t *testing.T) *Server { | ||
| 101 | t.Helper() | 101 | t.Helper() | ||
| 102 | - | srv, err := New(Config{ | 102 | + | return newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)}) |
| 103 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 104 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 105 | - | }) | |||
| 106 | - | require.NoError(t, err) | |||
| 107 | - | return srv | |||
| 108 | } | 103 | } | ||
| 109 | 104 | ||||
| 110 | func gunzip(t *testing.T, b []byte) string { | 105 | func gunzip(t *testing.T, b []byte) string { | ||
| @@ -318,7 +313,7 @@ func TestAssetsGzip(t *testing.T) { | |||||
| 318 | // The event stream is never compressed, even for a client that accepts gzip. | 313 | // The event stream is never compressed, even for a client that accepts gzip. | ||
| 319 | func TestEventsNotCompressed(t *testing.T) { | 314 | func TestEventsNotCompressed(t *testing.T) { | ||
| 320 | t.Parallel() | 315 | t.Parallel() | ||
| 321 | - | f := &fakeEvents{release: make(chan struct{})} | 316 | + | f := &watchProbe{release: make(chan struct{})} |
| 322 | ts := eventsServer(t, f, 20*time.Millisecond, 0) | 317 | ts := eventsServer(t, f, 20*time.Millisecond, 0) | ||
| 323 | resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"}) | 318 | resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"}) | ||
| 324 | require.Equal(t, http.StatusOK, resp.StatusCode) | 319 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
M internal/adapters/web/diff.go +50 -50
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/a-h/templ" | 14 | "github.com/a-h/templ" | ||
| 15 | 15 | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | 16 | + | "bnema.dev/ermite/internal/domain" |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // Seg is a piece of a line of code: plain text, or the visible marker of an | 19 | // Seg is a piece of a line of code: plain text, or the visible marker of an | ||
| @@ -101,11 +101,11 @@ type DiffFile struct { | |||||
| 101 | Hunks []DiffHunk | 101 | Hunks []DiffHunk | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | func lineNumber(n int32) string { | 104 | + | func lineNumber(n int) string { |
| 105 | if n <= 0 { | 105 | if n <= 0 { | ||
| 106 | return "" | 106 | return "" | ||
| 107 | } | 107 | } | ||
| 108 | - | return itoa(int(n)) | 108 | + | return itoa(n) |
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | // segments splits a line of code into plain text and markers. One trailing | 111 | // segments splits a line of code into plain text and markers. One trailing | ||
| @@ -197,79 +197,79 @@ func changeBar(add, del, most int) Bar { | |||||
| 197 | } | 197 | } | ||
| 198 | 198 | ||||
| 199 | // statusLetter is the one-letter status of a file. | 199 | // statusLetter is the one-letter status of a file. | ||
| 200 | - | func statusLetter(s pb.FileStatus) string { | 200 | + | func statusLetter(s domain.FileStatus) string { |
| 201 | switch s { | 201 | switch s { | ||
| 202 | - | case pb.FileStatus_FILE_STATUS_ADDED: | 202 | + | case domain.FileAdded: |
| 203 | return "A" | 203 | return "A" | ||
| 204 | - | case pb.FileStatus_FILE_STATUS_DELETED: | 204 | + | case domain.FileDeleted: |
| 205 | return "D" | 205 | return "D" | ||
| 206 | - | case pb.FileStatus_FILE_STATUS_RENAMED: | 206 | + | case domain.FileRenamed: |
| 207 | return "R" | 207 | return "R" | ||
| 208 | - | case pb.FileStatus_FILE_STATUS_COPIED: | 208 | + | case domain.FileCopied: |
| 209 | return "C" | 209 | return "C" | ||
| 210 | - | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | 210 | + | case domain.FileTypeChanged: |
| 211 | return "T" | 211 | return "T" | ||
| 212 | } | 212 | } | ||
| 213 | return "M" | 213 | return "M" | ||
| 214 | } | 214 | } | ||
| 215 | 215 | ||||
| 216 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | 216 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | ||
| 217 | - | func hunkHeader(h *pb.Hunk) string { | 217 | + | func hunkHeader(h domain.Hunk) string { |
| 218 | - | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines()) | 218 | + | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines) |
| 219 | - | if t := h.GetHeader(); t != "" { | 219 | + | if t := h.Header; t != "" { |
| 220 | s += " " + visibleText(strings.TrimSpace(t)) | 220 | s += " " + visibleText(strings.TrimSpace(t)) | ||
| 221 | } | 221 | } | ||
| 222 | return s | 222 | return s | ||
| 223 | } | 223 | } | ||
| 224 | 224 | ||||
| 225 | - | // diffLine converts a line of the API. | 225 | + | // diffLine converts a line of a hunk. |
| 226 | - | func diffLine(l *pb.Line) DiffLine { | 226 | + | func diffLine(l domain.Line) DiffLine { |
| 227 | - | dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())} | 227 | + | dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber} |
| 228 | - | switch l.GetKind() { | 228 | + | switch l.Kind { |
| 229 | - | case pb.LineKind_LINE_KIND_ADDITION: | 229 | + | case domain.LineAddition: |
| 230 | - | dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber()) | 230 | + | dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber) |
| 231 | - | case pb.LineKind_LINE_KIND_DELETION: | 231 | + | case domain.LineDeletion: |
| 232 | - | dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber()) | 232 | + | dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber) |
| 233 | default: | 233 | default: | ||
| 234 | dl.Kind, dl.Mark = "ctx", " " | 234 | dl.Kind, dl.Mark = "ctx", " " | ||
| 235 | - | dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber()) | 235 | + | dl.Old, dl.New = lineNumber(l.OldNumber), lineNumber(l.NewNumber) |
| 236 | } | 236 | } | ||
| 237 | return dl | 237 | return dl | ||
| 238 | } | 238 | } | ||
| 239 | 239 | ||||
| 240 | // diffFiles converts the files of a diff for the page. | 240 | // diffFiles converts the files of a diff for the page. | ||
| 241 | - | func diffFiles(files []*pb.FileDiff) []DiffFile { | 241 | + | func diffFiles(files []domain.FileDiff) []DiffFile { |
| 242 | most := 0 | 242 | most := 0 | ||
| 243 | for _, f := range files { | 243 | for _, f := range files { | ||
| 244 | - | most = max(most, int(f.GetAdditions()+f.GetDeletions())) | 244 | + | most = max(most, f.Additions+f.Deletions) |
| 245 | } | 245 | } | ||
| 246 | out := make([]DiffFile, 0, len(files)) | 246 | out := make([]DiffFile, 0, len(files)) | ||
| 247 | for i, f := range files { | 247 | for i, f := range files { | ||
| 248 | df := DiffFile{ | 248 | df := DiffFile{ | ||
| 249 | - | ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()), | 249 | + | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), |
| 250 | - | Add: int(f.GetAdditions()), Del: int(f.GetDeletions()), | 250 | + | Add: f.Additions, Del: f.Deletions, |
| 251 | - | Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most), | 251 | + | Bar: changeBar(f.Additions, f.Deletions, most), |
| 252 | // A file that is shown in full opens; a file with nothing to read | 252 | // A file that is shown in full opens; a file with nothing to read | ||
| 253 | // (binary, truncated) stays closed under its note. | 253 | // (binary, truncated) stays closed under its note. | ||
| 254 | - | Open: len(f.GetHunks()) > 0 && !f.GetTruncated(), | 254 | + | Open: len(f.Hunks) > 0 && !f.Truncated, |
| 255 | } | 255 | } | ||
| 256 | - | df.Key = f.GetNewPath() | 256 | + | df.Key = f.NewPath |
| 257 | - | switch f.GetStatus() { | 257 | + | switch f.Status { |
| 258 | - | case pb.FileStatus_FILE_STATUS_DELETED: | 258 | + | case domain.FileDeleted: |
| 259 | - | df.Key = f.GetOldPath() | 259 | + | df.Key = f.OldPath |
| 260 | - | df.Path = visibleText(f.GetOldPath()) | 260 | + | df.Path = visibleText(f.OldPath) |
| 261 | - | case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED: | 261 | + | case domain.FileRenamed, domain.FileCopied: |
| 262 | - | df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath()) | 262 | + | df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath) |
| 263 | default: | 263 | default: | ||
| 264 | - | df.Path = visibleText(f.GetNewPath()) | 264 | + | df.Path = visibleText(f.NewPath) |
| 265 | } | 265 | } | ||
| 266 | df.Note = fileNote(f) | 266 | df.Note = fileNote(f) | ||
| 267 | - | if f.GetBinary() { | 267 | + | if f.Binary { |
| 268 | df.Bar = Bar{Rest: "bin"} | 268 | df.Bar = Bar{Rest: "bin"} | ||
| 269 | } | 269 | } | ||
| 270 | - | for _, h := range f.GetHunks() { | 270 | + | for _, h := range f.Hunks { |
| 271 | dh := DiffHunk{Header: hunkHeader(h)} | 271 | dh := DiffHunk{Header: hunkHeader(h)} | ||
| 272 | - | for _, l := range h.GetLines() { | 272 | + | for _, l := range h.Lines { |
| 273 | dh.Lines = append(dh.Lines, diffLine(l)) | 273 | dh.Lines = append(dh.Lines, diffLine(l)) | ||
| 274 | } | 274 | } | ||
| 275 | dh.Split = pairLines(dh.Lines) | 275 | dh.Split = pairLines(dh.Lines) | ||
| @@ -282,23 +282,23 @@ func diffFiles(files []*pb.FileDiff) []DiffFile { | |||||
| 282 | 282 | ||||
| 283 | // fileNote says what the hunks cannot: the kind of change when there is no | 283 | // fileNote says what the hunks cannot: the kind of change when there is no | ||
| 284 | // text to show, the mode change, the similarity of a rename. | 284 | // text to show, the mode change, the similarity of a rename. | ||
| 285 | - | func fileNote(f *pb.FileDiff) string { | 285 | + | func fileNote(f domain.FileDiff) string { |
| 286 | var parts []string | 286 | var parts []string | ||
| 287 | switch { | 287 | switch { | ||
| 288 | - | case f.GetBinary(): | 288 | + | case f.Binary: |
| 289 | parts = append(parts, "binary file: no text diff") | 289 | parts = append(parts, "binary file: no text diff") | ||
| 290 | - | case f.GetTruncated(): | 290 | + | case f.Truncated: |
| 291 | - | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions())) | 291 | + | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions)) |
| 292 | } | 292 | } | ||
| 293 | - | if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() { | 293 | + | if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode { |
| 294 | - | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode())) | 294 | + | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode)) |
| 295 | } | 295 | } | ||
| 296 | - | switch f.GetStatus() { | 296 | + | switch f.Status { |
| 297 | - | case pb.FileStatus_FILE_STATUS_RENAMED: | 297 | + | case domain.FileRenamed: |
| 298 | - | parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity())) | 298 | + | parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity)) |
| 299 | - | case pb.FileStatus_FILE_STATUS_COPIED: | 299 | + | case domain.FileCopied: |
| 300 | - | parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity())) | 300 | + | parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity)) |
| 301 | - | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | 301 | + | case domain.FileTypeChanged: |
| 302 | parts = append(parts, "type changed") | 302 | parts = append(parts, "type changed") | ||
| 303 | } | 303 | } | ||
| 304 | return strings.Join(parts, " · ") | 304 | return strings.Join(parts, " · ") | ||
M internal/adapters/web/diff_test.go +36 -16
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // Trojan Source (ADR 0017): direction controls, zero-width and control | 16 | // Trojan Source (ADR 0017): direction controls, zero-width and control | ||
| @@ -45,8 +45,8 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) { | |||||
| 45 | assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n")) | 45 | assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n")) | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | - | func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line { | 48 | + | func line(kind domain.LineKind, oldN, newN int, text string) domain.Line { |
| 49 | - | return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text} | 49 | + | return domain.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text} |
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | // The split view lines a run of deletions up with the additions after it; | 52 | // The split view lines a run of deletions up with the additions after it; | ||
| @@ -54,9 +54,9 @@ func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line { | |||||
| 54 | // opposite side. | 54 | // opposite side. | ||
| 55 | func TestPairLines(t *testing.T) { | 55 | func TestPairLines(t *testing.T) { | ||
| 56 | t.Parallel() | 56 | t.Parallel() | ||
| 57 | - | ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION | 57 | + | ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion |
| 58 | var ls []DiffLine | 58 | var ls []DiffLine | ||
| 59 | - | for _, l := range []*pb.Line{ | 59 | + | for _, l := range []domain.Line{ |
| 60 | line(ctx, 1, 1, "c"), | 60 | line(ctx, 1, 1, "c"), | ||
| 61 | line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"), | 61 | line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"), | ||
| 62 | line(add, 0, 2, "a1"), | 62 | line(add, 0, 2, "a1"), | ||
| @@ -96,20 +96,40 @@ func TestChangeBar(t *testing.T) { | |||||
| 96 | 96 | ||||
| 97 | func TestFileNotes(t *testing.T) { | 97 | func TestFileNotes(t *testing.T) { | ||
| 98 | t.Parallel() | 98 | t.Parallel() | ||
| 99 | - | assert.Equal(t, "binary file: no text diff", fileNote(&pb.FileDiff{Binary: true, Status: pb.FileStatus_FILE_STATUS_MODIFIED})) | 99 | + | tests := []struct { |
| 100 | - | assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755})) | 100 | + | name string |
| 101 | - | assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change") | 101 | + | file domain.FileDiff |
| 102 | - | assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644})) | 102 | + | want string // a substring; empty means no note at all |
| 103 | - | assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large") | 103 | + | eq bool // want is the whole note |
| 104 | + | }{ | |||
| 105 | + | {"binary", domain.FileDiff{Binary: true, Status: domain.FileModified}, "binary file: no text diff", true}, | |||
| 106 | + | {"mode change", domain.FileDiff{Status: domain.FileModified, OldMode: 0o100644, NewMode: 0o100755}, "mode 100644 → 100755", true}, | |||
| 107 | + | {"an added file has no mode change", domain.FileDiff{Status: domain.FileAdded, NewMode: 0o100644}, "", true}, | |||
| 108 | + | {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true}, | |||
| 109 | + | {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true}, | |||
| 110 | + | {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true}, | |||
| 111 | + | {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false}, | |||
| 112 | + | } | |||
| 113 | + | for _, tt := range tests { | |||
| 114 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 115 | + | t.Parallel() | |||
| 116 | + | got := fileNote(tt.file) | |||
| 117 | + | if tt.eq { | |||
| 118 | + | assert.Equal(t, tt.want, got) | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | assert.Contains(t, got, tt.want) | |||
| 122 | + | }) | |||
| 123 | + | } | |||
| 104 | } | 124 | } | ||
| 105 | 125 | ||||
| 106 | func TestDiffFilesPaths(t *testing.T) { | 126 | func TestDiffFilesPaths(t *testing.T) { | ||
| 107 | t.Parallel() | 127 | t.Parallel() | ||
| 108 | - | fs := diffFiles([]*pb.FileDiff{ | 128 | + | fs := diffFiles([]domain.FileDiff{ |
| 109 | - | {Status: pb.FileStatus_FILE_STATUS_ADDED, NewPath: "a.go", Additions: 1, Hunks: []*pb.Hunk{{Lines: []*pb.Line{line(pb.LineKind_LINE_KIND_ADDITION, 0, 1, "x")}}}}, | 129 | + | {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}}, |
| 110 | - | {Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1}, | 130 | + | {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1}, |
| 111 | - | {Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"}, | 131 | + | {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"}, |
| 112 | - | {Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000}, | 132 | + | {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000}, |
| 113 | }) | 133 | }) | ||
| 114 | assert.Equal(t, "a.go", fs[0].Path) | 134 | assert.Equal(t, "a.go", fs[0].Path) | ||
| 115 | assert.True(t, fs[0].Open) | 135 | assert.True(t, fs[0].Open) | ||
| @@ -125,7 +145,7 @@ func TestDiffFilesPaths(t *testing.T) { | |||||
| 125 | func TestCodeLine(t *testing.T) { | 145 | func TestCodeLine(t *testing.T) { | ||
| 126 | t.Parallel() | 146 | t.Parallel() | ||
| 127 | var b strings.Builder | 147 | var b strings.Builder | ||
| 128 | - | ln := diffLine(&pb.Line{Kind: pb.LineKind_LINE_KIND_ADDITION, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEof: true}) | 148 | + | ln := diffLine(domain.Line{Kind: domain.LineAddition, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEOF: true}) |
| 129 | require.NoError(t, codeLine(ln).Render(context.Background(), &b)) | 149 | require.NoError(t, codeLine(ln).Render(context.Background(), &b)) | ||
| 130 | assert.Equal(t, `a <b> <span class="inv" title="invisible character"><U+202E></span> c d`+ | 150 | assert.Equal(t, `a <b> <span class="inv" title="invisible character"><U+202E></span> c d`+ | ||
| 131 | `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String()) | 151 | `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String()) | ||
M internal/adapters/web/errors_test.go +141 -48
| @@ -6,41 +6,27 @@ import ( | |||||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | + | "fmt" | |||
| 9 | "net/http" | 10 | "net/http" | ||
| 10 | "net/http/httptest" | 11 | "net/http/httptest" | ||
| 11 | "regexp" | 12 | "regexp" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | 14 | ||||
| 14 | - | "connectrpc.com/connect" | |||
| 15 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 16 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 17 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 18 | 19 | ||||
| 19 | - | pb "bnema.dev/ermite-api/gen/ermite" | 20 | + | "bnema.dev/ermite/internal/domain" |
| 20 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 21 | + | "bnema.dev/ermite/internal/ports" |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 21 | ) | 23 | ) | ||
| 22 | 24 | ||||
| 23 | - | // failing is a repository client that fails every call with err. | 25 | + | // serve asks the web for path while every read of a repository fails with err, |
| 24 | - | type failing struct { | 26 | + | // and returns the answer and what was logged. |
| 25 | - | ermiteconnect.RepoServiceClient | |||
| 26 | - | err error | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func (f failing) ListRepos(context.Context, *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | |||
| 30 | - | return nil, f.err | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func (f failing) GetRepo(context.Context, *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |||
| 34 | - | return nil, f.err | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) { | 27 | func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) { | ||
| 38 | t.Helper() | 28 | t.Helper() | ||
| 39 | - | srv, nerr := New(Config{ | 29 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, err), Browse: mocks.NewBrowseService(t)}) |
| 40 | - | Repos: failing{err: err}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 41 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 42 | - | }) | |||
| 43 | - | require.NoError(t, nerr) | |||
| 44 | var logs bytes.Buffer | 30 | var logs bytes.Buffer | ||
| 45 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | 31 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | ||
| 46 | rec := httptest.NewRecorder() | 32 | rec := httptest.NewRecorder() | ||
| @@ -48,39 +34,146 @@ func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, st | |||||
| 48 | return rec, logs.String() | 34 | return rec, logs.String() | ||
| 49 | } | 35 | } | ||
| 50 | 36 | ||||
| 51 | - | // Errors of the API: not found (and an invalid name) are the 404 page, any | 37 | + | // Errors of the use cases: not found (and an invalid name) are the 404 page, a |
| 52 | - | // other is the 500 page with an id that the log carries. | 38 | + | // visitor that left gets nothing, any other is the 500 page with an id that |
| 39 | + | // the log carries. | |||
| 53 | func TestErrorPages(t *testing.T) { | 40 | func TestErrorPages(t *testing.T) { | ||
| 54 | t.Parallel() | 41 | t.Parallel() | ||
| 55 | - | rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone") | 42 | + | tests := []struct { |
| 56 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | 43 | + | name string |
| 57 | - | assert.Contains(t, rec.Body.String(), "not found") | 44 | + | err error |
| 58 | - | assert.Empty(t, logs, "a 404 is not an error") | 45 | + | path string |
| 46 | + | want int | |||
| 47 | + | }{ | |||
| 48 | + | {"not found", fmt.Errorf("repository: %w", domain.ErrNotFound), "/gone", http.StatusNotFound}, | |||
| 49 | + | {"invalid repository name", domain.ErrInvalidName, "/Bad", http.StatusNotFound}, | |||
| 50 | + | {"invalid argument", domain.ErrInvalid, "/demo", http.StatusNotFound}, | |||
| 51 | + | {"invalid page token", domain.ErrInvalidPageToken, "/demo", http.StatusNotFound}, | |||
| 52 | + | {"invalid revision", domain.ErrInvalidRevision, "/demo", http.StatusNotFound}, | |||
| 53 | + | {"invalid path", domain.ErrInvalidPath, "/demo", http.StatusNotFound}, | |||
| 54 | + | {"internal", errors.New("dial tcp 10.0.0.5: kv exploded"), "/demo", http.StatusInternalServerError}, | |||
| 55 | + | {"internal on the home page", errors.New("kv exploded on the list"), "/", http.StatusInternalServerError}, | |||
| 56 | + | {"forbidden is not a page of its own", domain.ErrForbidden, "/demo", http.StatusInternalServerError}, | |||
| 57 | + | {"resource exhausted", domain.ErrResourceExhausted, "/demo", http.StatusInternalServerError}, | |||
| 58 | + | {"deadline", context.DeadlineExceeded, "/demo", http.StatusInternalServerError}, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | rec, logs := serve(t, tt.err, tt.path) | |||
| 64 | + | assert.Equal(t, tt.want, rec.Code) | |||
| 65 | + | if tt.want == http.StatusNotFound { | |||
| 66 | + | assert.Contains(t, rec.Body.String(), "not found") | |||
| 67 | + | assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays out of the page") | |||
| 68 | + | assert.Empty(t, logs, "a 404 is not an error") | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |||
| 72 | + | assert.Contains(t, rec.Body.String(), "internal error") | |||
| 73 | + | assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays in the log") | |||
| 74 | + | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | |||
| 75 | + | require.Len(t, m, 2, "the page shows a request id") | |||
| 76 | + | assert.Contains(t, logs, m[1], "the log carries the same id") | |||
| 77 | + | assert.Contains(t, logs, tt.err.Error()) | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 59 | 81 | ||||
| 60 | - | rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad") | 82 | + | // A visitor that left gets no page at all. |
| 61 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | 83 | + | func TestCanceledRequestGetsNoPage(t *testing.T) { |
| 62 | - | assert.NotContains(t, rec.Body.String(), "bad name") | 84 | + | t.Parallel() |
| 85 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, context.Canceled), Browse: mocks.NewBrowseService(t)}) | |||
| 86 | + | rec := httptest.NewRecorder() | |||
| 87 | + | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/demo", nil)) | |||
| 88 | + | assert.Equal(t, http.StatusOK, rec.Code, "nothing was written") | |||
| 89 | + | assert.Empty(t, rec.Body.String()) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // The caller of every use case call is the principal of the request: anonymous | |||
| 93 | + | // without a valid session, the owner with one. A private repository is as | |||
| 94 | + | // missing for the first as one that does not exist (the 404 page, never a 403). | |||
| 95 | + | func TestUseCasesGetThePrincipalOfTheRequest(t *testing.T) { | |||
| 96 | + | t.Parallel() | |||
| 97 | + | const token = "tok" | |||
| 98 | + | tests := []struct { | |||
| 99 | + | name string | |||
| 100 | + | cookie string | |||
| 101 | + | want domain.Principal | |||
| 102 | + | }{ | |||
| 103 | + | {"no cookie is anonymous", "", domain.Anonymous()}, | |||
| 104 | + | {"an invalid session is anonymous", "bad", domain.Anonymous()}, | |||
| 105 | + | {"a valid session is the owner", token, domain.Owner()}, | |||
| 106 | + | } | |||
| 107 | + | for _, tt := range tests { | |||
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 109 | + | t.Parallel() | |||
| 110 | + | repos := mocks.NewRepoService(t) | |||
| 111 | + | // The use case answers with the principal it was given: only the | |||
| 112 | + | // owner sees the private repository. | |||
| 113 | + | repos.EXPECT().Get(mock.Anything, tt.want, repoName(t, "secret")).RunAndReturn( | |||
| 114 | + | func(_ context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 115 | + | if !p.IsRoot() { | |||
| 116 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 117 | + | } | |||
| 118 | + | return domain.Repo{Name: name, Visibility: domain.VisibilityPrivate}, nil | |||
| 119 | + | }).Once() | |||
| 120 | + | if tt.want.IsOwner() { | |||
| 121 | + | repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "secret"), nil).Once() | |||
| 122 | + | repos.EXPECT().ListPage(mock.Anything, tt.want, mock.Anything, mock.Anything).Return(ports.RepoPage{}, nil).Once() | |||
| 123 | + | } | |||
| 124 | + | browse := mocks.NewBrowseService(t) | |||
| 125 | + | if tt.want.IsOwner() { | |||
| 126 | + | browse.EXPECT().ListRefs(mock.Anything, tt.want, repoName(t, "secret"), mock.Anything).Return(ports.RefPage{}, nil).Once() | |||
| 127 | + | } | |||
| 128 | + | srv := newTestServer(t, Config{Repos: repos, Browse: browse, Sessions: newSessions(t, token), Limiter: &countingLimiter{}}) | |||
| 129 | + | ||||
| 130 | + | req := httptest.NewRequest(http.MethodGet, "/secret/refs", nil) | |||
| 131 | + | if tt.cookie != "" { | |||
| 132 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: tt.cookie}) | |||
| 133 | + | } | |||
| 134 | + | rec := httptest.NewRecorder() | |||
| 135 | + | srv.ServeHTTP(rec, req) | |||
| 63 | 136 | ||||
| 64 | - | secret := "dial tcp 10.0.0.5: kv exploded" | 137 | + | if tt.want.IsOwner() { |
| 65 | - | rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo") | 138 | + | assert.Equal(t, http.StatusOK, rec.Code) |
| 66 | - | assert.Equal(t, http.StatusInternalServerError, rec.Code) | 139 | + | return |
| 67 | - | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | 140 | + | } |
| 68 | - | assert.Contains(t, rec.Body.String(), "internal error") | 141 | + | assert.Equal(t, http.StatusNotFound, rec.Code, "a private repository is a missing one") |
| 69 | - | assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log") | 142 | + | assert.Contains(t, rec.Body.String(), "not found") |
| 70 | - | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | 143 | + | }) |
| 71 | - | require.Len(t, m, 2, "the page shows a request id") | 144 | + | } |
| 72 | - | assert.Contains(t, logs, m[1], "the log carries the same id") | 145 | + | } |
| 73 | - | assert.Contains(t, logs, "kv exploded") | 146 | + | |
| 147 | + | // A request that arrives with a principal in its context is anonymous anyway: | |||
| 148 | + | // only a valid session makes the owner. | |||
| 149 | + | func TestRequestContextCannotSetThePrincipal(t *testing.T) { | |||
| 150 | + | t.Parallel() | |||
| 151 | + | repos := mocks.NewRepoService(t) | |||
| 152 | + | repos.EXPECT().Get(mock.Anything, domain.Anonymous(), repoName(t, "secret")).Return(domain.Repo{}, domain.ErrNotFound).Once() | |||
| 153 | + | srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t)}) | |||
| 74 | 154 | ||||
| 75 | - | rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/") | 155 | + | ctx := domain.ContextWithPrincipal(context.Background(), domain.Root()) |
| 76 | - | assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too") | 156 | + | rec := httptest.NewRecorder() |
| 157 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/secret", nil)) | |||
| 158 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 77 | } | 159 | } | ||
| 78 | 160 | ||||
| 79 | func TestNewValidates(t *testing.T) { | 161 | func TestNewValidates(t *testing.T) { | ||
| 80 | t.Parallel() | 162 | t.Parallel() | ||
| 81 | - | _, err := New(Config{PublicURL: "https://forge.example"}) | 163 | + | tests := []struct { |
| 82 | - | assert.Error(t, err, "clients are required") | 164 | + | name string |
| 83 | - | git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid") | 165 | + | cfg Config |
| 84 | - | _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"}) | 166 | + | }{ |
| 85 | - | assert.Error(t, err) | 167 | + | {"use cases are required", Config{PublicURL: testPublicURL}}, |
| 168 | + | {"browse is required", Config{Repos: mocks.NewRepoService(t), PublicURL: testPublicURL}}, | |||
| 169 | + | {"repos are required", Config{Browse: mocks.NewBrowseService(t), PublicURL: testPublicURL}}, | |||
| 170 | + | {"the public URL must be one", Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t), PublicURL: "not a url"}}, | |||
| 171 | + | } | |||
| 172 | + | for _, tt := range tests { | |||
| 173 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 174 | + | t.Parallel() | |||
| 175 | + | _, err := New(tt.cfg) | |||
| 176 | + | assert.Error(t, err) | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 86 | } | 179 | } | ||
M internal/adapters/web/events.go +58 -44
| @@ -10,17 +10,19 @@ import ( | |||||
| 10 | "net/url" | 10 | "net/url" | ||
| 11 | "strconv" | 11 | "strconv" | ||
| 12 | "strings" | 12 | "strings" | ||
| 13 | + | "sync" | |||
| 13 | "time" | 14 | "time" | ||
| 14 | 15 | ||||
| 15 | - | "connectrpc.com/connect" | |||
| 16 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 17 | 17 | ||||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | 18 | + | "bnema.dev/ermite/internal/clientlimit" |
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | ) | 21 | ) | ||
| 20 | 22 | ||||
| 21 | // Live updates. A page that can change under the eyes of its reader opens | 23 | // Live updates. A page that can change under the eyes of its reader opens | ||
| 22 | // GET /-/events (the Live component of the hint badge). The handler follows | 24 | // GET /-/events (the Live component of the hint badge). The handler follows | ||
| 23 | - | // EventService.Watch as the visitor of the request and turns each event into | 25 | + | // EventService.Watch as the principal of the request and turns each event into |
| 24 | // the HTML fragments the page shows, sent as Server-Sent Events: | 26 | // the HTML fragments the page shows, sent as Server-Sent Events: | ||
| 25 | // | 27 | // | ||
| 26 | // GET /-/events?topic=home | 28 | // GET /-/events?topic=home | ||
| @@ -63,10 +65,6 @@ import ( | |||||
| 63 | const DefaultHeartbeat = 25 * time.Second | 65 | const DefaultHeartbeat = 25 * time.Second | ||
| 64 | 66 | ||||
| 65 | const ( | 67 | const ( | ||
| 66 | - | // startGrace is how long the handler waits after the Watch call answered | |||
| 67 | - | // for an early failure (too many watchers) before it commits to 200: the | |||
| 68 | - | // Connect protocol reports it in the stream, after the headers. | |||
| 69 | - | startGrace = 100 * time.Millisecond | |||
| 70 | // writeWindow bounds one write to the visitor, whatever the write timeout | 68 | // writeWindow bounds one write to the visitor, whatever the write timeout | ||
| 71 | // of the server is. | 69 | // of the server is. | ||
| 72 | writeWindow = 30 * time.Second | 70 | writeWindow = 30 * time.Second | ||
| @@ -105,7 +103,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 105 | if repo != "" { | 103 | if repo != "" { | ||
| 106 | // A repository the visitor cannot see is a missing one (AC2). | 104 | // A repository the visitor cannot see is a missing one (AC2). | ||
| 107 | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | 105 | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | ||
| 108 | - | _, err := s.cfg.Repos.GetRepo(cctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo})) | 106 | + | err := s.canSee(cctx, repo) |
| 109 | ccancel() | 107 | ccancel() | ||
| 110 | if err != nil { | 108 | if err != nil { | ||
| 111 | s.denyEvents(ctx, w, err) | 109 | s.denyEvents(ctx, w, err) | ||
| @@ -113,29 +111,33 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 113 | } | 111 | } | ||
| 114 | } | 112 | } | ||
| 115 | 113 | ||||
| 116 | - | stream, err := s.cfg.Events.Watch(ctx, connect.NewRequest(&pb.WatchRequest{ | 114 | + | // The watch runs in a goroutine and hands each event to this one through |
| 115 | + | // an unbuffered channel: a visitor that reads slowly holds the watch in | |||
| 116 | + | // its send, falls behind the buffer of the use case, and gets a gap. | |||
| 117 | + | msgs := make(chan recvd) | |||
| 118 | + | started := make(chan struct{}) | |||
| 119 | + | var startOnce sync.Once | |||
| 120 | + | start := func() { startOnce.Do(func() { close(started) }) } | |||
| 121 | + | finished := make(chan struct{}) | |||
| 122 | + | params := ports.WatchParams{ | |||
| 117 | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | 123 | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | ||
| 118 | - | })) | 124 | + | // The visitor is the client of the watch, for the limit per client. |
| 119 | - | if err != nil { | 125 | + | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), |
| 120 | - | s.denyEvents(ctx, w, err) | 126 | + | Ready: start, |
| 121 | - | return | |||
| 122 | } | 127 | } | ||
| 123 | - | // Wait for the answer of the API, which comes once the watch is registered | |||
| 124 | - | // (or refused), then read the stream from a goroutine. | |||
| 125 | - | _ = stream.ResponseHeader() | |||
| 126 | - | msgs := make(chan recvd) | |||
| 127 | - | done := make(chan struct{}) | |||
| 128 | go func() { | 128 | go func() { | ||
| 129 | - | defer close(done) | 129 | + | defer close(finished) |
| 130 | defer close(msgs) | 130 | defer close(msgs) | ||
| 131 | - | for stream.Receive() { | 131 | + | err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error { |
| 132 | + | start() | |||
| 132 | select { | 133 | select { | ||
| 133 | - | case msgs <- recvd{ev: stream.Msg().GetEvent()}: | 134 | + | case msgs <- recvd{ev: we}: |
| 135 | + | return nil | |||
| 134 | case <-ctx.Done(): | 136 | case <-ctx.Done(): | ||
| 135 | - | return | 137 | + | return ctx.Err() |
| 136 | } | 138 | } | ||
| 137 | - | } | 139 | + | }) |
| 138 | - | if err := stream.Err(); err != nil { | 140 | + | if err != nil && ctx.Err() == nil { |
| 139 | select { | 141 | select { | ||
| 140 | case msgs <- recvd{err: err}: | 142 | case msgs <- recvd{err: err}: | ||
| 141 | case <-ctx.Done(): | 143 | case <-ctx.Done(): | ||
| @@ -144,26 +146,24 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 144 | }() | 146 | }() | ||
| 145 | defer func() { | 147 | defer func() { | ||
| 146 | cancel() | 148 | cancel() | ||
| 147 | - | _ = stream.Close() | 149 | + | <-finished |
| 148 | - | <-done | |||
| 149 | }() | 150 | }() | ||
| 150 | 151 | ||||
| 152 | + | // The watch is registered (or refused, which ends it before): only then | |||
| 153 | + | // does the answer commit to 200. | |||
| 151 | var first *recvd | 154 | var first *recvd | ||
| 152 | - | grace := time.NewTimer(startGrace) | |||
| 153 | select { | 155 | select { | ||
| 156 | + | case <-started: | |||
| 154 | case m, open := <-msgs: | 157 | case m, open := <-msgs: | ||
| 155 | - | grace.Stop() | |||
| 156 | if !open { | 158 | if !open { | ||
| 157 | return // the server is stopping | 159 | return // the server is stopping | ||
| 158 | } | 160 | } | ||
| 161 | + | if m.err != nil { | |||
| 162 | + | s.denyEvents(ctx, w, m.err) | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 159 | first = &m | 165 | first = &m | ||
| 160 | - | case <-grace.C: | |||
| 161 | case <-ctx.Done(): | 166 | case <-ctx.Done(): | ||
| 162 | - | grace.Stop() | |||
| 163 | - | return | |||
| 164 | - | } | |||
| 165 | - | if first != nil && first.err != nil { | |||
| 166 | - | s.denyEvents(ctx, w, first.err) | |||
| 167 | return | 167 | return | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| @@ -185,11 +185,14 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 185 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | 185 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | ||
| 186 | handle := func(m recvd) bool { | 186 | handle := func(m recvd) bool { | ||
| 187 | if m.err != nil { | 187 | if m.err != nil { | ||
| 188 | - | if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled { | 188 | + | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { |
| 189 | log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended") | 189 | log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended") | ||
| 190 | } | 190 | } | ||
| 191 | return false | 191 | return false | ||
| 192 | } | 192 | } | ||
| 193 | + | if !liveEvent(m.ev) { | |||
| 194 | + | return true // nothing to show, not even an id to resume after | |||
| 195 | + | } | |||
| 193 | // An owner's stream ends when the session does, before it sends | 196 | // An owner's stream ends when the session does, before it sends | ||
| 194 | // anything more: an event can carry what only the owner may see, and | 197 | // anything more: an event can carry what only the owner may see, and | ||
| 195 | // waiting for the next heartbeat would leave a window. | 198 | // waiting for the next heartbeat would leave a window. | ||
| @@ -208,7 +211,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 208 | log.Warn().Err(err).Str("topic", topic).Msg("render live fragment") | 211 | log.Warn().Err(err).Str("topic", topic).Msg("render live fragment") | ||
| 209 | frags = gap | 212 | frags = gap | ||
| 210 | } | 213 | } | ||
| 211 | - | return out.send(m.ev.GetId(), frags) == nil | 214 | + | return out.send(m.ev.ID, frags) == nil |
| 212 | } | 215 | } | ||
| 213 | if first != nil && !handle(*first) { | 216 | if first != nil && !handle(*first) { | ||
| 214 | return | 217 | return | ||
| @@ -243,30 +246,41 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 243 | 246 | ||||
| 244 | // recvd is a message of the Watch stream, or how it failed. | 247 | // recvd is a message of the Watch stream, or how it failed. | ||
| 245 | type recvd struct { | 248 | type recvd struct { | ||
| 246 | - | ev *pb.Event | 249 | + | ev ports.WatchEvent |
| 247 | err error | 250 | err error | ||
| 248 | } | 251 | } | ||
| 249 | 252 | ||||
| 253 | + | // canSee checks that the caller of the request may see the repository named in | |||
| 254 | + | // a topic: one it may not is as missing as one that does not exist. | |||
| 255 | + | func (s *Server) canSee(ctx context.Context, rawName string) error { | |||
| 256 | + | name, err := domain.ParseRepoName(rawName) | |||
| 257 | + | if err != nil { | |||
| 258 | + | return err | |||
| 259 | + | } | |||
| 260 | + | _, err = s.cfg.Repos.Get(ctx, principal(ctx), name) | |||
| 261 | + | return err | |||
| 262 | + | } | |||
| 263 | + | ||||
| 250 | // denyStream answers a request that gets no stream. | 264 | // denyStream answers a request that gets no stream. | ||
| 251 | func denyStream(w http.ResponseWriter, status int, msg string) { | 265 | func denyStream(w http.ResponseWriter, status int, msg string) { | ||
| 252 | w.Header().Set("Cache-Control", "no-store") | 266 | w.Header().Set("Cache-Control", "no-store") | ||
| 253 | http.Error(w, msg, status) | 267 | http.Error(w, msg, status) | ||
| 254 | } | 268 | } | ||
| 255 | 269 | ||||
| 256 | - | // denyEvents maps an error of the API, before the stream started, to a | 270 | + | // denyEvents maps an error of a use case, before the stream started, to a |
| 257 | // status. Not found and invalid are 404 whatever the reason: a private | 271 | // status. Not found and invalid are 404 whatever the reason: a private | ||
| 258 | // repository is a missing one. | 272 | // repository is a missing one. | ||
| 259 | func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) { | 273 | func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) { | ||
| 260 | - | switch code := connect.CodeOf(err); { | 274 | + | switch { |
| 261 | - | case code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | 275 | + | case errors.Is(err, context.Canceled): |
| 276 | + | // The visitor is gone. | |||
| 277 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): | |||
| 262 | denyStream(w, http.StatusNotFound, "not found") | 278 | denyStream(w, http.StatusNotFound, "not found") | ||
| 263 | - | case code == connect.CodeResourceExhausted: | 279 | + | case errors.Is(err, domain.ErrResourceExhausted): |
| 264 | w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) | 280 | w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) | ||
| 265 | denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later") | 281 | denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later") | ||
| 266 | - | case code == connect.CodeUnauthenticated: | 282 | + | case errors.Is(err, domain.ErrUnauthenticated): |
| 267 | denyStream(w, http.StatusUnauthorized, "unauthorized") | 283 | denyStream(w, http.StatusUnauthorized, "unauthorized") | ||
| 268 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |||
| 269 | - | // The visitor is gone. | |||
| 270 | default: | 284 | default: | ||
| 271 | log := zerowrap.FromCtx(ctx) | 285 | log := zerowrap.FromCtx(ctx) | ||
| 272 | log.Error().Err(err).Msg("open event stream") | 286 | log.Error().Err(err).Msg("open event stream") | ||
M internal/adapters/web/events_test.go +110 -82
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "bufio" | 6 | "bufio" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | + | "fmt" | |||
| 9 | "io" | 10 | "io" | ||
| 10 | "net/http" | 11 | "net/http" | ||
| 11 | "net/http/httptest" | 12 | "net/http/httptest" | ||
| @@ -14,12 +15,11 @@ import ( | |||||
| 14 | "testing" | 15 | "testing" | ||
| 15 | "time" | 16 | "time" | ||
| 16 | 17 | ||||
| 17 | - | "connectrpc.com/connect" | |||
| 18 | "github.com/stretchr/testify/assert" | 18 | "github.com/stretchr/testify/assert" | ||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 19 | "github.com/stretchr/testify/require" | 20 | "github.com/stretchr/testify/require" | ||
| 20 | 21 | ||||
| 21 | - | pb "bnema.dev/ermite-api/gen/ermite" | 22 | + | "bnema.dev/ermite/internal/domain" |
| 22 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 23 | "bnema.dev/ermite/internal/ports" | 23 | "bnema.dev/ermite/internal/ports" | ||
| 24 | "bnema.dev/ermite/internal/ports/mocks" | 24 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 25 | ) | 25 | ) | ||
| @@ -60,28 +60,45 @@ func TestNewCommitsText(t *testing.T) { | |||||
| 60 | assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) | 60 | assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | - | // fakeEvents is an EventService whose behaviour a test sets. | 63 | + | // watchProbe drives the generated EventService mock: what the watch does, and |
| 64 | - | type fakeEvents struct { | 64 | + | // a record of the principal and the parameters of the watches it was asked for. |
| 65 | - | ermiteconnect.UnimplementedEventServiceHandler | 65 | + | type watchProbe struct { |
| 66 | err error | 66 | err error | ||
| 67 | - | events []*pb.Event | 67 | + | events []ports.WatchEvent |
| 68 | opened atomic.Int32 | 68 | opened atomic.Int32 | ||
| 69 | closed atomic.Int32 | 69 | closed atomic.Int32 | ||
| 70 | - | resume atomic.Value | 70 | + | resume atomic.Value // string: ResumeAfter of the last watch |
| 71 | + | topics atomic.Value // []string: Topics of the last watch | |||
| 72 | + | client atomic.Value // string: Client of the last watch | |||
| 73 | + | who atomic.Value // domain.Principal: caller of the last watch | |||
| 71 | release chan struct{} | 74 | release chan struct{} | ||
| 72 | - | push chan *pb.Event // events sent on demand, after the fixed ones | 75 | + | push chan ports.WatchEvent // events sent on demand, after the fixed ones |
| 73 | } | 76 | } | ||
| 74 | 77 | ||||
| 75 | - | func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error { | 78 | + | // service is the EventService the web watches: the mock, answering with the |
| 79 | + | // probe. | |||
| 80 | + | func (f *watchProbe) service(t testing.TB) *mocks.EventService { | |||
| 81 | + | t.Helper() | |||
| 82 | + | m := mocks.NewEventService(t) | |||
| 83 | + | m.EXPECT().Watch(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(f.run).Maybe() | |||
| 84 | + | return m | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (f *watchProbe) run(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error { | |||
| 76 | f.opened.Add(1) | 88 | f.opened.Add(1) | ||
| 77 | defer f.closed.Add(1) | 89 | defer f.closed.Add(1) | ||
| 78 | - | f.resume.Store(req.Msg.GetResumeAfter()) | 90 | + | f.resume.Store(params.ResumeAfter) |
| 91 | + | f.topics.Store(params.Topics) | |||
| 92 | + | f.client.Store(params.Client) | |||
| 93 | + | f.who.Store(p) | |||
| 79 | if f.err != nil { | 94 | if f.err != nil { | ||
| 80 | return f.err | 95 | return f.err | ||
| 81 | } | 96 | } | ||
| 82 | - | _ = st.Send(nil) | 97 | + | if params.Ready != nil { |
| 98 | + | params.Ready() | |||
| 99 | + | } | |||
| 83 | for _, ev := range f.events { | 100 | for _, ev := range f.events { | ||
| 84 | - | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | 101 | + | if err := send(ev); err != nil { |
| 85 | return err | 102 | return err | ||
| 86 | } | 103 | } | ||
| 87 | } | 104 | } | ||
| @@ -92,46 +109,29 @@ func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchReq | |||||
| 92 | case <-f.release: | 109 | case <-f.release: | ||
| 93 | return nil | 110 | return nil | ||
| 94 | case ev := <-f.push: | 111 | case ev := <-f.push: | ||
| 95 | - | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | 112 | + | if err := send(ev); err != nil { |
| 96 | return err | 113 | return err | ||
| 97 | } | 114 | } | ||
| 98 | } | 115 | } | ||
| 99 | } | 116 | } | ||
| 100 | } | 117 | } | ||
| 101 | 118 | ||||
| 102 | - | // repoOK answers every GetRepo with a public repository. | 119 | + | // eventsServer serves the web on a fake EventService, with a write timeout |
| 103 | - | type repoOK struct { | 120 | + | // shorter than the heartbeat, like a server that sets one. |
| 104 | - | ermiteconnect.UnimplementedRepoServiceHandler | 121 | + | func eventsServer(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration) *httptest.Server { |
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |||
| 108 | - | return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // eventsServer serves the web on a fake API, with a write timeout shorter | |||
| 112 | - | // than the heartbeat, like a server that sets one. | |||
| 113 | - | func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server { | |||
| 114 | t.Helper() | 122 | t.Helper() | ||
| 115 | return eventsServerWith(t, f, beat, writeTimeout, nil) | 123 | return eventsServerWith(t, f, beat, writeTimeout, nil) | ||
| 116 | } | 124 | } | ||
| 117 | 125 | ||||
| 118 | // eventsServerWith is eventsServer with sessions, when sess is not nil. | 126 | // eventsServerWith is eventsServer with sessions, when sess is not nil. | ||
| 119 | - | func eventsServerWith(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration, sess ports.WebSessionService) *httptest.Server { | 127 | + | func eventsServerWith(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration, sess ports.WebSessionService) *httptest.Server { |
| 120 | t.Helper() | 128 | t.Helper() | ||
| 121 | - | api := http.NewServeMux() | 129 | + | srv := newTestServer(t, Config{ |
| 122 | - | api.Handle(ermiteconnect.NewEventServiceHandler(f)) | 130 | + | Repos: reposServing(t, publicRepo(t, "x")), |
| 123 | - | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | 131 | + | Browse: mocks.NewBrowseService(t), |
| 124 | - | backend := httptest.NewServer(api) | 132 | + | Events: f.service(t), |
| 125 | - | t.Cleanup(backend.Close) | |||
| 126 | - | ||||
| 127 | - | srv, err := New(Config{ | |||
| 128 | - | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |||
| 129 | - | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |||
| 130 | - | Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL), | |||
| 131 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 132 | Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess), | 133 | Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess), | ||
| 133 | }) | 134 | }) | ||
| 134 | - | require.NoError(t, err) | |||
| 135 | ts := httptest.NewUnstartedServer(srv) | 135 | ts := httptest.NewUnstartedServer(srv) | ||
| 136 | ts.Config.WriteTimeout = writeTimeout | 136 | ts.Config.WriteTimeout = writeTimeout | ||
| 137 | ts.Start() | 137 | ts.Start() | ||
| @@ -157,7 +157,7 @@ func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string | |||||
| 157 | // server does not end it. | 157 | // server does not end it. | ||
| 158 | func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | 158 | func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | ||
| 159 | t.Parallel() | 159 | t.Parallel() | ||
| 160 | - | f := &fakeEvents{release: make(chan struct{})} | 160 | + | f := &watchProbe{release: make(chan struct{})} |
| 161 | ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) | 161 | ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) | ||
| 162 | resp, cancel := open(t, ts, "topic=home", nil) | 162 | resp, cancel := open(t, ts, "topic=home", nil) | ||
| 163 | require.Equal(t, http.StatusOK, resp.StatusCode) | 163 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| @@ -181,9 +181,9 @@ func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | |||||
| 181 | // Last-Event-ID header is the resume point; a Gap becomes a gap event. | 181 | // Last-Event-ID header is the resume point; a Gap becomes a gap event. | ||
| 182 | func TestEventsStreamsEventsAndResumes(t *testing.T) { | 182 | func TestEventsStreamsEventsAndResumes(t *testing.T) { | ||
| 183 | t.Parallel() | 183 | t.Parallel() | ||
| 184 | - | f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{ | 184 | + | f := &watchProbe{release: make(chan struct{}), events: []ports.WatchEvent{ |
| 185 | - | {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}}, | 185 | + | {ID: "b-7", Event: domain.PushEvent{Repo: repoName(t, "x")}}, |
| 186 | - | {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}, | 186 | + | {Gap: true}, |
| 187 | }} | 187 | }} | ||
| 188 | ts := eventsServer(t, f, time.Hour, 0) | 188 | ts := eventsServer(t, f, time.Hour, 0) | ||
| 189 | resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) | 189 | resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) | ||
| @@ -199,31 +199,73 @@ func TestEventsStreamsEventsAndResumes(t *testing.T) { | |||||
| 199 | assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") | 199 | assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") | ||
| 200 | assert.Contains(t, lines, "event: gap") | 200 | assert.Contains(t, lines, "event: gap") | ||
| 201 | assert.Equal(t, "b-6", f.resume.Load()) | 201 | assert.Equal(t, "b-6", f.resume.Load()) | ||
| 202 | + | assert.Equal(t, []string{"repo:x"}, f.topics.Load()) | |||
| 203 | + | assert.Equal(t, domain.Anonymous(), f.who.Load(), "a visitor watches as anonymous") | |||
| 204 | + | assert.NotEmpty(t, f.client.Load(), "the watch is attributed to the address of the visitor") | |||
| 202 | } | 205 | } | ||
| 203 | 206 | ||||
| 204 | - | // Too many watchers is a 503 the browser can retry, not a stream. | 207 | + | // Too many watchers is a 503 the browser can retry, not a stream; what the |
| 205 | - | func TestEventsResourceExhausted(t *testing.T) { | 208 | + | // watch is refused for decides the status. |
| 209 | + | func TestEventsRefusals(t *testing.T) { | |||
| 206 | t.Parallel() | 210 | t.Parallel() | ||
| 207 | - | f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))} | 211 | + | tests := []struct { |
| 208 | - | ts := eventsServer(t, f, time.Hour, 0) | 212 | + | name string |
| 209 | - | resp, _ := open(t, ts, "topic=home", nil) | 213 | + | err error |
| 210 | - | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | 214 | + | want int |
| 211 | - | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | 215 | + | retryAfter bool |
| 212 | - | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | 216 | + | }{ |
| 217 | + | {"resource exhausted", fmt.Errorf("%w: too many watchers", domain.ErrResourceExhausted), http.StatusServiceUnavailable, true}, | |||
| 218 | + | {"not found", domain.ErrNotFound, http.StatusNotFound, false}, | |||
| 219 | + | {"invalid", domain.ErrInvalid, http.StatusNotFound, false}, | |||
| 220 | + | {"unauthenticated", domain.ErrUnauthenticated, http.StatusUnauthorized, false}, | |||
| 221 | + | {"internal", errors.New("boom"), http.StatusInternalServerError, false}, | |||
| 222 | + | } | |||
| 223 | + | for _, tt := range tests { | |||
| 224 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 225 | + | t.Parallel() | |||
| 226 | + | ts := eventsServer(t, &watchProbe{err: tt.err}, time.Hour, 0) | |||
| 227 | + | resp, _ := open(t, ts, "topic=home", nil) | |||
| 228 | + | assert.Equal(t, tt.want, resp.StatusCode) | |||
| 229 | + | assert.Equal(t, tt.retryAfter, resp.Header.Get("Retry-After") != "") | |||
| 230 | + | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | |||
| 231 | + | assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) | |||
| 232 | + | }) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 213 | 235 | ||||
| 214 | - | f.err = connect.NewError(connect.CodeNotFound, errors.New("x")) | 236 | + | // The topic of a repository the visitor cannot see is refused before any |
| 215 | - | resp, _ = open(t, ts, "topic=home", nil) | 237 | + | // watch is opened: a private repository is a missing one. |
| 216 | - | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | 238 | + | func TestEventsPrivateRepositoryTopic(t *testing.T) { |
| 217 | - | f.err = errors.New("boom") | 239 | + | t.Parallel() |
| 218 | - | resp, _ = open(t, ts, "topic=home", nil) | 240 | + | f := &watchProbe{release: make(chan struct{})} |
| 219 | - | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | 241 | + | srv := newTestServer(t, Config{ |
| 242 | + | Repos: reposServing(t, publicRepo(t, "pub")), Browse: mocks.NewBrowseService(t), Events: f.service(t), | |||
| 243 | + | }) | |||
| 244 | + | ts := httptest.NewServer(srv) | |||
| 245 | + | t.Cleanup(ts.Close) | |||
| 246 | + | for _, query := range []string{"topic=repo:secret", "topic=repo:Bad"} { | |||
| 247 | + | resp, _ := open(t, ts, query, nil) | |||
| 248 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode, query) | |||
| 249 | + | } | |||
| 250 | + | assert.Zero(t, f.opened.Load(), "no watch was opened for them") | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // The owner's stream is the owner's: the watch is opened as the owner. | |||
| 254 | + | func TestEventsOwnerWatchesAsOwner(t *testing.T) { | |||
| 255 | + | t.Parallel() | |||
| 256 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 257 | + | sess, _ := newRevocable(t, "tok") | |||
| 258 | + | ts := eventsServerWith(t, f, time.Hour, 0, sess) | |||
| 259 | + | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | |||
| 260 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 261 | + | assert.Equal(t, domain.Owner(), f.who.Load()) | |||
| 220 | } | 262 | } | ||
| 221 | 263 | ||||
| 222 | // The handler returns, and the watch it opened ends, when the visitor goes | 264 | // The handler returns, and the watch it opened ends, when the visitor goes | ||
| 223 | // away or the API ends the stream. | 265 | // away or the API ends the stream. | ||
| 224 | func TestEventsNoLeak(t *testing.T) { | 266 | func TestEventsNoLeak(t *testing.T) { | ||
| 225 | t.Parallel() | 267 | t.Parallel() | ||
| 226 | - | f := &fakeEvents{release: make(chan struct{})} | 268 | + | f := &watchProbe{release: make(chan struct{})} |
| 227 | ts := eventsServer(t, f, time.Hour, 0) | 269 | ts := eventsServer(t, f, time.Hour, 0) | ||
| 228 | 270 | ||||
| 229 | resp, cancel := open(t, ts, "topic=home", nil) | 271 | resp, cancel := open(t, ts, "topic=home", nil) | ||
| @@ -246,14 +288,9 @@ func TestEventsNoLeak(t *testing.T) { | |||||
| 246 | require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) | 288 | require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) | ||
| 247 | } | 289 | } | ||
| 248 | 290 | ||||
| 249 | - | func TestEventsOffWithoutClient(t *testing.T) { | 291 | + | func TestEventsOffWithoutEventService(t *testing.T) { |
| 250 | t.Parallel() | 292 | t.Parallel() | ||
| 251 | - | srv, err := New(Config{ | 293 | + | srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)}) |
| 252 | - | Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"), | |||
| 253 | - | Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"), | |||
| 254 | - | PublicURL: "https://forge.example", | |||
| 255 | - | }) | |||
| 256 | - | require.NoError(t, err) | |||
| 257 | rec := httptest.NewRecorder() | 294 | rec := httptest.NewRecorder() | ||
| 258 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) | 295 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) | ||
| 259 | assert.Equal(t, http.StatusNotFound, rec.Code) | 296 | assert.Equal(t, http.StatusNotFound, rec.Code) | ||
| @@ -264,18 +301,9 @@ func TestEventsOffWithoutClient(t *testing.T) { | |||||
| 264 | // and marks depend on it. The commits page shows a branch, not the default. | 301 | // and marks depend on it. The commits page shows a branch, not the default. | ||
| 265 | func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { | 302 | func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { | ||
| 266 | t.Parallel() | 303 | t.Parallel() | ||
| 267 | - | api := http.NewServeMux() | 304 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t)}) |
| 268 | - | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | |||
| 269 | - | backend := httptest.NewServer(api) | |||
| 270 | - | t.Cleanup(backend.Close) | |||
| 271 | - | srv, err := New(Config{ | |||
| 272 | - | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |||
| 273 | - | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |||
| 274 | - | PublicURL: "https://forge.example", | |||
| 275 | - | }) | |||
| 276 | - | require.NoError(t, err) | |||
| 277 | 305 | ||||
| 278 | - | ev := &pb.Event{Kind: &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: &pb.Repo{Name: "x"}}}} | 306 | + | ev := ports.WatchEvent{Event: domain.RepoUpdated{Repo: repoName(t, "x")}} |
| 279 | for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} { | 307 | for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} { | ||
| 280 | ls := &liveStream{s: srv, repo: "x", view: view} | 308 | ls := &liveStream{s: srv, repo: "x", view: view} | ||
| 281 | got, err := ls.render(t.Context(), ev) | 309 | got, err := ls.render(t.Context(), ev) | ||
| @@ -308,7 +336,7 @@ func newRevocable(t *testing.T, valid string) (*mocks.WebSessionService, *atomic | |||||
| 308 | // and the stream ends. | 336 | // and the stream ends. | ||
| 309 | func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | 337 | func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | ||
| 310 | t.Parallel() | 338 | t.Parallel() | ||
| 311 | - | f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} | 339 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} |
| 312 | sess, ended := newRevocable(t, "tok") | 340 | sess, ended := newRevocable(t, "tok") | ||
| 313 | ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test | 341 | ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test | ||
| 314 | resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | 342 | resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | ||
| @@ -328,7 +356,7 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||||
| 328 | // The stream is up; a first event goes through while the session lives. | 356 | // The stream is up; a first event goes through while the session lives. | ||
| 329 | require.Equal(t, ": connected", line()) | 357 | require.Equal(t, ": connected", line()) | ||
| 330 | line() | 358 | line() | ||
| 331 | - | f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | 359 | + | f.push <- ports.WatchEvent{ID: "b-1", Gap: true} |
| 332 | var seen []string | 360 | var seen []string | ||
| 333 | for l := line(); l != "data: gap"; l = line() { | 361 | for l := line(); l != "data: gap"; l = line() { | ||
| 334 | seen = append(seen, l) | 362 | seen = append(seen, l) | ||
| @@ -339,7 +367,7 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||||
| 339 | // Signed out everywhere: the next event is not sent, the stream ends. | 367 | // Signed out everywhere: the next event is not sent, the stream ends. | ||
| 340 | ended.Store(true) | 368 | ended.Store(true) | ||
| 341 | select { | 369 | select { | ||
| 342 | - | case f.push <- &pb.Event{Id: "b-2", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}: | 370 | + | case f.push <- ports.WatchEvent{ID: "b-2", Gap: true}: |
| 343 | case <-time.After(5 * time.Second): | 371 | case <-time.After(5 * time.Second): | ||
| 344 | t.Fatal("the event was not delivered to the web") | 372 | t.Fatal("the event was not delivered to the web") | ||
| 345 | } | 373 | } | ||
| @@ -351,13 +379,13 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||||
| 351 | // A visitor's stream does not depend on sessions: events keep flowing. | 379 | // A visitor's stream does not depend on sessions: events keep flowing. | ||
| 352 | func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | 380 | func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | ||
| 353 | t.Parallel() | 381 | t.Parallel() | ||
| 354 | - | f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} | 382 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} |
| 355 | sess, ended := newRevocable(t, "tok") | 383 | sess, ended := newRevocable(t, "tok") | ||
| 356 | ended.Store(true) | 384 | ended.Store(true) | ||
| 357 | ts := eventsServerWith(t, f, time.Hour, 0, sess) | 385 | ts := eventsServerWith(t, f, time.Hour, 0, sess) | ||
| 358 | resp, _ := open(t, ts, "topic=home", nil) | 386 | resp, _ := open(t, ts, "topic=home", nil) | ||
| 359 | require.Equal(t, http.StatusOK, resp.StatusCode) | 387 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 360 | - | f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | 388 | + | f.push <- ports.WatchEvent{ID: "b-1", Gap: true} |
| 361 | sc := bufio.NewScanner(resp.Body) | 389 | sc := bufio.NewScanner(resp.Body) | ||
| 362 | found := false | 390 | found := false | ||
| 363 | for sc.Scan() { | 391 | for sc.Scan() { | ||
| @@ -373,7 +401,7 @@ func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | |||||
| 373 | // say the answer must not be stored and varies on the cookie. | 401 | // say the answer must not be stored and varies on the cookie. | ||
| 374 | func TestEventsInvalidCookieStream(t *testing.T) { | 402 | func TestEventsInvalidCookieStream(t *testing.T) { | ||
| 375 | t.Parallel() | 403 | t.Parallel() | ||
| 376 | - | f := &fakeEvents{release: make(chan struct{})} | 404 | + | f := &watchProbe{release: make(chan struct{})} |
| 377 | ts := eventsServerWith(t, f, time.Hour, 0, newSessions(t, "tok")) | 405 | ts := eventsServerWith(t, f, time.Hour, 0, newSessions(t, "tok")) | ||
| 378 | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"}) | 406 | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"}) | ||
| 379 | require.Equal(t, http.StatusOK, resp.StatusCode) | 407 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
M internal/adapters/web/fanout_test.go +42 -41
| @@ -10,13 +10,13 @@ import ( | |||||
| 10 | "testing" | 10 | "testing" | ||
| 11 | "testing/synctest" | 11 | "testing/synctest" | ||
| 12 | 12 | ||||
| 13 | - | "connectrpc.com/connect" | |||
| 14 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| 16 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 17 | 16 | ||||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | 17 | + | "bnema.dev/ermite/internal/domain" |
| 19 | - | "bnema.dev/ermite/internal/adapters/web/mocks" | 18 | + | "bnema.dev/ermite/internal/ports" |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | func frag(s string) []fragment { return []fragment{{event: "refs-list", html: s}} } | 22 | func frag(s string) []fragment { return []fragment{{event: "refs-list", html: s}} } | ||
| @@ -137,58 +137,59 @@ func TestFanoutIsBounded(t *testing.T) { | |||||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| 139 | // refsMocks is a web server over mocks that expect the refs fragment to load | 139 | // refsMocks is a web server over mocks that expect the refs fragment to load | ||
| 140 | - | // the repository and its refs repos times, and to ask for a commit commits | 140 | + | // the repository and its refs once per build of each class of watcher |
| 141 | - | // times. | 141 | + | // (anonymous, owner), asking the use cases as that class's principal, and to |
| 142 | - | func refsMocks(t *testing.T, repos, commits int) *Server { | 142 | + | // ask for a commit once per build when commits is set. |
| 143 | + | func refsMocks(t *testing.T, anonymous, owner int, commits bool) *Server { | |||
| 143 | t.Helper() | 144 | t.Helper() | ||
| 144 | - | api := mocks.NewRepoServiceClient(t) | 145 | + | const id = "0123456789abcdef0123456789abcdef01234567" |
| 145 | - | git := mocks.NewGitServiceClient(t) | 146 | + | repos := mocks.NewRepoService(t) |
| 146 | - | api.EXPECT().GetRepo(mock.Anything, mock.Anything).RunAndReturn( | 147 | + | browse := mocks.NewBrowseService(t) |
| 147 | - | func(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | 148 | + | for _, class := range []struct { |
| 148 | - | return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil | 149 | + | p domain.Principal |
| 149 | - | }).Times(repos) | 150 | + | builds int |
| 150 | - | id := "0123456789abcdef0123456789abcdef01234567" | 151 | + | }{{domain.Anonymous(), anonymous}, {domain.Owner(), owner}} { |
| 151 | - | git.EXPECT().ListRefs(mock.Anything, mock.MatchedBy(func(req *connect.Request[pb.ListRefsRequest]) bool { | 152 | + | p, builds := class.p, class.builds |
| 152 | - | return req.Msg.GetPageSize() == maxRefs // the refs are asked in one page | 153 | + | if builds == 0 { |
| 153 | - | })).RunAndReturn(func(context.Context, *connect.Request[pb.ListRefsRequest]) (*connect.Response[pb.ListRefsResponse], error) { | 154 | + | continue |
| 154 | - | return connect.NewResponse(&pb.ListRefsResponse{Refs: []*pb.Ref{ | 155 | + | } |
| 155 | - | {Name: "refs/heads/main", ShortName: "main", Kind: pb.RefKind_REF_KIND_BRANCH, CommitId: id}, | 156 | + | repos.EXPECT().Get(mock.Anything, p, repoName(t, "x")).Return(publicRepo(t, "x").Repo, nil).Times(builds) |
| 156 | - | {Name: "refs/tags/v1", ShortName: "v1", Kind: pb.RefKind_REF_KIND_TAG, CommitId: id}, | 157 | + | browse.EXPECT().ListRefs(mock.Anything, p, repoName(t, "x"), mock.MatchedBy(func(params ports.ListRefsParams) bool { |
| 157 | - | }}), nil | 158 | + | return params.PageSize == maxRefs // the refs are asked in one page |
| 158 | - | }).Times(repos) | 159 | + | })).Return(ports.RefPage{Refs: []domain.Ref{ |
| 159 | - | if commits > 0 { | 160 | + | {Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: objectID(t, id)}, |
| 160 | - | git.EXPECT().ListCommits(mock.Anything, mock.Anything).RunAndReturn( | 161 | + | {Name: "refs/tags/v1", ShortName: "v1", Kind: domain.RefKindTag, CommitID: objectID(t, id)}, |
| 161 | - | func(_ context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) { | 162 | + | }}, nil).Times(builds) |
| 162 | - | return connect.NewResponse(&pb.ListCommitsResponse{Commits: []*pb.Commit{{Id: req.Msg.GetRevision(), Summary: "subject"}}}), nil | 163 | + | if commits { |
| 163 | - | }).Times(commits) | 164 | + | browse.EXPECT().ListCommits(mock.Anything, p, repoName(t, "x"), mock.Anything).RunAndReturn( |
| 165 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error) { | |||
| 166 | + | return ports.HistoryPage{Commits: []domain.Commit{{ID: objectID(t, params.Revision.String()), Summary: "subject"}}}, nil | |||
| 167 | + | }).Times(builds) | |||
| 168 | + | } | |||
| 164 | } | 169 | } | ||
| 165 | - | s, err := New(Config{ | 170 | + | repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "x"), nil).Times(anonymous + owner) |
| 166 | - | Repos: api, Git: git, | 171 | + | return newTestServer(t, Config{Repos: repos, Browse: browse}) |
| 167 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 168 | - | }) | |||
| 169 | - | require.NoError(t, err) | |||
| 170 | - | return s | |||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
| 173 | // A push is rendered once for the watchers of the same class, and the owner | 174 | // A push is rendered once for the watchers of the same class, and the owner | ||
| 174 | // and the anonymous visitors never share what was built. | 175 | // and the anonymous visitors never share what was built. | ||
| 175 | func TestLiveRefsSharesAPushBetweenWatchers(t *testing.T) { | 176 | func TestLiveRefsSharesAPushBetweenWatchers(t *testing.T) { | ||
| 176 | t.Parallel() | 177 | t.Parallel() | ||
| 177 | - | owner := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | 178 | + | owner := domain.ContextWithPrincipal(context.WithValue(context.Background(), signedInKey{}, struct{}{}), domain.Owner()) |
| 178 | anon := context.Background() | 179 | anon := context.Background() | ||
| 179 | 180 | ||||
| 180 | for _, tt := range []struct { | 181 | for _, tt := range []struct { | ||
| 181 | - | name string | 182 | + | name string |
| 182 | - | ctxs []context.Context | 183 | + | ctxs []context.Context |
| 183 | - | builds int // loads of the repository and refs, and ListCommits calls: one per class | 184 | + | anonymous, mine int // builds: one per class |
| 184 | }{ | 185 | }{ | ||
| 185 | - | {"five anonymous watchers", []context.Context{anon, anon, anon, anon, anon}, 1}, | 186 | + | {"five anonymous watchers", []context.Context{anon, anon, anon, anon, anon}, 1, 0}, |
| 186 | - | {"owner and anonymous", []context.Context{anon, owner, anon, owner}, 2}, | 187 | + | {"owner and anonymous", []context.Context{anon, owner, anon, owner}, 1, 1}, |
| 187 | } { | 188 | } { | ||
| 188 | t.Run(tt.name, func(t *testing.T) { | 189 | t.Run(tt.name, func(t *testing.T) { | ||
| 189 | t.Parallel() | 190 | t.Parallel() | ||
| 190 | - | s := refsMocks(t, tt.builds, tt.builds) | 191 | + | s := refsMocks(t, tt.anonymous, tt.mine, true) |
| 191 | - | ev := &pb.Event{Id: "b-1", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}} | 192 | + | ev := ports.WatchEvent{ID: "b-1", Event: domain.PushEvent{Repo: repoName(t, "x")}} |
| 192 | for _, ctx := range tt.ctxs { | 193 | for _, ctx := range tt.ctxs { | ||
| 193 | st := &liveStream{s: s, repo: "x", view: "refs"} | 194 | st := &liveStream{s: s, repo: "x", view: "refs"} | ||
| 194 | frags, err := st.render(ctx, ev) | 195 | frags, err := st.render(ctx, ev) | ||
| @@ -204,7 +205,7 @@ func TestLiveRefsSharesAPushBetweenWatchers(t *testing.T) { | |||||
| 204 | // The refs are asked in one page of up to maxRefs (the mock only answers that). | 205 | // The refs are asked in one page of up to maxRefs (the mock only answers that). | ||
| 205 | func TestLoadRepoAsksTheRefsInOnePage(t *testing.T) { | 206 | func TestLoadRepoAsksTheRefsInOnePage(t *testing.T) { | ||
| 206 | t.Parallel() | 207 | t.Parallel() | ||
| 207 | - | s := refsMocks(t, 1, 0) | 208 | + | s := refsMocks(t, 1, 0, false) |
| 208 | d, err := s.loadRepo(context.Background(), "x") | 209 | d, err := s.loadRepo(context.Background(), "x") | ||
| 209 | require.NoError(t, err) | 210 | require.NoError(t, err) | ||
| 210 | assert.Len(t, d.branches, 1) | 211 | assert.Len(t, d.branches, 1) | ||
M internal/adapters/web/fragments.go +91 -59
| @@ -10,10 +10,10 @@ import ( | |||||
| 10 | "sync" | 10 | "sync" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | - | "connectrpc.com/connect" | |||
| 14 | "github.com/a-h/templ" | 13 | "github.com/a-h/templ" | ||
| 15 | 14 | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | 15 | + | "bnema.dev/ermite/internal/domain" |
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // liveStream turns the events of Watch into the fragments one page shows. | 19 | // liveStream turns the events of Watch into the fragments one page shows. | ||
| @@ -37,48 +37,73 @@ func renderFragment(ctx context.Context, event string, c templ.Component) (fragm | |||||
| 37 | 37 | ||||
| 38 | var gap = []fragment{{event: "gap", html: "gap"}} | 38 | var gap = []fragment{{event: "gap", html: "gap"}} | ||
| 39 | 39 | ||||
| 40 | + | // liveEvent reports whether the event of a watch is one the pages follow: a | |||
| 41 | + | // gap, or one of the events of the forge. The use case may send others, which | |||
| 42 | + | // no page shows and no stream carries, not even their id. | |||
| 43 | + | func liveEvent(we ports.WatchEvent) bool { | |||
| 44 | + | if we.Gap { | |||
| 45 | + | return true | |||
| 46 | + | } | |||
| 47 | + | switch we.Event.(type) { | |||
| 48 | + | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |||
| 49 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded: | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | ||||
| 40 | // render returns the fragments for an event, none when it changes nothing on | 55 | // render returns the fragments for an event, none when it changes nothing on | ||
| 41 | // the page. | 56 | // the page. | ||
| 42 | - | func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) { | 57 | + | func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { |
| 43 | - | if ev == nil { | 58 | + | if we.Gap { |
| 44 | - | return nil, nil | |||
| 45 | - | } | |||
| 46 | - | if ev.GetGap() != nil { | |||
| 47 | return gap, nil | 59 | return gap, nil | ||
| 48 | } | 60 | } | ||
| 61 | + | if we.Event == nil { | |||
| 62 | + | return nil, nil | |||
| 63 | + | } | |||
| 49 | if l.repo == "" { | 64 | if l.repo == "" { | ||
| 50 | - | return l.home(ctx, ev) | 65 | + | return l.home(ctx, we) |
| 51 | } | 66 | } | ||
| 52 | - | return l.repoPage(ctx, ev) | 67 | + | return l.repoPage(ctx, we) |
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // repoByName reads a repository as the caller of the request. A name that is | |||
| 71 | + | // not valid is a repository that is not there. | |||
| 72 | + | func (l *liveStream) repoByName(ctx context.Context, rawName string) (ports.RepoView, error) { | |||
| 73 | + | name, err := domain.ParseRepoName(rawName) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return ports.RepoView{}, domain.ErrNotFound | |||
| 76 | + | } | |||
| 77 | + | return l.s.repoView(ctx, name) | |||
| 53 | } | 78 | } | ||
| 54 | 79 | ||||
| 55 | // home upserts the row of the repository an event is about. The row is read | 80 | // home upserts the row of the repository an event is about. The row is read | ||
| 56 | - | // again from the API as the visitor of the stream, so a repository that was | 81 | + | // again from the use case as the principal of the stream, so a repository |
| 57 | - | // deleted or that the visitor can no longer see (public to private) removes | 82 | + | // that was deleted or that the visitor can no longer see (public to private) |
| 58 | - | // the row, and the stream never carries more than the list shows. | 83 | + | // removes the row, and the stream never carries more than the list shows. |
| 59 | - | func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) { | 84 | + | func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { |
| 60 | var name string | 85 | var name string | ||
| 61 | fresh := false | 86 | fresh := false | ||
| 62 | - | switch k := ev.GetKind().(type) { | 87 | + | switch ev := we.Event.(type) { |
| 63 | - | case *pb.Event_RepoDeleted: | 88 | + | case domain.RepoDeleted: |
| 64 | - | return l.gone(ctx, k.RepoDeleted.GetRepo()) | 89 | + | return l.gone(ctx, ev.Repo.String()) |
| 65 | - | case *pb.Event_RepoCreated: | 90 | + | case domain.RepoCreated: |
| 66 | - | name = k.RepoCreated.GetRepo().GetName() | 91 | + | name = ev.Repo.String() |
| 67 | - | case *pb.Event_RepoUpdated: | 92 | + | case domain.RepoUpdated: |
| 68 | - | name = k.RepoUpdated.GetRepo().GetName() | 93 | + | name = ev.Repo.String() |
| 69 | - | case *pb.Event_Pushed: | 94 | + | case domain.PushEvent: |
| 70 | - | name, fresh = k.Pushed.GetRepo(), true | 95 | + | name, fresh = ev.Repo.String(), true |
| 71 | default: | 96 | default: | ||
| 72 | return nil, nil | 97 | return nil, nil | ||
| 73 | } | 98 | } | ||
| 74 | - | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | 99 | + | view, err := l.repoByName(ctx, name) |
| 75 | switch { | 100 | switch { | ||
| 76 | - | case connect.CodeOf(err) == connect.CodeNotFound: | 101 | + | case errors.Is(err, domain.ErrNotFound): |
| 77 | return l.gone(ctx, name) | 102 | return l.gone(ctx, name) | ||
| 78 | case err != nil: | 103 | case err != nil: | ||
| 79 | return nil, err | 104 | return nil, err | ||
| 80 | } | 105 | } | ||
| 81 | - | row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false) | 106 | + | row := homeRow(view, l.s.cfg.Now(), false) |
| 82 | row.Fresh = fresh | 107 | row.Fresh = fresh | ||
| 83 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | 108 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | ||
| 84 | return []fragment{f}, err | 109 | return []fragment{f}, err | ||
| @@ -90,16 +115,16 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||||
| 90 | } | 115 | } | ||
| 91 | 116 | ||||
| 92 | // repoPage handles the events of the topic of one repository. | 117 | // repoPage handles the events of the topic of one repository. | ||
| 93 | - | func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) { | 118 | + | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { |
| 94 | - | switch k := ev.GetKind().(type) { | 119 | + | switch ev := we.Event.(type) { |
| 95 | - | case *pb.Event_RepoDeleted, *pb.Event_RepoCreated: | 120 | + | case domain.RepoDeleted, domain.RepoCreated: |
| 96 | // Gone, or a new repository under the same name: whatever the page | 121 | // Gone, or a new repository under the same name: whatever the page | ||
| 97 | // shows is not true any more. | 122 | // shows is not true any more. | ||
| 98 | return gap, nil | 123 | return gap, nil | ||
| 99 | - | case *pb.Event_RepoUpdated: | 124 | + | case domain.RepoUpdated: |
| 100 | - | _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | 125 | + | _, err := l.repoByName(ctx, l.repo) |
| 101 | switch { | 126 | switch { | ||
| 102 | - | case connect.CodeOf(err) == connect.CodeNotFound: | 127 | + | case errors.Is(err, domain.ErrNotFound): |
| 103 | return gap, nil // no longer visible | 128 | return gap, nil // no longer visible | ||
| 104 | case err != nil: | 129 | case err != nil: | ||
| 105 | return nil, err | 130 | return nil, err | ||
| @@ -107,26 +132,26 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er | |||||
| 107 | return gap, nil // the description, the default branch | 132 | return gap, nil // the description, the default branch | ||
| 108 | } | 133 | } | ||
| 109 | return nil, nil | 134 | return nil, nil | ||
| 110 | - | case *pb.Event_MergeRequestOpened: | 135 | + | case domain.MergeRequestOpened: |
| 111 | - | return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload") | 136 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload") |
| 112 | - | case *pb.Event_MergeRequestMerged: | 137 | + | case domain.MergeRequestMerged: |
| 113 | - | return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload") | 138 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload") |
| 114 | - | case *pb.Event_MergeRequestClosed: | 139 | + | case domain.MergeRequestClosed: |
| 115 | - | return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload") | 140 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") |
| 116 | - | case *pb.Event_CommentAdded: | 141 | + | case domain.CommentAdded: |
| 117 | - | return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload") | 142 | + | return l.mrNotice(ctx, ev.Number, "a comment was added — reload") |
| 118 | - | case *pb.Event_Pushed: | 143 | + | case domain.PushEvent: |
| 119 | switch l.view { | 144 | switch l.view { | ||
| 120 | case "mr": | 145 | case "mr": | ||
| 121 | - | return l.mrPushed(ctx, k.Pushed) | 146 | + | return l.mrPushed(ctx, ev) |
| 122 | case "mrs": | 147 | case "mrs": | ||
| 123 | return nil, nil | 148 | return nil, nil | ||
| 124 | } | 149 | } | ||
| 125 | switch l.view { | 150 | switch l.view { | ||
| 126 | case "refs": | 151 | case "refs": | ||
| 127 | - | return l.refs(ctx, ev.GetId()) | 152 | + | return l.refs(ctx, we.ID) |
| 128 | case "commits": | 153 | case "commits": | ||
| 129 | - | return l.banner(ctx, k.Pushed) | 154 | + | return l.banner(ctx, ev) |
| 130 | } | 155 | } | ||
| 131 | return l.lastPush(ctx) | 156 | return l.lastPush(ctx) | ||
| 132 | } | 157 | } | ||
| @@ -135,11 +160,11 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er | |||||
| 135 | 160 | ||||
| 136 | // lastPush is the "last push" fact of the summary. | 161 | // lastPush is the "last push" fact of the summary. | ||
| 137 | func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) { | 162 | func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) { | ||
| 138 | - | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | 163 | + | view, err := l.repoByName(ctx, l.repo) |
| 139 | if err != nil { | 164 | if err != nil { | ||
| 140 | return nil, err | 165 | return nil, err | ||
| 141 | } | 166 | } | ||
| 142 | - | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now()))) | 167 | + | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(view, l.s.cfg.Now()))) |
| 143 | return []fragment{f}, err | 168 | return []fragment{f}, err | ||
| 144 | } | 169 | } | ||
| 145 | 170 | ||||
| @@ -260,14 +285,19 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) | |||||
| 260 | 285 | ||||
| 261 | // banner tells the commits page how many commits its branch got since the | 286 | // banner tells the commits page how many commits its branch got since the | ||
| 262 | // head it lists. | 287 | // head it lists. | ||
| 263 | - | func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | 288 | + | // |
| 289 | + | //hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only | |||
| 290 | + | func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |||
| 264 | if l.ref == "" || !isFullID(l.head) { | 291 | if l.ref == "" || !isFullID(l.head) { | ||
| 265 | return nil, nil | 292 | return nil, nil | ||
| 266 | } | 293 | } | ||
| 267 | tip, touched := "", false | 294 | tip, touched := "", false | ||
| 268 | - | for _, u := range p.GetRefUpdates() { | 295 | + | for _, u := range p.Updates { |
| 269 | - | if u.GetRef() == "refs/heads/"+l.ref { | 296 | + | if u.Ref == "refs/heads/"+l.ref { |
| 270 | - | tip, touched = u.GetNewId(), true | 297 | + | tip, touched = u.New, true |
| 298 | + | if u.IsDelete() { | |||
| 299 | + | tip = "" | |||
| 300 | + | } | |||
| 271 | } | 301 | } | ||
| 272 | } | 302 | } | ||
| 273 | switch { | 303 | switch { | ||
| @@ -278,9 +308,11 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||||
| 278 | } | 308 | } | ||
| 279 | text := "" | 309 | text := "" | ||
| 280 | if tip != l.head { | 310 | if tip != l.head { | ||
| 281 | - | res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 311 | + | name, err := domain.ParseRepoName(l.repo) |
| 282 | - | Repo: l.repo, Revision: tip, PageSize: pageSize, | 312 | + | if err != nil { |
| 283 | - | })) | 313 | + | return nil, err |
| 314 | + | } | |||
| 315 | + | res, err := l.s.commitList(ctx, name, tip, "", pageSize, "") | |||
| 284 | if err != nil { | 316 | if err != nil { | ||
| 285 | return nil, err | 317 | return nil, err | ||
| 286 | } | 318 | } | ||
| @@ -288,10 +320,10 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||||
| 288 | // ordered by date, not as "head..tip", so commits that are not new may | 320 | // ordered by date, not as "head..tip", so commits that are not new may | ||
| 289 | // be counted before the head is met. And one page of the log being | 321 | // be counted before the head is met. And one page of the log being | ||
| 290 | // all that is read, "100+" cannot be told from a rewritten branch | 322 | // all that is read, "100+" cannot be told from a rewritten branch | ||
| 291 | - | // whose old head is further back: the API has no "is ancestor". | 323 | + | // whose old head is further back: the use cases have no "is ancestor". |
| 292 | n, found := 0, false | 324 | n, found := 0, false | ||
| 293 | - | for _, c := range res.Msg.GetCommits() { | 325 | + | for _, c := range res.Commits { |
| 294 | - | if c.GetId() == l.head { | 326 | + | if c.ID.String() == l.head { |
| 295 | found = true | 327 | found = true | ||
| 296 | break | 328 | break | ||
| 297 | } | 329 | } | ||
| @@ -300,7 +332,7 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||||
| 300 | switch { | 332 | switch { | ||
| 301 | case found: | 333 | case found: | ||
| 302 | text = newCommits(strconv.Itoa(n), n) | 334 | text = newCommits(strconv.Itoa(n), n) | ||
| 303 | - | case res.Msg.GetNextPageToken() != "": | 335 | + | case res.NextPageToken != "": |
| 304 | text = newCommits(strconv.Itoa(n)+"+", 2) | 336 | text = newCommits(strconv.Itoa(n)+"+", 2) | ||
| 305 | default: | 337 | default: | ||
| 306 | text = "the branch was rewritten — reload" | 338 | text = "the branch was rewritten — reload" | ||
| @@ -343,12 +375,12 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([ | |||||
| 343 | } | 375 | } | ||
| 344 | 376 | ||||
| 345 | // mrPushed announces a push to the source branch of the merge request shown. | 377 | // mrPushed announces a push to the source branch of the merge request shown. | ||
| 346 | - | func (l *liveStream) mrPushed(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | 378 | + | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { |
| 347 | if l.ref == "" { | 379 | if l.ref == "" { | ||
| 348 | return nil, nil | 380 | return nil, nil | ||
| 349 | } | 381 | } | ||
| 350 | - | for _, u := range p.GetRefUpdates() { | 382 | + | for _, u := range p.Updates { |
| 351 | - | if u.GetRef() == "refs/heads/"+l.ref { | 383 | + | if u.Ref == "refs/heads/"+l.ref { |
| 352 | n, err := strconv.ParseInt(l.n, 10, 64) | 384 | n, err := strconv.ParseInt(l.n, 10, 64) | ||
| 353 | if err != nil { | 385 | if err != nil { | ||
| 354 | return nil, nil | 386 | return nil, nil | ||
A internal/adapters/web/helpers_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // testPublicURL and testSourceURL are the instance of the servers the tests | |||
| 18 | + | // build. | |||
| 19 | + | const ( | |||
| 20 | + | testPublicURL = "https://forge.example" | |||
| 21 | + | testSourceURL = "https://forge.example/ermite" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // repoName parses a repository name the test knows is valid. | |||
| 25 | + | func repoName(t testing.TB, s string) domain.RepoName { | |||
| 26 | + | t.Helper() | |||
| 27 | + | n, err := domain.ParseRepoName(s) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | return n | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // objectID parses a commit id the test knows is valid. | |||
| 33 | + | func objectID(t testing.TB, s string) domain.ObjectID { | |||
| 34 | + | t.Helper() | |||
| 35 | + | id, err := domain.ParseObjectID(s) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | return id | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // publicRepo is the repository the use cases of the tests answer with: | |||
| 41 | + | // public, with main as its default branch. | |||
| 42 | + | func publicRepo(t testing.TB, name string) ports.RepoView { | |||
| 43 | + | t.Helper() | |||
| 44 | + | return ports.RepoView{ | |||
| 45 | + | Repo: domain.Repo{Name: repoName(t, name), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}, | |||
| 46 | + | DefaultBranch: "main", | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // reposFailing is a RepoService whose reads fail with err, whatever they ask. | |||
| 51 | + | func reposFailing(t testing.TB, err error) *mocks.RepoService { | |||
| 52 | + | t.Helper() | |||
| 53 | + | m := mocks.NewRepoService(t) | |||
| 54 | + | m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, err).Maybe() | |||
| 55 | + | m.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, err).Maybe() | |||
| 56 | + | return m | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // reposServing is a RepoService that knows the repositories, by name, as seen | |||
| 60 | + | // by every caller. | |||
| 61 | + | func reposServing(t testing.TB, views ...ports.RepoView) *mocks.RepoService { | |||
| 62 | + | t.Helper() | |||
| 63 | + | m := mocks.NewRepoService(t) | |||
| 64 | + | byName := map[string]ports.RepoView{} | |||
| 65 | + | for _, v := range views { | |||
| 66 | + | byName[v.Name.String()] = v | |||
| 67 | + | } | |||
| 68 | + | m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 69 | + | func(_ context.Context, _ domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 70 | + | v, ok := byName[name.String()] | |||
| 71 | + | if !ok { | |||
| 72 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 73 | + | } | |||
| 74 | + | return v.Repo, nil | |||
| 75 | + | }).Maybe() | |||
| 76 | + | m.EXPECT().View(mock.Anything, mock.Anything).RunAndReturn( | |||
| 77 | + | func(_ context.Context, r domain.Repo) (ports.RepoView, error) { | |||
| 78 | + | v, ok := byName[r.Name.String()] | |||
| 79 | + | if !ok { | |||
| 80 | + | return ports.RepoView{}, domain.ErrNotFound | |||
| 81 | + | } | |||
| 82 | + | return v, nil | |||
| 83 | + | }).Maybe() | |||
| 84 | + | m.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 85 | + | func(context.Context, domain.Principal, int, string) (ports.RepoPage, error) { | |||
| 86 | + | var page ports.RepoPage | |||
| 87 | + | for _, v := range views { | |||
| 88 | + | page.Repos = append(page.Repos, v.Repo) | |||
| 89 | + | } | |||
| 90 | + | return page, nil | |||
| 91 | + | }).Maybe() | |||
| 92 | + | return m | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // newTestServer builds a Server from cfg, with the instance filled in. | |||
| 96 | + | func newTestServer(t testing.TB, cfg Config) *Server { | |||
| 97 | + | t.Helper() | |||
| 98 | + | if cfg.PublicURL == "" { | |||
| 99 | + | cfg.PublicURL = testPublicURL | |||
| 100 | + | } | |||
| 101 | + | if cfg.SourceURL == "" { | |||
| 102 | + | cfg.SourceURL = testSourceURL | |||
| 103 | + | } | |||
| 104 | + | if cfg.Version == "" { | |||
| 105 | + | cfg.Version = "v1" | |||
| 106 | + | } | |||
| 107 | + | srv, err := New(cfg) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | return srv | |||
| 110 | + | } | |||
M internal/adapters/web/highlight_test.go +5 -5
| @@ -11,7 +11,7 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/domain" |
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | func TestHighlightLines(t *testing.T) { | 17 | func TestHighlightLines(t *testing.T) { | ||
| @@ -141,12 +141,12 @@ func TestSniffImage(t *testing.T) { | |||||
| 141 | 141 | ||||
| 142 | func TestLocate(t *testing.T) { | 142 | func TestLocate(t *testing.T) { | ||
| 143 | t.Parallel() | 143 | t.Parallel() | ||
| 144 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | 144 | + | b, tag := domain.RefKindBranch, domain.RefKindTag |
| 145 | d := &repoData{ | 145 | d := &repoData{ | ||
| 146 | - | branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, | 146 | + | branches: []domain.Ref{ref(b, "main"), ref(b, "feature/x")}, |
| 147 | - | tags: []*pb.Ref{ref(tag, "v1/rc")}, | 147 | + | tags: []domain.Ref{ref(tag, "v1/rc")}, |
| 148 | } | 148 | } | ||
| 149 | - | d.head = d.branches[0] | 149 | + | d.head = &d.branches[0] |
| 150 | for _, tt := range []struct { | 150 | for _, tt := range []struct { | ||
| 151 | in, name, path string | 151 | in, name, path string | ||
| 152 | detached, ok bool | 152 | detached, ok bool | ||
M internal/adapters/web/home.go +31 -26
| @@ -9,34 +9,39 @@ import ( | |||||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | - | "connectrpc.com/connect" | |||
| 13 | "github.com/a-h/templ" | 12 | "github.com/a-h/templ" | ||
| 14 | 13 | ||||
| 15 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/ports" |
| 16 | ) | 15 | ) | ||
| 17 | 16 | ||||
| 18 | // maxRepos bounds the repositories one page lists. | 17 | // maxRepos bounds the repositories one page lists. | ||
| 19 | const maxRepos = 1000 | 18 | const maxRepos = 1000 | ||
| 20 | 19 | ||||
| 21 | - | // pageSize is the page size asked from the commit lists: the largest the API | 20 | + | // pageSize is the page size asked from the commit lists: the largest the use |
| 22 | - | // accepts for them. | 21 | + | // case accepts for them. |
| 23 | const pageSize = 100 | 22 | const pageSize = 100 | ||
| 24 | 23 | ||||
| 25 | // listRepos reads the repositories the visitor may see, ordered by name. | 24 | // listRepos reads the repositories the visitor may see, ordered by name. | ||
| 26 | // Every page of ListRepos scans the whole repository list and reads the HEAD | 25 | // Every page of ListRepos scans the whole repository list and reads the HEAD | ||
| 27 | // of each repository of the page, so the list is asked in one page of up to | 26 | // of each repository of the page, so the list is asked in one page of up to | ||
| 28 | - | // maxRepos: one scan in the common case, whatever the API cap is (a lower | 27 | + | // maxRepos: one scan in the common case, whatever the cap of the use case is |
| 29 | - | // one only means more pages, followed by their token). | 28 | + | // (a lower one only means more pages, followed by their token). |
| 30 | - | func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) { | 29 | + | func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) { |
| 31 | - | var repos []*pb.Repo | 30 | + | var repos []ports.RepoView |
| 32 | token := "" | 31 | token := "" | ||
| 33 | for len(repos) < maxRepos { | 32 | for len(repos) < maxRepos { | ||
| 34 | - | res, err := s.cfg.Repos.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: maxRepos, PageToken: token})) | 33 | + | page, err := s.cfg.Repos.ListPage(ctx, principal(ctx), maxRepos, token) |
| 35 | if err != nil { | 34 | if err != nil { | ||
| 36 | return nil, err | 35 | return nil, err | ||
| 37 | } | 36 | } | ||
| 38 | - | repos = append(repos, res.Msg.GetRepos()...) | 37 | + | for _, repo := range page.Repos { |
| 39 | - | if token = res.Msg.GetNextPageToken(); token == "" { | 38 | + | view, err := s.cfg.Repos.View(ctx, repo) |
| 39 | + | if err != nil { | |||
| 40 | + | return nil, err | |||
| 41 | + | } | |||
| 42 | + | repos = append(repos, view) | |||
| 43 | + | } | |||
| 44 | + | if token = page.NextPageToken; token == "" { | |||
| 40 | break | 45 | break | ||
| 41 | } | 46 | } | ||
| 42 | } | 47 | } | ||
| @@ -52,17 +57,17 @@ var homeCols = []struct{ key, label, class string }{ | |||||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| 54 | // sortRepos orders repos by the column key; ties keep the order by name. | 59 | // sortRepos orders repos by the column key; ties keep the order by name. | ||
| 55 | - | func sortRepos(repos []*pb.Repo, key string, desc bool) { | 60 | + | func sortRepos(repos []ports.RepoView, key string, desc bool) { |
| 56 | - | less := func(a, b *pb.Repo) bool { | 61 | + | less := func(a, b ports.RepoView) bool { |
| 57 | switch key { | 62 | switch key { | ||
| 58 | case "desc": | 63 | case "desc": | ||
| 59 | - | return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription()) | 64 | + | return strings.ToLower(a.Description) < strings.ToLower(b.Description) |
| 60 | case "branch": | 65 | case "branch": | ||
| 61 | - | return a.GetDefaultBranch() < b.GetDefaultBranch() | 66 | + | return a.DefaultBranch < b.DefaultBranch |
| 62 | case "push": | 67 | case "push": | ||
| 63 | - | return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime()) | 68 | + | return a.PushedAt.Before(b.PushedAt) |
| 64 | } | 69 | } | ||
| 65 | - | return a.GetName() < b.GetName() | 70 | + | return a.Name.String() < b.Name.String() |
| 66 | } | 71 | } | ||
| 67 | sort.SliceStable(repos, func(i, j int) bool { | 72 | sort.SliceStable(repos, func(i, j int) bool { | ||
| 68 | if desc { | 73 | if desc { | ||
| @@ -118,25 +123,25 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 118 | } | 123 | } | ||
| 119 | 124 | ||||
| 120 | // homeRow is the row of a repository in the home list. | 125 | // homeRow is the row of a repository in the home list. | ||
| 121 | - | func homeRow(r *pb.Repo, now time.Time, cursor bool) HomeRow { | 126 | + | func homeRow(r ports.RepoView, now time.Time, cursor bool) HomeRow { |
| 127 | + | name := r.Name.String() | |||
| 122 | row := HomeRow{ | 128 | row := HomeRow{ | ||
| 123 | - | ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()), | 129 | + | ID: "repo-" + name, Name: name, Href: repoHref(name), |
| 124 | - | Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor, | 130 | + | Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor, |
| 125 | } | 131 | } | ||
| 126 | - | if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 { | 132 | + | if !r.PushedAt.IsZero() { |
| 127 | - | t := r.GetPushedAt().AsTime() | 133 | + | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) |
| 128 | - | row.Pushed, row.PushedAt = ago(now, t), rfc3339(t) | |||
| 129 | } | 134 | } | ||
| 130 | return row | 135 | return row | ||
| 131 | } | 136 | } | ||
| 132 | 137 | ||||
| 133 | // repoCmds are the repositories as targets of the ":" command line, but the | 138 | // repoCmds are the repositories as targets of the ":" command line, but the | ||
| 134 | // one being viewed. | 139 | // one being viewed. | ||
| 135 | - | func repoCmds(repos []*pb.Repo, except string) []CmdItem { | 140 | + | func repoCmds(repos []ports.RepoView, except string) []CmdItem { |
| 136 | var out []CmdItem | 141 | var out []CmdItem | ||
| 137 | for _, r := range repos { | 142 | for _, r := range repos { | ||
| 138 | - | if r.GetName() != except { | 143 | + | if name := r.Name.String(); name != except { |
| 139 | - | out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())}) | 144 | + | out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)}) |
| 140 | } | 145 | } | ||
| 141 | } | 146 | } | ||
| 142 | return out | 147 | return out | ||
D internal/adapters/web/mocks/GitServiceClient.go +0 -457
| @@ -1,457 +0,0 @@ | |||||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | - | // github.com/vektra/mockery | |||
| 3 | - | // template: testify | |||
| 4 | - | ||||
| 5 | - | package mocks | |||
| 6 | - | ||||
| 7 | - | import ( | |||
| 8 | - | "context" | |||
| 9 | - | ||||
| 10 | - | "bnema.dev/ermite-api/gen/ermite" | |||
| 11 | - | "connectrpc.com/connect" | |||
| 12 | - | mock "github.com/stretchr/testify/mock" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // NewGitServiceClient creates a new instance of GitServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | - | // The first argument is typically a *testing.T value. | |||
| 17 | - | func NewGitServiceClient(t interface { | |||
| 18 | - | mock.TestingT | |||
| 19 | - | Cleanup(func()) | |||
| 20 | - | }) *GitServiceClient { | |||
| 21 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | - | helper.Helper() | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | mock := &GitServiceClient{} | |||
| 26 | - | mock.Mock.Test(t) | |||
| 27 | - | ||||
| 28 | - | t.Cleanup(func() { | |||
| 29 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | - | helper.Helper() | |||
| 31 | - | } | |||
| 32 | - | mock.AssertExpectations(t) | |||
| 33 | - | }) | |||
| 34 | - | ||||
| 35 | - | return mock | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // GitServiceClient is an autogenerated mock type for the GitServiceClient type | |||
| 39 | - | type GitServiceClient struct { | |||
| 40 | - | mock.Mock | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | type GitServiceClient_Expecter struct { | |||
| 44 | - | mock *mock.Mock | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | func (_m *GitServiceClient) EXPECT() *GitServiceClient_Expecter { | |||
| 48 | - | return &GitServiceClient_Expecter{mock: &_m.Mock} | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | // GetBlob provides a mock function for the type GitServiceClient | |||
| 52 | - | func (_mock *GitServiceClient) GetBlob(context1 context.Context, request *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error) { | |||
| 53 | - | ret := _mock.Called(context1, request) | |||
| 54 | - | ||||
| 55 | - | if len(ret) == 0 { | |||
| 56 | - | panic("no return value specified for GetBlob") | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | var r0 *connect.ServerStreamForClient[ermite.GetBlobResponse] | |||
| 60 | - | var r1 error | |||
| 61 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error)); ok { | |||
| 62 | - | return returnFunc(context1, request) | |||
| 63 | - | } | |||
| 64 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) *connect.ServerStreamForClient[ermite.GetBlobResponse]); ok { | |||
| 65 | - | r0 = returnFunc(context1, request) | |||
| 66 | - | } else { | |||
| 67 | - | if ret.Get(0) != nil { | |||
| 68 | - | r0 = ret.Get(0).(*connect.ServerStreamForClient[ermite.GetBlobResponse]) | |||
| 69 | - | } | |||
| 70 | - | } | |||
| 71 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) error); ok { | |||
| 72 | - | r1 = returnFunc(context1, request) | |||
| 73 | - | } else { | |||
| 74 | - | r1 = ret.Error(1) | |||
| 75 | - | } | |||
| 76 | - | return r0, r1 | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // GitServiceClient_GetBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlob' | |||
| 80 | - | type GitServiceClient_GetBlob_Call struct { | |||
| 81 | - | *mock.Call | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | // GetBlob is a helper method to define mock.On call | |||
| 85 | - | // - context1 context.Context | |||
| 86 | - | // - request *connect.Request[ermite.GetBlobRequest] | |||
| 87 | - | func (_e *GitServiceClient_Expecter) GetBlob(context1 any, request any) *GitServiceClient_GetBlob_Call { | |||
| 88 | - | return &GitServiceClient_GetBlob_Call{Call: _e.mock.On("GetBlob", context1, request)} | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func (_c *GitServiceClient_GetBlob_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetBlobRequest])) *GitServiceClient_GetBlob_Call { | |||
| 92 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 93 | - | var arg0 context.Context | |||
| 94 | - | if args[0] != nil { | |||
| 95 | - | arg0 = args[0].(context.Context) | |||
| 96 | - | } | |||
| 97 | - | var arg1 *connect.Request[ermite.GetBlobRequest] | |||
| 98 | - | if args[1] != nil { | |||
| 99 | - | arg1 = args[1].(*connect.Request[ermite.GetBlobRequest]) | |||
| 100 | - | } | |||
| 101 | - | run( | |||
| 102 | - | arg0, | |||
| 103 | - | arg1, | |||
| 104 | - | ) | |||
| 105 | - | }) | |||
| 106 | - | return _c | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func (_c *GitServiceClient_GetBlob_Call) Return(serverStreamForClient *connect.ServerStreamForClient[ermite.GetBlobResponse], err error) *GitServiceClient_GetBlob_Call { | |||
| 110 | - | _c.Call.Return(serverStreamForClient, err) | |||
| 111 | - | return _c | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | func (_c *GitServiceClient_GetBlob_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error)) *GitServiceClient_GetBlob_Call { | |||
| 115 | - | _c.Call.Return(run) | |||
| 116 | - | return _c | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | // GetCommit provides a mock function for the type GitServiceClient | |||
| 120 | - | func (_mock *GitServiceClient) GetCommit(context1 context.Context, request *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error) { | |||
| 121 | - | ret := _mock.Called(context1, request) | |||
| 122 | - | ||||
| 123 | - | if len(ret) == 0 { | |||
| 124 | - | panic("no return value specified for GetCommit") | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | var r0 *connect.Response[ermite.GetCommitResponse] | |||
| 128 | - | var r1 error | |||
| 129 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error)); ok { | |||
| 130 | - | return returnFunc(context1, request) | |||
| 131 | - | } | |||
| 132 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) *connect.Response[ermite.GetCommitResponse]); ok { | |||
| 133 | - | r0 = returnFunc(context1, request) | |||
| 134 | - | } else { | |||
| 135 | - | if ret.Get(0) != nil { | |||
| 136 | - | r0 = ret.Get(0).(*connect.Response[ermite.GetCommitResponse]) | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) error); ok { | |||
| 140 | - | r1 = returnFunc(context1, request) | |||
| 141 | - | } else { | |||
| 142 | - | r1 = ret.Error(1) | |||
| 143 | - | } | |||
| 144 | - | return r0, r1 | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | // GitServiceClient_GetCommit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommit' | |||
| 148 | - | type GitServiceClient_GetCommit_Call struct { | |||
| 149 | - | *mock.Call | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | // GetCommit is a helper method to define mock.On call | |||
| 153 | - | // - context1 context.Context | |||
| 154 | - | // - request *connect.Request[ermite.GetCommitRequest] | |||
| 155 | - | func (_e *GitServiceClient_Expecter) GetCommit(context1 any, request any) *GitServiceClient_GetCommit_Call { | |||
| 156 | - | return &GitServiceClient_GetCommit_Call{Call: _e.mock.On("GetCommit", context1, request)} | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | func (_c *GitServiceClient_GetCommit_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetCommitRequest])) *GitServiceClient_GetCommit_Call { | |||
| 160 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 161 | - | var arg0 context.Context | |||
| 162 | - | if args[0] != nil { | |||
| 163 | - | arg0 = args[0].(context.Context) | |||
| 164 | - | } | |||
| 165 | - | var arg1 *connect.Request[ermite.GetCommitRequest] | |||
| 166 | - | if args[1] != nil { | |||
| 167 | - | arg1 = args[1].(*connect.Request[ermite.GetCommitRequest]) | |||
| 168 | - | } | |||
| 169 | - | run( | |||
| 170 | - | arg0, | |||
| 171 | - | arg1, | |||
| 172 | - | ) | |||
| 173 | - | }) | |||
| 174 | - | return _c | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | func (_c *GitServiceClient_GetCommit_Call) Return(response *connect.Response[ermite.GetCommitResponse], err error) *GitServiceClient_GetCommit_Call { | |||
| 178 | - | _c.Call.Return(response, err) | |||
| 179 | - | return _c | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | func (_c *GitServiceClient_GetCommit_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error)) *GitServiceClient_GetCommit_Call { | |||
| 183 | - | _c.Call.Return(run) | |||
| 184 | - | return _c | |||
| 185 | - | } | |||
| 186 | - | ||||
| 187 | - | // GetTree provides a mock function for the type GitServiceClient | |||
| 188 | - | func (_mock *GitServiceClient) GetTree(context1 context.Context, request *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error) { | |||
| 189 | - | ret := _mock.Called(context1, request) | |||
| 190 | - | ||||
| 191 | - | if len(ret) == 0 { | |||
| 192 | - | panic("no return value specified for GetTree") | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | var r0 *connect.Response[ermite.GetTreeResponse] | |||
| 196 | - | var r1 error | |||
| 197 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error)); ok { | |||
| 198 | - | return returnFunc(context1, request) | |||
| 199 | - | } | |||
| 200 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) *connect.Response[ermite.GetTreeResponse]); ok { | |||
| 201 | - | r0 = returnFunc(context1, request) | |||
| 202 | - | } else { | |||
| 203 | - | if ret.Get(0) != nil { | |||
| 204 | - | r0 = ret.Get(0).(*connect.Response[ermite.GetTreeResponse]) | |||
| 205 | - | } | |||
| 206 | - | } | |||
| 207 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) error); ok { | |||
| 208 | - | r1 = returnFunc(context1, request) | |||
| 209 | - | } else { | |||
| 210 | - | r1 = ret.Error(1) | |||
| 211 | - | } | |||
| 212 | - | return r0, r1 | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | // GitServiceClient_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree' | |||
| 216 | - | type GitServiceClient_GetTree_Call struct { | |||
| 217 | - | *mock.Call | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | // GetTree is a helper method to define mock.On call | |||
| 221 | - | // - context1 context.Context | |||
| 222 | - | // - request *connect.Request[ermite.GetTreeRequest] | |||
| 223 | - | func (_e *GitServiceClient_Expecter) GetTree(context1 any, request any) *GitServiceClient_GetTree_Call { | |||
| 224 | - | return &GitServiceClient_GetTree_Call{Call: _e.mock.On("GetTree", context1, request)} | |||
| 225 | - | } | |||
| 226 | - | ||||
| 227 | - | func (_c *GitServiceClient_GetTree_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetTreeRequest])) *GitServiceClient_GetTree_Call { | |||
| 228 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 229 | - | var arg0 context.Context | |||
| 230 | - | if args[0] != nil { | |||
| 231 | - | arg0 = args[0].(context.Context) | |||
| 232 | - | } | |||
| 233 | - | var arg1 *connect.Request[ermite.GetTreeRequest] | |||
| 234 | - | if args[1] != nil { | |||
| 235 | - | arg1 = args[1].(*connect.Request[ermite.GetTreeRequest]) | |||
| 236 | - | } | |||
| 237 | - | run( | |||
| 238 | - | arg0, | |||
| 239 | - | arg1, | |||
| 240 | - | ) | |||
| 241 | - | }) | |||
| 242 | - | return _c | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | func (_c *GitServiceClient_GetTree_Call) Return(response *connect.Response[ermite.GetTreeResponse], err error) *GitServiceClient_GetTree_Call { | |||
| 246 | - | _c.Call.Return(response, err) | |||
| 247 | - | return _c | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | func (_c *GitServiceClient_GetTree_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error)) *GitServiceClient_GetTree_Call { | |||
| 251 | - | _c.Call.Return(run) | |||
| 252 | - | return _c | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | // ListCommits provides a mock function for the type GitServiceClient | |||
| 256 | - | func (_mock *GitServiceClient) ListCommits(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error) { | |||
| 257 | - | ret := _mock.Called(context1, request) | |||
| 258 | - | ||||
| 259 | - | if len(ret) == 0 { | |||
| 260 | - | panic("no return value specified for ListCommits") | |||
| 261 | - | } | |||
| 262 | - | ||||
| 263 | - | var r0 *connect.Response[ermite.ListCommitsResponse] | |||
| 264 | - | var r1 error | |||
| 265 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error)); ok { | |||
| 266 | - | return returnFunc(context1, request) | |||
| 267 | - | } | |||
| 268 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) *connect.Response[ermite.ListCommitsResponse]); ok { | |||
| 269 | - | r0 = returnFunc(context1, request) | |||
| 270 | - | } else { | |||
| 271 | - | if ret.Get(0) != nil { | |||
| 272 | - | r0 = ret.Get(0).(*connect.Response[ermite.ListCommitsResponse]) | |||
| 273 | - | } | |||
| 274 | - | } | |||
| 275 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) error); ok { | |||
| 276 | - | r1 = returnFunc(context1, request) | |||
| 277 | - | } else { | |||
| 278 | - | r1 = ret.Error(1) | |||
| 279 | - | } | |||
| 280 | - | return r0, r1 | |||
| 281 | - | } | |||
| 282 | - | ||||
| 283 | - | // GitServiceClient_ListCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCommits' | |||
| 284 | - | type GitServiceClient_ListCommits_Call struct { | |||
| 285 | - | *mock.Call | |||
| 286 | - | } | |||
| 287 | - | ||||
| 288 | - | // ListCommits is a helper method to define mock.On call | |||
| 289 | - | // - context1 context.Context | |||
| 290 | - | // - request *connect.Request[ermite.ListCommitsRequest] | |||
| 291 | - | func (_e *GitServiceClient_Expecter) ListCommits(context1 any, request any) *GitServiceClient_ListCommits_Call { | |||
| 292 | - | return &GitServiceClient_ListCommits_Call{Call: _e.mock.On("ListCommits", context1, request)} | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | func (_c *GitServiceClient_ListCommits_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest])) *GitServiceClient_ListCommits_Call { | |||
| 296 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 297 | - | var arg0 context.Context | |||
| 298 | - | if args[0] != nil { | |||
| 299 | - | arg0 = args[0].(context.Context) | |||
| 300 | - | } | |||
| 301 | - | var arg1 *connect.Request[ermite.ListCommitsRequest] | |||
| 302 | - | if args[1] != nil { | |||
| 303 | - | arg1 = args[1].(*connect.Request[ermite.ListCommitsRequest]) | |||
| 304 | - | } | |||
| 305 | - | run( | |||
| 306 | - | arg0, | |||
| 307 | - | arg1, | |||
| 308 | - | ) | |||
| 309 | - | }) | |||
| 310 | - | return _c | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func (_c *GitServiceClient_ListCommits_Call) Return(response *connect.Response[ermite.ListCommitsResponse], err error) *GitServiceClient_ListCommits_Call { | |||
| 314 | - | _c.Call.Return(response, err) | |||
| 315 | - | return _c | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | func (_c *GitServiceClient_ListCommits_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error)) *GitServiceClient_ListCommits_Call { | |||
| 319 | - | _c.Call.Return(run) | |||
| 320 | - | return _c | |||
| 321 | - | } | |||
| 322 | - | ||||
| 323 | - | // ListRefs provides a mock function for the type GitServiceClient | |||
| 324 | - | func (_mock *GitServiceClient) ListRefs(context1 context.Context, request *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error) { | |||
| 325 | - | ret := _mock.Called(context1, request) | |||
| 326 | - | ||||
| 327 | - | if len(ret) == 0 { | |||
| 328 | - | panic("no return value specified for ListRefs") | |||
| 329 | - | } | |||
| 330 | - | ||||
| 331 | - | var r0 *connect.Response[ermite.ListRefsResponse] | |||
| 332 | - | var r1 error | |||
| 333 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error)); ok { | |||
| 334 | - | return returnFunc(context1, request) | |||
| 335 | - | } | |||
| 336 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) *connect.Response[ermite.ListRefsResponse]); ok { | |||
| 337 | - | r0 = returnFunc(context1, request) | |||
| 338 | - | } else { | |||
| 339 | - | if ret.Get(0) != nil { | |||
| 340 | - | r0 = ret.Get(0).(*connect.Response[ermite.ListRefsResponse]) | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) error); ok { | |||
| 344 | - | r1 = returnFunc(context1, request) | |||
| 345 | - | } else { | |||
| 346 | - | r1 = ret.Error(1) | |||
| 347 | - | } | |||
| 348 | - | return r0, r1 | |||
| 349 | - | } | |||
| 350 | - | ||||
| 351 | - | // GitServiceClient_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs' | |||
| 352 | - | type GitServiceClient_ListRefs_Call struct { | |||
| 353 | - | *mock.Call | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | // ListRefs is a helper method to define mock.On call | |||
| 357 | - | // - context1 context.Context | |||
| 358 | - | // - request *connect.Request[ermite.ListRefsRequest] | |||
| 359 | - | func (_e *GitServiceClient_Expecter) ListRefs(context1 any, request any) *GitServiceClient_ListRefs_Call { | |||
| 360 | - | return &GitServiceClient_ListRefs_Call{Call: _e.mock.On("ListRefs", context1, request)} | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | - | func (_c *GitServiceClient_ListRefs_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListRefsRequest])) *GitServiceClient_ListRefs_Call { | |||
| 364 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 365 | - | var arg0 context.Context | |||
| 366 | - | if args[0] != nil { | |||
| 367 | - | arg0 = args[0].(context.Context) | |||
| 368 | - | } | |||
| 369 | - | var arg1 *connect.Request[ermite.ListRefsRequest] | |||
| 370 | - | if args[1] != nil { | |||
| 371 | - | arg1 = args[1].(*connect.Request[ermite.ListRefsRequest]) | |||
| 372 | - | } | |||
| 373 | - | run( | |||
| 374 | - | arg0, | |||
| 375 | - | arg1, | |||
| 376 | - | ) | |||
| 377 | - | }) | |||
| 378 | - | return _c | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | func (_c *GitServiceClient_ListRefs_Call) Return(response *connect.Response[ermite.ListRefsResponse], err error) *GitServiceClient_ListRefs_Call { | |||
| 382 | - | _c.Call.Return(response, err) | |||
| 383 | - | return _c | |||
| 384 | - | } | |||
| 385 | - | ||||
| 386 | - | func (_c *GitServiceClient_ListRefs_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error)) *GitServiceClient_ListRefs_Call { | |||
| 387 | - | _c.Call.Return(run) | |||
| 388 | - | return _c | |||
| 389 | - | } | |||
| 390 | - | ||||
| 391 | - | // ResolveRevision provides a mock function for the type GitServiceClient | |||
| 392 | - | func (_mock *GitServiceClient) ResolveRevision(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error) { | |||
| 393 | - | ret := _mock.Called(context1, request) | |||
| 394 | - | ||||
| 395 | - | if len(ret) == 0 { | |||
| 396 | - | panic("no return value specified for ResolveRevision") | |||
| 397 | - | } | |||
| 398 | - | ||||
| 399 | - | var r0 *connect.Response[ermite.ResolveRevisionResponse] | |||
| 400 | - | var r1 error | |||
| 401 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error)); ok { | |||
| 402 | - | return returnFunc(context1, request) | |||
| 403 | - | } | |||
| 404 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) *connect.Response[ermite.ResolveRevisionResponse]); ok { | |||
| 405 | - | r0 = returnFunc(context1, request) | |||
| 406 | - | } else { | |||
| 407 | - | if ret.Get(0) != nil { | |||
| 408 | - | r0 = ret.Get(0).(*connect.Response[ermite.ResolveRevisionResponse]) | |||
| 409 | - | } | |||
| 410 | - | } | |||
| 411 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) error); ok { | |||
| 412 | - | r1 = returnFunc(context1, request) | |||
| 413 | - | } else { | |||
| 414 | - | r1 = ret.Error(1) | |||
| 415 | - | } | |||
| 416 | - | return r0, r1 | |||
| 417 | - | } | |||
| 418 | - | ||||
| 419 | - | // GitServiceClient_ResolveRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRevision' | |||
| 420 | - | type GitServiceClient_ResolveRevision_Call struct { | |||
| 421 | - | *mock.Call | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | // ResolveRevision is a helper method to define mock.On call | |||
| 425 | - | // - context1 context.Context | |||
| 426 | - | // - request *connect.Request[ermite.ResolveRevisionRequest] | |||
| 427 | - | func (_e *GitServiceClient_Expecter) ResolveRevision(context1 any, request any) *GitServiceClient_ResolveRevision_Call { | |||
| 428 | - | return &GitServiceClient_ResolveRevision_Call{Call: _e.mock.On("ResolveRevision", context1, request)} | |||
| 429 | - | } | |||
| 430 | - | ||||
| 431 | - | func (_c *GitServiceClient_ResolveRevision_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest])) *GitServiceClient_ResolveRevision_Call { | |||
| 432 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 433 | - | var arg0 context.Context | |||
| 434 | - | if args[0] != nil { | |||
| 435 | - | arg0 = args[0].(context.Context) | |||
| 436 | - | } | |||
| 437 | - | var arg1 *connect.Request[ermite.ResolveRevisionRequest] | |||
| 438 | - | if args[1] != nil { | |||
| 439 | - | arg1 = args[1].(*connect.Request[ermite.ResolveRevisionRequest]) | |||
| 440 | - | } | |||
| 441 | - | run( | |||
| 442 | - | arg0, | |||
| 443 | - | arg1, | |||
| 444 | - | ) | |||
| 445 | - | }) | |||
| 446 | - | return _c | |||
| 447 | - | } | |||
| 448 | - | ||||
| 449 | - | func (_c *GitServiceClient_ResolveRevision_Call) Return(response *connect.Response[ermite.ResolveRevisionResponse], err error) *GitServiceClient_ResolveRevision_Call { | |||
| 450 | - | _c.Call.Return(response, err) | |||
| 451 | - | return _c | |||
| 452 | - | } | |||
| 453 | - | ||||
| 454 | - | func (_c *GitServiceClient_ResolveRevision_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error)) *GitServiceClient_ResolveRevision_Call { | |||
| 455 | - | _c.Call.Return(run) | |||
| 456 | - | return _c | |||
| 457 | - | } | |||
D internal/adapters/web/mocks/RepoServiceClient.go +0 -389
| @@ -1,389 +0,0 @@ | |||||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | - | // github.com/vektra/mockery | |||
| 3 | - | // template: testify | |||
| 4 | - | ||||
| 5 | - | package mocks | |||
| 6 | - | ||||
| 7 | - | import ( | |||
| 8 | - | "context" | |||
| 9 | - | ||||
| 10 | - | "bnema.dev/ermite-api/gen/ermite" | |||
| 11 | - | "connectrpc.com/connect" | |||
| 12 | - | mock "github.com/stretchr/testify/mock" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // NewRepoServiceClient creates a new instance of RepoServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | - | // The first argument is typically a *testing.T value. | |||
| 17 | - | func NewRepoServiceClient(t interface { | |||
| 18 | - | mock.TestingT | |||
| 19 | - | Cleanup(func()) | |||
| 20 | - | }) *RepoServiceClient { | |||
| 21 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | - | helper.Helper() | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | mock := &RepoServiceClient{} | |||
| 26 | - | mock.Mock.Test(t) | |||
| 27 | - | ||||
| 28 | - | t.Cleanup(func() { | |||
| 29 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | - | helper.Helper() | |||
| 31 | - | } | |||
| 32 | - | mock.AssertExpectations(t) | |||
| 33 | - | }) | |||
| 34 | - | ||||
| 35 | - | return mock | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // RepoServiceClient is an autogenerated mock type for the RepoServiceClient type | |||
| 39 | - | type RepoServiceClient struct { | |||
| 40 | - | mock.Mock | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | type RepoServiceClient_Expecter struct { | |||
| 44 | - | mock *mock.Mock | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | func (_m *RepoServiceClient) EXPECT() *RepoServiceClient_Expecter { | |||
| 48 | - | return &RepoServiceClient_Expecter{mock: &_m.Mock} | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | // CreateRepo provides a mock function for the type RepoServiceClient | |||
| 52 | - | func (_mock *RepoServiceClient) CreateRepo(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error) { | |||
| 53 | - | ret := _mock.Called(context1, request) | |||
| 54 | - | ||||
| 55 | - | if len(ret) == 0 { | |||
| 56 | - | panic("no return value specified for CreateRepo") | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | var r0 *connect.Response[ermite.CreateRepoResponse] | |||
| 60 | - | var r1 error | |||
| 61 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error)); ok { | |||
| 62 | - | return returnFunc(context1, request) | |||
| 63 | - | } | |||
| 64 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) *connect.Response[ermite.CreateRepoResponse]); ok { | |||
| 65 | - | r0 = returnFunc(context1, request) | |||
| 66 | - | } else { | |||
| 67 | - | if ret.Get(0) != nil { | |||
| 68 | - | r0 = ret.Get(0).(*connect.Response[ermite.CreateRepoResponse]) | |||
| 69 | - | } | |||
| 70 | - | } | |||
| 71 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) error); ok { | |||
| 72 | - | r1 = returnFunc(context1, request) | |||
| 73 | - | } else { | |||
| 74 | - | r1 = ret.Error(1) | |||
| 75 | - | } | |||
| 76 | - | return r0, r1 | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // RepoServiceClient_CreateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRepo' | |||
| 80 | - | type RepoServiceClient_CreateRepo_Call struct { | |||
| 81 | - | *mock.Call | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | // CreateRepo is a helper method to define mock.On call | |||
| 85 | - | // - context1 context.Context | |||
| 86 | - | // - request *connect.Request[ermite.CreateRepoRequest] | |||
| 87 | - | func (_e *RepoServiceClient_Expecter) CreateRepo(context1 any, request any) *RepoServiceClient_CreateRepo_Call { | |||
| 88 | - | return &RepoServiceClient_CreateRepo_Call{Call: _e.mock.On("CreateRepo", context1, request)} | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func (_c *RepoServiceClient_CreateRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest])) *RepoServiceClient_CreateRepo_Call { | |||
| 92 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 93 | - | var arg0 context.Context | |||
| 94 | - | if args[0] != nil { | |||
| 95 | - | arg0 = args[0].(context.Context) | |||
| 96 | - | } | |||
| 97 | - | var arg1 *connect.Request[ermite.CreateRepoRequest] | |||
| 98 | - | if args[1] != nil { | |||
| 99 | - | arg1 = args[1].(*connect.Request[ermite.CreateRepoRequest]) | |||
| 100 | - | } | |||
| 101 | - | run( | |||
| 102 | - | arg0, | |||
| 103 | - | arg1, | |||
| 104 | - | ) | |||
| 105 | - | }) | |||
| 106 | - | return _c | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func (_c *RepoServiceClient_CreateRepo_Call) Return(response *connect.Response[ermite.CreateRepoResponse], err error) *RepoServiceClient_CreateRepo_Call { | |||
| 110 | - | _c.Call.Return(response, err) | |||
| 111 | - | return _c | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | func (_c *RepoServiceClient_CreateRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error)) *RepoServiceClient_CreateRepo_Call { | |||
| 115 | - | _c.Call.Return(run) | |||
| 116 | - | return _c | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | // DeleteRepo provides a mock function for the type RepoServiceClient | |||
| 120 | - | func (_mock *RepoServiceClient) DeleteRepo(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error) { | |||
| 121 | - | ret := _mock.Called(context1, request) | |||
| 122 | - | ||||
| 123 | - | if len(ret) == 0 { | |||
| 124 | - | panic("no return value specified for DeleteRepo") | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | var r0 *connect.Response[ermite.DeleteRepoResponse] | |||
| 128 | - | var r1 error | |||
| 129 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error)); ok { | |||
| 130 | - | return returnFunc(context1, request) | |||
| 131 | - | } | |||
| 132 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) *connect.Response[ermite.DeleteRepoResponse]); ok { | |||
| 133 | - | r0 = returnFunc(context1, request) | |||
| 134 | - | } else { | |||
| 135 | - | if ret.Get(0) != nil { | |||
| 136 | - | r0 = ret.Get(0).(*connect.Response[ermite.DeleteRepoResponse]) | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) error); ok { | |||
| 140 | - | r1 = returnFunc(context1, request) | |||
| 141 | - | } else { | |||
| 142 | - | r1 = ret.Error(1) | |||
| 143 | - | } | |||
| 144 | - | return r0, r1 | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | // RepoServiceClient_DeleteRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRepo' | |||
| 148 | - | type RepoServiceClient_DeleteRepo_Call struct { | |||
| 149 | - | *mock.Call | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | // DeleteRepo is a helper method to define mock.On call | |||
| 153 | - | // - context1 context.Context | |||
| 154 | - | // - request *connect.Request[ermite.DeleteRepoRequest] | |||
| 155 | - | func (_e *RepoServiceClient_Expecter) DeleteRepo(context1 any, request any) *RepoServiceClient_DeleteRepo_Call { | |||
| 156 | - | return &RepoServiceClient_DeleteRepo_Call{Call: _e.mock.On("DeleteRepo", context1, request)} | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | func (_c *RepoServiceClient_DeleteRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest])) *RepoServiceClient_DeleteRepo_Call { | |||
| 160 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 161 | - | var arg0 context.Context | |||
| 162 | - | if args[0] != nil { | |||
| 163 | - | arg0 = args[0].(context.Context) | |||
| 164 | - | } | |||
| 165 | - | var arg1 *connect.Request[ermite.DeleteRepoRequest] | |||
| 166 | - | if args[1] != nil { | |||
| 167 | - | arg1 = args[1].(*connect.Request[ermite.DeleteRepoRequest]) | |||
| 168 | - | } | |||
| 169 | - | run( | |||
| 170 | - | arg0, | |||
| 171 | - | arg1, | |||
| 172 | - | ) | |||
| 173 | - | }) | |||
| 174 | - | return _c | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | func (_c *RepoServiceClient_DeleteRepo_Call) Return(response *connect.Response[ermite.DeleteRepoResponse], err error) *RepoServiceClient_DeleteRepo_Call { | |||
| 178 | - | _c.Call.Return(response, err) | |||
| 179 | - | return _c | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | func (_c *RepoServiceClient_DeleteRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error)) *RepoServiceClient_DeleteRepo_Call { | |||
| 183 | - | _c.Call.Return(run) | |||
| 184 | - | return _c | |||
| 185 | - | } | |||
| 186 | - | ||||
| 187 | - | // GetRepo provides a mock function for the type RepoServiceClient | |||
| 188 | - | func (_mock *RepoServiceClient) GetRepo(context1 context.Context, request *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error) { | |||
| 189 | - | ret := _mock.Called(context1, request) | |||
| 190 | - | ||||
| 191 | - | if len(ret) == 0 { | |||
| 192 | - | panic("no return value specified for GetRepo") | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | var r0 *connect.Response[ermite.GetRepoResponse] | |||
| 196 | - | var r1 error | |||
| 197 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error)); ok { | |||
| 198 | - | return returnFunc(context1, request) | |||
| 199 | - | } | |||
| 200 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) *connect.Response[ermite.GetRepoResponse]); ok { | |||
| 201 | - | r0 = returnFunc(context1, request) | |||
| 202 | - | } else { | |||
| 203 | - | if ret.Get(0) != nil { | |||
| 204 | - | r0 = ret.Get(0).(*connect.Response[ermite.GetRepoResponse]) | |||
| 205 | - | } | |||
| 206 | - | } | |||
| 207 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) error); ok { | |||
| 208 | - | r1 = returnFunc(context1, request) | |||
| 209 | - | } else { | |||
| 210 | - | r1 = ret.Error(1) | |||
| 211 | - | } | |||
| 212 | - | return r0, r1 | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | // RepoServiceClient_GetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepo' | |||
| 216 | - | type RepoServiceClient_GetRepo_Call struct { | |||
| 217 | - | *mock.Call | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | // GetRepo is a helper method to define mock.On call | |||
| 221 | - | // - context1 context.Context | |||
| 222 | - | // - request *connect.Request[ermite.GetRepoRequest] | |||
| 223 | - | func (_e *RepoServiceClient_Expecter) GetRepo(context1 any, request any) *RepoServiceClient_GetRepo_Call { | |||
| 224 | - | return &RepoServiceClient_GetRepo_Call{Call: _e.mock.On("GetRepo", context1, request)} | |||
| 225 | - | } | |||
| 226 | - | ||||
| 227 | - | func (_c *RepoServiceClient_GetRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetRepoRequest])) *RepoServiceClient_GetRepo_Call { | |||
| 228 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 229 | - | var arg0 context.Context | |||
| 230 | - | if args[0] != nil { | |||
| 231 | - | arg0 = args[0].(context.Context) | |||
| 232 | - | } | |||
| 233 | - | var arg1 *connect.Request[ermite.GetRepoRequest] | |||
| 234 | - | if args[1] != nil { | |||
| 235 | - | arg1 = args[1].(*connect.Request[ermite.GetRepoRequest]) | |||
| 236 | - | } | |||
| 237 | - | run( | |||
| 238 | - | arg0, | |||
| 239 | - | arg1, | |||
| 240 | - | ) | |||
| 241 | - | }) | |||
| 242 | - | return _c | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | func (_c *RepoServiceClient_GetRepo_Call) Return(response *connect.Response[ermite.GetRepoResponse], err error) *RepoServiceClient_GetRepo_Call { | |||
| 246 | - | _c.Call.Return(response, err) | |||
| 247 | - | return _c | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | func (_c *RepoServiceClient_GetRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error)) *RepoServiceClient_GetRepo_Call { | |||
| 251 | - | _c.Call.Return(run) | |||
| 252 | - | return _c | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | // ListRepos provides a mock function for the type RepoServiceClient | |||
| 256 | - | func (_mock *RepoServiceClient) ListRepos(context1 context.Context, request *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error) { | |||
| 257 | - | ret := _mock.Called(context1, request) | |||
| 258 | - | ||||
| 259 | - | if len(ret) == 0 { | |||
| 260 | - | panic("no return value specified for ListRepos") | |||
| 261 | - | } | |||
| 262 | - | ||||
| 263 | - | var r0 *connect.Response[ermite.ListReposResponse] | |||
| 264 | - | var r1 error | |||
| 265 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error)); ok { | |||
| 266 | - | return returnFunc(context1, request) | |||
| 267 | - | } | |||
| 268 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListReposRequest]) *connect.Response[ermite.ListReposResponse]); ok { | |||
| 269 | - | r0 = returnFunc(context1, request) | |||
| 270 | - | } else { | |||
| 271 | - | if ret.Get(0) != nil { | |||
| 272 | - | r0 = ret.Get(0).(*connect.Response[ermite.ListReposResponse]) | |||
| 273 | - | } | |||
| 274 | - | } | |||
| 275 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListReposRequest]) error); ok { | |||
| 276 | - | r1 = returnFunc(context1, request) | |||
| 277 | - | } else { | |||
| 278 | - | r1 = ret.Error(1) | |||
| 279 | - | } | |||
| 280 | - | return r0, r1 | |||
| 281 | - | } | |||
| 282 | - | ||||
| 283 | - | // RepoServiceClient_ListRepos_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRepos' | |||
| 284 | - | type RepoServiceClient_ListRepos_Call struct { | |||
| 285 | - | *mock.Call | |||
| 286 | - | } | |||
| 287 | - | ||||
| 288 | - | // ListRepos is a helper method to define mock.On call | |||
| 289 | - | // - context1 context.Context | |||
| 290 | - | // - request *connect.Request[ermite.ListReposRequest] | |||
| 291 | - | func (_e *RepoServiceClient_Expecter) ListRepos(context1 any, request any) *RepoServiceClient_ListRepos_Call { | |||
| 292 | - | return &RepoServiceClient_ListRepos_Call{Call: _e.mock.On("ListRepos", context1, request)} | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | func (_c *RepoServiceClient_ListRepos_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListReposRequest])) *RepoServiceClient_ListRepos_Call { | |||
| 296 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 297 | - | var arg0 context.Context | |||
| 298 | - | if args[0] != nil { | |||
| 299 | - | arg0 = args[0].(context.Context) | |||
| 300 | - | } | |||
| 301 | - | var arg1 *connect.Request[ermite.ListReposRequest] | |||
| 302 | - | if args[1] != nil { | |||
| 303 | - | arg1 = args[1].(*connect.Request[ermite.ListReposRequest]) | |||
| 304 | - | } | |||
| 305 | - | run( | |||
| 306 | - | arg0, | |||
| 307 | - | arg1, | |||
| 308 | - | ) | |||
| 309 | - | }) | |||
| 310 | - | return _c | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func (_c *RepoServiceClient_ListRepos_Call) Return(response *connect.Response[ermite.ListReposResponse], err error) *RepoServiceClient_ListRepos_Call { | |||
| 314 | - | _c.Call.Return(response, err) | |||
| 315 | - | return _c | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | func (_c *RepoServiceClient_ListRepos_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error)) *RepoServiceClient_ListRepos_Call { | |||
| 319 | - | _c.Call.Return(run) | |||
| 320 | - | return _c | |||
| 321 | - | } | |||
| 322 | - | ||||
| 323 | - | // UpdateRepo provides a mock function for the type RepoServiceClient | |||
| 324 | - | func (_mock *RepoServiceClient) UpdateRepo(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error) { | |||
| 325 | - | ret := _mock.Called(context1, request) | |||
| 326 | - | ||||
| 327 | - | if len(ret) == 0 { | |||
| 328 | - | panic("no return value specified for UpdateRepo") | |||
| 329 | - | } | |||
| 330 | - | ||||
| 331 | - | var r0 *connect.Response[ermite.UpdateRepoResponse] | |||
| 332 | - | var r1 error | |||
| 333 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error)); ok { | |||
| 334 | - | return returnFunc(context1, request) | |||
| 335 | - | } | |||
| 336 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) *connect.Response[ermite.UpdateRepoResponse]); ok { | |||
| 337 | - | r0 = returnFunc(context1, request) | |||
| 338 | - | } else { | |||
| 339 | - | if ret.Get(0) != nil { | |||
| 340 | - | r0 = ret.Get(0).(*connect.Response[ermite.UpdateRepoResponse]) | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) error); ok { | |||
| 344 | - | r1 = returnFunc(context1, request) | |||
| 345 | - | } else { | |||
| 346 | - | r1 = ret.Error(1) | |||
| 347 | - | } | |||
| 348 | - | return r0, r1 | |||
| 349 | - | } | |||
| 350 | - | ||||
| 351 | - | // RepoServiceClient_UpdateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRepo' | |||
| 352 | - | type RepoServiceClient_UpdateRepo_Call struct { | |||
| 353 | - | *mock.Call | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | // UpdateRepo is a helper method to define mock.On call | |||
| 357 | - | // - context1 context.Context | |||
| 358 | - | // - request *connect.Request[ermite.UpdateRepoRequest] | |||
| 359 | - | func (_e *RepoServiceClient_Expecter) UpdateRepo(context1 any, request any) *RepoServiceClient_UpdateRepo_Call { | |||
| 360 | - | return &RepoServiceClient_UpdateRepo_Call{Call: _e.mock.On("UpdateRepo", context1, request)} | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | - | func (_c *RepoServiceClient_UpdateRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest])) *RepoServiceClient_UpdateRepo_Call { | |||
| 364 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 365 | - | var arg0 context.Context | |||
| 366 | - | if args[0] != nil { | |||
| 367 | - | arg0 = args[0].(context.Context) | |||
| 368 | - | } | |||
| 369 | - | var arg1 *connect.Request[ermite.UpdateRepoRequest] | |||
| 370 | - | if args[1] != nil { | |||
| 371 | - | arg1 = args[1].(*connect.Request[ermite.UpdateRepoRequest]) | |||
| 372 | - | } | |||
| 373 | - | run( | |||
| 374 | - | arg0, | |||
| 375 | - | arg1, | |||
| 376 | - | ) | |||
| 377 | - | }) | |||
| 378 | - | return _c | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | func (_c *RepoServiceClient_UpdateRepo_Call) Return(response *connect.Response[ermite.UpdateRepoResponse], err error) *RepoServiceClient_UpdateRepo_Call { | |||
| 382 | - | _c.Call.Return(response, err) | |||
| 383 | - | return _c | |||
| 384 | - | } | |||
| 385 | - | ||||
| 386 | - | func (_c *RepoServiceClient_UpdateRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error)) *RepoServiceClient_UpdateRepo_Call { | |||
| 387 | - | _c.Call.Return(run) | |||
| 388 | - | return _c | |||
| 389 | - | } | |||
M internal/adapters/web/mr.go +111 -116
| @@ -11,14 +11,14 @@ import ( | |||||
| 11 | "strconv" | 11 | "strconv" | ||
| 12 | "strings" | 12 | "strings" | ||
| 13 | 13 | ||||
| 14 | - | "connectrpc.com/connect" | |||
| 15 | "github.com/a-h/templ" | 14 | "github.com/a-h/templ" | ||
| 16 | 15 | ||||
| 17 | - | pb "bnema.dev/ermite-api/gen/ermite" | 16 | + | "bnema.dev/ermite/internal/domain" |
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | // The merge request pages (ADR 0017). They read through MergeRequestService | 20 | // The merge request pages (ADR 0017). They read through MergeRequestService | ||
| 21 | - | // and GitService as the visitor of the request: anonymous sees the merge | 21 | + | // and BrowseService as the visitor of the request: anonymous sees the merge |
| 22 | // requests of public repositories, the signed-in owner those of every | 22 | // requests of public repositories, the signed-in owner those of every | ||
| 23 | // repository, and only the owner gets the forms (mr_post.go handles them). | 23 | // repository, and only the owner gets the forms (mr_post.go handles them). | ||
| 24 | // | 24 | // | ||
| @@ -44,9 +44,9 @@ import ( | |||||
| 44 | 44 | ||||
| 45 | const ( | 45 | const ( | ||
| 46 | // mrPerPage is how many decided merge requests a page of the list shows, | 46 | // mrPerPage is how many decided merge requests a page of the list shows, | ||
| 47 | - | // at least; pages of the API are read whole. | 47 | + | // at least; pages of the use case are read whole. |
| 48 | mrPerPage = 50 | 48 | mrPerPage = 50 | ||
| 49 | - | // maxMRPages bounds the API pages one list page reads. | 49 | + | // maxMRPages bounds the pages of the use case one list page reads. |
| 50 | maxMRPages = 20 | 50 | maxMRPages = 20 | ||
| 51 | // maxOpenPages bounds the pages read for the open merge requests, which | 51 | // maxOpenPages bounds the pages read for the open merge requests, which | ||
| 52 | // the first list page shows in full. | 52 | // the first list page shows in full. | ||
| @@ -260,39 +260,39 @@ func mrNumber(r *http.Request) (int64, error) { | |||||
| 260 | return n, nil | 260 | return n, nil | ||
| 261 | } | 261 | } | ||
| 262 | 262 | ||||
| 263 | - | func stateName(s pb.MergeRequestState) string { | 263 | + | func stateName(s domain.MRState) string { |
| 264 | switch s { | 264 | switch s { | ||
| 265 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN: | 265 | + | case domain.MRStateOpen: |
| 266 | return "open" | 266 | return "open" | ||
| 267 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED: | 267 | + | case domain.MRStateMerged: |
| 268 | return "merged" | 268 | return "merged" | ||
| 269 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED: | 269 | + | case domain.MRStateClosed: |
| 270 | return "closed" | 270 | return "closed" | ||
| 271 | } | 271 | } | ||
| 272 | return "unknown" | 272 | return "unknown" | ||
| 273 | } | 273 | } | ||
| 274 | 274 | ||||
| 275 | - | func modeName(m pb.MergeMode) string { | 275 | + | func modeName(m domain.MergeMode) string { |
| 276 | switch m { | 276 | switch m { | ||
| 277 | - | case pb.MergeMode_MERGE_MODE_FAST_FORWARD: | 277 | + | case domain.MergeModeFastForward: |
| 278 | return "fast-forward" | 278 | return "fast-forward" | ||
| 279 | - | case pb.MergeMode_MERGE_MODE_SQUASH: | 279 | + | case domain.MergeModeSquash: |
| 280 | return "squash" | 280 | return "squash" | ||
| 281 | } | 281 | } | ||
| 282 | return "" | 282 | return "" | ||
| 283 | } | 283 | } | ||
| 284 | 284 | ||||
| 285 | // mrRow converts a merge request of a listing. | 285 | // mrRow converts a merge request of a listing. | ||
| 286 | - | func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow { | 286 | + | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { |
| 287 | row := MRRow{ | 287 | row := MRRow{ | ||
| 288 | - | Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()), | 288 | + | Number: "!" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number), |
| 289 | - | Title: visibleText(m.GetTitle()), State: stateName(m.GetState()), | 289 | + | Title: visibleText(m.Title.String()), State: stateName(m.State), |
| 290 | - | Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()), | 290 | + | Branches: visibleText(m.Source) + " → " + visibleText(m.Target), |
| 291 | - | Author: visibleText(m.GetAuthor()), | 291 | + | Author: visibleText(m.Author), |
| 292 | } | 292 | } | ||
| 293 | - | ts := m.GetUpdatedAt() | 293 | + | ts := m.UpdatedAt |
| 294 | - | if !ts.IsValid() { | 294 | + | if ts.IsZero() { |
| 295 | - | ts = m.GetCreatedAt() | 295 | + | ts = m.CreatedAt |
| 296 | } | 296 | } | ||
| 297 | row.When, row.WhenAt = whenOf(s.cfg.Now(), ts) | 297 | row.When, row.WhenAt = whenOf(s.cfg.Now(), ts) | ||
| 298 | return row | 298 | return row | ||
| @@ -300,30 +300,30 @@ func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow { | |||||
| 300 | 300 | ||||
| 301 | // mrList lists the merge requests: the open ones, then the merged and closed | 301 | // mrList lists the merge requests: the open ones, then the merged and closed | ||
| 302 | // ones, a page at a time. The decided ones are paged with the page token of | 302 | // ones, a page at a time. The decided ones are paged with the page token of | ||
| 303 | - | // the API in "page"; the open ones show on the first page only. | 303 | + | // the use case in "page"; the open ones show on the first page only. |
| 304 | func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 304 | func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 305 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 305 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 306 | if err != nil { | 306 | if err != nil { | ||
| 307 | return nil, 0, err | 307 | return nil, 0, err | ||
| 308 | } | 308 | } | ||
| 309 | token := r.URL.Query().Get("page") | 309 | token := r.URL.Query().Get("page") | ||
| 310 | - | p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())} | 310 | + | p := MRListPage{Default: visibleText(d.repo.DefaultBranch)} |
| 311 | p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}} | 311 | p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}} | ||
| 312 | base := mrBase(d.name()) | 312 | base := mrBase(d.name()) | ||
| 313 | 313 | ||||
| 314 | if token == "" { | 314 | if token == "" { | ||
| 315 | tok := "" | 315 | tok := "" | ||
| 316 | for i := 0; i < maxOpenPages; i++ { | 316 | for i := 0; i < maxOpenPages; i++ { | ||
| 317 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | 317 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{ |
| 318 | - | Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok, | 318 | + | State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok, |
| 319 | - | })) | 319 | + | }) |
| 320 | if err != nil { | 320 | if err != nil { | ||
| 321 | return nil, 0, err | 321 | return nil, 0, err | ||
| 322 | } | 322 | } | ||
| 323 | - | for _, m := range res.Msg.GetMergeRequests() { | 323 | + | for _, m := range res.MergeRequests { |
| 324 | p.Open = append(p.Open, s.mrRow(d.name(), m)) | 324 | p.Open = append(p.Open, s.mrRow(d.name(), m)) | ||
| 325 | } | 325 | } | ||
| 326 | - | if tok = res.Msg.GetNextPageToken(); tok == "" { | 326 | + | if tok = res.NextPageToken; tok == "" { |
| 327 | break | 327 | break | ||
| 328 | } | 328 | } | ||
| 329 | } | 329 | } | ||
| @@ -334,18 +334,18 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 334 | 334 | ||||
| 335 | tok := token | 335 | tok := token | ||
| 336 | for i := 0; i < maxMRPages; i++ { | 336 | for i := 0; i < maxMRPages; i++ { | ||
| 337 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | 337 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{ |
| 338 | - | Repo: d.name(), PageSize: pageSize, PageToken: tok, | 338 | + | PageSize: pageSize, PageToken: tok, |
| 339 | - | })) | 339 | + | }) |
| 340 | if err != nil { | 340 | if err != nil { | ||
| 341 | return nil, 0, err | 341 | return nil, 0, err | ||
| 342 | } | 342 | } | ||
| 343 | - | for _, m := range res.Msg.GetMergeRequests() { | 343 | + | for _, m := range res.MergeRequests { |
| 344 | - | if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | 344 | + | if m.State != domain.MRStateOpen { |
| 345 | p.Done = append(p.Done, s.mrRow(d.name(), m)) | 345 | p.Done = append(p.Done, s.mrRow(d.name(), m)) | ||
| 346 | } | 346 | } | ||
| 347 | } | 347 | } | ||
| 348 | - | tok = res.Msg.GetNextPageToken() | 348 | + | tok = res.NextPageToken |
| 349 | if tok == "" || len(p.Done) >= mrPerPage { | 349 | if tok == "" || len(p.Done) >= mrPerPage { | ||
| 350 | break | 350 | break | ||
| 351 | } | 351 | } | ||
| @@ -363,10 +363,10 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 363 | p.Done[0].Cursor = true | 363 | p.Done[0].Cursor = true | ||
| 364 | } | 364 | } | ||
| 365 | for _, b := range d.branches { | 365 | for _, b := range d.branches { | ||
| 366 | - | if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches { | 366 | + | if b.ShortName == d.repo.DefaultBranch || len(p.Branches) >= railBranches { |
| 367 | continue | 367 | continue | ||
| 368 | } | 368 | } | ||
| 369 | - | p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())}) | 369 | + | p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.ShortName), Href: repoHref(d.name(), "compare", b.ShortName)}) |
| 370 | } | 370 | } | ||
| 371 | 371 | ||||
| 372 | l := s.repoLayout(ctx, d, "merge-requests") | 372 | l := s.repoLayout(ctx, d, "merge-requests") | ||
| @@ -391,6 +391,7 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 391 | return s.mrPage(ctx, r, true) | 391 | return s.mrPage(ctx, r, true) | ||
| 392 | } | 392 | } | ||
| 393 | 393 | ||||
| 394 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here | |||
| 394 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | 395 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | ||
| 395 | n, err := mrNumber(r) | 396 | n, err := mrNumber(r) | ||
| 396 | if err != nil { | 397 | if err != nil { | ||
| @@ -404,79 +405,72 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 404 | if err != nil { | 405 | if err != nil { | ||
| 405 | return nil, 0, err | 406 | return nil, 0, err | ||
| 406 | } | 407 | } | ||
| 407 | - | res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{ | 408 | + | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since) |
| 408 | - | Repo: d.name(), Number: n, SinceLastRead: since, | |||
| 409 | - | })) | |||
| 410 | if err != nil { | 409 | if err != nil { | ||
| 411 | return nil, 0, err | 410 | return nil, 0, err | ||
| 412 | } | 411 | } | ||
| 413 | - | m := res.Msg.GetMergeRequest() | 412 | + | m := res.MergeRequest |
| 414 | - | if m == nil { | |||
| 415 | - | return nil, 0, errNotFound | |||
| 416 | - | } | |||
| 417 | now := s.cfg.Now() | 413 | now := s.cfg.Now() | ||
| 418 | signed := SignedIn(ctx) | 414 | signed := SignedIn(ctx) | ||
| 419 | - | open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN | 415 | + | open := m.State == domain.MRStateOpen |
| 420 | - | head := m.GetHeadId() | 416 | + | head := m.HeadID.String() |
| 421 | self := mrHref(d.name(), n) | 417 | self := mrHref(d.name(), n) | ||
| 422 | if since { | 418 | if since { | ||
| 423 | self = mrHref(d.name(), n, "since") | 419 | self = mrHref(d.name(), n, "since") | ||
| 424 | } | 420 | } | ||
| 425 | 421 | ||||
| 426 | p := MRPage{ | 422 | p := MRPage{ | ||
| 427 | - | Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()), | 423 | + | Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()), |
| 428 | - | State: stateName(m.GetState()), Open: open, Signed: signed, | 424 | + | State: stateName(m.State), Open: open, Signed: signed, |
| 429 | - | Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()), | 425 | + | Source: visibleText(m.Source), Target: visibleText(m.Target), |
| 430 | - | Author: visibleText(m.GetAuthor()), | 426 | + | Author: visibleText(m.Author), |
| 431 | - | Desc: plainParts(m.GetDescription()), | 427 | + | Desc: plainParts(m.Description.String()), |
| 432 | Head: head, HeadShort: short(head), | 428 | Head: head, HeadShort: short(head), | ||
| 433 | - | NFiles: int(res.Msg.GetStats().GetFilesChanged()), | 429 | + | NFiles: res.Stats.FilesChanged, |
| 434 | - | Adds: int(res.Msg.GetStats().GetAdditions()), | 430 | + | Adds: res.Stats.Additions, |
| 435 | - | Dels: int(res.Msg.GetStats().GetDeletions()), | 431 | + | Dels: res.Stats.Deletions, |
| 436 | - | Files: diffFiles(res.Msg.GetDiff().GetFiles()), | 432 | + | Files: diffFiles(res.Diff.Files), |
| 437 | } | 433 | } | ||
| 438 | p.Opened.Name = p.Author | 434 | p.Opened.Name = p.Author | ||
| 439 | - | p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt()) | 435 | + | p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt) |
| 440 | - | if t := m.GetCreatedAt(); t.IsValid() { | 436 | + | if !m.CreatedAt.IsZero() { |
| 441 | - | p.Opened.Abs = absolute(t.AsTime()) | 437 | + | p.Opened.Abs = absolute(m.CreatedAt) |
| 442 | } | 438 | } | ||
| 443 | - | p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt()) | 439 | + | p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt) |
| 444 | for _, b := range d.branches { | 440 | for _, b := range d.branches { | ||
| 445 | - | switch b.GetShortName() { | 441 | + | switch b.ShortName { |
| 446 | - | case m.GetSourceBranch(): | 442 | + | case m.Source: |
| 447 | - | p.SourceHref = repoHref(d.name(), "tree", b.GetShortName()) | 443 | + | p.SourceHref = repoHref(d.name(), "tree", b.ShortName) |
| 448 | - | case m.GetTargetBranch(): | 444 | + | case m.Target: |
| 449 | - | p.TargetHref = repoHref(d.name(), "tree", b.GetShortName()) | 445 | + | p.TargetHref = repoHref(d.name(), "tree", b.ShortName) |
| 450 | } | 446 | } | ||
| 451 | } | 447 | } | ||
| 452 | - | if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" { | 448 | + | if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() { |
| 453 | - | p.MergedShort = short(m.GetMergedCommitId()) | 449 | + | p.MergedShort = short(m.MergedCommitID.String()) |
| 454 | - | p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId()) | 450 | + | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) |
| 455 | - | p.MergeMode = modeName(m.GetMergeMode()) | 451 | + | p.MergeMode = modeName(m.MergeMode) |
| 456 | } | 452 | } | ||
| 457 | - | if st := d.repo.GetSettings(); st != nil { | 453 | + | for _, mode := range d.repo.Settings.AllowedMergeModes { |
| 458 | - | for _, mode := range st.GetAllowedMergeModes() { | 454 | + | if name := modeName(mode); name != "" { |
| 459 | - | if name := modeName(mode); name != "" { | 455 | + | p.Modes = append(p.Modes, name) |
| 460 | - | p.Modes = append(p.Modes, name) | |||
| 461 | - | } | |||
| 462 | } | 456 | } | ||
| 463 | - | p.ChecksBlock = st.GetFailingChecksBlock() | |||
| 464 | } | 457 | } | ||
| 465 | - | p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles) | 458 | + | p.ChecksBlock = d.repo.Settings.FailingChecksBlock |
| 459 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |||
| 466 | 460 | ||||
| 467 | // The commits of the request, newest first. | 461 | // The commits of the request, newest first. | ||
| 468 | - | for _, c := range res.Msg.GetCommits() { | 462 | + | for _, c := range res.Commits { |
| 469 | row := commitRow(d.name(), c, now) | 463 | row := commitRow(d.name(), c, now) | ||
| 470 | p.Commits = append(p.Commits, MRCommit{ | 464 | p.Commits = append(p.Commits, MRCommit{ | ||
| 471 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | 465 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | ||
| 472 | }) | 466 | }) | ||
| 473 | } | 467 | } | ||
| 474 | - | lastRead := res.Msg.GetLastReadId() | 468 | + | lastRead := res.LastReadID.String() |
| 475 | if signed && lastRead != "" { | 469 | if signed && lastRead != "" { | ||
| 476 | // The commits are newest first: those before the last read head are new. | 470 | // The commits are newest first: those before the last read head are new. | ||
| 477 | // A head that is not among them (the branch was rewritten) marks none. | 471 | // A head that is not among them (the branch was rewritten) marks none. | ||
| 478 | - | for i, c := range res.Msg.GetCommits() { | 472 | + | for i, c := range res.Commits { |
| 479 | - | if c.GetId() != lastRead { | 473 | + | if c.ID.String() != lastRead { |
| 480 | continue | 474 | continue | ||
| 481 | } | 475 | } | ||
| 482 | for j := 0; j < i; j++ { | 476 | for j := 0; j < i; j++ { | ||
| @@ -484,7 +478,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 484 | } | 478 | } | ||
| 485 | } | 479 | } | ||
| 486 | } | 480 | } | ||
| 487 | - | if res.Msg.GetCommitsTruncated() { | 481 | + | if res.CommitsTruncated { |
| 488 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | 482 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | ||
| 489 | } | 483 | } | ||
| 490 | 484 | ||||
| @@ -502,7 +496,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 502 | switch { | 496 | switch { | ||
| 503 | case lastRead == "": | 497 | case lastRead == "": | ||
| 504 | p.SinceNote = "nothing was marked read yet: this is the whole change." | 498 | p.SinceNote = "nothing was marked read yet: this is the whole change." | ||
| 505 | - | case res.Msg.GetLastReadLost(): | 499 | + | case res.LastReadLost: |
| 506 | p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again." | 500 | p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again." | ||
| 507 | case len(p.Files) == 0: | 501 | case len(p.Files) == 0: | ||
| 508 | p.SinceNote = "nothing changed since " + short(lastRead) + "." | 502 | p.SinceNote = "nothing changed since " + short(lastRead) + "." | ||
| @@ -521,12 +515,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 521 | if canWrite { | 515 | if canWrite { | ||
| 522 | on = parseOn(r.URL.Query().Get("on")) | 516 | on = parseOn(r.URL.Query().Get("on")) | ||
| 523 | } | 517 | } | ||
| 524 | - | s.placeComments(&p, d.name(), n, res.Msg.GetComments(), head, !since, canWrite, on) | 518 | + | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) |
| 525 | lines, invisible := diffLines(p.Files) | 519 | lines, invisible := diffLines(p.Files) | ||
| 526 | p.Invisible = invisible | 520 | p.Invisible = invisible | ||
| 527 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | 521 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | ||
| 528 | if signed && open { | 522 | if signed && open { | ||
| 529 | - | p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead) | 523 | + | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) |
| 530 | } | 524 | } | ||
| 531 | if canWrite { | 525 | if canWrite { | ||
| 532 | p.GeneralForm = &CommentForm{ | 526 | p.GeneralForm = &CommentForm{ | ||
| @@ -537,8 +531,8 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 537 | l := s.repoLayout(ctx, d, "merge-requests") | 531 | l := s.repoLayout(ctx, d, "merge-requests") | ||
| 538 | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | 532 | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | ||
| 539 | if head != "" { | 533 | if head != "" { | ||
| 540 | - | pinRef(&l, target{rev: head, name: m.GetSourceBranch()}) | 534 | + | pinRef(&l, target{rev: head, name: m.Source}) |
| 541 | - | if !d.hasBranch(m.GetSourceBranch()) { | 535 | + | if !d.hasBranch(m.Source) { |
| 542 | pinRef(&l, target{rev: head, name: short(head), detached: true}) | 536 | pinRef(&l, target{rev: head, name: short(head), detached: true}) | ||
| 543 | } | 537 | } | ||
| 544 | } | 538 | } | ||
| @@ -551,7 +545,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 551 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | 545 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | ||
| 552 | } | 546 | } | ||
| 553 | p.Live = true | 547 | p.Live = true | ||
| 554 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch())) | 548 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)) |
| 555 | l.HasRail = true | 549 | l.HasRail = true | ||
| 556 | return MergeRequest(l, p), http.StatusOK, nil | 550 | return MergeRequest(l, p), http.StatusOK, nil | ||
| 557 | } | 551 | } | ||
| @@ -559,7 +553,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 559 | // hasBranch tells whether the repository has the branch. | 553 | // hasBranch tells whether the repository has the branch. | ||
| 560 | func (d *repoData) hasBranch(name string) bool { | 554 | func (d *repoData) hasBranch(name string) bool { | ||
| 561 | for _, b := range d.branches { | 555 | for _, b := range d.branches { | ||
| 562 | - | if b.GetShortName() == name { | 556 | + | if b.ShortName == name { |
| 563 | return true | 557 | return true | ||
| 564 | } | 558 | } | ||
| 565 | } | 559 | } | ||
| @@ -572,19 +566,17 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) * | |||||
| 572 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | 566 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | ||
| 573 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | 567 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | ||
| 574 | CanMarkRead: head != "" && head != lastRead, | 568 | CanMarkRead: head != "" && head != lastRead, | ||
| 575 | - | CanDeleteBranch: source != d.repo.GetDefaultBranch(), | 569 | + | CanDeleteBranch: source != d.repo.DefaultBranch, |
| 576 | } | 570 | } | ||
| 577 | ff, sq := false, false | 571 | ff, sq := false, false | ||
| 578 | - | if st := d.repo.GetSettings(); st != nil { | 572 | + | for _, mode := range d.repo.Settings.AllowedMergeModes { |
| 579 | - | for _, mode := range st.GetAllowedMergeModes() { | 573 | + | switch mode { |
| 580 | - | switch mode { | 574 | + | case domain.MergeModeFastForward: |
| 581 | - | case pb.MergeMode_MERGE_MODE_FAST_FORWARD: | 575 | + | ff = true |
| 582 | - | ff = true | 576 | + | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) |
| 583 | - | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | 577 | + | case domain.MergeModeSquash: |
| 584 | - | case pb.MergeMode_MERGE_MODE_SQUASH: | 578 | + | sq = true |
| 585 | - | sq = true | 579 | + | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) |
| 586 | - | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | |||
| 587 | - | } | |||
| 588 | } | 580 | } | ||
| 589 | } | 581 | } | ||
| 590 | if head != "" { | 582 | if head != "" { | ||
| @@ -637,14 +629,16 @@ func parseOn(s string) *lineRef { | |||||
| 637 | // the target, so a page that shows another diff (since the last read) lists | 629 | // the target, so a page that shows another diff (since the last read) lists | ||
| 638 | // them all below, with their line. A reader who can write gets the line numbers | 630 | // them all below, with their line. A reader who can write gets the line numbers | ||
| 639 | // as links to the form of their line; on designates the line whose form shows. | 631 | // as links to the form of their line; on designates the line whose form shows. | ||
| 640 | - | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) { | 632 | + | // |
| 633 | + | //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only | |||
| 634 | + | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) { | |||
| 641 | now := s.cfg.Now() | 635 | now := s.cfg.Now() | ||
| 642 | self := mrHref(repo, n) | 636 | self := mrHref(repo, n) | ||
| 643 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | 637 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | ||
| 644 | 638 | ||||
| 645 | type lineKey struct { | 639 | type lineKey struct { | ||
| 646 | path string | 640 | path string | ||
| 647 | - | side pb.DiffSide | 641 | + | side domain.DiffSide |
| 648 | line int | 642 | line int | ||
| 649 | } | 643 | } | ||
| 650 | index := map[lineKey]*DiffLine{} | 644 | index := map[lineKey]*DiffLine{} | ||
| @@ -657,13 +651,13 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 657 | for li := range f.Hunks[hi].Lines { | 651 | for li := range f.Hunks[hi].Lines { | ||
| 658 | ln := &f.Hunks[hi].Lines[li] | 652 | ln := &f.Hunks[hi].Lines[li] | ||
| 659 | if ln.oldN > 0 && ln.Kind != "add" { | 653 | if ln.oldN > 0 && ln.Kind != "add" { | ||
| 660 | - | index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln | 654 | + | index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln |
| 661 | if linkable { | 655 | if linkable { | ||
| 662 | ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | 656 | ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | ||
| 663 | } | 657 | } | ||
| 664 | } | 658 | } | ||
| 665 | if ln.newN > 0 && ln.Kind != "del" { | 659 | if ln.newN > 0 && ln.Kind != "del" { | ||
| 666 | - | index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln | 660 | + | index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln |
| 667 | if linkable { | 661 | if linkable { | ||
| 668 | ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | 662 | ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | ||
| 669 | } | 663 | } | ||
| @@ -671,7 +665,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 671 | } | 665 | } | ||
| 672 | } | 666 | } | ||
| 673 | } | 667 | } | ||
| 674 | - | find := func(path string, side pb.DiffSide, line int) *DiffLine { | 668 | + | find := func(path string, side domain.DiffSide, line int) *DiffLine { |
| 675 | if !inline { | 669 | if !inline { | ||
| 676 | return nil | 670 | return nil | ||
| 677 | } | 671 | } | ||
| @@ -679,34 +673,35 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 679 | } | 673 | } | ||
| 680 | 674 | ||||
| 681 | for _, c := range comments { | 675 | for _, c := range comments { | ||
| 676 | + | cid := strconv.FormatInt(c.ID, 10) | |||
| 682 | v := CommentView{ | 677 | v := CommentView{ | ||
| 683 | - | ID: c.GetId(), Author: visibleText(c.GetAuthor()), | 678 | + | ID: cid, Author: visibleText(c.Author), |
| 684 | - | Parts: plainParts(c.GetText()), Resolved: c.GetResolved(), | 679 | + | Parts: plainParts(c.Text.String()), Resolved: c.Resolved, |
| 685 | - | Outdated: c.GetCommitId() != "" && c.GetCommitId() != head, | 680 | + | Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head, |
| 686 | } | 681 | } | ||
| 687 | - | v.When, v.WhenAt = whenOf(now, c.GetCreatedAt()) | 682 | + | v.When, v.WhenAt = whenOf(now, c.CreatedAt) |
| 688 | - | if t := c.GetCreatedAt(); t.IsValid() { | 683 | + | if !c.CreatedAt.IsZero() { |
| 689 | - | v.Abs = absolute(t.AsTime()) | 684 | + | v.Abs = absolute(c.CreatedAt) |
| 690 | } | 685 | } | ||
| 691 | p.NComments++ | 686 | p.NComments++ | ||
| 692 | - | if !c.GetResolved() { | 687 | + | if !c.Resolved { |
| 693 | p.NUnresolved++ | 688 | p.NUnresolved++ | ||
| 694 | } | 689 | } | ||
| 695 | - | general := c.GetPath() == "" | 690 | + | general := c.Path == "" |
| 696 | var ln *DiffLine | 691 | var ln *DiffLine | ||
| 697 | if !general { | 692 | if !general { | ||
| 698 | - | ln = find(c.GetPath(), c.GetSide(), int(c.GetLine())) | 693 | + | ln = find(c.Path, c.Side, c.Line) |
| 699 | if ln == nil { | 694 | if ln == nil { | ||
| 700 | side := "new" | 695 | side := "new" | ||
| 701 | - | if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD { | 696 | + | if c.Side == domain.SideOld { |
| 702 | side = "old" | 697 | side = "old" | ||
| 703 | } | 698 | } | ||
| 704 | - | v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")" | 699 | + | v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")" |
| 705 | } | 700 | } | ||
| 706 | } | 701 | } | ||
| 707 | if canWrite { | 702 | if canWrite { | ||
| 708 | - | v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"} | 703 | + | v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"} |
| 709 | - | if c.GetResolved() { | 704 | + | if c.Resolved { |
| 710 | v.Resolve.Label = "Unresolve" | 705 | v.Resolve.Label = "Unresolve" | ||
| 711 | } | 706 | } | ||
| 712 | } | 707 | } | ||
| @@ -721,9 +716,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||||
| 721 | } | 716 | } | ||
| 722 | 717 | ||||
| 723 | if on != nil { | 718 | if on != nil { | ||
| 724 | - | side := pb.DiffSide_DIFF_SIDE_NEW | 719 | + | side := domain.SideNew |
| 725 | if on.side == "old" { | 720 | if on.side == "old" { | ||
| 726 | - | side = pb.DiffSide_DIFF_SIDE_OLD | 721 | + | side = domain.SideOld |
| 727 | } | 722 | } | ||
| 728 | if ln := find(on.path, side, on.line); ln != nil { | 723 | if ln := find(on.path, side, on.line); ln != nil { | ||
| 729 | ln.Form = &CommentForm{ | 724 | ln.Form = &CommentForm{ | ||
M internal/adapters/web/mr_post.go +139 -71
| @@ -5,24 +5,23 @@ package web | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | - | "fmt" | |||
| 9 | "net/http" | 8 | "net/http" | ||
| 10 | "strconv" | 9 | "strconv" | ||
| 11 | "strings" | 10 | "strings" | ||
| 12 | 11 | ||||
| 13 | - | "connectrpc.com/connect" | |||
| 14 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 15 | 13 | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | 14 | + | "bnema.dev/ermite/internal/domain" |
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | ) | 16 | ) | ||
| 18 | 17 | ||||
| 19 | // The writes of the merge request pages. Each is a POST that passed the gate | 18 | // The writes of the merge request pages. Each is a POST that passed the gate | ||
| 20 | - | // (an Origin of this site and the owner's session), calls the API as the | 19 | + | // (an Origin of this site and the owner's session), calls the use case as the |
| 21 | // owner, and answers 303 to the page that shows the result, with | 20 | // owner, and answers 303 to the page that shows the result, with | ||
| 22 | - | // "Cache-Control: no-store". A request the API refuses is answered with a | 21 | + | // "Cache-Control: no-store". A request the use case refuses is answered with a |
| 23 | - | // state page that says why, in the words of the API for the rules the owner | 22 | + | // state page that says why, in the words of the use case for the rules the |
| 24 | - | // can fix (a text rule, a moved branch, a full merge request), and without | 23 | + | // owner can fix (a text rule, a moved branch, a full merge request), and |
| 25 | - | // detail for anything else. | 24 | + | // without detail for anything else. |
| 26 | 25 | ||||
| 27 | // maxFormBytes bounds the body of a form: a description of 8,000 characters is | 26 | // maxFormBytes bounds the body of a form: a description of 8,000 characters is | ||
| 28 | // at most 32,000 bytes. | 27 | // at most 32,000 bytes. | ||
| @@ -44,8 +43,8 @@ func seeOther(w http.ResponseWriter, r *http.Request, to string) { | |||||
| 44 | http.Redirect(w, r, to, http.StatusSeeOther) | 43 | http.Redirect(w, r, to, http.StatusSeeOther) | ||
| 45 | } | 44 | } | ||
| 46 | 45 | ||||
| 47 | - | // mrAction wraps a POST handler: it bounds the API calls, reads the form and | 46 | + | // mrAction wraps a POST handler: it bounds the use case calls, reads the form |
| 48 | - | // maps the error of the API to a page. | 47 | + | // and maps the error of the use case to a page. |
| 49 | func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc { | 48 | func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc { | ||
| 50 | return func(w http.ResponseWriter, r *http.Request) { | 49 | return func(w http.ResponseWriter, r *http.Request) { | ||
| 51 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | 50 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | ||
| @@ -64,37 +63,42 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||||
| 64 | 63 | ||||
| 65 | // actionFailed answers a refused action. | 64 | // actionFailed answers a refused action. | ||
| 66 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | 65 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | ||
| 67 | - | msg := "" | 66 | + | // The words of the use case, for the rules the owner can fix. |
| 68 | - | var ce *connect.Error | 67 | + | msg := err.Error() |
| 69 | - | if errors.As(err, &ce) { | 68 | + | // The order is that of the API's mapping of the domain errors to codes |
| 70 | - | msg = ce.Message() | 69 | + | // (rpc.codeOf), so that a web action answers what the API would. |
| 71 | - | } | 70 | + | switch { |
| 72 | - | switch code := connect.CodeOf(err); { | 71 | + | case errors.Is(err, context.Canceled): |
| 73 | - | case code == connect.CodeNotFound: | 72 | + | // The browser is gone. |
| 73 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 74 | w.Header().Set("Cache-Control", "no-store") | 74 | w.Header().Set("Cache-Control", "no-store") | ||
| 75 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | 75 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | ||
| 76 | - | case code == connect.CodeInvalidArgument: | 76 | + | case errors.Is(err, domain.ErrAlreadyExists): |
| 77 | - | s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back) | |||
| 78 | - | case code == connect.CodeFailedPrecondition, code == connect.CodeAborted: | |||
| 79 | - | s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back) | |||
| 80 | - | case code == connect.CodeAlreadyExists: | |||
| 81 | s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back) | 77 | s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back) | ||
| 82 | - | case code == connect.CodeResourceExhausted: | 78 | + | case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected): |
| 83 | - | s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back) | |||
| 84 | - | case code == connect.CodePermissionDenied, code == connect.CodeUnauthenticated: | |||
| 85 | s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back) | 79 | s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back) | ||
| 86 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | 80 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): |
| 87 | - | // The browser is gone. | 81 | + | s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back) |
| 82 | + | case errors.Is(err, domain.ErrResourceExhausted): | |||
| 83 | + | s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back) | |||
| 84 | + | case domain.IsInvalid(err): | |||
| 85 | + | s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back) | |||
| 88 | default: | 86 | default: | ||
| 89 | - | id := requestID() | 87 | + | s.internalFailure(ctx, w, r, err) |
| 90 | - | log := zerowrap.FromCtx(ctx) | |||
| 91 | - | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed") | |||
| 92 | - | w.Header().Set("Cache-Control", "no-store") | |||
| 93 | - | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |||
| 94 | } | 88 | } | ||
| 95 | } | 89 | } | ||
| 96 | 90 | ||||
| 97 | - | // rule is a message of the API fit to show: the invisible characters marked, | 91 | + | // internalFailure answers a failure of an action that is not the owner's to |
| 92 | + | // fix: the 500 page with an id, and the cause in the log. | |||
| 93 | + | func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |||
| 94 | + | id := requestID() | |||
| 95 | + | log := zerowrap.FromCtx(ctx) | |||
| 96 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed") | |||
| 97 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 98 | + | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // rule is a message of the use case fit to show: the invisible characters marked, | |||
| 98 | // bounded, and a fallback for an empty one. | 102 | // bounded, and a fallback for an empty one. | ||
| 99 | func rule(msg, fallback string) string { | 103 | func rule(msg, fallback string) string { | ||
| 100 | msg = strings.TrimSpace(visibleText(msg)) | 104 | msg = strings.TrimSpace(visibleText(msg)) | ||
| @@ -126,69 +130,116 @@ func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, | |||||
| 126 | repo := r.PathValue("repo") | 130 | repo := r.PathValue("repo") | ||
| 127 | source := r.PostFormValue("source") | 131 | source := r.PostFormValue("source") | ||
| 128 | back := repoHref(repo, "compare", source) | 132 | back := repoHref(repo, "compare", source) | ||
| 129 | - | res, err := s.cfg.MergeRequests.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | 133 | + | name, err := domain.ParseRepoName(repo) |
| 130 | - | Repo: repo, Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")), | 134 | + | if err != nil { |
| 131 | - | SourceBranch: source, TargetBranch: r.PostFormValue("target"), | 135 | + | return "", back, err |
| 132 | - | })) | 136 | + | } |
| 137 | + | mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{ | |||
| 138 | + | Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")), | |||
| 139 | + | Source: source, Target: r.PostFormValue("target"), | |||
| 140 | + | }) | |||
| 133 | if err != nil { | 141 | if err != nil { | ||
| 134 | return "", back, err | 142 | return "", back, err | ||
| 135 | } | 143 | } | ||
| 136 | - | return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil | 144 | + | return mrHref(repo, mr.Number), back, nil |
| 137 | } | 145 | } | ||
| 138 | 146 | ||||
| 147 | + | // formError is a form the server refuses before asking a use case: it matches | |||
| 148 | + | // domain.ErrInvalid and says just what is wrong. | |||
| 149 | + | type formError string | |||
| 150 | + | ||||
| 151 | + | func (e formError) Error() string { return string(e) } | |||
| 152 | + | ||||
| 153 | + | func (formError) Unwrap() error { return domain.ErrInvalid } | |||
| 154 | + | ||||
| 139 | // mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or | 155 | // mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or | ||
| 140 | // one on a line (path, line and side). | 156 | // one on a line (path, line and side). | ||
| 141 | func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) { | 157 | func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) { | ||
| 142 | repo := r.PathValue("repo") | 158 | repo := r.PathValue("repo") | ||
| 143 | n, err := number(r) | 159 | n, err := number(r) | ||
| 144 | if err != nil { | 160 | if err != nil { | ||
| 145 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 161 | + | return "", "", err |
| 146 | } | 162 | } | ||
| 147 | back := mrHref(repo, n) | 163 | back := mrHref(repo, n) | ||
| 148 | - | req := &pb.AddCommentRequest{Repo: repo, Number: n, Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")} | 164 | + | params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")} |
| 149 | - | if req.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" { | 165 | + | if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" { |
| 150 | line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32) | 166 | line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32) | ||
| 151 | if err != nil { | 167 | if err != nil { | ||
| 152 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("line is not a number")) | 168 | + | return "", back, formError("line is not a number") |
| 153 | } | 169 | } | ||
| 154 | - | req.Line = int32(line) | 170 | + | params.Line = int(line) |
| 155 | switch r.PostFormValue("side") { | 171 | switch r.PostFormValue("side") { | ||
| 156 | case "new": | 172 | case "new": | ||
| 157 | - | req.Side = pb.DiffSide_DIFF_SIDE_NEW | 173 | + | params.Side = domain.SideNew |
| 158 | case "old": | 174 | case "old": | ||
| 159 | - | req.Side = pb.DiffSide_DIFF_SIDE_OLD | 175 | + | params.Side = domain.SideOld |
| 160 | default: | 176 | default: | ||
| 161 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("side must be new or old")) | 177 | + | return "", back, formError("side must be new or old") |
| 162 | } | 178 | } | ||
| 163 | } | 179 | } | ||
| 164 | - | res, err := s.cfg.MergeRequests.AddComment(ctx, connect.NewRequest(req)) | 180 | + | name, err := domain.ParseRepoName(repo) |
| 165 | if err != nil { | 181 | if err != nil { | ||
| 166 | return "", back, err | 182 | return "", back, err | ||
| 167 | } | 183 | } | ||
| 168 | - | return back + "#c" + res.Msg.GetComment().GetId(), back, nil | 184 | + | c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params) |
| 185 | + | if err != nil { | |||
| 186 | + | return "", back, err | |||
| 187 | + | } | |||
| 188 | + | return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil | |||
| 169 | } | 189 | } | ||
| 170 | 190 | ||||
| 171 | // mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve. | 191 | // mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve. | ||
| 172 | // | 192 | // | ||
| 173 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into an API call; the state of the comment is decided by the API | 193 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into a use case call; the state of the comment is decided by the use case |
| 174 | func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) { | 194 | func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) { | ||
| 175 | repo := r.PathValue("repo") | 195 | repo := r.PathValue("repo") | ||
| 176 | n, err := number(r) | 196 | n, err := number(r) | ||
| 177 | if err != nil { | 197 | if err != nil { | ||
| 178 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 198 | + | return "", "", err |
| 179 | } | 199 | } | ||
| 180 | back := mrHref(repo, n) | 200 | back := mrHref(repo, n) | ||
| 181 | resolved, err := formBool(r.PostFormValue("resolved")) | 201 | resolved, err := formBool(r.PostFormValue("resolved")) | ||
| 182 | if err != nil { | 202 | if err != nil { | ||
| 183 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("resolved: %w", err)) | 203 | + | return "", back, formError("resolved: " + err.Error()) |
| 184 | } | 204 | } | ||
| 185 | - | res, err := s.cfg.MergeRequests.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{ | 205 | + | name, err := domain.ParseRepoName(repo) |
| 186 | - | Repo: repo, Number: n, CommentId: r.PathValue("id"), Resolved: resolved, | |||
| 187 | - | })) | |||
| 188 | if err != nil { | 206 | if err != nil { | ||
| 189 | return "", back, err | 207 | return "", back, err | ||
| 190 | } | 208 | } | ||
| 191 | - | return back + "#c" + res.Msg.GetComment().GetId(), back, nil | 209 | + | // An id this server never issued is 0: the use case answers not found, |
| 210 | + | // after the repository and the scope were checked, as for any unknown | |||
| 211 | + | // comment. | |||
| 212 | + | c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved) | |||
| 213 | + | if err != nil { | |||
| 214 | + | return "", back, err | |||
| 215 | + | } | |||
| 216 | + | return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // commentID parses the id of a comment in a path: a positive decimal number, | |||
| 220 | + | // written without sign or leading zeros. Anything else is 0, which names no | |||
| 221 | + | // comment. | |||
| 222 | + | func commentID(s string) int64 { | |||
| 223 | + | n, err := strconv.ParseInt(s, 10, 64) | |||
| 224 | + | if err != nil || n < 1 || strconv.FormatInt(n, 10) != s { | |||
| 225 | + | return 0 | |||
| 226 | + | } | |||
| 227 | + | return n | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // headID parses a commit id of a form; the empty string is the zero id. | |||
| 231 | + | // | |||
| 232 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here | |||
| 233 | + | func headID(s string) (domain.ObjectID, error) { | |||
| 234 | + | if s == "" { | |||
| 235 | + | return domain.ObjectID{}, nil | |||
| 236 | + | } | |||
| 237 | + | var v domain.Violations | |||
| 238 | + | id, err := domain.ParseObjectID(s) | |||
| 239 | + | if err != nil { | |||
| 240 | + | v.Add("head_id", "is not a full commit id") | |||
| 241 | + | } | |||
| 242 | + | return id, v.Err() | |||
| 192 | } | 243 | } | ||
| 193 | 244 | ||||
| 194 | // mrClose is POST /<repo>/merge-requests/<n>/close. | 245 | // mrClose is POST /<repo>/merge-requests/<n>/close. | ||
| @@ -196,10 +247,14 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, | |||||
| 196 | repo := r.PathValue("repo") | 247 | repo := r.PathValue("repo") | ||
| 197 | n, err := number(r) | 248 | n, err := number(r) | ||
| 198 | if err != nil { | 249 | if err != nil { | ||
| 199 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 250 | + | return "", "", err |
| 200 | } | 251 | } | ||
| 201 | back := mrHref(repo, n) | 252 | back := mrHref(repo, n) | ||
| 202 | - | if _, err := s.cfg.MergeRequests.CloseMergeRequest(ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: repo, Number: n})); err != nil { | 253 | + | name, err := domain.ParseRepoName(repo) |
| 254 | + | if err != nil { | |||
| 255 | + | return "", back, err | |||
| 256 | + | } | |||
| 257 | + | if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil { | |||
| 203 | return "", back, err | 258 | return "", back, err | ||
| 204 | } | 259 | } | ||
| 205 | return back, back, nil | 260 | return back, back, nil | ||
| @@ -213,22 +268,29 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||||
| 213 | repo := r.PathValue("repo") | 268 | repo := r.PathValue("repo") | ||
| 214 | n, err := number(r) | 269 | n, err := number(r) | ||
| 215 | if err != nil { | 270 | if err != nil { | ||
| 216 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 271 | + | return "", "", err |
| 217 | } | 272 | } | ||
| 218 | back := mrHref(repo, n) | 273 | back := mrHref(repo, n) | ||
| 219 | - | var mode pb.MergeMode | 274 | + | var mode domain.MergeMode |
| 220 | switch r.PostFormValue("mode") { | 275 | switch r.PostFormValue("mode") { | ||
| 221 | case "fast-forward": | 276 | case "fast-forward": | ||
| 222 | - | mode = pb.MergeMode_MERGE_MODE_FAST_FORWARD | 277 | + | mode = domain.MergeModeFastForward |
| 223 | case "squash": | 278 | case "squash": | ||
| 224 | - | mode = pb.MergeMode_MERGE_MODE_SQUASH | 279 | + | mode = domain.MergeModeSquash |
| 225 | default: | 280 | default: | ||
| 226 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("mode must be fast-forward or squash")) | 281 | + | return "", back, formError("mode must be fast-forward or squash") |
| 227 | } | 282 | } | ||
| 228 | - | if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{ | 283 | + | name, err := domain.ParseRepoName(repo) |
| 229 | - | Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"), | 284 | + | if err != nil { |
| 230 | - | DeleteSourceBranch: r.PostFormValue("delete_branch") == "1", | 285 | + | return "", back, err |
| 231 | - | })); err != nil { | 286 | + | } |
| 287 | + | head, err := headID(r.PostFormValue("head_id")) | |||
| 288 | + | if err != nil { | |||
| 289 | + | return "", back, err | |||
| 290 | + | } | |||
| 291 | + | if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |||
| 292 | + | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | |||
| 293 | + | }); err != nil { | |||
| 232 | return "", back, err | 294 | return "", back, err | ||
| 233 | } | 295 | } | ||
| 234 | return back, back, nil | 296 | return back, back, nil | ||
| @@ -240,12 +302,18 @@ func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, e | |||||
| 240 | repo := r.PathValue("repo") | 302 | repo := r.PathValue("repo") | ||
| 241 | n, err := number(r) | 303 | n, err := number(r) | ||
| 242 | if err != nil { | 304 | if err != nil { | ||
| 243 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | 305 | + | return "", "", err |
| 244 | } | 306 | } | ||
| 245 | back := mrHref(repo, n) | 307 | back := mrHref(repo, n) | ||
| 246 | - | if _, err := s.cfg.MergeRequests.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{ | 308 | + | name, err := domain.ParseRepoName(repo) |
| 247 | - | Repo: repo, Number: n, HeadId: r.PostFormValue("head_id"), | 309 | + | if err != nil { |
| 248 | - | })); err != nil { | 310 | + | return "", back, err |
| 311 | + | } | |||
| 312 | + | head, err := headID(r.PostFormValue("head_id")) | |||
| 313 | + | if err != nil { | |||
| 314 | + | return "", back, err | |||
| 315 | + | } | |||
| 316 | + | if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil { | |||
| 249 | return "", back, err | 317 | return "", back, err | ||
| 250 | } | 318 | } | ||
| 251 | return back, back, nil | 319 | return back, back, nil | ||
| @@ -263,7 +331,7 @@ func formBool(s string) (bool, error) { | |||||
| 263 | } | 331 | } | ||
| 264 | 332 | ||||
| 265 | // normalizeNewlines turns the CRLF of a browser textarea into LF, the line | 333 | // normalizeNewlines turns the CRLF of a browser textarea into LF, the line | ||
| 266 | - | // break the API stores. | 334 | + | // break the use case stores. |
| 267 | func normalizeNewlines(s string) string { | 335 | func normalizeNewlines(s string) string { | ||
| 268 | return strings.ReplaceAll(s, "\r\n", "\n") | 336 | return strings.ReplaceAll(s, "\r\n", "\n") | ||
| 269 | } | 337 | } | ||
M internal/adapters/web/raw.go +37 -18
| @@ -4,6 +4,8 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 7 | "mime" | 9 | "mime" | ||
| 8 | "net/http" | 10 | "net/http" | ||
| 9 | "path" | 11 | "path" | ||
| @@ -11,11 +13,16 @@ import ( | |||||
| 11 | "time" | 13 | "time" | ||
| 12 | 14 | ||||
| 13 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | ) | 18 | ) | ||
| 15 | 19 | ||||
| 16 | // rawTimeout bounds the stream of one file to the visitor. | 20 | // rawTimeout bounds the stream of one file to the visitor. | ||
| 17 | const rawTimeout = 5 * time.Minute | 21 | const rawTimeout = 5 * time.Minute | ||
| 18 | 22 | ||||
| 23 | + | // rawChunkSize is the size of the writes that stream a file to the visitor. | |||
| 24 | + | const rawChunkSize = 64 << 10 | |||
| 25 | + | ||||
| 19 | // rawCSP makes a raw file inert if a browser opens it as a document: it can | 26 | // rawCSP makes a raw file inert if a browser opens it as a document: it can | ||
| 20 | // load nothing and run nothing, whatever it contains. | 27 | // load nothing and run nothing, whatever it contains. | ||
| 21 | const rawCSP = "default-src 'none'; sandbox" | 28 | const rawCSP = "default-src 'none'; sandbox" | ||
| @@ -24,6 +31,8 @@ const rawCSP = "default-src 'none'; sandbox" | |||||
| 24 | // image types (by their bytes, never SVG) as images, and anything else as an | 31 | // image types (by their bytes, never SVG) as images, and anything else as an | ||
| 25 | // attachment of unknown type. A file is never served as HTML or SVG, so what | 32 | // attachment of unknown type. A file is never served as HTML or SVG, so what | ||
| 26 | // a repository holds cannot run in the origin of the forge. | 33 | // a repository holds cannot run in the origin of the forge. | ||
| 34 | + | // | |||
| 35 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here | |||
| 27 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | 36 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | ||
| 28 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | 37 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | ||
| 29 | defer cancel() | 38 | defer cancel() | ||
| @@ -49,7 +58,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 49 | h.Set("Content-Type", typ) | 58 | h.Set("Content-Type", typ) | ||
| 50 | h.Set("X-Content-Type-Options", "nosniff") | 59 | h.Set("X-Content-Type-Options", "nosniff") | ||
| 51 | h.Set("Content-Security-Policy", rawCSP) | 60 | h.Set("Content-Security-Policy", rawCSP) | ||
| 52 | - | h.Set("Content-Length", strconv.FormatInt(b.info.GetSize(), 10)) | 61 | + | h.Set("Content-Length", strconv.FormatInt(b.info.Size, 10)) |
| 53 | if attach { | 62 | if attach { | ||
| 54 | name := path.Base(t.path) | 63 | name := path.Base(t.path) | ||
| 55 | cd := mime.FormatMediaType("attachment", map[string]string{"filename": name}) | 64 | cd := mime.FormatMediaType("attachment", map[string]string{"filename": name}) | ||
| @@ -58,13 +67,13 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 58 | } | 67 | } | ||
| 59 | h.Set("Content-Disposition", cd) | 68 | h.Set("Content-Disposition", cd) | ||
| 60 | } | 69 | } | ||
| 61 | - | if etag := b.info.GetId(); etag != "" { | 70 | + | if etag := b.info.ID.String(); etag != "" { |
| 62 | h.Set("ETag", `"`+etag+`"`) | 71 | h.Set("ETag", `"`+etag+`"`) | ||
| 63 | } | 72 | } | ||
| 64 | // Revalidated every time, even at a full commit id: a repository can turn | 73 | // Revalidated every time, even at a full commit id: a repository can turn | ||
| 65 | // private, and a shared cache must not keep serving its files (AC2). The | 74 | // private, and a shared cache must not keep serving its files (AC2). The | ||
| 66 | - | // ETag spares the body: the blob id comes with the first message of | 75 | + | // ETag spares the body: the blob id comes with the metadata, before the |
| 67 | - | // the stream, and a match ends the answer there. No cheaper call gives the | 76 | + | // content, and a match ends the answer there. No cheaper call gives the |
| 68 | // id of the blob at a path: a tree listing of its directory costs more | 77 | // id of the blob at a path: a tree listing of its directory costs more | ||
| 69 | // than opening a small file. | 78 | // than opening a small file. | ||
| 70 | cacheHeaders(ctx, h, "no-cache", sessionCache) | 79 | cacheHeaders(ctx, h, "no-cache", sessionCache) | ||
| @@ -80,31 +89,41 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 80 | return | 89 | return | ||
| 81 | } | 90 | } | ||
| 82 | b.buf = nil | 91 | b.buf = nil | ||
| 83 | - | for b.stream.Receive() { | 92 | + | if b.eof { |
| 84 | - | if _, err := w.Write(b.stream.Msg().GetChunk()); err != nil { | 93 | + | return |
| 94 | + | } | |||
| 95 | + | chunk := make([]byte, rawChunkSize) | |||
| 96 | + | for { | |||
| 97 | + | n, rerr := b.body.Read(chunk) | |||
| 98 | + | if n > 0 { | |||
| 99 | + | if _, err := w.Write(chunk[:n]); err != nil { | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | if errors.Is(rerr, io.EOF) { | |||
| 85 | return | 104 | return | ||
| 86 | } | 105 | } | ||
| 87 | - | } | 106 | + | if rerr != nil { |
| 88 | - | if err := b.stream.Err(); err != nil { | 107 | + | // Headers are gone: all that is left is to cut the answer short, |
| 89 | - | // Headers are gone: all that is left is to cut the answer short, so | 108 | + | // so that the client sees it is incomplete. |
| 90 | - | // that the client sees it is incomplete. | 109 | + | log := zerowrap.FromCtx(ctx) |
| 91 | - | log := zerowrap.FromCtx(ctx) | 110 | + | log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed") |
| 92 | - | log.Error().Err(err).Str("repo", repo).Str("path", logPath(r.URL.Path)).Msg("raw stream failed") | 111 | + | panic(http.ErrAbortHandler) |
| 93 | - | panic(http.ErrAbortHandler) | 112 | + | } |
| 94 | } | 113 | } | ||
| 95 | } | 114 | } | ||
| 96 | 115 | ||||
| 97 | // rawTarget finds the file a raw URL designates and the name of its repository. | 116 | // rawTarget finds the file a raw URL designates and the name of its repository. | ||
| 98 | - | func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, string, error) { | 117 | + | func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, domain.RepoName, error) { |
| 99 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 118 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 100 | if err != nil { | 119 | if err != nil { | ||
| 101 | - | return target{}, "", err | 120 | + | return target{}, domain.RepoName{}, err |
| 102 | } | 121 | } | ||
| 103 | t, ok := d.locate(r.PathValue("rest")) | 122 | t, ok := d.locate(r.PathValue("rest")) | ||
| 104 | if !ok || t.path == "" { | 123 | if !ok || t.path == "" { | ||
| 105 | - | return target{}, "", errNotFound | 124 | + | return target{}, domain.RepoName{}, errNotFound |
| 106 | } | 125 | } | ||
| 107 | - | return t, d.name(), nil | 126 | + | return t, d.repo.Name, nil |
| 108 | } | 127 | } | ||
| 109 | 128 | ||||
| 110 | // rawType is the Content-Type of a file and whether it is an attachment. | 129 | // rawType is the Content-Type of a file and whether it is an attachment. | ||
| @@ -113,7 +132,7 @@ func rawType(b *blobStream) (typ string, attachment bool) { | |||||
| 113 | if img := sniffImage(b.buf); img != "" { | 132 | if img := sniffImage(b.buf); img != "" { | ||
| 114 | return img, false | 133 | return img, false | ||
| 115 | } | 134 | } | ||
| 116 | - | if !b.info.GetBinary() { | 135 | + | if !b.info.Binary { |
| 117 | return "text/plain; charset=utf-8", false | 136 | return "text/plain; charset=utf-8", false | ||
| 118 | } | 137 | } | ||
| 119 | return "application/octet-stream", true | 138 | return "application/octet-stream", true | ||
M internal/adapters/web/raw_test.go +33 -44
| @@ -4,39 +4,50 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "io" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "net/http/httptest" | 9 | "net/http/httptest" | ||
| 10 | + | "strings" | |||
| 9 | "testing" | 11 | "testing" | ||
| 10 | 12 | ||||
| 11 | - | "connectrpc.com/connect" | |||
| 12 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| 14 | - | "github.com/stretchr/testify/require" | |||
| 15 | 15 | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | 16 | + | "bnema.dev/ermite/internal/domain" |
| 17 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 17 | + | "bnema.dev/ermite/internal/ports" |
| 18 | - | "bnema.dev/ermite/internal/adapters/web/mocks" | 18 | + | "bnema.dev/ermite/internal/ports/mocks" |
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | - | const blobID = "0123456789abcdef0123456789abcdef01234567" | 21 | + | const ( |
| 22 | - | 22 | + | blobID = "0123456789abcdef0123456789abcdef01234567" | |
| 23 | - | // blobAPI serves one text file, the only call raw makes besides the refs. | 23 | + | mainTip = "c0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe" |
| 24 | - | type blobAPI struct { | 24 | + | blobBody = "hello" |
| 25 | - | ermiteconnect.UnimplementedGitServiceHandler | 25 | + | ) |
| 26 | - | } | |||
| 27 | 26 | ||||
| 28 | - | func (*blobAPI) GetBlob(_ context.Context, req *connect.Request[pb.GetBlobRequest], st *connect.ServerStream[pb.GetBlobResponse]) error { | 27 | + | // rawBrowse serves one text file, the only call raw makes besides the refs. |
| 29 | - | if req.Msg.GetPath() != "docs/a.txt" { | 28 | + | func rawBrowse(t *testing.T) *mocks.BrowseService { |
| 30 | - | return connect.NewError(connect.CodeNotFound, assert.AnError) | 29 | + | t.Helper() |
| 31 | - | } | 30 | + | browse := mocks.NewBrowseService(t) |
| 32 | - | return st.Send(&pb.GetBlobResponse{ | 31 | + | browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{ |
| 33 | - | Info: &pb.BlobInfo{Id: blobID, Path: "docs/a.txt", Size: 5, MediaType: "text/plain"}, | 32 | + | Refs: []domain.Ref{{ |
| 34 | - | Chunk: []byte("hello"), | 33 | + | Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, |
| 35 | - | }) | 34 | + | ID: objectID(t, mainTip), CommitID: objectID(t, mainTip), |
| 35 | + | }}, | |||
| 36 | + | }, nil).Maybe() | |||
| 37 | + | browse.EXPECT().OpenBlob(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything, mock.Anything).RunAndReturn( | |||
| 38 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 39 | + | if path.String() != "docs/a.txt" || rev.String() != mainTip { | |||
| 40 | + | return domain.BlobInfo{}, nil, domain.ErrNotFound | |||
| 41 | + | } | |||
| 42 | + | return domain.BlobInfo{ID: objectID(t, blobID), Path: "docs/a.txt", Size: int64(len(blobBody)), MediaType: "text/plain"}, | |||
| 43 | + | io.NopCloser(strings.NewReader(blobBody)), nil | |||
| 44 | + | }).Maybe() | |||
| 45 | + | return browse | |||
| 36 | } | 46 | } | ||
| 37 | 47 | ||||
| 38 | // A revalidation of an unchanged file ends with a 304 and no body. The blob is | 48 | // A revalidation of an unchanged file ends with a 304 and no body. The blob is | ||
| 39 | - | // opened in every case: its id is only known from the stream. | 49 | + | // opened in every case: its id is only known from the metadata the use case |
| 50 | + | // returns with the content. | |||
| 40 | func TestRawRevalidation(t *testing.T) { | 51 | func TestRawRevalidation(t *testing.T) { | ||
| 41 | t.Parallel() | 52 | t.Parallel() | ||
| 42 | tests := []struct { | 53 | tests := []struct { | ||
| @@ -58,19 +69,8 @@ func TestRawRevalidation(t *testing.T) { | |||||
| 58 | for _, tt := range tests { | 69 | for _, tt := range tests { | ||
| 59 | t.Run(tt.name, func(t *testing.T) { | 70 | t.Run(tt.name, func(t *testing.T) { | ||
| 60 | t.Parallel() | 71 | t.Parallel() | ||
| 61 | - | repos := mocks.NewRepoServiceClient(t) | 72 | + | repos := reposServing(t, publicRepo(t, "r")) |
| 62 | - | repos.EXPECT().GetRepo(mock.Anything, mock.Anything). | 73 | + | srv := newTestServer(t, Config{Repos: repos, Browse: rawBrowse(t)}) |
| 63 | - | Return(connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: "r", DefaultBranch: "main"}}), nil) | |||
| 64 | - | mux := http.NewServeMux() | |||
| 65 | - | mux.Handle(ermiteconnect.NewGitServiceHandler(&refsGit{})) | |||
| 66 | - | backend := httptest.NewServer(mux) | |||
| 67 | - | t.Cleanup(backend.Close) | |||
| 68 | - | ||||
| 69 | - | srv, err := New(Config{ | |||
| 70 | - | Repos: repos, Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |||
| 71 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 72 | - | }) | |||
| 73 | - | require.NoError(t, err) | |||
| 74 | req := httptest.NewRequest(tt.method, tt.url, nil) | 74 | req := httptest.NewRequest(tt.method, tt.url, nil) | ||
| 75 | if tt.ifNone != "" { | 75 | if tt.ifNone != "" { | ||
| 76 | req.Header.Set("If-None-Match", tt.ifNone) | 76 | req.Header.Set("If-None-Match", tt.ifNone) | ||
| @@ -93,14 +93,3 @@ func TestRawRevalidation(t *testing.T) { | |||||
| 93 | }) | 93 | }) | ||
| 94 | } | 94 | } | ||
| 95 | } | 95 | } | ||
| 96 | - | ||||
| 97 | - | // refsGit adds the refs of the repository to blobAPI. | |||
| 98 | - | type refsGit struct { | |||
| 99 | - | blobAPI | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func (g *refsGit) ListRefs(context.Context, *connect.Request[pb.ListRefsRequest]) (*connect.Response[pb.ListRefsResponse], error) { | |||
| 103 | - | return connect.NewResponse(&pb.ListRefsResponse{Refs: []*pb.Ref{ | |||
| 104 | - | {Name: "refs/heads/main", ShortName: "main", Kind: pb.RefKind_REF_KIND_BRANCH, CommitId: "c0ffee0"}, | |||
| 105 | - | }}), nil | |||
| 106 | - | } | |||
M internal/adapters/web/refs.go +19 -17
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "net/url" | 6 | "net/url" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | 8 | ||||
| 9 | - | pb "bnema.dev/ermite-api/gen/ermite" | 9 | + | "bnema.dev/ermite/internal/domain" |
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | // resolveRef finds the ref a URL path starts with and the path that follows. | 12 | // resolveRef finds the ref a URL path starts with and the path that follows. | ||
| @@ -14,7 +14,9 @@ import ( | |||||
| 14 | // that is the short name of a branch or tag wins. A branch wins over a tag of | 14 | // that is the short name of a branch or tag wins. A branch wins over a tag of | ||
| 15 | // the same name. rest has no leading slash and is "" when the path is the ref | 15 | // the same name. rest has no leading slash and is "" when the path is the ref | ||
| 16 | // alone. ok is false when no ref matches; the caller may then try a commit id. | 16 | // alone. ok is false when no ref matches; the caller may then try a commit id. | ||
| 17 | - | func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | 17 | + | // |
| 18 | + | //hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here | |||
| 19 | + | func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) { | |||
| 18 | names := refsByName(refs) | 20 | names := refsByName(refs) | ||
| 19 | p = strings.Trim(p, "/") | 21 | p = strings.Trim(p, "/") | ||
| 20 | for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") { | 22 | for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") { | ||
| @@ -22,21 +24,21 @@ func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | |||||
| 22 | return r, strings.TrimPrefix(p[end:], "/"), true | 24 | return r, strings.TrimPrefix(p[end:], "/"), true | ||
| 23 | } | 25 | } | ||
| 24 | } | 26 | } | ||
| 25 | - | return nil, "", false | 27 | + | return domain.Ref{}, "", false |
| 26 | } | 28 | } | ||
| 27 | 29 | ||||
| 28 | // refsByName indexes the branches and tags by short name. A branch wins over | 30 | // refsByName indexes the branches and tags by short name. A branch wins over | ||
| 29 | // a tag of the same name. | 31 | // a tag of the same name. | ||
| 30 | - | func refsByName(refs []*pb.Ref) map[string]*pb.Ref { | 32 | + | func refsByName(refs []domain.Ref) map[string]domain.Ref { |
| 31 | - | names := make(map[string]*pb.Ref, len(refs)) | 33 | + | names := make(map[string]domain.Ref, len(refs)) |
| 32 | for _, r := range refs { | 34 | for _, r := range refs { | ||
| 33 | - | k := r.GetKind() | 35 | + | k := r.Kind |
| 34 | - | if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG { | 36 | + | if k != domain.RefKindBranch && k != domain.RefKindTag { |
| 35 | continue | 37 | continue | ||
| 36 | } | 38 | } | ||
| 37 | - | cur, dup := names[r.GetShortName()] | 39 | + | cur, dup := names[r.ShortName] |
| 38 | - | if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH { | 40 | + | if !dup || cur.Kind != domain.RefKindBranch { |
| 39 | - | names[r.GetShortName()] = r | 41 | + | names[r.ShortName] = r |
| 40 | } | 42 | } | ||
| 41 | } | 43 | } | ||
| 42 | return names | 44 | return names | ||
| @@ -68,8 +70,8 @@ func repoHref(repo string, parts ...string) string { | |||||
| 68 | // target is what the URL of a tree, blob, raw or commits page designates: a | 70 | // target is what the URL of a tree, blob, raw or commits page designates: a | ||
| 69 | // commit, the name it was reached by, and a path in its tree. | 71 | // commit, the name it was reached by, and a path in its tree. | ||
| 70 | type target struct { | 72 | type target struct { | ||
| 71 | - | // rev is what the API is asked for: the id of the commit of a ref, or | 73 | + | // rev is what the use cases are asked for: the id of the commit of a ref, |
| 72 | - | // the commit id of the URL itself. | 74 | + | // or the commit id of the URL itself. |
| 73 | rev string | 75 | rev string | ||
| 74 | // name is how the URL spells the ref: a branch or a tag, or the commit id | 76 | // name is how the URL spells the ref: a branch or a tag, or the commit id | ||
| 75 | // when detached. | 77 | // when detached. | ||
| @@ -83,17 +85,17 @@ type target struct { | |||||
| 83 | // longest prefix of the path that is the short name of a branch or a tag | 85 | // longest prefix of the path that is the short name of a branch or a tag | ||
| 84 | // (names may contain "/"); a branch wins over a tag of the same name. An | 86 | // (names may contain "/"); a branch wins over a tag of the same name. An | ||
| 85 | // empty rest designates the head of the repository. Whether the commit or | 87 | // empty rest designates the head of the repository. Whether the commit or | ||
| 86 | - | // the path exists is for the API to say. | 88 | + | // the path exists is for the use cases to say. |
| 87 | func (d *repoData) locate(rest string) (target, bool) { | 89 | func (d *repoData) locate(rest string) (target, bool) { | ||
| 88 | rest = strings.Trim(rest, "/") | 90 | rest = strings.Trim(rest, "/") | ||
| 89 | if rest == "" { | 91 | if rest == "" { | ||
| 90 | if d.head == nil { | 92 | if d.head == nil { | ||
| 91 | return target{}, false | 93 | return target{}, false | ||
| 92 | } | 94 | } | ||
| 93 | - | return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true | 95 | + | return target{rev: d.head.CommitID.String(), name: d.head.ShortName}, true |
| 94 | } | 96 | } | ||
| 95 | - | if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" { | 97 | + | if ref, p, ok := resolveRef(d.allRefs(), rest); ok && !ref.CommitID.IsZero() { |
| 96 | - | return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true | 98 | + | return target{rev: ref.CommitID.String(), name: ref.ShortName, path: p}, true |
| 97 | } | 99 | } | ||
| 98 | first, p, _ := strings.Cut(rest, "/") | 100 | first, p, _ := strings.Cut(rest, "/") | ||
| 99 | if isCommitID(first) { | 101 | if isCommitID(first) { | ||
| @@ -123,7 +125,7 @@ func (d *repoData) isBranchHead(t target) bool { | |||||
| 123 | return false | 125 | return false | ||
| 124 | } | 126 | } | ||
| 125 | for _, b := range d.branches { | 127 | for _, b := range d.branches { | ||
| 126 | - | if b.GetShortName() == t.name && b.GetCommitId() == t.rev { | 128 | + | if b.ShortName == t.name && b.CommitID.String() == t.rev { |
| 127 | return true | 129 | return true | ||
| 128 | } | 130 | } | ||
| 129 | } | 131 | } | ||
M internal/adapters/web/refs_test.go +26 -12
| @@ -3,28 +3,42 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "crypto/sha1" // #nosec G505 -- ids for tests | |||
| 7 | + | "encoding/hex" | |||
| 6 | "testing" | 8 | "testing" | ||
| 7 | 9 | ||||
| 8 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 9 | 11 | ||||
| 10 | - | pb "bnema.dev/ermite-api/gen/ermite" | 12 | + | "bnema.dev/ermite/internal/domain" |
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | - | func ref(kind pb.RefKind, short string) *pb.Ref { | 15 | + | // commitOf is a commit id derived from a name, so that two refs of one name |
| 14 | - | return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short} | 16 | + | // share their commit. |
| 17 | + | func commitOf(name string) domain.ObjectID { | |||
| 18 | + | sum := sha1.Sum([]byte(name)) // #nosec G401 -- an id for a test, not a hash for security | |||
| 19 | + | id, err := domain.ParseObjectID(hex.EncodeToString(sum[:])) | |||
| 20 | + | if err != nil { | |||
| 21 | + | panic(err) | |||
| 22 | + | } | |||
| 23 | + | return id | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func ref(kind domain.RefKind, short string) domain.Ref { | |||
| 27 | + | id := commitOf(short) | |||
| 28 | + | return domain.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, ID: id, CommitID: id} | |||
| 15 | } | 29 | } | ||
| 16 | 30 | ||||
| 17 | // AC3: a ref with slashes is found by the longest matching prefix. | 31 | // AC3: a ref with slashes is found by the longest matching prefix. | ||
| 18 | func TestResolveRef(t *testing.T) { | 32 | func TestResolveRef(t *testing.T) { | ||
| 19 | t.Parallel() | 33 | t.Parallel() | ||
| 20 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | 34 | + | b, tag := domain.RefKindBranch, domain.RefKindTag |
| 21 | - | refs := []*pb.Ref{ | 35 | + | refs := []domain.Ref{ |
| 22 | ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"), | 36 | ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"), | ||
| 23 | - | ref(pb.RefKind_REF_KIND_HEAD, "main"), ref(tag, "main"), | 37 | + | ref(domain.RefKindHead, "main"), ref(tag, "main"), |
| 24 | } | 38 | } | ||
| 25 | tests := []struct { | 39 | tests := []struct { | ||
| 26 | path, ref, rest string | 40 | path, ref, rest string | ||
| 27 | - | kind pb.RefKind | 41 | + | kind domain.RefKind |
| 28 | ok bool | 42 | ok bool | ||
| 29 | }{ | 43 | }{ | ||
| 30 | {"main", "main", "", b, true}, | 44 | {"main", "main", "", b, true}, | ||
| @@ -34,9 +48,9 @@ func TestResolveRef(t *testing.T) { | |||||
| 34 | {"feature/other", "feature", "other", b, true}, | 48 | {"feature/other", "feature", "other", b, true}, | ||
| 35 | {"/feature/x/", "feature/x", "", b, true}, | 49 | {"/feature/x/", "feature/x", "", b, true}, | ||
| 36 | {"v1/rc/README", "v1/rc", "README", tag, true}, | 50 | {"v1/rc/README", "v1/rc", "README", tag, true}, | ||
| 37 | - | {"v1/README", "", "", 0, false}, | 51 | + | {"v1/README", "", "", "", false}, |
| 38 | - | {"nope/main", "", "", 0, false}, | 52 | + | {"nope/main", "", "", "", false}, |
| 39 | - | {"", "", "", 0, false}, | 53 | + | {"", "", "", "", false}, |
| 40 | } | 54 | } | ||
| 41 | for _, tt := range tests { | 55 | for _, tt := range tests { | ||
| 42 | t.Run(tt.path, func(t *testing.T) { | 56 | t.Run(tt.path, func(t *testing.T) { | ||
| @@ -46,8 +60,8 @@ func TestResolveRef(t *testing.T) { | |||||
| 46 | if !ok { | 60 | if !ok { | ||
| 47 | return | 61 | return | ||
| 48 | } | 62 | } | ||
| 49 | - | assert.Equal(t, tt.ref, r.GetShortName()) | 63 | + | assert.Equal(t, tt.ref, r.ShortName) |
| 50 | - | assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name") | 64 | + | assert.Equal(t, tt.kind, r.Kind, "a branch wins over a tag of the same name") |
| 51 | assert.Equal(t, tt.rest, rest) | 65 | assert.Equal(t, tt.rest, rest) | ||
| 52 | }) | 66 | }) | ||
| 53 | } | 67 | } | ||
M internal/adapters/web/refspage.go +16 -15
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/a-h/templ" | 10 | "github.com/a-h/templ" | ||
| 11 | 11 | ||||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | 12 | + | "bnema.dev/ermite/internal/domain" |
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // refs lists the branches and the tags of a repository with the commit each | 15 | // refs lists the branches and the tags of a repository with the commit each | ||
| @@ -33,27 +33,27 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 33 | // refsPage is the content of the refs page, which the live updates of the | 33 | // refsPage is the content of the refs page, which the live updates of the | ||
| 34 | // page render again. | 34 | // page render again. | ||
| 35 | func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | 35 | func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | ||
| 36 | - | commits := s.commitsOf(ctx, d.name(), d.allRefs()) | 36 | + | commits := s.commitsOf(ctx, d.repo.Name, d.allRefs()) |
| 37 | 37 | ||||
| 38 | - | def := d.repo.GetDefaultBranch() | 38 | + | def := d.repo.DefaultBranch |
| 39 | p := RefsPage{Default: def} | 39 | p := RefsPage{Default: def} | ||
| 40 | - | branches := append([]*pb.Ref(nil), d.branches...) | 40 | + | branches := append([]domain.Ref(nil), d.branches...) |
| 41 | sort.SliceStable(branches, func(i, j int) bool { | 41 | sort.SliceStable(branches, func(i, j int) bool { | ||
| 42 | - | a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def | 42 | + | a, b := branches[i].ShortName == def, branches[j].ShortName == def |
| 43 | return a && !b | 43 | return a && !b | ||
| 44 | }) | 44 | }) | ||
| 45 | for i, b := range branches { | 45 | for i, b := range branches { | ||
| 46 | - | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.GetShortName() == def, i == 0)) | 46 | + | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0)) |
| 47 | } | 47 | } | ||
| 48 | - | tags := append([]*pb.Ref(nil), d.tags...) | 48 | + | tags := append([]domain.Ref(nil), d.tags...) |
| 49 | sort.SliceStable(tags, func(i, j int) bool { | 49 | sort.SliceStable(tags, func(i, j int) bool { | ||
| 50 | - | return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at | 50 | + | return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at |
| 51 | }) | 51 | }) | ||
| 52 | for i, t := range tags { | 52 | for i, t := range tags { | ||
| 53 | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | 53 | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | ||
| 54 | } | 54 | } | ||
| 55 | if len(tags) > 0 { | 55 | if len(tags) > 0 { | ||
| 56 | - | p.Latest = tags[0].GetShortName() | 56 | + | p.Latest = tags[0].ShortName |
| 57 | } | 57 | } | ||
| 58 | if d.cut { | 58 | if d.cut { | ||
| 59 | p.Note = "this repository has more refs than are listed here." | 59 | p.Note = "this repository has more refs than are listed here." | ||
| @@ -61,15 +61,16 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||||
| 61 | return p | 61 | return p | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | 64 | + | func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { |
| 65 | - | c := commits[r.GetCommitId()] | 65 | + | id := r.CommitID.String() |
| 66 | + | c := commits[id] | |||
| 66 | row := RefRow{ | 67 | row := RefRow{ | ||
| 67 | - | Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()), | 68 | + | Name: r.ShortName, Href: repoHref(d.name(), "tree", r.ShortName), |
| 68 | - | Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt, | 69 | + | Short: short(id), Subject: c.subject, When: c.when, WhenAt: c.whenAt, |
| 69 | Default: isDefault, Cursor: cursor, | 70 | Default: isDefault, Cursor: cursor, | ||
| 70 | } | 71 | } | ||
| 71 | - | if r.GetCommitId() != "" { | 72 | + | if id != "" { |
| 72 | - | row.CHref = repoHref(d.name(), "commit", r.GetCommitId()) | 73 | + | row.CHref = repoHref(d.name(), "commit", id) |
| 73 | } | 74 | } | ||
| 74 | return row | 75 | return row | ||
| 75 | } | 76 | } | ||
M internal/adapters/web/repo.go +83 -55
| @@ -10,10 +10,8 @@ import ( | |||||
| 10 | "sync" | 10 | "sync" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | - | "connectrpc.com/connect" | 13 | + | "bnema.dev/ermite/internal/domain" |
| 14 | - | "google.golang.org/protobuf/types/known/timestamppb" | 14 | + | "bnema.dev/ermite/internal/ports" |
| 15 | - | ||||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 17 | ) | 15 | ) | ||
| 18 | 16 | ||||
| 19 | // maxRefs bounds the refs one page reads. A repository with more has its | 17 | // maxRefs bounds the refs one page reads. A repository with more has its | ||
| @@ -23,47 +21,69 @@ const maxRefs = 1000 | |||||
| 23 | // repoData is what the pages of one repository start from: its metadata and | 21 | // repoData is what the pages of one repository start from: its metadata and | ||
| 24 | // its refs, read once per request. | 22 | // its refs, read once per request. | ||
| 25 | type repoData struct { | 23 | type repoData struct { | ||
| 26 | - | repo *pb.Repo | 24 | + | repo ports.RepoView |
| 27 | - | branches []*pb.Ref | 25 | + | branches []domain.Ref |
| 28 | - | tags []*pb.Ref | 26 | + | tags []domain.Ref |
| 29 | // cut is true when the repository has more refs than maxRefs. | 27 | // cut is true when the repository has more refs than maxRefs. | ||
| 30 | cut bool | 28 | cut bool | ||
| 31 | // head is the branch the repository opens on: its default branch, or the | 29 | // head is the branch the repository opens on: its default branch, or the | ||
| 32 | // first one when the default does not exist. nil for an empty repository. | 30 | // first one when the default does not exist. nil for an empty repository. | ||
| 33 | - | head *pb.Ref | 31 | + | head *domain.Ref |
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // repoView reads a repository as the caller of the request may see it, with | |||
| 35 | + | // the Git state of its summary. A missing or private repository is | |||
| 36 | + | // domain.ErrNotFound. | |||
| 37 | + | func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) { | |||
| 38 | + | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return ports.RepoView{}, err | |||
| 41 | + | } | |||
| 42 | + | view, err := s.cfg.Repos.View(ctx, repo) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return ports.RepoView{}, err | |||
| 45 | + | } | |||
| 46 | + | if view.Settings.IsZero() { | |||
| 47 | + | view.Settings = domain.DefaultRepoSettings() | |||
| 48 | + | } | |||
| 49 | + | return view, nil | |||
| 34 | } | 50 | } | ||
| 35 | 51 | ||||
| 36 | // loadRepo reads a repository and its refs. A missing, private or invalidly | 52 | // loadRepo reads a repository and its refs. A missing, private or invalidly | ||
| 37 | - | // named repository is a not_found / invalid_argument error of the API, which | 53 | + | // named repository is a domain.ErrNotFound / invalid name error, which the |
| 38 | - | // the caller turns into the 404 page. | 54 | + | // caller turns into the 404 page. |
| 39 | - | func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | 55 | + | func (s *Server) loadRepo(ctx context.Context, rawName string) (*repoData, error) { |
| 40 | - | res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | 56 | + | name, err := domain.ParseRepoName(rawName) |
| 57 | + | if err != nil { | |||
| 58 | + | return nil, err | |||
| 59 | + | } | |||
| 60 | + | view, err := s.repoView(ctx, name) | |||
| 41 | if err != nil { | 61 | if err != nil { | ||
| 42 | return nil, err | 62 | return nil, err | ||
| 43 | } | 63 | } | ||
| 44 | - | d := &repoData{repo: res.Msg.GetRepo()} | 64 | + | d := &repoData{repo: view} |
| 45 | // Each page of ListRefs runs the whole for-each-ref again: the refs are | 65 | // Each page of ListRefs runs the whole for-each-ref again: the refs are | ||
| 46 | - | // asked in one page of up to maxRefs (the API lowers it to its cap, and | 66 | + | // asked in one page of up to maxRefs (the use case lowers it to its cap, |
| 47 | - | // the token then leads to the rest). | 67 | + | // and the token then leads to the rest). |
| 48 | token, n := "", 0 | 68 | token, n := "", 0 | ||
| 49 | for { | 69 | for { | ||
| 50 | - | page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{ | 70 | + | page, err := s.cfg.Browse.ListRefs(ctx, principal(ctx), name, ports.ListRefsParams{ |
| 51 | - | Repo: name, PageSize: maxRefs, PageToken: token, | 71 | + | Kinds: []domain.RefKind{domain.RefKindBranch, domain.RefKindTag}, |
| 52 | - | Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG}, | 72 | + | PageSize: maxRefs, PageToken: token, |
| 53 | - | })) | 73 | + | }) |
| 54 | if err != nil { | 74 | if err != nil { | ||
| 55 | return nil, err | 75 | return nil, err | ||
| 56 | } | 76 | } | ||
| 57 | - | for _, r := range page.Msg.GetRefs() { | 77 | + | for _, r := range page.Refs { |
| 58 | - | switch r.GetKind() { | 78 | + | switch r.Kind { |
| 59 | - | case pb.RefKind_REF_KIND_BRANCH: | 79 | + | case domain.RefKindBranch: |
| 60 | d.branches = append(d.branches, r) | 80 | d.branches = append(d.branches, r) | ||
| 61 | - | case pb.RefKind_REF_KIND_TAG: | 81 | + | case domain.RefKindTag: |
| 62 | d.tags = append(d.tags, r) | 82 | d.tags = append(d.tags, r) | ||
| 63 | } | 83 | } | ||
| 64 | n++ | 84 | n++ | ||
| 65 | } | 85 | } | ||
| 66 | - | if token = page.Msg.GetNextPageToken(); token == "" { | 86 | + | if token = page.NextPageToken; token == "" { |
| 67 | break | 87 | break | ||
| 68 | } | 88 | } | ||
| 69 | if n >= maxRefs { | 89 | if n >= maxRefs { | ||
| @@ -71,24 +91,24 @@ func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | |||||
| 71 | break | 91 | break | ||
| 72 | } | 92 | } | ||
| 73 | } | 93 | } | ||
| 74 | - | for _, b := range d.branches { | 94 | + | for i := range d.branches { |
| 75 | - | if b.GetShortName() == d.repo.GetDefaultBranch() { | 95 | + | if d.branches[i].ShortName == d.repo.DefaultBranch { |
| 76 | - | d.head = b | 96 | + | d.head = &d.branches[i] |
| 77 | } | 97 | } | ||
| 78 | } | 98 | } | ||
| 79 | if d.head == nil && len(d.branches) > 0 { | 99 | if d.head == nil && len(d.branches) > 0 { | ||
| 80 | - | d.head = d.branches[0] | 100 | + | d.head = &d.branches[0] |
| 81 | } | 101 | } | ||
| 82 | return d, nil | 102 | return d, nil | ||
| 83 | } | 103 | } | ||
| 84 | 104 | ||||
| 85 | // allRefs is the branches then the tags. | 105 | // allRefs is the branches then the tags. | ||
| 86 | - | func (d *repoData) allRefs() []*pb.Ref { | 106 | + | func (d *repoData) allRefs() []domain.Ref { |
| 87 | - | return append(append([]*pb.Ref(nil), d.branches...), d.tags...) | 107 | + | return append(append([]domain.Ref(nil), d.branches...), d.tags...) |
| 88 | } | 108 | } | ||
| 89 | 109 | ||||
| 90 | // name is the repository name. | 110 | // name is the repository name. | ||
| 91 | - | func (d *repoData) name() string { return d.repo.GetName() } | 111 | + | func (d *repoData) name() string { return d.repo.Name.String() } |
| 92 | 112 | ||||
| 93 | // layout is the Layout of the pages of a repository: the sections, the ref | 113 | // layout is the Layout of the pages of a repository: the sections, the ref | ||
| 94 | // switcher on the head ref and the ":" targets. The caller sets the title, | 114 | // switcher on the head ref and the ":" targets. The caller sets the title, | ||
| @@ -96,11 +116,11 @@ func (d *repoData) name() string { return d.repo.GetName() } | |||||
| 96 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | 116 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | ||
| 97 | l := s.base() | 117 | l := s.base() | ||
| 98 | l.Repo = d.name() | 118 | l.Repo = d.name() | ||
| 99 | - | def := d.repo.GetDefaultBranch() | 119 | + | def := d.repo.DefaultBranch |
| 100 | if d.head != nil { | 120 | if d.head != nil { | ||
| 101 | - | def = d.head.GetShortName() | 121 | + | def = d.head.ShortName |
| 102 | l.Ref, l.RefPath = def, def | 122 | l.Ref, l.RefPath = def, def | ||
| 103 | - | l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId()) | 123 | + | l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String()) |
| 104 | } | 124 | } | ||
| 105 | mk := func(key, label, href, id string) Section { | 125 | mk := func(key, label, href, id string) Section { | ||
| 106 | return Section{Key: key, Label: label, Href: href, Current: section == id} | 126 | return Section{Key: key, Label: label, Href: href, Current: section == id} | ||
| @@ -122,12 +142,12 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||||
| 122 | } | 142 | } | ||
| 123 | if l.Ref != "" { | 143 | if l.Ref != "" { | ||
| 124 | for _, b := range d.branches { | 144 | for _, b := range d.branches { | ||
| 125 | - | it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head} | 145 | + | it := RefItem{Name: b.ShortName, Href: repoHref(d.name(), "tree", b.ShortName), Current: b.ShortName == d.head.ShortName} |
| 126 | l.Heads = append(l.Heads, it) | 146 | l.Heads = append(l.Heads, it) | ||
| 127 | l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href}) | 147 | l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href}) | ||
| 128 | } | 148 | } | ||
| 129 | for _, t := range d.tags { | 149 | for _, t := range d.tags { | ||
| 130 | - | it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())} | 150 | + | it := RefItem{Name: t.ShortName, Href: repoHref(d.name(), "tree", t.ShortName)} |
| 131 | l.Tags = append(l.Tags, it) | 151 | l.Tags = append(l.Tags, it) | ||
| 132 | l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href}) | 152 | l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href}) | ||
| 133 | } | 153 | } | ||
| @@ -153,7 +173,7 @@ type refCommit struct { | |||||
| 153 | 173 | ||||
| 154 | // commitsOf reads the head commit of each ref, a few at a time. A ref whose | 174 | // commitsOf reads the head commit of each ref, a few at a time. A ref whose | ||
| 155 | // commit cannot be read (a tag on a tree, say) has no entry. | 175 | // commit cannot be read (a tag on a tree, say) has no entry. | ||
| 156 | - | func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit { | 176 | + | func (s *Server) commitsOf(ctx context.Context, repo domain.RepoName, refs []domain.Ref) map[string]refCommit { |
| 157 | const workers = 8 | 177 | const workers = 8 | ||
| 158 | var ( | 178 | var ( | ||
| 159 | mu sync.Mutex | 179 | mu sync.Mutex | ||
| @@ -163,10 +183,10 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||||
| 163 | ) | 183 | ) | ||
| 164 | now := s.cfg.Now() | 184 | now := s.cfg.Now() | ||
| 165 | for _, r := range refs { | 185 | for _, r := range refs { | ||
| 166 | - | id := r.GetCommitId() | 186 | + | if r.CommitID.IsZero() { |
| 167 | - | if id == "" { | |||
| 168 | continue | 187 | continue | ||
| 169 | } | 188 | } | ||
| 189 | + | id := r.CommitID.String() | |||
| 170 | mu.Lock() | 190 | mu.Lock() | ||
| 171 | _, done := out[id] | 191 | _, done := out[id] | ||
| 172 | if !done { | 192 | if !done { | ||
| @@ -181,12 +201,12 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||||
| 181 | go func() { | 201 | go func() { | ||
| 182 | defer func() { <-sem; wg.Done() }() | 202 | defer func() { <-sem; wg.Done() }() | ||
| 183 | // By id, not by name: a branch and a tag may share a name. | 203 | // By id, not by name: a branch and a tag may share a name. | ||
| 184 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1})) | 204 | + | res, err := s.commitList(ctx, repo, id, "", 1, "") |
| 185 | var rc refCommit | 205 | var rc refCommit | ||
| 186 | - | if err == nil && len(res.Msg.GetCommits()) == 1 { | 206 | + | if err == nil && len(res.Commits) == 1 { |
| 187 | - | row := commitRow(repo, res.Msg.GetCommits()[0], now) | 207 | + | row := commitRow(repo.String(), res.Commits[0], now) |
| 188 | rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt | 208 | rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt | ||
| 189 | - | rc.at = res.Msg.GetCommits()[0].GetCommitter().GetTime().GetSeconds() | 209 | + | rc.at = unixSeconds(res.Commits[0].Committer.Time) |
| 190 | } | 210 | } | ||
| 191 | mu.Lock() | 211 | mu.Lock() | ||
| 192 | out[id] = rc | 212 | out[id] = rc | ||
| @@ -198,32 +218,40 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||||
| 198 | } | 218 | } | ||
| 199 | 219 | ||||
| 200 | // whenOf is the relative and the machine form of a time; both are "" when | 220 | // whenOf is the relative and the machine form of a time; both are "" when | ||
| 201 | - | // the time is unknown. | 221 | + | // the time is unknown (the zero time). |
| 202 | - | func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) { | 222 | + | func whenOf(now time.Time, t time.Time) (when, at string) { |
| 203 | - | if !ts.IsValid() { | 223 | + | if t.IsZero() { |
| 204 | return "", "" | 224 | return "", "" | ||
| 205 | } | 225 | } | ||
| 206 | - | return ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | 226 | + | return ago(now, t), rfc3339(t) |
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // unixSeconds is the Unix time of t in seconds, 0 for the zero time. | |||
| 230 | + | func unixSeconds(t time.Time) int64 { | |||
| 231 | + | if t.IsZero() { | |||
| 232 | + | return 0 | |||
| 233 | + | } | |||
| 234 | + | return t.Unix() | |||
| 207 | } | 235 | } | ||
| 208 | 236 | ||||
| 209 | // commitRow is the row every list of commits starts from: the short id, the | 237 | // commitRow is the row every list of commits starts from: the short id, the | ||
| 210 | // subject, the author and the date of the committer, all fit to display. The | 238 | // subject, the author and the date of the committer, all fit to display. The | ||
| 211 | // caller adds what is specific to its list. | 239 | // caller adds what is specific to its list. | ||
| 212 | - | func commitRow(repo string, c *pb.Commit, now time.Time) CommitRow { | 240 | + | func commitRow(repo string, c domain.Commit, now time.Time) CommitRow { |
| 213 | row := CommitRow{ | 241 | row := CommitRow{ | ||
| 214 | - | Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), | 242 | + | Short: short(c.ID.String()), Subject: visibleText(c.Summary), |
| 215 | - | CHref: repoHref(repo, "commit", c.GetId()), | 243 | + | CHref: repoHref(repo, "commit", c.ID.String()), |
| 216 | - | Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1, | 244 | + | Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1, |
| 217 | } | 245 | } | ||
| 218 | - | row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime()) | 246 | + | row.When, row.WhenAt = whenOf(now, c.Committer.Time) |
| 219 | return row | 247 | return row | ||
| 220 | } | 248 | } | ||
| 221 | 249 | ||||
| 222 | // namesAt maps commit ids to the names of the refs that designate them. | 250 | // namesAt maps commit ids to the names of the refs that designate them. | ||
| 223 | - | func namesAt(refs []*pb.Ref) map[string][]string { | 251 | + | func namesAt(refs []domain.Ref) map[string][]string { |
| 224 | m := map[string][]string{} | 252 | m := map[string][]string{} | ||
| 225 | for _, r := range refs { | 253 | for _, r := range refs { | ||
| 226 | - | m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName()) | 254 | + | m[r.CommitID.String()] = append(m[r.CommitID.String()], r.ShortName) |
| 227 | } | 255 | } | ||
| 228 | for _, v := range m { | 256 | for _, v := range m { | ||
| 229 | sort.Strings(v) | 257 | sort.Strings(v) | ||
| @@ -237,7 +265,7 @@ func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage | |||||
| 237 | l.Title = d.name() + " · " + s.instance | 265 | l.Title = d.name() + " · " + s.instance | ||
| 238 | l.Sections = nil | 266 | l.Sections = nil | ||
| 239 | l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" }) | 267 | l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" }) | ||
| 240 | - | branch := d.repo.GetDefaultBranch() | 268 | + | branch := d.repo.DefaultBranch |
| 241 | return l, StatePage{ | 269 | return l, StatePage{ | ||
| 242 | Code: "empty", Title: d.name() + " is empty", | 270 | Code: "empty", Title: d.name() + " is empty", | ||
| 243 | Lines: []string{"no commits yet. push a branch to create one."}, | 271 | Lines: []string{"no commits yet. push a branch to create one."}, | ||
M internal/adapters/web/session.go +15 -2
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 17 | 17 | ||||
| 18 | "bnema.dev/ermite/internal/clientlimit" | 18 | "bnema.dev/ermite/internal/clientlimit" | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | "bnema.dev/ermite/internal/ports" | 20 | "bnema.dev/ermite/internal/ports" | ||
| 20 | ) | 21 | ) | ||
| 21 | 22 | ||||
| @@ -30,7 +31,7 @@ import ( | |||||
| 30 | // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to | 31 | // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to | ||
| 31 | // the origin of the public URL, and a valid session, except the login POST, | 32 | // the origin of the public URL, and a valid session, except the login POST, | ||
| 32 | // which needs the link instead. The routes read the cookie and never the | 33 | // which needs the link instead. The routes read the cookie and never the | ||
| 33 | - | // Authorization header; the API does the opposite. | 34 | + | // Authorization header; the API does the opposite, it reads the header and no cookie. |
| 34 | 35 | ||||
| 35 | // CookieName is the session cookie. The __Host- prefix makes browsers accept | 36 | // CookieName is the session cookie. The __Host- prefix makes browsers accept | ||
| 36 | // it only with Secure, Path=/ and no Domain, and refuse it from a sibling | 37 | // it only with Secure, Path=/ and no Domain, and refuse it from a sibling | ||
| @@ -126,7 +127,19 @@ func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Reque | |||||
| 126 | varyCookie(h) | 127 | varyCookie(h) | ||
| 127 | return r | 128 | return r | ||
| 128 | } | 129 | } | ||
| 129 | - | return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{})) | 130 | + | ctx = domain.ContextWithPrincipal(ctx, owner) |
| 131 | + | return r.WithContext(context.WithValue(ctx, signedInKey{}, struct{}{})) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // principal is the caller of the request, the one every use case call carries: | |||
| 135 | + | // the owner when the request has a valid session (withSession), anonymous | |||
| 136 | + | // otherwise. | |||
| 137 | + | func principal(ctx context.Context) domain.Principal { | |||
| 138 | + | p, ok := domain.PrincipalFromContext(ctx) | |||
| 139 | + | if !ok { | |||
| 140 | + | return domain.Anonymous() | |||
| 141 | + | } | |||
| 142 | + | return p | |||
| 130 | } | 143 | } | ||
| 131 | 144 | ||||
| 132 | // sessionStillValid checks again the cookie a long-lived request started | 145 | // sessionStillValid checks again the cookie a long-lived request started | ||
M internal/adapters/web/session_test.go +21 -21
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | "github.com/stretchr/testify/mock" | 16 | "github.com/stretchr/testify/mock" | ||
| 17 | "github.com/stretchr/testify/require" | 17 | "github.com/stretchr/testify/require" | ||
| 18 | 18 | ||||
| 19 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 19 | + | "bnema.dev/ermite/internal/domain" |
| 20 | "bnema.dev/ermite/internal/ports" | 20 | "bnema.dev/ermite/internal/ports" | ||
| 21 | "bnema.dev/ermite/internal/ports/mocks" | 21 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 22 | ) | 22 | ) | ||
| @@ -34,9 +34,12 @@ func newVerifier(t *testing.T, valid string, verify func(token string) (bool, er | |||||
| 34 | } | 34 | } | ||
| 35 | m := mocks.NewWebSessionService(t) | 35 | m := mocks.NewWebSessionService(t) | ||
| 36 | m.EXPECT().Verify(mock.Anything, mock.Anything).RunAndReturn( | 36 | m.EXPECT().Verify(mock.Anything, mock.Anything).RunAndReturn( | ||
| 37 | - | func(ctx context.Context, token string) (context.Context, bool, error) { | 37 | + | func(_ context.Context, token string) (domain.Principal, bool, error) { |
| 38 | ok, err := verify(token) | 38 | ok, err := verify(token) | ||
| 39 | - | return ctx, ok, err | 39 | + | if !ok || err != nil { |
| 40 | + | return domain.Anonymous(), false, err | |||
| 41 | + | } | |||
| 42 | + | return domain.Owner(), true, nil | |||
| 40 | }).Maybe() | 43 | }).Maybe() | ||
| 41 | return m | 44 | return m | ||
| 42 | } | 45 | } | ||
| @@ -72,12 +75,10 @@ func (l *countingLimiter) Fail(context.Context, string) { | |||||
| 72 | func newSessionServer(t *testing.T, sess ports.WebSessionService, publicURL string) (*Server, *countingLimiter) { | 75 | func newSessionServer(t *testing.T, sess ports.WebSessionService, publicURL string) (*Server, *countingLimiter) { | ||
| 73 | t.Helper() | 76 | t.Helper() | ||
| 74 | lim := &countingLimiter{} | 77 | lim := &countingLimiter{} | ||
| 75 | - | srv, err := New(Config{ | 78 | + | srv := newTestServer(t, Config{ |
| 76 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 79 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), |
| 77 | - | PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1", | 80 | + | PublicURL: publicURL, Sessions: sess, Limiter: lim, |
| 78 | - | Sessions: sess, Limiter: lim, | |||
| 79 | }) | 81 | }) | ||
| 80 | - | require.NoError(t, err) | |||
| 81 | return srv, lim | 82 | return srv, lim | ||
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| @@ -153,12 +154,11 @@ func TestGateLoginExemptionIsExact(t *testing.T) { | |||||
| 153 | t.Parallel() | 154 | t.Parallel() | ||
| 154 | sess := newVerifier(t, "tok", nil) // no Login is expected until the last request | 155 | sess := newVerifier(t, "tok", nil) // no Login is expected until the last request | ||
| 155 | lim := &countingLimiter{} | 156 | lim := &countingLimiter{} | ||
| 156 | - | srv, err := New(Config{ | 157 | + | srv := newTestServer(t, Config{ |
| 157 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 158 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), |
| 158 | - | MergeRequests: ermiteconnect.NewMergeRequestServiceClient(http.DefaultClient, "http://unused.invalid"), | 159 | + | MergeRequests: mocks.NewMergeRequestService(t), |
| 159 | - | PublicURL: "https://forge.example", Sessions: sess, Limiter: lim, | 160 | + | Sessions: sess, Limiter: lim, |
| 160 | }) | 161 | }) | ||
| 161 | - | require.NoError(t, err) | |||
| 162 | post := func(target, cookie string) int { | 162 | post := func(target, cookie string) int { | ||
| 163 | req := httptest.NewRequest(http.MethodPost, target, nil) | 163 | req := httptest.NewRequest(http.MethodPost, target, nil) | ||
| 164 | req.Header.Set("Origin", "https://forge.example") | 164 | req.Header.Set("Origin", "https://forge.example") | ||
| @@ -206,6 +206,7 @@ func TestWithSessionFailsClosed(t *testing.T) { | |||||
| 206 | rec := httptest.NewRecorder() | 206 | rec := httptest.NewRecorder() | ||
| 207 | r := srv.withSession(rec, req) | 207 | r := srv.withSession(rec, req) | ||
| 208 | assert.False(t, SignedIn(r.Context())) | 208 | assert.False(t, SignedIn(r.Context())) | ||
| 209 | + | assert.Equal(t, domain.Anonymous(), principal(r.Context())) | |||
| 209 | assert.Empty(t, rec.Header().Values("Set-Cookie")) | 210 | assert.Empty(t, rec.Header().Values("Set-Cookie")) | ||
| 210 | 211 | ||||
| 211 | srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example") | 212 | srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example") | ||
| @@ -218,7 +219,10 @@ func TestWithSessionFailsClosed(t *testing.T) { | |||||
| 218 | 219 | ||||
| 219 | req = httptest.NewRequest(http.MethodGet, "/", nil) | 220 | req = httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 220 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | 221 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | ||
| 221 | - | assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context())) | 222 | + | r = srv.withSession(httptest.NewRecorder(), req) |
| 223 | + | assert.True(t, SignedIn(r.Context())) | |||
| 224 | + | assert.Equal(t, domain.Owner(), principal(r.Context()), "a valid session is the owner") | |||
| 225 | + | assert.Equal(t, domain.Anonymous(), principal(context.Background()), "no principal is anonymous") | |||
| 222 | } | 226 | } | ||
| 223 | 227 | ||||
| 224 | // GO-006: the answer to a request with an invalid cookie is not cached and | 228 | // GO-006: the answer to a request with an invalid cookie is not cached and | ||
| @@ -284,17 +288,13 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||||
| 284 | // Without sessions configured there is no login route and no cookie is read. | 288 | // Without sessions configured there is no login route and no cookie is read. | ||
| 285 | func TestNoSessionsNoRoutes(t *testing.T) { | 289 | func TestNoSessionsNoRoutes(t *testing.T) { | ||
| 286 | t.Parallel() | 290 | t.Parallel() | ||
| 287 | - | srv, err := New(Config{ | 291 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)}) |
| 288 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 289 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 290 | - | }) | |||
| 291 | - | require.NoError(t, err) | |||
| 292 | rec := httptest.NewRecorder() | 292 | rec := httptest.NewRecorder() | ||
| 293 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil)) | 293 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil)) | ||
| 294 | assert.Equal(t, 404, rec.Code) | 294 | assert.Equal(t, 404, rec.Code) | ||
| 295 | 295 | ||||
| 296 | - | _, err = New(Config{ | 296 | + | _, err := New(Config{ |
| 297 | - | Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | 297 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), |
| 298 | PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t), | 298 | PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t), | ||
| 299 | }) | 299 | }) | ||
| 300 | assert.Error(t, err, "sessions need a limiter") | 300 | assert.Error(t, err, "sessions need a limiter") | ||
M internal/adapters/web/summary.go +53 -52
| @@ -7,15 +7,16 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "html" | 8 | "html" | ||
| 9 | "html/template" | 9 | "html/template" | ||
| 10 | + | "io" | |||
| 10 | "net/http" | 11 | "net/http" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | 14 | ||||
| 14 | - | "connectrpc.com/connect" | |||
| 15 | "github.com/a-h/templ" | 15 | "github.com/a-h/templ" | ||
| 16 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 17 | 17 | ||||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | 18 | + | "bnema.dev/ermite/internal/domain" |
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| 21 | // maxReadmeBytes is the largest README that is rendered in the page; a | 22 | // maxReadmeBytes is the largest README that is rendered in the page; a | ||
| @@ -40,7 +41,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 40 | l, st := s.emptyState(ctx, d) | 41 | l, st := s.emptyState(ctx, d) | ||
| 41 | return State(l, st), http.StatusOK, nil | 42 | return State(l, st), http.StatusOK, nil | ||
| 42 | } | 43 | } | ||
| 43 | - | ref := d.head.GetShortName() | 44 | + | ref := d.head.ShortName |
| 44 | now := s.cfg.Now() | 45 | now := s.cfg.Now() | ||
| 45 | 46 | ||||
| 46 | p := SummaryPage{ | 47 | p := SummaryPage{ | ||
| @@ -48,16 +49,15 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 48 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | 49 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 51 | - | commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | 52 | + | commits, err := s.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "") |
| 52 | - | Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits, | |||
| 53 | - | })) | |||
| 54 | if err != nil { | 53 | if err != nil { | ||
| 55 | return nil, 0, err | 54 | return nil, 0, err | ||
| 56 | } | 55 | } | ||
| 57 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | 56 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | ||
| 58 | - | for i, c := range commits.Msg.GetCommits() { | 57 | + | for i, c := range commits.Commits { |
| 58 | + | id := c.ID.String() | |||
| 59 | row := commitRow(d.name(), c, now) | 59 | row := commitRow(d.name(), c, now) | ||
| 60 | - | row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0 | 60 | + | row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0 |
| 61 | p.Recent = append(p.Recent, row) | 61 | p.Recent = append(p.Recent, row) | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| @@ -74,7 +74,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 74 | l.Title = d.name() + " · " + s.instance | 74 | l.Title = d.name() + " · " + s.instance | ||
| 75 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | 75 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | ||
| 76 | l.HasRail = true | 76 | l.HasRail = true | ||
| 77 | - | l.Lede = d.repo.GetDescription() | 77 | + | l.Lede = d.repo.Description |
| 78 | return Summary(l, p), http.StatusOK, nil | 78 | return Summary(l, p), http.StatusOK, nil | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| @@ -90,7 +90,7 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag { | |||||
| 90 | } | 90 | } | ||
| 91 | lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")} | 91 | lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")} | ||
| 92 | if n <= maxTagDates { | 92 | if n <= maxTagDates { | ||
| 93 | - | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" { | 93 | + | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.repo.Name, d.tags)); name != "" { |
| 94 | lt.Name, lt.Href = name, repoHref(d.name(), "tree", name) | 94 | lt.Name, lt.Href = name, repoHref(d.name(), "tree", name) | ||
| 95 | lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt | 95 | lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt | ||
| 96 | } | 96 | } | ||
| @@ -102,33 +102,33 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag { | |||||
| 102 | func (s *Server) facts(d *repoData, now time.Time) []Fact { | 102 | func (s *Server) facts(d *repoData, now time.Time) []Fact { | ||
| 103 | fs := []Fact{ | 103 | fs := []Fact{ | ||
| 104 | lastPushFact(d.repo, now), | 104 | lastPushFact(d.repo, now), | ||
| 105 | - | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | 105 | + | {Key: "branch", Val: d.head.ShortName + " · " + plural(len(d.branches), "branch", "branches")}, |
| 106 | } | 106 | } | ||
| 107 | - | if c := d.repo.GetCreatedAt(); c.IsValid() { | 107 | + | if c := d.repo.CreatedAt; !c.IsZero() { |
| 108 | - | fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())}) | 108 | + | fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)}) |
| 109 | } | 109 | } | ||
| 110 | return fs | 110 | return fs | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | // lastPushFact is the "last push" fact of a repository; the live updates | 113 | // lastPushFact is the "last push" fact of a repository; the live updates | ||
| 114 | // render it again. | 114 | // render it again. | ||
| 115 | - | func lastPushFact(r *pb.Repo, now time.Time) Fact { | 115 | + | func lastPushFact(r ports.RepoView, now time.Time) Fact { |
| 116 | - | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()} | 116 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()} |
| 117 | - | if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | 117 | + | if t := r.PushedAt; !t.IsZero() { |
| 118 | - | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | 118 | + | last.Val = ago(now, t) + " · " + absolute(t) |
| 119 | - | last.Time = rfc3339(t.AsTime()) | 119 | + | last.Time = rfc3339(t) |
| 120 | } | 120 | } | ||
| 121 | return last | 121 | return last | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | // latestTag is the tag whose commit is the newest. | 124 | // latestTag is the tag whose commit is the newest. | ||
| 125 | - | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | 125 | + | func latestTag(tags []domain.Ref, commits map[string]refCommit) (string, refCommit) { |
| 126 | var name string | 126 | var name string | ||
| 127 | var best refCommit | 127 | var best refCommit | ||
| 128 | for _, t := range tags { | 128 | for _, t := range tags { | ||
| 129 | - | c, ok := commits[t.GetCommitId()] | 129 | + | c, ok := commits[t.CommitID.String()] |
| 130 | if ok && c.short != "" && (name == "" || c.at > best.at) { | 130 | if ok && c.short != "" && (name == "" || c.at > best.at) { | ||
| 131 | - | name, best = t.GetShortName(), c | 131 | + | name, best = t.ShortName, c |
| 132 | } | 132 | } | ||
| 133 | } | 133 | } | ||
| 134 | return name, best | 134 | return name, best | ||
| @@ -142,27 +142,31 @@ type renderedReadme struct { | |||||
| 142 | 142 | ||||
| 143 | // readme finds the README at the root of the tree of the head commit, reads | 143 | // readme finds the README at the root of the tree of the head commit, reads | ||
| 144 | // it and renders it. It is nil when the repository has none. Rendering | 144 | // it and renders it. It is nil when the repository has none. Rendering | ||
| 145 | - | // problems degrade to plain text; only API failures are errors. | 145 | + | // problems degrade to plain text; only use case failures are errors. |
| 146 | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | 146 | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | ||
| 147 | - | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()})) | 147 | + | rev := d.head.CommitID.String() |
| 148 | + | tree, err := s.getTree(ctx, d.repo.Name, rev, "", false) | |||
| 148 | if err != nil { | 149 | if err != nil { | ||
| 149 | return nil, err | 150 | return nil, err | ||
| 150 | } | 151 | } | ||
| 151 | - | return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries()) | 152 | + | return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries) |
| 152 | } | 153 | } | ||
| 153 | 154 | ||||
| 154 | // readmeIn picks the README among the entries of the directory dir ("" for | 155 | // readmeIn picks the README among the entries of the directory dir ("" for | ||
| 155 | // the root) of the commit rev, reads it and renders it. Links in it are | 156 | // the root) of the commit rev, reads it and renders it. Links in it are | ||
| 156 | // relative to dir, on the ref ref. It is nil when the directory has none. | 157 | // relative to dir, on the ref ref. It is nil when the directory has none. | ||
| 157 | - | func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) { | 158 | + | // |
| 158 | - | var best *pb.TreeEntry | 159 | + | //hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only |
| 160 | + | func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) { | |||
| 161 | + | var best *domain.TreeEntry | |||
| 159 | rank := len(readmeNames) | 162 | rank := len(readmeNames) | ||
| 160 | - | for _, e := range entries { | 163 | + | for i := range entries { |
| 161 | - | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | 164 | + | e := &entries[i] |
| 165 | + | if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable { | |||
| 162 | continue | 166 | continue | ||
| 163 | } | 167 | } | ||
| 164 | for i, n := range readmeNames { | 168 | for i, n := range readmeNames { | ||
| 165 | - | if i < rank && strings.EqualFold(e.GetName(), n) { | 169 | + | if i < rank && strings.EqualFold(e.Name, n) { |
| 166 | best, rank = e, i | 170 | best, rank = e, i | ||
| 167 | } | 171 | } | ||
| 168 | } | 172 | } | ||
| @@ -170,54 +174,51 @@ func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entri | |||||
| 170 | if best == nil { | 174 | if best == nil { | ||
| 171 | return nil, nil | 175 | return nil, nil | ||
| 172 | } | 176 | } | ||
| 173 | - | rd := &Readme{Name: best.GetName()} | 177 | + | rd := &Readme{Name: best.Name} |
| 174 | - | if best.GetSize() > maxReadmeBytes { | 178 | + | if best.Size > maxReadmeBytes { |
| 175 | - | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | 179 | + | rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")." |
| 176 | return &renderedReadme{readme: rd}, nil | 180 | return &renderedReadme{readme: rd}, nil | ||
| 177 | } | 181 | } | ||
| 178 | - | body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes) | 182 | + | body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes) |
| 179 | if err != nil { | 183 | if err != nil { | ||
| 180 | return nil, err | 184 | return nil, err | ||
| 181 | } | 185 | } | ||
| 182 | if binary { | 186 | if binary { | ||
| 183 | - | rd.Note = best.GetName() + " is not text." | 187 | + | rd.Note = best.Name + " is not text." |
| 184 | return &renderedReadme{readme: rd}, nil | 188 | return &renderedReadme{readme: rd}, nil | ||
| 185 | } | 189 | } | ||
| 186 | - | name := strings.ToLower(best.GetName()) | 190 | + | name := strings.ToLower(best.Name) |
| 187 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | 191 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | ||
| 188 | - | h, outline, err := s.md.render(body, repo, ref, dir) | 192 | + | h, outline, err := s.md.render(body, repo.String(), ref, dir) |
| 189 | if err == nil { | 193 | if err == nil { | ||
| 190 | rd.HTML = h | 194 | rd.HTML = h | ||
| 191 | return &renderedReadme{readme: rd, outline: outline}, nil | 195 | return &renderedReadme{readme: rd, outline: outline}, nil | ||
| 192 | } | 196 | } | ||
| 193 | log := zerowrap.FromCtx(ctx) | 197 | log := zerowrap.FromCtx(ctx) | ||
| 194 | - | log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text") | 198 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("render README, showing plain text") |
| 195 | } | 199 | } | ||
| 196 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | 200 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | ||
| 197 | return &renderedReadme{readme: rd}, nil | 201 | return &renderedReadme{readme: rd}, nil | ||
| 198 | } | 202 | } | ||
| 199 | 203 | ||||
| 200 | - | // readBlob reads a file through the GetBlob stream, at most limit bytes. It | 204 | + | // readBlob reads a file, at most limit bytes. It reports a binary file |
| 201 | - | // reports a binary file without its content. | 205 | + | // without its content. |
| 202 | - | func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) { | 206 | + | func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) { |
| 203 | - | stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path})) | 207 | + | info, rc, err := s.openBlobAt(ctx, repo, rev, path) |
| 204 | if err != nil { | 208 | if err != nil { | ||
| 205 | return nil, false, err | 209 | return nil, false, err | ||
| 206 | } | 210 | } | ||
| 207 | - | defer func() { _ = stream.Close() }() | 211 | + | defer func() { _ = rc.Close() }() |
| 208 | - | for stream.Receive() { | 212 | + | if info.Binary { |
| 209 | - | m := stream.Msg() | 213 | + | return nil, true, nil |
| 210 | - | if m.GetInfo().GetBinary() { | |||
| 211 | - | return nil, true, nil | |||
| 212 | - | } | |||
| 213 | - | body = append(body, m.GetChunk()...) | |||
| 214 | - | if int64(len(body)) > limit { | |||
| 215 | - | return body[:limit], false, nil | |||
| 216 | - | } | |||
| 217 | } | 214 | } | ||
| 218 | - | if err := stream.Err(); err != nil { | 215 | + | body, err = io.ReadAll(io.LimitReader(rc, limit+1)) |
| 216 | + | if err != nil { | |||
| 219 | return nil, false, err | 217 | return nil, false, err | ||
| 220 | } | 218 | } | ||
| 219 | + | if int64(len(body)) > limit { | |||
| 220 | + | return body[:limit], false, nil | |||
| 221 | + | } | |||
| 221 | return body, false, nil | 222 | return body, false, nil | ||
| 222 | } | 223 | } | ||
| 223 | 224 | ||||
M internal/adapters/web/tree.go +19 -20
| @@ -6,10 +6,9 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | 8 | ||||
| 9 | - | "connectrpc.com/connect" | |||
| 10 | "github.com/a-h/templ" | 9 | "github.com/a-h/templ" | ||
| 11 | 10 | ||||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | 11 | + | "bnema.dev/ermite/internal/domain" |
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| 15 | // maxFileTargets bounds the files of a directory offered in the ":" list. | 14 | // maxFileTargets bounds the files of a directory offered in the ":" list. | ||
| @@ -39,6 +38,8 @@ type TreePage struct { | |||||
| 39 | 38 | ||||
| 40 | // tree lists a directory of a ref, with the last commit of each entry, and | 39 | // tree lists a directory of a ref, with the last commit of each entry, and | ||
| 41 | // renders the README of the directory below. | 40 | // renders the README of the directory below. | ||
| 41 | + | // | |||
| 42 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here | |||
| 42 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 43 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 43 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 44 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 44 | if err != nil { | 45 | if err != nil { | ||
| @@ -48,47 +49,45 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 48 | if !ok { | 49 | if !ok { | ||
| 49 | return nil, 0, errNotFound | 50 | return nil, 0, errNotFound | ||
| 50 | } | 51 | } | ||
| 51 | - | res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{ | 52 | + | res, err := s.getTree(ctx, d.repo.Name, t.rev, t.path, true) |
| 52 | - | Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true, | |||
| 53 | - | })) | |||
| 54 | if err != nil { | 53 | if err != nil { | ||
| 55 | return nil, 0, err | 54 | return nil, 0, err | ||
| 56 | } | 55 | } | ||
| 57 | - | entries := res.Msg.GetEntries() | 56 | + | entries := res.Entries |
| 58 | now := s.cfg.Now() | 57 | now := s.cfg.Now() | ||
| 59 | 58 | ||||
| 60 | p := TreePage{} | 59 | p := TreePage{} | ||
| 61 | l := s.repoLayout(ctx, d, "tree") | 60 | l := s.repoLayout(ctx, d, "tree") | ||
| 62 | for i, e := range entries { | 61 | for i, e := range entries { | ||
| 63 | - | row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} | 62 | + | row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0} |
| 64 | - | switch e.GetKind() { | 63 | + | switch e.Kind { |
| 65 | - | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | 64 | + | case domain.EntryDirectory: |
| 66 | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | 65 | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | ||
| 67 | - | row.Href = repoHref(d.name(), "tree", t.name, e.GetPath()) | 66 | + | row.Href = repoHref(d.name(), "tree", t.name, e.Path) |
| 68 | - | case pb.EntryKind_ENTRY_KIND_SYMLINK: | 67 | + | case domain.EntrySymlink: |
| 69 | row.Glyph = "@" | 68 | row.Glyph = "@" | ||
| 70 | - | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | 69 | + | row.Href = repoHref(d.name(), "blob", t.name, e.Path) |
| 71 | - | case pb.EntryKind_ENTRY_KIND_SUBMODULE: | 70 | + | case domain.EntrySubmodule: |
| 72 | row.Glyph, row.Size = "+", "-" | 71 | row.Glyph, row.Size = "+", "-" | ||
| 73 | default: | 72 | default: | ||
| 74 | - | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | 73 | + | row.Href = repoHref(d.name(), "blob", t.name, e.Path) |
| 75 | if len(l.Cmd) < maxFileTargets { | 74 | if len(l.Cmd) < maxFileTargets { | ||
| 76 | - | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.GetPath()), Href: row.Href}) | 75 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.Path), Href: row.Href}) |
| 77 | } | 76 | } | ||
| 78 | } | 77 | } | ||
| 79 | - | if c := e.GetLastCommit(); c != nil { | 78 | + | if c := e.LastCommit; c != nil { |
| 80 | - | last := commitRow(d.name(), c, now) | 79 | + | last := commitRow(d.name(), *c, now) |
| 81 | row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt | 80 | row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt | ||
| 82 | } | 81 | } | ||
| 83 | p.Rows = append(p.Rows, row) | 82 | p.Rows = append(p.Rows, row) | ||
| 84 | } | 83 | } | ||
| 85 | - | if res.Msg.GetTruncated() { | 84 | + | if res.Truncated { |
| 86 | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | 85 | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | ||
| 87 | } | 86 | } | ||
| 88 | - | if res.Msg.GetLastCommitsTruncated() { | 87 | + | if res.LastCommitsOmitted { |
| 89 | p.Notes = append(p.Notes, "the last commit is shown for the first entries only.") | 88 | p.Notes = append(p.Notes, "the last commit is shown for the first entries only.") | ||
| 90 | } | 89 | } | ||
| 91 | - | rd, err := s.readmeIn(ctx, d.name(), res.Msg.GetCommitId(), t.name, t.path, entries) | 90 | + | rd, err := s.readmeIn(ctx, d.repo.Name, res.CommitID.String(), t.name, t.path, entries) |
| 92 | if err != nil { | 91 | if err != nil { | ||
| 93 | return nil, 0, err | 92 | return nil, 0, err | ||
| 94 | } | 93 | } | ||
M internal/adapters/web/view.go +1 -1
| @@ -5,7 +5,7 @@ package web | |||||
| 5 | import "html/template" | 5 | import "html/template" | ||
| 6 | 6 | ||||
| 7 | // View models handed to the templ components. Components only loop and | 7 | // View models handed to the templ components. Components only loop and | ||
| 8 | - | // branch on these; the handlers build them from the API messages. | 8 | + | // branch on these; the handlers build them from the use case results. |
| 9 | 9 | ||||
| 10 | // Crumb is one segment of the path in the header. | 10 | // Crumb is one segment of the path in the header. | ||
| 11 | type Crumb struct { | 11 | type Crumb struct { | ||
M internal/adapters/web/web.go +37 -32
| @@ -1,15 +1,16 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | // Package web is the HTML front of ermite: the pages of the read-only | 3 | // Package web is the HTML front of ermite: the pages of the read-only | ||
| 4 | - | // showcase, rendered with templ from the data of the ermite API. | 4 | + | // showcase, rendered with templ from the data of the use cases. |
| 5 | // | 5 | // | ||
| 6 | - | // The package is a client of the API like any other. It talks to the | 6 | + | // Like every other inbound adapter, the package reads and writes through the |
| 7 | - | // generated ermite-api Connect clients, which internal/app binds in process to the | 7 | + | // use case interfaces of package ports (ADR 0024), never through the API. The |
| 8 | - | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | 8 | + | // caller of every use case call is passed explicitly: the principal of the |
| 9 | - | // cases or the domain. Who the visitor is comes with the request context, set | 9 | + | // request, which is anonymous unless the request carries a valid owner session |
| 10 | - | // by the middleware internal/app puts in front of the handler: anonymous in P5. The | 10 | + | // (ServeHTTP sets it in the context, principal reads it). The use cases then |
| 11 | - | // API then shows public repositories only; a private repository is | 11 | + | // show public repositories only to an anonymous caller; a private repository |
| 12 | - | // indistinguishable from a missing one, here as everywhere. | 12 | + | // is indistinguishable from a missing one (domain.ErrNotFound), here as |
| 13 | + | // everywhere. | |||
| 13 | // | 14 | // | ||
| 14 | // Adding a page: a handler on Server, a route in routes, a templ page in | 15 | // Adding a page: a handler on Server, a route in routes, a templ page in | ||
| 15 | // pages.templ and its view model in view.go. | 16 | // pages.templ and its view model in view.go. | ||
| @@ -28,11 +29,10 @@ import ( | |||||
| 28 | "strings" | 29 | "strings" | ||
| 29 | "time" | 30 | "time" | ||
| 30 | 31 | ||||
| 31 | - | "connectrpc.com/connect" | |||
| 32 | "github.com/a-h/templ" | 32 | "github.com/a-h/templ" | ||
| 33 | "github.com/bnema/zerowrap" | 33 | "github.com/bnema/zerowrap" | ||
| 34 | 34 | ||||
| 35 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 35 | + | "bnema.dev/ermite/internal/domain" |
| 36 | "bnema.dev/ermite/internal/ports" | 36 | "bnema.dev/ermite/internal/ports" | ||
| 37 | ) | 37 | ) | ||
| 38 | 38 | ||||
| @@ -42,20 +42,22 @@ import ( | |||||
| 42 | // over this one. | 42 | // over this one. | ||
| 43 | var Patterns = []string{"/"} | 43 | var Patterns = []string{"/"} | ||
| 44 | 44 | ||||
| 45 | - | // requestTimeout bounds the API calls that build one page. | 45 | + | // requestTimeout bounds the use case calls that build one page. |
| 46 | const requestTimeout = 30 * time.Second | 46 | const requestTimeout = 30 * time.Second | ||
| 47 | 47 | ||||
| 48 | // Config configures a Server. | 48 | // Config configures a Server. | ||
| 49 | type Config struct { | 49 | type Config struct { | ||
| 50 | - | // Repos and Git are the API clients the pages are built from. | 50 | + | // Repos and Browse are the use cases the pages are built from. |
| 51 | - | Repos ermiteconnect.RepoServiceClient | 51 | + | Repos ports.RepoService |
| 52 | - | Git ermiteconnect.GitServiceClient | 52 | + | Browse ports.BrowseService |
| 53 | - | // MergeRequests is the client of the merge request pages (ADR 0017). | 53 | + | // MergeRequests serves the merge request pages (ADR 0017). Without it |
| 54 | - | // Without it there are no such pages, nor section for them. | 54 | + | // there are no such pages, nor section for them. Leave it unset rather |
| 55 | - | MergeRequests ermiteconnect.MergeRequestServiceClient | 55 | + | // than assigning a typed nil pointer. |
| 56 | - | // Events is the client of the event stream behind /-/events. Without | 56 | + | MergeRequests ports.MergeRequestService |
| 57 | - | // it the pages are not live and the route answers 404. | 57 | + | // Events is the source of the event stream behind /-/events. Without |
| 58 | - | Events ermiteconnect.EventServiceClient | 58 | + | // it the pages are not live and the route answers 404. Leave it unset |
| 59 | + | // rather than assigning a typed nil pointer. | |||
| 60 | + | Events ports.EventService | |||
| 59 | // Heartbeat is how often an idle event stream gets a comment line; | 61 | // Heartbeat is how often an idle event stream gets a comment line; | ||
| 60 | // DefaultHeartbeat when zero. | 62 | // DefaultHeartbeat when zero. | ||
| 61 | Heartbeat time.Duration | 63 | Heartbeat time.Duration | ||
| @@ -94,8 +96,8 @@ type Server struct { | |||||
| 94 | 96 | ||||
| 95 | // New builds the web handler. | 97 | // New builds the web handler. | ||
| 96 | func New(cfg Config) (*Server, error) { | 98 | func New(cfg Config) (*Server, error) { | ||
| 97 | - | if cfg.Repos == nil || cfg.Git == nil { | 99 | + | if cfg.Repos == nil || cfg.Browse == nil { |
| 98 | - | return nil, errors.New("web: the repository and Git API clients are required") | 100 | + | return nil, errors.New("web: the repository and browse use cases are required") |
| 99 | } | 101 | } | ||
| 100 | u, err := url.Parse(cfg.PublicURL) | 102 | u, err := url.Parse(cfg.PublicURL) | ||
| 101 | if err != nil || u.Host == "" { | 103 | if err != nil || u.Host == "" { | ||
| @@ -161,6 +163,9 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 161 | 163 | ||||
| 162 | // ServeHTTP implements http.Handler. | 164 | // ServeHTTP implements http.Handler. | ||
| 163 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 165 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 166 | + | // Whatever the context carried, the caller is anonymous until a valid | |||
| 167 | + | // session says otherwise (withSession). | |||
| 168 | + | r = r.WithContext(domain.ContextWithPrincipal(r.Context(), domain.Anonymous())) | |||
| 164 | r = s.withSession(w, r) | 169 | r = s.withSession(w, r) | ||
| 165 | if !s.gate(w, r) { | 170 | if !s.gate(w, r) { | ||
| 166 | return | 171 | return | ||
| @@ -172,7 +177,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 172 | // error, which becomes the 404 or 500 page. | 177 | // error, which becomes the 404 or 500 page. | ||
| 173 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | 178 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | ||
| 174 | 179 | ||||
| 175 | - | // page adapts a handler: it bounds the API calls, renders the component into | 180 | + | // page adapts a handler: it bounds the use case calls, renders the component into |
| 176 | // a buffer (so that a failure never leaves half a page), and maps errors to | 181 | // a buffer (so that a failure never leaves half a page), and maps errors to | ||
| 177 | // the state pages. | 182 | // the state pages. | ||
| 178 | func (s *Server) page(h handler) http.HandlerFunc { | 183 | func (s *Server) page(h handler) http.HandlerFunc { | ||
| @@ -241,15 +246,15 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque | |||||
| 241 | } | 246 | } | ||
| 242 | } | 247 | } | ||
| 243 | 248 | ||||
| 244 | - | // fail answers an error of the API: not found (and a name or revision the | 249 | + | // fail answers an error of a use case: not found (and a name or revision the |
| 245 | - | // API refuses, which no page can show either) gives the 404 page, a visitor | 250 | + | // use case refuses, which no page can show either) gives the 404 page, a |
| 246 | - | // that left gives nothing, anything else gives the 500 page with an id to | 251 | + | // visitor that left gives nothing, anything else gives the 500 page with an id |
| 247 | - | // find the cause in the log. | 252 | + | // to find the cause in the log. |
| 248 | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | 253 | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | ||
| 249 | - | switch code := connect.CodeOf(err); { | 254 | + | switch { |
| 250 | - | case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | 255 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): |
| 251 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | 256 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | ||
| 252 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | 257 | + | case errors.Is(err, context.Canceled): |
| 253 | // The client is gone; there is nobody to answer. | 258 | // The client is gone; there is nobody to answer. | ||
| 254 | default: | 259 | default: | ||
| 255 | id := requestID() | 260 | id := requestID() | ||
| @@ -260,8 +265,8 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques | |||||
| 260 | } | 265 | } | ||
| 261 | 266 | ||||
| 262 | // errNotFound is returned by handlers for a resource that does not exist | 267 | // errNotFound is returned by handlers for a resource that does not exist | ||
| 263 | - | // although the API did not say so (a ref or a path that is not there). | 268 | + | // although the use case did not say so (a ref or a path that is not there). |
| 264 | - | var errNotFound = errors.New("not found") | 269 | + | var errNotFound = domain.ErrNotFound |
| 265 | 270 | ||||
| 266 | // requestID is a short random id, shown on the 500 page and logged. | 271 | // requestID is a short random id, shown on the 500 page and logged. | ||
| 267 | func requestID() string { | 272 | func requestID() string { | ||
M internal/app/api_test.go +1 -70
| @@ -765,77 +765,8 @@ func TestDeleteRepoMovesToTrash(t *testing.T) { | |||||
| 765 | assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process") | 765 | assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process") | ||
| 766 | } | 766 | } | ||
| 767 | 767 | ||||
| 768 | - | // TestInProcessClient: the handlers answer calls made in the process with a | |||
| 769 | - | // principal in the context, with no HTTP authentication, and refuse to be | |||
| 770 | - | // fooled by an Authorization header. | |||
| 771 | - | func TestInProcessClient(t *testing.T) { | |||
| 772 | - | h := newHarness(t, harnessOpts{}) | |||
| 773 | - | h.seed("pub", domain.VisibilityPublic, "README.md") | |||
| 774 | - | h.seed("priv", domain.VisibilityPrivate, "README.md") | |||
| 775 | - | c := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | |||
| 776 | - | list := func(ctx context.Context) []string { | |||
| 777 | - | resp, err := c.repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{})) | |||
| 778 | - | require.NoError(t, err) | |||
| 779 | - | var out []string | |||
| 780 | - | for _, r := range resp.Msg.GetRepos() { | |||
| 781 | - | out = append(out, r.GetName()) | |||
| 782 | - | } | |||
| 783 | - | return out | |||
| 784 | - | } | |||
| 785 | - | ||||
| 786 | - | assert.Equal(t, []string{"pub"}, list(h.ctx), "no principal in the context is anonymous") | |||
| 787 | - | assert.Equal(t, []string{"pub"}, list(domain.ContextWithPrincipal(h.ctx, domain.Anonymous()))) | |||
| 788 | - | owner := domain.ContextWithPrincipal(h.ctx, domain.Root()) | |||
| 789 | - | assert.Equal(t, []string{"priv", "pub"}, list(owner)) | |||
| 790 | - | ||||
| 791 | - | // Mutations and streams work the same way. | |||
| 792 | - | _, err := c.repo.CreateRepo(owner, connect.NewRequest(&pb.CreateRepoRequest{Name: "web-made"})) | |||
| 793 | - | require.NoError(t, err) | |||
| 794 | - | _, err = c.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "denied"})) | |||
| 795 | - | assert.Equal(t, "unauthenticated", codeName(err)) | |||
| 796 | - | msgs, err := readBlob(owner, c, &pb.GetBlobRequest{Repo: "priv", Path: "README.md"}) | |||
| 797 | - | require.NoError(t, err) | |||
| 798 | - | assert.Equal(t, "content of README.md\n", string(msgs[0].GetChunk())) | |||
| 799 | - | _, err = readBlob(h.ctx, c, &pb.GetBlobRequest{Repo: "priv", Path: "README.md"}) | |||
| 800 | - | assert.Equal(t, "not_found", codeName(err)) | |||
| 801 | - | ||||
| 802 | - | wctx, cancel := context.WithCancel(owner) | |||
| 803 | - | defer cancel() | |||
| 804 | - | st, err := c.event.Watch(wctx, connect.NewRequest(&pb.WatchRequest{Topics: []string{"repo:web-made"}})) | |||
| 805 | - | require.NoError(t, err) | |||
| 806 | - | got := make(chan *pb.Event, 1) | |||
| 807 | - | go func() { | |||
| 808 | - | if st.Receive() { | |||
| 809 | - | got <- st.Msg().GetEvent() | |||
| 810 | - | } | |||
| 811 | - | }() | |||
| 812 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 813 | - | _, err = c.repo.UpdateRepo(owner, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 814 | - | Repo: "web-made", Description: "d", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |||
| 815 | - | })) | |||
| 816 | - | require.NoError(t, err) | |||
| 817 | - | select { | |||
| 818 | - | case ev := <-got: | |||
| 819 | - | assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription()) | |||
| 820 | - | case <-time.After(10 * time.Second): | |||
| 821 | - | t.Fatal("no event through the in-process client") | |||
| 822 | - | } | |||
| 823 | - | ||||
| 824 | - | // An Authorization header does not give a principal in process. | |||
| 825 | - | hc := h.forge.API.InProcessClient() | |||
| 826 | - | req, _ := http.NewRequestWithContext(h.ctx, http.MethodPost, rpc.InProcessURL+"/ermite.RepoService/ListRepos", strings.NewReader("{}")) | |||
| 827 | - | req.Header.Set("Content-Type", "application/json") | |||
| 828 | - | req.Header.Set("Authorization", "Bearer "+adminToken) | |||
| 829 | - | resp, err := hc.Do(req) | |||
| 830 | - | require.NoError(t, err) | |||
| 831 | - | body, _ := io.ReadAll(resp.Body) | |||
| 832 | - | _ = resp.Body.Close() | |||
| 833 | - | assert.NotContains(t, string(body), `"priv"`) | |||
| 834 | - | } | |||
| 835 | - | ||||
| 836 | // A principal in the base context of the HTTP server does not authenticate | 768 | // A principal in the base context of the HTTP server does not authenticate | ||
| 837 | - | // requests: only the in-process transport can mark a context as trusted, and | 769 | + | // requests: requests from the network are judged by their headers. |
| 838 | - | // requests from the network are judged by their headers. | |||
| 839 | func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) { | 770 | func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) { | ||
| 840 | h := newHarness(t, harnessOpts{}) | 771 | h := newHarness(t, harnessOpts{}) | ||
| 841 | h.seed("pub", domain.VisibilityPublic, "README.md") | 772 | h.seed("pub", domain.VisibilityPublic, "README.md") | ||
M internal/app/events_test.go +7 -6
| @@ -18,7 +18,6 @@ import ( | |||||
| 18 | "google.golang.org/protobuf/types/known/fieldmaskpb" | 18 | "google.golang.org/protobuf/types/known/fieldmaskpb" | ||
| 19 | 19 | ||||
| 20 | pb "bnema.dev/ermite-api/gen/ermite" | 20 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 21 | - | "bnema.dev/ermite/internal/adapters/rpc" | |||
| 22 | "bnema.dev/ermite/internal/domain" | 21 | "bnema.dev/ermite/internal/domain" | ||
| 23 | "bnema.dev/ermite/internal/ports" | 22 | "bnema.dev/ermite/internal/ports" | ||
| 24 | "bnema.dev/ermite/internal/usecase" | 23 | "bnema.dev/ermite/internal/usecase" | ||
| @@ -382,13 +381,15 @@ func TestWatchLimits(t *testing.T) { | |||||
| 382 | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == usecase.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | 381 | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == usecase.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | ||
| 383 | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | 382 | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | ||
| 384 | // A stream of another client address is not affected (the harness trusts | 383 | // A stream of another client address is not affected (the harness trusts | ||
| 385 | - | // the loopback proxy only when configured: use the in-process caller). | 384 | + | // the loopback proxy only when configured: ask the use case directly). |
| 386 | - | inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | 385 | + | ctx, cancel := context.WithCancel(h.ctx) |
| 387 | - | ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root())) | |||
| 388 | defer cancel() | 386 | defer cancel() | ||
| 389 | - | st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | 387 | + | registered := false |
| 388 | + | err := h.forge.EventLog.Watch(ctx, domain.Root(), ports.WatchParams{ | |||
| 389 | + | Client: "another-client", Ready: func() { registered = true; cancel() }, | |||
| 390 | + | }, func(ports.WatchEvent) error { return nil }) | |||
| 390 | require.NoError(t, err) | 391 | require.NoError(t, err) | ||
| 391 | - | _ = st.Close() | 392 | + | assert.True(t, registered, "a watch of another client address is registered") |
| 392 | } | 393 | } | ||
| 393 | 394 | ||||
| 394 | // sentinel proves that nothing reached the stream since the last event it | 395 | // sentinel proves that nothing reached the stream since the last event it | ||
M internal/app/forge.go +1 -1
| @@ -241,7 +241,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 241 | } | 241 | } | ||
| 242 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | 242 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | ||
| 243 | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | 243 | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | ||
| 244 | - | site, err := newWeb(cfg, f.API, f.Sessions, limiter) | 244 | + | site, err := newWeb(cfg, f, limiter) |
| 245 | if err != nil { | 245 | if err != nil { | ||
| 246 | return nil, fmt.Errorf("web: %w", err) | 246 | return nil, fmt.Errorf("web: %w", err) | ||
| 247 | } | 247 | } | ||
M internal/app/web.go +17 -36
| @@ -13,9 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 15 | 15 | ||||
| 16 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 17 | "bnema.dev/ermite/internal/adapters/httpserver" | 16 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 18 | - | "bnema.dev/ermite/internal/adapters/rpc" | |||
| 19 | "bnema.dev/ermite/internal/adapters/web" | 17 | "bnema.dev/ermite/internal/adapters/web" | ||
| 20 | "bnema.dev/ermite/internal/clientlimit" | 18 | "bnema.dev/ermite/internal/clientlimit" | ||
| 21 | "bnema.dev/ermite/internal/config" | 19 | "bnema.dev/ermite/internal/config" | ||
| @@ -24,30 +22,26 @@ import ( | |||||
| 24 | "bnema.dev/ermite/internal/usecase" | 22 | "bnema.dev/ermite/internal/usecase" | ||
| 25 | ) | 23 | ) | ||
| 26 | 24 | ||||
| 27 | - | // newWeb builds the web pages on the API of this process: the generated | 25 | + | // newWeb builds the web pages on the use cases of this process, through the |
| 28 | - | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | 26 | + | // ports (ADR 0020), like the API and Git over HTTP. |
| 29 | // | 27 | // | ||
| 30 | // The owner's session cookie turns a request into the owner's (web.Server | 28 | // The owner's session cookie turns a request into the owner's (web.Server | ||
| 31 | - | // checks it with sessions); every other request is anonymous. | 29 | + | // checks it with sessions); every other request is anonymous. The web passes |
| 32 | - | func newWeb(cfg config.Config, api *rpc.API, sessions *usecase.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) { | 30 | + | // the principal of the request to each use case call itself. |
| 33 | - | hc := api.InProcessClient() | 31 | + | func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) { |
| 34 | - | srv, err := web.New(web.Config{ | 32 | + | return web.New(web.Config{ |
| 35 | - | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | 33 | + | Repos: f.Repos, |
| 36 | - | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | 34 | + | Browse: f.Browse, |
| 37 | - | MergeRequests: ermiteconnect.NewMergeRequestServiceClient(hc, rpc.InProcessURL), | 35 | + | MergeRequests: f.MergeRequests, |
| 38 | - | Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL), | 36 | + | Events: f.EventLog, |
| 39 | PublicURL: cfg.PublicURL, | 37 | PublicURL: cfg.PublicURL, | ||
| 40 | SourceURL: cfg.SourceURL, | 38 | SourceURL: cfg.SourceURL, | ||
| 41 | Version: serverVersion(), | 39 | Version: serverVersion(), | ||
| 42 | 40 | ||||
| 43 | - | Sessions: webSessions{sessions}, | 41 | + | Sessions: webSessions{f.Sessions}, |
| 44 | Limiter: limiter, | 42 | Limiter: limiter, | ||
| 45 | TrustedProxies: cfg.TrustedProxies, | 43 | TrustedProxies: cfg.TrustedProxies, | ||
| 46 | }) | 44 | }) | ||
| 47 | - | if err != nil { | |||
| 48 | - | return nil, err | |||
| 49 | - | } | |||
| 50 | - | return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil | |||
| 51 | } | 45 | } | ||
| 52 | 46 | ||||
| 53 | // sessionsNeedHTTPS reports whether browser sign-in cannot work on this public | 47 | // sessionsNeedHTTPS reports whether browser sign-in cannot work on this public | ||
| @@ -98,8 +92,8 @@ func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClie | |||||
| 98 | } | 92 | } | ||
| 99 | 93 | ||||
| 100 | // webSessions adapts the session use cases to ports.WebSessionService: a valid | 94 | // webSessions adapts the session use cases to ports.WebSessionService: a valid | ||
| 101 | - | // token gives the context of the owner, whose principal the API calls of the | 95 | + | // token gives the owner, the principal the web passes to the use cases for |
| 102 | - | // page carry (rpc.API.InProcessClient). | 96 | + | // that request. |
| 103 | type webSessions struct{ *usecase.Sessions } | 97 | type webSessions struct{ *usecase.Sessions } | ||
| 104 | 98 | ||||
| 105 | var _ ports.WebSessionService = webSessions{} | 99 | var _ ports.WebSessionService = webSessions{} | ||
| @@ -126,28 +120,15 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura | |||||
| 126 | return token, lifetime, nil | 120 | return token, lifetime, nil | ||
| 127 | } | 121 | } | ||
| 128 | 122 | ||||
| 129 | - | func (s webSessions) Verify(ctx context.Context, token string) (context.Context, bool, error) { | 123 | + | func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal, bool, error) { |
| 130 | p, err := s.Sessions.Verify(ctx, token) | 124 | p, err := s.Sessions.Verify(ctx, token) | ||
| 131 | if errors.Is(err, domain.ErrUnauthenticated) { | 125 | if errors.Is(err, domain.ErrUnauthenticated) { | ||
| 132 | - | return ctx, false, nil | 126 | + | return domain.Anonymous(), false, nil |
| 133 | } | 127 | } | ||
| 134 | if err != nil { | 128 | if err != nil { | ||
| 135 | - | return ctx, false, err | 129 | + | return domain.Anonymous(), false, err |
| 136 | } | 130 | } | ||
| 137 | - | return domain.ContextWithPrincipal(ctx, p), true, nil | 131 | + | return p, true, nil |
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | // asPrincipal runs next with p as the caller of every request, attributed to | |||
| 141 | - | // the browser's address: the API calls the web makes carry both | |||
| 142 | - | // (rpc.API.InProcessClient), so the per-client limits of the API count each | |||
| 143 | - | // visitor. Browsers are anonymous; web.Server replaces the principal with the | |||
| 144 | - | // owner's when the request carries a valid session. | |||
| 145 | - | func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler { | |||
| 146 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 147 | - | ctx := domain.ContextWithPrincipal(r.Context(), p) | |||
| 148 | - | ctx = rpc.ContextWithClient(ctx, clientlimit.ClientAddr(r, trusted)) | |||
| 149 | - | next.ServeHTTP(w, r.WithContext(ctx)) | |||
| 150 | - | }) | |||
| 151 | } | 132 | } | ||
| 152 | 133 | ||||
| 153 | // webRoutes mounts the web on the patterns it serves. | 134 | // webRoutes mounts the web on the patterns it serves. | ||
M internal/app/web_session_test.go +15 -7
| @@ -506,14 +506,22 @@ func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) { | |||||
| 506 | assert.NotContains(t, logs, string(key)) | 506 | assert.NotContains(t, logs, string(key)) | ||
| 507 | } | 507 | } | ||
| 508 | 508 | ||||
| 509 | - | // The owner principal is what the API sees from a signed-in page, and is | 509 | + | // The owner principal is what the use cases see from a signed-in page, and is |
| 510 | - | // labelled "owner". | 510 | + | // labelled "owner": the use case logs it with a write the page made. |
| 511 | func TestWebOwnerIsLabelledInLogs(t *testing.T) { | 511 | func TestWebOwnerIsLabelledInLogs(t *testing.T) { | ||
| 512 | - | c := newCLIHarness(t) | 512 | + | r := newMRRepo(t, domain.VisibilityPublic) |
| 513 | - | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | 513 | + | token := r.signIn(t) |
| 514 | - | token := c.signIn(t) | 514 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) |
| 515 | - | browser{c.harness}.get("/secret", as(token)) | 515 | + | b := browser{r.harness} |
| 516 | - | assert.Contains(t, c.logs.String(), `"principal":"owner"`) | 516 | + | res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"from the page"}}) |
| 517 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |||
| 518 | + | var line string | |||
| 519 | + | for _, l := range strings.Split(r.logs.String(), "\n") { | |||
| 520 | + | if strings.Contains(l, `"comment added"`) { | |||
| 521 | + | line = l | |||
| 522 | + | } | |||
| 523 | + | } | |||
| 524 | + | assert.Contains(t, line, `"principal":"owner"`) | |||
| 517 | } | 525 | } | ||
| 518 | 526 | ||||
| 519 | // GO-004: a Secure cookie is kept by browsers only from https (or a loopback | 527 | // GO-004: a Secure cookie is kept by browsers only from https (or a loopback | ||
M internal/boundary/boundary_test.go +67 -7
| @@ -20,6 +20,10 @@ import ( | |||||
| 20 | 20 | ||||
| 21 | const modulePath = "bnema.dev/ermite" | 21 | const modulePath = "bnema.dev/ermite" | ||
| 22 | 22 | ||||
| 23 | + | // apiGenPath is the generated code of the API contract (protobuf and Connect), | |||
| 24 | + | // a module of its own. | |||
| 25 | + | const apiGenPath = "bnema.dev/ermite-api/gen" | |||
| 26 | + | ||||
| 23 | // moduleRoot is the module root relative to this package's directory. | 27 | // moduleRoot is the module root relative to this package's directory. | ||
| 24 | const moduleRoot = "../.." | 28 | const moduleRoot = "../.." | ||
| 25 | 29 | ||||
| @@ -48,7 +52,8 @@ const ( | |||||
| 48 | // - internal/usecase holds the use cases: domain and ports only. | 52 | // - internal/usecase holds the use cases: domain and ports only. | ||
| 49 | // - adapters implement ports and never import use cases or app; the | 53 | // - adapters implement ports and never import use cases or app; the | ||
| 50 | // per-package rule in packageImportDenied also keeps them from importing | 54 | // per-package rule in packageImportDenied also keeps them from importing | ||
| 51 | - | // each other. | 55 | + | // each other, and keeps the web adapter off the generated API code: like |
| 56 | + | // every other adapter it reads and writes through the ports (ADR 0020). | |||
| 52 | // - internal/clientlimit is infrastructure shared by the HTTP adapters. | 57 | // - internal/clientlimit is infrastructure shared by the HTTP adapters. | ||
| 53 | // - internal/config parses the environment into plain values. | 58 | // - internal/config parses the environment into plain values. | ||
| 54 | var productionDependencies = map[packageLayer]map[packageLayer]bool{ | 59 | var productionDependencies = map[packageLayer]map[packageLayer]bool{ | ||
| @@ -78,6 +83,33 @@ func TestImportBoundaries(t *testing.T) { | |||||
| 78 | } | 83 | } | ||
| 79 | } | 84 | } | ||
| 80 | 85 | ||||
| 86 | + | // The generated API code is outside the module: the walk would skip it. The | |||
| 87 | + | // web adapter may not import it in production code, whichever package of it. | |||
| 88 | + | func TestWebAdapterRejectsAPIGeneratedCode(t *testing.T) { | |||
| 89 | + | tests := []struct { | |||
| 90 | + | name, source, target string | |||
| 91 | + | testFile bool | |||
| 92 | + | want bool | |||
| 93 | + | }{ | |||
| 94 | + | {"web messages", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", false, false}, | |||
| 95 | + | {"web connect", modulePath + "/internal/adapters/web", apiGenPath + "/ermite/ermiteconnect", false, false}, | |||
| 96 | + | {"web subpackage", modulePath + "/internal/adapters/web/sub", apiGenPath + "/ermite", false, false}, | |||
| 97 | + | {"web test file", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", true, true}, | |||
| 98 | + | {"rpc adapter", modulePath + "/internal/adapters/rpc", apiGenPath + "/ermite", false, true}, | |||
| 99 | + | {"client adapter", modulePath + "/internal/adapters/client", apiGenPath + "/ermite/ermiteconnect", false, true}, | |||
| 100 | + | {"other module of the API", modulePath + "/internal/adapters/web", "bnema.dev/ermite-api/other", false, true}, | |||
| 101 | + | {"web other external package", modulePath + "/internal/adapters/web", "github.com/a-h/templ", false, true}, | |||
| 102 | + | {"a package whose name only starts alike", modulePath + "/internal/adapters/web", apiGenPath + "x/y", false, true}, | |||
| 103 | + | } | |||
| 104 | + | for _, tt := range tests { | |||
| 105 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 106 | + | if got := !externalImportDenied(tt.source, tt.target, tt.testFile); got != tt.want { | |||
| 107 | + | t.Fatalf("externalImportDenied(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, !got, !tt.want) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 81 | func TestImportBoundaryFixtures(t *testing.T) { | 113 | func TestImportBoundaryFixtures(t *testing.T) { | ||
| 82 | p := func(s string) string { return modulePath + "/" + s } | 114 | p := func(s string) string { return modulePath + "/" + s } | ||
| 83 | tests := []struct { | 115 | tests := []struct { | ||
| @@ -106,8 +138,12 @@ func TestImportBoundaryFixtures(t *testing.T) { | |||||
| 106 | {"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true}, | 138 | {"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true}, | ||
| 107 | {"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true}, | 139 | {"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true}, | ||
| 108 | {"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true}, | 140 | {"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true}, | ||
| 109 | - | {"adapter production rejects generated mocks", p("internal/adapters/web"), p("internal/adapters/web/mocks"), false, false}, | 141 | + | {"adapter accepts generated", p("internal/adapters/rpc"), p("gen/ermite"), false, true}, |
| 110 | - | {"adapter test may import generated mocks", p("internal/adapters/web"), p("internal/adapters/web/mocks"), true, true}, | 142 | + | {"web rejects generated", p("internal/adapters/web"), p("gen/ermite"), false, false}, |
| 143 | + | {"web rejects generated connect", p("internal/adapters/web"), p("gen/ermite/ermiteconnect"), false, false}, | |||
| 144 | + | {"web subpackage rejects generated", p("internal/adapters/web/sub"), p("gen/ermite"), false, false}, | |||
| 145 | + | {"web test may import ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), true, true}, | |||
| 146 | + | {"web production rejects ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), false, false}, | |||
| 111 | {"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false}, | 147 | {"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false}, | ||
| 112 | {"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false}, | 148 | {"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false}, | ||
| 113 | {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false}, | 149 | {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false}, | ||
| @@ -176,7 +212,14 @@ func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) | |||||
| 176 | testFile := strings.HasSuffix(path, "_test.go") | 212 | testFile := strings.HasSuffix(path, "_test.go") | ||
| 177 | for _, spec := range file.Imports { | 213 | for _, spec := range file.Imports { | ||
| 178 | target, err := strconv.Unquote(spec.Path.Value) | 214 | target, err := strconv.Unquote(spec.Path.Value) | ||
| 179 | - | if err != nil || (target != modulePath && !strings.HasPrefix(target, modulePath+"/")) { | 215 | + | if err != nil { |
| 216 | + | continue | |||
| 217 | + | } | |||
| 218 | + | if externalImportDenied(source, target, testFile) { | |||
| 219 | + | violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target)) | |||
| 220 | + | continue | |||
| 221 | + | } | |||
| 222 | + | if target != modulePath && !strings.HasPrefix(target, modulePath+"/") { | |||
| 180 | continue | 223 | continue | ||
| 181 | } | 224 | } | ||
| 182 | allowed, err := dependencyAllowed(source, target, testFile) | 225 | allowed, err := dependencyAllowed(source, target, testFile) | ||
| @@ -218,14 +261,31 @@ func dependencyAllowed(source, target string, testFile bool) (bool, error) { | |||||
| 218 | return productionDependencies[sourceLayer][targetLayer], nil | 261 | return productionDependencies[sourceLayer][targetLayer], nil | ||
| 219 | } | 262 | } | ||
| 220 | 263 | ||||
| 264 | + | // externalImportDenied reports whether a file of package source may not import | |||
| 265 | + | // target, a package outside the module. The only such rule: the production | |||
| 266 | + | // code of the web adapter does not import the generated API code | |||
| 267 | + | // (apiGenPath), so that it goes through the ports. Test files are exempt, like | |||
| 268 | + | // for every rule here. | |||
| 269 | + | func externalImportDenied(source, target string, testFile bool) bool { | |||
| 270 | + | if testFile || !under(target, apiGenPath) { | |||
| 271 | + | return false | |||
| 272 | + | } | |||
| 273 | + | return under(source, modulePath+"/internal/adapters/web") | |||
| 274 | + | } | |||
| 275 | + | ||||
| 221 | // packageImportDenied narrows the layer rules to packages: an adapter may | 276 | // packageImportDenied narrows the layer rules to packages: an adapter may | ||
| 222 | - | // import only packages under its own directory (its mocks, for instance), | 277 | + | // import only packages under its own directory, never a sibling adapter; and |
| 223 | - | // never a sibling adapter. | 278 | + | // the web adapter may not import the generated API code at all (protobuf, |
| 279 | + | // Connect): it goes through the ports, as the other adapters that serve the | |||
| 280 | + | // use cases do. | |||
| 224 | func packageImportDenied(source, target string) bool { | 281 | func packageImportDenied(source, target string) bool { | ||
| 225 | const adapters = modulePath + "/internal/adapters/" | 282 | const adapters = modulePath + "/internal/adapters/" | ||
| 226 | if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) { | 283 | if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) { | ||
| 227 | return adapterRoot(source) != adapterRoot(target) | 284 | return adapterRoot(source) != adapterRoot(target) | ||
| 228 | } | 285 | } | ||
| 286 | + | if strings.HasPrefix(source, adapters) && adapterRoot(source) == "web" { | |||
| 287 | + | return under(target, modulePath+"/gen") | |||
| 288 | + | } | |||
| 229 | return false | 289 | return false | ||
| 230 | } | 290 | } | ||
| 231 | 291 | ||||
| @@ -253,7 +313,7 @@ func classifyPackage(path string) (packageLayer, error) { | |||||
| 253 | return layerApp, nil | 313 | return layerApp, nil | ||
| 254 | case under(path, p("internal/usecase")): | 314 | case under(path, p("internal/usecase")): | ||
| 255 | return layerUsecase, nil | 315 | return layerUsecase, nil | ||
| 256 | - | case path == p("internal/ports/mocks"), under(path, p("internal/adapters/web/mocks")), under(path, p("internal/readertest")): | 316 | + | case path == p("internal/ports/mocks"), under(path, p("internal/readertest")): |
| 257 | return layerTestSupport, nil | 317 | return layerTestSupport, nil | ||
| 258 | case under(path, p("internal/adapters")): | 318 | case under(path, p("internal/adapters")): | ||
| 259 | return layerAdapter, nil | 319 | return layerAdapter, nil | ||
M internal/domain/errors.go +22 -0
| @@ -37,3 +37,25 @@ var ( | |||||
| 37 | // the number of event watchers; the caller may retry later. | 37 | // the number of event watchers; the caller may retry later. | ||
| 38 | ErrResourceExhausted = errors.New("resource exhausted") | 38 | ErrResourceExhausted = errors.New("resource exhausted") | ||
| 39 | ) | 39 | ) | ||
| 40 | + | ||||
| 41 | + | // invalidErrors are the errors that mean a request was malformed. | |||
| 42 | + | var invalidErrors = []error{ | |||
| 43 | + | ErrInvalid, ErrInvalidName, ErrInvalidVisibility, | |||
| 44 | + | ErrInvalidScope, ErrInvalidToken, | |||
| 45 | + | ErrInvalidObjectID, ErrInvalidRevision, ErrInvalidPath, | |||
| 46 | + | ErrInvalidPageToken, ErrInvalidPageSize, ErrInvalidRefKind, | |||
| 47 | + | ErrNotDirectory, ErrNotFile, ErrInvalidText, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // IsInvalid reports whether err means that the request was malformed: an | |||
| 51 | + | // invalid name, revision, path, page token, text, and the like. The adapters | |||
| 52 | + | // answer all of them alike (invalid_argument on the API, the 404 page on the | |||
| 53 | + | // web). | |||
| 54 | + | func IsInvalid(err error) bool { | |||
| 55 | + | for _, invalid := range invalidErrors { | |||
| 56 | + | if errors.Is(err, invalid) { | |||
| 57 | + | return true | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return false | |||
| 61 | + | } | |||
A internal/domain/errors_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestIsInvalid(t *testing.T) { | |||
| 14 | + | t.Parallel() | |||
| 15 | + | var named *InvalidNameError | |||
| 16 | + | _, parseErr := ParseRepoName("Bad Name") | |||
| 17 | + | assert.ErrorAs(t, parseErr, &named) | |||
| 18 | + | ||||
| 19 | + | tests := []struct { | |||
| 20 | + | name string | |||
| 21 | + | err error | |||
| 22 | + | want bool | |||
| 23 | + | }{ | |||
| 24 | + | {"nil", nil, false}, | |||
| 25 | + | {"invalid", ErrInvalid, true}, | |||
| 26 | + | {"wrapped invalid", fmt.Errorf("x: %w", ErrInvalidRevision), true}, | |||
| 27 | + | {"invalid name from the parser", parseErr, true}, | |||
| 28 | + | {"invalid fields", &InvalidFieldsError{Fields: []FieldViolation{{Field: "title", Reason: "is empty"}}}, true}, | |||
| 29 | + | {"page token", ErrInvalidPageToken, true}, | |||
| 30 | + | {"not a file", ErrNotFile, true}, | |||
| 31 | + | {"invalid text", ErrInvalidText, true}, | |||
| 32 | + | {"not found is not invalid", ErrNotFound, false}, | |||
| 33 | + | {"forbidden is not invalid", ErrForbidden, false}, | |||
| 34 | + | {"other", errors.New("boom"), false}, | |||
| 35 | + | } | |||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | t.Parallel() | |||
| 39 | + | assert.Equal(t, tt.want, IsInvalid(tt.err)) | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
M internal/ports/mocks/WebSessionService.go +8 -9
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "context" | 8 | "context" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | mock "github.com/stretchr/testify/mock" | 12 | mock "github.com/stretchr/testify/mock" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| @@ -171,25 +172,23 @@ func (_c *WebSessionService_LoginLinkValid_Call) RunAndReturn(run func(nonce str | |||||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
| 173 | // Verify provides a mock function for the type WebSessionService | 174 | // Verify provides a mock function for the type WebSessionService | ||
| 174 | - | func (_mock *WebSessionService) Verify(ctx context.Context, token string) (context.Context, bool, error) { | 175 | + | func (_mock *WebSessionService) Verify(ctx context.Context, token string) (domain.Principal, bool, error) { |
| 175 | ret := _mock.Called(ctx, token) | 176 | ret := _mock.Called(ctx, token) | ||
| 176 | 177 | ||||
| 177 | if len(ret) == 0 { | 178 | if len(ret) == 0 { | ||
| 178 | panic("no return value specified for Verify") | 179 | panic("no return value specified for Verify") | ||
| 179 | } | 180 | } | ||
| 180 | 181 | ||||
| 181 | - | var r0 context.Context | 182 | + | var r0 domain.Principal |
| 182 | var r1 bool | 183 | var r1 bool | ||
| 183 | var r2 error | 184 | var r2 error | ||
| 184 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (context.Context, bool, error)); ok { | 185 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, bool, error)); ok { |
| 185 | return returnFunc(ctx, token) | 186 | return returnFunc(ctx, token) | ||
| 186 | } | 187 | } | ||
| 187 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) context.Context); ok { | 188 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok { |
| 188 | r0 = returnFunc(ctx, token) | 189 | r0 = returnFunc(ctx, token) | ||
| 189 | } else { | 190 | } else { | ||
| 190 | - | if ret.Get(0) != nil { | 191 | + | r0 = ret.Get(0).(domain.Principal) |
| 191 | - | r0 = ret.Get(0).(context.Context) | |||
| 192 | - | } | |||
| 193 | } | 192 | } | ||
| 194 | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | 193 | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | ||
| 195 | r1 = returnFunc(ctx, token) | 194 | r1 = returnFunc(ctx, token) | ||
| @@ -234,12 +233,12 @@ func (_c *WebSessionService_Verify_Call) Run(run func(ctx context.Context, token | |||||
| 234 | return _c | 233 | return _c | ||
| 235 | } | 234 | } | ||
| 236 | 235 | ||||
| 237 | - | func (_c *WebSessionService_Verify_Call) Return(owner context.Context, ok bool, err error) *WebSessionService_Verify_Call { | 236 | + | func (_c *WebSessionService_Verify_Call) Return(owner domain.Principal, ok bool, err error) *WebSessionService_Verify_Call { |
| 238 | _c.Call.Return(owner, ok, err) | 237 | _c.Call.Return(owner, ok, err) | ||
| 239 | return _c | 238 | return _c | ||
| 240 | } | 239 | } | ||
| 241 | 240 | ||||
| 242 | - | func (_c *WebSessionService_Verify_Call) RunAndReturn(run func(ctx context.Context, token string) (context.Context, bool, error)) *WebSessionService_Verify_Call { | 241 | + | func (_c *WebSessionService_Verify_Call) RunAndReturn(run func(ctx context.Context, token string) (domain.Principal, bool, error)) *WebSessionService_Verify_Call { |
| 243 | _c.Call.Return(run) | 242 | _c.Call.Return(run) | ||
| 244 | return _c | 243 | return _c | ||
| 245 | } | 244 | } | ||
M internal/ports/services.go +5 -5
| @@ -109,11 +109,11 @@ type WebSessionService interface { | |||||
| 109 | // Login uses the link and returns the session token and how long it | 109 | // Login uses the link and returns the session token and how long it | ||
| 110 | // lasts, or ErrLinkRejected. | 110 | // lasts, or ErrLinkRejected. | ||
| 111 | Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error) | 111 | Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error) | ||
| 112 | - | // Verify checks a session token. When it is valid it returns the context | 112 | + | // Verify checks a session token. When it is valid it returns the owner, |
| 113 | - | // to serve the request with, which carries the owner as the caller of the | 113 | + | // the principal the web passes to the use cases for that request; when it |
| 114 | - | // API; when it is not, ok is false and err nil. An error means the check | 114 | + | // is not, ok is false and err nil. An error means the check could not be |
| 115 | - | // could not be made (the request then runs as anonymous). | 115 | + | // made (the request then runs as anonymous). |
| 116 | - | Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error) | 116 | + | Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error) |
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | // PushService opens a repository for a push: it is domain.ErrUnauthenticated | 119 | // PushService opens a repository for a push: it is domain.ErrUnauthenticated | ||