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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 72 | 72 | func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 73 | 73 | var keys [][]byte | |
| 74 | 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 | 77 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 78 | 78 | keys = append(keys, slices.Clone(k)) | |
| @@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||
| 312 | 312 | return nil, err | |
| 313 | 313 | } | |
| 314 | 314 | 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, | |
| 324 | 325 | }), nil | |
| 325 | 326 | } | |
| 326 | 327 | ||
| @@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |||
| 348 | 349 | Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state, | |
| 349 | 350 | Author: pb.GetAuthor(), | |
| 350 | 351 | 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, | |
| 354 | 356 | }, nil | |
| 355 | 357 | } | |
| 356 | 358 | ||
| @@ -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 [][]byte | 73 | 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, err | 312 | 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 | }), nil | 325 | }), 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 | }, nil | 356 | }, 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 | |||
| 181 | 181 | var keys [][]byte | |
| 182 | 182 | for _, p := range []string{ | |
| 183 | 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 | 185 | mergePrefix + repo.String() + "/", | |
| 186 | 186 | } { | |
| 187 | 187 | 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 [][]byte | 181 | 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 { | |||
| 530 | 530 | out.Time = timestamp(ev.At) | |
| 531 | 531 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | |
| 532 | 532 | 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. | |
| 535 | 535 | return nil | |
| 536 | 536 | } | |
| 537 | 537 | 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 has | 533 | + | // 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 nil | 535 | return nil | ||
| 536 | } | 536 | } | ||
| 537 | return out | 537 | return out | ||
M internal/domain/event.go +12 -0
| @@ -186,6 +186,18 @@ type IssueOpened struct { | |||
| 186 | 186 | // EventKind implements Event. | |
| 187 | 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 | 201 | // IssueClosed is published when an issue was closed. | |
| 190 | 202 | type IssueClosed struct { | |
| 191 | 203 | 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 Issue | 203 | Issue Issue | ||
M internal/domain/issue.go +8 -0
| @@ -73,11 +73,19 @@ type Issue struct { | |||
| 73 | 73 | // External is true for an issue approved from the public form: its text | |
| 74 | 74 | // comes from an anonymous visitor and is rendered as untrusted. | |
| 75 | 75 | External bool | |
| 76 | + | // ClosedByMergeRequest is zero unless a merge closed the issue. | |
| 77 | + | ClosedByMergeRequest int64 | |
| 76 | 78 | // Version is set by the store on read: Update fails with ErrConflict when | |
| 77 | 79 | // the stored issue changed since. Opaque to everyone else. | |
| 78 | 80 | 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 | 89 | // IsOpen reports whether the issue is open. | |
| 82 | 90 | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | |
| 83 | 91 | ||
| @@ -73,11 +73,19 @@ type Issue struct { | |||||
| 73 | // External is true for an issue approved from the public form: its text | 73 | // 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 bool | 75 | 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 when | 78 | // 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 uint64 | 80 | 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 ' ##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 ' ##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 { | |||
| 536 | 536 | 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 | 551 | // Clock tells the time. | |
| 540 | 552 | type Clock interface { | |
| 541 | 553 | Now() time.Time | |
| @@ -536,6 +536,18 @@ type EventBus interface { | |||||
| 536 | Subscribe(ctx context.Context) <-chan domain.Event | 536 | 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.Time | 553 | Now() time.Time | ||
M internal/ports/services.go +2 -1
| @@ -203,7 +203,8 @@ type IssueSubmissionService interface { | |||
| 203 | 203 | // implements it. | |
| 204 | 204 | type IssueNumberResolver interface { | |
| 205 | 205 | // 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 | 208 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | |
| 208 | 209 | } | |
| 209 | 210 | ||
| @@ -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 answers | 205 | // 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 { | |||
| 186 | 186 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 187 | 187 | case domain.CommentAdded: | |
| 188 | 188 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 189 | + | case domain.IssueReferenced: | |
| 190 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 189 | 191 | case domain.IssueOpened: | |
| 190 | 192 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 191 | 193 | case domain.IssueClosed: | |
| @@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 537 | 539 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 538 | 540 | case domain.CommentAdded: | |
| 539 | 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 | 544 | case domain.IssueOpened: | |
| 541 | 545 | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | |
| 542 | 546 | case domain.IssueClosed: | |
| @@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||
| 637 | 641 | // isIssueEvent reports whether ev is about an issue. | |
| 638 | 642 | func isIssueEvent(ev domain.Event) bool { | |
| 639 | 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 | 645 | return true | |
| 642 | 646 | } | |
| 643 | 647 | return false | |
| @@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||
| 656 | 660 | // isItemEvent reports whether ev is about a merge request or an issue. | |
| 657 | 661 | func isItemEvent(ev domain.Event) bool { | |
| 658 | 662 | switch ev.(type) { | |
| 663 | + | case domain.IssueReferenced: | |
| 664 | + | return false // back-reference refreshes do not change the inbox | |
| 659 | 665 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | |
| 660 | 666 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | |
| 661 | 667 | 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 true | 645 | return true | ||
| 642 | } | 646 | } | ||
| 643 | return false | 647 | 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) { | |||
| 1010 | 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 | + | } | |
| @@ -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 | |||
| 274 | 274 | } | |
| 275 | 275 | ||
| 276 | 276 | // 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. | |
| 279 | 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 | 282 | return domain.NumberNone, err | |
| 282 | 283 | } | |
| 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 | + | } | |
| 287 | 293 | } | |
| 288 | 294 | if _, err := i.mrs.Get(ctx, name, n); err == nil { | |
| 289 | 295 | 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 the | 276 | // 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 | 277 | + | // 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, err | 282 | 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, nil | 285 | + | 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, nil | 295 | return domain.NumberMergeRequest, nil | ||
M internal/usecase/issues_test.go +4 -2
| @@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) { | |||
| 65 | 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 66 | 66 | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | |
| 67 | 67 | 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) | |
| 70 | 72 | } | |
| 71 | 73 | } | |
| 72 | 74 | ||
| @@ -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 { | |||
| 36 | 36 | // True for an issue approved from the public form: its text comes from | |
| 37 | 37 | // an anonymous visitor and is rendered as untrusted. | |
| 38 | 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 | 53 | // 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 from | 36 | // 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 key | 53 | // IssueComment is stored under the key | ||