detached 8a27537

feat(issues): parse issue references and store issue to merge request links

commit
8a27537793aa340fa6240dbfcf4962edf1525671
author
brice · · 2026-10-20 15:02 UTC
parent
c899179
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +920 -58
M gen/ermite/storage/issue.pb.go +146 -31 generated file: collapsed
A internal/adapters/kvstore/issue_links.go +139 -0
@@ -0,0 +1,139 @@
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+
13+ kv "bnema.dev/ermite-kv"
14+ storagepb "bnema.dev/ermite/gen/ermite/storage"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "google.golang.org/protobuf/proto"
18+)
19+
20+const issueRefPrefix = "isref/"
21+const mrRefPrefix = "mrref/"
22+
23+// IssueLinks stores the derived issue-to-merge-request index.
24+type IssueLinks struct{ db *kv.DB }
25+
26+var _ ports.IssueLinkStore = (*IssueLinks)(nil)
27+
28+// IssueLinks returns a link store sharing this Store's database and lifetime.
29+func (s *Store) IssueLinks() *IssueLinks { return &IssueLinks{db: s.db} }
30+
31+func mrRefKey(repo domain.RepoName, mr int64) []byte {
32+ return []byte(mrRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, mr))
33+}
34+func issueRefKeys(repo domain.RepoName, issue int64) string {
35+ return issueRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, issue) + "/"
36+}
37+func issueRefKey(repo domain.RepoName, issue, mr int64) []byte {
38+ return []byte(issueRefKeys(repo, issue) + fmt.Sprintf(numberFormat, mr))
39+}
40+
41+// ReplaceLinks atomically replaces both directions of the index.
42+func (l *IssueLinks) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) {
43+ key := mrRefKey(repo, mr)
44+ item, exists := l.db.Get(ctx, key)
45+ var old storagepb.MergeRequestReferences
46+ if exists {
47+ if err := proto.Unmarshal(item.Value, &old); err != nil {
48+ return nil, nil, fmt.Errorf("decode MR references: %w", err)
49+ }
50+ }
51+ issues = slices.Sorted(slices.Values(issues))
52+ issues = slices.Compact(issues)
53+ closes = slices.Sorted(slices.Values(closes))
54+ closes = slices.Compact(closes)
55+ for _, n := range issues {
56+ if n < 1 {
57+ return nil, nil, domain.ErrInvalid
58+ }
59+ }
60+ for _, n := range closes {
61+ if !slices.Contains(issues, n) {
62+ return nil, nil, domain.ErrInvalid
63+ }
64+ }
65+ var b kv.Batch
66+ if err := guardRepoKey(ctx, l.db, &b, repo); err != nil {
67+ return nil, nil, err
68+ }
69+ if exists {
70+ b.RequireVersion(key, item.Version)
71+ } else {
72+ b.RequireAbsent(key)
73+ }
74+ for _, n := range old.GetIssues() {
75+ wasClosing := slices.Contains(old.GetCloses(), n)
76+ if !slices.Contains(issues, n) {
77+ b.Delete(issueRefKey(repo, n, mr))
78+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing})
79+ } else if wasClosing && !slices.Contains(closes, n) {
80+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true})
81+ }
82+ }
83+ for _, n := range issues {
84+ closing := slices.Contains(closes, n)
85+ b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing}))
86+ if !slices.Contains(old.GetIssues(), n) || closing && !slices.Contains(old.GetCloses(), n) {
87+ added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing})
88+ }
89+ }
90+ b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes}))
91+ if _, err := l.db.Apply(ctx, &b); err != nil {
92+ if errors.Is(err, kv.ErrConflict) {
93+ return nil, nil, domain.ErrConflict
94+ }
95+ return nil, nil, fmt.Errorf("replace issue links: %w", err)
96+ }
97+ return added, removed, nil
98+}
99+
100+// LinksOfIssue returns links ordered by merge request number.
101+func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
102+ var result []domain.IssueLink
103+ prefix := issueRefKeys(repo, issue)
104+ for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
105+ if err := ctx.Err(); err != nil {
106+ return nil, err
107+ }
108+ mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
109+ if err != nil {
110+ return nil, fmt.Errorf("decode issue link key: %w", err)
111+ }
112+ var pb storagepb.IssueLink
113+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
114+ return nil, fmt.Errorf("decode issue link: %w", err)
115+ }
116+ result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()})
117+ if limit > 0 && len(result) >= limit {
118+ break
119+ }
120+ }
121+ return result, nil
122+}
123+
124+// LinksOfMergeRequest returns the links recorded by its last rebuild.
125+func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
126+ item, ok := l.db.Get(ctx, mrRefKey(repo, mr))
127+ if !ok {
128+ return nil, nil
129+ }
130+ var pb storagepb.MergeRequestReferences
131+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
132+ return nil, fmt.Errorf("decode MR references: %w", err)
133+ }
134+ var result []domain.IssueLink
135+ for _, n := range pb.GetIssues() {
136+ result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)})
137+ }
138+ return result, nil
139+}
@@ -0,0 +1,139 @@
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+
13+ kv "bnema.dev/ermite-kv"
14+ storagepb "bnema.dev/ermite/gen/ermite/storage"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "google.golang.org/protobuf/proto"
18+)
19+
20+const issueRefPrefix = "isref/"
21+const mrRefPrefix = "mrref/"
22+
23+// IssueLinks stores the derived issue-to-merge-request index.
24+type IssueLinks struct{ db *kv.DB }
25+
26+var _ ports.IssueLinkStore = (*IssueLinks)(nil)
27+
28+// IssueLinks returns a link store sharing this Store's database and lifetime.
29+func (s *Store) IssueLinks() *IssueLinks { return &IssueLinks{db: s.db} }
30+
31+func mrRefKey(repo domain.RepoName, mr int64) []byte {
32+ return []byte(mrRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, mr))
33+}
34+func issueRefKeys(repo domain.RepoName, issue int64) string {
35+ return issueRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, issue) + "/"
36+}
37+func issueRefKey(repo domain.RepoName, issue, mr int64) []byte {
38+ return []byte(issueRefKeys(repo, issue) + fmt.Sprintf(numberFormat, mr))
39+}
40+
41+// ReplaceLinks atomically replaces both directions of the index.
42+func (l *IssueLinks) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) {
43+ key := mrRefKey(repo, mr)
44+ item, exists := l.db.Get(ctx, key)
45+ var old storagepb.MergeRequestReferences
46+ if exists {
47+ if err := proto.Unmarshal(item.Value, &old); err != nil {
48+ return nil, nil, fmt.Errorf("decode MR references: %w", err)
49+ }
50+ }
51+ issues = slices.Sorted(slices.Values(issues))
52+ issues = slices.Compact(issues)
53+ closes = slices.Sorted(slices.Values(closes))
54+ closes = slices.Compact(closes)
55+ for _, n := range issues {
56+ if n < 1 {
57+ return nil, nil, domain.ErrInvalid
58+ }
59+ }
60+ for _, n := range closes {
61+ if !slices.Contains(issues, n) {
62+ return nil, nil, domain.ErrInvalid
63+ }
64+ }
65+ var b kv.Batch
66+ if err := guardRepoKey(ctx, l.db, &b, repo); err != nil {
67+ return nil, nil, err
68+ }
69+ if exists {
70+ b.RequireVersion(key, item.Version)
71+ } else {
72+ b.RequireAbsent(key)
73+ }
74+ for _, n := range old.GetIssues() {
75+ wasClosing := slices.Contains(old.GetCloses(), n)
76+ if !slices.Contains(issues, n) {
77+ b.Delete(issueRefKey(repo, n, mr))
78+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing})
79+ } else if wasClosing && !slices.Contains(closes, n) {
80+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true})
81+ }
82+ }
83+ for _, n := range issues {
84+ closing := slices.Contains(closes, n)
85+ b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing}))
86+ if !slices.Contains(old.GetIssues(), n) || closing && !slices.Contains(old.GetCloses(), n) {
87+ added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing})
88+ }
89+ }
90+ b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes}))
91+ if _, err := l.db.Apply(ctx, &b); err != nil {
92+ if errors.Is(err, kv.ErrConflict) {
93+ return nil, nil, domain.ErrConflict
94+ }
95+ return nil, nil, fmt.Errorf("replace issue links: %w", err)
96+ }
97+ return added, removed, nil
98+}
99+
100+// LinksOfIssue returns links ordered by merge request number.
101+func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
102+ var result []domain.IssueLink
103+ prefix := issueRefKeys(repo, issue)
104+ for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
105+ if err := ctx.Err(); err != nil {
106+ return nil, err
107+ }
108+ mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
109+ if err != nil {
110+ return nil, fmt.Errorf("decode issue link key: %w", err)
111+ }
112+ var pb storagepb.IssueLink
113+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
114+ return nil, fmt.Errorf("decode issue link: %w", err)
115+ }
116+ result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()})
117+ if limit > 0 && len(result) >= limit {
118+ break
119+ }
120+ }
121+ return result, nil
122+}
123+
124+// LinksOfMergeRequest returns the links recorded by its last rebuild.
125+func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
126+ item, ok := l.db.Get(ctx, mrRefKey(repo, mr))
127+ if !ok {
128+ return nil, nil
129+ }
130+ var pb storagepb.MergeRequestReferences
131+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
132+ return nil, fmt.Errorf("decode MR references: %w", err)
133+ }
134+ var result []domain.IssueLink
135+ for _, n := range pb.GetIssues() {
136+ result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)})
137+ }
138+ return result, nil
139+}
A internal/adapters/kvstore/issue_links_test.go +66 -0
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "bnema.dev/ermite/internal/domain"
6+ "context"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "testing"
10+)
11+
12+func TestIssueLinksReplace(t *testing.T) {
13+ ctx := context.Background()
14+ s, _, repo := repoWithMRs(t)
15+ links := s.IssueLinks()
16+ added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2})
17+ require.NoError(t, err)
18+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added)
19+ assert.Empty(t, removed)
20+ got, err := links.LinksOfMergeRequest(ctx, repo, 9)
21+ require.NoError(t, err)
22+ assert.Equal(t, added, got)
23+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2})
24+ require.NoError(t, err)
25+ assert.Empty(t, added)
26+ assert.Empty(t, removed)
27+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3})
28+ require.NoError(t, err)
29+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added)
30+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed)
31+ got, err = links.LinksOfIssue(ctx, repo, 2, 100)
32+ require.NoError(t, err)
33+ assert.Empty(t, got)
34+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil)
35+ require.NoError(t, err)
36+ assert.Empty(t, added)
37+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed)
38+ for _, mr := range []int64{12, 1, 5} {
39+ _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil)
40+ require.NoError(t, err)
41+ }
42+ got, err = links.LinksOfIssue(ctx, repo, 3, 2)
43+ require.NoError(t, err)
44+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got)
45+ require.NoError(t, s.Delete(ctx, repo))
46+ assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/"))
47+ assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/"))
48+ _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil)
49+ assert.ErrorIs(t, err, domain.ErrNotFound)
50+}
51+
52+func TestIssueClosedByRoundTrip(t *testing.T) {
53+ ctx := context.Background()
54+ s, _, repo := repoWithMRs(t)
55+ issue := newIssue(t, repo, "closed by merge")
56+ issue.State = domain.IssueStateClosed
57+ issue.ClosedByMergeRequest = 17
58+ stored, err := s.Issues().Create(ctx, issue)
59+ require.NoError(t, err)
60+ assert.Equal(t, int64(17), stored.ClosedByMergeRequest)
61+ stored.ClosedByMergeRequest = 0
62+ require.NoError(t, s.Issues().Update(ctx, stored))
63+ stored, err = s.Issues().Get(ctx, repo, stored.Number)
64+ require.NoError(t, err)
65+ assert.Zero(t, stored.ClosedByMergeRequest)
66+}
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "bnema.dev/ermite/internal/domain"
6+ "context"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "testing"
10+)
11+
12+func TestIssueLinksReplace(t *testing.T) {
13+ ctx := context.Background()
14+ s, _, repo := repoWithMRs(t)
15+ links := s.IssueLinks()
16+ added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2})
17+ require.NoError(t, err)
18+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added)
19+ assert.Empty(t, removed)
20+ got, err := links.LinksOfMergeRequest(ctx, repo, 9)
21+ require.NoError(t, err)
22+ assert.Equal(t, added, got)
23+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2})
24+ require.NoError(t, err)
25+ assert.Empty(t, added)
26+ assert.Empty(t, removed)
27+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3})
28+ require.NoError(t, err)
29+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added)
30+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed)
31+ got, err = links.LinksOfIssue(ctx, repo, 2, 100)
32+ require.NoError(t, err)
33+ assert.Empty(t, got)
34+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil)
35+ require.NoError(t, err)
36+ assert.Empty(t, added)
37+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed)
38+ for _, mr := range []int64{12, 1, 5} {
39+ _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil)
40+ require.NoError(t, err)
41+ }
42+ got, err = links.LinksOfIssue(ctx, repo, 3, 2)
43+ require.NoError(t, err)
44+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got)
45+ require.NoError(t, s.Delete(ctx, repo))
46+ assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/"))
47+ assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/"))
48+ _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil)
49+ assert.ErrorIs(t, err, domain.ErrNotFound)
50+}
51+
52+func TestIssueClosedByRoundTrip(t *testing.T) {
53+ ctx := context.Background()
54+ s, _, repo := repoWithMRs(t)
55+ issue := newIssue(t, repo, "closed by merge")
56+ issue.State = domain.IssueStateClosed
57+ issue.ClosedByMergeRequest = 17
58+ stored, err := s.Issues().Create(ctx, issue)
59+ require.NoError(t, err)
60+ assert.Equal(t, int64(17), stored.ClosedByMergeRequest)
61+ stored.ClosedByMergeRequest = 0
62+ require.NoError(t, s.Issues().Update(ctx, stored))
63+ stored, err = s.Issues().Get(ctx, repo, stored.Number)
64+ require.NoError(t, err)
65+ assert.Zero(t, stored.ClosedByMergeRequest)
66+}
M internal/adapters/kvstore/issues.go +15 -13
@@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
7272 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
7373 var keys [][]byte
7474 for _, p := range []string{
75- issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/",
7676 } {
7777 for k := range snap.Prefix(ctx, []byte(p)) {
7878 keys = append(keys, slices.Clone(k))
@@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
312312 return nil, err
313313 }
314314 return mustMarshal(&storagepb.Issue{
315- Number: issue.Number,
316- Title: issue.Title.String(),
317- Body: issue.Body.String(),
318- State: state,
319- Author: issue.Author,
320- CreatedAt: encodeTime(issue.CreatedAt),
321- UpdatedAt: encodeTime(issue.UpdatedAt),
322- CommentSeq: issue.CommentSeq,
323- External: issue.External,
315+ Number: issue.Number,
316+ Title: issue.Title.String(),
317+ Body: issue.Body.String(),
318+ State: state,
319+ Author: issue.Author,
320+ CreatedAt: encodeTime(issue.CreatedAt),
321+ UpdatedAt: encodeTime(issue.UpdatedAt),
322+ CommentSeq: issue.CommentSeq,
323+ External: issue.External,
324+ ClosedByMergeRequest: issue.ClosedByMergeRequest,
324325 }), nil
325326 }
326327
@@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
348349 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
349350 Author: pb.GetAuthor(),
350351 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
351- CommentSeq: pb.GetCommentSeq(),
352- External: pb.GetExternal(),
353- Version: item.Version,
352+ CommentSeq: pb.GetCommentSeq(),
353+ External: pb.GetExternal(),
354+ ClosedByMergeRequest: pb.GetClosedByMergeRequest(),
355+ Version: item.Version,
354356 }, nil
355357 }
356358
@@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
72 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {72 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
73 var keys [][]byte73 var keys [][]byte
74 for _, p := range []string{74 for _, p := range []string{
75- issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/",
76 } {76 } {
77 for k := range snap.Prefix(ctx, []byte(p)) {77 for k := range snap.Prefix(ctx, []byte(p)) {
78 keys = append(keys, slices.Clone(k))78 keys = append(keys, slices.Clone(k))
@@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
312 return nil, err312 return nil, err
313 }313 }
314 return mustMarshal(&storagepb.Issue{314 return mustMarshal(&storagepb.Issue{
315- Number: issue.Number,315+ Number: issue.Number,
316- Title: issue.Title.String(),316+ Title: issue.Title.String(),
317- Body: issue.Body.String(),317+ Body: issue.Body.String(),
318- State: state,318+ State: state,
319- Author: issue.Author,319+ Author: issue.Author,
320- CreatedAt: encodeTime(issue.CreatedAt),320+ CreatedAt: encodeTime(issue.CreatedAt),
321- UpdatedAt: encodeTime(issue.UpdatedAt),321+ UpdatedAt: encodeTime(issue.UpdatedAt),
322- CommentSeq: issue.CommentSeq,322+ CommentSeq: issue.CommentSeq,
323- External: issue.External,323+ External: issue.External,
324+ ClosedByMergeRequest: issue.ClosedByMergeRequest,
324 }), nil325 }), nil
325 }326 }
326 327
@@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
348 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,349 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
349 Author: pb.GetAuthor(),350 Author: pb.GetAuthor(),
350 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),351 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
351- CommentSeq: pb.GetCommentSeq(),352+ CommentSeq: pb.GetCommentSeq(),
352- External: pb.GetExternal(),353+ External: pb.GetExternal(),
353- Version: item.Version,354+ ClosedByMergeRequest: pb.GetClosedByMergeRequest(),
355+ Version: item.Version,
354 }, nil356 }, nil
355 }357 }
356 358
M internal/adapters/kvstore/mergerequests.go +1 -1
@@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
181181 var keys [][]byte
182182 for _, p := range []string{
183183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
184- mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
184+ mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/",
185185 mergePrefix + repo.String() + "/",
186186 } {
187187 for k := range snap.Prefix(ctx, []byte(p)) {
@@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
181 var keys [][]byte181 var keys [][]byte
182 for _, p := range []string{182 for _, p := range []string{
183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
184- mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",184+ mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/",
185 mergePrefix + repo.String() + "/",185 mergePrefix + repo.String() + "/",
186 } {186 } {
187 for k := range snap.Prefix(ctx, []byte(p)) {187 for k := range snap.Prefix(ctx, []byte(p)) {
M internal/adapters/rpc/convert.go +2 -2
@@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
530530 out.Time = timestamp(ev.At)
531531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
532532 default:
533- // Web-only events, such as domain.IssuePendingRemoved (the API has
534- // no message for it; watchers list pending issues on demand).
533+ // Web-only events, such as domain.IssueReferenced and
534+ // domain.IssuePendingRemoved: the API has no messages for them.
535535 return nil
536536 }
537537 return out
@@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
530 out.Time = timestamp(ev.At)530 out.Time = timestamp(ev.At)
531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
532 default:532 default:
533- // Web-only events, such as domain.IssuePendingRemoved (the API has533+ // Web-only events, such as domain.IssueReferenced and
534- // no message for it; watchers list pending issues on demand).534+ // domain.IssuePendingRemoved: the API has no messages for them.
535 return nil535 return nil
536 }536 }
537 return out537 return out
M internal/domain/event.go +12 -0
@@ -186,6 +186,18 @@ type IssueOpened struct {
186186 // EventKind implements Event.
187187 func (IssueOpened) EventKind() string { return "issue.opened" }
188188
189+// IssueReferenced refreshes an issue's merge request back-references.
190+type IssueReferenced struct {
191+ Repo RepoName
192+ Issue, MergeRequest int64
193+ At time.Time
194+ Visibility Visibility
195+ CreatedAt time.Time
196+}
197+
198+// EventKind implements Event.
199+func (IssueReferenced) EventKind() string { return "issue.referenced" }
200+
189201 // IssueClosed is published when an issue was closed.
190202 type IssueClosed struct {
191203 Issue Issue
@@ -186,6 +186,18 @@ type IssueOpened struct {
186 // EventKind implements Event.186 // EventKind implements Event.
187 func (IssueOpened) EventKind() string { return "issue.opened" }187 func (IssueOpened) EventKind() string { return "issue.opened" }
188 188
189+// IssueReferenced refreshes an issue's merge request back-references.
190+type IssueReferenced struct {
191+ Repo RepoName
192+ Issue, MergeRequest int64
193+ At time.Time
194+ Visibility Visibility
195+ CreatedAt time.Time
196+}
197+
198+// EventKind implements Event.
199+func (IssueReferenced) EventKind() string { return "issue.referenced" }
200+
189 // IssueClosed is published when an issue was closed.201 // IssueClosed is published when an issue was closed.
190 type IssueClosed struct {202 type IssueClosed struct {
191 Issue Issue203 Issue Issue
M internal/domain/issue.go +8 -0
@@ -73,11 +73,19 @@ type Issue struct {
7373 // External is true for an issue approved from the public form: its text
7474 // comes from an anonymous visitor and is rendered as untrusted.
7575 External bool
76+ // ClosedByMergeRequest is zero unless a merge closed the issue.
77+ ClosedByMergeRequest int64
7678 // Version is set by the store on read: Update fails with ErrConflict when
7779 // the stored issue changed since. Opaque to everyone else.
7880 Version uint64
7981 }
8082
83+// IssueLink is a merge request naming an issue in the same repository.
84+type IssueLink struct {
85+ Issue, MergeRequest int64
86+ Closes bool
87+}
88+
8189 // IsOpen reports whether the issue is open.
8290 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
8391
@@ -73,11 +73,19 @@ type Issue struct {
73 // External is true for an issue approved from the public form: its text73 // External is true for an issue approved from the public form: its text
74 // comes from an anonymous visitor and is rendered as untrusted.74 // comes from an anonymous visitor and is rendered as untrusted.
75 External bool75 External bool
76+ // ClosedByMergeRequest is zero unless a merge closed the issue.
77+ ClosedByMergeRequest int64
76 // Version is set by the store on read: Update fails with ErrConflict when78 // Version is set by the store on read: Update fails with ErrConflict when
77 // the stored issue changed since. Opaque to everyone else.79 // the stored issue changed since. Opaque to everyone else.
78 Version uint6480 Version uint64
79 }81 }
80 82
83+// IssueLink is a merge request naming an issue in the same repository.
84+type IssueLink struct {
85+ Issue, MergeRequest int64
86+ Closes bool
87+}
88+
81 // IsOpen reports whether the issue is open.89 // IsOpen reports whether the issue is open.
82 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }90 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
83 91
A internal/domain/reference.go +102 -0
@@ -0,0 +1,102 @@
1+package domain
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "strconv"
7+)
8+
9+const maxReferenceDigits = 10
10+
11+// MaxReferenceTextBytes bounds each title, description, note and commit message scanned.
12+const MaxReferenceTextBytes = 16 << 10
13+
14+var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
15+var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`)
16+
17+// FindReferences returns the positive #N numbers in text, sorted and unique.
18+// Numbers glued to words, entities or another # are excluded, as are zero,
19+// leading zeroes and numbers longer than ten digits. Code is scanned as text;
20+// cross-repository references and !N are not supported.
21+func FindReferences(text string) []int64 {
22+ numbers := make(map[int64]struct{})
23+ // Resume at the end of the number, retaining its following delimiter for the
24+ // next match (for example, both references in "#1,#2").
25+ for offset := 0; offset < len(text); {
26+ match := numberRef.FindStringSubmatchIndex(text[offset:])
27+ if match == nil {
28+ break
29+ }
30+ start, end := offset+match[4], offset+match[5]
31+ // A sliced search must not manufacture a beginning-of-text boundary.
32+ if start < 2 || !referenceWord(text[start-2]) {
33+ n, _ := strconv.ParseInt(text[start:end], 10, 64)
34+ numbers[n] = struct{}{}
35+ }
36+ offset = end
37+ }
38+ return sortedReferences(numbers)
39+}
40+
41+func referenceWord(b byte) bool {
42+ return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#'
43+}
44+
45+// FindClosingReferences returns sorted, unique references following close,
46+// closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case
47+// insensitive). A colon is optional; lists use commas or "and", not spaces.
48+// Like FindReferences, this scans code spans and fences as ordinary text.
49+func FindClosingReferences(text string) []int64 {
50+ numbers := make(map[int64]struct{})
51+ for _, match := range closingRef.FindAllString(text, -1) {
52+ for _, n := range FindReferences(match) {
53+ numbers[n] = struct{}{}
54+ }
55+ }
56+ return sortedReferences(numbers)
57+}
58+
59+func sortedReferences(numbers map[int64]struct{}) []int64 {
60+ var result []int64
61+ for n := range numbers {
62+ result = append(result, n)
63+ }
64+ slices.Sort(result)
65+ return result
66+}
67+
68+// MergeRequestReferences describes the issues named by a merge request.
69+type MergeRequestReferences struct {
70+ Mentions []int64
71+ Closes []int64
72+}
73+
74+// ScanMergeRequest scans titles, descriptions, notes and commit messages for
75+// references. Notes can mention issues but cannot close them. Each individual
76+// text is truncated to MaxReferenceTextBytes before scanning; no Markdown or
77+// cross-repository interpretation is performed.
78+func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences {
79+ mentions, closes := make(map[int64]struct{}), make(map[int64]struct{})
80+ scan := func(text string, closing bool) {
81+ if len(text) > MaxReferenceTextBytes {
82+ text = text[:MaxReferenceTextBytes]
83+ }
84+ for _, n := range FindReferences(text) {
85+ mentions[n] = struct{}{}
86+ }
87+ if closing {
88+ for _, n := range FindClosingReferences(text) {
89+ closes[n] = struct{}{}
90+ }
91+ }
92+ }
93+ scan(title, true)
94+ scan(description, true)
95+ for _, text := range notes {
96+ scan(text, false)
97+ }
98+ for _, text := range commitMessages {
99+ scan(text, true)
100+ }
101+ return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)}
102+}
@@ -0,0 +1,102 @@
1+package domain
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "strconv"
7+)
8+
9+const maxReferenceDigits = 10
10+
11+// MaxReferenceTextBytes bounds each title, description, note and commit message scanned.
12+const MaxReferenceTextBytes = 16 << 10
13+
14+var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
15+var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`)
16+
17+// FindReferences returns the positive #N numbers in text, sorted and unique.
18+// Numbers glued to words, entities or another # are excluded, as are zero,
19+// leading zeroes and numbers longer than ten digits. Code is scanned as text;
20+// cross-repository references and !N are not supported.
21+func FindReferences(text string) []int64 {
22+ numbers := make(map[int64]struct{})
23+ // Resume at the end of the number, retaining its following delimiter for the
24+ // next match (for example, both references in "#1,#2").
25+ for offset := 0; offset < len(text); {
26+ match := numberRef.FindStringSubmatchIndex(text[offset:])
27+ if match == nil {
28+ break
29+ }
30+ start, end := offset+match[4], offset+match[5]
31+ // A sliced search must not manufacture a beginning-of-text boundary.
32+ if start < 2 || !referenceWord(text[start-2]) {
33+ n, _ := strconv.ParseInt(text[start:end], 10, 64)
34+ numbers[n] = struct{}{}
35+ }
36+ offset = end
37+ }
38+ return sortedReferences(numbers)
39+}
40+
41+func referenceWord(b byte) bool {
42+ return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#'
43+}
44+
45+// FindClosingReferences returns sorted, unique references following close,
46+// closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case
47+// insensitive). A colon is optional; lists use commas or "and", not spaces.
48+// Like FindReferences, this scans code spans and fences as ordinary text.
49+func FindClosingReferences(text string) []int64 {
50+ numbers := make(map[int64]struct{})
51+ for _, match := range closingRef.FindAllString(text, -1) {
52+ for _, n := range FindReferences(match) {
53+ numbers[n] = struct{}{}
54+ }
55+ }
56+ return sortedReferences(numbers)
57+}
58+
59+func sortedReferences(numbers map[int64]struct{}) []int64 {
60+ var result []int64
61+ for n := range numbers {
62+ result = append(result, n)
63+ }
64+ slices.Sort(result)
65+ return result
66+}
67+
68+// MergeRequestReferences describes the issues named by a merge request.
69+type MergeRequestReferences struct {
70+ Mentions []int64
71+ Closes []int64
72+}
73+
74+// ScanMergeRequest scans titles, descriptions, notes and commit messages for
75+// references. Notes can mention issues but cannot close them. Each individual
76+// text is truncated to MaxReferenceTextBytes before scanning; no Markdown or
77+// cross-repository interpretation is performed.
78+func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences {
79+ mentions, closes := make(map[int64]struct{}), make(map[int64]struct{})
80+ scan := func(text string, closing bool) {
81+ if len(text) > MaxReferenceTextBytes {
82+ text = text[:MaxReferenceTextBytes]
83+ }
84+ for _, n := range FindReferences(text) {
85+ mentions[n] = struct{}{}
86+ }
87+ if closing {
88+ for _, n := range FindClosingReferences(text) {
89+ closes[n] = struct{}{}
90+ }
91+ }
92+ }
93+ scan(title, true)
94+ scan(description, true)
95+ for _, text := range notes {
96+ scan(text, false)
97+ }
98+ for _, text := range commitMessages {
99+ scan(text, true)
100+ }
101+ return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)}
102+}
A internal/domain/reference_test.go +59 -0
@@ -0,0 +1,59 @@
1+package domain
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestFindReferences(t *testing.T) {
10+ tests := []struct {
11+ text string
12+ mentions, closes []int64
13+ }{
14+ {"abc#3 a_b#3 1#3 &#39; ##3 #3abc #03 #0", nil, nil},
15+ {"(#3) see #3, \"#3\"", []int64{3}, nil},
16+ {"fixes #3.", []int64{3}, []int64{3}},
17+ {"Fixes: #3", []int64{3}, []int64{3}},
18+ {"fixes:#3", []int64{3}, []int64{3}},
19+ {"Closes #3", []int64{3}, []int64{3}},
20+ {"RESOLVED #3", []int64{3}, []int64{3}},
21+ {"fix #3", []int64{3}, []int64{3}},
22+ {"prefixes #3", []int64{3}, nil},
23+ {"fixes 3 fixes#3", nil, nil},
24+ {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}},
25+ {"fixes #3 #4", []int64{3, 4}, []int64{3}},
26+ {"#5,#3,#5", []int64{3, 5}, nil},
27+ {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil},
28+ {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}},
29+ {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.text, func(t *testing.T) {
33+ if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) {
34+ t.Errorf("mentions = %v, want %v", got, tt.mentions)
35+ }
36+ if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) {
37+ t.Errorf("closes = %v, want %v", got, tt.closes)
38+ }
39+ })
40+ }
41+}
42+
43+func TestScanMergeRequest(t *testing.T) {
44+ got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"})
45+ want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}}
46+ if !reflect.DeepEqual(got, want) {
47+ t.Fatalf("got %+v, want %+v", got, want)
48+ }
49+ for _, scan := range []func(string) MergeRequestReferences{
50+ func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) },
51+ func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) },
52+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) },
53+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) },
54+ } {
55+ if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 {
56+ t.Errorf("text beyond limit scanned: %+v", got)
57+ }
58+ }
59+}
@@ -0,0 +1,59 @@
1+package domain
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestFindReferences(t *testing.T) {
10+ tests := []struct {
11+ text string
12+ mentions, closes []int64
13+ }{
14+ {"abc#3 a_b#3 1#3 &#39; ##3 #3abc #03 #0", nil, nil},
15+ {"(#3) see #3, \"#3\"", []int64{3}, nil},
16+ {"fixes #3.", []int64{3}, []int64{3}},
17+ {"Fixes: #3", []int64{3}, []int64{3}},
18+ {"fixes:#3", []int64{3}, []int64{3}},
19+ {"Closes #3", []int64{3}, []int64{3}},
20+ {"RESOLVED #3", []int64{3}, []int64{3}},
21+ {"fix #3", []int64{3}, []int64{3}},
22+ {"prefixes #3", []int64{3}, nil},
23+ {"fixes 3 fixes#3", nil, nil},
24+ {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}},
25+ {"fixes #3 #4", []int64{3, 4}, []int64{3}},
26+ {"#5,#3,#5", []int64{3, 5}, nil},
27+ {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil},
28+ {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}},
29+ {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.text, func(t *testing.T) {
33+ if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) {
34+ t.Errorf("mentions = %v, want %v", got, tt.mentions)
35+ }
36+ if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) {
37+ t.Errorf("closes = %v, want %v", got, tt.closes)
38+ }
39+ })
40+ }
41+}
42+
43+func TestScanMergeRequest(t *testing.T) {
44+ got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"})
45+ want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}}
46+ if !reflect.DeepEqual(got, want) {
47+ t.Fatalf("got %+v, want %+v", got, want)
48+ }
49+ for _, scan := range []func(string) MergeRequestReferences{
50+ func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) },
51+ func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) },
52+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) },
53+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) },
54+ } {
55+ if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 {
56+ t.Errorf("text beyond limit scanned: %+v", got)
57+ }
58+ }
59+}
A internal/ports/mocks/IssueLinkStore.go +296 -0
@@ -0,0 +1,296 @@
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+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkStore is an autogenerated mock type for the IssueLinkStore type
38+type IssueLinkStore struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter {
47+ return &IssueLinkStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// LinksOfIssue provides a mock function for the type IssueLinkStore
51+func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
52+ ret := _mock.Called(ctx, repo, issue, limit)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for LinksOfIssue")
56+ }
57+
58+ var r0 []domain.IssueLink
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok {
61+ return returnFunc(ctx, repo, issue, limit)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok {
64+ r0 = returnFunc(ctx, repo, issue, limit)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.IssueLink)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok {
71+ r1 = returnFunc(ctx, repo, issue, limit)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue'
79+type IssueLinkStore_LinksOfIssue_Call struct {
80+ *mock.Call
81+}
82+
83+// LinksOfIssue is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - issue int64
87+// - limit int
88+func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call {
89+ return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)}
90+}
91+
92+func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 int64
103+ if args[2] != nil {
104+ arg2 = args[2].(int64)
105+ }
106+ var arg3 int
107+ if args[3] != nil {
108+ arg3 = args[3].(int)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call {
121+ _c.Call.Return(issueLinks, err)
122+ return _c
123+}
124+
125+func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// LinksOfMergeRequest provides a mock function for the type IssueLinkStore
131+func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
132+ ret := _mock.Called(ctx, repo, mr)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for LinksOfMergeRequest")
136+ }
137+
138+ var r0 []domain.IssueLink
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok {
141+ return returnFunc(ctx, repo, mr)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok {
144+ r0 = returnFunc(ctx, repo, mr)
145+ } else {
146+ if ret.Get(0) != nil {
147+ r0 = ret.Get(0).([]domain.IssueLink)
148+ }
149+ }
150+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
151+ r1 = returnFunc(ctx, repo, mr)
152+ } else {
153+ r1 = ret.Error(1)
154+ }
155+ return r0, r1
156+}
157+
158+// IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest'
159+type IssueLinkStore_LinksOfMergeRequest_Call struct {
160+ *mock.Call
161+}
162+
163+// LinksOfMergeRequest is a helper method to define mock.On call
164+// - ctx context.Context
165+// - repo domain.RepoName
166+// - mr int64
167+func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call {
168+ return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)}
169+}
170+
171+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ var arg0 context.Context
174+ if args[0] != nil {
175+ arg0 = args[0].(context.Context)
176+ }
177+ var arg1 domain.RepoName
178+ if args[1] != nil {
179+ arg1 = args[1].(domain.RepoName)
180+ }
181+ var arg2 int64
182+ if args[2] != nil {
183+ arg2 = args[2].(int64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ )
190+ })
191+ return _c
192+}
193+
194+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call {
195+ _c.Call.Return(issueLinks, err)
196+ return _c
197+}
198+
199+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call {
200+ _c.Call.Return(run)
201+ return _c
202+}
203+
204+// ReplaceLinks provides a mock function for the type IssueLinkStore
205+func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) {
206+ ret := _mock.Called(ctx, repo, mr, issues, closes)
207+
208+ if len(ret) == 0 {
209+ panic("no return value specified for ReplaceLinks")
210+ }
211+
212+ var r0 []domain.IssueLink
213+ var r1 []domain.IssueLink
214+ var r2 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok {
216+ return returnFunc(ctx, repo, mr, issues, closes)
217+ }
218+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
219+ r0 = returnFunc(ctx, repo, mr, issues, closes)
220+ } else {
221+ if ret.Get(0) != nil {
222+ r0 = ret.Get(0).([]domain.IssueLink)
223+ }
224+ }
225+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
226+ r1 = returnFunc(ctx, repo, mr, issues, closes)
227+ } else {
228+ if ret.Get(1) != nil {
229+ r1 = ret.Get(1).([]domain.IssueLink)
230+ }
231+ }
232+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok {
233+ r2 = returnFunc(ctx, repo, mr, issues, closes)
234+ } else {
235+ r2 = ret.Error(2)
236+ }
237+ return r0, r1, r2
238+}
239+
240+// IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks'
241+type IssueLinkStore_ReplaceLinks_Call struct {
242+ *mock.Call
243+}
244+
245+// ReplaceLinks is a helper method to define mock.On call
246+// - ctx context.Context
247+// - repo domain.RepoName
248+// - mr int64
249+// - issues []int64
250+// - closes []int64
251+func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call {
252+ return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)}
253+}
254+
255+func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call {
256+ _c.Call.Run(func(args mock.Arguments) {
257+ var arg0 context.Context
258+ if args[0] != nil {
259+ arg0 = args[0].(context.Context)
260+ }
261+ var arg1 domain.RepoName
262+ if args[1] != nil {
263+ arg1 = args[1].(domain.RepoName)
264+ }
265+ var arg2 int64
266+ if args[2] != nil {
267+ arg2 = args[2].(int64)
268+ }
269+ var arg3 []int64
270+ if args[3] != nil {
271+ arg3 = args[3].([]int64)
272+ }
273+ var arg4 []int64
274+ if args[4] != nil {
275+ arg4 = args[4].([]int64)
276+ }
277+ run(
278+ arg0,
279+ arg1,
280+ arg2,
281+ arg3,
282+ arg4,
283+ )
284+ })
285+ return _c
286+}
287+
288+func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call {
289+ _c.Call.Return(added, removed, err)
290+ return _c
291+}
292+
293+func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call {
294+ _c.Call.Return(run)
295+ return _c
296+}
@@ -0,0 +1,296 @@
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+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueLinkStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueLinkStore is an autogenerated mock type for the IssueLinkStore type
38+type IssueLinkStore struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter {
47+ return &IssueLinkStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// LinksOfIssue provides a mock function for the type IssueLinkStore
51+func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
52+ ret := _mock.Called(ctx, repo, issue, limit)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for LinksOfIssue")
56+ }
57+
58+ var r0 []domain.IssueLink
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok {
61+ return returnFunc(ctx, repo, issue, limit)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok {
64+ r0 = returnFunc(ctx, repo, issue, limit)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.IssueLink)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok {
71+ r1 = returnFunc(ctx, repo, issue, limit)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue'
79+type IssueLinkStore_LinksOfIssue_Call struct {
80+ *mock.Call
81+}
82+
83+// LinksOfIssue is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - issue int64
87+// - limit int
88+func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call {
89+ return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)}
90+}
91+
92+func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 int64
103+ if args[2] != nil {
104+ arg2 = args[2].(int64)
105+ }
106+ var arg3 int
107+ if args[3] != nil {
108+ arg3 = args[3].(int)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call {
121+ _c.Call.Return(issueLinks, err)
122+ return _c
123+}
124+
125+func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// LinksOfMergeRequest provides a mock function for the type IssueLinkStore
131+func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
132+ ret := _mock.Called(ctx, repo, mr)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for LinksOfMergeRequest")
136+ }
137+
138+ var r0 []domain.IssueLink
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok {
141+ return returnFunc(ctx, repo, mr)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok {
144+ r0 = returnFunc(ctx, repo, mr)
145+ } else {
146+ if ret.Get(0) != nil {
147+ r0 = ret.Get(0).([]domain.IssueLink)
148+ }
149+ }
150+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
151+ r1 = returnFunc(ctx, repo, mr)
152+ } else {
153+ r1 = ret.Error(1)
154+ }
155+ return r0, r1
156+}
157+
158+// IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest'
159+type IssueLinkStore_LinksOfMergeRequest_Call struct {
160+ *mock.Call
161+}
162+
163+// LinksOfMergeRequest is a helper method to define mock.On call
164+// - ctx context.Context
165+// - repo domain.RepoName
166+// - mr int64
167+func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call {
168+ return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)}
169+}
170+
171+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ var arg0 context.Context
174+ if args[0] != nil {
175+ arg0 = args[0].(context.Context)
176+ }
177+ var arg1 domain.RepoName
178+ if args[1] != nil {
179+ arg1 = args[1].(domain.RepoName)
180+ }
181+ var arg2 int64
182+ if args[2] != nil {
183+ arg2 = args[2].(int64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ )
190+ })
191+ return _c
192+}
193+
194+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call {
195+ _c.Call.Return(issueLinks, err)
196+ return _c
197+}
198+
199+func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call {
200+ _c.Call.Return(run)
201+ return _c
202+}
203+
204+// ReplaceLinks provides a mock function for the type IssueLinkStore
205+func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) {
206+ ret := _mock.Called(ctx, repo, mr, issues, closes)
207+
208+ if len(ret) == 0 {
209+ panic("no return value specified for ReplaceLinks")
210+ }
211+
212+ var r0 []domain.IssueLink
213+ var r1 []domain.IssueLink
214+ var r2 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok {
216+ return returnFunc(ctx, repo, mr, issues, closes)
217+ }
218+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
219+ r0 = returnFunc(ctx, repo, mr, issues, closes)
220+ } else {
221+ if ret.Get(0) != nil {
222+ r0 = ret.Get(0).([]domain.IssueLink)
223+ }
224+ }
225+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok {
226+ r1 = returnFunc(ctx, repo, mr, issues, closes)
227+ } else {
228+ if ret.Get(1) != nil {
229+ r1 = ret.Get(1).([]domain.IssueLink)
230+ }
231+ }
232+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok {
233+ r2 = returnFunc(ctx, repo, mr, issues, closes)
234+ } else {
235+ r2 = ret.Error(2)
236+ }
237+ return r0, r1, r2
238+}
239+
240+// IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks'
241+type IssueLinkStore_ReplaceLinks_Call struct {
242+ *mock.Call
243+}
244+
245+// ReplaceLinks is a helper method to define mock.On call
246+// - ctx context.Context
247+// - repo domain.RepoName
248+// - mr int64
249+// - issues []int64
250+// - closes []int64
251+func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call {
252+ return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)}
253+}
254+
255+func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call {
256+ _c.Call.Run(func(args mock.Arguments) {
257+ var arg0 context.Context
258+ if args[0] != nil {
259+ arg0 = args[0].(context.Context)
260+ }
261+ var arg1 domain.RepoName
262+ if args[1] != nil {
263+ arg1 = args[1].(domain.RepoName)
264+ }
265+ var arg2 int64
266+ if args[2] != nil {
267+ arg2 = args[2].(int64)
268+ }
269+ var arg3 []int64
270+ if args[3] != nil {
271+ arg3 = args[3].([]int64)
272+ }
273+ var arg4 []int64
274+ if args[4] != nil {
275+ arg4 = args[4].([]int64)
276+ }
277+ run(
278+ arg0,
279+ arg1,
280+ arg2,
281+ arg3,
282+ arg4,
283+ )
284+ })
285+ return _c
286+}
287+
288+func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call {
289+ _c.Call.Return(added, removed, err)
290+ return _c
291+}
292+
293+func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call {
294+ _c.Call.Return(run)
295+ return _c
296+}
M internal/ports/ports.go +12 -0
@@ -536,6 +536,18 @@ type EventBus interface {
536536 Subscribe(ctx context.Context) <-chan domain.Event
537537 }
538538
539+// IssueLinkStore keeps which merge requests name which issues.
540+type IssueLinkStore interface {
541+ // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues).
542+ // Added includes new links and links whose closes flag turned on.
543+ // Returns ErrNotFound for a missing repository, ErrConflict on a race.
544+ ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error)
545+ // LinksOfIssue returns links ascending by MR number, at most limit.
546+ LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)
547+ // LinksOfMergeRequest returns the issues named by an MR.
548+ LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)
549+}
550+
539551 // Clock tells the time.
540552 type Clock interface {
541553 Now() time.Time
@@ -536,6 +536,18 @@ type EventBus interface {
536 Subscribe(ctx context.Context) <-chan domain.Event536 Subscribe(ctx context.Context) <-chan domain.Event
537 }537 }
538 538
539+// IssueLinkStore keeps which merge requests name which issues.
540+type IssueLinkStore interface {
541+ // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues).
542+ // Added includes new links and links whose closes flag turned on.
543+ // Returns ErrNotFound for a missing repository, ErrConflict on a race.
544+ ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error)
545+ // LinksOfIssue returns links ascending by MR number, at most limit.
546+ LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)
547+ // LinksOfMergeRequest returns the issues named by an MR.
548+ LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)
549+}
550+
539 // Clock tells the time.551 // Clock tells the time.
540 type Clock interface {552 type Clock interface {
541 Now() time.Time553 Now() time.Time
M internal/ports/services.go +2 -1
@@ -203,7 +203,8 @@ type IssueSubmissionService interface {
203203 // implements it.
204204 type IssueNumberResolver interface {
205205 // Kind needs the access of reading the repository and answers
206- // domain.ErrNotFound otherwise, also when issues are off.
206+ // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge
207+ // requests are resolved even when issues are off.
207208 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
208209 }
209210
@@ -203,7 +203,8 @@ type IssueSubmissionService interface {
203 // implements it.203 // implements it.
204 type IssueNumberResolver interface {204 type IssueNumberResolver interface {
205 // Kind needs the access of reading the repository and answers205 // Kind needs the access of reading the repository and answers
206- // domain.ErrNotFound otherwise, also when issues are off.206+ // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge
207+ // requests are resolved even when issues are off.
207 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)208 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
208 }209 }
209 210
M internal/usecase/events.go +7 -1
@@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
186186 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187187 case domain.CommentAdded:
188188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReferenced:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189191 case domain.IssueOpened:
190192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191193 case domain.IssueClosed:
@@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
537539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538540 case domain.CommentAdded:
539541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.IssueReferenced:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540544 case domain.IssueOpened:
541545 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
542546 case domain.IssueClosed:
@@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
637641 // isIssueEvent reports whether ev is about an issue.
638642 func isIssueEvent(ev domain.Event) bool {
639643 switch ev.(type) {
640- case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
644+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced:
641645 return true
642646 }
643647 return false
@@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
656660 // isItemEvent reports whether ev is about a merge request or an issue.
657661 func isItemEvent(ev domain.Event) bool {
658662 switch ev.(type) {
663+ case domain.IssueReferenced:
664+ return false // back-reference refreshes do not change the inbox
659665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
660666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
@@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
186 return repoSnapshot(ev.Visibility, ev.CreatedAt)186 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187 case domain.CommentAdded:187 case domain.CommentAdded:
188 return repoSnapshot(ev.Visibility, ev.CreatedAt)188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReferenced:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189 case domain.IssueOpened:191 case domain.IssueOpened:
190 return repoSnapshot(ev.Visibility, ev.CreatedAt)192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191 case domain.IssueClosed:193 case domain.IssueClosed:
@@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538 case domain.CommentAdded:540 case domain.CommentAdded:
539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.IssueReferenced:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540 case domain.IssueOpened:544 case domain.IssueOpened:
541 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)545 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
542 case domain.IssueClosed:546 case domain.IssueClosed:
@@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
637 // isIssueEvent reports whether ev is about an issue.641 // isIssueEvent reports whether ev is about an issue.
638 func isIssueEvent(ev domain.Event) bool {642 func isIssueEvent(ev domain.Event) bool {
639 switch ev.(type) {643 switch ev.(type) {
640- case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:644+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced:
641 return true645 return true
642 }646 }
643 return false647 return false
@@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
656 // isItemEvent reports whether ev is about a merge request or an issue.660 // isItemEvent reports whether ev is about a merge request or an issue.
657 func isItemEvent(ev domain.Event) bool {661 func isItemEvent(ev domain.Event) bool {
658 switch ev.(type) {662 switch ev.(type) {
663+ case domain.IssueReferenced:
664+ return false // back-reference refreshes do not change the inbox
659 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
660 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
M internal/usecase/events_test.go +24 -0
@@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
10101010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
10111011 })
10121012 }
1013+
1014+func TestIssueReferencedRepoOnly(t *testing.T) {
1015+ synctest.Test(t, func(t *testing.T) {
1016+ r := newEventRig(t, 16)
1017+ n := name(t, "linked")
1018+ settings := domain.DefaultRepoSettings()
1019+ settings.Issues = domain.IssuesOwner
1020+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe()
1021+ ctx, cancel := context.WithCancel(context.Background())
1022+ defer cancel()
1023+ repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}})
1024+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
1025+ synctest.Wait()
1026+ ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
1027+ r.feed <- ev
1028+ assert.Equal(t, ev, repo.next(t).Event)
1029+ synctest.Wait()
1030+ select {
1031+ case got := <-owner.got:
1032+ t.Fatalf("owner received %+v", got)
1033+ default:
1034+ }
1035+ })
1036+}
@@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
1010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)1010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
1011 })1011 })
1012 }1012 }
1013+
1014+func TestIssueReferencedRepoOnly(t *testing.T) {
1015+ synctest.Test(t, func(t *testing.T) {
1016+ r := newEventRig(t, 16)
1017+ n := name(t, "linked")
1018+ settings := domain.DefaultRepoSettings()
1019+ settings.Issues = domain.IssuesOwner
1020+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe()
1021+ ctx, cancel := context.WithCancel(context.Background())
1022+ defer cancel()
1023+ repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}})
1024+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
1025+ synctest.Wait()
1026+ ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
1027+ r.feed <- ev
1028+ assert.Equal(t, ev, repo.next(t).Event)
1029+ synctest.Wait()
1030+ select {
1031+ case got := <-owner.got:
1032+ t.Fatalf("owner received %+v", got)
1033+ default:
1034+ }
1035+ })
1036+}
M internal/usecase/issues.go +13 -7
@@ -274,16 +274,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
274274 }
275275
276276 // Kind tells what number n of the repository names. The principal needs the
277-// access of reading the repository, and issues must be on there: otherwise it
278-// is domain.ErrNotFound.
277+// access of reading the repository. Issues are resolved only when enabled;
278+// merge requests are resolved even when issues are off.
279279 func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
280- if _, err := i.readable(ctx, p, name); err != nil {
280+ repo, err := lookupRepo(ctx, i.meta, name)
281+ if err != nil {
281282 return domain.NumberNone, err
282283 }
283- if _, err := i.store.Get(ctx, name, n); err == nil {
284- return domain.NumberIssue, nil
285- } else if !errors.Is(err, domain.ErrNotFound) {
286- return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
284+ if !canRead(p, repo) {
285+ return domain.NumberNone, domain.ErrNotFound
286+ }
287+ if repo.Settings.IssuesEnabled() {
288+ if _, err := i.store.Get(ctx, name, n); err == nil {
289+ return domain.NumberIssue, nil
290+ } else if !errors.Is(err, domain.ErrNotFound) {
291+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
292+ }
287293 }
288294 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289295 return domain.NumberMergeRequest, nil
@@ -274,16 +274,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
274 }274 }
275 275
276 // Kind tells what number n of the repository names. The principal needs the276 // Kind tells what number n of the repository names. The principal needs the
277-// access of reading the repository, and issues must be on there: otherwise it277+// access of reading the repository. Issues are resolved only when enabled;
278-// is domain.ErrNotFound.278+// merge requests are resolved even when issues are off.
279 func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {279 func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
280- if _, err := i.readable(ctx, p, name); err != nil {280+ repo, err := lookupRepo(ctx, i.meta, name)
281+ if err != nil {
281 return domain.NumberNone, err282 return domain.NumberNone, err
282 }283 }
283- if _, err := i.store.Get(ctx, name, n); err == nil {284+ if !canRead(p, repo) {
284- return domain.NumberIssue, nil285+ return domain.NumberNone, domain.ErrNotFound
285- } else if !errors.Is(err, domain.ErrNotFound) {286+ }
286- return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)287+ if repo.Settings.IssuesEnabled() {
288+ if _, err := i.store.Get(ctx, name, n); err == nil {
289+ return domain.NumberIssue, nil
290+ } else if !errors.Is(err, domain.ErrNotFound) {
291+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
292+ }
287 }293 }
288 if _, err := i.mrs.Get(ctx, name, n); err == nil {294 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289 return domain.NumberMergeRequest, nil295 return domain.NumberMergeRequest, nil
M internal/usecase/issues_test.go +4 -2
@@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) {
6565 assert.ErrorIs(t, err, domain.ErrNotFound)
6666 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
6767 assert.ErrorIs(t, err, domain.ErrNotFound)
68- _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
69- assert.ErrorIs(t, err, domain.ErrNotFound)
68+ u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
69+ kind, err := u.issues.Kind(ctx, domain.Root(), n, 1)
70+ require.NoError(t, err)
71+ assert.Equal(t, domain.NumberMergeRequest, kind)
7072 }
7173 }
7274
@@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) {
65 assert.ErrorIs(t, err, domain.ErrNotFound)65 assert.ErrorIs(t, err, domain.ErrNotFound)
66 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)66 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
67 assert.ErrorIs(t, err, domain.ErrNotFound)67 assert.ErrorIs(t, err, domain.ErrNotFound)
68- _, err = u.issues.Kind(ctx, domain.Root(), n, 1)68+ u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
69- assert.ErrorIs(t, err, domain.ErrNotFound)69+ kind, err := u.issues.Kind(ctx, domain.Root(), n, 1)
70+ require.NoError(t, err)
71+ assert.Equal(t, domain.NumberMergeRequest, kind)
70 }72 }
71 }73 }
72 74
M proto/ermite/storage/issue.proto +12 -0
@@ -36,6 +36,18 @@ message Issue {
3636 // True for an issue approved from the public form: its text comes from
3737 // an anonymous visitor and is rendered as untrusted.
3838 bool external = 9;
39+ int64 closed_by_merge_request = 10;
40+}
41+
42+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
43+message IssueLink {
44+ bool closes = 1;
45+}
46+
47+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
48+message MergeRequestReferences {
49+ repeated int64 issues = 1;
50+ repeated int64 closes = 2;
3951 }
4052
4153 // IssueComment is stored under the key
@@ -36,6 +36,18 @@ message Issue {
36 // True for an issue approved from the public form: its text comes from36 // True for an issue approved from the public form: its text comes from
37 // an anonymous visitor and is rendered as untrusted.37 // an anonymous visitor and is rendered as untrusted.
38 bool external = 9;38 bool external = 9;
39+ int64 closed_by_merge_request = 10;
40+}
41+
42+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
43+message IssueLink {
44+ bool closes = 1;
45+}
46+
47+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
48+message MergeRequestReferences {
49+ repeated int64 issues = 1;
50+ repeated int64 closes = 2;
39 }51 }
40 52
41 // IssueComment is stored under the key53 // IssueComment is stored under the key

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