detached c52c40f

feat(storage): store issues on the merge request sequence, schema 4

commit
c52c40f8d9839eaef0288029b066baac8011cebf
author
brice · · 2026-10-19 05:26 UTC
parent
b41adf0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1931 -64
M docs/configuration.md +1 -1
@@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
6666
6767 ## Storage schema
6868
69-This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
69+This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
@@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
66 66
67 ## Storage schema67 ## Storage schema
68 68
69-This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.69+This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
A gen/ermite/storage/issue.pb.go +343 -0 generated file: collapsed
M gen/ermite/storage/merge_request.pb.go +102 -31 generated file: collapsed
M internal/adapters/kvstore/export_test.go +9 -0
@@ -25,3 +25,12 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) {
2525 item, ok := c.db.Get(ctx, runnerKey(id))
2626 return item.Version, ok
2727 }
28+
29+// CountKeysForTest counts the keys of the database under prefix.
30+func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int {
31+ n := 0
32+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
33+ n++
34+ }
35+ return n
36+}
@@ -25,3 +25,12 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) {
25 item, ok := c.db.Get(ctx, runnerKey(id))25 item, ok := c.db.Get(ctx, runnerKey(id))
26 return item.Version, ok26 return item.Version, ok
27 }27 }
28+
29+// CountKeysForTest counts the keys of the database under prefix.
30+func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int {
31+ n := 0
32+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
33+ n++
34+ }
35+ return n
36+}
A internal/adapters/kvstore/issues.go +396 -0
@@ -0,0 +1,396 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of the issues, like that of the merge requests. The numbers come
23+// from the sequence of the merge requests (mrseq/<repo>): an issue and a merge
24+// request never share one.
25+const (
26+ issuePrefix = "is/" // is/<repo>/<number:010d>
27+ issueCommentPrefix = "isc/" // isc/<repo>/<number:010d>/<id:010d>
28+ issueOpenPrefix = "isopen/" // isopen/<repo>/<number:010d>, written with the issue
29+)
30+
31+// Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32+// the Store it comes from.
33+type Issues struct {
34+ db *kv.DB
35+}
36+
37+var _ ports.IssueStore = (*Issues)(nil)
38+
39+// Issues returns the issue store over the same database. It is valid until the
40+// Store is closed.
41+func (s *Store) Issues() *Issues { return &Issues{db: s.db} }
42+
43+func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
44+func issueKey(repo domain.RepoName, n int64) []byte {
45+ return []byte(issueRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
46+}
47+func issueCommentsPrefix(repo domain.RepoName, n int64) string {
48+ return issueCommentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/"
49+}
50+func issueCommentKey(repo domain.RepoName, n, id int64) []byte {
51+ return []byte(issueCommentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id))
52+}
53+func issueOpenRepoPrefix(repo domain.RepoName) string { return issueOpenPrefix + repo.String() + "/" }
54+func issueOpenKey(repo domain.RepoName, n int64) []byte {
55+ return []byte(issueOpenRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
56+}
57+
58+// indexOpenIssue adds to b the write that keeps the index of the open issues in
59+// step with the issue: the key is put when it is open, deleted otherwise
60+// (deleting a key that is not there is not an error).
61+func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
62+ if issue.IsOpen() {
63+ b.Put(issueOpenKey(issue.Repo, issue.Number), []byte{1})
64+ return
65+ }
66+ b.Delete(issueOpenKey(issue.Repo, issue.Number))
67+}
68+
69+// issueKeys lists every key of the snapshot that belongs to the repository's
70+// issues.
71+func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
72+ var keys [][]byte
73+ for _, p := range []string{
74+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ } {
76+ for k := range snap.Prefix(ctx, []byte(p)) {
77+ keys = append(keys, slices.Clone(k))
78+ }
79+ }
80+ return keys
81+}
82+
83+// Create takes the next number of the repository from mrseq/<repo>, the
84+// sequence of the merge requests, and stores the issue under it; see
85+// createNumbered. A missing repository gives domain.ErrNotFound.
86+//
87+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
88+func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
89+ err := createNumbered(ctx, i.db, issue.Repo, func(b *kv.Batch, n int64) error {
90+ issue.Number = n
91+ val, err := encodeIssue(issue)
92+ if err != nil {
93+ return err
94+ }
95+ b.RequireAbsent(issueKey(issue.Repo, n))
96+ b.Put(issueKey(issue.Repo, n), val)
97+ indexOpenIssue(b, issue)
98+ return nil
99+ })
100+ if err != nil {
101+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", issue.Repo, err)
102+ }
103+ return i.Get(ctx, issue.Repo, issue.Number)
104+}
105+
106+// Get reads is/<repo>/<number>.
107+func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
108+ if number < 1 {
109+ return domain.Issue{}, domain.ErrNotFound
110+ }
111+ item, ok := i.db.Get(ctx, issueKey(repo, number))
112+ if !ok {
113+ return domain.Issue{}, domain.ErrNotFound
114+ }
115+ return decodeIssue(repo, item)
116+}
117+
118+// List returns a page of the issues of the repository, highest number first. The
119+// page token is the last number of the previous page.
120+//
121+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
122+func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
123+ before := int64(0) // 0: from the highest
124+ if params.PageToken != "" {
125+ n, err := strconv.ParseInt(params.PageToken, 10, 64)
126+ if err != nil || n < 1 {
127+ return ports.IssuePage{}, fmt.Errorf("%w: page token", domain.ErrInvalid)
128+ }
129+ before = n
130+ }
131+ var all []domain.Issue
132+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueRepoPrefix(repo))) {
133+ issue, err := decodeIssue(repo, item)
134+ if err != nil {
135+ return ports.IssuePage{}, fmt.Errorf("decode %s: %w", key, err)
136+ }
137+ if params.State != "" && issue.State != params.State {
138+ continue
139+ }
140+ if before > 0 && issue.Number >= before {
141+ continue
142+ }
143+ all = append(all, issue)
144+ }
145+ slices.Reverse(all)
146+ var page ports.IssuePage
147+ if params.PageSize > 0 && len(all) > params.PageSize {
148+ all = all[:params.PageSize]
149+ page.NextPageToken = strconv.FormatInt(all[len(all)-1].Number, 10)
150+ }
151+ page.Issues = all
152+ return page, nil
153+}
154+
155+// ListOpen returns the open issues of the repository, highest number first,
156+// through the index: only the open ones are read.
157+//
158+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open issues through their index: a storage concern, no policy
159+func (i *Issues) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
160+ var numbers []int64
161+ for key := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueOpenRepoPrefix(repo))) {
162+ if err := ctx.Err(); err != nil {
163+ return nil, err
164+ }
165+ n, err := strconv.ParseInt(string(key[strings.LastIndexByte(string(key), '/')+1:]), 10, 64)
166+ if err != nil {
167+ return nil, fmt.Errorf("index key %q: %w", key, err)
168+ }
169+ numbers = append(numbers, n)
170+ }
171+ slices.Sort(numbers)
172+ var out []domain.Issue
173+ for _, n := range slices.Backward(numbers) {
174+ issue, err := i.Get(ctx, repo, n)
175+ if errors.Is(err, domain.ErrNotFound) {
176+ continue // closed or deleted since the snapshot of the index
177+ }
178+ if err != nil {
179+ return nil, err
180+ }
181+ if issue.IsOpen() {
182+ out = append(out, issue)
183+ }
184+ }
185+ return out, nil
186+}
187+
188+// Update replaces the issue with a version precondition and moves its index key
189+// in the same batch.
190+//
191+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
192+func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
193+ val, err := encodeIssue(issue)
194+ if err != nil {
195+ return err
196+ }
197+ key := issueKey(issue.Repo, issue.Number)
198+ if _, ok := i.db.Get(ctx, key); !ok {
199+ return domain.ErrNotFound
200+ }
201+ if issue.Version == 0 {
202+ return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
203+ }
204+ var b kv.Batch
205+ b.RequireVersion(key, issue.Version)
206+ b.Put(key, val)
207+ indexOpenIssue(&b, issue)
208+ if _, err := i.db.Apply(ctx, &b); err != nil {
209+ if errors.Is(err, kv.ErrConflict) {
210+ return domain.ErrConflict
211+ }
212+ return fmt.Errorf("update issue %d of %q: %w", issue.Number, issue.Repo, err)
213+ }
214+ return nil
215+}
216+
217+// ListComments returns the comments of the issue, oldest first.
218+func (i *Issues) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
219+ var out []domain.IssueComment
220+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueCommentsPrefix(repo, number))) {
221+ c, err := decodeIssueComment(item)
222+ if err != nil {
223+ return nil, fmt.Errorf("decode %s: %w", key, err)
224+ }
225+ out = append(out, c)
226+ }
227+ return out, nil
228+}
229+
230+// SaveComment writes the comment and the issue in one batch, each with its
231+// version precondition (absent for a new comment), and requires the repository
232+// to exist (see guardRepoKey): domain.ErrNotFound otherwise.
233+func (i *Issues) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
234+ issueVal, err := encodeIssue(issue)
235+ if err != nil {
236+ return err
237+ }
238+ cVal, err := encodeIssueComment(c)
239+ if err != nil {
240+ return err
241+ }
242+ if issue.Version == 0 {
243+ return fmt.Errorf("save comment on %d of %q: read the issue first: %w", issue.Number, issue.Repo, domain.ErrConflict)
244+ }
245+ var b kv.Batch
246+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
247+ return err
248+ }
249+ b.RequireVersion(issueKey(issue.Repo, issue.Number), issue.Version)
250+ b.Put(issueKey(issue.Repo, issue.Number), issueVal)
251+ indexOpenIssue(&b, issue)
252+ ckey := issueCommentKey(issue.Repo, issue.Number, c.ID)
253+ if c.Version == 0 {
254+ b.RequireAbsent(ckey)
255+ } else {
256+ b.RequireVersion(ckey, c.Version)
257+ }
258+ b.Put(ckey, cVal)
259+ if _, err := i.db.Apply(ctx, &b); err != nil {
260+ if errors.Is(err, kv.ErrConflict) {
261+ return domain.ErrConflict
262+ }
263+ return fmt.Errorf("save comment on %d of %q: %w", issue.Number, issue.Repo, err)
264+ }
265+ return nil
266+}
267+
268+func encodeIssue(issue domain.Issue) ([]byte, error) {
269+ if issue.Repo.IsZero() {
270+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
271+ }
272+ if !utf8.ValidString(issue.Author) {
273+ return nil, errors.New("issue author is not valid UTF-8")
274+ }
275+ state, err := encodeIssueState(issue.State)
276+ if err != nil {
277+ return nil, err
278+ }
279+ return mustMarshal(&storagepb.Issue{
280+ Number: issue.Number,
281+ Title: issue.Title.String(),
282+ Body: issue.Body.String(),
283+ State: state,
284+ Author: issue.Author,
285+ CreatedAt: encodeTime(issue.CreatedAt),
286+ UpdatedAt: encodeTime(issue.UpdatedAt),
287+ CommentSeq: issue.CommentSeq,
288+ }), nil
289+}
290+
291+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
292+func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
293+ var pb storagepb.Issue
294+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
295+ return domain.Issue{}, fmt.Errorf("decode issue: %w", err)
296+ }
297+ fail := func(err error) (domain.Issue, error) {
298+ return domain.Issue{}, fmt.Errorf("decode issue %d of %q: %w", pb.GetNumber(), repo, err)
299+ }
300+ title, err := domain.ParseIssueTitle(pb.GetTitle())
301+ if err != nil {
302+ return fail(err)
303+ }
304+ body, err := domain.ParseIssueBody(pb.GetBody())
305+ if err != nil {
306+ return fail(err)
307+ }
308+ state, err := decodeIssueState(pb.GetState())
309+ if err != nil {
310+ return fail(err)
311+ }
312+ return domain.Issue{
313+ Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
314+ Author: pb.GetAuthor(),
315+ CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
316+ CommentSeq: pb.GetCommentSeq(),
317+ Version: item.Version,
318+ }, nil
319+}
320+
321+func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
322+ if !utf8.ValidString(c.Author) {
323+ return nil, errors.New("issue comment author is not valid UTF-8")
324+ }
325+ return mustMarshal(&storagepb.IssueComment{
326+ Id: c.ID,
327+ Author: c.Author,
328+ Text: c.Text.String(),
329+ CreatedAt: encodeTime(c.CreatedAt),
330+ }), nil
331+}
332+
333+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334+func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
335+ var pb storagepb.IssueComment
336+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
337+ return domain.IssueComment{}, fmt.Errorf("decode issue comment: %w", err)
338+ }
339+ text, err := domain.ParseIssueBody(pb.GetText())
340+ if err != nil {
341+ return domain.IssueComment{}, fmt.Errorf("decode issue comment %d: %w", pb.GetId(), err)
342+ }
343+ return domain.IssueComment{
344+ ID: pb.GetId(), Author: pb.GetAuthor(), Text: text,
345+ CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
346+ }, nil
347+}
348+
349+func encodeIssueState(s domain.IssueState) (storagepb.IssueState, error) {
350+ switch s {
351+ case domain.IssueStateOpen:
352+ return storagepb.IssueState_ISSUE_STATE_OPEN, nil
353+ case domain.IssueStateClosed:
354+ return storagepb.IssueState_ISSUE_STATE_CLOSED, nil
355+ case domain.IssueStatePending:
356+ return storagepb.IssueState_ISSUE_STATE_PENDING, nil
357+ }
358+ return 0, fmt.Errorf("%w: issue state %q", domain.ErrInvalid, s)
359+}
360+
361+func decodeIssueState(s storagepb.IssueState) (domain.IssueState, error) {
362+ switch s {
363+ case storagepb.IssueState_ISSUE_STATE_OPEN:
364+ return domain.IssueStateOpen, nil
365+ case storagepb.IssueState_ISSUE_STATE_CLOSED:
366+ return domain.IssueStateClosed, nil
367+ case storagepb.IssueState_ISSUE_STATE_PENDING:
368+ return domain.IssueStatePending, nil
369+ }
370+ return "", fmt.Errorf("unknown issue state %d", s)
371+}
372+
373+func encodeIssuesMode(m domain.IssuesMode) (storagepb.IssuesMode, error) {
374+ switch m {
375+ case "":
376+ return storagepb.IssuesMode_ISSUES_MODE_UNSPECIFIED, nil
377+ case domain.IssuesOff:
378+ return storagepb.IssuesMode_ISSUES_MODE_OFF, nil
379+ case domain.IssuesOwner:
380+ return storagepb.IssuesMode_ISSUES_MODE_OWNER, nil
381+ case domain.IssuesPublic:
382+ return storagepb.IssuesMode_ISSUES_MODE_PUBLIC, nil
383+ }
384+ return 0, fmt.Errorf("%w: issues mode %q in the settings", domain.ErrInvalid, m)
385+}
386+
387+// decodeIssuesMode reads an unspecified or unknown mode as off: the safe side.
388+func decodeIssuesMode(m storagepb.IssuesMode) domain.IssuesMode {
389+ switch m {
390+ case storagepb.IssuesMode_ISSUES_MODE_OWNER:
391+ return domain.IssuesOwner
392+ case storagepb.IssuesMode_ISSUES_MODE_PUBLIC:
393+ return domain.IssuesPublic
394+ }
395+ return domain.IssuesOff
396+}
@@ -0,0 +1,396 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of the issues, like that of the merge requests. The numbers come
23+// from the sequence of the merge requests (mrseq/<repo>): an issue and a merge
24+// request never share one.
25+const (
26+ issuePrefix = "is/" // is/<repo>/<number:010d>
27+ issueCommentPrefix = "isc/" // isc/<repo>/<number:010d>/<id:010d>
28+ issueOpenPrefix = "isopen/" // isopen/<repo>/<number:010d>, written with the issue
29+)
30+
31+// Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32+// the Store it comes from.
33+type Issues struct {
34+ db *kv.DB
35+}
36+
37+var _ ports.IssueStore = (*Issues)(nil)
38+
39+// Issues returns the issue store over the same database. It is valid until the
40+// Store is closed.
41+func (s *Store) Issues() *Issues { return &Issues{db: s.db} }
42+
43+func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
44+func issueKey(repo domain.RepoName, n int64) []byte {
45+ return []byte(issueRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
46+}
47+func issueCommentsPrefix(repo domain.RepoName, n int64) string {
48+ return issueCommentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/"
49+}
50+func issueCommentKey(repo domain.RepoName, n, id int64) []byte {
51+ return []byte(issueCommentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id))
52+}
53+func issueOpenRepoPrefix(repo domain.RepoName) string { return issueOpenPrefix + repo.String() + "/" }
54+func issueOpenKey(repo domain.RepoName, n int64) []byte {
55+ return []byte(issueOpenRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
56+}
57+
58+// indexOpenIssue adds to b the write that keeps the index of the open issues in
59+// step with the issue: the key is put when it is open, deleted otherwise
60+// (deleting a key that is not there is not an error).
61+func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
62+ if issue.IsOpen() {
63+ b.Put(issueOpenKey(issue.Repo, issue.Number), []byte{1})
64+ return
65+ }
66+ b.Delete(issueOpenKey(issue.Repo, issue.Number))
67+}
68+
69+// issueKeys lists every key of the snapshot that belongs to the repository's
70+// issues.
71+func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
72+ var keys [][]byte
73+ for _, p := range []string{
74+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ } {
76+ for k := range snap.Prefix(ctx, []byte(p)) {
77+ keys = append(keys, slices.Clone(k))
78+ }
79+ }
80+ return keys
81+}
82+
83+// Create takes the next number of the repository from mrseq/<repo>, the
84+// sequence of the merge requests, and stores the issue under it; see
85+// createNumbered. A missing repository gives domain.ErrNotFound.
86+//
87+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
88+func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
89+ err := createNumbered(ctx, i.db, issue.Repo, func(b *kv.Batch, n int64) error {
90+ issue.Number = n
91+ val, err := encodeIssue(issue)
92+ if err != nil {
93+ return err
94+ }
95+ b.RequireAbsent(issueKey(issue.Repo, n))
96+ b.Put(issueKey(issue.Repo, n), val)
97+ indexOpenIssue(b, issue)
98+ return nil
99+ })
100+ if err != nil {
101+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", issue.Repo, err)
102+ }
103+ return i.Get(ctx, issue.Repo, issue.Number)
104+}
105+
106+// Get reads is/<repo>/<number>.
107+func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
108+ if number < 1 {
109+ return domain.Issue{}, domain.ErrNotFound
110+ }
111+ item, ok := i.db.Get(ctx, issueKey(repo, number))
112+ if !ok {
113+ return domain.Issue{}, domain.ErrNotFound
114+ }
115+ return decodeIssue(repo, item)
116+}
117+
118+// List returns a page of the issues of the repository, highest number first. The
119+// page token is the last number of the previous page.
120+//
121+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
122+func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
123+ before := int64(0) // 0: from the highest
124+ if params.PageToken != "" {
125+ n, err := strconv.ParseInt(params.PageToken, 10, 64)
126+ if err != nil || n < 1 {
127+ return ports.IssuePage{}, fmt.Errorf("%w: page token", domain.ErrInvalid)
128+ }
129+ before = n
130+ }
131+ var all []domain.Issue
132+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueRepoPrefix(repo))) {
133+ issue, err := decodeIssue(repo, item)
134+ if err != nil {
135+ return ports.IssuePage{}, fmt.Errorf("decode %s: %w", key, err)
136+ }
137+ if params.State != "" && issue.State != params.State {
138+ continue
139+ }
140+ if before > 0 && issue.Number >= before {
141+ continue
142+ }
143+ all = append(all, issue)
144+ }
145+ slices.Reverse(all)
146+ var page ports.IssuePage
147+ if params.PageSize > 0 && len(all) > params.PageSize {
148+ all = all[:params.PageSize]
149+ page.NextPageToken = strconv.FormatInt(all[len(all)-1].Number, 10)
150+ }
151+ page.Issues = all
152+ return page, nil
153+}
154+
155+// ListOpen returns the open issues of the repository, highest number first,
156+// through the index: only the open ones are read.
157+//
158+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open issues through their index: a storage concern, no policy
159+func (i *Issues) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
160+ var numbers []int64
161+ for key := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueOpenRepoPrefix(repo))) {
162+ if err := ctx.Err(); err != nil {
163+ return nil, err
164+ }
165+ n, err := strconv.ParseInt(string(key[strings.LastIndexByte(string(key), '/')+1:]), 10, 64)
166+ if err != nil {
167+ return nil, fmt.Errorf("index key %q: %w", key, err)
168+ }
169+ numbers = append(numbers, n)
170+ }
171+ slices.Sort(numbers)
172+ var out []domain.Issue
173+ for _, n := range slices.Backward(numbers) {
174+ issue, err := i.Get(ctx, repo, n)
175+ if errors.Is(err, domain.ErrNotFound) {
176+ continue // closed or deleted since the snapshot of the index
177+ }
178+ if err != nil {
179+ return nil, err
180+ }
181+ if issue.IsOpen() {
182+ out = append(out, issue)
183+ }
184+ }
185+ return out, nil
186+}
187+
188+// Update replaces the issue with a version precondition and moves its index key
189+// in the same batch.
190+//
191+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
192+func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
193+ val, err := encodeIssue(issue)
194+ if err != nil {
195+ return err
196+ }
197+ key := issueKey(issue.Repo, issue.Number)
198+ if _, ok := i.db.Get(ctx, key); !ok {
199+ return domain.ErrNotFound
200+ }
201+ if issue.Version == 0 {
202+ return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
203+ }
204+ var b kv.Batch
205+ b.RequireVersion(key, issue.Version)
206+ b.Put(key, val)
207+ indexOpenIssue(&b, issue)
208+ if _, err := i.db.Apply(ctx, &b); err != nil {
209+ if errors.Is(err, kv.ErrConflict) {
210+ return domain.ErrConflict
211+ }
212+ return fmt.Errorf("update issue %d of %q: %w", issue.Number, issue.Repo, err)
213+ }
214+ return nil
215+}
216+
217+// ListComments returns the comments of the issue, oldest first.
218+func (i *Issues) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
219+ var out []domain.IssueComment
220+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueCommentsPrefix(repo, number))) {
221+ c, err := decodeIssueComment(item)
222+ if err != nil {
223+ return nil, fmt.Errorf("decode %s: %w", key, err)
224+ }
225+ out = append(out, c)
226+ }
227+ return out, nil
228+}
229+
230+// SaveComment writes the comment and the issue in one batch, each with its
231+// version precondition (absent for a new comment), and requires the repository
232+// to exist (see guardRepoKey): domain.ErrNotFound otherwise.
233+func (i *Issues) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
234+ issueVal, err := encodeIssue(issue)
235+ if err != nil {
236+ return err
237+ }
238+ cVal, err := encodeIssueComment(c)
239+ if err != nil {
240+ return err
241+ }
242+ if issue.Version == 0 {
243+ return fmt.Errorf("save comment on %d of %q: read the issue first: %w", issue.Number, issue.Repo, domain.ErrConflict)
244+ }
245+ var b kv.Batch
246+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
247+ return err
248+ }
249+ b.RequireVersion(issueKey(issue.Repo, issue.Number), issue.Version)
250+ b.Put(issueKey(issue.Repo, issue.Number), issueVal)
251+ indexOpenIssue(&b, issue)
252+ ckey := issueCommentKey(issue.Repo, issue.Number, c.ID)
253+ if c.Version == 0 {
254+ b.RequireAbsent(ckey)
255+ } else {
256+ b.RequireVersion(ckey, c.Version)
257+ }
258+ b.Put(ckey, cVal)
259+ if _, err := i.db.Apply(ctx, &b); err != nil {
260+ if errors.Is(err, kv.ErrConflict) {
261+ return domain.ErrConflict
262+ }
263+ return fmt.Errorf("save comment on %d of %q: %w", issue.Number, issue.Repo, err)
264+ }
265+ return nil
266+}
267+
268+func encodeIssue(issue domain.Issue) ([]byte, error) {
269+ if issue.Repo.IsZero() {
270+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
271+ }
272+ if !utf8.ValidString(issue.Author) {
273+ return nil, errors.New("issue author is not valid UTF-8")
274+ }
275+ state, err := encodeIssueState(issue.State)
276+ if err != nil {
277+ return nil, err
278+ }
279+ return mustMarshal(&storagepb.Issue{
280+ Number: issue.Number,
281+ Title: issue.Title.String(),
282+ Body: issue.Body.String(),
283+ State: state,
284+ Author: issue.Author,
285+ CreatedAt: encodeTime(issue.CreatedAt),
286+ UpdatedAt: encodeTime(issue.UpdatedAt),
287+ CommentSeq: issue.CommentSeq,
288+ }), nil
289+}
290+
291+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
292+func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
293+ var pb storagepb.Issue
294+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
295+ return domain.Issue{}, fmt.Errorf("decode issue: %w", err)
296+ }
297+ fail := func(err error) (domain.Issue, error) {
298+ return domain.Issue{}, fmt.Errorf("decode issue %d of %q: %w", pb.GetNumber(), repo, err)
299+ }
300+ title, err := domain.ParseIssueTitle(pb.GetTitle())
301+ if err != nil {
302+ return fail(err)
303+ }
304+ body, err := domain.ParseIssueBody(pb.GetBody())
305+ if err != nil {
306+ return fail(err)
307+ }
308+ state, err := decodeIssueState(pb.GetState())
309+ if err != nil {
310+ return fail(err)
311+ }
312+ return domain.Issue{
313+ Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
314+ Author: pb.GetAuthor(),
315+ CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
316+ CommentSeq: pb.GetCommentSeq(),
317+ Version: item.Version,
318+ }, nil
319+}
320+
321+func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
322+ if !utf8.ValidString(c.Author) {
323+ return nil, errors.New("issue comment author is not valid UTF-8")
324+ }
325+ return mustMarshal(&storagepb.IssueComment{
326+ Id: c.ID,
327+ Author: c.Author,
328+ Text: c.Text.String(),
329+ CreatedAt: encodeTime(c.CreatedAt),
330+ }), nil
331+}
332+
333+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334+func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
335+ var pb storagepb.IssueComment
336+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
337+ return domain.IssueComment{}, fmt.Errorf("decode issue comment: %w", err)
338+ }
339+ text, err := domain.ParseIssueBody(pb.GetText())
340+ if err != nil {
341+ return domain.IssueComment{}, fmt.Errorf("decode issue comment %d: %w", pb.GetId(), err)
342+ }
343+ return domain.IssueComment{
344+ ID: pb.GetId(), Author: pb.GetAuthor(), Text: text,
345+ CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
346+ }, nil
347+}
348+
349+func encodeIssueState(s domain.IssueState) (storagepb.IssueState, error) {
350+ switch s {
351+ case domain.IssueStateOpen:
352+ return storagepb.IssueState_ISSUE_STATE_OPEN, nil
353+ case domain.IssueStateClosed:
354+ return storagepb.IssueState_ISSUE_STATE_CLOSED, nil
355+ case domain.IssueStatePending:
356+ return storagepb.IssueState_ISSUE_STATE_PENDING, nil
357+ }
358+ return 0, fmt.Errorf("%w: issue state %q", domain.ErrInvalid, s)
359+}
360+
361+func decodeIssueState(s storagepb.IssueState) (domain.IssueState, error) {
362+ switch s {
363+ case storagepb.IssueState_ISSUE_STATE_OPEN:
364+ return domain.IssueStateOpen, nil
365+ case storagepb.IssueState_ISSUE_STATE_CLOSED:
366+ return domain.IssueStateClosed, nil
367+ case storagepb.IssueState_ISSUE_STATE_PENDING:
368+ return domain.IssueStatePending, nil
369+ }
370+ return "", fmt.Errorf("unknown issue state %d", s)
371+}
372+
373+func encodeIssuesMode(m domain.IssuesMode) (storagepb.IssuesMode, error) {
374+ switch m {
375+ case "":
376+ return storagepb.IssuesMode_ISSUES_MODE_UNSPECIFIED, nil
377+ case domain.IssuesOff:
378+ return storagepb.IssuesMode_ISSUES_MODE_OFF, nil
379+ case domain.IssuesOwner:
380+ return storagepb.IssuesMode_ISSUES_MODE_OWNER, nil
381+ case domain.IssuesPublic:
382+ return storagepb.IssuesMode_ISSUES_MODE_PUBLIC, nil
383+ }
384+ return 0, fmt.Errorf("%w: issues mode %q in the settings", domain.ErrInvalid, m)
385+}
386+
387+// decodeIssuesMode reads an unspecified or unknown mode as off: the safe side.
388+func decodeIssuesMode(m storagepb.IssuesMode) domain.IssuesMode {
389+ switch m {
390+ case storagepb.IssuesMode_ISSUES_MODE_OWNER:
391+ return domain.IssuesOwner
392+ case storagepb.IssuesMode_ISSUES_MODE_PUBLIC:
393+ return domain.IssuesPublic
394+ }
395+ return domain.IssuesOff
396+}
A internal/adapters/kvstore/issues_test.go +339 -0
@@ -0,0 +1,339 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ storagepb "bnema.dev/ermite/gen/ermite/storage"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
20+ t.Helper()
21+ ti, err := domain.ParseIssueTitle(title)
22+ require.NoError(t, err)
23+ body, err := domain.ParseIssueBody("what\nhappened")
24+ require.NoError(t, err)
25+ return domain.Issue{
26+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
27+ Author: "agent", CreatedAt: created, UpdatedAt: created,
28+ }
29+}
30+
31+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
32+ t.Helper()
33+ body, err := domain.ParseIssueBody(text)
34+ require.NoError(t, err)
35+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
36+}
37+
38+// countKeys counts the keys of the store under prefix.
39+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
40+ t.Helper()
41+ return s.CountKeysForTest(context.Background(), prefix)
42+}
43+
44+func TestIssueRoundTripAndPaging(t *testing.T) {
45+ ctx := context.Background()
46+ s, _, n := repoWithMRs(t)
47+ is := s.Issues()
48+
49+ first, err := is.Create(ctx, newIssue(t, n, "one"))
50+ require.NoError(t, err)
51+ assert.Equal(t, int64(1), first.Number)
52+ assert.NotZero(t, first.Version)
53+ got, err := is.Get(ctx, n, 1)
54+ require.NoError(t, err)
55+ assert.Equal(t, first, got)
56+ _, err = is.Get(ctx, n, 2)
57+ assert.ErrorIs(t, err, domain.ErrNotFound)
58+
59+ for _, title := range []string{"two", "three", "four"} {
60+ _, err := is.Create(ctx, newIssue(t, n, title))
61+ require.NoError(t, err)
62+ }
63+ closed, err := is.Get(ctx, n, 2)
64+ require.NoError(t, err)
65+ closed.State = domain.IssueStateClosed
66+ require.NoError(t, is.Update(ctx, closed))
67+
68+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
69+ require.NoError(t, err)
70+ require.Len(t, page.Issues, 3)
71+ assert.Equal(t, int64(4), page.Issues[0].Number)
72+ require.NotEmpty(t, page.NextPageToken)
73+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
74+ require.NoError(t, err)
75+ require.Len(t, page.Issues, 1)
76+ assert.Equal(t, int64(1), page.Issues[0].Number)
77+ assert.Empty(t, page.NextPageToken)
78+
79+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
80+ require.NoError(t, err)
81+ require.Len(t, page.Issues, 1)
82+ assert.Equal(t, int64(2), page.Issues[0].Number)
83+
84+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
85+ assert.ErrorIs(t, err, domain.ErrInvalid)
86+
87+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
88+ assert.ErrorIs(t, err, domain.ErrNotFound)
89+}
90+
91+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ is := s.Issues()
95+ first, err := is.Create(ctx, newIssue(t, n, "t"))
96+ require.NoError(t, err)
97+ a, b := first, first
98+ a.State = domain.IssueStateClosed
99+ require.NoError(t, is.Update(ctx, a))
100+ b.State = domain.IssueStateClosed
101+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
102+}
103+
104+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
105+ ctx := context.Background()
106+ s, _, n := repoWithMRs(t)
107+ is := s.Issues()
108+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
109+ require.NoError(t, err)
110+
111+ issue.CommentSeq = 1
112+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
113+ // The issue moved on: the same version is refused.
114+ issue.CommentSeq = 2
115+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
116+ issue, err = is.Get(ctx, n, issue.Number)
117+ require.NoError(t, err)
118+ issue.CommentSeq = 2
119+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
120+
121+ comments, err := is.ListComments(ctx, n, issue.Number)
122+ require.NoError(t, err)
123+ require.Len(t, comments, 2)
124+ assert.Equal(t, "first", comments[0].Text.String())
125+ assert.Equal(t, int64(2), comments[1].ID)
126+
127+ require.NoError(t, s.Delete(ctx, n))
128+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
129+}
130+
131+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
132+ ctx := context.Background()
133+ s, mrs, n := repoWithMRs(t)
134+ is := s.Issues()
135+ const workers = 16
136+ var (
137+ wg sync.WaitGroup
138+ mu sync.Mutex
139+ numbers []int64
140+ )
141+ for w := range workers {
142+ wg.Go(func() {
143+ var number int64
144+ if w%2 == 0 {
145+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
146+ if !assert.NoError(t, err) {
147+ return
148+ }
149+ number = got.Number
150+ } else {
151+ got, err := is.Create(ctx, newIssue(t, n, "t"))
152+ if !assert.NoError(t, err) {
153+ return
154+ }
155+ number = got.Number
156+ }
157+ mu.Lock()
158+ numbers = append(numbers, number)
159+ mu.Unlock()
160+ })
161+ }
162+ wg.Wait()
163+ want := make([]int64, workers)
164+ for i := range want {
165+ want[i] = int64(i + 1)
166+ }
167+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
168+}
169+
170+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
171+ ctx := context.Background()
172+ s, _, n := repoWithMRs(t)
173+ sib := name(t, "proj-2")
174+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
175+ is := s.Issues()
176+
177+ for _, repo := range []domain.RepoName{n, sib} {
178+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
179+ require.NoError(t, err)
180+ issue.CommentSeq = 1
181+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
182+ }
183+ require.NoError(t, s.Delete(ctx, n))
184+
185+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
186+ require.NoError(t, err)
187+ assert.Empty(t, page.Issues)
188+ comments, err := is.ListComments(ctx, n, 1)
189+ require.NoError(t, err)
190+ assert.Empty(t, comments)
191+ open, err := is.ListOpen(ctx, n)
192+ require.NoError(t, err)
193+ assert.Empty(t, open)
194+ assert.Zero(t, countKeys(t, s, "is/proj/"))
195+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
196+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
197+
198+ open, err = is.ListOpen(ctx, sib)
199+ require.NoError(t, err)
200+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
201+ comments, err = is.ListComments(ctx, sib, 1)
202+ require.NoError(t, err)
203+ assert.Len(t, comments, 1)
204+
205+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
206+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
207+ require.NoError(t, err)
208+ assert.Equal(t, int64(1), again.Number)
209+}
210+
211+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
212+ ctx := context.Background()
213+ s, _, n := repoWithMRs(t)
214+ is := s.Issues()
215+ a, err := is.Create(ctx, newIssue(t, n, "a"))
216+ require.NoError(t, err)
217+ b, err := is.Create(ctx, newIssue(t, n, "b"))
218+ require.NoError(t, err)
219+
220+ numbers := func() []int64 {
221+ open, err := is.ListOpen(ctx, n)
222+ require.NoError(t, err)
223+ var out []int64
224+ for _, i := range open {
225+ out = append(out, i.Number)
226+ }
227+ return out
228+ }
229+ assert.Equal(t, []int64{2, 1}, numbers())
230+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231+
232+ a.State = domain.IssueStateClosed
233+ require.NoError(t, is.Update(ctx, a))
234+ assert.Equal(t, []int64{2}, numbers())
235+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236+
237+ a, err = is.Get(ctx, n, a.Number)
238+ require.NoError(t, err)
239+ a.State = domain.IssueStateOpen
240+ require.NoError(t, is.Update(ctx, a))
241+ assert.Equal(t, []int64{2, 1}, numbers())
242+
243+ // A comment keeps the index as the issue says.
244+ b.State = domain.IssueStateClosed
245+ b.CommentSeq = 1
246+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247+ assert.Equal(t, []int64{1}, numbers())
248+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249+}
250+
251+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252+ ctx := context.Background()
253+ s := openStore(t, t.TempDir())
254+ n := name(t, "proj")
255+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256+ got, err := s.Get(ctx, n)
257+ require.NoError(t, err)
258+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260+ got.Settings.Issues = m
261+ require.NoError(t, s.Update(ctx, got))
262+ again, err := s.Get(ctx, n)
263+ require.NoError(t, err)
264+ assert.Equal(t, m, again.Settings.Issues)
265+ }
266+ got.Settings.Issues = "everyone"
267+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+}
269+
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope
271+// before issue:write existed keep their owner rights; no other token changes.
272+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
273+ ctx := context.Background()
274+ dir := t.TempDir()
275+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
276+
277+ all9 := []string{
278+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
279+ "ci:write", "secrets:write", "runner:register", "token:admin",
280+ }
281+ tokens := map[string]*storagepb.Token{
282+ "full": {Id: "full", Scopes: all9},
283+ "missing": {Id: "missing", Scopes: all9[:8]},
284+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
285+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
286+ "small": {Id: "small", Scopes: []string{"repo:read"}},
287+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
288+ }
289+ for id, tok := range tokens {
290+ tok.Hash = make([]byte, 32)
291+ tok.Hash[0] = byte(len(id))
292+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
293+ }
294+
295+ s, err := kvstore.Open(ctx, dir)
296+ require.NoError(t, err)
297+ t.Cleanup(func() { _ = s.Close(ctx) })
298+ get := func(id string) domain.Token {
299+ tok, err := s.Tokens().Get(ctx, id)
300+ require.NoError(t, err)
301+ return tok
302+ }
303+
304+ full := get("full")
305+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
306+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
307+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
308+
309+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
310+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
311+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
312+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
313+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
314+ assert.Len(t, get("missing").Scopes, 8)
315+ assert.Len(t, get("repos").Scopes, 9)
316+ assert.Len(t, get("prefix").Scopes, 9)
317+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
318+ assert.Equal(t, "full", full.ID)
319+
320+ require.NoError(t, s.Close(ctx))
321+ assert.Equal(t, uint32(4), readSchema(t, dir))
322+
323+ // A second open changes nothing.
324+ s2, err := kvstore.Open(ctx, dir)
325+ require.NoError(t, err)
326+ tok, err := s2.Tokens().Get(ctx, "full")
327+ require.NoError(t, err)
328+ assert.Equal(t, full.Scopes, tok.Scopes)
329+ require.NoError(t, s2.Close(ctx))
330+}
331+
332+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
333+ ctx := context.Background()
334+ dir := t.TempDir()
335+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
336+ _, err := kvstore.Open(ctx, dir)
337+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
338+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
339+}
@@ -0,0 +1,339 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ storagepb "bnema.dev/ermite/gen/ermite/storage"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
20+ t.Helper()
21+ ti, err := domain.ParseIssueTitle(title)
22+ require.NoError(t, err)
23+ body, err := domain.ParseIssueBody("what\nhappened")
24+ require.NoError(t, err)
25+ return domain.Issue{
26+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
27+ Author: "agent", CreatedAt: created, UpdatedAt: created,
28+ }
29+}
30+
31+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
32+ t.Helper()
33+ body, err := domain.ParseIssueBody(text)
34+ require.NoError(t, err)
35+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
36+}
37+
38+// countKeys counts the keys of the store under prefix.
39+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
40+ t.Helper()
41+ return s.CountKeysForTest(context.Background(), prefix)
42+}
43+
44+func TestIssueRoundTripAndPaging(t *testing.T) {
45+ ctx := context.Background()
46+ s, _, n := repoWithMRs(t)
47+ is := s.Issues()
48+
49+ first, err := is.Create(ctx, newIssue(t, n, "one"))
50+ require.NoError(t, err)
51+ assert.Equal(t, int64(1), first.Number)
52+ assert.NotZero(t, first.Version)
53+ got, err := is.Get(ctx, n, 1)
54+ require.NoError(t, err)
55+ assert.Equal(t, first, got)
56+ _, err = is.Get(ctx, n, 2)
57+ assert.ErrorIs(t, err, domain.ErrNotFound)
58+
59+ for _, title := range []string{"two", "three", "four"} {
60+ _, err := is.Create(ctx, newIssue(t, n, title))
61+ require.NoError(t, err)
62+ }
63+ closed, err := is.Get(ctx, n, 2)
64+ require.NoError(t, err)
65+ closed.State = domain.IssueStateClosed
66+ require.NoError(t, is.Update(ctx, closed))
67+
68+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
69+ require.NoError(t, err)
70+ require.Len(t, page.Issues, 3)
71+ assert.Equal(t, int64(4), page.Issues[0].Number)
72+ require.NotEmpty(t, page.NextPageToken)
73+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
74+ require.NoError(t, err)
75+ require.Len(t, page.Issues, 1)
76+ assert.Equal(t, int64(1), page.Issues[0].Number)
77+ assert.Empty(t, page.NextPageToken)
78+
79+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
80+ require.NoError(t, err)
81+ require.Len(t, page.Issues, 1)
82+ assert.Equal(t, int64(2), page.Issues[0].Number)
83+
84+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
85+ assert.ErrorIs(t, err, domain.ErrInvalid)
86+
87+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
88+ assert.ErrorIs(t, err, domain.ErrNotFound)
89+}
90+
91+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ is := s.Issues()
95+ first, err := is.Create(ctx, newIssue(t, n, "t"))
96+ require.NoError(t, err)
97+ a, b := first, first
98+ a.State = domain.IssueStateClosed
99+ require.NoError(t, is.Update(ctx, a))
100+ b.State = domain.IssueStateClosed
101+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
102+}
103+
104+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
105+ ctx := context.Background()
106+ s, _, n := repoWithMRs(t)
107+ is := s.Issues()
108+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
109+ require.NoError(t, err)
110+
111+ issue.CommentSeq = 1
112+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
113+ // The issue moved on: the same version is refused.
114+ issue.CommentSeq = 2
115+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
116+ issue, err = is.Get(ctx, n, issue.Number)
117+ require.NoError(t, err)
118+ issue.CommentSeq = 2
119+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
120+
121+ comments, err := is.ListComments(ctx, n, issue.Number)
122+ require.NoError(t, err)
123+ require.Len(t, comments, 2)
124+ assert.Equal(t, "first", comments[0].Text.String())
125+ assert.Equal(t, int64(2), comments[1].ID)
126+
127+ require.NoError(t, s.Delete(ctx, n))
128+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
129+}
130+
131+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
132+ ctx := context.Background()
133+ s, mrs, n := repoWithMRs(t)
134+ is := s.Issues()
135+ const workers = 16
136+ var (
137+ wg sync.WaitGroup
138+ mu sync.Mutex
139+ numbers []int64
140+ )
141+ for w := range workers {
142+ wg.Go(func() {
143+ var number int64
144+ if w%2 == 0 {
145+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
146+ if !assert.NoError(t, err) {
147+ return
148+ }
149+ number = got.Number
150+ } else {
151+ got, err := is.Create(ctx, newIssue(t, n, "t"))
152+ if !assert.NoError(t, err) {
153+ return
154+ }
155+ number = got.Number
156+ }
157+ mu.Lock()
158+ numbers = append(numbers, number)
159+ mu.Unlock()
160+ })
161+ }
162+ wg.Wait()
163+ want := make([]int64, workers)
164+ for i := range want {
165+ want[i] = int64(i + 1)
166+ }
167+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
168+}
169+
170+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
171+ ctx := context.Background()
172+ s, _, n := repoWithMRs(t)
173+ sib := name(t, "proj-2")
174+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
175+ is := s.Issues()
176+
177+ for _, repo := range []domain.RepoName{n, sib} {
178+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
179+ require.NoError(t, err)
180+ issue.CommentSeq = 1
181+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
182+ }
183+ require.NoError(t, s.Delete(ctx, n))
184+
185+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
186+ require.NoError(t, err)
187+ assert.Empty(t, page.Issues)
188+ comments, err := is.ListComments(ctx, n, 1)
189+ require.NoError(t, err)
190+ assert.Empty(t, comments)
191+ open, err := is.ListOpen(ctx, n)
192+ require.NoError(t, err)
193+ assert.Empty(t, open)
194+ assert.Zero(t, countKeys(t, s, "is/proj/"))
195+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
196+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
197+
198+ open, err = is.ListOpen(ctx, sib)
199+ require.NoError(t, err)
200+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
201+ comments, err = is.ListComments(ctx, sib, 1)
202+ require.NoError(t, err)
203+ assert.Len(t, comments, 1)
204+
205+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
206+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
207+ require.NoError(t, err)
208+ assert.Equal(t, int64(1), again.Number)
209+}
210+
211+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
212+ ctx := context.Background()
213+ s, _, n := repoWithMRs(t)
214+ is := s.Issues()
215+ a, err := is.Create(ctx, newIssue(t, n, "a"))
216+ require.NoError(t, err)
217+ b, err := is.Create(ctx, newIssue(t, n, "b"))
218+ require.NoError(t, err)
219+
220+ numbers := func() []int64 {
221+ open, err := is.ListOpen(ctx, n)
222+ require.NoError(t, err)
223+ var out []int64
224+ for _, i := range open {
225+ out = append(out, i.Number)
226+ }
227+ return out
228+ }
229+ assert.Equal(t, []int64{2, 1}, numbers())
230+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231+
232+ a.State = domain.IssueStateClosed
233+ require.NoError(t, is.Update(ctx, a))
234+ assert.Equal(t, []int64{2}, numbers())
235+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236+
237+ a, err = is.Get(ctx, n, a.Number)
238+ require.NoError(t, err)
239+ a.State = domain.IssueStateOpen
240+ require.NoError(t, is.Update(ctx, a))
241+ assert.Equal(t, []int64{2, 1}, numbers())
242+
243+ // A comment keeps the index as the issue says.
244+ b.State = domain.IssueStateClosed
245+ b.CommentSeq = 1
246+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247+ assert.Equal(t, []int64{1}, numbers())
248+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249+}
250+
251+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252+ ctx := context.Background()
253+ s := openStore(t, t.TempDir())
254+ n := name(t, "proj")
255+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256+ got, err := s.Get(ctx, n)
257+ require.NoError(t, err)
258+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260+ got.Settings.Issues = m
261+ require.NoError(t, s.Update(ctx, got))
262+ again, err := s.Get(ctx, n)
263+ require.NoError(t, err)
264+ assert.Equal(t, m, again.Settings.Issues)
265+ }
266+ got.Settings.Issues = "everyone"
267+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+}
269+
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope
271+// before issue:write existed keep their owner rights; no other token changes.
272+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
273+ ctx := context.Background()
274+ dir := t.TempDir()
275+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
276+
277+ all9 := []string{
278+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
279+ "ci:write", "secrets:write", "runner:register", "token:admin",
280+ }
281+ tokens := map[string]*storagepb.Token{
282+ "full": {Id: "full", Scopes: all9},
283+ "missing": {Id: "missing", Scopes: all9[:8]},
284+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
285+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
286+ "small": {Id: "small", Scopes: []string{"repo:read"}},
287+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
288+ }
289+ for id, tok := range tokens {
290+ tok.Hash = make([]byte, 32)
291+ tok.Hash[0] = byte(len(id))
292+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
293+ }
294+
295+ s, err := kvstore.Open(ctx, dir)
296+ require.NoError(t, err)
297+ t.Cleanup(func() { _ = s.Close(ctx) })
298+ get := func(id string) domain.Token {
299+ tok, err := s.Tokens().Get(ctx, id)
300+ require.NoError(t, err)
301+ return tok
302+ }
303+
304+ full := get("full")
305+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
306+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
307+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
308+
309+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
310+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
311+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
312+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
313+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
314+ assert.Len(t, get("missing").Scopes, 8)
315+ assert.Len(t, get("repos").Scopes, 9)
316+ assert.Len(t, get("prefix").Scopes, 9)
317+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
318+ assert.Equal(t, "full", full.ID)
319+
320+ require.NoError(t, s.Close(ctx))
321+ assert.Equal(t, uint32(4), readSchema(t, dir))
322+
323+ // A second open changes nothing.
324+ s2, err := kvstore.Open(ctx, dir)
325+ require.NoError(t, err)
326+ tok, err := s2.Tokens().Get(ctx, "full")
327+ require.NoError(t, err)
328+ assert.Equal(t, full.Scopes, tok.Scopes)
329+ require.NoError(t, s2.Close(ctx))
330+}
331+
332+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
333+ ctx := context.Background()
334+ dir := t.TempDir()
335+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
336+ _, err := kvstore.Open(ctx, dir)
337+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
338+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
339+}
M internal/adapters/kvstore/mergerequests.go +47 -24
@@ -198,47 +198,65 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198198 //
199199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
201+ err := createNumbered(ctx, m.db, mr.Repo, func(b *kv.Batch, n int64) error {
202+ mr.Number = n
203+ val, err := encodeMergeRequest(mr)
204+ if err != nil {
205+ return err
206+ }
207+ b.RequireAbsent(mrKey(mr.Repo, n))
208+ b.Put(mrKey(mr.Repo, n), val)
209+ indexOpen(b, nil, mr)
210+ return nil
211+ })
212+ if err != nil {
213+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
214+ }
215+ return m.Get(ctx, mr.Repo, mr.Number)
216+}
217+
218+// createNumbered takes the next number of the repository from mrseq/<repo>,
219+// shared by the merge requests and the issues, and lets add put the record
220+// under it into a batch that requires the sequence to be unchanged since it was
221+// read (absent for the first number) and the repository to be unchanged (see
222+// guardRepoKey): two concurrent creations, of either kind, never share a
223+// number. It retries when the batch conflicts; a missing repository gives
224+// domain.ErrNotFound.
225+func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
201226 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))
227+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203228 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound
229+ return domain.ErrNotFound
205230 }
206231 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))
232+ item, ok := db.Get(ctx, seqKey(repo))
208233 if ok {
209234 var seq storagepb.Sequence
210235 if err := proto.Unmarshal(item.Value, &seq); err != nil {
211- return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err)
236+ return fmt.Errorf("decode sequence of %q: %w", repo, err)
212237 }
213238 last = seq.GetLast()
214239 }
215- mr.Number = last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
240+ number := last + 1
220241 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo
242+ b.RequireVersion(repoKey(repo), repoItem.Version)
243+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223244 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)
245+ b.RequireVersion(seqKey(repo), item.Version)
225246 } else {
226- b.RequireAbsent(seqKey(mr.Repo))
247+ b.RequireAbsent(seqKey(repo))
227248 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))
230- b.Put(mrKey(mr.Repo, mr.Number), val)
231- indexOpen(&b, nil, mr)
232- _, err = m.db.Apply(ctx, &b)
249+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
250+ if err := add(&b, number); err != nil {
251+ return err
252+ }
253+ _, err := db.Apply(ctx, &b)
233254 if errors.Is(err, kv.ErrConflict) {
234255 continue
235256 }
236- if err != nil {
237- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
238- }
239- return m.Get(ctx, mr.Repo, mr.Number)
257+ return err
240258 }
241- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict)
259+ return domain.ErrConflict
242260 }
243261
244262 // Get reads mr/<repo>/<number>.
@@ -683,6 +701,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
683701 }
684702 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
685703 }
704+ issues, err := encodeIssuesMode(s.Issues)
705+ if err != nil {
706+ return nil, err
707+ }
708+ pb.Issues = issues
686709 return pb, nil
687710 }
688711
@@ -707,5 +730,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
707730 if err != nil {
708731 return domain.DefaultRepoSettings()
709732 }
710- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}
733+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
711734 }
@@ -198,47 +198,65 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198 //198 //
199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
201+ err := createNumbered(ctx, m.db, mr.Repo, func(b *kv.Batch, n int64) error {
202+ mr.Number = n
203+ val, err := encodeMergeRequest(mr)
204+ if err != nil {
205+ return err
206+ }
207+ b.RequireAbsent(mrKey(mr.Repo, n))
208+ b.Put(mrKey(mr.Repo, n), val)
209+ indexOpen(b, nil, mr)
210+ return nil
211+ })
212+ if err != nil {
213+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
214+ }
215+ return m.Get(ctx, mr.Repo, mr.Number)
216+}
217+
218+// createNumbered takes the next number of the repository from mrseq/<repo>,
219+// shared by the merge requests and the issues, and lets add put the record
220+// under it into a batch that requires the sequence to be unchanged since it was
221+// read (absent for the first number) and the repository to be unchanged (see
222+// guardRepoKey): two concurrent creations, of either kind, never share a
223+// number. It retries when the batch conflicts; a missing repository gives
224+// domain.ErrNotFound.
225+func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
201 for range createTries {226 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))227+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203 if !repoOK {228 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound229+ return domain.ErrNotFound
205 }230 }
206 var last int64231 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))232+ item, ok := db.Get(ctx, seqKey(repo))
208 if ok {233 if ok {
209 var seq storagepb.Sequence234 var seq storagepb.Sequence
210 if err := proto.Unmarshal(item.Value, &seq); err != nil {235 if err := proto.Unmarshal(item.Value, &seq); err != nil {
211- return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err)236+ return fmt.Errorf("decode sequence of %q: %w", repo, err)
212 }237 }
213 last = seq.GetLast()238 last = seq.GetLast()
214 }239 }
215- mr.Number = last + 1240+ number := last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
220 var b kv.Batch241 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)242+ b.RequireVersion(repoKey(repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo243+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223 if ok {244 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)245+ b.RequireVersion(seqKey(repo), item.Version)
225 } else {246 } else {
226- b.RequireAbsent(seqKey(mr.Repo))247+ b.RequireAbsent(seqKey(repo))
227 }248 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))249+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))250+ if err := add(&b, number); err != nil {
230- b.Put(mrKey(mr.Repo, mr.Number), val)251+ return err
231- indexOpen(&b, nil, mr)252+ }
232- _, err = m.db.Apply(ctx, &b)253+ _, err := db.Apply(ctx, &b)
233 if errors.Is(err, kv.ErrConflict) {254 if errors.Is(err, kv.ErrConflict) {
234 continue255 continue
235 }256 }
236- if err != nil {257+ return err
237- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
238- }
239- return m.Get(ctx, mr.Repo, mr.Number)
240 }258 }
241- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict)259+ return domain.ErrConflict
242 }260 }
243 261
244 // Get reads mr/<repo>/<number>.262 // Get reads mr/<repo>/<number>.
@@ -683,6 +701,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
683 }701 }
684 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)702 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
685 }703 }
704+ issues, err := encodeIssuesMode(s.Issues)
705+ if err != nil {
706+ return nil, err
707+ }
708+ pb.Issues = issues
686 return pb, nil709 return pb, nil
687 }710 }
688 711
@@ -707,5 +730,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
707 if err != nil {730 if err != nil {
708 return domain.DefaultRepoSettings()731 return domain.DefaultRepoSettings()
709 }732 }
710- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}733+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
711 }734 }
M internal/adapters/kvstore/mergerequests_test.go +1 -1
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441441 require.NoError(t, err)
442442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443443
444- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}
444+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445445 require.NoError(t, s.Update(ctx, got))
446446 again, err := s.Get(ctx, n)
447447 require.NoError(t, err)
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441 require.NoError(t, err)441 require.NoError(t, err)
442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443 443
444- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}444+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445 require.NoError(t, s.Update(ctx, got))445 require.NoError(t, s.Update(ctx, got))
446 again, err := s.Get(ctx, n)446 again, err := s.Get(ctx, n)
447 require.NoError(t, err)447 require.NoError(t, err)
M internal/adapters/kvstore/repos.go +2 -1
@@ -144,7 +144,8 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144144 for range deleteTries {
145145 snap := s.db.Snapshot(ctx)
146146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147- keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)
147+ keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, ciKeys(ctx, snap, name)...)
148149 keys = append(keys, settingsKeys(ctx, snap, name)...)
149150 for len(keys) > deleteChunk {
150151 var b kv.Batch
@@ -144,7 +144,8 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144 for range deleteTries {144 for range deleteTries {
145 snap := s.db.Snapshot(ctx)145 snap := s.db.Snapshot(ctx)
146 repoItem, repoOK := snap.Get(ctx, repoKey(name))146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147- keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)147+ keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, ciKeys(ctx, snap, name)...)
148 keys = append(keys, settingsKeys(ctx, snap, name)...)149 keys = append(keys, settingsKeys(ctx, snap, name)...)
149 for len(keys) > deleteChunk {150 for len(keys) > deleteChunk {
150 var b kv.Batch151 var b kv.Batch
M internal/adapters/kvstore/store.go +59 -2
@@ -8,12 +8,14 @@ import (
88 "context"
99 "errors"
1010 "fmt"
11+ "slices"
1112
1213 kv "bnema.dev/ermite-kv"
1314 "github.com/bnema/zerowrap"
1415 "google.golang.org/protobuf/proto"
1516
1617 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
1719 "bnema.dev/ermite/internal/ports"
1820 )
1921
@@ -27,8 +29,14 @@ import (
2729 // (CIRun.head_sha: it would attach their checks to the merge commit, where
2830 // nothing looks for them), and the index of the open merge requests by source
2931 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).
31-const SchemaVersion = 3
32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
33+// isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34+// RepoSettings.issues): an older build would not delete them with their
35+// repository, nor keep the numbers apart, and it would misread the issue:write
36+// scope, so it must refuse the data. The upgrade also adds issue:write to the
37+// stored tokens that held all nine earlier scopes with no repository list and
38+// no branch prefix, so that they keep the owner rights (see upgradeTokens).
39+const SchemaVersion = 4
3240
3341 const schemaKey = "meta/schema"
3442
@@ -125,8 +133,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125133 if sv.GetVersion() < SchemaVersion {
126134 // Older data stays readable as it is (the changes are additive);
127135 // the new version is recorded so that older builds refuse it.
136+ // The tokens are upgraded in the same batch, so that a crash
137+ // leaves the old version and the upgrade runs again.
128138 var b kv.Batch
129139 b.RequireVersion(key, item.Version)
140+ if sv.GetVersion() < 4 {
141+ if err := s.upgradeTokens(ctx, &b); err != nil {
142+ return err
143+ }
144+ }
130145 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131146 _, err := s.db.Apply(ctx, &b)
132147 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +166,45 @@ func mustMarshal(m proto.Message) []byte {
151166 }
152167 return b
153168 }
169+
170+// preIssueScopes are the nine scopes that existed before schema 4.
171+var preIssueScopes = []domain.Scope{
172+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
173+ domain.ScopeMRWrite, domain.ScopeMRMerge,
174+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
175+ domain.ScopeTokenAdmin,
176+}
177+
178+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
179+// holds all of preIssueScopes, limited to no repository and no branch prefix,
180+// had the owner rights, which AllScopes now needs issue:write for, so it gets
181+// the scope (this is no escalation). Every other token is left as it is. The
182+// batch requires each token to be at the version it was read at.
183+//
184+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
185+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
186+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
187+ pb := &storagepb.Token{}
188+ if err := proto.Unmarshal(item.Value, pb); err != nil {
189+ return fmt.Errorf("upgrade token %s: %w", key, err)
190+ }
191+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 ||
192+ slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) {
193+ continue
194+ }
195+ full := true
196+ for _, sc := range preIssueScopes {
197+ if !slices.Contains(pb.GetScopes(), sc.String()) {
198+ full = false
199+ break
200+ }
201+ }
202+ if !full {
203+ continue
204+ }
205+ pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String())
206+ b.RequireVersion(slices.Clone(key), item.Version)
207+ b.Put(slices.Clone(key), mustMarshal(pb))
208+ }
209+ return nil
210+}
@@ -8,12 +8,14 @@ import (
8 "context"8 "context"
9 "errors"9 "errors"
10 "fmt"10 "fmt"
11+ "slices"
11 12
12 kv "bnema.dev/ermite-kv"13 kv "bnema.dev/ermite-kv"
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 "google.golang.org/protobuf/proto"15 "google.golang.org/protobuf/proto"
15 16
16 storagepb "bnema.dev/ermite/gen/ermite/storage"17 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
18 )20 )
19 21
@@ -27,8 +29,14 @@ import (
27 // (CIRun.head_sha: it would attach their checks to the merge commit, where29 // (CIRun.head_sha: it would attach their checks to the merge commit, where
28 // nothing looks for them), and the index of the open merge requests by source30 // nothing looks for them), and the index of the open merge requests by source
29 // branch (mropen/: an older build would not keep it up to date; the upgrade31 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
31-const SchemaVersion = 333+// isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34+// RepoSettings.issues): an older build would not delete them with their
35+// repository, nor keep the numbers apart, and it would misread the issue:write
36+// scope, so it must refuse the data. The upgrade also adds issue:write to the
37+// stored tokens that held all nine earlier scopes with no repository list and
38+// no branch prefix, so that they keep the owner rights (see upgradeTokens).
39+const SchemaVersion = 4
32 40
33 const schemaKey = "meta/schema"41 const schemaKey = "meta/schema"
34 42
@@ -125,8 +133,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125 if sv.GetVersion() < SchemaVersion {133 if sv.GetVersion() < SchemaVersion {
126 // Older data stays readable as it is (the changes are additive);134 // Older data stays readable as it is (the changes are additive);
127 // the new version is recorded so that older builds refuse it.135 // the new version is recorded so that older builds refuse it.
136+ // The tokens are upgraded in the same batch, so that a crash
137+ // leaves the old version and the upgrade runs again.
128 var b kv.Batch138 var b kv.Batch
129 b.RequireVersion(key, item.Version)139 b.RequireVersion(key, item.Version)
140+ if sv.GetVersion() < 4 {
141+ if err := s.upgradeTokens(ctx, &b); err != nil {
142+ return err
143+ }
144+ }
130 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))145 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131 _, err := s.db.Apply(ctx, &b)146 _, err := s.db.Apply(ctx, &b)
132 if errors.Is(err, kv.ErrConflict) {147 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +166,45 @@ func mustMarshal(m proto.Message) []byte {
151 }166 }
152 return b167 return b
153 }168 }
169+
170+// preIssueScopes are the nine scopes that existed before schema 4.
171+var preIssueScopes = []domain.Scope{
172+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
173+ domain.ScopeMRWrite, domain.ScopeMRMerge,
174+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
175+ domain.ScopeTokenAdmin,
176+}
177+
178+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
179+// holds all of preIssueScopes, limited to no repository and no branch prefix,
180+// had the owner rights, which AllScopes now needs issue:write for, so it gets
181+// the scope (this is no escalation). Every other token is left as it is. The
182+// batch requires each token to be at the version it was read at.
183+//
184+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
185+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
186+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
187+ pb := &storagepb.Token{}
188+ if err := proto.Unmarshal(item.Value, pb); err != nil {
189+ return fmt.Errorf("upgrade token %s: %w", key, err)
190+ }
191+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 ||
192+ slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) {
193+ continue
194+ }
195+ full := true
196+ for _, sc := range preIssueScopes {
197+ if !slices.Contains(pb.GetScopes(), sc.String()) {
198+ full = false
199+ break
200+ }
201+ }
202+ if !full {
203+ continue
204+ }
205+ pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String())
206+ b.RequireVersion(slices.Clone(key), item.Version)
207+ b.Put(slices.Clone(key), mustMarshal(pb))
208+ }
209+ return nil
210+}
M internal/adapters/kvstore/store_test.go +4 -4
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231231 })
232232
233- t.Run("version 2 is upgraded to 3, its runs kept", func(t *testing.T) {
233+ t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
234234 dir := t.TempDir()
235235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -261,10 +261,10 @@ func TestSchema(t *testing.T) {
261261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263263 require.NoError(t, s.Close(ctx))
264- assert.Equal(t, uint32(3), readSchema(t, dir))
264+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
265265 })
266266
267- t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) {
267+ t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
268268 dir := t.TempDir()
269269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -273,7 +273,7 @@ func TestSchema(t *testing.T) {
273273 _, err = s.Get(ctx, name(t, "old"))
274274 require.NoError(t, err)
275275 require.NoError(t, s.Close(ctx))
276- assert.Equal(t, uint32(3), readSchema(t, dir))
276+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
277277 })
278278
279279 // Downgrade guard: once a build with reusable workflows has opened the data
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231 })231 })
232 232
233- t.Run("version 2 is upgraded to 3, its runs kept", func(t *testing.T) {233+ t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
234 dir := t.TempDir()234 dir := t.TempDir()
235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -261,10 +261,10 @@ func TestSchema(t *testing.T) {
261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263 require.NoError(t, s.Close(ctx))263 require.NoError(t, s.Close(ctx))
264- assert.Equal(t, uint32(3), readSchema(t, dir))264+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
265 })265 })
266 266
267- t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) {267+ t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
268 dir := t.TempDir()268 dir := t.TempDir()
269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -273,7 +273,7 @@ func TestSchema(t *testing.T) {
273 _, err = s.Get(ctx, name(t, "old"))273 _, err = s.Get(ctx, name(t, "old"))
274 require.NoError(t, err)274 require.NoError(t, err)
275 require.NoError(t, s.Close(ctx))275 require.NoError(t, s.Close(ctx))
276- assert.Equal(t, uint32(3), readSchema(t, dir))276+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
277 })277 })
278 278
279 // Downgrade guard: once a build with reusable workflows has opened the data279 // Downgrade guard: once a build with reusable workflows has opened the data
M internal/ports/dto.go +18 -0
@@ -198,6 +198,24 @@ type MergeRequestPage struct {
198198 NextPageToken string
199199 }
200200
201+// ListIssuesParams selects a page of issues from an IssueStore.
202+type ListIssuesParams struct {
203+ // State restricts the result; empty means every state.
204+ State domain.IssueState
205+ // PageSize is the most issues returned; 0 or less means no limit. The
206+ // use case chooses it.
207+ PageSize int
208+ // PageToken is the NextPageToken of the previous page, or empty.
209+ PageToken string
210+}
211+
212+// IssuePage is a page of issues, highest number first.
213+type IssuePage struct {
214+ Issues []domain.Issue
215+ // NextPageToken is empty on the last page.
216+ NextPageToken string
217+}
218+
201219 // MergeRequestDetail is a merge request with its review data.
202220 type MergeRequestDetail struct {
203221 MergeRequest domain.MergeRequest
@@ -198,6 +198,24 @@ type MergeRequestPage struct {
198 NextPageToken string198 NextPageToken string
199 }199 }
200 200
201+// ListIssuesParams selects a page of issues from an IssueStore.
202+type ListIssuesParams struct {
203+ // State restricts the result; empty means every state.
204+ State domain.IssueState
205+ // PageSize is the most issues returned; 0 or less means no limit. The
206+ // use case chooses it.
207+ PageSize int
208+ // PageToken is the NextPageToken of the previous page, or empty.
209+ PageToken string
210+}
211+
212+// IssuePage is a page of issues, highest number first.
213+type IssuePage struct {
214+ Issues []domain.Issue
215+ // NextPageToken is empty on the last page.
216+ NextPageToken string
217+}
218+
201 // MergeRequestDetail is a merge request with its review data.219 // MergeRequestDetail is a merge request with its review data.
202 type MergeRequestDetail struct {220 type MergeRequestDetail struct {
203 MergeRequest domain.MergeRequest221 MergeRequest domain.MergeRequest
A internal/ports/mocks/IssueStore.go +521 -0
@@ -0,0 +1,521 @@
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/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueStore creates a new instance of IssueStore. 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 NewIssueStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueStore{}
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+// IssueStore is an autogenerated mock type for the IssueStore type
39+type IssueStore struct {
40+ mock.Mock
41+}
42+
43+type IssueStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueStore) EXPECT() *IssueStore_Expecter {
48+ return &IssueStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type IssueStore
52+func (_mock *IssueStore) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, issue)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, issue)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) domain.Issue); ok {
65+ r0 = returnFunc(ctx, issue)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Issue) error); ok {
70+ r1 = returnFunc(ctx, issue)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
78+type IssueStore_Create_Call struct {
79+ *mock.Call
80+}
81+
82+// Create is a helper method to define mock.On call
83+// - ctx context.Context
84+// - issue domain.Issue
85+func (_e *IssueStore_Expecter) Create(ctx any, issue any) *IssueStore_Create_Call {
86+ return &IssueStore_Create_Call{Call: _e.mock.On("Create", ctx, issue)}
87+}
88+
89+func (_c *IssueStore_Create_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Create_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Issue
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Issue)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *IssueStore_Create_Call) Return(issue1 domain.Issue, err error) *IssueStore_Create_Call {
108+ _c.Call.Return(issue1, err)
109+ return _c
110+}
111+
112+func (_c *IssueStore_Create_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) (domain.Issue, error)) *IssueStore_Create_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// Get provides a mock function for the type IssueStore
118+func (_mock *IssueStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
119+ ret := _mock.Called(ctx, repo, number)
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for Get")
123+ }
124+
125+ var r0 domain.Issue
126+ var r1 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Issue, error)); ok {
128+ return returnFunc(ctx, repo, number)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Issue); ok {
131+ r0 = returnFunc(ctx, repo, number)
132+ } else {
133+ r0 = ret.Get(0).(domain.Issue)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
136+ r1 = returnFunc(ctx, repo, number)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// IssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
144+type IssueStore_Get_Call struct {
145+ *mock.Call
146+}
147+
148+// Get is a helper method to define mock.On call
149+// - ctx context.Context
150+// - repo domain.RepoName
151+// - number int64
152+func (_e *IssueStore_Expecter) Get(ctx any, repo any, number any) *IssueStore_Get_Call {
153+ return &IssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, number)}
154+}
155+
156+func (_c *IssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_Get_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 context.Context
159+ if args[0] != nil {
160+ arg0 = args[0].(context.Context)
161+ }
162+ var arg1 domain.RepoName
163+ if args[1] != nil {
164+ arg1 = args[1].(domain.RepoName)
165+ }
166+ var arg2 int64
167+ if args[2] != nil {
168+ arg2 = args[2].(int64)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *IssueStore_Get_Call) Return(issue domain.Issue, err error) *IssueStore_Get_Call {
180+ _c.Call.Return(issue, err)
181+ return _c
182+}
183+
184+func (_c *IssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)) *IssueStore_Get_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// List provides a mock function for the type IssueStore
190+func (_mock *IssueStore) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
191+ ret := _mock.Called(ctx, repo, params)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for List")
195+ }
196+
197+ var r0 ports.IssuePage
198+ var r1 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
200+ return returnFunc(ctx, repo, params)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
203+ r0 = returnFunc(ctx, repo, params)
204+ } else {
205+ r0 = ret.Get(0).(ports.IssuePage)
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListIssuesParams) error); ok {
208+ r1 = returnFunc(ctx, repo, params)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// IssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
216+type IssueStore_List_Call struct {
217+ *mock.Call
218+}
219+
220+// List is a helper method to define mock.On call
221+// - ctx context.Context
222+// - repo domain.RepoName
223+// - params ports.ListIssuesParams
224+func (_e *IssueStore_Expecter) List(ctx any, repo any, params any) *IssueStore_List_Call {
225+ return &IssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, params)}
226+}
227+
228+func (_c *IssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams)) *IssueStore_List_Call {
229+ _c.Call.Run(func(args mock.Arguments) {
230+ var arg0 context.Context
231+ if args[0] != nil {
232+ arg0 = args[0].(context.Context)
233+ }
234+ var arg1 domain.RepoName
235+ if args[1] != nil {
236+ arg1 = args[1].(domain.RepoName)
237+ }
238+ var arg2 ports.ListIssuesParams
239+ if args[2] != nil {
240+ arg2 = args[2].(ports.ListIssuesParams)
241+ }
242+ run(
243+ arg0,
244+ arg1,
245+ arg2,
246+ )
247+ })
248+ return _c
249+}
250+
251+func (_c *IssueStore_List_Call) Return(issuePage ports.IssuePage, err error) *IssueStore_List_Call {
252+ _c.Call.Return(issuePage, err)
253+ return _c
254+}
255+
256+func (_c *IssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueStore_List_Call {
257+ _c.Call.Return(run)
258+ return _c
259+}
260+
261+// ListComments provides a mock function for the type IssueStore
262+func (_mock *IssueStore) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
263+ ret := _mock.Called(ctx, repo, number)
264+
265+ if len(ret) == 0 {
266+ panic("no return value specified for ListComments")
267+ }
268+
269+ var r0 []domain.IssueComment
270+ var r1 error
271+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueComment, error)); ok {
272+ return returnFunc(ctx, repo, number)
273+ }
274+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueComment); ok {
275+ r0 = returnFunc(ctx, repo, number)
276+ } else {
277+ if ret.Get(0) != nil {
278+ r0 = ret.Get(0).([]domain.IssueComment)
279+ }
280+ }
281+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
282+ r1 = returnFunc(ctx, repo, number)
283+ } else {
284+ r1 = ret.Error(1)
285+ }
286+ return r0, r1
287+}
288+
289+// IssueStore_ListComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListComments'
290+type IssueStore_ListComments_Call struct {
291+ *mock.Call
292+}
293+
294+// ListComments is a helper method to define mock.On call
295+// - ctx context.Context
296+// - repo domain.RepoName
297+// - number int64
298+func (_e *IssueStore_Expecter) ListComments(ctx any, repo any, number any) *IssueStore_ListComments_Call {
299+ return &IssueStore_ListComments_Call{Call: _e.mock.On("ListComments", ctx, repo, number)}
300+}
301+
302+func (_c *IssueStore_ListComments_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_ListComments_Call {
303+ _c.Call.Run(func(args mock.Arguments) {
304+ var arg0 context.Context
305+ if args[0] != nil {
306+ arg0 = args[0].(context.Context)
307+ }
308+ var arg1 domain.RepoName
309+ if args[1] != nil {
310+ arg1 = args[1].(domain.RepoName)
311+ }
312+ var arg2 int64
313+ if args[2] != nil {
314+ arg2 = args[2].(int64)
315+ }
316+ run(
317+ arg0,
318+ arg1,
319+ arg2,
320+ )
321+ })
322+ return _c
323+}
324+
325+func (_c *IssueStore_ListComments_Call) Return(issueComments []domain.IssueComment, err error) *IssueStore_ListComments_Call {
326+ _c.Call.Return(issueComments, err)
327+ return _c
328+}
329+
330+func (_c *IssueStore_ListComments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)) *IssueStore_ListComments_Call {
331+ _c.Call.Return(run)
332+ return _c
333+}
334+
335+// ListOpen provides a mock function for the type IssueStore
336+func (_mock *IssueStore) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
337+ ret := _mock.Called(ctx, repo)
338+
339+ if len(ret) == 0 {
340+ panic("no return value specified for ListOpen")
341+ }
342+
343+ var r0 []domain.Issue
344+ var r1 error
345+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Issue, error)); ok {
346+ return returnFunc(ctx, repo)
347+ }
348+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Issue); ok {
349+ r0 = returnFunc(ctx, repo)
350+ } else {
351+ if ret.Get(0) != nil {
352+ r0 = ret.Get(0).([]domain.Issue)
353+ }
354+ }
355+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
356+ r1 = returnFunc(ctx, repo)
357+ } else {
358+ r1 = ret.Error(1)
359+ }
360+ return r0, r1
361+}
362+
363+// IssueStore_ListOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpen'
364+type IssueStore_ListOpen_Call struct {
365+ *mock.Call
366+}
367+
368+// ListOpen is a helper method to define mock.On call
369+// - ctx context.Context
370+// - repo domain.RepoName
371+func (_e *IssueStore_Expecter) ListOpen(ctx any, repo any) *IssueStore_ListOpen_Call {
372+ return &IssueStore_ListOpen_Call{Call: _e.mock.On("ListOpen", ctx, repo)}
373+}
374+
375+func (_c *IssueStore_ListOpen_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *IssueStore_ListOpen_Call {
376+ _c.Call.Run(func(args mock.Arguments) {
377+ var arg0 context.Context
378+ if args[0] != nil {
379+ arg0 = args[0].(context.Context)
380+ }
381+ var arg1 domain.RepoName
382+ if args[1] != nil {
383+ arg1 = args[1].(domain.RepoName)
384+ }
385+ run(
386+ arg0,
387+ arg1,
388+ )
389+ })
390+ return _c
391+}
392+
393+func (_c *IssueStore_ListOpen_Call) Return(issues []domain.Issue, err error) *IssueStore_ListOpen_Call {
394+ _c.Call.Return(issues, err)
395+ return _c
396+}
397+
398+func (_c *IssueStore_ListOpen_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)) *IssueStore_ListOpen_Call {
399+ _c.Call.Return(run)
400+ return _c
401+}
402+
403+// SaveComment provides a mock function for the type IssueStore
404+func (_mock *IssueStore) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
405+ ret := _mock.Called(ctx, issue, c)
406+
407+ if len(ret) == 0 {
408+ panic("no return value specified for SaveComment")
409+ }
410+
411+ var r0 error
412+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue, domain.IssueComment) error); ok {
413+ r0 = returnFunc(ctx, issue, c)
414+ } else {
415+ r0 = ret.Error(0)
416+ }
417+ return r0
418+}
419+
420+// IssueStore_SaveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveComment'
421+type IssueStore_SaveComment_Call struct {
422+ *mock.Call
423+}
424+
425+// SaveComment is a helper method to define mock.On call
426+// - ctx context.Context
427+// - issue domain.Issue
428+// - c domain.IssueComment
429+func (_e *IssueStore_Expecter) SaveComment(ctx any, issue any, c any) *IssueStore_SaveComment_Call {
430+ return &IssueStore_SaveComment_Call{Call: _e.mock.On("SaveComment", ctx, issue, c)}
431+}
432+
433+func (_c *IssueStore_SaveComment_Call) Run(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment)) *IssueStore_SaveComment_Call {
434+ _c.Call.Run(func(args mock.Arguments) {
435+ var arg0 context.Context
436+ if args[0] != nil {
437+ arg0 = args[0].(context.Context)
438+ }
439+ var arg1 domain.Issue
440+ if args[1] != nil {
441+ arg1 = args[1].(domain.Issue)
442+ }
443+ var arg2 domain.IssueComment
444+ if args[2] != nil {
445+ arg2 = args[2].(domain.IssueComment)
446+ }
447+ run(
448+ arg0,
449+ arg1,
450+ arg2,
451+ )
452+ })
453+ return _c
454+}
455+
456+func (_c *IssueStore_SaveComment_Call) Return(err error) *IssueStore_SaveComment_Call {
457+ _c.Call.Return(err)
458+ return _c
459+}
460+
461+func (_c *IssueStore_SaveComment_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment) error) *IssueStore_SaveComment_Call {
462+ _c.Call.Return(run)
463+ return _c
464+}
465+
466+// Update provides a mock function for the type IssueStore
467+func (_mock *IssueStore) Update(ctx context.Context, issue domain.Issue) error {
468+ ret := _mock.Called(ctx, issue)
469+
470+ if len(ret) == 0 {
471+ panic("no return value specified for Update")
472+ }
473+
474+ var r0 error
475+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) error); ok {
476+ r0 = returnFunc(ctx, issue)
477+ } else {
478+ r0 = ret.Error(0)
479+ }
480+ return r0
481+}
482+
483+// IssueStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
484+type IssueStore_Update_Call struct {
485+ *mock.Call
486+}
487+
488+// Update is a helper method to define mock.On call
489+// - ctx context.Context
490+// - issue domain.Issue
491+func (_e *IssueStore_Expecter) Update(ctx any, issue any) *IssueStore_Update_Call {
492+ return &IssueStore_Update_Call{Call: _e.mock.On("Update", ctx, issue)}
493+}
494+
495+func (_c *IssueStore_Update_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Update_Call {
496+ _c.Call.Run(func(args mock.Arguments) {
497+ var arg0 context.Context
498+ if args[0] != nil {
499+ arg0 = args[0].(context.Context)
500+ }
501+ var arg1 domain.Issue
502+ if args[1] != nil {
503+ arg1 = args[1].(domain.Issue)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ )
509+ })
510+ return _c
511+}
512+
513+func (_c *IssueStore_Update_Call) Return(err error) *IssueStore_Update_Call {
514+ _c.Call.Return(err)
515+ return _c
516+}
517+
518+func (_c *IssueStore_Update_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) error) *IssueStore_Update_Call {
519+ _c.Call.Return(run)
520+ return _c
521+}
@@ -0,0 +1,521 @@
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/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueStore creates a new instance of IssueStore. 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 NewIssueStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueStore{}
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+// IssueStore is an autogenerated mock type for the IssueStore type
39+type IssueStore struct {
40+ mock.Mock
41+}
42+
43+type IssueStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueStore) EXPECT() *IssueStore_Expecter {
48+ return &IssueStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type IssueStore
52+func (_mock *IssueStore) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, issue)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, issue)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) domain.Issue); ok {
65+ r0 = returnFunc(ctx, issue)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Issue) error); ok {
70+ r1 = returnFunc(ctx, issue)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
78+type IssueStore_Create_Call struct {
79+ *mock.Call
80+}
81+
82+// Create is a helper method to define mock.On call
83+// - ctx context.Context
84+// - issue domain.Issue
85+func (_e *IssueStore_Expecter) Create(ctx any, issue any) *IssueStore_Create_Call {
86+ return &IssueStore_Create_Call{Call: _e.mock.On("Create", ctx, issue)}
87+}
88+
89+func (_c *IssueStore_Create_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Create_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Issue
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Issue)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *IssueStore_Create_Call) Return(issue1 domain.Issue, err error) *IssueStore_Create_Call {
108+ _c.Call.Return(issue1, err)
109+ return _c
110+}
111+
112+func (_c *IssueStore_Create_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) (domain.Issue, error)) *IssueStore_Create_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// Get provides a mock function for the type IssueStore
118+func (_mock *IssueStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
119+ ret := _mock.Called(ctx, repo, number)
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for Get")
123+ }
124+
125+ var r0 domain.Issue
126+ var r1 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Issue, error)); ok {
128+ return returnFunc(ctx, repo, number)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Issue); ok {
131+ r0 = returnFunc(ctx, repo, number)
132+ } else {
133+ r0 = ret.Get(0).(domain.Issue)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
136+ r1 = returnFunc(ctx, repo, number)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// IssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
144+type IssueStore_Get_Call struct {
145+ *mock.Call
146+}
147+
148+// Get is a helper method to define mock.On call
149+// - ctx context.Context
150+// - repo domain.RepoName
151+// - number int64
152+func (_e *IssueStore_Expecter) Get(ctx any, repo any, number any) *IssueStore_Get_Call {
153+ return &IssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, number)}
154+}
155+
156+func (_c *IssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_Get_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 context.Context
159+ if args[0] != nil {
160+ arg0 = args[0].(context.Context)
161+ }
162+ var arg1 domain.RepoName
163+ if args[1] != nil {
164+ arg1 = args[1].(domain.RepoName)
165+ }
166+ var arg2 int64
167+ if args[2] != nil {
168+ arg2 = args[2].(int64)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *IssueStore_Get_Call) Return(issue domain.Issue, err error) *IssueStore_Get_Call {
180+ _c.Call.Return(issue, err)
181+ return _c
182+}
183+
184+func (_c *IssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)) *IssueStore_Get_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// List provides a mock function for the type IssueStore
190+func (_mock *IssueStore) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
191+ ret := _mock.Called(ctx, repo, params)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for List")
195+ }
196+
197+ var r0 ports.IssuePage
198+ var r1 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
200+ return returnFunc(ctx, repo, params)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
203+ r0 = returnFunc(ctx, repo, params)
204+ } else {
205+ r0 = ret.Get(0).(ports.IssuePage)
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListIssuesParams) error); ok {
208+ r1 = returnFunc(ctx, repo, params)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// IssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
216+type IssueStore_List_Call struct {
217+ *mock.Call
218+}
219+
220+// List is a helper method to define mock.On call
221+// - ctx context.Context
222+// - repo domain.RepoName
223+// - params ports.ListIssuesParams
224+func (_e *IssueStore_Expecter) List(ctx any, repo any, params any) *IssueStore_List_Call {
225+ return &IssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, params)}
226+}
227+
228+func (_c *IssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams)) *IssueStore_List_Call {
229+ _c.Call.Run(func(args mock.Arguments) {
230+ var arg0 context.Context
231+ if args[0] != nil {
232+ arg0 = args[0].(context.Context)
233+ }
234+ var arg1 domain.RepoName
235+ if args[1] != nil {
236+ arg1 = args[1].(domain.RepoName)
237+ }
238+ var arg2 ports.ListIssuesParams
239+ if args[2] != nil {
240+ arg2 = args[2].(ports.ListIssuesParams)
241+ }
242+ run(
243+ arg0,
244+ arg1,
245+ arg2,
246+ )
247+ })
248+ return _c
249+}
250+
251+func (_c *IssueStore_List_Call) Return(issuePage ports.IssuePage, err error) *IssueStore_List_Call {
252+ _c.Call.Return(issuePage, err)
253+ return _c
254+}
255+
256+func (_c *IssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueStore_List_Call {
257+ _c.Call.Return(run)
258+ return _c
259+}
260+
261+// ListComments provides a mock function for the type IssueStore
262+func (_mock *IssueStore) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
263+ ret := _mock.Called(ctx, repo, number)
264+
265+ if len(ret) == 0 {
266+ panic("no return value specified for ListComments")
267+ }
268+
269+ var r0 []domain.IssueComment
270+ var r1 error
271+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueComment, error)); ok {
272+ return returnFunc(ctx, repo, number)
273+ }
274+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueComment); ok {
275+ r0 = returnFunc(ctx, repo, number)
276+ } else {
277+ if ret.Get(0) != nil {
278+ r0 = ret.Get(0).([]domain.IssueComment)
279+ }
280+ }
281+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
282+ r1 = returnFunc(ctx, repo, number)
283+ } else {
284+ r1 = ret.Error(1)
285+ }
286+ return r0, r1
287+}
288+
289+// IssueStore_ListComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListComments'
290+type IssueStore_ListComments_Call struct {
291+ *mock.Call
292+}
293+
294+// ListComments is a helper method to define mock.On call
295+// - ctx context.Context
296+// - repo domain.RepoName
297+// - number int64
298+func (_e *IssueStore_Expecter) ListComments(ctx any, repo any, number any) *IssueStore_ListComments_Call {
299+ return &IssueStore_ListComments_Call{Call: _e.mock.On("ListComments", ctx, repo, number)}
300+}
301+
302+func (_c *IssueStore_ListComments_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_ListComments_Call {
303+ _c.Call.Run(func(args mock.Arguments) {
304+ var arg0 context.Context
305+ if args[0] != nil {
306+ arg0 = args[0].(context.Context)
307+ }
308+ var arg1 domain.RepoName
309+ if args[1] != nil {
310+ arg1 = args[1].(domain.RepoName)
311+ }
312+ var arg2 int64
313+ if args[2] != nil {
314+ arg2 = args[2].(int64)
315+ }
316+ run(
317+ arg0,
318+ arg1,
319+ arg2,
320+ )
321+ })
322+ return _c
323+}
324+
325+func (_c *IssueStore_ListComments_Call) Return(issueComments []domain.IssueComment, err error) *IssueStore_ListComments_Call {
326+ _c.Call.Return(issueComments, err)
327+ return _c
328+}
329+
330+func (_c *IssueStore_ListComments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)) *IssueStore_ListComments_Call {
331+ _c.Call.Return(run)
332+ return _c
333+}
334+
335+// ListOpen provides a mock function for the type IssueStore
336+func (_mock *IssueStore) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
337+ ret := _mock.Called(ctx, repo)
338+
339+ if len(ret) == 0 {
340+ panic("no return value specified for ListOpen")
341+ }
342+
343+ var r0 []domain.Issue
344+ var r1 error
345+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Issue, error)); ok {
346+ return returnFunc(ctx, repo)
347+ }
348+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Issue); ok {
349+ r0 = returnFunc(ctx, repo)
350+ } else {
351+ if ret.Get(0) != nil {
352+ r0 = ret.Get(0).([]domain.Issue)
353+ }
354+ }
355+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
356+ r1 = returnFunc(ctx, repo)
357+ } else {
358+ r1 = ret.Error(1)
359+ }
360+ return r0, r1
361+}
362+
363+// IssueStore_ListOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpen'
364+type IssueStore_ListOpen_Call struct {
365+ *mock.Call
366+}
367+
368+// ListOpen is a helper method to define mock.On call
369+// - ctx context.Context
370+// - repo domain.RepoName
371+func (_e *IssueStore_Expecter) ListOpen(ctx any, repo any) *IssueStore_ListOpen_Call {
372+ return &IssueStore_ListOpen_Call{Call: _e.mock.On("ListOpen", ctx, repo)}
373+}
374+
375+func (_c *IssueStore_ListOpen_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *IssueStore_ListOpen_Call {
376+ _c.Call.Run(func(args mock.Arguments) {
377+ var arg0 context.Context
378+ if args[0] != nil {
379+ arg0 = args[0].(context.Context)
380+ }
381+ var arg1 domain.RepoName
382+ if args[1] != nil {
383+ arg1 = args[1].(domain.RepoName)
384+ }
385+ run(
386+ arg0,
387+ arg1,
388+ )
389+ })
390+ return _c
391+}
392+
393+func (_c *IssueStore_ListOpen_Call) Return(issues []domain.Issue, err error) *IssueStore_ListOpen_Call {
394+ _c.Call.Return(issues, err)
395+ return _c
396+}
397+
398+func (_c *IssueStore_ListOpen_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)) *IssueStore_ListOpen_Call {
399+ _c.Call.Return(run)
400+ return _c
401+}
402+
403+// SaveComment provides a mock function for the type IssueStore
404+func (_mock *IssueStore) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
405+ ret := _mock.Called(ctx, issue, c)
406+
407+ if len(ret) == 0 {
408+ panic("no return value specified for SaveComment")
409+ }
410+
411+ var r0 error
412+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue, domain.IssueComment) error); ok {
413+ r0 = returnFunc(ctx, issue, c)
414+ } else {
415+ r0 = ret.Error(0)
416+ }
417+ return r0
418+}
419+
420+// IssueStore_SaveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveComment'
421+type IssueStore_SaveComment_Call struct {
422+ *mock.Call
423+}
424+
425+// SaveComment is a helper method to define mock.On call
426+// - ctx context.Context
427+// - issue domain.Issue
428+// - c domain.IssueComment
429+func (_e *IssueStore_Expecter) SaveComment(ctx any, issue any, c any) *IssueStore_SaveComment_Call {
430+ return &IssueStore_SaveComment_Call{Call: _e.mock.On("SaveComment", ctx, issue, c)}
431+}
432+
433+func (_c *IssueStore_SaveComment_Call) Run(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment)) *IssueStore_SaveComment_Call {
434+ _c.Call.Run(func(args mock.Arguments) {
435+ var arg0 context.Context
436+ if args[0] != nil {
437+ arg0 = args[0].(context.Context)
438+ }
439+ var arg1 domain.Issue
440+ if args[1] != nil {
441+ arg1 = args[1].(domain.Issue)
442+ }
443+ var arg2 domain.IssueComment
444+ if args[2] != nil {
445+ arg2 = args[2].(domain.IssueComment)
446+ }
447+ run(
448+ arg0,
449+ arg1,
450+ arg2,
451+ )
452+ })
453+ return _c
454+}
455+
456+func (_c *IssueStore_SaveComment_Call) Return(err error) *IssueStore_SaveComment_Call {
457+ _c.Call.Return(err)
458+ return _c
459+}
460+
461+func (_c *IssueStore_SaveComment_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment) error) *IssueStore_SaveComment_Call {
462+ _c.Call.Return(run)
463+ return _c
464+}
465+
466+// Update provides a mock function for the type IssueStore
467+func (_mock *IssueStore) Update(ctx context.Context, issue domain.Issue) error {
468+ ret := _mock.Called(ctx, issue)
469+
470+ if len(ret) == 0 {
471+ panic("no return value specified for Update")
472+ }
473+
474+ var r0 error
475+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) error); ok {
476+ r0 = returnFunc(ctx, issue)
477+ } else {
478+ r0 = ret.Error(0)
479+ }
480+ return r0
481+}
482+
483+// IssueStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
484+type IssueStore_Update_Call struct {
485+ *mock.Call
486+}
487+
488+// Update is a helper method to define mock.On call
489+// - ctx context.Context
490+// - issue domain.Issue
491+func (_e *IssueStore_Expecter) Update(ctx any, issue any) *IssueStore_Update_Call {
492+ return &IssueStore_Update_Call{Call: _e.mock.On("Update", ctx, issue)}
493+}
494+
495+func (_c *IssueStore_Update_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Update_Call {
496+ _c.Call.Run(func(args mock.Arguments) {
497+ var arg0 context.Context
498+ if args[0] != nil {
499+ arg0 = args[0].(context.Context)
500+ }
501+ var arg1 domain.Issue
502+ if args[1] != nil {
503+ arg1 = args[1].(domain.Issue)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ )
509+ })
510+ return _c
511+}
512+
513+func (_c *IssueStore_Update_Call) Return(err error) *IssueStore_Update_Call {
514+ _c.Call.Return(err)
515+ return _c
516+}
517+
518+func (_c *IssueStore_Update_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) error) *IssueStore_Update_Call {
519+ _c.Call.Return(run)
520+ return _c
521+}
M internal/ports/ports.go +34 -0
@@ -241,6 +241,40 @@ type TokenStore interface {
241241 Touch(ctx context.Context, id string, at time.Time) error
242242 }
243243
244+// IssueStore keeps the issues of the repositories and their comments. Issues
245+// share the number sequence of the merge requests of their repository, so that
246+// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
247+// the repository does not exist, so that deleting a repository leaves nothing
248+// for one created again under its name. *kvstore.Issues implements it.
249+type IssueStore interface {
250+ // Create stores the issue under the next number of its repository (the
251+ // one merge requests draw from), which it sets, and returns it. A number
252+ // is never given twice, to an issue or to a merge request, even by a
253+ // concurrent Create.
254+ Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
255+ // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
256+ // which state it was read at.
257+ Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
258+ // List returns a page of the issues of the repository in the given state
259+ // (any when empty), highest number first.
260+ List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
261+ // ListOpen returns the open issues of the repository, highest number
262+ // first, reading only the open ones (through an index).
263+ ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)
264+ // Update replaces the stored issue and keeps the index of the open issues
265+ // in step. It returns domain.ErrNotFound if there is none and
266+ // domain.ErrConflict if another write changed it since issue was read.
267+ Update(ctx context.Context, issue domain.Issue) error
268+ // SaveComment writes a comment and the issue it belongs to in one batch: a
269+ // new comment (Version 0) must not exist yet, an existing one must be
270+ // unchanged since it was read, and so must issue. It returns
271+ // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
272+ // comment's ID for a new comment, and issue.UpdatedAt.
273+ SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
274+ // ListComments returns the comments of the issue, oldest first.
275+ ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
276+}
277+
244278 // MergeRequestStore keeps the merge requests of the repositories, their
245279 // comments and the read position of the reviewer. A repository has no entry
246280 // until its first merge request. Create, SaveComment and SetRead return
@@ -241,6 +241,40 @@ type TokenStore interface {
241 Touch(ctx context.Context, id string, at time.Time) error241 Touch(ctx context.Context, id string, at time.Time) error
242 }242 }
243 243
244+// IssueStore keeps the issues of the repositories and their comments. Issues
245+// share the number sequence of the merge requests of their repository, so that
246+// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
247+// the repository does not exist, so that deleting a repository leaves nothing
248+// for one created again under its name. *kvstore.Issues implements it.
249+type IssueStore interface {
250+ // Create stores the issue under the next number of its repository (the
251+ // one merge requests draw from), which it sets, and returns it. A number
252+ // is never given twice, to an issue or to a merge request, even by a
253+ // concurrent Create.
254+ Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
255+ // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
256+ // which state it was read at.
257+ Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
258+ // List returns a page of the issues of the repository in the given state
259+ // (any when empty), highest number first.
260+ List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
261+ // ListOpen returns the open issues of the repository, highest number
262+ // first, reading only the open ones (through an index).
263+ ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)
264+ // Update replaces the stored issue and keeps the index of the open issues
265+ // in step. It returns domain.ErrNotFound if there is none and
266+ // domain.ErrConflict if another write changed it since issue was read.
267+ Update(ctx context.Context, issue domain.Issue) error
268+ // SaveComment writes a comment and the issue it belongs to in one batch: a
269+ // new comment (Version 0) must not exist yet, an existing one must be
270+ // unchanged since it was read, and so must issue. It returns
271+ // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
272+ // comment's ID for a new comment, and issue.UpdatedAt.
273+ SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
274+ // ListComments returns the comments of the issue, oldest first.
275+ ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
276+}
277+
244 // MergeRequestStore keeps the merge requests of the repositories, their278 // MergeRequestStore keeps the merge requests of the repositories, their
245 // comments and the read position of the reviewer. A repository has no entry279 // comments and the read position of the reviewer. A repository has no entry
246 // until its first merge request. Create, SaveComment and SetRead return280 // until its first merge request. Create, SaveComment and SetRead return
A proto/ermite/storage/issue.proto +45 -0
@@ -0,0 +1,45 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+syntax = "proto3";
6+
7+package ermite.storage;
8+
9+import "google/protobuf/timestamp.proto";
10+
11+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
12+
13+// IssueState is where an issue is in its life.
14+enum IssueState {
15+ ISSUE_STATE_UNSPECIFIED = 0;
16+ ISSUE_STATE_OPEN = 1;
17+ ISSUE_STATE_CLOSED = 2;
18+ // Awaiting moderation. Not stored yet.
19+ ISSUE_STATE_PENDING = 3;
20+}
21+
22+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
23+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
24+// indexed under "isopen/<repo>/<number:010d>".
25+message Issue {
26+ int64 number = 1;
27+ string title = 2;
28+ string body = 3;
29+ IssueState state = 4;
30+ // Label of the token that opened it.
31+ string author = 5;
32+ google.protobuf.Timestamp created_at = 6;
33+ google.protobuf.Timestamp updated_at = 7;
34+ // Last comment id handed out for this issue.
35+ int64 comment_seq = 8;
36+}
37+
38+// IssueComment is stored under the key
39+// "isc/<repo>/<number:010d>/<id:010d>".
40+message IssueComment {
41+ int64 id = 1;
42+ string author = 2;
43+ string text = 3;
44+ google.protobuf.Timestamp created_at = 4;
45+}
@@ -0,0 +1,45 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+syntax = "proto3";
6+
7+package ermite.storage;
8+
9+import "google/protobuf/timestamp.proto";
10+
11+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
12+
13+// IssueState is where an issue is in its life.
14+enum IssueState {
15+ ISSUE_STATE_UNSPECIFIED = 0;
16+ ISSUE_STATE_OPEN = 1;
17+ ISSUE_STATE_CLOSED = 2;
18+ // Awaiting moderation. Not stored yet.
19+ ISSUE_STATE_PENDING = 3;
20+}
21+
22+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
23+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
24+// indexed under "isopen/<repo>/<number:010d>".
25+message Issue {
26+ int64 number = 1;
27+ string title = 2;
28+ string body = 3;
29+ IssueState state = 4;
30+ // Label of the token that opened it.
31+ string author = 5;
32+ google.protobuf.Timestamp created_at = 6;
33+ google.protobuf.Timestamp updated_at = 7;
34+ // Last comment id handed out for this issue.
35+ int64 comment_seq = 8;
36+}
37+
38+// IssueComment is stored under the key
39+// "isc/<repo>/<number:010d>/<id:010d>".
40+message IssueComment {
41+ int64 id = 1;
42+ string author = 2;
43+ string text = 3;
44+ google.protobuf.Timestamp created_at = 4;
45+}
M proto/ermite/storage/merge_request.proto +10 -0
@@ -22,6 +22,16 @@ message RepoSettings {
2222 // Never empty once stored.
2323 repeated MergeMode allowed_merge_modes = 1;
2424 bool failing_checks_block = 2;
25+ // Who may write issues; unspecified reads as off.
26+ IssuesMode issues = 3;
27+}
28+
29+// IssuesMode says who may write issues in a repository.
30+enum IssuesMode {
31+ ISSUES_MODE_UNSPECIFIED = 0;
32+ ISSUES_MODE_OFF = 1;
33+ ISSUES_MODE_OWNER = 2;
34+ ISSUES_MODE_PUBLIC = 3;
2535 }
2636
2737 // MergeRequestState is where a merge request is in its life.
@@ -22,6 +22,16 @@ message RepoSettings {
22 // Never empty once stored.22 // Never empty once stored.
23 repeated MergeMode allowed_merge_modes = 1;23 repeated MergeMode allowed_merge_modes = 1;
24 bool failing_checks_block = 2;24 bool failing_checks_block = 2;
25+ // Who may write issues; unspecified reads as off.
26+ IssuesMode issues = 3;
27+}
28+
29+// IssuesMode says who may write issues in a repository.
30+enum IssuesMode {
31+ ISSUES_MODE_UNSPECIFIED = 0;
32+ ISSUES_MODE_OFF = 1;
33+ ISSUES_MODE_OWNER = 2;
34+ ISSUES_MODE_PUBLIC = 3;
25 }35 }
26 36
27 // MergeRequestState is where a merge request is in its life.37 // MergeRequestState is where a merge request is in its life.

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 43

↑↓ moveenter openesc close