1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package ports declares the interfaces the use cases need from the outside
4// world. Adapters implement them; cmd wires them.
5package ports
6
7import (
8 "context"
9 "fmt"
10 "io"
11 "time"
12
13 "bnema.dev/ermite/internal/domain"
14)
15
16//go:generate go tool mockery
17
18// RepoMetaStore keeps repository metadata. It is the source of truth for
19// everything ermite owns about a repository except its Git data.
20type RepoMetaStore interface {
21 // Create stores repo. It returns domain.ErrAlreadyExists if a repository
22 // with that name exists, including when a concurrent Create wins.
23 Create(ctx context.Context, repo domain.Repo) error
24 // Get returns the repository, or domain.ErrNotFound.
25 Get(ctx context.Context, name domain.RepoName) (domain.Repo, error)
26 // List returns every repository ordered by name.
27 List(ctx context.Context) ([]domain.Repo, error)
28 // Update replaces the stored repository. It returns domain.ErrNotFound if
29 // there is none and domain.ErrConflict if another write changed the
30 // stored repository since it was read.
31 Update(ctx context.Context, repo domain.Repo) error
32 // Delete removes the repository and everything keyed to it: its merge
33 // requests, their comments and read positions, and its number sequence.
34 // A repository created again under the same name starts empty, with its
35 // numbers starting at 1. Deleting a missing one is not an error.
36 Delete(ctx context.Context, name domain.RepoName) error
37}
38
39// StagedID identifies a repository being created. It is opaque to callers.
40type StagedID string
41
42// RepoDirs is the result of listing the repository directories.
43type RepoDirs struct {
44 // Names are the directories that hold a valid repository name.
45 Names []domain.RepoName
46 // Invalid are the entries that cannot be a repository directory.
47 Invalid []string
48}
49
50// GitRepos manages bare repository directories.
51type GitRepos interface {
52 // Stage creates an empty bare repository outside the public directory
53 // and returns its identifier.
54 Stage(ctx context.Context) (StagedID, error)
55 // Publish moves a staged repository to its final place under name. It
56 // returns domain.ErrAlreadyExists if a directory for name exists; the
57 // staged repository is left in place for Discard.
58 Publish(ctx context.Context, id StagedID, name domain.RepoName) error
59 // Discard removes a staged repository. A missing one is not an error.
60 Discard(ctx context.Context, id StagedID) error
61 // PurgeStaging removes every staged repository left by a previous run.
62 PurgeStaging(ctx context.Context) error
63 // List returns the repository directories.
64 List(ctx context.Context) (RepoDirs, error)
65 // Trash moves the repository directory to the trash, where it can be
66 // recovered by hand. It returns domain.ErrNotFound if there is none.
67 Trash(ctx context.Context, name domain.RepoName) error
68 // DefaultBranch returns the branch HEAD points to (without refs/heads/)
69 // and whether that branch exists. It returns domain.ErrNotFound if there
70 // is no such repository.
71 DefaultBranch(ctx context.Context, name domain.RepoName) (branch string, exists bool, err error)
72 // HasBranch reports whether refs/heads/<branch> exists. A name that
73 // cannot be a branch does not exist. It returns domain.ErrNotFound if
74 // there is no such repository.
75 HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error)
76 // HeadBranch returns the short name of the branch HEAD points to, whether
77 // or not that branch exists, and "" when HEAD is detached. It reads no
78 // refs, so it is cheap enough for a listing. It returns
79 // domain.ErrNotFound if there is no such repository.
80 HeadBranch(ctx context.Context, name domain.RepoName) (string, error)
81 // SetDefaultBranch points HEAD to the branch, which need not exist yet.
82 SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error
83 // Maintain refreshes the commit-graph of the repository.
84 Maintain(ctx context.Context, name domain.RepoName) error
85}
86
87// RepoFreshener is implemented by a RepoReader that keeps repositories open
88// between reads. The use cases that write to a repository (a push, the
89// maintenance that follows it) call Refresh so that the reader picks the change
90// up at once instead of when its own timer says so. It is optional: reads
91// stay correct without it.
92type RepoFreshener interface {
93 // Refresh tells the reader that the repository changed on disk. It does
94 // not fail: a reader that cannot refresh finds out on its next read.
95 Refresh(ctx context.Context, name domain.RepoName)
96}
97
98// CommitQuery selects a page of history.
99type CommitQuery struct {
100 // Revision is where the history starts; the zero value is HEAD.
101 Revision domain.Revision
102 // Path restricts the history to the commits that changed this file or
103 // directory; the root means the whole history.
104 Path domain.TreePath
105 // Skip is the number of commits of the listing to leave out. A caller that
106 // pages passes the commit it got in CommitPage.Head as Revision and the
107 // number of commits already read, so that every page walks the same
108 // history even when the revision moves.
109 Skip int
110 // Limit is the number of commits wanted, 1 to MaxCommitLimit.
111 Limit int
112}
113
114// MaxCommitLimit is the most commits one CommitQuery may ask for.
115const MaxCommitLimit = 1000
116
117// Validate checks the page size and the offset of the query. It returns an
118// error matching domain.ErrInvalidPageSize.
119func (q CommitQuery) Validate() error {
120 if q.Limit < 1 || q.Limit > MaxCommitLimit || q.Skip < 0 {
121 return fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
122 }
123 return nil
124}
125
126// CommitPage is a page of history, newest first.
127type CommitPage struct {
128 // Head is the commit the revision resolved to.
129 Head domain.ObjectID
130 // Commits has at most Limit commits.
131 Commits []domain.Commit
132 // HasMore tells that commits follow the last one.
133 HasMore bool
134}
135
136// RepoReader reads the Git data of repositories: refs, trees, blobs, history
137// and diffs. A repository that has no directory gives domain.ErrNotFound; so
138// do an unknown revision, an empty repository (which has no commit) and a
139// path that does not exist at the revision.
140type RepoReader interface {
141 // ListRefs returns HEAD, then the branches, then the tags, the last two
142 // ordered by name. HEAD is always listed: when its branch does not exist
143 // yet its ShortName and ids are empty.
144 ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error)
145 // ResolveRevision turns a revision into the commit it designates. A name
146 // that is both a branch and a tag means the branch.
147 ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)
148 // ResolveRefPath splits a path such as "feature/x/dir/file" into the
149 // longest leading part that names a branch or a tag and the rest. When no
150 // ref matches, the first segment may be a commit id (full, or abbreviated
151 // to MinAbbrevLen or more). It returns domain.ErrNotFound if nothing
152 // matches.
153 ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error)
154 // GetTree lists the directory at path, at most domain.MaxTreeEntries
155 // entries (Tree.Truncated tells). With includeLastCommit, the first
156 // domain.MaxLastCommitEntries entries get their last commit. It returns
157 // domain.ErrNotDirectory if path names something else.
158 GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)
159 // OpenBlob returns the description of the file at path and a reader of its
160 // content, which streams without holding the whole file in memory. The
161 // caller must close the reader. It returns domain.ErrNotFile if path names
162 // something else.
163 OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
164 // ListCommits returns a page of history.
165 ListCommits(ctx context.Context, repo domain.RepoName, q CommitQuery) (CommitPage, error)
166 // GetCommit returns a commit with its statistics and its diff against the
167 // first parent, bounded by limits.
168 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
169}
170
171// HTTPBackend tells how to run `git http-backend` for one request.
172type HTTPBackend struct {
173 // Path is the git-http-backend executable.
174 Path string
175 // Dir is the working directory of the process.
176 Dir string
177 // Env is the complete environment of the process: the isolated Git
178 // environment, GIT_PROJECT_ROOT and the Git configuration of the request
179 // (GIT_CONFIG_COUNT/KEY/VALUE). The caller may append to it.
180 Env []string
181}
182
183// GitBackend provides the Git side of smart HTTP.
184type GitBackend interface {
185 // HTTPBackend returns how to run git http-backend. For a push it also
186 // enables http.receivepack and sets REMOTE_USER; a read request cannot
187 // reach receive-pack.
188 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
189}
190
191// TokenStore keeps the stored tokens and the index from secret hash to token.
192// The secret itself is never stored.
193type TokenStore interface {
194 // Create stores the token and its hash index in one write. It returns
195 // domain.ErrAlreadyExists if the id or the hash is taken.
196 Create(ctx context.Context, token domain.Token) error
197 // Get returns the token with that id, or domain.ErrNotFound.
198 Get(ctx context.Context, id string) (domain.Token, error)
199 // FindByHash returns the token whose secret has that hash, or
200 // domain.ErrNotFound.
201 FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)
202 // List returns every token, oldest first.
203 List(ctx context.Context) ([]domain.Token, error)
204 // Revoke removes the token and its hash index in one write, or returns
205 // domain.ErrNotFound.
206 Revoke(ctx context.Context, id string) error
207 // Touch records that the token was used at the given time. It returns
208 // domain.ErrNotFound if the token is gone.
209 Touch(ctx context.Context, id string, at time.Time) error
210}
211
212// MergeRequestStore keeps the merge requests of the repositories, their
213// comments and the read position of the reviewer. A repository has no entry
214// until its first merge request. Create, SaveComment and SetRead return
215// domain.ErrNotFound when the repository does not exist, so that deleting a
216// repository leaves nothing for one created again under its name.
217type MergeRequestStore interface {
218 // Create stores the merge request under the next number of its
219 // repository, which it sets, and returns it. Numbers start at 1 and are
220 // never reused, even by a concurrent Create.
221 Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error)
222 // Get returns the merge request, or domain.ErrNotFound. Its Version
223 // tells Update which state it was read at.
224 Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error)
225 // List returns every merge request of the repository, highest number
226 // first.
227 List(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
228 // Update replaces the stored merge request. It returns domain.ErrNotFound
229 // if there is none and domain.ErrConflict if another write changed it
230 // since mr was read.
231 Update(ctx context.Context, mr domain.MergeRequest) error
232 // ListComments returns the comments of the merge request, oldest first.
233 ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.Comment, error)
234 // GetComment returns one comment, or domain.ErrNotFound.
235 GetComment(ctx context.Context, repo domain.RepoName, number int64, id int64) (domain.Comment, error)
236 // SaveComment writes a comment and the merge request it belongs to in one
237 // batch: a new comment (Version 0) must not exist yet, an existing one
238 // must be unchanged since it was read, and so must mr. It returns
239 // domain.ErrConflict otherwise. The caller sets mr.CommentSeq and the
240 // comment's ID for a new comment, and mr.UpdatedAt.
241 SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error
242 // GetRead returns the head the reviewer last marked read, or the zero
243 // ObjectID when there is none.
244 GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error)
245 // SetRead records the head the reviewer has read. It returns
246 // domain.ErrNotFound when the repository does not exist.
247 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
248}
249
250// MergeGit is what merge requests need from the Git repositories beyond
251// RepoReader. Every method returns domain.ErrNotFound when the repository has
252// no directory.
253type MergeGit interface {
254 // BranchHead returns the commit refs/heads/<branch> points to, or
255 // domain.ErrNotFound when there is no such branch.
256 BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error)
257 // HasCommit reports whether the repository holds that commit.
258 HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error)
259 // MergeBase returns the best common ancestor of two commits, or
260 // domain.ErrNotFound when they share no history.
261 MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error)
262 // IsAncestor reports whether ancestor is reachable from descendant, a
263 // commit being its own ancestor.
264 IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error)
265 // ListRange returns the commits reachable from head and not from exclude
266 // ("exclude..head"), newest first, at most limit; more is true when the
267 // range holds more. A zero exclude means the whole history of head.
268 ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
269 // DiffCommits returns the statistics and the diff from commit base to
270 // commit head (renames detected), bounded by limits.
271 DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error)
272 // CommitTree returns the tree of a commit.
273 CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error)
274 // WriteCommit stores a commit object, given as the text of the object,
275 // and returns its id.
276 WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error)
277 // UpdateBranch moves refs/heads/<branch> from old to new. It returns
278 // domain.ErrConflict when the branch no longer points to old.
279 UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, new domain.ObjectID) error
280 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
281 // returns domain.ErrConflict when the branch moved or is gone.
282 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
283}
284
285// CommitSigner signs the commits ermite creates (ADR 0022). The private key
286// never leaves it.
287type CommitSigner interface {
288 // PublicKey returns the public key and its fingerprint.
289 PublicKey() domain.SigningPublicKey
290 // Sign signs the text of a commit object and returns the armored SSH
291 // signature (namespace "git") to put in its gpgsig header.
292 Sign(payload []byte) (string, error)
293}
294
295// SessionStore keeps what the browser sessions of the owner (ADR 0018) need
296// to survive a restart: the key that signs them and the epoch that voids them.
297type SessionStore interface {
298 // Key returns the 32-byte secret that signs the session cookies. The first
299 // call on an empty store generates and stores it; a restart keeps it.
300 Key(ctx context.Context) ([]byte, error)
301 // Epoch returns the current epoch, 0 until the first BumpEpoch. A session
302 // is valid only while its epoch is the current one.
303 Epoch(ctx context.Context) (uint64, error)
304 // BumpEpoch increments the epoch, which ends every session, and returns
305 // the new value.
306 BumpEpoch(ctx context.Context) (uint64, error)
307}
308
309// EventBus fans events out to in-process subscribers.
310type EventBus interface {
311 // Publish hands the event to every subscriber without blocking. A
312 // subscriber that cannot keep up loses events and receives a
313 // domain.EventGap.
314 Publish(ctx context.Context, ev domain.Event)
315 // Subscribe returns a channel of events published from now on. The
316 // channel is closed when ctx ends.
317 Subscribe(ctx context.Context) <-chan domain.Event
318}
319
320// Clock tells the time.
321type Clock interface {
322 Now() time.Time
323}