detached 582b620

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 generated1+# Mocks of the ports, generated in internal/ports/mocks. Regenerate with:
2-# Connect clients the web adapter calls. Regenerate with:
3 # go generate ./internal/ports2 # go generate ./internal/ports
4 template: testify3 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: *mocks13 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 route188 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 here379 //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) with3 // 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 cases4 // 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 error5 // 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 in6+// mapping.
7-// process (see InProcessClient).
8 package rpc7 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 p96 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, true57 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, false62 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 a65 // 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 on31 // 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 its34+// 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. What35+// 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 context36+// 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.AuthService38 auth ports.AuthService
42 limiter ports.FailureLimiter39 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 proven49 // 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 an87+// 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 c90 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 string46 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.BlobInfo52+ 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 []byte54 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, err63 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, errNotFound112 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, err116 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, img145 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 = last185 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 cannot196 // 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 nil201 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 &row204 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 the17 // 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 the91 // 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, err102 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, err106 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 = &cm124 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 = invisible143 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.instance148 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 p170 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 []string185 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 := 0189 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, with45 // 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 the46+// 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, errNotFound55 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, err60 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, errNotFound65 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 == 0104 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 nil134 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 continue138 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_TAG32+ 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 []string60 heads, tags []string
54 ref, short string61 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 branch16 // 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, which27 // 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 merge28 // 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, errNotFound39 return nil, 0, errNotFound
38 }40 }
39- tgt := d.head41+ 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.Commit53+ var ahead []domain.Commit
52 exact, behind := false, false54 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, err61 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, err86 return nil, 0, err
85 }87 }
86 p.HasDiff = true88 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 = n92 p.Note = n
91 }93 }
92 lines, invisible := diffLines(p.Files)94 lines, invisible := diffLines(p.Files)
93 p.Invisible = invisible95 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.MergeRequest105+ 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, err109 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.instance129 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 = false131 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 source142 // 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 source143 // 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, nil149 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, err156 return nil, false, false, err
155 }157 }
156- for _, c := range res.Msg.GetCommits() {158+ for _, c := range res.Commits {
157- onTarget[c.GetId()] = true159+ onTarget[c.ID.String()] = true
158 }160 }
159- if tokenT = res.Msg.GetNextPageToken(); tokenT == "" {161+ if tokenT = res.NextPageToken; tokenT == "" {
160 break162 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, err169 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 the174 // 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, nil176+ 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, nil182 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 }), nil185+ 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, err196 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, nil200 return m, nil
201 }201 }
202 }202 }
203- if token = res.Msg.GetNextPageToken(); token == "" {203+ if token = res.NextPageToken; token == "" {
204 break204 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 an19 // 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 []DiffHunk101 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 trailing111 // 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 s222 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 dl237 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 := 0242 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 read252 // 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 no283 // 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 []string286 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 control16 // 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_DELETION57+ ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion
58 var ls []DiffLine58 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.Builder147 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 &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+150 assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</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.Buffer30 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, any37+// 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 opens23 // 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 follows24 // 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 into25+// 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=home28 // GET /-/events?topic=home
@@ -63,10 +65,6 @@ import (
63 const DefaultHeartbeat = 25 * time.Second65 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 timeout68 // 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.Second70 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- return137+ 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 *recvd154 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 stopping159 return // the server is stopping
158 }160 }
161+ if m.err != nil {
162+ s.denyEvents(ctx, w, m.err)
163+ return
164+ }
159 first = &m165 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 return167 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 false191 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 sends196 // 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, and197 // 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 = gap212 frags = gap
210 }213 }
211- return out.send(m.ev.GetId(), frags) == nil214+ return out.send(m.ev.ID, frags) == nil
212 }215 }
213 if first != nil && !handle(*first) {216 if first != nil && !handle(*first) {
214 return217 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.Event249+ ev ports.WatchEvent
247 err error250 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 a270+// 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 private271 // 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.UnimplementedEventServiceHandler65+type watchProbe struct {
66 err error66 err error
67- events []*pb.Event67+ events []ports.WatchEvent
68 opened atomic.Int3268 opened atomic.Int32
69 closed atomic.Int3269 closed atomic.Int32
70- resume atomic.Value70+ 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 ones75+ 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.err95 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 err102 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 nil110 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 err113 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.UnimplementedRepoServiceHandler121+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 = writeTimeout136 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 goes264 // 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 test341 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 []string360 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 := false390 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 load139 // 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 commits140+// 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"}}), nil149+ 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 page153+ 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- }}), nil158+ 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"}}}), nil163+ 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 owner174 // 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 string182+ name string
182- ctxs []context.Context183+ ctxs []context.Context
183- builds int // loads of the repository and refs, and ListCommits calls: one per class184+ 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 on55 // 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, nil59 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 read80 // 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 was81+// 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) removes82+// 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 string85 var name string
61 fresh := false86 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(), true95+ name, fresh = ev.Repo.String(), true
71 default:96 default:
72 return nil, nil97 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, err104 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 = fresh107 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}, err109 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 page121 // 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, nil123 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 visible128 return gap, nil // no longer visible
104 case err != nil:129 case err != nil:
105 return nil, err130 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 branch132 return gap, nil // the description, the default branch
108 }133 }
109 return nil, nil134 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, nil148 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, err165 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}, err168 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 the286 // 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, nil292 return nil, nil
266 }293 }
267 tip, touched := "", false294 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(), true297+ 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, err317 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 may320 // 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 being321 // 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 branch322 // 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, false324 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 = true327 found = true
296 break328 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, nil380 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, nil386 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_TAG144+ 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 string151 in, name, path string
152 detached, ok bool152 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 = 100018 const maxRepos = 1000
20 19
21-// pageSize is the page size asked from the commit lists: the largest the API20+// 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 = 10022 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 HEAD25 // 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 to26 // 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 lower27+// 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.Repo30+ 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, err35 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 break45 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 row135 return row
131 }136 }
132 137
133 // repoCmds are the repositories as targets of the ":" command line, but the138 // 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 []CmdItem141 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 out147 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 MergeRequestService20 // The merge request pages (ADR 0017). They read through MergeRequestService
21-// and GitService as the visitor of the request: anonymous sees the merge21+// and BrowseService as the visitor of the request: anonymous sees the merge
22 // requests of public repositories, the signed-in owner those of every22 // 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 = 5048 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 = 2050 maxMRPages = 20
51 // maxOpenPages bounds the pages read for the open merge requests, which51 // 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, nil260 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 row298 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 closed301 // 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 of302 // 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, err307 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, err321 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 break327 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 := token335 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, err341 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 break350 break
351 }351 }
@@ -363,10 +363,10 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component,
363 p.Done[0].Cursor = true363 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 continue367 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, err406 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, err410 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_OPEN415+ 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.Author434 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 continue474 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 = invisible520 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.instance532 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 = true547 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 = true549 l.HasRail = true
556 return MergeRequest(l, p), http.StatusOK, nil550 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 true557 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, false571 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 = true576+ 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 = true579+ 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) lists629 // 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 numbers630 // 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 string640 path string
647- side pb.DiffSide641+ side domain.DiffSide
648 line int642 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}] = ln654+ 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.ID656 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}] = ln660+ 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.ID662 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 nil670 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 *DiffLine691 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_NEW719+ side := domain.SideNew
725 if on.side == "old" {720 if on.side == "old" {
726- side = pb.DiffSide_DIFF_SIDE_OLD721+ 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 gate18 // 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 the19+// (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, with20 // 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 a21+// "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 owner22+// 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 without23+// 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 is26 // 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 and46+// 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.Error67+ 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, err142 return "", back, err
135 }143 }
136- return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil144+ 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, or155 // 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_NEW173+ params.Side = domain.SideNew
158 case "old":174 case "old":
159- req.Side = pb.DiffSide_DIFF_SIDE_OLD175+ 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, err182 return "", back, err
167 }183 }
168- return back + "#c" + res.Msg.GetComment().GetId(), back, nil184+ 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 API193+//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, err207 return "", back, err
190 }208 }
191- return back + "#c" + res.Msg.GetComment().GetId(), back, nil209+ // 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, err258 return "", back, err
204 }259 }
205 return back, back, nil260 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.MergeMode274+ 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_FORWARD277+ mode = domain.MergeModeFastForward
223 case "squash":278 case "squash":
224- mode = pb.MergeMode_MERGE_MODE_SQUASH279+ 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, err294 return "", back, err
233 }295 }
234 return back, back, nil296 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, err317 return "", back, err
250 }318 }
251 return back, back, nil319 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 line333 // 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.Minute21 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 can26 // 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 an31 // 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 what32 // 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 turn73 // 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). The74 // 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 of75+ // 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 the76+ // 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 more77 // 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 return89 return
81 }90 }
82 b.buf = nil91 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 return104 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, so108+ // 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{}, "", err120+ 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{}, "", errNotFound124+ return target{}, domain.RepoName{}, errNotFound
106 }125 }
107- return t, d.name(), nil126+ 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, false133 return img, false
115 }134 }
116- if !b.info.GetBinary() {135+ if !b.info.Binary {
117 return "text/plain; charset=utf-8", false136 return "text/plain; charset=utf-8", false
118 }137 }
119 return "application/octet-stream", true138 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.UnimplementedGitServiceHandler25+)
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 is48 // 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 of14 // 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 ref15 // 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:], "/"), true24 return r, strings.TrimPrefix(p[end:], "/"), true
23 }25 }
24 }26 }
25- return nil, "", false27+ return domain.Ref{}, "", false
26 }28 }
27 29
28 // refsByName indexes the branches and tags by short name. A branch wins over30 // 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 continue37 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()] = r41+ names[r.ShortName] = r
40 }42 }
41 }43 }
42 return names44 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: a70 // 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, or73+ // 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 string75 rev string
74 // name is how the URL spells the ref: a branch or a tag, or the commit id76 // 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 tag85 // 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. An86 // (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 or87 // 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{}, false93 return target{}, false
92 }94 }
93- return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true95+ 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}, true98+ 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 false125 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 true129 return true
128 }130 }
129 }131 }
M internal/adapters/web/refs_test.go +26 -12
@@ -3,28 +3,42 @@
3 package web3 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_TAG34+ 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 string40 path, ref, rest string
27- kind pb.RefKind41+ kind domain.RefKind
28 ok bool42 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 return61 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 each15 // 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 the33 // 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() == def42+ a, b := branches[i].ShortName == def, branches[j].ShortName == def
43 return a && !b43 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()].at50+ 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 p61 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 row75 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 its17 // 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 and21 // 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.Repo24+ repo ports.RepoView
27- branches []*pb.Ref25+ branches []domain.Ref
28- tags []*pb.Ref26+ 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 bool28 cut bool
31 // head is the branch the repository opens on: its default branch, or the29 // 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.Ref31+ 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 invalidly52 // 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, which53+// 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, err62 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 are65 // 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, and66+ // 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 := "", 068 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, err75 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 break87 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 break91 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 = b96+ 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, nil102 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 ref113 // 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, def122 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 whose174 // 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 = 8177 const workers = 8
158 var (178 var (
159 mu sync.Mutex179 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 continue187 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 refCommit205 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.WhenAt208 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] = rc212 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 "" when220 // 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, the237 // 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. The238 // 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 row247 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.instance265 l.Title = d.name() + " · " + s.instance
238 l.Sections = nil266 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 to31 // 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 the33 // 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 accept36 // 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 sibling37 // 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 r128 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 started145 // 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, err39+ if !ok || err != nil {
40+ return domain.Anonymous(), false, err
41+ }
42+ return domain.Owner(), true, nil
40 }).Maybe()43 }).Maybe()
41 return m44 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, lim82 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 request155 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 and228 // 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; a22 // 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, nil42 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, err54 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 == 060+ 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.instance74 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 = true76 l.HasRail = true
77- l.Lede = d.repo.GetDescription()77+ l.Lede = d.repo.Description
78 return Summary(l, p), http.StatusOK, nil78 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.whenAt95 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 fs110 return fs
111 }111 }
112 112
113 // lastPushFact is the "last push" fact of a repository; the live updates113 // 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 last121 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 string126 var name string
127 var best refCommit127 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(), c131+ name, best = t.ShortName, c
132 }132 }
133 }133 }
134 return name, best134 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, reads143 // 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. Rendering144 // 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, err150 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 ("" for155 // 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 are156 // 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.TreeEntry159+//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 continue166 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, i170 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, nil175 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}, nil180 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, err184 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}, nil188 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 = h194 rd.HTML = h
191 return &renderedReadme{readme: rd, outline: outline}, nil195 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 -- escaped200 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
197 return &renderedReadme{readme: rd}, nil201 return &renderedReadme{readme: rd}, nil
198 }202 }
199 203
200-// readBlob reads a file through the GetBlob stream, at most limit bytes. It204+// 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, err209 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, err217 return nil, false, err
220 }218 }
219+ if int64(len(body)) > limit {
220+ return body[:limit], false, nil
221+ }
221 return body, false, nil222 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, and39 // 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, errNotFound50 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, err54 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.WhenAt80 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, err92 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 and7 // 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-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3 // Package web is the HTML front of ermite: the pages of the read-only3 // 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 the6+// 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 the7+// 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 use8+// 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, set9+// 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. The10+// (ServeHTTP sets it in the context, principal reads it). The use cases then
11-// API then shows public repositories only; a private repository is11+// 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 in15 // 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.Second46 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.RepoServiceClient51+ Repos ports.RepoService
52- Git ermiteconnect.GitServiceClient52+ 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.MergeRequestServiceClient55+ // than assigning a typed nil pointer.
56- // Events is the client of the event stream behind /-/events. Without56+ 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.EventServiceClient58+ // 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.Duration63 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 return171 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 into180+// 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 to181 // 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 the249+// 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 visitor250+// 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 to251+// 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 exist267 // 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 authenticate768 // 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, and769+// 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 trusts383 // 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 it395 // 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 generated25+// 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.Server28 // 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 public47 // 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 valid94 // 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 the95+// 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, nil120 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, nil126+ return domain.Anonymous(), false, nil
133 }127 }
134 if err != nil {128 if err != nil {
135- return ctx, false, err129+ return domain.Anonymous(), false, err
136 }130 }
137- return domain.ContextWithPrincipal(ctx, p), true, nil131+ 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 is509+// 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 loopback527 // 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; the53 // - adapters implement ports and never import use cases or app; the
50 // per-package rule in packageImportDenied also keeps them from importing54 // 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 continue223 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], nil261 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 may276 // 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 false289 return false
230 }290 }
231 291
@@ -253,7 +313,7 @@ func classifyPackage(path string) (packageLayer, error) {
253 return layerApp, nil313 return layerApp, nil
254 case under(path, p("internal/usecase")):314 case under(path, p("internal/usecase")):
255 return layerUsecase, nil315 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, nil317 return layerTestSupport, nil
258 case under(path, p("internal/adapters")):318 case under(path, p("internal/adapters")):
259 return layerAdapter, nil319 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 WebSessionService174 // 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.Context182+ var r0 domain.Principal
182 var r1 bool183 var r1 bool
183 var r2 error184 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 _c233 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 _c238 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 _c243 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 it109 // 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 context112+ // 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 the113+ // 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 check114+ // 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.ErrUnauthenticated119 // PushService opens a repository for a push: it is domain.ErrUnauthenticated

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close