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:
6565 mocking:
6666 generatedMockPaths:
6767 - internal/ports/mocks/**
68- - internal/adapters/web/mocks/**
6968 generatedMockNamePatterns:
7069 - '{{Interface}}'
7170 allow:
M .mockery.yml +1 -12
@@ -1,5 +1,4 @@
1-# Mocks of the ports, generated in internal/ports/mocks, and of the generated
2-# Connect clients the web adapter calls. Regenerate with:
1+# Mocks of the ports, generated in internal/ports/mocks. Regenerate with:
32 # go generate ./internal/ports
43 template: testify
54 _anchors:
@@ -12,13 +11,3 @@ _anchors:
1211 packages:
1312 bnema.dev/ermite/internal/ports:
1413 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 ./...
104104
105105 - `internal/usecase` imports only `internal/domain` and `internal/ports`.
106106 - 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.
107108 - `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module.
108109 - `internal/config` (environment parsing) is used by `internal/app` only.
109110
M internal/adapters/githttp/handler.go +2 -0
@@ -182,6 +182,7 @@ func parseRoute(r *http.Request) (route, int) {
182182 }
183183
184184 // ServeHTTP implements http.Handler.
185+//
185186 //hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases'
186187 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
187188 var rt route
@@ -374,6 +375,7 @@ func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route,
374375 }
375376
376377 // serveBackend runs git http-backend for an authorized request.
378+//
377379 //hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here
378380 func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) {
379381 ctx := r.Context()
M internal/adapters/githttp/hooks.go +1 -0
@@ -175,6 +175,7 @@ func (s *HookServer) Close(ctx context.Context) {
175175 }
176176
177177 // handle serves one connection: one request line, one reply line.
178+//
178179 //hexcheck:ignore suspicious-business-logic-in-adapter hook protocol dispatch: the decisions are the use cases'
179180 func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
180181 log := zerowrap.FromCtx(ctx)
M internal/adapters/rpc/api.go +3 -4
@@ -3,8 +3,7 @@
33 // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
44 // ConnectRPC: handlers that map the messages of the contract to the use cases
55 // of usecase and back, the interceptor that authenticates callers, and the error
6-// mapping. The same handlers serve remote clients over HTTP and the web in
7-// process (see InProcessClient).
6+// mapping.
87 package rpc
98
109 import (
@@ -90,8 +89,8 @@ func New(cfg Config) *API {
9089 // it under /-/api/ and strips the prefix.
9190 func (a *API) Handler() http.Handler { return a.handler }
9291
93-// caller returns the principal the interceptor (or the in-process caller)
94-// put in ctx; anonymous when there is none.
92+// caller returns the principal the interceptor put in ctx; anonymous when
93+// there is none.
9594 func caller(ctx context.Context) domain.Principal {
9695 p, _ := domain.PrincipalFromContext(ctx)
9796 return p
M internal/adapters/rpc/errors.go +2 -13
@@ -56,23 +56,12 @@ func codeOf(err error) (connect.Code, bool) {
5656 case errors.Is(err, domain.ErrResourceExhausted):
5757 return connect.CodeResourceExhausted, true
5858 }
59- for _, invalid := range invalidErrors {
60- if errors.Is(err, invalid) {
61- return connect.CodeInvalidArgument, true
62- }
59+ if domain.IsInvalid(err) {
60+ return connect.CodeInvalidArgument, true
6361 }
6462 return 0, false
6563 }
6664
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-
7665 // addFieldDetail attaches the fields a request was rejected for as a
7766 // BadRequest detail, when err carries them.
7867 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")
3131 // limiter; a client that used up its failures gets resource_exhausted (429 on
3232 // the Connect protocol) whatever it presents, like on the Git routes.
3333 //
34-// A call made through InProcessClient is trusted with the principal its
35-// context carries: that is how the web calls the handlers in process. What
36-// marks it is a value only the in-process transport can put in the context
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.
34+// A principal in the context of a network request, say one put there by a
35+// server's base context, is ignored and the request is authenticated from its
36+// headers.
4037 type interceptor struct {
4138 auth ports.AuthService
4239 limiter ports.FailureLimiter
@@ -49,11 +46,6 @@ func newInterceptor(cfg Config) *interceptor {
4946
5047 // authenticate returns the context to run the call in.
5148 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- }
5749 // Whatever the context carried, the caller is anonymous until proven
5850 // otherwise.
5951 ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
@@ -91,18 +83,8 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
9183
9284 type clientKey struct{}
9385
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-
10386 // clientFromContext returns the normalized address of the client of a call:
104-// its peer for a network call, the one given to ContextWithClient for an
105-// in-process call, empty when there is none.
87+// its peer, empty when there is none.
10688 func clientFromContext(ctx context.Context) string {
10789 c, _ := ctx.Value(clientKey{}).(string)
10890 return c
M internal/adapters/web/blob.go +33 -38
@@ -4,17 +4,18 @@ package web
44
55 import (
66 "context"
7+ "errors"
78 "fmt"
89 "html/template"
10+ "io"
911 "net/http"
1012 "path"
1113 "strings"
1214
13- "connectrpc.com/connect"
1415 "github.com/a-h/templ"
1516 "github.com/bnema/zerowrap"
1617
17- pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
1819 )
1920
2021 const (
@@ -45,47 +46,43 @@ type BlobPage struct {
4546 DirHref string
4647 }
4748
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.
4950 type blobStream struct {
50- stream *connect.ServerStreamForClient[pb.GetBlobResponse]
51- info *pb.BlobInfo
52- // buf holds the content already received and not yet handed out.
51+ body io.ReadCloser
52+ info domain.BlobInfo
53+ // buf holds the content already read and not yet handed out.
5354 buf []byte
55+ // eof is true once body has nothing more.
56+ eof bool
5457 }
5558
56-// openBlob starts the stream of a file and reads its metadata.
57-func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) {
58- if 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}))
59+// openBlob opens a file of the repository.
60+func (s *Server) openBlob(ctx context.Context, repo domain.RepoName, t target) (*blobStream, error) {
61+ info, body, err := s.openBlobAt(ctx, repo, t.rev, t.path)
6262 if err != nil {
6363 return nil, err
6464 }
65- if !st.Receive() {
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
65+ return &blobStream{body: body, info: info}, nil
7566 }
7667
77-func (b *blobStream) close() { _ = b.stream.Close() }
68+func (b *blobStream) close() { _ = b.body.Close() }
7869
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.
8071 func (b *blobStream) fill(n int) error {
72+ if b.eof || len(b.buf) >= n {
73+ return nil
74+ }
8175 // One allocation for the whole content, not the doublings of append.
8276 if cap(b.buf) < n {
8377 b.buf = append(make([]byte, 0, n), b.buf...)
8478 }
85- for len(b.buf) < n && b.stream.Receive() {
86- b.buf = append(b.buf, b.stream.Msg().GetChunk()...)
79+ m, err := io.ReadFull(b.body, b.buf[len(b.buf):n])
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
8784 }
88- return b.stream.Err()
85+ return err
8986 }
9087
9188 // 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
114111 if !ok {
115112 return nil, 0, errNotFound
116113 }
117- b, err := s.openBlob(ctx, d.name(), t)
114+ b, err := s.openBlob(ctx, d.repo.Name, t)
118115 if err != nil {
119116 return nil, 0, err
120117 }
@@ -125,7 +122,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
125122 if dir == "." {
126123 dir = ""
127124 }
128- size := b.info.GetSize()
125+ size := b.info.Size
129126 p := BlobPage{
130127 Name: visibleText(name),
131128 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
133130 DirHref: repoHref(d.name(), "tree", t.name, dir),
134131 Facts: []Fact{{Key: "size", Val: humanSize(size)}},
135132 }
136- typ := b.info.GetMediaType()
133+ typ := b.info.MediaType
137134 lang := ""
138135
139136 // 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
146143 switch {
147144 case img != "":
148145 p.Image, typ = true, img
149- case b.info.GetBinary():
146+ case b.info.Binary:
150147 p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size))
151148 case size > maxCodeBytes:
152149 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
184181 }
185182 p.Facts = append(p.Facts, Fact{Key: "type", Val: lang})
186183
187- if last := s.lastChange(ctx, d.name(), t); last != nil {
184+ if last := s.lastChange(ctx, d.repo.Name, t); last != nil {
188185 p.Last = last
189186 }
190187
@@ -198,14 +195,12 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
198195
199196 // lastChange is the latest commit that touched the path, nil when it cannot
200197 // be found (the page does not depend on it).
201-func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow {
202- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
203- Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1,
204- }))
205- if err != nil || len(res.Msg.GetCommits()) != 1 {
198+func (s *Server) lastChange(ctx context.Context, repo domain.RepoName, t target) *CommitRow {
199+ res, err := s.commitList(ctx, repo, t.rev, t.path, 1, "")
200+ if err != nil || len(res.Commits) != 1 {
206201 return nil
207202 }
208- row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now())
203+ row := commitRow(repo.String(), res.Commits[0], s.cfg.Now())
209204 return &row
210205 }
211206
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 (
99 "strings"
1010 "time"
1111
12- "connectrpc.com/connect"
1312 "github.com/a-h/templ"
1413
15- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
1615 )
1716
1817 // 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) {
9190
9291 // commit shows a commit: its message, its totals, the changed files and the
9392 // 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
9495 func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
9596 sha := r.PathValue("sha")
9697 if !isCommitID(sha) {
@@ -100,57 +101,55 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
100101 if err != nil {
101102 return nil, 0, err
102103 }
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)
104105 if err != nil {
105106 return nil, 0, err
106107 }
107- c := res.Msg.GetCommit()
108- if c == nil {
109- return nil, 0, errNotFound
110- }
111- stats := res.Msg.GetStats()
108+ c := res.Commit
109+ cid := c.ID.String()
112110 now := s.cfg.Now()
113111
114112 p := CommitPage{
115- SHA: c.GetId(), Short: short(c.GetId()),
116- Subject: visibleText(c.GetSummary()), Body: commitBody(c),
117- Author: person(c.GetAuthor(), now),
118- Merge: len(c.GetParentIds()) > 1,
119- Root: len(c.GetParentIds()) == 0,
120- NFiles: int(stats.GetFilesChanged()),
121- Adds: int(stats.GetAdditions()),
122- Dels: int(stats.GetDeletions()),
123- Files: diffFiles(res.Msg.GetDiff().GetFiles()),
124- }
125- if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() {
113+ SHA: cid, Short: short(cid),
114+ Subject: visibleText(c.Summary), Body: commitBody(c),
115+ Author: person(c.Author, now),
116+ Merge: len(c.Parents) > 1,
117+ Root: len(c.Parents) == 0,
118+ NFiles: res.Stats.FilesChanged,
119+ Adds: res.Stats.Additions,
120+ Dels: res.Stats.Deletions,
121+ Files: diffFiles(res.Diff.Files),
122+ }
123+ if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
126124 p.Committer = &cm
127125 }
128- for _, id := range c.GetParentIds() {
126+ for _, pid := range c.Parents {
127+ id := pid.String()
129128 p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
130129 }
131130 for _, b := range d.branches {
132- if b.GetCommitId() == c.GetId() {
133- p.Refs = append(p.Refs, b.GetShortName())
131+ if b.CommitID.String() == cid {
132+ p.Refs = append(p.Refs, b.ShortName)
134133 }
135134 }
136135 for _, t := range d.tags {
137- if t.GetCommitId() == c.GetId() {
138- p.Refs = append(p.Refs, "tag: "+t.GetShortName())
136+ if t.CommitID.String() == cid {
137+ p.Refs = append(p.Refs, "tag: "+t.ShortName)
139138 }
140139 }
141- p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
140+ p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
142141
143142 lines, invisible := diffLines(p.Files)
144143 p.Invisible = invisible
145- base := repoHref(d.name(), "commit", c.GetId())
144+ base := repoHref(d.name(), "commit", cid)
146145 p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split")
147146
148147 l := s.repoLayout(ctx, d, "commits")
149148 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})
151150 history := repoHref(d.name())
152151 if d.head != nil {
153- history = repoHref(d.name(), "commits", d.head.GetShortName())
152+ history = repoHref(d.name(), "commits", d.head.ShortName)
154153 }
155154 l.Crumbs = []Crumb{
156155 {Label: d.name(), Href: repoHref(d.name())},
@@ -162,25 +161,25 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
162161 }
163162
164163 // person converts a signature.
165-func person(s *pb.Signature, now time.Time) Person {
166- p := Person{Name: visibleText(s.GetName())}
167- p.When, p.WhenAt = whenOf(now, s.GetTime())
168- if t := s.GetTime(); t.IsValid() {
169- p.Abs = absolute(t.AsTime())
164+func person(s domain.Signature, now time.Time) Person {
165+ p := Person{Name: visibleText(s.Name)}
166+ p.When, p.WhenAt = whenOf(now, s.Time)
167+ if !s.Time.IsZero() {
168+ p.Abs = absolute(s.Time)
170169 }
171170 return p
172171 }
173172
174173 // commitBody is the message without its first line, for display.
175-func commitBody(c *pb.Commit) string {
176- msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n")
174+func commitBody(c domain.Commit) string {
175+ msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
177176 _, body, _ := strings.Cut(msg, "\n")
178177 return messageText(strings.TrimSpace(body))
179178 }
180179
181180 // diffNote says what is missing from an incomplete diff, "" for a complete one.
182-func diffNote(d *pb.Diff, shown, total int) string {
183- if !d.GetTruncated() {
181+func diffNote(d domain.Diff, shown, total int) string {
182+ if !d.Truncated {
184183 return ""
185184 }
186185 var parts []string
@@ -188,8 +187,8 @@ func diffNote(d *pb.Diff, shown, total int) string {
188187 parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
189188 }
190189 cut := 0
191- for _, f := range d.GetFiles() {
192- if f.GetTruncated() {
190+ for _, f := range d.Files {
191+ if f.Truncated {
193192 cut++
194193 }
195194 }
M internal/adapters/web/commits.go +13 -16
@@ -8,10 +8,9 @@ import (
88 "net/url"
99 "path"
1010
11- "connectrpc.com/connect"
1211 "github.com/a-h/templ"
1312
14- pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
1514 )
1615
1716 // commitsPerPage is how many commits one page of history lists.
@@ -44,7 +43,7 @@ type CommitsPage struct {
4443 }
4544
4645 // commits lists the history of a ref, newest first, a page at a time, with
47-// an optional path to limit it to. The page token of the API travels in the
46+// an optional path to limit it to. The page token of the use case travels in the
4847 // "page" query parameter.
4948 func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) {
5049 d, err := s.loadRepo(ctx, r.PathValue("repo"))
@@ -56,13 +55,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
5655 return nil, 0, errNotFound
5756 }
5857 token := r.URL.Query().Get("page")
59- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
60- Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token,
61- }))
58+ res, err := s.commitList(ctx, d.repo.Name, sel.rev, sel.path, commitsPerPage, token)
6259 if err != nil {
6360 return nil, 0, err
6461 }
65- cs := res.Msg.GetCommits()
62+ cs := res.Commits
6663 if len(cs) == 0 && token == "" && sel.path != "" {
6764 // Git answers an empty history for a path that never existed.
6865 return nil, 0, errNotFound
@@ -70,7 +67,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
7067
7168 p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)}
7269 base := repoHref(d.name(), "commits", sel.name, sel.path)
73- if t := res.Msg.GetNextPageToken(); t != "" {
70+ if t := res.NextPageToken; t != "" {
7471 p.Older = base + "?page=" + url.QueryEscape(t)
7572 }
7673 if token != "" {
@@ -99,16 +96,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
9996 }
10097
10198 // 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) {
103100 now := s.cfg.Now()
104101 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
105102 for i, c := range cs {
106103 row := commitRow(d.name(), c, now)
107104 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()]
109106 day := "unknown date"
110- if t := c.GetCommitter().GetTime(); t.IsValid() {
111- day = t.AsTime().UTC().Format("2006-01-02")
107+ if t := c.Committer.Time; !t.IsZero() {
108+ day = t.UTC().Format("2006-01-02")
112109 }
113110 if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
114111 p.Days = append(p.Days, DayGroup{Day: day})
@@ -132,15 +129,15 @@ func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link {
132129 if dir == "." {
133130 dir = ""
134131 }
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)
136133 if err != nil {
137134 return nil
138135 }
139- for _, e := range tree.Msg.GetEntries() {
140- if e.GetPath() != sel.path {
136+ for _, e := range tree.Entries {
137+ if e.Path != sel.path {
141138 continue
142139 }
143- if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY {
140+ if e.Kind == domain.EntryDirectory {
144141 return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)}
145142 }
146143 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
44
55 import (
66 "errors"
7- "net/http"
87 "testing"
98
109 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212
13- pb "bnema.dev/ermite-api/gen/ermite"
14- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
1516 )
1617
1718 func TestIsCommitID(t *testing.T) {
@@ -28,15 +29,21 @@ func TestIsCommitID(t *testing.T) {
2829 // branch other than the default one, or none for a commit.
2930 func TestPinRefMarksCurrent(t *testing.T) {
3031 t.Parallel()
31- b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
32+ b, tag := domain.RefKindBranch, domain.RefKindTag
33+ name, err := domain.ParseRepoName("demo")
34+ require.NoError(t, err)
3235 d := &repoData{
33- repo: &pb.Repo{Name: "demo", DefaultBranch: "main"},
34- branches: []*pb.Ref{ref(b, "main"), ref(b, "side")},
35- tags: []*pb.Ref{ref(tag, "v1")},
36+ repo: ports.RepoView{Repo: domain.Repo{Name: name}, DefaultBranch: "main"},
37+ branches: []domain.Ref{ref(b, "main"), ref(b, "side")},
38+ tags: []domain.Ref{ref(tag, "v1")},
3639 }
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"))
3845 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),
4047 PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
4148 })
4249 require.NoError(t, err)
@@ -53,9 +60,9 @@ func TestPinRefMarksCurrent(t *testing.T) {
5360 heads, tags []string
5461 ref, short string
5562 }{
56- {"", []string{"main"}, nil, "main", "c-main"},
57- {"v1", nil, []string{"v1"}, "v1", "c-v1"},
58- {"side", []string{"side"}, nil, "side", "c-side"},
63+ {"", []string{"main"}, nil, "main", short(commitOf("main").String())},
64+ {"v1", nil, []string{"v1"}, "v1", short(commitOf("v1").String())},
65+ {"side", []string{"side"}, nil, "side", short(commitOf("side").String())},
5966 {"abcdef0123", nil, nil, "detached", "abcdef0"},
6067 } {
6168 tg, ok := d.locate(tt.rest)
M internal/adapters/web/compare.go +43 -43
@@ -7,10 +7,10 @@ import (
77 "net/http"
88 "slices"
99
10- "connectrpc.com/connect"
1110 "github.com/a-h/templ"
1211
13- pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
1414 )
1515
1616 // compareDepth bounds the commits read on each side to find where a branch
@@ -27,6 +27,8 @@ const compareDepth = maxComparePages * pageSize
2727 // own diff is then the whole change). Otherwise it lists the commits, which
2828 // link to their own diffs, and says the whole diff comes with the merge
2929 // 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
3032 func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
3133 d, err := s.loadRepo(ctx, r.PathValue("repo"))
3234 if err != nil {
@@ -36,25 +38,25 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
3638 if !ok || rest != "" || d.head == nil {
3739 return nil, 0, errNotFound
3840 }
39- tgt := d.head
41+ tgt := *d.head
4042 name := d.name()
4143
4244 p := ComparePage{
43- Source: visibleText(src.GetShortName()), Target: visibleText(tgt.GetShortName()),
44- SourceHref: repoHref(name, "tree", src.GetShortName()), TargetHref: repoHref(name, "tree", tgt.GetShortName()),
45+ Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName),
46+ SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName),
4547 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>",
4749 }},
4850 }
4951
50- same := src.GetShortName() == tgt.GetShortName()
51- var ahead []*pb.Commit
52+ same := src.ShortName == tgt.ShortName
53+ var ahead []domain.Commit
5254 exact, behind := false, false
5355 if same {
5456 p.Note = "this is the default branch: there is nothing to compare it with."
5557 p.AheadText = "0 commits"
5658 } 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())
5860 if err != nil {
5961 return nil, 0, err
6062 }
@@ -79,19 +81,19 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
7981
8082 // The exact diff: one commit on top of the tip of the target.
8183 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())
8385 if err != nil {
8486 return nil, 0, err
8587 }
8688 p.HasDiff = true
87- p.Files = diffFiles(res.Msg.GetDiff().GetFiles())
88- p.NFiles, p.Adds, p.Dels = int(res.Msg.GetStats().GetFilesChanged()), int(res.Msg.GetStats().GetAdditions()), int(res.Msg.GetStats().GetDeletions())
89- if n := diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles); n != "" {
89+ p.Files = diffFiles(res.Diff.Files)
90+ p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
91+ if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
9092 p.Note = n
9193 }
9294 lines, invisible := diffLines(p.Files)
9395 p.Invisible = invisible
94- self := repoHref(name, "compare", src.GetShortName())
96+ self := repoHref(name, "compare", src.ShortName)
9597 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
9698 } else if len(ahead) > 1 && exact {
9799 if p.Note == "" {
@@ -100,32 +102,32 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
100102 }
101103
102104 // An open request for the same pair, or the form to open one.
103- var existing *pb.MergeRequest
105+ var existing *domain.MergeRequest
104106 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)
106108 if err != nil {
107109 return nil, 0, err
108110 }
109111 }
110112 switch {
111113 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)}
113115 case SignedIn(ctx) && !same && len(ahead) > 0:
114116 title := ""
115117 if len(ahead) == 1 {
116- title = ahead[0].GetSummary()
118+ title = ahead[0].Summary
117119 } else {
118- title = ahead[len(ahead)-1].GetSummary()
120+ title = ahead[len(ahead)-1].Summary
119121 }
120122 p.Create = &CreateForm{
121- Action: mrBase(name), Source: src.GetShortName(), Target: tgt.GetShortName(),
123+ Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName,
122124 Title: cutRunes(title, 120),
123125 }
124126 }
125127
126128 l := s.repoLayout(ctx, d, "merge-requests")
127129 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})
129131 l.Crumbs[0].Current = false
130132 l.Crumbs = append(l.Crumbs,
131133 Crumb{Label: "merge requests", Href: mrBase(name)},
@@ -140,67 +142,65 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
140142 // first. exact is false when the walk gave up before finding where the source
141143 // meets the target. behind is true when the target has commits the source
142144 // 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) {
144148 if srcTip == tgtTip {
145149 return nil, true, false, nil
146150 }
147151 onTarget := map[string]bool{}
148152 tokenT := ""
149153 for len(onTarget) < compareDepth {
150- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
151- Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT,
152- }))
154+ res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT)
153155 if err != nil {
154156 return nil, false, false, err
155157 }
156- for _, c := range res.Msg.GetCommits() {
157- onTarget[c.GetId()] = true
158+ for _, c := range res.Commits {
159+ onTarget[c.ID.String()] = true
158160 }
159- if tokenT = res.Msg.GetNextPageToken(); tokenT == "" {
161+ if tokenT = res.NextPageToken; tokenT == "" {
160162 break
161163 }
162164 }
163165 token := ""
164166 for n := 0; n < compareDepth; {
165- res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
166- Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token,
167- }))
167+ res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token)
168168 if err != nil {
169169 return nil, false, false, err
170170 }
171- for _, c := range res.Msg.GetCommits() {
171+ for _, c := range res.Commits {
172172 n++
173- if onTarget[c.GetId()] {
173+ if onTarget[c.ID.String()] {
174174 // The first commit of the source that the target has: where the
175175 // branch left it. The target moved on when this is not its tip.
176- return ahead, true, c.GetId() != tgtTip, nil
176+ return ahead, true, c.ID.String() != tgtTip, nil
177177 }
178178 ahead = append(ahead, c)
179179 }
180- if token = res.Msg.GetNextPageToken(); token == "" {
180+ if token = res.NextPageToken; token == "" {
181181 // The whole history of the source is ahead: no common history.
182182 return ahead, true, true, nil
183183 }
184184 }
185- return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil
185+ return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil
186186 }
187187
188188 // 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) {
190190 token := ""
191191 for range maxOpenPages {
192- res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
193- Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token,
194- }))
192+ res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{
193+ State: domain.MRStateOpen, PageSize: pageSize, PageToken: token,
194+ })
195195 if err != nil {
196196 return nil, err
197197 }
198- for _, m := range res.Msg.GetMergeRequests() {
199- if m.GetSourceBranch() == source && m.GetTargetBranch() == target {
198+ for i := range res.MergeRequests {
199+ if m := &res.MergeRequests[i]; m.Source == source && m.Target == target {
200200 return m, nil
201201 }
202202 }
203- if token = res.Msg.GetNextPageToken(); token == "" {
203+ if token = res.NextPageToken; token == "" {
204204 break
205205 }
206206 }
M internal/adapters/web/compress_test.go +3 -8
@@ -18,7 +18,7 @@ import (
1818 "github.com/stretchr/testify/assert"
1919 "github.com/stretchr/testify/require"
2020
21- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
21+ "bnema.dev/ermite/internal/ports/mocks"
2222 )
2323
2424 func TestAcceptsGzip(t *testing.T) {
@@ -99,12 +99,7 @@ func TestPageETag(t *testing.T) {
9999
100100 func newBareServer(t *testing.T) *Server {
101101 t.Helper()
102- srv, err := New(Config{
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
102+ return newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)})
108103 }
109104
110105 func gunzip(t *testing.T, b []byte) string {
@@ -318,7 +313,7 @@ func TestAssetsGzip(t *testing.T) {
318313 // The event stream is never compressed, even for a client that accepts gzip.
319314 func TestEventsNotCompressed(t *testing.T) {
320315 t.Parallel()
321- f := &fakeEvents{release: make(chan struct{})}
316+ f := &watchProbe{release: make(chan struct{})}
322317 ts := eventsServer(t, f, 20*time.Millisecond, 0)
323318 resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"})
324319 require.Equal(t, http.StatusOK, resp.StatusCode)
M internal/adapters/web/diff.go +50 -50
@@ -13,7 +13,7 @@ import (
1313
1414 "github.com/a-h/templ"
1515
16- pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
1717 )
1818
1919 // Seg is a piece of a line of code: plain text, or the visible marker of an
@@ -101,11 +101,11 @@ type DiffFile struct {
101101 Hunks []DiffHunk
102102 }
103103
104-func lineNumber(n int32) string {
104+func lineNumber(n int) string {
105105 if n <= 0 {
106106 return ""
107107 }
108- return itoa(int(n))
108+ return itoa(n)
109109 }
110110
111111 // segments splits a line of code into plain text and markers. One trailing
@@ -197,79 +197,79 @@ func changeBar(add, del, most int) Bar {
197197 }
198198
199199 // statusLetter is the one-letter status of a file.
200-func statusLetter(s pb.FileStatus) string {
200+func statusLetter(s domain.FileStatus) string {
201201 switch s {
202- case pb.FileStatus_FILE_STATUS_ADDED:
202+ case domain.FileAdded:
203203 return "A"
204- case pb.FileStatus_FILE_STATUS_DELETED:
204+ case domain.FileDeleted:
205205 return "D"
206- case pb.FileStatus_FILE_STATUS_RENAMED:
206+ case domain.FileRenamed:
207207 return "R"
208- case pb.FileStatus_FILE_STATUS_COPIED:
208+ case domain.FileCopied:
209209 return "C"
210- case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
210+ case domain.FileTypeChanged:
211211 return "T"
212212 }
213213 return "M"
214214 }
215215
216216 // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
217-func hunkHeader(h *pb.Hunk) string {
218- s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines())
219- if t := h.GetHeader(); t != "" {
217+func hunkHeader(h domain.Hunk) string {
218+ s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
219+ if t := h.Header; t != "" {
220220 s += " " + visibleText(strings.TrimSpace(t))
221221 }
222222 return s
223223 }
224224
225-// diffLine converts a line of the API.
226-func diffLine(l *pb.Line) DiffLine {
227- dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())}
228- switch l.GetKind() {
229- case pb.LineKind_LINE_KIND_ADDITION:
230- dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber())
231- case pb.LineKind_LINE_KIND_DELETION:
232- dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber())
225+// diffLine converts a line of a hunk.
226+func diffLine(l domain.Line) DiffLine {
227+ dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber}
228+ switch l.Kind {
229+ case domain.LineAddition:
230+ dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber)
231+ case domain.LineDeletion:
232+ dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber)
233233 default:
234234 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)
236236 }
237237 return dl
238238 }
239239
240240 // diffFiles converts the files of a diff for the page.
241-func diffFiles(files []*pb.FileDiff) []DiffFile {
241+func diffFiles(files []domain.FileDiff) []DiffFile {
242242 most := 0
243243 for _, f := range files {
244- most = max(most, int(f.GetAdditions()+f.GetDeletions()))
244+ most = max(most, f.Additions+f.Deletions)
245245 }
246246 out := make([]DiffFile, 0, len(files))
247247 for i, f := range files {
248248 df := DiffFile{
249- ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()),
250- Add: int(f.GetAdditions()), Del: int(f.GetDeletions()),
251- Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most),
249+ ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
250+ Add: f.Additions, Del: f.Deletions,
251+ Bar: changeBar(f.Additions, f.Deletions, most),
252252 // A file that is shown in full opens; a file with nothing to read
253253 // (binary, truncated) stays closed under its note.
254- Open: len(f.GetHunks()) > 0 && !f.GetTruncated(),
254+ Open: len(f.Hunks) > 0 && !f.Truncated,
255255 }
256- df.Key = f.GetNewPath()
257- switch f.GetStatus() {
258- case pb.FileStatus_FILE_STATUS_DELETED:
259- df.Key = f.GetOldPath()
260- df.Path = visibleText(f.GetOldPath())
261- case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED:
262- df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath())
256+ df.Key = f.NewPath
257+ switch f.Status {
258+ case domain.FileDeleted:
259+ df.Key = f.OldPath
260+ df.Path = visibleText(f.OldPath)
261+ case domain.FileRenamed, domain.FileCopied:
262+ df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath)
263263 default:
264- df.Path = visibleText(f.GetNewPath())
264+ df.Path = visibleText(f.NewPath)
265265 }
266266 df.Note = fileNote(f)
267- if f.GetBinary() {
267+ if f.Binary {
268268 df.Bar = Bar{Rest: "bin"}
269269 }
270- for _, h := range f.GetHunks() {
270+ for _, h := range f.Hunks {
271271 dh := DiffHunk{Header: hunkHeader(h)}
272- for _, l := range h.GetLines() {
272+ for _, l := range h.Lines {
273273 dh.Lines = append(dh.Lines, diffLine(l))
274274 }
275275 dh.Split = pairLines(dh.Lines)
@@ -282,23 +282,23 @@ func diffFiles(files []*pb.FileDiff) []DiffFile {
282282
283283 // fileNote says what the hunks cannot: the kind of change when there is no
284284 // 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 {
286286 var parts []string
287287 switch {
288- case f.GetBinary():
288+ case f.Binary:
289289 parts = append(parts, "binary file: no text diff")
290- case f.GetTruncated():
291- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions()))
290+ case f.Truncated:
291+ parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
292292 }
293- if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() {
294- parts = append(parts, fmt.Sprintf("mode %06o → %06o", 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.OldMode, f.NewMode))
295295 }
296- switch f.GetStatus() {
297- case pb.FileStatus_FILE_STATUS_RENAMED:
298- parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity()))
299- case pb.FileStatus_FILE_STATUS_COPIED:
300- parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity()))
301- case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
296+ switch f.Status {
297+ case domain.FileRenamed:
298+ parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity))
299+ case domain.FileCopied:
300+ parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity))
301+ case domain.FileTypeChanged:
302302 parts = append(parts, "type changed")
303303 }
304304 return strings.Join(parts, " · ")
M internal/adapters/web/diff_test.go +36 -16
@@ -10,7 +10,7 @@ import (
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/require"
1212
13- pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
1414 )
1515
1616 // Trojan Source (ADR 0017): direction controls, zero-width and control
@@ -45,8 +45,8 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) {
4545 assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n"))
4646 }
4747
48-func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line {
49- return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
48+func line(kind domain.LineKind, oldN, newN int, text string) domain.Line {
49+ return domain.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
5050 }
5151
5252 // 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 {
5454 // opposite side.
5555 func TestPairLines(t *testing.T) {
5656 t.Parallel()
57- ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION
57+ ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion
5858 var ls []DiffLine
59- for _, l := range []*pb.Line{
59+ for _, l := range []domain.Line{
6060 line(ctx, 1, 1, "c"),
6161 line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"),
6262 line(add, 0, 2, "a1"),
@@ -96,20 +96,40 @@ func TestChangeBar(t *testing.T) {
9696
9797 func TestFileNotes(t *testing.T) {
9898 t.Parallel()
99- assert.Equal(t, "binary file: no text diff", fileNote(&pb.FileDiff{Binary: true, Status: pb.FileStatus_FILE_STATUS_MODIFIED}))
100- assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755}))
101- assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change")
102- assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}))
103- assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large")
99+ tests := []struct {
100+ name string
101+ file domain.FileDiff
102+ want string // a substring; empty means no note at all
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+ }
104124 }
105125
106126 func TestDiffFilesPaths(t *testing.T) {
107127 t.Parallel()
108- fs := diffFiles([]*pb.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")}}}},
110- {Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1},
111- {Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"},
112- {Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000},
128+ fs := diffFiles([]domain.FileDiff{
129+ {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
130+ {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
131+ {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
132+ {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
113133 })
114134 assert.Equal(t, "a.go", fs[0].Path)
115135 assert.True(t, fs[0].Open)
@@ -125,7 +145,7 @@ func TestDiffFilesPaths(t *testing.T) {
125145 func TestCodeLine(t *testing.T) {
126146 t.Parallel()
127147 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})
129149 require.NoError(t, codeLine(ln).Render(context.Background(), &b))
130150 assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+
131151 `<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 (
66 "bytes"
77 "context"
88 "errors"
9+ "fmt"
910 "net/http"
1011 "net/http/httptest"
1112 "regexp"
1213 "testing"
1314
14- "connectrpc.com/connect"
1515 "github.com/bnema/zerowrap"
1616 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
1718 "github.com/stretchr/testify/require"
1819
19- pb "bnema.dev/ermite-api/gen/ermite"
20- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
2123 )
2224
23-// failing is a repository client that fails every call with err.
24-type failing struct {
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-
25+// serve asks the web for path while every read of a repository fails with err,
26+// and returns the answer and what was logged.
3727 func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) {
3828 t.Helper()
39- srv, nerr := New(Config{
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)
29+ srv := newTestServer(t, Config{Repos: reposFailing(t, err), Browse: mocks.NewBrowseService(t)})
4430 var logs bytes.Buffer
4531 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
4632 rec := httptest.NewRecorder()
@@ -48,39 +34,146 @@ func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, st
4834 return rec, logs.String()
4935 }
5036
51-// Errors of the API: not found (and an invalid name) are the 404 page, any
52-// other is the 500 page with an id that the log carries.
37+// Errors of the use cases: not found (and an invalid name) are the 404 page, a
38+// visitor that left gets nothing, any other is the 500 page with an id that
39+// the log carries.
5340 func TestErrorPages(t *testing.T) {
5441 t.Parallel()
55- rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone")
56- assert.Equal(t, http.StatusNotFound, rec.Code)
57- assert.Contains(t, rec.Body.String(), "not found")
58- assert.Empty(t, logs, "a 404 is not an error")
42+ tests := []struct {
43+ name string
44+ err 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+}
5981
60- rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad")
61- assert.Equal(t, http.StatusNotFound, rec.Code)
62- assert.NotContains(t, rec.Body.String(), "bad name")
82+// A visitor that left gets no page at all.
83+func TestCanceledRequestGetsNoPage(t *testing.T) {
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)
63136
64- secret := "dial tcp 10.0.0.5: kv exploded"
65- rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo")
66- assert.Equal(t, http.StatusInternalServerError, rec.Code)
67- assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
68- assert.Contains(t, rec.Body.String(), "internal error")
69- assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log")
70- m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String())
71- require.Len(t, m, 2, "the page shows a request id")
72- assert.Contains(t, logs, m[1], "the log carries the same id")
73- assert.Contains(t, logs, "kv exploded")
137+ if tt.want.IsOwner() {
138+ assert.Equal(t, http.StatusOK, rec.Code)
139+ return
140+ }
141+ assert.Equal(t, http.StatusNotFound, rec.Code, "a private repository is a missing one")
142+ assert.Contains(t, rec.Body.String(), "not found")
143+ })
144+ }
145+}
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)})
74154
75- rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/")
76- assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too")
155+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Root())
156+ rec := httptest.NewRecorder()
157+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/secret", nil))
158+ assert.Equal(t, http.StatusNotFound, rec.Code)
77159 }
78160
79161 func TestNewValidates(t *testing.T) {
80162 t.Parallel()
81- _, err := New(Config{PublicURL: "https://forge.example"})
82- assert.Error(t, err, "clients are required")
83- git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid")
84- _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"})
85- assert.Error(t, err)
163+ tests := []struct {
164+ name string
165+ cfg Config
166+ }{
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+ }
86179 }
M internal/adapters/web/events.go +58 -44
@@ -10,17 +10,19 @@ import (
1010 "net/url"
1111 "strconv"
1212 "strings"
13+ "sync"
1314 "time"
1415
15- "connectrpc.com/connect"
1616 "github.com/bnema/zerowrap"
1717
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"
1921 )
2022
2123 // Live updates. A page that can change under the eyes of its reader opens
2224 // GET /-/events (the Live component of the hint badge). The handler follows
23-// EventService.Watch as the visitor of the request and turns each event into
25+// EventService.Watch as the principal of the request and turns each event into
2426 // the HTML fragments the page shows, sent as Server-Sent Events:
2527 //
2628 // GET /-/events?topic=home
@@ -63,10 +65,6 @@ import (
6365 const DefaultHeartbeat = 25 * time.Second
6466
6567 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
7068 // writeWindow bounds one write to the visitor, whatever the write timeout
7169 // of the server is.
7270 writeWindow = 30 * time.Second
@@ -105,7 +103,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
105103 if repo != "" {
106104 // A repository the visitor cannot see is a missing one (AC2).
107105 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)
109107 ccancel()
110108 if err != nil {
111109 s.denyEvents(ctx, w, err)
@@ -113,29 +111,33 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
113111 }
114112 }
115113
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{
117123 Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
118- }))
119- if err != nil {
120- s.denyEvents(ctx, w, err)
121- return
124+ // The visitor is the client of the watch, for the limit per client.
125+ Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
126+ Ready: start,
122127 }
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{})
128128 go func() {
129- defer close(done)
129+ defer close(finished)
130130 defer close(msgs)
131- for stream.Receive() {
131+ err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error {
132+ start()
132133 select {
133- case msgs <- recvd{ev: stream.Msg().GetEvent()}:
134+ case msgs <- recvd{ev: we}:
135+ return nil
134136 case <-ctx.Done():
135- return
137+ return ctx.Err()
136138 }
137- }
138- if err := stream.Err(); err != nil {
139+ })
140+ if err != nil && ctx.Err() == nil {
139141 select {
140142 case msgs <- recvd{err: err}:
141143 case <-ctx.Done():
@@ -144,26 +146,24 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
144146 }()
145147 defer func() {
146148 cancel()
147- _ = stream.Close()
148- <-done
149+ <-finished
149150 }()
150151
152+ // The watch is registered (or refused, which ends it before): only then
153+ // does the answer commit to 200.
151154 var first *recvd
152- grace := time.NewTimer(startGrace)
153155 select {
156+ case <-started:
154157 case m, open := <-msgs:
155- grace.Stop()
156158 if !open {
157159 return // the server is stopping
158160 }
161+ if m.err != nil {
162+ s.denyEvents(ctx, w, m.err)
163+ return
164+ }
159165 first = &m
160- case <-grace.C:
161166 case <-ctx.Done():
162- grace.Stop()
163- return
164- }
165- if first != nil && first.err != nil {
166- s.denyEvents(ctx, w, first.err)
167167 return
168168 }
169169
@@ -185,11 +185,14 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
185185 st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")}
186186 handle := func(m recvd) bool {
187187 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) {
189189 log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended")
190190 }
191191 return false
192192 }
193+ if !liveEvent(m.ev) {
194+ return true // nothing to show, not even an id to resume after
195+ }
193196 // An owner's stream ends when the session does, before it sends
194197 // anything more: an event can carry what only the owner may see, and
195198 // waiting for the next heartbeat would leave a window.
@@ -208,7 +211,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
208211 log.Warn().Err(err).Str("topic", topic).Msg("render live fragment")
209212 frags = gap
210213 }
211- return out.send(m.ev.GetId(), frags) == nil
214+ return out.send(m.ev.ID, frags) == nil
212215 }
213216 if first != nil && !handle(*first) {
214217 return
@@ -243,30 +246,41 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
243246
244247 // recvd is a message of the Watch stream, or how it failed.
245248 type recvd struct {
246- ev *pb.Event
249+ ev ports.WatchEvent
247250 err error
248251 }
249252
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+
250264 // denyStream answers a request that gets no stream.
251265 func denyStream(w http.ResponseWriter, status int, msg string) {
252266 w.Header().Set("Cache-Control", "no-store")
253267 http.Error(w, msg, status)
254268 }
255269
256-// denyEvents maps an error of the API, before the stream started, to a
270+// denyEvents maps an error of a use case, before the stream started, to a
257271 // status. Not found and invalid are 404 whatever the reason: a private
258272 // repository is a missing one.
259273 func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) {
260- switch code := connect.CodeOf(err); {
261- case code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
274+ switch {
275+ case errors.Is(err, context.Canceled):
276+ // The visitor is gone.
277+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
262278 denyStream(w, http.StatusNotFound, "not found")
263- case code == connect.CodeResourceExhausted:
279+ case errors.Is(err, domain.ErrResourceExhausted):
264280 w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
265281 denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later")
266- case code == connect.CodeUnauthenticated:
282+ case errors.Is(err, domain.ErrUnauthenticated):
267283 denyStream(w, http.StatusUnauthorized, "unauthorized")
268- case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
269- // The visitor is gone.
270284 default:
271285 log := zerowrap.FromCtx(ctx)
272286 log.Error().Err(err).Msg("open event stream")
M internal/adapters/web/events_test.go +110 -82
@@ -6,6 +6,7 @@ import (
66 "bufio"
77 "context"
88 "errors"
9+ "fmt"
910 "io"
1011 "net/http"
1112 "net/http/httptest"
@@ -14,12 +15,11 @@ import (
1415 "testing"
1516 "time"
1617
17- "connectrpc.com/connect"
1818 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
1920 "github.com/stretchr/testify/require"
2021
21- pb "bnema.dev/ermite-api/gen/ermite"
22- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+ "bnema.dev/ermite/internal/domain"
2323 "bnema.dev/ermite/internal/ports"
2424 "bnema.dev/ermite/internal/ports/mocks"
2525 )
@@ -60,28 +60,45 @@ func TestNewCommitsText(t *testing.T) {
6060 assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2))
6161 }
6262
63-// fakeEvents is an EventService whose behaviour a test sets.
64-type fakeEvents struct {
65- ermiteconnect.UnimplementedEventServiceHandler
63+// watchProbe drives the generated EventService mock: what the watch does, and
64+// a record of the principal and the parameters of the watches it was asked for.
65+type watchProbe struct {
6666 err error
67- events []*pb.Event
67+ events []ports.WatchEvent
6868 opened atomic.Int32
6969 closed atomic.Int32
70- resume atomic.Value
70+ resume atomic.Value // string: ResumeAfter of the last watch
71+ topics atomic.Value // []string: Topics of the last watch
72+ client atomic.Value // string: Client of the last watch
73+ who atomic.Value // domain.Principal: caller of the last watch
7174 release chan struct{}
72- push chan *pb.Event // events sent on demand, after the fixed ones
75+ push chan ports.WatchEvent // events sent on demand, after the fixed ones
7376 }
7477
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 {
7688 f.opened.Add(1)
7789 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)
7994 if f.err != nil {
8095 return f.err
8196 }
82- _ = st.Send(nil)
97+ if params.Ready != nil {
98+ params.Ready()
99+ }
83100 for _, ev := range f.events {
84- if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil {
101+ if err := send(ev); err != nil {
85102 return err
86103 }
87104 }
@@ -92,46 +109,29 @@ func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchReq
92109 case <-f.release:
93110 return nil
94111 case ev := <-f.push:
95- if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil {
112+ if err := send(ev); err != nil {
96113 return err
97114 }
98115 }
99116 }
100117 }
101118
102-// repoOK answers every GetRepo with a public repository.
103-type repoOK struct {
104- ermiteconnect.UnimplementedRepoServiceHandler
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 {
119+// eventsServer serves the web on a fake EventService, with a write timeout
120+// shorter than the heartbeat, like a server that sets one.
121+func eventsServer(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration) *httptest.Server {
114122 t.Helper()
115123 return eventsServerWith(t, f, beat, writeTimeout, nil)
116124 }
117125
118126 // 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 {
120128 t.Helper()
121- api := http.NewServeMux()
122- api.Handle(ermiteconnect.NewEventServiceHandler(f))
123- api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{}))
124- backend := httptest.NewServer(api)
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",
129+ srv := newTestServer(t, Config{
130+ Repos: reposServing(t, publicRepo(t, "x")),
131+ Browse: mocks.NewBrowseService(t),
132+ Events: f.service(t),
132133 Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess),
133134 })
134- require.NoError(t, err)
135135 ts := httptest.NewUnstartedServer(srv)
136136 ts.Config.WriteTimeout = writeTimeout
137137 ts.Start()
@@ -157,7 +157,7 @@ func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string
157157 // server does not end it.
158158 func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) {
159159 t.Parallel()
160- f := &fakeEvents{release: make(chan struct{})}
160+ f := &watchProbe{release: make(chan struct{})}
161161 ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond)
162162 resp, cancel := open(t, ts, "topic=home", nil)
163163 require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -181,9 +181,9 @@ func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) {
181181 // Last-Event-ID header is the resume point; a Gap becomes a gap event.
182182 func TestEventsStreamsEventsAndResumes(t *testing.T) {
183183 t.Parallel()
184- f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{
185- {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}},
186- {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}},
184+ f := &watchProbe{release: make(chan struct{}), events: []ports.WatchEvent{
185+ {ID: "b-7", Event: domain.PushEvent{Repo: repoName(t, "x")}},
186+ {Gap: true},
187187 }}
188188 ts := eventsServer(t, f, time.Hour, 0)
189189 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) {
199199 assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id")
200200 assert.Contains(t, lines, "event: gap")
201201 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")
202205 }
203206
204-// Too many watchers is a 503 the browser can retry, not a stream.
205-func TestEventsResourceExhausted(t *testing.T) {
207+// Too many watchers is a 503 the browser can retry, not a stream; what the
208+// watch is refused for decides the status.
209+func TestEventsRefusals(t *testing.T) {
206210 t.Parallel()
207- f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))}
208- ts := eventsServer(t, f, time.Hour, 0)
209- resp, _ := open(t, ts, "topic=home", nil)
210- assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
211- assert.NotEmpty(t, resp.Header.Get("Retry-After"))
212- assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream")
211+ tests := []struct {
212+ name string
213+ err error
214+ want int
215+ retryAfter bool
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+}
213235
214- f.err = connect.NewError(connect.CodeNotFound, errors.New("x"))
215- resp, _ = open(t, ts, "topic=home", nil)
216- assert.Equal(t, http.StatusNotFound, resp.StatusCode)
217- f.err = errors.New("boom")
218- resp, _ = open(t, ts, "topic=home", nil)
219- assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
236+// The topic of a repository the visitor cannot see is refused before any
237+// watch is opened: a private repository is a missing one.
238+func TestEventsPrivateRepositoryTopic(t *testing.T) {
239+ t.Parallel()
240+ f := &watchProbe{release: make(chan struct{})}
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())
220262 }
221263
222264 // The handler returns, and the watch it opened ends, when the visitor goes
223265 // away or the API ends the stream.
224266 func TestEventsNoLeak(t *testing.T) {
225267 t.Parallel()
226- f := &fakeEvents{release: make(chan struct{})}
268+ f := &watchProbe{release: make(chan struct{})}
227269 ts := eventsServer(t, f, time.Hour, 0)
228270
229271 resp, cancel := open(t, ts, "topic=home", nil)
@@ -246,14 +288,9 @@ func TestEventsNoLeak(t *testing.T) {
246288 require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond)
247289 }
248290
249-func TestEventsOffWithoutClient(t *testing.T) {
291+func TestEventsOffWithoutEventService(t *testing.T) {
250292 t.Parallel()
251- srv, err := New(Config{
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)
293+ srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
257294 rec := httptest.NewRecorder()
258295 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil))
259296 assert.Equal(t, http.StatusNotFound, rec.Code)
@@ -264,18 +301,9 @@ func TestEventsOffWithoutClient(t *testing.T) {
264301 // and marks depend on it. The commits page shows a branch, not the default.
265302 func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) {
266303 t.Parallel()
267- api := http.NewServeMux()
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)
304+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t)})
277305
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")}}
279307 for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} {
280308 ls := &liveStream{s: srv, repo: "x", view: view}
281309 got, err := ls.render(t.Context(), ev)
@@ -308,7 +336,7 @@ func newRevocable(t *testing.T, valid string) (*mocks.WebSessionService, *atomic
308336 // and the stream ends.
309337 func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
310338 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)}
312340 sess, ended := newRevocable(t, "tok")
313341 ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test
314342 resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"})
@@ -328,7 +356,7 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
328356 // The stream is up; a first event goes through while the session lives.
329357 require.Equal(t, ": connected", line())
330358 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}
332360 var seen []string
333361 for l := line(); l != "data: gap"; l = line() {
334362 seen = append(seen, l)
@@ -339,7 +367,7 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
339367 // Signed out everywhere: the next event is not sent, the stream ends.
340368 ended.Store(true)
341369 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}:
343371 case <-time.After(5 * time.Second):
344372 t.Fatal("the event was not delivered to the web")
345373 }
@@ -351,13 +379,13 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) {
351379 // A visitor's stream does not depend on sessions: events keep flowing.
352380 func TestEventsVisitorStreamIgnoresSessions(t *testing.T) {
353381 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)}
355383 sess, ended := newRevocable(t, "tok")
356384 ended.Store(true)
357385 ts := eventsServerWith(t, f, time.Hour, 0, sess)
358386 resp, _ := open(t, ts, "topic=home", nil)
359387 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}
361389 sc := bufio.NewScanner(resp.Body)
362390 found := false
363391 for sc.Scan() {
@@ -373,7 +401,7 @@ func TestEventsVisitorStreamIgnoresSessions(t *testing.T) {
373401 // say the answer must not be stored and varies on the cookie.
374402 func TestEventsInvalidCookieStream(t *testing.T) {
375403 t.Parallel()
376- f := &fakeEvents{release: make(chan struct{})}
404+ f := &watchProbe{release: make(chan struct{})}
377405 ts := eventsServerWith(t, f, time.Hour, 0, newSessions(t, "tok"))
378406 resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"})
379407 require.Equal(t, http.StatusOK, resp.StatusCode)
M internal/adapters/web/fanout_test.go +42 -41
@@ -10,13 +10,13 @@ import (
1010 "testing"
1111 "testing/synctest"
1212
13- "connectrpc.com/connect"
1413 "github.com/stretchr/testify/assert"
1514 "github.com/stretchr/testify/mock"
1615 "github.com/stretchr/testify/require"
1716
18- pb "bnema.dev/ermite-api/gen/ermite"
19- "bnema.dev/ermite/internal/adapters/web/mocks"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
2020 )
2121
2222 func frag(s string) []fragment { return []fragment{{event: "refs-list", html: s}} }
@@ -137,58 +137,59 @@ func TestFanoutIsBounded(t *testing.T) {
137137 }
138138
139139 // refsMocks is a web server over mocks that expect the refs fragment to load
140-// the repository and its refs repos times, and to ask for a commit commits
141-// times.
142-func refsMocks(t *testing.T, repos, commits int) *Server {
140+// the repository and its refs once per build of each class of watcher
141+// (anonymous, owner), asking the use cases as that class's principal, and to
142+// ask for a commit once per build when commits is set.
143+func refsMocks(t *testing.T, anonymous, owner int, commits bool) *Server {
143144 t.Helper()
144- api := mocks.NewRepoServiceClient(t)
145- git := mocks.NewGitServiceClient(t)
146- api.EXPECT().GetRepo(mock.Anything, mock.Anything).RunAndReturn(
147- func(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) {
148- return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil
149- }).Times(repos)
150- id := "0123456789abcdef0123456789abcdef01234567"
151- git.EXPECT().ListRefs(mock.Anything, mock.MatchedBy(func(req *connect.Request[pb.ListRefsRequest]) bool {
152- return req.Msg.GetPageSize() == maxRefs // the refs are asked in one page
153- })).RunAndReturn(func(context.Context, *connect.Request[pb.ListRefsRequest]) (*connect.Response[pb.ListRefsResponse], error) {
154- return connect.NewResponse(&pb.ListRefsResponse{Refs: []*pb.Ref{
155- {Name: "refs/heads/main", ShortName: "main", Kind: pb.RefKind_REF_KIND_BRANCH, CommitId: id},
156- {Name: "refs/tags/v1", ShortName: "v1", Kind: pb.RefKind_REF_KIND_TAG, CommitId: id},
157- }}), nil
158- }).Times(repos)
159- if commits > 0 {
160- git.EXPECT().ListCommits(mock.Anything, mock.Anything).RunAndReturn(
161- func(_ context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) {
162- return connect.NewResponse(&pb.ListCommitsResponse{Commits: []*pb.Commit{{Id: req.Msg.GetRevision(), Summary: "subject"}}}), nil
163- }).Times(commits)
145+ const id = "0123456789abcdef0123456789abcdef01234567"
146+ repos := mocks.NewRepoService(t)
147+ browse := mocks.NewBrowseService(t)
148+ for _, class := range []struct {
149+ p domain.Principal
150+ builds int
151+ }{{domain.Anonymous(), anonymous}, {domain.Owner(), owner}} {
152+ p, builds := class.p, class.builds
153+ if builds == 0 {
154+ continue
155+ }
156+ repos.EXPECT().Get(mock.Anything, p, repoName(t, "x")).Return(publicRepo(t, "x").Repo, nil).Times(builds)
157+ browse.EXPECT().ListRefs(mock.Anything, p, repoName(t, "x"), mock.MatchedBy(func(params ports.ListRefsParams) bool {
158+ return params.PageSize == maxRefs // the refs are asked in one page
159+ })).Return(ports.RefPage{Refs: []domain.Ref{
160+ {Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: objectID(t, id)},
161+ {Name: "refs/tags/v1", ShortName: "v1", Kind: domain.RefKindTag, CommitID: objectID(t, id)},
162+ }}, nil).Times(builds)
163+ if 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+ }
164169 }
165- s, err := New(Config{
166- Repos: api, Git: git,
167- PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
168- })
169- require.NoError(t, err)
170- return s
170+ repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "x"), nil).Times(anonymous + owner)
171+ return newTestServer(t, Config{Repos: repos, Browse: browse})
171172 }
172173
173174 // A push is rendered once for the watchers of the same class, and the owner
174175 // and the anonymous visitors never share what was built.
175176 func TestLiveRefsSharesAPushBetweenWatchers(t *testing.T) {
176177 t.Parallel()
177- owner := context.WithValue(context.Background(), signedInKey{}, struct{}{})
178+ owner := domain.ContextWithPrincipal(context.WithValue(context.Background(), signedInKey{}, struct{}{}), domain.Owner())
178179 anon := context.Background()
179180
180181 for _, tt := range []struct {
181- name string
182- ctxs []context.Context
183- builds int // loads of the repository and refs, and ListCommits calls: one per class
182+ name string
183+ ctxs []context.Context
184+ anonymous, mine int // builds: one per class
184185 }{
185- {"five anonymous watchers", []context.Context{anon, anon, anon, anon, anon}, 1},
186- {"owner and anonymous", []context.Context{anon, owner, anon, owner}, 2},
186+ {"five anonymous watchers", []context.Context{anon, anon, anon, anon, anon}, 1, 0},
187+ {"owner and anonymous", []context.Context{anon, owner, anon, owner}, 1, 1},
187188 } {
188189 t.Run(tt.name, func(t *testing.T) {
189190 t.Parallel()
190- s := refsMocks(t, tt.builds, tt.builds)
191- ev := &pb.Event{Id: "b-1", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}}
191+ s := refsMocks(t, tt.anonymous, tt.mine, true)
192+ ev := ports.WatchEvent{ID: "b-1", Event: domain.PushEvent{Repo: repoName(t, "x")}}
192193 for _, ctx := range tt.ctxs {
193194 st := &liveStream{s: s, repo: "x", view: "refs"}
194195 frags, err := st.render(ctx, ev)
@@ -204,7 +205,7 @@ func TestLiveRefsSharesAPushBetweenWatchers(t *testing.T) {
204205 // The refs are asked in one page of up to maxRefs (the mock only answers that).
205206 func TestLoadRepoAsksTheRefsInOnePage(t *testing.T) {
206207 t.Parallel()
207- s := refsMocks(t, 1, 0)
208+ s := refsMocks(t, 1, 0, false)
208209 d, err := s.loadRepo(context.Background(), "x")
209210 require.NoError(t, err)
210211 assert.Len(t, d.branches, 1)
M internal/adapters/web/fragments.go +91 -59
@@ -10,10 +10,10 @@ import (
1010 "sync"
1111 "time"
1212
13- "connectrpc.com/connect"
1413 "github.com/a-h/templ"
1514
16- pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
1717 )
1818
1919 // 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
3737
3838 var gap = []fragment{{event: "gap", html: "gap"}}
3939
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+
4055 // render returns the fragments for an event, none when it changes nothing on
4156 // the page.
42-func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) {
43- if ev == nil {
44- return nil, nil
45- }
46- if ev.GetGap() != nil {
57+func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
58+ if we.Gap {
4759 return gap, nil
4860 }
61+ if we.Event == nil {
62+ return nil, nil
63+ }
4964 if l.repo == "" {
50- return l.home(ctx, ev)
65+ return l.home(ctx, we)
5166 }
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)
5378 }
5479
5580 // home upserts the row of the repository an event is about. The row is read
56-// again from the API as the visitor of the stream, so a repository that was
57-// deleted or that the visitor can no longer see (public to private) removes
58-// 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) {
81+// again from the use case as the principal of the stream, so a repository
82+// that was deleted or that the visitor can no longer see (public to private)
83+// removes the row, and the stream never carries more than the list shows.
84+func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
6085 var name string
6186 fresh := false
62- switch k := ev.GetKind().(type) {
63- case *pb.Event_RepoDeleted:
64- return l.gone(ctx, k.RepoDeleted.GetRepo())
65- case *pb.Event_RepoCreated:
66- name = k.RepoCreated.GetRepo().GetName()
67- case *pb.Event_RepoUpdated:
68- name = k.RepoUpdated.GetRepo().GetName()
69- case *pb.Event_Pushed:
70- name, fresh = k.Pushed.GetRepo(), true
87+ switch ev := we.Event.(type) {
88+ case domain.RepoDeleted:
89+ return l.gone(ctx, ev.Repo.String())
90+ case domain.RepoCreated:
91+ name = ev.Repo.String()
92+ case domain.RepoUpdated:
93+ name = ev.Repo.String()
94+ case domain.PushEvent:
95+ name, fresh = ev.Repo.String(), true
7196 default:
7297 return nil, nil
7398 }
74- res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
99+ view, err := l.repoByName(ctx, name)
75100 switch {
76- case connect.CodeOf(err) == connect.CodeNotFound:
101+ case errors.Is(err, domain.ErrNotFound):
77102 return l.gone(ctx, name)
78103 case err != nil:
79104 return nil, err
80105 }
81- row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false)
106+ row := homeRow(view, l.s.cfg.Now(), false)
82107 row.Fresh = fresh
83108 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
84109 return []fragment{f}, err
@@ -90,16 +115,16 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
90115 }
91116
92117 // repoPage handles the events of the topic of one repository.
93-func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) {
94- switch k := ev.GetKind().(type) {
95- case *pb.Event_RepoDeleted, *pb.Event_RepoCreated:
118+func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
119+ switch ev := we.Event.(type) {
120+ case domain.RepoDeleted, domain.RepoCreated:
96121 // Gone, or a new repository under the same name: whatever the page
97122 // shows is not true any more.
98123 return gap, nil
99- case *pb.Event_RepoUpdated:
100- _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
124+ case domain.RepoUpdated:
125+ _, err := l.repoByName(ctx, l.repo)
101126 switch {
102- case connect.CodeOf(err) == connect.CodeNotFound:
127+ case errors.Is(err, domain.ErrNotFound):
103128 return gap, nil // no longer visible
104129 case err != nil:
105130 return nil, err
@@ -107,26 +132,26 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er
107132 return gap, nil // the description, the default branch
108133 }
109134 return nil, nil
110- case *pb.Event_MergeRequestOpened:
111- return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload")
112- case *pb.Event_MergeRequestMerged:
113- return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload")
114- case *pb.Event_MergeRequestClosed:
115- return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload")
116- case *pb.Event_CommentAdded:
117- return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload")
118- case *pb.Event_Pushed:
135+ case domain.MergeRequestOpened:
136+ return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
137+ case domain.MergeRequestMerged:
138+ return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
139+ case domain.MergeRequestClosed:
140+ return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
141+ case domain.CommentAdded:
142+ return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
143+ case domain.PushEvent:
119144 switch l.view {
120145 case "mr":
121- return l.mrPushed(ctx, k.Pushed)
146+ return l.mrPushed(ctx, ev)
122147 case "mrs":
123148 return nil, nil
124149 }
125150 switch l.view {
126151 case "refs":
127- return l.refs(ctx, ev.GetId())
152+ return l.refs(ctx, we.ID)
128153 case "commits":
129- return l.banner(ctx, k.Pushed)
154+ return l.banner(ctx, ev)
130155 }
131156 return l.lastPush(ctx)
132157 }
@@ -135,11 +160,11 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er
135160
136161 // lastPush is the "last push" fact of the summary.
137162 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)
139164 if err != nil {
140165 return nil, err
141166 }
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())))
143168 return []fragment{f}, err
144169 }
145170
@@ -260,14 +285,19 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error))
260285
261286 // banner tells the commits page how many commits its branch got since the
262287 // 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) {
264291 if l.ref == "" || !isFullID(l.head) {
265292 return nil, nil
266293 }
267294 tip, touched := "", false
268- for _, u := range p.GetRefUpdates() {
269- if u.GetRef() == "refs/heads/"+l.ref {
270- tip, touched = u.GetNewId(), true
295+ for _, u := range p.Updates {
296+ if u.Ref == "refs/heads/"+l.ref {
297+ tip, touched = u.New, true
298+ if u.IsDelete() {
299+ tip = ""
300+ }
271301 }
272302 }
273303 switch {
@@ -278,9 +308,11 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro
278308 }
279309 text := ""
280310 if tip != l.head {
281- res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
282- Repo: l.repo, Revision: tip, PageSize: pageSize,
283- }))
311+ name, err := domain.ParseRepoName(l.repo)
312+ if err != nil {
313+ return nil, err
314+ }
315+ res, err := l.s.commitList(ctx, name, tip, "", pageSize, "")
284316 if err != nil {
285317 return nil, err
286318 }
@@ -288,10 +320,10 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro
288320 // ordered by date, not as "head..tip", so commits that are not new may
289321 // be counted before the head is met. And one page of the log being
290322 // 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".
292324 n, found := 0, false
293- for _, c := range res.Msg.GetCommits() {
294- if c.GetId() == l.head {
325+ for _, c := range res.Commits {
326+ if c.ID.String() == l.head {
295327 found = true
296328 break
297329 }
@@ -300,7 +332,7 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro
300332 switch {
301333 case found:
302334 text = newCommits(strconv.Itoa(n), n)
303- case res.Msg.GetNextPageToken() != "":
335+ case res.NextPageToken != "":
304336 text = newCommits(strconv.Itoa(n)+"+", 2)
305337 default:
306338 text = "the branch was rewritten — reload"
@@ -343,12 +375,12 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
343375 }
344376
345377 // 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) {
347379 if l.ref == "" {
348380 return nil, nil
349381 }
350- for _, u := range p.GetRefUpdates() {
351- if u.GetRef() == "refs/heads/"+l.ref {
382+ for _, u := range p.Updates {
383+ if u.Ref == "refs/heads/"+l.ref {
352384 n, err := strconv.ParseInt(l.n, 10, 64)
353385 if err != nil {
354386 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 (
1111 "github.com/stretchr/testify/assert"
1212 "github.com/stretchr/testify/require"
1313
14- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
1515 )
1616
1717 func TestHighlightLines(t *testing.T) {
@@ -141,12 +141,12 @@ func TestSniffImage(t *testing.T) {
141141
142142 func TestLocate(t *testing.T) {
143143 t.Parallel()
144- b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
144+ b, tag := domain.RefKindBranch, domain.RefKindTag
145145 d := &repoData{
146- branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")},
147- tags: []*pb.Ref{ref(tag, "v1/rc")},
146+ branches: []domain.Ref{ref(b, "main"), ref(b, "feature/x")},
147+ tags: []domain.Ref{ref(tag, "v1/rc")},
148148 }
149- d.head = d.branches[0]
149+ d.head = &d.branches[0]
150150 for _, tt := range []struct {
151151 in, name, path string
152152 detached, ok bool
M internal/adapters/web/home.go +31 -26
@@ -9,34 +9,39 @@ import (
99 "strings"
1010 "time"
1111
12- "connectrpc.com/connect"
1312 "github.com/a-h/templ"
1413
15- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/ports"
1615 )
1716
1817 // maxRepos bounds the repositories one page lists.
1918 const maxRepos = 1000
2019
21-// pageSize is the page size asked from the commit lists: the largest the API
22-// accepts for them.
20+// pageSize is the page size asked from the commit lists: the largest the use
21+// case accepts for them.
2322 const pageSize = 100
2423
2524 // listRepos reads the repositories the visitor may see, ordered by name.
2625 // Every page of ListRepos scans the whole repository list and reads the HEAD
2726 // of each repository of the page, so the list is asked in one page of up to
28-// maxRepos: one scan in the common case, whatever the API cap is (a lower
29-// one only means more pages, followed by their token).
30-func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) {
31- var repos []*pb.Repo
27+// maxRepos: one scan in the common case, whatever the cap of the use case is
28+// (a lower one only means more pages, followed by their token).
29+func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
30+ var repos []ports.RepoView
3231 token := ""
3332 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)
3534 if err != nil {
3635 return nil, err
3736 }
38- repos = append(repos, res.Msg.GetRepos()...)
39- if token = res.Msg.GetNextPageToken(); token == "" {
37+ for _, repo := range page.Repos {
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 == "" {
4045 break
4146 }
4247 }
@@ -52,17 +57,17 @@ var homeCols = []struct{ key, label, class string }{
5257 }
5358
5459 // sortRepos orders repos by the column key; ties keep the order by name.
55-func sortRepos(repos []*pb.Repo, key string, desc bool) {
56- less := func(a, b *pb.Repo) bool {
60+func sortRepos(repos []ports.RepoView, key string, desc bool) {
61+ less := func(a, b ports.RepoView) bool {
5762 switch key {
5863 case "desc":
59- return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription())
64+ return strings.ToLower(a.Description) < strings.ToLower(b.Description)
6065 case "branch":
61- return a.GetDefaultBranch() < b.GetDefaultBranch()
66+ return a.DefaultBranch < b.DefaultBranch
6267 case "push":
63- return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime())
68+ return a.PushedAt.Before(b.PushedAt)
6469 }
65- return a.GetName() < b.GetName()
70+ return a.Name.String() < b.Name.String()
6671 }
6772 sort.SliceStable(repos, func(i, j int) bool {
6873 if desc {
@@ -118,25 +123,25 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
118123 }
119124
120125 // 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()
122128 row := HomeRow{
123- ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()),
124- Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor,
129+ ID: "repo-" + name, Name: name, Href: repoHref(name),
130+ Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor,
125131 }
126- if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 {
127- t := r.GetPushedAt().AsTime()
128- row.Pushed, row.PushedAt = ago(now, t), rfc3339(t)
132+ if !r.PushedAt.IsZero() {
133+ row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
129134 }
130135 return row
131136 }
132137
133138 // repoCmds are the repositories as targets of the ":" command line, but the
134139 // one being viewed.
135-func repoCmds(repos []*pb.Repo, except string) []CmdItem {
140+func repoCmds(repos []ports.RepoView, except string) []CmdItem {
136141 var out []CmdItem
137142 for _, r := range repos {
138- if r.GetName() != except {
139- out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())})
143+ if name := r.Name.String(); name != except {
144+ out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
140145 }
141146 }
142147 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 (
1111 "strconv"
1212 "strings"
1313
14- "connectrpc.com/connect"
1514 "github.com/a-h/templ"
1615
17- pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
1818 )
1919
2020 // The merge request pages (ADR 0017). They read through MergeRequestService
21-// and GitService as the visitor of the request: anonymous sees the merge
21+// and BrowseService as the visitor of the request: anonymous sees the merge
2222 // requests of public repositories, the signed-in owner those of every
2323 // repository, and only the owner gets the forms (mr_post.go handles them).
2424 //
@@ -44,9 +44,9 @@ import (
4444
4545 const (
4646 // 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.
4848 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.
5050 maxMRPages = 20
5151 // maxOpenPages bounds the pages read for the open merge requests, which
5252 // the first list page shows in full.
@@ -260,39 +260,39 @@ func mrNumber(r *http.Request) (int64, error) {
260260 return n, nil
261261 }
262262
263-func stateName(s pb.MergeRequestState) string {
263+func stateName(s domain.MRState) string {
264264 switch s {
265- case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
265+ case domain.MRStateOpen:
266266 return "open"
267- case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
267+ case domain.MRStateMerged:
268268 return "merged"
269- case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
269+ case domain.MRStateClosed:
270270 return "closed"
271271 }
272272 return "unknown"
273273 }
274274
275-func modeName(m pb.MergeMode) string {
275+func modeName(m domain.MergeMode) string {
276276 switch m {
277- case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
277+ case domain.MergeModeFastForward:
278278 return "fast-forward"
279- case pb.MergeMode_MERGE_MODE_SQUASH:
279+ case domain.MergeModeSquash:
280280 return "squash"
281281 }
282282 return ""
283283 }
284284
285285 // 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 {
287287 row := MRRow{
288- Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()),
289- Title: visibleText(m.GetTitle()), State: stateName(m.GetState()),
290- Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()),
291- Author: visibleText(m.GetAuthor()),
288+ Number: "!" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
289+ Title: visibleText(m.Title.String()), State: stateName(m.State),
290+ Branches: visibleText(m.Source) + " → " + visibleText(m.Target),
291+ Author: visibleText(m.Author),
292292 }
293- ts := m.GetUpdatedAt()
294- if !ts.IsValid() {
295- ts = m.GetCreatedAt()
293+ ts := m.UpdatedAt
294+ if ts.IsZero() {
295+ ts = m.CreatedAt
296296 }
297297 row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
298298 return row
@@ -300,30 +300,30 @@ func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow {
300300
301301 // mrList lists the merge requests: the open ones, then the merged and closed
302302 // 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.
304304 func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
305305 d, err := s.loadRepo(ctx, r.PathValue("repo"))
306306 if err != nil {
307307 return nil, 0, err
308308 }
309309 token := r.URL.Query().Get("page")
310- p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())}
310+ p := MRListPage{Default: visibleText(d.repo.DefaultBranch)}
311311 p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
312312 base := mrBase(d.name())
313313
314314 if token == "" {
315315 tok := ""
316316 for i := 0; i < maxOpenPages; i++ {
317- res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
318- Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok,
319- }))
317+ res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
318+ State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok,
319+ })
320320 if err != nil {
321321 return nil, 0, err
322322 }
323- for _, m := range res.Msg.GetMergeRequests() {
323+ for _, m := range res.MergeRequests {
324324 p.Open = append(p.Open, s.mrRow(d.name(), m))
325325 }
326- if tok = res.Msg.GetNextPageToken(); tok == "" {
326+ if tok = res.NextPageToken; tok == "" {
327327 break
328328 }
329329 }
@@ -334,18 +334,18 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component,
334334
335335 tok := token
336336 for i := 0; i < maxMRPages; i++ {
337- res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
338- Repo: d.name(), PageSize: pageSize, PageToken: tok,
339- }))
337+ res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
338+ PageSize: pageSize, PageToken: tok,
339+ })
340340 if err != nil {
341341 return nil, 0, err
342342 }
343- for _, m := range res.Msg.GetMergeRequests() {
344- if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
343+ for _, m := range res.MergeRequests {
344+ if m.State != domain.MRStateOpen {
345345 p.Done = append(p.Done, s.mrRow(d.name(), m))
346346 }
347347 }
348- tok = res.Msg.GetNextPageToken()
348+ tok = res.NextPageToken
349349 if tok == "" || len(p.Done) >= mrPerPage {
350350 break
351351 }
@@ -363,10 +363,10 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component,
363363 p.Done[0].Cursor = true
364364 }
365365 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 {
367367 continue
368368 }
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)})
370370 }
371371
372372 l := s.repoLayout(ctx, d, "merge-requests")
@@ -391,6 +391,7 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
391391 return s.mrPage(ctx, r, true)
392392 }
393393
394+//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
394395 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
395396 n, err := mrNumber(r)
396397 if err != nil {
@@ -404,79 +405,72 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
404405 if err != nil {
405406 return nil, 0, err
406407 }
407- res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
408- Repo: d.name(), Number: n, SinceLastRead: since,
409- }))
408+ res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since)
410409 if err != nil {
411410 return nil, 0, err
412411 }
413- m := res.Msg.GetMergeRequest()
414- if m == nil {
415- return nil, 0, errNotFound
416- }
412+ m := res.MergeRequest
417413 now := s.cfg.Now()
418414 signed := SignedIn(ctx)
419- open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
420- head := m.GetHeadId()
415+ open := m.State == domain.MRStateOpen
416+ head := m.HeadID.String()
421417 self := mrHref(d.name(), n)
422418 if since {
423419 self = mrHref(d.name(), n, "since")
424420 }
425421
426422 p := MRPage{
427- Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
428- State: stateName(m.GetState()), Open: open, Signed: signed,
429- Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
430- Author: visibleText(m.GetAuthor()),
431- Desc: plainParts(m.GetDescription()),
423+ Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
424+ State: stateName(m.State), Open: open, Signed: signed,
425+ Source: visibleText(m.Source), Target: visibleText(m.Target),
426+ Author: visibleText(m.Author),
427+ Desc: plainParts(m.Description.String()),
432428 Head: head, HeadShort: short(head),
433- NFiles: int(res.Msg.GetStats().GetFilesChanged()),
434- Adds: int(res.Msg.GetStats().GetAdditions()),
435- Dels: int(res.Msg.GetStats().GetDeletions()),
436- Files: diffFiles(res.Msg.GetDiff().GetFiles()),
429+ NFiles: res.Stats.FilesChanged,
430+ Adds: res.Stats.Additions,
431+ Dels: res.Stats.Deletions,
432+ Files: diffFiles(res.Diff.Files),
437433 }
438434 p.Opened.Name = p.Author
439- p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
440- if t := m.GetCreatedAt(); t.IsValid() {
441- p.Opened.Abs = absolute(t.AsTime())
435+ p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
436+ if !m.CreatedAt.IsZero() {
437+ p.Opened.Abs = absolute(m.CreatedAt)
442438 }
443- p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
439+ p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
444440 for _, b := range d.branches {
445- switch b.GetShortName() {
446- case m.GetSourceBranch():
447- p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
448- case m.GetTargetBranch():
449- p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
441+ switch b.ShortName {
442+ case m.Source:
443+ p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
444+ case m.Target:
445+ p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
450446 }
451447 }
452- if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
453- p.MergedShort = short(m.GetMergedCommitId())
454- p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
455- p.MergeMode = modeName(m.GetMergeMode())
448+ if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
449+ p.MergedShort = short(m.MergedCommitID.String())
450+ p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
451+ p.MergeMode = modeName(m.MergeMode)
456452 }
457- if st := d.repo.GetSettings(); st != nil {
458- for _, mode := range st.GetAllowedMergeModes() {
459- if name := modeName(mode); name != "" {
460- p.Modes = append(p.Modes, name)
461- }
453+ for _, mode := range d.repo.Settings.AllowedMergeModes {
454+ if name := modeName(mode); name != "" {
455+ p.Modes = append(p.Modes, name)
462456 }
463- p.ChecksBlock = st.GetFailingChecksBlock()
464457 }
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)
466460
467461 // The commits of the request, newest first.
468- for _, c := range res.Msg.GetCommits() {
462+ for _, c := range res.Commits {
469463 row := commitRow(d.name(), c, now)
470464 p.Commits = append(p.Commits, MRCommit{
471465 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
472466 })
473467 }
474- lastRead := res.Msg.GetLastReadId()
468+ lastRead := res.LastReadID.String()
475469 if signed && lastRead != "" {
476470 // The commits are newest first: those before the last read head are new.
477471 // A head that is not among them (the branch was rewritten) marks none.
478- for i, c := range res.Msg.GetCommits() {
479- if c.GetId() != lastRead {
472+ for i, c := range res.Commits {
473+ if c.ID.String() != lastRead {
480474 continue
481475 }
482476 for j := 0; j < i; j++ {
@@ -484,7 +478,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
484478 }
485479 }
486480 }
487- if res.Msg.GetCommitsTruncated() {
481+ if res.CommitsTruncated {
488482 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
489483 }
490484
@@ -502,7 +496,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
502496 switch {
503497 case lastRead == "":
504498 p.SinceNote = "nothing was marked read yet: this is the whole change."
505- case res.Msg.GetLastReadLost():
499+ case res.LastReadLost:
506500 p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
507501 case len(p.Files) == 0:
508502 p.SinceNote = "nothing changed since " + short(lastRead) + "."
@@ -521,12 +515,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
521515 if canWrite {
522516 on = parseOn(r.URL.Query().Get("on"))
523517 }
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)
525519 lines, invisible := diffLines(p.Files)
526520 p.Invisible = invisible
527521 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
528522 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)
530524 }
531525 if canWrite {
532526 p.GeneralForm = &CommentForm{
@@ -537,8 +531,8 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
537531 l := s.repoLayout(ctx, d, "merge-requests")
538532 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
539533 if head != "" {
540- pinRef(&l, target{rev: head, name: m.GetSourceBranch()})
541- if !d.hasBranch(m.GetSourceBranch()) {
534+ pinRef(&l, target{rev: head, name: m.Source})
535+ if !d.hasBranch(m.Source) {
542536 pinRef(&l, target{rev: head, name: short(head), detached: true})
543537 }
544538 }
@@ -551,7 +545,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
551545 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
552546 }
553547 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))
555549 l.HasRail = true
556550 return MergeRequest(l, p), http.StatusOK, nil
557551 }
@@ -559,7 +553,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
559553 // hasBranch tells whether the repository has the branch.
560554 func (d *repoData) hasBranch(name string) bool {
561555 for _, b := range d.branches {
562- if b.GetShortName() == name {
556+ if b.ShortName == name {
563557 return true
564558 }
565559 }
@@ -572,19 +566,17 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *
572566 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
573567 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
574568 CanMarkRead: head != "" && head != lastRead,
575- CanDeleteBranch: source != d.repo.GetDefaultBranch(),
569+ CanDeleteBranch: source != d.repo.DefaultBranch,
576570 }
577571 ff, sq := false, false
578- if st := d.repo.GetSettings(); st != nil {
579- for _, mode := range st.GetAllowedMergeModes() {
580- switch mode {
581- case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
582- ff = true
583- b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
584- case pb.MergeMode_MERGE_MODE_SQUASH:
585- sq = true
586- b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
587- }
572+ for _, mode := range d.repo.Settings.AllowedMergeModes {
573+ switch mode {
574+ case domain.MergeModeFastForward:
575+ ff = true
576+ b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
577+ case domain.MergeModeSquash:
578+ sq = true
579+ b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
588580 }
589581 }
590582 if head != "" {
@@ -637,14 +629,16 @@ func parseOn(s string) *lineRef {
637629 // the target, so a page that shows another diff (since the last read) lists
638630 // them all below, with their line. A reader who can write gets the line numbers
639631 // 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) {
641635 now := s.cfg.Now()
642636 self := mrHref(repo, n)
643637 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
644638
645639 type lineKey struct {
646640 path string
647- side pb.DiffSide
641+ side domain.DiffSide
648642 line int
649643 }
650644 index := map[lineKey]*DiffLine{}
@@ -657,13 +651,13 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
657651 for li := range f.Hunks[hi].Lines {
658652 ln := &f.Hunks[hi].Lines[li]
659653 if ln.oldN > 0 && ln.Kind != "add" {
660- index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
654+ index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
661655 if linkable {
662656 ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
663657 }
664658 }
665659 if ln.newN > 0 && ln.Kind != "del" {
666- index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
660+ index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
667661 if linkable {
668662 ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
669663 }
@@ -671,7 +665,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
671665 }
672666 }
673667 }
674- find := func(path string, side pb.DiffSide, line int) *DiffLine {
668+ find := func(path string, side domain.DiffSide, line int) *DiffLine {
675669 if !inline {
676670 return nil
677671 }
@@ -679,34 +673,35 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
679673 }
680674
681675 for _, c := range comments {
676+ cid := strconv.FormatInt(c.ID, 10)
682677 v := CommentView{
683- ID: c.GetId(), Author: visibleText(c.GetAuthor()),
684- Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
685- Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
678+ ID: cid, Author: visibleText(c.Author),
679+ Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
680+ Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
686681 }
687- v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
688- if t := c.GetCreatedAt(); t.IsValid() {
689- v.Abs = absolute(t.AsTime())
682+ v.When, v.WhenAt = whenOf(now, c.CreatedAt)
683+ if !c.CreatedAt.IsZero() {
684+ v.Abs = absolute(c.CreatedAt)
690685 }
691686 p.NComments++
692- if !c.GetResolved() {
687+ if !c.Resolved {
693688 p.NUnresolved++
694689 }
695- general := c.GetPath() == ""
690+ general := c.Path == ""
696691 var ln *DiffLine
697692 if !general {
698- ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
693+ ln = find(c.Path, c.Side, c.Line)
699694 if ln == nil {
700695 side := "new"
701- if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
696+ if c.Side == domain.SideOld {
702697 side = "old"
703698 }
704- v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
699+ v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
705700 }
706701 }
707702 if canWrite {
708- v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"}
709- if c.GetResolved() {
703+ v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
704+ if c.Resolved {
710705 v.Resolve.Label = "Unresolve"
711706 }
712707 }
@@ -721,9 +716,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
721716 }
722717
723718 if on != nil {
724- side := pb.DiffSide_DIFF_SIDE_NEW
719+ side := domain.SideNew
725720 if on.side == "old" {
726- side = pb.DiffSide_DIFF_SIDE_OLD
721+ side = domain.SideOld
727722 }
728723 if ln := find(on.path, side, on.line); ln != nil {
729724 ln.Form = &CommentForm{
M internal/adapters/web/mr_post.go +139 -71
@@ -5,24 +5,23 @@ package web
55 import (
66 "context"
77 "errors"
8- "fmt"
98 "net/http"
109 "strconv"
1110 "strings"
1211
13- "connectrpc.com/connect"
1412 "github.com/bnema/zerowrap"
1513
16- pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
1716 )
1817
1918 // The writes of the merge request pages. Each is a POST that passed the gate
20-// (an Origin of this site and the owner's session), calls the API as the
19+// (an Origin of this site and the owner's session), calls the use case as the
2120 // owner, and answers 303 to the page that shows the result, with
22-// "Cache-Control: no-store". A request the API refuses is answered with a
23-// state page that says why, in the words of the API for the rules the owner
24-// can fix (a text rule, a moved branch, a full merge request), and without
25-// detail for anything else.
21+// "Cache-Control: no-store". A request the use case refuses is answered with a
22+// state page that says why, in the words of the use case for the rules the
23+// owner can fix (a text rule, a moved branch, a full merge request), and
24+// without detail for anything else.
2625
2726 // maxFormBytes bounds the body of a form: a description of 8,000 characters is
2827 // at most 32,000 bytes.
@@ -44,8 +43,8 @@ func seeOther(w http.ResponseWriter, r *http.Request, to string) {
4443 http.Redirect(w, r, to, http.StatusSeeOther)
4544 }
4645
47-// mrAction wraps a POST handler: it bounds the API calls, reads the form and
48-// maps the error of the API to a page.
46+// mrAction wraps a POST handler: it bounds the use case calls, reads the form
47+// and maps the error of the use case to a page.
4948 func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
5049 return func(w http.ResponseWriter, r *http.Request) {
5150 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
6463
6564 // actionFailed answers a refused action.
6665 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
67- msg := ""
68- var ce *connect.Error
69- if errors.As(err, &ce) {
70- msg = ce.Message()
71- }
72- switch code := connect.CodeOf(err); {
73- case code == connect.CodeNotFound:
66+ // The words of the use case, for the rules the owner can fix.
67+ msg := err.Error()
68+ // The order is that of the API's mapping of the domain errors to codes
69+ // (rpc.codeOf), so that a web action answers what the API would.
70+ switch {
71+ case errors.Is(err, context.Canceled):
72+ // The browser is gone.
73+ case errors.Is(err, domain.ErrNotFound):
7474 w.Header().Set("Cache-Control", "no-store")
7575 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
76- case code == connect.CodeInvalidArgument:
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:
76+ case errors.Is(err, domain.ErrAlreadyExists):
8177 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
82- case code == connect.CodeResourceExhausted:
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:
78+ case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
8579 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:
87- // The browser is gone.
80+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
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)
8886 default:
89- id := requestID()
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))
87+ s.internalFailure(ctx, w, r, err)
9488 }
9589 }
9690
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,
98102 // bounded, and a fallback for an empty one.
99103 func rule(msg, fallback string) string {
100104 msg = strings.TrimSpace(visibleText(msg))
@@ -126,69 +130,116 @@ func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string,
126130 repo := r.PathValue("repo")
127131 source := r.PostFormValue("source")
128132 back := repoHref(repo, "compare", source)
129- res, err := s.cfg.MergeRequests.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
130- Repo: repo, Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
131- SourceBranch: source, TargetBranch: r.PostFormValue("target"),
132- }))
133+ name, err := domain.ParseRepoName(repo)
134+ if err != nil {
135+ return "", back, err
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+ })
133141 if err != nil {
134142 return "", back, err
135143 }
136- return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil
144+ return mrHref(repo, mr.Number), back, nil
137145 }
138146
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+
139155 // mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
140156 // one on a line (path, line and side).
141157 func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
142158 repo := r.PathValue("repo")
143159 n, err := number(r)
144160 if err != nil {
145- return "", "", connect.NewError(connect.CodeNotFound, err)
161+ return "", "", err
146162 }
147163 back := mrHref(repo, n)
148- req := &pb.AddCommentRequest{Repo: repo, Number: n, Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
149- if req.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
164+ params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
165+ if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
150166 line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
151167 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")
153169 }
154- req.Line = int32(line)
170+ params.Line = int(line)
155171 switch r.PostFormValue("side") {
156172 case "new":
157- req.Side = pb.DiffSide_DIFF_SIDE_NEW
173+ params.Side = domain.SideNew
158174 case "old":
159- req.Side = pb.DiffSide_DIFF_SIDE_OLD
175+ params.Side = domain.SideOld
160176 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")
162178 }
163179 }
164- res, err := s.cfg.MergeRequests.AddComment(ctx, connect.NewRequest(req))
180+ name, err := domain.ParseRepoName(repo)
165181 if err != nil {
166182 return "", back, err
167183 }
168- return back + "#c" + res.Msg.GetComment().GetId(), back, nil
184+ c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
185+ if err != nil {
186+ return "", back, err
187+ }
188+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
169189 }
170190
171191 // mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
172192 //
173-//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into an API call; the state of the comment is decided by the API
193+//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into a use case call; the state of the comment is decided by the use case
174194 func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
175195 repo := r.PathValue("repo")
176196 n, err := number(r)
177197 if err != nil {
178- return "", "", connect.NewError(connect.CodeNotFound, err)
198+ return "", "", err
179199 }
180200 back := mrHref(repo, n)
181201 resolved, err := formBool(r.PostFormValue("resolved"))
182202 if err != nil {
183- return "", back, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("resolved: %w", err))
203+ return "", back, formError("resolved: " + err.Error())
184204 }
185- res, err := s.cfg.MergeRequests.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{
186- Repo: repo, Number: n, CommentId: r.PathValue("id"), Resolved: resolved,
187- }))
205+ name, err := domain.ParseRepoName(repo)
188206 if err != nil {
189207 return "", back, err
190208 }
191- return back + "#c" + res.Msg.GetComment().GetId(), back, nil
209+ // An id this server never issued is 0: the use case answers not found,
210+ // after the repository and the scope were checked, as for any unknown
211+ // comment.
212+ c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
213+ if err != nil {
214+ return "", back, err
215+ }
216+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
217+}
218+
219+// commentID parses the id of a comment in a path: a positive decimal number,
220+// written without sign or leading zeros. Anything else is 0, which names no
221+// comment.
222+func commentID(s string) int64 {
223+ n, err := strconv.ParseInt(s, 10, 64)
224+ if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
225+ return 0
226+ }
227+ return n
228+}
229+
230+// headID parses a commit id of a form; the empty string is the zero id.
231+//
232+//hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here
233+func headID(s string) (domain.ObjectID, error) {
234+ if s == "" {
235+ return domain.ObjectID{}, nil
236+ }
237+ var v domain.Violations
238+ id, err := domain.ParseObjectID(s)
239+ if err != nil {
240+ v.Add("head_id", "is not a full commit id")
241+ }
242+ return id, v.Err()
192243 }
193244
194245 // mrClose is POST /<repo>/merge-requests/<n>/close.
@@ -196,10 +247,14 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string,
196247 repo := r.PathValue("repo")
197248 n, err := number(r)
198249 if err != nil {
199- return "", "", connect.NewError(connect.CodeNotFound, err)
250+ return "", "", err
200251 }
201252 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 {
203258 return "", back, err
204259 }
205260 return back, back, nil
@@ -213,22 +268,29 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
213268 repo := r.PathValue("repo")
214269 n, err := number(r)
215270 if err != nil {
216- return "", "", connect.NewError(connect.CodeNotFound, err)
271+ return "", "", err
217272 }
218273 back := mrHref(repo, n)
219- var mode pb.MergeMode
274+ var mode domain.MergeMode
220275 switch r.PostFormValue("mode") {
221276 case "fast-forward":
222- mode = pb.MergeMode_MERGE_MODE_FAST_FORWARD
277+ mode = domain.MergeModeFastForward
223278 case "squash":
224- mode = pb.MergeMode_MERGE_MODE_SQUASH
279+ mode = domain.MergeModeSquash
225280 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")
227282 }
228- if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
229- Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"),
230- DeleteSourceBranch: r.PostFormValue("delete_branch") == "1",
231- })); err != nil {
283+ name, err := domain.ParseRepoName(repo)
284+ if err != nil {
285+ return "", back, err
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 {
232294 return "", back, err
233295 }
234296 return back, back, nil
@@ -240,12 +302,18 @@ func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, e
240302 repo := r.PathValue("repo")
241303 n, err := number(r)
242304 if err != nil {
243- return "", "", connect.NewError(connect.CodeNotFound, err)
305+ return "", "", err
244306 }
245307 back := mrHref(repo, n)
246- if _, err := s.cfg.MergeRequests.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{
247- Repo: repo, Number: n, HeadId: r.PostFormValue("head_id"),
248- })); err != nil {
308+ name, err := domain.ParseRepoName(repo)
309+ if 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 {
249317 return "", back, err
250318 }
251319 return back, back, nil
@@ -263,7 +331,7 @@ func formBool(s string) (bool, error) {
263331 }
264332
265333 // normalizeNewlines turns the CRLF of a browser textarea into LF, the line
266-// break the API stores.
334+// break the use case stores.
267335 func normalizeNewlines(s string) string {
268336 return strings.ReplaceAll(s, "\r\n", "\n")
269337 }
M internal/adapters/web/raw.go +37 -18
@@ -4,6 +4,8 @@ package web
44
55 import (
66 "context"
7+ "errors"
8+ "io"
79 "mime"
810 "net/http"
911 "path"
@@ -11,11 +13,16 @@ import (
1113 "time"
1214
1315 "github.com/bnema/zerowrap"
16+
17+ "bnema.dev/ermite/internal/domain"
1418 )
1519
1620 // rawTimeout bounds the stream of one file to the visitor.
1721 const rawTimeout = 5 * time.Minute
1822
23+// rawChunkSize is the size of the writes that stream a file to the visitor.
24+const rawChunkSize = 64 << 10
25+
1926 // rawCSP makes a raw file inert if a browser opens it as a document: it can
2027 // load nothing and run nothing, whatever it contains.
2128 const rawCSP = "default-src 'none'; sandbox"
@@ -24,6 +31,8 @@ const rawCSP = "default-src 'none'; sandbox"
2431 // image types (by their bytes, never SVG) as images, and anything else as an
2532 // attachment of unknown type. A file is never served as HTML or SVG, so what
2633 // 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
2736 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
2837 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
2938 defer cancel()
@@ -49,7 +58,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
4958 h.Set("Content-Type", typ)
5059 h.Set("X-Content-Type-Options", "nosniff")
5160 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))
5362 if attach {
5463 name := path.Base(t.path)
5564 cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
@@ -58,13 +67,13 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
5867 }
5968 h.Set("Content-Disposition", cd)
6069 }
61- if etag := b.info.GetId(); etag != "" {
70+ if etag := b.info.ID.String(); etag != "" {
6271 h.Set("ETag", `"`+etag+`"`)
6372 }
6473 // Revalidated every time, even at a full commit id: a repository can turn
6574 // private, and a shared cache must not keep serving its files (AC2). The
66- // ETag spares the body: the blob id comes with the first message of
67- // the stream, and a match ends the answer there. No cheaper call gives the
75+ // ETag spares the body: the blob id comes with the metadata, before the
76+ // content, and a match ends the answer there. No cheaper call gives the
6877 // id of the blob at a path: a tree listing of its directory costs more
6978 // than opening a small file.
7079 cacheHeaders(ctx, h, "no-cache", sessionCache)
@@ -80,31 +89,41 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
8089 return
8190 }
8291 b.buf = nil
83- for b.stream.Receive() {
84- if _, err := w.Write(b.stream.Msg().GetChunk()); err != nil {
92+ if b.eof {
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) {
85104 return
86105 }
87- }
88- if err := b.stream.Err(); err != nil {
89- // Headers are gone: all that is left is to cut the answer short, so
90- // that the client sees it is incomplete.
91- log := zerowrap.FromCtx(ctx)
92- log.Error().Err(err).Str("repo", repo).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
93- panic(http.ErrAbortHandler)
106+ if rerr != nil {
107+ // Headers are gone: all that is left is to cut the answer short,
108+ // so that the client sees it is incomplete.
109+ log := zerowrap.FromCtx(ctx)
110+ log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
111+ panic(http.ErrAbortHandler)
112+ }
94113 }
95114 }
96115
97116 // 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) {
99118 d, err := s.loadRepo(ctx, r.PathValue("repo"))
100119 if err != nil {
101- return target{}, "", err
120+ return target{}, domain.RepoName{}, err
102121 }
103122 t, ok := d.locate(r.PathValue("rest"))
104123 if !ok || t.path == "" {
105- return target{}, "", errNotFound
124+ return target{}, domain.RepoName{}, errNotFound
106125 }
107- return t, d.name(), nil
126+ return t, d.repo.Name, nil
108127 }
109128
110129 // 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) {
113132 if img := sniffImage(b.buf); img != "" {
114133 return img, false
115134 }
116- if !b.info.GetBinary() {
135+ if !b.info.Binary {
117136 return "text/plain; charset=utf-8", false
118137 }
119138 return "application/octet-stream", true
M internal/adapters/web/raw_test.go +33 -44
@@ -4,39 +4,50 @@ package web
44
55 import (
66 "context"
7+ "io"
78 "net/http"
89 "net/http/httptest"
10+ "strings"
911 "testing"
1012
11- "connectrpc.com/connect"
1213 "github.com/stretchr/testify/assert"
1314 "github.com/stretchr/testify/mock"
14- "github.com/stretchr/testify/require"
1515
16- pb "bnema.dev/ermite-api/gen/ermite"
17- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
18- "bnema.dev/ermite/internal/adapters/web/mocks"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
1919 )
2020
21-const blobID = "0123456789abcdef0123456789abcdef01234567"
22-
23-// blobAPI serves one text file, the only call raw makes besides the refs.
24-type blobAPI struct {
25- ermiteconnect.UnimplementedGitServiceHandler
26-}
21+const (
22+ blobID = "0123456789abcdef0123456789abcdef01234567"
23+ mainTip = "c0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe"
24+ blobBody = "hello"
25+)
2726
28-func (*blobAPI) GetBlob(_ context.Context, req *connect.Request[pb.GetBlobRequest], st *connect.ServerStream[pb.GetBlobResponse]) error {
29- if req.Msg.GetPath() != "docs/a.txt" {
30- return connect.NewError(connect.CodeNotFound, assert.AnError)
31- }
32- return st.Send(&pb.GetBlobResponse{
33- Info: &pb.BlobInfo{Id: blobID, Path: "docs/a.txt", Size: 5, MediaType: "text/plain"},
34- Chunk: []byte("hello"),
35- })
27+// rawBrowse serves one text file, the only call raw makes besides the refs.
28+func rawBrowse(t *testing.T) *mocks.BrowseService {
29+ t.Helper()
30+ browse := mocks.NewBrowseService(t)
31+ browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{
32+ Refs: []domain.Ref{{
33+ Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch,
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
3646 }
3747
3848 // 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.
4051 func TestRawRevalidation(t *testing.T) {
4152 t.Parallel()
4253 tests := []struct {
@@ -58,19 +69,8 @@ func TestRawRevalidation(t *testing.T) {
5869 for _, tt := range tests {
5970 t.Run(tt.name, func(t *testing.T) {
6071 t.Parallel()
61- repos := mocks.NewRepoServiceClient(t)
62- repos.EXPECT().GetRepo(mock.Anything, mock.Anything).
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)
72+ repos := reposServing(t, publicRepo(t, "r"))
73+ srv := newTestServer(t, Config{Repos: repos, Browse: rawBrowse(t)})
7474 req := httptest.NewRequest(tt.method, tt.url, nil)
7575 if tt.ifNone != "" {
7676 req.Header.Set("If-None-Match", tt.ifNone)
@@ -93,14 +93,3 @@ func TestRawRevalidation(t *testing.T) {
9393 })
9494 }
9595 }
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 (
66 "net/url"
77 "strings"
88
9- pb "bnema.dev/ermite-api/gen/ermite"
9+ "bnema.dev/ermite/internal/domain"
1010 )
1111
1212 // resolveRef finds the ref a URL path starts with and the path that follows.
@@ -14,7 +14,9 @@ import (
1414 // that is the short name of a branch or tag wins. A branch wins over a tag of
1515 // the same name. rest has no leading slash and is "" when the path is the ref
1616 // 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) {
1820 names := refsByName(refs)
1921 p = strings.Trim(p, "/")
2022 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) {
2224 return r, strings.TrimPrefix(p[end:], "/"), true
2325 }
2426 }
25- return nil, "", false
27+ return domain.Ref{}, "", false
2628 }
2729
2830 // refsByName indexes the branches and tags by short name. A branch wins over
2931 // a tag of the same name.
30-func refsByName(refs []*pb.Ref) map[string]*pb.Ref {
31- names := make(map[string]*pb.Ref, len(refs))
32+func refsByName(refs []domain.Ref) map[string]domain.Ref {
33+ names := make(map[string]domain.Ref, len(refs))
3234 for _, r := range refs {
33- k := r.GetKind()
34- if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG {
35+ k := r.Kind
36+ if k != domain.RefKindBranch && k != domain.RefKindTag {
3537 continue
3638 }
37- cur, dup := names[r.GetShortName()]
38- if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH {
39- names[r.GetShortName()] = r
39+ cur, dup := names[r.ShortName]
40+ if !dup || cur.Kind != domain.RefKindBranch {
41+ names[r.ShortName] = r
4042 }
4143 }
4244 return names
@@ -68,8 +70,8 @@ func repoHref(repo string, parts ...string) string {
6870 // target is what the URL of a tree, blob, raw or commits page designates: a
6971 // commit, the name it was reached by, and a path in its tree.
7072 type target struct {
71- // rev is what the API is asked for: the id of the commit of a ref, or
72- // the commit id of the URL itself.
73+ // rev is what the use cases are asked for: the id of the commit of a ref,
74+ // or the commit id of the URL itself.
7375 rev string
7476 // name is how the URL spells the ref: a branch or a tag, or the commit id
7577 // when detached.
@@ -83,17 +85,17 @@ type target struct {
8385 // longest prefix of the path that is the short name of a branch or a tag
8486 // (names may contain "/"); a branch wins over a tag of the same name. An
8587 // 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.
8789 func (d *repoData) locate(rest string) (target, bool) {
8890 rest = strings.Trim(rest, "/")
8991 if rest == "" {
9092 if d.head == nil {
9193 return target{}, false
9294 }
93- return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
95+ return target{rev: d.head.CommitID.String(), name: d.head.ShortName}, true
9496 }
95- if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" {
96- return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
97+ if ref, p, ok := resolveRef(d.allRefs(), rest); ok && !ref.CommitID.IsZero() {
98+ return target{rev: ref.CommitID.String(), name: ref.ShortName, path: p}, true
9799 }
98100 first, p, _ := strings.Cut(rest, "/")
99101 if isCommitID(first) {
@@ -123,7 +125,7 @@ func (d *repoData) isBranchHead(t target) bool {
123125 return false
124126 }
125127 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 {
127129 return true
128130 }
129131 }
M internal/adapters/web/refs_test.go +26 -12
@@ -3,28 +3,42 @@
33 package web
44
55 import (
6+ "crypto/sha1" // #nosec G505 -- ids for tests
7+ "encoding/hex"
68 "testing"
79
810 "github.com/stretchr/testify/assert"
911
10- pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
1113 )
1214
13-func ref(kind pb.RefKind, short string) *pb.Ref {
14- return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short}
15+// commitOf is a commit id derived from a name, so that two refs of one name
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}
1529 }
1630
1731 // AC3: a ref with slashes is found by the longest matching prefix.
1832 func TestResolveRef(t *testing.T) {
1933 t.Parallel()
20- b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
21- refs := []*pb.Ref{
34+ b, tag := domain.RefKindBranch, domain.RefKindTag
35+ refs := []domain.Ref{
2236 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"),
2438 }
2539 tests := []struct {
2640 path, ref, rest string
27- kind pb.RefKind
41+ kind domain.RefKind
2842 ok bool
2943 }{
3044 {"main", "main", "", b, true},
@@ -34,9 +48,9 @@ func TestResolveRef(t *testing.T) {
3448 {"feature/other", "feature", "other", b, true},
3549 {"/feature/x/", "feature/x", "", b, true},
3650 {"v1/rc/README", "v1/rc", "README", tag, true},
37- {"v1/README", "", "", 0, false},
38- {"nope/main", "", "", 0, false},
39- {"", "", "", 0, false},
51+ {"v1/README", "", "", "", false},
52+ {"nope/main", "", "", "", false},
53+ {"", "", "", "", false},
4054 }
4155 for _, tt := range tests {
4256 t.Run(tt.path, func(t *testing.T) {
@@ -46,8 +60,8 @@ func TestResolveRef(t *testing.T) {
4660 if !ok {
4761 return
4862 }
49- assert.Equal(t, tt.ref, r.GetShortName())
50- assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name")
63+ assert.Equal(t, tt.ref, r.ShortName)
64+ assert.Equal(t, tt.kind, r.Kind, "a branch wins over a tag of the same name")
5165 assert.Equal(t, tt.rest, rest)
5266 })
5367 }
M internal/adapters/web/refspage.go +16 -15
@@ -9,7 +9,7 @@ import (
99
1010 "github.com/a-h/templ"
1111
12- pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
1313 )
1414
1515 // 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
3333 // refsPage is the content of the refs page, which the live updates of the
3434 // page render again.
3535 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())
3737
38- def := d.repo.GetDefaultBranch()
38+ def := d.repo.DefaultBranch
3939 p := RefsPage{Default: def}
40- branches := append([]*pb.Ref(nil), d.branches...)
40+ branches := append([]domain.Ref(nil), d.branches...)
4141 sort.SliceStable(branches, func(i, j int) bool {
42- a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def
42+ a, b := branches[i].ShortName == def, branches[j].ShortName == def
4343 return a && !b
4444 })
4545 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))
4747 }
48- tags := append([]*pb.Ref(nil), d.tags...)
48+ tags := append([]domain.Ref(nil), d.tags...)
4949 sort.SliceStable(tags, func(i, j int) bool {
50- return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at
50+ return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at
5151 })
5252 for i, t := range tags {
5353 p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0))
5454 }
5555 if len(tags) > 0 {
56- p.Latest = tags[0].GetShortName()
56+ p.Latest = tags[0].ShortName
5757 }
5858 if d.cut {
5959 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 {
6161 return p
6262 }
6363
64-func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
65- c := commits[r.GetCommitId()]
64+func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
65+ id := r.CommitID.String()
66+ c := commits[id]
6667 row := RefRow{
67- Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()),
68- Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt,
68+ Name: r.ShortName, Href: repoHref(d.name(), "tree", r.ShortName),
69+ Short: short(id), Subject: c.subject, When: c.when, WhenAt: c.whenAt,
6970 Default: isDefault, Cursor: cursor,
7071 }
71- if r.GetCommitId() != "" {
72- row.CHref = repoHref(d.name(), "commit", r.GetCommitId())
72+ if id != "" {
73+ row.CHref = repoHref(d.name(), "commit", id)
7374 }
7475 return row
7576 }
M internal/adapters/web/repo.go +83 -55
@@ -10,10 +10,8 @@ import (
1010 "sync"
1111 "time"
1212
13- "connectrpc.com/connect"
14- "google.golang.org/protobuf/types/known/timestamppb"
15-
16- pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
1715 )
1816
1917 // maxRefs bounds the refs one page reads. A repository with more has its
@@ -23,47 +21,69 @@ const maxRefs = 1000
2321 // repoData is what the pages of one repository start from: its metadata and
2422 // its refs, read once per request.
2523 type repoData struct {
26- repo *pb.Repo
27- branches []*pb.Ref
28- tags []*pb.Ref
24+ repo ports.RepoView
25+ branches []domain.Ref
26+ tags []domain.Ref
2927 // cut is true when the repository has more refs than maxRefs.
3028 cut bool
3129 // head is the branch the repository opens on: its default branch, or the
3230 // first one when the default does not exist. nil for an empty repository.
33- head *pb.Ref
31+ head *domain.Ref
32+}
33+
34+// repoView reads a repository as the caller of the request may see it, with
35+// the Git state of its summary. A missing or private repository is
36+// domain.ErrNotFound.
37+func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) {
38+ repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
39+ if err != nil {
40+ return ports.RepoView{}, err
41+ }
42+ view, err := s.cfg.Repos.View(ctx, repo)
43+ if err != nil {
44+ return ports.RepoView{}, err
45+ }
46+ if view.Settings.IsZero() {
47+ view.Settings = domain.DefaultRepoSettings()
48+ }
49+ return view, nil
3450 }
3551
3652 // loadRepo reads a repository and its refs. A missing, private or invalidly
37-// named repository is a not_found / invalid_argument error of the API, which
38-// the caller turns into the 404 page.
39-func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
40- res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
53+// named repository is a domain.ErrNotFound / invalid name error, which the
54+// caller turns into the 404 page.
55+func (s *Server) loadRepo(ctx context.Context, rawName string) (*repoData, error) {
56+ name, err := domain.ParseRepoName(rawName)
57+ if err != nil {
58+ return nil, err
59+ }
60+ view, err := s.repoView(ctx, name)
4161 if err != nil {
4262 return nil, err
4363 }
44- d := &repoData{repo: res.Msg.GetRepo()}
64+ d := &repoData{repo: view}
4565 // Each page of ListRefs runs the whole for-each-ref again: the refs are
46- // asked in one page of up to maxRefs (the API lowers it to its cap, and
47- // the token then leads to the rest).
66+ // asked in one page of up to maxRefs (the use case lowers it to its cap,
67+ // and the token then leads to the rest).
4868 token, n := "", 0
4969 for {
50- page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{
51- Repo: name, PageSize: maxRefs, PageToken: token,
52- Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG},
53- }))
70+ page, err := s.cfg.Browse.ListRefs(ctx, principal(ctx), name, ports.ListRefsParams{
71+ Kinds: []domain.RefKind{domain.RefKindBranch, domain.RefKindTag},
72+ PageSize: maxRefs, PageToken: token,
73+ })
5474 if err != nil {
5575 return nil, err
5676 }
57- for _, r := range page.Msg.GetRefs() {
58- switch r.GetKind() {
59- case pb.RefKind_REF_KIND_BRANCH:
77+ for _, r := range page.Refs {
78+ switch r.Kind {
79+ case domain.RefKindBranch:
6080 d.branches = append(d.branches, r)
61- case pb.RefKind_REF_KIND_TAG:
81+ case domain.RefKindTag:
6282 d.tags = append(d.tags, r)
6383 }
6484 n++
6585 }
66- if token = page.Msg.GetNextPageToken(); token == "" {
86+ if token = page.NextPageToken; token == "" {
6787 break
6888 }
6989 if n >= maxRefs {
@@ -71,24 +91,24 @@ func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
7191 break
7292 }
7393 }
74- for _, b := range d.branches {
75- if b.GetShortName() == d.repo.GetDefaultBranch() {
76- d.head = b
94+ for i := range d.branches {
95+ if d.branches[i].ShortName == d.repo.DefaultBranch {
96+ d.head = &d.branches[i]
7797 }
7898 }
7999 if d.head == nil && len(d.branches) > 0 {
80- d.head = d.branches[0]
100+ d.head = &d.branches[0]
81101 }
82102 return d, nil
83103 }
84104
85105 // allRefs is the branches then the tags.
86-func (d *repoData) allRefs() []*pb.Ref {
87- return append(append([]*pb.Ref(nil), d.branches...), d.tags...)
106+func (d *repoData) allRefs() []domain.Ref {
107+ return append(append([]domain.Ref(nil), d.branches...), d.tags...)
88108 }
89109
90110 // 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() }
92112
93113 // layout is the Layout of the pages of a repository: the sections, the ref
94114 // 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() }
96116 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
97117 l := s.base()
98118 l.Repo = d.name()
99- def := d.repo.GetDefaultBranch()
119+ def := d.repo.DefaultBranch
100120 if d.head != nil {
101- def = d.head.GetShortName()
121+ def = d.head.ShortName
102122 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())
104124 }
105125 mk := func(key, label, href, id string) Section {
106126 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
122142 }
123143 if l.Ref != "" {
124144 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}
126146 l.Heads = append(l.Heads, it)
127147 l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
128148 }
129149 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)}
131151 l.Tags = append(l.Tags, it)
132152 l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
133153 }
@@ -153,7 +173,7 @@ type refCommit struct {
153173
154174 // commitsOf reads the head commit of each ref, a few at a time. A ref whose
155175 // 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 {
157177 const workers = 8
158178 var (
159179 mu sync.Mutex
@@ -163,10 +183,10 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
163183 )
164184 now := s.cfg.Now()
165185 for _, r := range refs {
166- id := r.GetCommitId()
167- if id == "" {
186+ if r.CommitID.IsZero() {
168187 continue
169188 }
189+ id := r.CommitID.String()
170190 mu.Lock()
171191 _, done := out[id]
172192 if !done {
@@ -181,12 +201,12 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
181201 go func() {
182202 defer func() { <-sem; wg.Done() }()
183203 // 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, "")
185205 var rc refCommit
186- if err == nil && len(res.Msg.GetCommits()) == 1 {
187- row := commitRow(repo, res.Msg.GetCommits()[0], now)
206+ if err == nil && len(res.Commits) == 1 {
207+ row := commitRow(repo.String(), res.Commits[0], now)
188208 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)
190210 }
191211 mu.Lock()
192212 out[id] = rc
@@ -198,32 +218,40 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
198218 }
199219
200220 // whenOf is the relative and the machine form of a time; both are "" when
201-// the time is unknown.
202-func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) {
203- if !ts.IsValid() {
221+// the time is unknown (the zero time).
222+func whenOf(now time.Time, t time.Time) (when, at string) {
223+ if t.IsZero() {
204224 return "", ""
205225 }
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()
207235 }
208236
209237 // commitRow is the row every list of commits starts from: the short id, the
210238 // subject, the author and the date of the committer, all fit to display. The
211239 // 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 {
213241 row := CommitRow{
214- Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
215- CHref: repoHref(repo, "commit", c.GetId()),
216- Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
242+ Short: short(c.ID.String()), Subject: visibleText(c.Summary),
243+ CHref: repoHref(repo, "commit", c.ID.String()),
244+ Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1,
217245 }
218- row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime())
246+ row.When, row.WhenAt = whenOf(now, c.Committer.Time)
219247 return row
220248 }
221249
222250 // 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 {
224252 m := map[string][]string{}
225253 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)
227255 }
228256 for _, v := range m {
229257 sort.Strings(v)
@@ -237,7 +265,7 @@ func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage
237265 l.Title = d.name() + " · " + s.instance
238266 l.Sections = nil
239267 l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
240- branch := d.repo.GetDefaultBranch()
268+ branch := d.repo.DefaultBranch
241269 return l, StatePage{
242270 Code: "empty", Title: d.name() + " is empty",
243271 Lines: []string{"no commits yet. push a branch to create one."},
M internal/adapters/web/session.go +15 -2
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/zerowrap"
1717
1818 "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
1920 "bnema.dev/ermite/internal/ports"
2021 )
2122
@@ -30,7 +31,7 @@ import (
3031 // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
3132 // the origin of the public URL, and a valid session, except the login POST,
3233 // 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.
3435
3536 // CookieName is the session cookie. The __Host- prefix makes browsers accept
3637 // 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
126127 varyCookie(h)
127128 return r
128129 }
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
130143 }
131144
132145 // sessionStillValid checks again the cookie a long-lived request started
M internal/adapters/web/session_test.go +21 -21
@@ -16,7 +16,7 @@ import (
1616 "github.com/stretchr/testify/mock"
1717 "github.com/stretchr/testify/require"
1818
19- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+ "bnema.dev/ermite/internal/domain"
2020 "bnema.dev/ermite/internal/ports"
2121 "bnema.dev/ermite/internal/ports/mocks"
2222 )
@@ -34,9 +34,12 @@ func newVerifier(t *testing.T, valid string, verify func(token string) (bool, er
3434 }
3535 m := mocks.NewWebSessionService(t)
3636 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) {
3838 ok, err := verify(token)
39- return ctx, ok, err
39+ if !ok || err != nil {
40+ return domain.Anonymous(), false, err
41+ }
42+ return domain.Owner(), true, nil
4043 }).Maybe()
4144 return m
4245 }
@@ -72,12 +75,10 @@ func (l *countingLimiter) Fail(context.Context, string) {
7275 func newSessionServer(t *testing.T, sess ports.WebSessionService, publicURL string) (*Server, *countingLimiter) {
7376 t.Helper()
7477 lim := &countingLimiter{}
75- srv, err := New(Config{
76- Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
77- PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1",
78- Sessions: sess, Limiter: lim,
78+ srv := newTestServer(t, Config{
79+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
80+ PublicURL: publicURL, Sessions: sess, Limiter: lim,
7981 })
80- require.NoError(t, err)
8182 return srv, lim
8283 }
8384
@@ -153,12 +154,11 @@ func TestGateLoginExemptionIsExact(t *testing.T) {
153154 t.Parallel()
154155 sess := newVerifier(t, "tok", nil) // no Login is expected until the last request
155156 lim := &countingLimiter{}
156- srv, err := New(Config{
157- Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
158- MergeRequests: ermiteconnect.NewMergeRequestServiceClient(http.DefaultClient, "http://unused.invalid"),
159- PublicURL: "https://forge.example", Sessions: sess, Limiter: lim,
157+ srv := newTestServer(t, Config{
158+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
159+ MergeRequests: mocks.NewMergeRequestService(t),
160+ Sessions: sess, Limiter: lim,
160161 })
161- require.NoError(t, err)
162162 post := func(target, cookie string) int {
163163 req := httptest.NewRequest(http.MethodPost, target, nil)
164164 req.Header.Set("Origin", "https://forge.example")
@@ -206,6 +206,7 @@ func TestWithSessionFailsClosed(t *testing.T) {
206206 rec := httptest.NewRecorder()
207207 r := srv.withSession(rec, req)
208208 assert.False(t, SignedIn(r.Context()))
209+ assert.Equal(t, domain.Anonymous(), principal(r.Context()))
209210 assert.Empty(t, rec.Header().Values("Set-Cookie"))
210211
211212 srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example")
@@ -218,7 +219,10 @@ func TestWithSessionFailsClosed(t *testing.T) {
218219
219220 req = httptest.NewRequest(http.MethodGet, "/", nil)
220221 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")
222226 }
223227
224228 // GO-006: the answer to a request with an invalid cookie is not cached and
@@ -284,17 +288,13 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
284288 // Without sessions configured there is no login route and no cookie is read.
285289 func TestNoSessionsNoRoutes(t *testing.T) {
286290 t.Parallel()
287- srv, err := New(Config{
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)
291+ srv := newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)})
292292 rec := httptest.NewRecorder()
293293 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil))
294294 assert.Equal(t, 404, rec.Code)
295295
296- _, err = New(Config{
297- Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
296+ _, err := New(Config{
297+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
298298 PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t),
299299 })
300300 assert.Error(t, err, "sessions need a limiter")
M internal/adapters/web/summary.go +53 -52
@@ -7,15 +7,16 @@ import (
77 "fmt"
88 "html"
99 "html/template"
10+ "io"
1011 "net/http"
1112 "strings"
1213 "time"
1314
14- "connectrpc.com/connect"
1515 "github.com/a-h/templ"
1616 "github.com/bnema/zerowrap"
1717
18- pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
1920 )
2021
2122 // 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,
4041 l, st := s.emptyState(ctx, d)
4142 return State(l, st), http.StatusOK, nil
4243 }
43- ref := d.head.GetShortName()
44+ ref := d.head.ShortName
4445 now := s.cfg.Now()
4546
4647 p := SummaryPage{
@@ -48,16 +49,15 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
4849 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
4950 }
5051
51- commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
52- Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
53- }))
52+ commits, err := s.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "")
5453 if err != nil {
5554 return nil, 0, err
5655 }
5756 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()
5959 row := commitRow(d.name(), c, now)
60- row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
60+ row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0
6161 p.Recent = append(p.Recent, row)
6262 }
6363
@@ -74,7 +74,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
7474 l.Title = d.name() + " · " + s.instance
7575 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
7676 l.HasRail = true
77- l.Lede = d.repo.GetDescription()
77+ l.Lede = d.repo.Description
7878 return Summary(l, p), http.StatusOK, nil
7979 }
8080
@@ -90,7 +90,7 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
9090 }
9191 lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")}
9292 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 != "" {
9494 lt.Name, lt.Href = name, repoHref(d.name(), "tree", name)
9595 lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt
9696 }
@@ -102,33 +102,33 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
102102 func (s *Server) facts(d *repoData, now time.Time) []Fact {
103103 fs := []Fact{
104104 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")},
106106 }
107- if c := d.repo.GetCreatedAt(); c.IsValid() {
108- fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
107+ if c := d.repo.CreatedAt; !c.IsZero() {
108+ fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)})
109109 }
110110 return fs
111111 }
112112
113113 // lastPushFact is the "last push" fact of a repository; the live updates
114114 // render it again.
115-func lastPushFact(r *pb.Repo, now time.Time) Fact {
116- last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
117- if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
118- last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
119- last.Time = rfc3339(t.AsTime())
115+func lastPushFact(r ports.RepoView, now time.Time) Fact {
116+ last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()}
117+ if t := r.PushedAt; !t.IsZero() {
118+ last.Val = ago(now, t) + " · " + absolute(t)
119+ last.Time = rfc3339(t)
120120 }
121121 return last
122122 }
123123
124124 // 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) {
126126 var name string
127127 var best refCommit
128128 for _, t := range tags {
129- c, ok := commits[t.GetCommitId()]
129+ c, ok := commits[t.CommitID.String()]
130130 if ok && c.short != "" && (name == "" || c.at > best.at) {
131- name, best = t.GetShortName(), c
131+ name, best = t.ShortName, c
132132 }
133133 }
134134 return name, best
@@ -142,27 +142,31 @@ type renderedReadme struct {
142142
143143 // readme finds the README at the root of the tree of the head commit, reads
144144 // 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.
146146 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)
148149 if err != nil {
149150 return nil, err
150151 }
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)
152153 }
153154
154155 // readmeIn picks the README among the entries of the directory dir ("" for
155156 // the root) of the commit rev, reads it and renders it. Links in it are
156157 // 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- var best *pb.TreeEntry
158+//
159+//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only
160+func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {
161+ var best *domain.TreeEntry
159162 rank := len(readmeNames)
160- for _, e := range entries {
161- if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
163+ for i := range entries {
164+ e := &entries[i]
165+ if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {
162166 continue
163167 }
164168 for i, n := range readmeNames {
165- if i < rank && strings.EqualFold(e.GetName(), n) {
169+ if i < rank && strings.EqualFold(e.Name, n) {
166170 best, rank = e, i
167171 }
168172 }
@@ -170,54 +174,51 @@ func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entri
170174 if best == nil {
171175 return nil, nil
172176 }
173- rd := &Readme{Name: best.GetName()}
174- if best.GetSize() > maxReadmeBytes {
175- rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
177+ rd := &Readme{Name: best.Name}
178+ if best.Size > maxReadmeBytes {
179+ rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
176180 return &renderedReadme{readme: rd}, nil
177181 }
178- body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes)
182+ body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes)
179183 if err != nil {
180184 return nil, err
181185 }
182186 if binary {
183- rd.Note = best.GetName() + " is not text."
187+ rd.Note = best.Name + " is not text."
184188 return &renderedReadme{readme: rd}, nil
185189 }
186- name := strings.ToLower(best.GetName())
190+ name := strings.ToLower(best.Name)
187191 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)
189193 if err == nil {
190194 rd.HTML = h
191195 return &renderedReadme{readme: rd, outline: outline}, nil
192196 }
193197 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")
195199 }
196200 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
197201 return &renderedReadme{readme: rd}, nil
198202 }
199203
200-// readBlob reads a file through the GetBlob stream, at most limit bytes. It
201-// reports a binary file without its content.
202-func (s *Server) readBlob(ctx context.Context, repo, 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}))
204+// readBlob reads a file, at most limit bytes. It reports a binary file
205+// without its content.
206+func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) {
207+ info, rc, err := s.openBlobAt(ctx, repo, rev, path)
204208 if err != nil {
205209 return nil, false, err
206210 }
207- defer func() { _ = stream.Close() }()
208- for stream.Receive() {
209- m := stream.Msg()
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- }
211+ defer func() { _ = rc.Close() }()
212+ if info.Binary {
213+ return nil, true, nil
217214 }
218- if err := stream.Err(); err != nil {
215+ body, err = io.ReadAll(io.LimitReader(rc, limit+1))
216+ if err != nil {
219217 return nil, false, err
220218 }
219+ if int64(len(body)) > limit {
220+ return body[:limit], false, nil
221+ }
221222 return body, false, nil
222223 }
223224
M internal/adapters/web/tree.go +19 -20
@@ -6,10 +6,9 @@ import (
66 "context"
77 "net/http"
88
9- "connectrpc.com/connect"
109 "github.com/a-h/templ"
1110
12- pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
1312 )
1413
1514 // maxFileTargets bounds the files of a directory offered in the ":" list.
@@ -39,6 +38,8 @@ type TreePage struct {
3938
4039 // tree lists a directory of a ref, with the last commit of each entry, and
4140 // 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
4243 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
4344 d, err := s.loadRepo(ctx, r.PathValue("repo"))
4445 if err != nil {
@@ -48,47 +49,45 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
4849 if !ok {
4950 return nil, 0, errNotFound
5051 }
51- res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{
52- Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true,
53- }))
52+ res, err := s.getTree(ctx, d.repo.Name, t.rev, t.path, true)
5453 if err != nil {
5554 return nil, 0, err
5655 }
57- entries := res.Msg.GetEntries()
56+ entries := res.Entries
5857 now := s.cfg.Now()
5958
6059 p := TreePage{}
6160 l := s.repoLayout(ctx, d, "tree")
6261 for i, e := range entries {
63- row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0}
64- switch e.GetKind() {
65- case pb.EntryKind_ENTRY_KIND_DIRECTORY:
62+ row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0}
63+ switch e.Kind {
64+ case domain.EntryDirectory:
6665 row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"
67- row.Href = repoHref(d.name(), "tree", t.name, e.GetPath())
68- case pb.EntryKind_ENTRY_KIND_SYMLINK:
66+ row.Href = repoHref(d.name(), "tree", t.name, e.Path)
67+ case domain.EntrySymlink:
6968 row.Glyph = "@"
70- row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
71- case pb.EntryKind_ENTRY_KIND_SUBMODULE:
69+ row.Href = repoHref(d.name(), "blob", t.name, e.Path)
70+ case domain.EntrySubmodule:
7271 row.Glyph, row.Size = "+", "-"
7372 default:
74- row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
73+ row.Href = repoHref(d.name(), "blob", t.name, e.Path)
7574 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})
7776 }
7877 }
79- if c := e.GetLastCommit(); c != nil {
80- last := commitRow(d.name(), c, now)
78+ if c := e.LastCommit; c != nil {
79+ last := commitRow(d.name(), *c, now)
8180 row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt
8281 }
8382 p.Rows = append(p.Rows, row)
8483 }
85- if res.Msg.GetTruncated() {
84+ if res.Truncated {
8685 p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")
8786 }
88- if res.Msg.GetLastCommitsTruncated() {
87+ if res.LastCommitsOmitted {
8988 p.Notes = append(p.Notes, "the last commit is shown for the first entries only.")
9089 }
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)
9291 if err != nil {
9392 return nil, 0, err
9493 }
M internal/adapters/web/view.go +1 -1
@@ -5,7 +5,7 @@ package web
55 import "html/template"
66
77 // 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.
99
1010 // Crumb is one segment of the path in the header.
1111 type Crumb struct {
M internal/adapters/web/web.go +37 -32
@@ -1,15 +1,16 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
33 // 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.
55 //
6-// The package is a client of the API like any other. It talks to the
7-// generated ermite-api Connect clients, which internal/app binds in process to the
8-// handlers of the rpc adapter (ADR 0020), and it knows nothing of the use
9-// cases or the domain. Who the visitor is comes with the request context, set
10-// by the middleware internal/app puts in front of the handler: anonymous in P5. The
11-// API then shows public repositories only; a private repository is
12-// indistinguishable from a missing one, here as everywhere.
6+// Like every other inbound adapter, the package reads and writes through the
7+// use case interfaces of package ports (ADR 0024), never through the API. The
8+// caller of every use case call is passed explicitly: the principal of the
9+// request, which is anonymous unless the request carries a valid owner session
10+// (ServeHTTP sets it in the context, principal reads it). The use cases then
11+// show public repositories only to an anonymous caller; a private repository
12+// is indistinguishable from a missing one (domain.ErrNotFound), here as
13+// everywhere.
1314 //
1415 // Adding a page: a handler on Server, a route in routes, a templ page in
1516 // pages.templ and its view model in view.go.
@@ -28,11 +29,10 @@ import (
2829 "strings"
2930 "time"
3031
31- "connectrpc.com/connect"
3232 "github.com/a-h/templ"
3333 "github.com/bnema/zerowrap"
3434
35- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
35+ "bnema.dev/ermite/internal/domain"
3636 "bnema.dev/ermite/internal/ports"
3737 )
3838
@@ -42,20 +42,22 @@ import (
4242 // over this one.
4343 var Patterns = []string{"/"}
4444
45-// requestTimeout bounds the API calls that build one page.
45+// requestTimeout bounds the use case calls that build one page.
4646 const requestTimeout = 30 * time.Second
4747
4848 // Config configures a Server.
4949 type Config struct {
50- // Repos and Git are the API clients the pages are built from.
51- Repos ermiteconnect.RepoServiceClient
52- Git ermiteconnect.GitServiceClient
53- // MergeRequests is the client of the merge request pages (ADR 0017).
54- // Without it there are no such pages, nor section for them.
55- MergeRequests ermiteconnect.MergeRequestServiceClient
56- // Events is the client of the event stream behind /-/events. Without
57- // it the pages are not live and the route answers 404.
58- Events ermiteconnect.EventServiceClient
50+ // Repos and Browse are the use cases the pages are built from.
51+ Repos ports.RepoService
52+ Browse ports.BrowseService
53+ // MergeRequests serves the merge request pages (ADR 0017). Without it
54+ // there are no such pages, nor section for them. Leave it unset rather
55+ // than assigning a typed nil pointer.
56+ MergeRequests ports.MergeRequestService
57+ // Events is the source of the event stream behind /-/events. Without
58+ // it the pages are not live and the route answers 404. Leave it unset
59+ // rather than assigning a typed nil pointer.
60+ Events ports.EventService
5961 // Heartbeat is how often an idle event stream gets a comment line;
6062 // DefaultHeartbeat when zero.
6163 Heartbeat time.Duration
@@ -94,8 +96,8 @@ type Server struct {
9496
9597 // New builds the web handler.
9698 func New(cfg Config) (*Server, error) {
97- if cfg.Repos == nil || cfg.Git == nil {
98- return nil, errors.New("web: the repository and Git API clients are required")
99+ if cfg.Repos == nil || cfg.Browse == nil {
100+ return nil, errors.New("web: the repository and browse use cases are required")
99101 }
100102 u, err := url.Parse(cfg.PublicURL)
101103 if err != nil || u.Host == "" {
@@ -161,6 +163,9 @@ func (s *Server) routes() *http.ServeMux {
161163
162164 // ServeHTTP implements http.Handler.
163165 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()))
164169 r = s.withSession(w, r)
165170 if !s.gate(w, r) {
166171 return
@@ -172,7 +177,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172177 // error, which becomes the 404 or 500 page.
173178 type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
174179
175-// page adapts a handler: it bounds the API calls, renders the component into
180+// page adapts a handler: it bounds the use case calls, renders the component into
176181 // a buffer (so that a failure never leaves half a page), and maps errors to
177182 // the state pages.
178183 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
241246 }
242247 }
243248
244-// fail answers an error of the API: not found (and a name or revision the
245-// API refuses, which no page can show either) gives the 404 page, a visitor
246-// that left gives nothing, anything else gives the 500 page with an id to
247-// find the cause in the log.
249+// fail answers an error of a use case: not found (and a name or revision the
250+// use case refuses, which no page can show either) gives the 404 page, a
251+// visitor that left gives nothing, anything else gives the 500 page with an id
252+// to find the cause in the log.
248253 func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
249- switch code := connect.CodeOf(err); {
250- case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
254+ switch {
255+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
251256 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):
253258 // The client is gone; there is nobody to answer.
254259 default:
255260 id := requestID()
@@ -260,8 +265,8 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques
260265 }
261266
262267 // 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).
264-var errNotFound = errors.New("not found")
268+// although the use case did not say so (a ref or a path that is not there).
269+var errNotFound = domain.ErrNotFound
265270
266271 // requestID is a short random id, shown on the 500 page and logged.
267272 func requestID() string {
M internal/app/api_test.go +1 -70
@@ -765,77 +765,8 @@ func TestDeleteRepoMovesToTrash(t *testing.T) {
765765 assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process")
766766 }
767767
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-
836768 // A principal in the base context of the HTTP server does not authenticate
837-// requests: only the in-process transport can mark a context as trusted, and
838-// requests from the network are judged by their headers.
769+// requests: requests from the network are judged by their headers.
839770 func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) {
840771 h := newHarness(t, harnessOpts{})
841772 h.seed("pub", domain.VisibilityPublic, "README.md")
M internal/app/events_test.go +7 -6
@@ -18,7 +18,6 @@ import (
1818 "google.golang.org/protobuf/types/known/fieldmaskpb"
1919
2020 pb "bnema.dev/ermite-api/gen/ermite"
21- "bnema.dev/ermite/internal/adapters/rpc"
2221 "bnema.dev/ermite/internal/domain"
2322 "bnema.dev/ermite/internal/ports"
2423 "bnema.dev/ermite/internal/usecase"
@@ -382,13 +381,15 @@ func TestWatchLimits(t *testing.T) {
382381 require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == usecase.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
383382 assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api)))
384383 // A stream of another client address is not affected (the harness trusts
385- // the loopback proxy only when configured: use the in-process caller).
386- inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL)
387- ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root()))
384+ // the loopback proxy only when configured: ask the use case directly).
385+ ctx, cancel := context.WithCancel(h.ctx)
388386 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 })
390391 require.NoError(t, err)
391- _ = st.Close()
392+ assert.True(t, registered, "a watch of another client address is registered")
392393 }
393394
394395 // 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
241241 }
242242 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
243243 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
244- site, err := newWeb(cfg, f.API, f.Sessions, limiter)
244+ site, err := newWeb(cfg, f, limiter)
245245 if err != nil {
246246 return nil, fmt.Errorf("web: %w", err)
247247 }
M internal/app/web.go +17 -36
@@ -13,9 +13,7 @@ import (
1313
1414 "github.com/bnema/zerowrap"
1515
16- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1716 "bnema.dev/ermite/internal/adapters/httpserver"
18- "bnema.dev/ermite/internal/adapters/rpc"
1917 "bnema.dev/ermite/internal/adapters/web"
2018 "bnema.dev/ermite/internal/clientlimit"
2119 "bnema.dev/ermite/internal/config"
@@ -24,30 +22,26 @@ import (
2422 "bnema.dev/ermite/internal/usecase"
2523 )
2624
27-// newWeb builds the web pages on the API of this process: the generated
28-// Connect clients are bound to the handlers of api, with no socket (ADR 0020).
25+// newWeb builds the web pages on the use cases of this process, through the
26+// ports (ADR 0020), like the API and Git over HTTP.
2927 //
3028 // The owner's session cookie turns a request into the owner's (web.Server
31-// checks it with sessions); every other request is anonymous.
32-func newWeb(cfg config.Config, api *rpc.API, sessions *usecase.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) {
33- hc := api.InProcessClient()
34- srv, err := web.New(web.Config{
35- Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
36- Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
37- MergeRequests: ermiteconnect.NewMergeRequestServiceClient(hc, rpc.InProcessURL),
38- Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL),
29+// checks it with sessions); every other request is anonymous. The web passes
30+// the principal of the request to each use case call itself.
31+func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {
32+ return web.New(web.Config{
33+ Repos: f.Repos,
34+ Browse: f.Browse,
35+ MergeRequests: f.MergeRequests,
36+ Events: f.EventLog,
3937 PublicURL: cfg.PublicURL,
4038 SourceURL: cfg.SourceURL,
4139 Version: serverVersion(),
4240
43- Sessions: webSessions{sessions},
41+ Sessions: webSessions{f.Sessions},
4442 Limiter: limiter,
4543 TrustedProxies: cfg.TrustedProxies,
4644 })
47- if err != nil {
48- return nil, err
49- }
50- return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil
5145 }
5246
5347 // sessionsNeedHTTPS reports whether browser sign-in cannot work on this public
@@ -98,8 +92,8 @@ func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClie
9892 }
9993
10094 // webSessions adapts the session use cases to ports.WebSessionService: a valid
101-// token gives the context of the owner, whose principal the API calls of the
102-// page carry (rpc.API.InProcessClient).
95+// token gives the owner, the principal the web passes to the use cases for
96+// that request.
10397 type webSessions struct{ *usecase.Sessions }
10498
10599 var _ ports.WebSessionService = webSessions{}
@@ -126,28 +120,15 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura
126120 return token, lifetime, nil
127121 }
128122
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) {
130124 p, err := s.Sessions.Verify(ctx, token)
131125 if errors.Is(err, domain.ErrUnauthenticated) {
132- return ctx, false, nil
126+ return domain.Anonymous(), false, nil
133127 }
134128 if err != nil {
135- return ctx, false, err
129+ return domain.Anonymous(), false, err
136130 }
137- return domain.ContextWithPrincipal(ctx, 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- })
131+ return p, true, nil
151132 }
152133
153134 // 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) {
506506 assert.NotContains(t, logs, string(key))
507507 }
508508
509-// The owner principal is what the API sees from a signed-in page, and is
510-// labelled "owner".
509+// The owner principal is what the use cases see from a signed-in page, and is
510+// labelled "owner": the use case logs it with a write the page made.
511511 func TestWebOwnerIsLabelledInLogs(t *testing.T) {
512- c := newCLIHarness(t)
513- c.seed("secret", domain.VisibilityPrivate, "notes.txt")
514- token := c.signIn(t)
515- browser{c.harness}.get("/secret", as(token))
516- assert.Contains(t, c.logs.String(), `"principal":"owner"`)
512+ r := newMRRepo(t, domain.VisibilityPublic)
513+ token := r.signIn(t)
514+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
515+ b := browser{r.harness}
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"`)
517525 }
518526
519527 // 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 (
2020
2121 const modulePath = "bnema.dev/ermite"
2222
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+
2327 // moduleRoot is the module root relative to this package's directory.
2428 const moduleRoot = "../.."
2529
@@ -48,7 +52,8 @@ const (
4852 // - internal/usecase holds the use cases: domain and ports only.
4953 // - adapters implement ports and never import use cases or app; the
5054 // 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).
5257 // - internal/clientlimit is infrastructure shared by the HTTP adapters.
5358 // - internal/config parses the environment into plain values.
5459 var productionDependencies = map[packageLayer]map[packageLayer]bool{
@@ -78,6 +83,33 @@ func TestImportBoundaries(t *testing.T) {
7883 }
7984 }
8085
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+
81113 func TestImportBoundaryFixtures(t *testing.T) {
82114 p := func(s string) string { return modulePath + "/" + s }
83115 tests := []struct {
@@ -106,8 +138,12 @@ func TestImportBoundaryFixtures(t *testing.T) {
106138 {"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true},
107139 {"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true},
108140 {"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},
110- {"adapter test may import generated mocks", p("internal/adapters/web"), p("internal/adapters/web/mocks"), true, true},
141+ {"adapter accepts generated", p("internal/adapters/rpc"), p("gen/ermite"), false, 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},
111147 {"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false},
112148 {"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false},
113149 {"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{})
176212 testFile := strings.HasSuffix(path, "_test.go")
177213 for _, spec := range file.Imports {
178214 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+"/") {
180223 continue
181224 }
182225 allowed, err := dependencyAllowed(source, target, testFile)
@@ -218,14 +261,31 @@ func dependencyAllowed(source, target string, testFile bool) (bool, error) {
218261 return productionDependencies[sourceLayer][targetLayer], nil
219262 }
220263
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+
221276 // packageImportDenied narrows the layer rules to packages: an adapter may
222-// import only packages under its own directory (its mocks, for instance),
223-// never a sibling adapter.
277+// import only packages under its own directory, never a sibling adapter; and
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.
224281 func packageImportDenied(source, target string) bool {
225282 const adapters = modulePath + "/internal/adapters/"
226283 if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) {
227284 return adapterRoot(source) != adapterRoot(target)
228285 }
286+ if strings.HasPrefix(source, adapters) && adapterRoot(source) == "web" {
287+ return under(target, modulePath+"/gen")
288+ }
229289 return false
230290 }
231291
@@ -253,7 +313,7 @@ func classifyPackage(path string) (packageLayer, error) {
253313 return layerApp, nil
254314 case under(path, p("internal/usecase")):
255315 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")):
257317 return layerTestSupport, nil
258318 case under(path, p("internal/adapters")):
259319 return layerAdapter, nil
M internal/domain/errors.go +22 -0
@@ -37,3 +37,25 @@ var (
3737 // the number of event watchers; the caller may retry later.
3838 ErrResourceExhausted = errors.New("resource exhausted")
3939 )
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 (
88 "context"
99 "time"
1010
11+ "bnema.dev/ermite/internal/domain"
1112 mock "github.com/stretchr/testify/mock"
1213 )
1314
@@ -171,25 +172,23 @@ func (_c *WebSessionService_LoginLinkValid_Call) RunAndReturn(run func(nonce str
171172 }
172173
173174 // 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) {
175176 ret := _mock.Called(ctx, token)
176177
177178 if len(ret) == 0 {
178179 panic("no return value specified for Verify")
179180 }
180181
181- var r0 context.Context
182+ var r0 domain.Principal
182183 var r1 bool
183184 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 {
185186 return returnFunc(ctx, token)
186187 }
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 {
188189 r0 = returnFunc(ctx, token)
189190 } else {
190- if ret.Get(0) != nil {
191- r0 = ret.Get(0).(context.Context)
192- }
191+ r0 = ret.Get(0).(domain.Principal)
193192 }
194193 if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
195194 r1 = returnFunc(ctx, token)
@@ -234,12 +233,12 @@ func (_c *WebSessionService_Verify_Call) Run(run func(ctx context.Context, token
234233 return _c
235234 }
236235
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 {
238237 _c.Call.Return(owner, ok, err)
239238 return _c
240239 }
241240
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 {
243242 _c.Call.Return(run)
244243 return _c
245244 }
M internal/ports/services.go +5 -5
@@ -109,11 +109,11 @@ type WebSessionService interface {
109109 // Login uses the link and returns the session token and how long it
110110 // lasts, or ErrLinkRejected.
111111 Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error)
112- // Verify checks a session token. When it is valid it returns the context
113- // to serve the request with, which carries the owner as the caller of the
114- // API; when it is not, ok is false and err nil. An error means the check
115- // could not be made (the request then runs as anonymous).
116- Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error)
112+ // Verify checks a session token. When it is valid it returns the owner,
113+ // the principal the web passes to the use cases for that request; when it
114+ // is not, ok is false and err nil. An error means the check could not be
115+ // made (the request then runs as anonymous).
116+ Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error)
117117 }
118118
119119 // 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 42

↑↓ moveenter openesc close