detached 9724b25

Merge 4bec9a08f1d16de05320cc93a6cc759e32faa7a4 into 3ed746903cd6a04584ae699b9d178cd5e6d53513

commit
9724b250b3c0ef76713b6e9afe62e11085b78bb1
author
ermite · · 2026-10-20 16:26 UTC
merge of
3ed7469 + 4bec9a0 · diff against the first parent
signature
unsigned
48 files · +2515 -179
M docs/cli.md +1 -0
@@ -61,6 +61,7 @@ ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--del
6161
6262 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
6363 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references
6465
6566 ermite run list <repo> [--workflow …] [--branch …] [--status …]
6667 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
@@ -61,6 +61,7 @@ ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--del
61 61
62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references
64 65
65 ermite run list <repo> [--workflow …] [--branch …] [--status …]66 ermite run list <repo> [--workflow …] [--branch …] [--status …]
66 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]67 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
M docs/issues.md +12 -0
@@ -34,6 +34,18 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br
3434
3535 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
3636
37+## Merge request links
38+
39+A merge request and the issues of its repository are linked by `#N` in its text:
40+
41+- **Closing keywords** close the issue when the merge request is **merged**, and only then: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved` (any case), then `#N`, with an optional `:` after the keyword. A list works: `fixes #3, #4 and #5`. They count in the title, the description and the commit messages, not in notes. Closing the merge request without merging closes nothing.
42+- **Any `#N`** in the title, description, notes or commit messages shows the merge request on the issue (`ermite issue show`, the API `references`), with its state and whether it closes the issue.
43+- A closed issue records the merge request that closed it ("closed by !N"). Reopening or closing by hand clears it.
44+- The links are recomputed whenever the merge request changes (a note added, edited or deleted, a push to its branch, a merge), so removing a mention removes the link.
45+- Words glued to the number (`abc#3`), `&#39;` and `##3` are not references. Code spans are not skipped.
46+
47+Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 100 merge requests listed per issue. Only numbers of existing issues are linked: a mention of a number that does not exist yet is not filled in later. With issues `off`, nothing is closed.
48+
3749 ## Inbox
3850
3951 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
@@ -34,6 +34,18 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br
34 34
35 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.35 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
36 36
37+## Merge request links
38+
39+A merge request and the issues of its repository are linked by `#N` in its text:
40+
41+- **Closing keywords** close the issue when the merge request is **merged**, and only then: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved` (any case), then `#N`, with an optional `:` after the keyword. A list works: `fixes #3, #4 and #5`. They count in the title, the description and the commit messages, not in notes. Closing the merge request without merging closes nothing.
42+- **Any `#N`** in the title, description, notes or commit messages shows the merge request on the issue (`ermite issue show`, the API `references`), with its state and whether it closes the issue.
43+- A closed issue records the merge request that closed it ("closed by !N"). Reopening or closing by hand clears it.
44+- The links are recomputed whenever the merge request changes (a note added, edited or deleted, a push to its branch, a merge), so removing a mention removes the link.
45+- Words glued to the number (`abc#3`), `&#39;` and `##3` are not references. Code spans are not skipped.
46+
47+Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 100 merge requests listed per issue. Only numbers of existing issues are linked: a mention of a number that does not exist yet is not filled in later. With issues `off`, nothing is closed.
48+
37 ## Inbox49 ## Inbox
38 50
39 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.51 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
M docs/merge-requests.md +2 -0
@@ -8,6 +8,8 @@ ermite mr show myrepo 1 --diff
88 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]
99 ```
1010
11+Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issues](issues.md#merge-request-links).
12+
1113 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
1214 merge request web URL on its own line. With `--json`, merge request objects
1315 (create, show, list, close and merge) include `url` alongside the other fields.
@@ -8,6 +8,8 @@ ermite mr show myrepo 1 --diff
8 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]8 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]
9 ```9 ```
10 10
11+Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issues](issues.md#merge-request-links).
12+
11 `ermite mr create` and `ermite mr close` print a confirmation followed by the full13 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
12 merge request web URL on its own line. With `--json`, merge request objects14 merge request web URL on its own line. With `--json`, merge request objects
13 (create, show, list, close and merge) include `url` alongside the other fields.15 (create, show, list, close and merge) include `url` alongside the other fields.
M gen/ermite/storage/issue.pb.go +146 -31 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.12.0
16+ bnema.dev/ermite-api v0.13.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.12.016+ bnema.dev/ermite-api v0.13.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
A internal/adapters/kvstore/issue_links.go +151 -0
@@ -0,0 +1,151 @@
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+ issueSet, closeSet := toSet(issues), toSet(closes)
61+ oldIssues, oldCloses := toSet(old.GetIssues()), toSet(old.GetCloses())
62+ for _, n := range closes {
63+ if _, ok := issueSet[n]; !ok {
64+ return nil, nil, domain.ErrInvalid
65+ }
66+ }
67+ var b kv.Batch
68+ if err := guardRepoKey(ctx, l.db, &b, repo); err != nil {
69+ return nil, nil, err
70+ }
71+ if exists {
72+ b.RequireVersion(key, item.Version)
73+ } else {
74+ b.RequireAbsent(key)
75+ }
76+ for _, n := range old.GetIssues() {
77+ _, wasClosing := oldCloses[n]
78+ if _, ok := issueSet[n]; !ok {
79+ b.Delete(issueRefKey(repo, n, mr))
80+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing})
81+ } else if _, still := closeSet[n]; wasClosing && !still {
82+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true})
83+ }
84+ }
85+ for _, n := range issues {
86+ _, closing := closeSet[n]
87+ b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing}))
88+ _, was := oldIssues[n]
89+ _, wasClosing := oldCloses[n]
90+ if !was || closing && !wasClosing {
91+ added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing})
92+ }
93+ }
94+ b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes}))
95+ if _, err := l.db.Apply(ctx, &b); err != nil {
96+ if errors.Is(err, kv.ErrConflict) {
97+ return nil, nil, domain.ErrConflict
98+ }
99+ return nil, nil, fmt.Errorf("replace issue links: %w", err)
100+ }
101+ return added, removed, nil
102+}
103+
104+// LinksOfIssue returns links ordered by merge request number.
105+func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
106+ var result []domain.IssueLink
107+ prefix := issueRefKeys(repo, issue)
108+ for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
109+ if err := ctx.Err(); err != nil {
110+ return nil, err
111+ }
112+ mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
113+ if err != nil {
114+ return nil, fmt.Errorf("decode issue link key: %w", err)
115+ }
116+ var pb storagepb.IssueLink
117+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
118+ return nil, fmt.Errorf("decode issue link: %w", err)
119+ }
120+ result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()})
121+ if limit > 0 && len(result) >= limit {
122+ break
123+ }
124+ }
125+ return result, nil
126+}
127+
128+// LinksOfMergeRequest returns the links recorded by its last rebuild.
129+func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
130+ item, ok := l.db.Get(ctx, mrRefKey(repo, mr))
131+ if !ok {
132+ return nil, nil
133+ }
134+ var pb storagepb.MergeRequestReferences
135+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
136+ return nil, fmt.Errorf("decode MR references: %w", err)
137+ }
138+ var result []domain.IssueLink
139+ for _, n := range pb.GetIssues() {
140+ result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)})
141+ }
142+ return result, nil
143+}
144+
145+func toSet(numbers []int64) map[int64]struct{} {
146+ set := make(map[int64]struct{}, len(numbers))
147+ for _, n := range numbers {
148+ set[n] = struct{}{}
149+ }
150+ return set
151+}
@@ -0,0 +1,151 @@
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+ issueSet, closeSet := toSet(issues), toSet(closes)
61+ oldIssues, oldCloses := toSet(old.GetIssues()), toSet(old.GetCloses())
62+ for _, n := range closes {
63+ if _, ok := issueSet[n]; !ok {
64+ return nil, nil, domain.ErrInvalid
65+ }
66+ }
67+ var b kv.Batch
68+ if err := guardRepoKey(ctx, l.db, &b, repo); err != nil {
69+ return nil, nil, err
70+ }
71+ if exists {
72+ b.RequireVersion(key, item.Version)
73+ } else {
74+ b.RequireAbsent(key)
75+ }
76+ for _, n := range old.GetIssues() {
77+ _, wasClosing := oldCloses[n]
78+ if _, ok := issueSet[n]; !ok {
79+ b.Delete(issueRefKey(repo, n, mr))
80+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing})
81+ } else if _, still := closeSet[n]; wasClosing && !still {
82+ removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true})
83+ }
84+ }
85+ for _, n := range issues {
86+ _, closing := closeSet[n]
87+ b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing}))
88+ _, was := oldIssues[n]
89+ _, wasClosing := oldCloses[n]
90+ if !was || closing && !wasClosing {
91+ added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing})
92+ }
93+ }
94+ b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes}))
95+ if _, err := l.db.Apply(ctx, &b); err != nil {
96+ if errors.Is(err, kv.ErrConflict) {
97+ return nil, nil, domain.ErrConflict
98+ }
99+ return nil, nil, fmt.Errorf("replace issue links: %w", err)
100+ }
101+ return added, removed, nil
102+}
103+
104+// LinksOfIssue returns links ordered by merge request number.
105+func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) {
106+ var result []domain.IssueLink
107+ prefix := issueRefKeys(repo, issue)
108+ for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
109+ if err := ctx.Err(); err != nil {
110+ return nil, err
111+ }
112+ mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
113+ if err != nil {
114+ return nil, fmt.Errorf("decode issue link key: %w", err)
115+ }
116+ var pb storagepb.IssueLink
117+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
118+ return nil, fmt.Errorf("decode issue link: %w", err)
119+ }
120+ result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()})
121+ if limit > 0 && len(result) >= limit {
122+ break
123+ }
124+ }
125+ return result, nil
126+}
127+
128+// LinksOfMergeRequest returns the links recorded by its last rebuild.
129+func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) {
130+ item, ok := l.db.Get(ctx, mrRefKey(repo, mr))
131+ if !ok {
132+ return nil, nil
133+ }
134+ var pb storagepb.MergeRequestReferences
135+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
136+ return nil, fmt.Errorf("decode MR references: %w", err)
137+ }
138+ var result []domain.IssueLink
139+ for _, n := range pb.GetIssues() {
140+ result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)})
141+ }
142+ return result, nil
143+}
144+
145+func toSet(numbers []int64) map[int64]struct{} {
146+ set := make(map[int64]struct{}, len(numbers))
147+ for _, n := range numbers {
148+ set[n] = struct{}{}
149+ }
150+ return set
151+}
A internal/adapters/kvstore/issue_links_test.go +66 -0
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "bnema.dev/ermite/internal/domain"
6+ "context"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "testing"
10+)
11+
12+func TestIssueLinksReplace(t *testing.T) {
13+ ctx := context.Background()
14+ s, _, repo := repoWithMRs(t)
15+ links := s.IssueLinks()
16+ added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2})
17+ require.NoError(t, err)
18+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added)
19+ assert.Empty(t, removed)
20+ got, err := links.LinksOfMergeRequest(ctx, repo, 9)
21+ require.NoError(t, err)
22+ assert.Equal(t, added, got)
23+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2})
24+ require.NoError(t, err)
25+ assert.Empty(t, added)
26+ assert.Empty(t, removed)
27+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3})
28+ require.NoError(t, err)
29+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added)
30+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed)
31+ got, err = links.LinksOfIssue(ctx, repo, 2, 100)
32+ require.NoError(t, err)
33+ assert.Empty(t, got)
34+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil)
35+ require.NoError(t, err)
36+ assert.Empty(t, added)
37+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed)
38+ for _, mr := range []int64{12, 1, 5} {
39+ _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil)
40+ require.NoError(t, err)
41+ }
42+ got, err = links.LinksOfIssue(ctx, repo, 3, 2)
43+ require.NoError(t, err)
44+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got)
45+ require.NoError(t, s.Delete(ctx, repo))
46+ assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/"))
47+ assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/"))
48+ _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil)
49+ assert.ErrorIs(t, err, domain.ErrNotFound)
50+}
51+
52+func TestIssueClosedByRoundTrip(t *testing.T) {
53+ ctx := context.Background()
54+ s, _, repo := repoWithMRs(t)
55+ issue := newIssue(t, repo, "closed by merge")
56+ issue.State = domain.IssueStateClosed
57+ issue.ClosedByMergeRequest = 17
58+ stored, err := s.Issues().Create(ctx, issue)
59+ require.NoError(t, err)
60+ assert.Equal(t, int64(17), stored.ClosedByMergeRequest)
61+ stored.ClosedByMergeRequest = 0
62+ require.NoError(t, s.Issues().Update(ctx, stored))
63+ stored, err = s.Issues().Get(ctx, repo, stored.Number)
64+ require.NoError(t, err)
65+ assert.Zero(t, stored.ClosedByMergeRequest)
66+}
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "bnema.dev/ermite/internal/domain"
6+ "context"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+ "testing"
10+)
11+
12+func TestIssueLinksReplace(t *testing.T) {
13+ ctx := context.Background()
14+ s, _, repo := repoWithMRs(t)
15+ links := s.IssueLinks()
16+ added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2})
17+ require.NoError(t, err)
18+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added)
19+ assert.Empty(t, removed)
20+ got, err := links.LinksOfMergeRequest(ctx, repo, 9)
21+ require.NoError(t, err)
22+ assert.Equal(t, added, got)
23+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2})
24+ require.NoError(t, err)
25+ assert.Empty(t, added)
26+ assert.Empty(t, removed)
27+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3})
28+ require.NoError(t, err)
29+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added)
30+ assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed)
31+ got, err = links.LinksOfIssue(ctx, repo, 2, 100)
32+ require.NoError(t, err)
33+ assert.Empty(t, got)
34+ added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil)
35+ require.NoError(t, err)
36+ assert.Empty(t, added)
37+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed)
38+ for _, mr := range []int64{12, 1, 5} {
39+ _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil)
40+ require.NoError(t, err)
41+ }
42+ got, err = links.LinksOfIssue(ctx, repo, 3, 2)
43+ require.NoError(t, err)
44+ assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got)
45+ require.NoError(t, s.Delete(ctx, repo))
46+ assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/"))
47+ assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/"))
48+ _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil)
49+ assert.ErrorIs(t, err, domain.ErrNotFound)
50+}
51+
52+func TestIssueClosedByRoundTrip(t *testing.T) {
53+ ctx := context.Background()
54+ s, _, repo := repoWithMRs(t)
55+ issue := newIssue(t, repo, "closed by merge")
56+ issue.State = domain.IssueStateClosed
57+ issue.ClosedByMergeRequest = 17
58+ stored, err := s.Issues().Create(ctx, issue)
59+ require.NoError(t, err)
60+ assert.Equal(t, int64(17), stored.ClosedByMergeRequest)
61+ stored.ClosedByMergeRequest = 0
62+ require.NoError(t, s.Issues().Update(ctx, stored))
63+ stored, err = s.Issues().Get(ctx, repo, stored.Number)
64+ require.NoError(t, err)
65+ assert.Zero(t, stored.ClosedByMergeRequest)
66+}
M internal/adapters/kvstore/issues.go +15 -13
@@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
7272 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
7373 var keys [][]byte
7474 for _, p := range []string{
75- issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/",
7676 } {
7777 for k := range snap.Prefix(ctx, []byte(p)) {
7878 keys = append(keys, slices.Clone(k))
@@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
312312 return nil, err
313313 }
314314 return mustMarshal(&storagepb.Issue{
315- Number: issue.Number,
316- Title: issue.Title.String(),
317- Body: issue.Body.String(),
318- State: state,
319- Author: issue.Author,
320- CreatedAt: encodeTime(issue.CreatedAt),
321- UpdatedAt: encodeTime(issue.UpdatedAt),
322- CommentSeq: issue.CommentSeq,
323- External: issue.External,
315+ Number: issue.Number,
316+ Title: issue.Title.String(),
317+ Body: issue.Body.String(),
318+ State: state,
319+ Author: issue.Author,
320+ CreatedAt: encodeTime(issue.CreatedAt),
321+ UpdatedAt: encodeTime(issue.UpdatedAt),
322+ CommentSeq: issue.CommentSeq,
323+ External: issue.External,
324+ ClosedByMergeRequest: issue.ClosedByMergeRequest,
324325 }), nil
325326 }
326327
@@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
348349 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
349350 Author: pb.GetAuthor(),
350351 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
351- CommentSeq: pb.GetCommentSeq(),
352- External: pb.GetExternal(),
353- Version: item.Version,
352+ CommentSeq: pb.GetCommentSeq(),
353+ External: pb.GetExternal(),
354+ ClosedByMergeRequest: pb.GetClosedByMergeRequest(),
355+ Version: item.Version,
354356 }, nil
355357 }
356358
@@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
72 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {72 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
73 var keys [][]byte73 var keys [][]byte
74 for _, p := range []string{74 for _, p := range []string{
75- issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/",
76 } {76 } {
77 for k := range snap.Prefix(ctx, []byte(p)) {77 for k := range snap.Prefix(ctx, []byte(p)) {
78 keys = append(keys, slices.Clone(k))78 keys = append(keys, slices.Clone(k))
@@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
312 return nil, err312 return nil, err
313 }313 }
314 return mustMarshal(&storagepb.Issue{314 return mustMarshal(&storagepb.Issue{
315- Number: issue.Number,315+ Number: issue.Number,
316- Title: issue.Title.String(),316+ Title: issue.Title.String(),
317- Body: issue.Body.String(),317+ Body: issue.Body.String(),
318- State: state,318+ State: state,
319- Author: issue.Author,319+ Author: issue.Author,
320- CreatedAt: encodeTime(issue.CreatedAt),320+ CreatedAt: encodeTime(issue.CreatedAt),
321- UpdatedAt: encodeTime(issue.UpdatedAt),321+ UpdatedAt: encodeTime(issue.UpdatedAt),
322- CommentSeq: issue.CommentSeq,322+ CommentSeq: issue.CommentSeq,
323- External: issue.External,323+ External: issue.External,
324+ ClosedByMergeRequest: issue.ClosedByMergeRequest,
324 }), nil325 }), nil
325 }326 }
326 327
@@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
348 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,349 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
349 Author: pb.GetAuthor(),350 Author: pb.GetAuthor(),
350 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),351 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
351- CommentSeq: pb.GetCommentSeq(),352+ CommentSeq: pb.GetCommentSeq(),
352- External: pb.GetExternal(),353+ External: pb.GetExternal(),
353- Version: item.Version,354+ ClosedByMergeRequest: pb.GetClosedByMergeRequest(),
355+ Version: item.Version,
354 }, nil356 }, nil
355 }357 }
356 358
M internal/adapters/kvstore/mergerequests.go +1 -1
@@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
181181 var keys [][]byte
182182 for _, p := range []string{
183183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
184- mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
184+ mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/",
185185 mergePrefix + repo.String() + "/",
186186 } {
187187 for k := range snap.Prefix(ctx, []byte(p)) {
@@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
181 var keys [][]byte181 var keys [][]byte
182 for _, p := range []string{182 for _, p := range []string{
183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
184- mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",184+ mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/",
185 mergePrefix + repo.String() + "/",185 mergePrefix + repo.String() + "/",
186 } {186 } {
187 for k := range snap.Prefix(ctx, []byte(p)) {187 for k := range snap.Prefix(ctx, []byte(p)) {
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +2 -2
@@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
530530 out.Time = timestamp(ev.At)
531531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
532532 default:
533- // Web-only events, such as domain.IssuePendingRemoved (the API has
534- // no message for it; watchers list pending issues on demand).
533+ // Web-only events, such as domain.IssueReferenced and
534+ // domain.IssuePendingRemoved: the API has no messages for them.
535535 return nil
536536 }
537537 return out
@@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
530 out.Time = timestamp(ev.At)530 out.Time = timestamp(ev.At)
531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
532 default:532 default:
533- // Web-only events, such as domain.IssuePendingRemoved (the API has533+ // Web-only events, such as domain.IssueReferenced and
534- // no message for it; watchers list pending issues on demand).534+ // domain.IssuePendingRemoved: the API has no messages for them.
535 return nil535 return nil
536 }536 }
537 return out537 return out
M internal/adapters/rpc/issue.go +19 -9
@@ -70,15 +70,16 @@ func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
7070
7171 func issueToPB(i domain.Issue) *pb.Issue {
7272 return &pb.Issue{
73- Repo: i.Repo.String(),
74- Number: i.Number,
75- Title: i.Title.String(),
76- Body: i.Body.String(),
77- State: issueStateToPB(i.State),
78- Author: i.Author,
79- CreatedAt: timestamp(i.CreatedAt),
80- UpdatedAt: timestamp(i.UpdatedAt),
81- CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
73+ Repo: i.Repo.String(),
74+ Number: i.Number,
75+ Title: i.Title.String(),
76+ Body: i.Body.String(),
77+ State: issueStateToPB(i.State),
78+ Author: i.Author,
79+ CreatedAt: timestamp(i.CreatedAt),
80+ UpdatedAt: timestamp(i.UpdatedAt),
81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ ClosedByMergeRequest: i.ClosedByMergeRequest,
8283 }
8384 }
8485
@@ -138,6 +139,15 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
138139 for _, c := range d.Comments {
139140 out.Comments = append(out.Comments, issueCommentToPB(c))
140141 }
142+ out.References = make([]*pb.IssueReference, 0, len(d.References))
143+ for _, r := range d.References {
144+ out.References = append(out.References, &pb.IssueReference{
145+ Number: r.Number,
146+ Title: r.Title.String(),
147+ State: mergeRequestStateToPB(r.State),
148+ Closes: r.Closes,
149+ })
150+ }
141151 return connect.NewResponse(out), nil
142152 }
143153
@@ -70,15 +70,16 @@ func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
70 70
71 func issueToPB(i domain.Issue) *pb.Issue {71 func issueToPB(i domain.Issue) *pb.Issue {
72 return &pb.Issue{72 return &pb.Issue{
73- Repo: i.Repo.String(),73+ Repo: i.Repo.String(),
74- Number: i.Number,74+ Number: i.Number,
75- Title: i.Title.String(),75+ Title: i.Title.String(),
76- Body: i.Body.String(),76+ Body: i.Body.String(),
77- State: issueStateToPB(i.State),77+ State: issueStateToPB(i.State),
78- Author: i.Author,78+ Author: i.Author,
79- CreatedAt: timestamp(i.CreatedAt),79+ CreatedAt: timestamp(i.CreatedAt),
80- UpdatedAt: timestamp(i.UpdatedAt),80+ UpdatedAt: timestamp(i.UpdatedAt),
81- CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ ClosedByMergeRequest: i.ClosedByMergeRequest,
82 }83 }
83 }84 }
84 85
@@ -138,6 +139,15 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
138 for _, c := range d.Comments {139 for _, c := range d.Comments {
139 out.Comments = append(out.Comments, issueCommentToPB(c))140 out.Comments = append(out.Comments, issueCommentToPB(c))
140 }141 }
142+ out.References = make([]*pb.IssueReference, 0, len(d.References))
143+ for _, r := range d.References {
144+ out.References = append(out.References, &pb.IssueReference{
145+ Number: r.Number,
146+ Title: r.Title.String(),
147+ State: mergeRequestStateToPB(r.State),
148+ Closes: r.Closes,
149+ })
150+ }
141 return connect.NewResponse(out), nil151 return connect.NewResponse(out), nil
142 }152 }
143 153
M internal/adapters/web/fragments.go +3 -1
@@ -63,7 +63,7 @@ func liveEvent(we ports.WatchEvent) bool {
6363 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6464 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6565 domain.RunUpdated, domain.JobUpdated,
66- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
66+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
6767 return true
6868 }
6969 return false
@@ -210,6 +210,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
210210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
211211 case domain.IssueCommented:
212212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
213+ case domain.IssueReferenced:
214+ return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload")
213215 case domain.PushEvent:
214216 switch l.view {
215217 case "mr":
@@ -63,7 +63,7 @@ func liveEvent(we ports.WatchEvent) bool {
63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,64 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
65 domain.RunUpdated, domain.JobUpdated,65 domain.RunUpdated, domain.JobUpdated,
66- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:66+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
67 return true67 return true
68 }68 }
69 return false69 return false
@@ -210,6 +210,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
211 case domain.IssueCommented:211 case domain.IssueCommented:
212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
213+ case domain.IssueReferenced:
214+ return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload")
213 case domain.PushEvent:215 case domain.PushEvent:
214 switch l.view {216 switch l.view {
215 case "mr":217 case "mr":
M internal/adapters/web/issues.go +90 -13
@@ -83,6 +83,58 @@ type IssuePage struct {
8383 Form *CommentForm
8484 Transition *IssueActions
8585 Live bool
86+ // ClosedBy is the merge request whose merge closed the issue.
87+ ClosedBy *Link
88+ // References are the merge requests naming the issue.
89+ References []IssueRefView
90+}
91+
92+// IssueRefView is a merge request naming an issue.
93+type IssueRefView struct {
94+ Number, Title, State, Href string
95+ Closes bool
96+}
97+
98+// maxNumberLookups bounds the lookups of #N references of one render.
99+const maxNumberLookups = 50
100+
101+// numberResolver resolves #N to the page of an issue or a merge request of
102+// the repository. It is meant for one render: answers are memoized and at most
103+// maxNumberLookups numbers are asked, the others do not resolve.
104+func (s *Server) numberResolver(ctx context.Context, repo string) func(int64) (string, domain.NumberKind, bool) {
105+ if s.cfg.IssueNumbers == nil {
106+ return nil
107+ }
108+ name, err := domain.ParseRepoName(repo)
109+ if err != nil {
110+ return nil
111+ }
112+ type answer struct {
113+ href string
114+ kind domain.NumberKind
115+ ok bool
116+ }
117+ seen := map[int64]answer{}
118+ return func(n int64) (string, domain.NumberKind, bool) {
119+ if a, ok := seen[n]; ok {
120+ return a.href, a.kind, a.ok
121+ }
122+ if len(seen) >= maxNumberLookups {
123+ return "", domain.NumberNone, false
124+ }
125+ var a answer
126+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
127+ if err == nil {
128+ switch kind {
129+ case domain.NumberIssue:
130+ a = answer{issueHref(repo, n), kind, true}
131+ case domain.NumberMergeRequest:
132+ a = answer{mrHref(repo, n), kind, true}
133+ }
134+ }
135+ seen[n] = a
136+ return a.href, a.kind, a.ok
137+ }
86138 }
87139
88140 // IssueNewPage is the form that opens an issue.
@@ -238,20 +290,11 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
238290 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
239291 State: string(i.State), Open: i.IsOpen(),
240292 }
241- if body := i.Body.String(); strings.TrimSpace(body) != "" {
242- var h template.HTML
243- var err error
244- if i.External {
245- // Written by an anonymous visitor: the untrusted policy.
246- h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
247- } else {
248- h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
249- }
250- if err != nil {
251- return nil, 0, err
252- }
253- p.Desc = h
293+ if p.Desc, err = s.issueBody(ctx, d, i); err != nil {
294+ return nil, 0, err
254295 }
296+ p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest)
297+ p.References = issueRefViews(d.name(), res.References)
255298 p.Opened.Name = s.who(i.Author)
256299 if i.External {
257300 // The label is the visitor's, not the owner's: shown as plain text.
@@ -295,6 +338,40 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
295338 return IssueShow(l, p), http.StatusOK, nil
296339 }
297340
341+// issueBody renders the description of an issue; an issue from the public form
342+// was written by an anonymous visitor and gets the untrusted policy.
343+func (s *Server) issueBody(ctx context.Context, d *repoData, i domain.Issue) (template.HTML, error) {
344+ body := i.Body.String()
345+ if strings.TrimSpace(body) == "" {
346+ return "", nil
347+ }
348+ numbers := s.numberResolver(ctx, d.name())
349+ if i.External {
350+ return s.md.renderUntrusted([]byte(body), "issue-desc-", numbers)
351+ }
352+ return s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", numbers)
353+}
354+
355+// numberLink links merge request n of repo; nil for 0.
356+func numberLink(repo string, n int64) *Link {
357+ if n == 0 {
358+ return nil
359+ }
360+ return &Link{Label: "!" + strconv.FormatInt(n, 10), Href: mrHref(repo, n)}
361+}
362+
363+// issueRefViews are the merge requests that mention an issue.
364+func issueRefViews(repo string, refs []ports.IssueReference) []IssueRefView {
365+ views := make([]IssueRefView, 0, len(refs))
366+ for _, ref := range refs {
367+ views = append(views, IssueRefView{
368+ Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
369+ State: string(ref.State), Href: mrHref(repo, ref.Number), Closes: ref.Closes,
370+ })
371+ }
372+ return views
373+}
374+
298375 // pageOrRedirect is page(), except that a page that is not found may be the
299376 // page of the other kind of the number: target then names where to send the
300377 // visitor ("" when there is none).
@@ -83,6 +83,58 @@ type IssuePage struct {
83 Form *CommentForm83 Form *CommentForm
84 Transition *IssueActions84 Transition *IssueActions
85 Live bool85 Live bool
86+ // ClosedBy is the merge request whose merge closed the issue.
87+ ClosedBy *Link
88+ // References are the merge requests naming the issue.
89+ References []IssueRefView
90+}
91+
92+// IssueRefView is a merge request naming an issue.
93+type IssueRefView struct {
94+ Number, Title, State, Href string
95+ Closes bool
96+}
97+
98+// maxNumberLookups bounds the lookups of #N references of one render.
99+const maxNumberLookups = 50
100+
101+// numberResolver resolves #N to the page of an issue or a merge request of
102+// the repository. It is meant for one render: answers are memoized and at most
103+// maxNumberLookups numbers are asked, the others do not resolve.
104+func (s *Server) numberResolver(ctx context.Context, repo string) func(int64) (string, domain.NumberKind, bool) {
105+ if s.cfg.IssueNumbers == nil {
106+ return nil
107+ }
108+ name, err := domain.ParseRepoName(repo)
109+ if err != nil {
110+ return nil
111+ }
112+ type answer struct {
113+ href string
114+ kind domain.NumberKind
115+ ok bool
116+ }
117+ seen := map[int64]answer{}
118+ return func(n int64) (string, domain.NumberKind, bool) {
119+ if a, ok := seen[n]; ok {
120+ return a.href, a.kind, a.ok
121+ }
122+ if len(seen) >= maxNumberLookups {
123+ return "", domain.NumberNone, false
124+ }
125+ var a answer
126+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
127+ if err == nil {
128+ switch kind {
129+ case domain.NumberIssue:
130+ a = answer{issueHref(repo, n), kind, true}
131+ case domain.NumberMergeRequest:
132+ a = answer{mrHref(repo, n), kind, true}
133+ }
134+ }
135+ seen[n] = a
136+ return a.href, a.kind, a.ok
137+ }
86 }138 }
87 139
88 // IssueNewPage is the form that opens an issue.140 // IssueNewPage is the form that opens an issue.
@@ -238,20 +290,11 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
238 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),290 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
239 State: string(i.State), Open: i.IsOpen(),291 State: string(i.State), Open: i.IsOpen(),
240 }292 }
241- if body := i.Body.String(); strings.TrimSpace(body) != "" {293+ if p.Desc, err = s.issueBody(ctx, d, i); err != nil {
242- var h template.HTML294+ return nil, 0, err
243- var err error
244- if i.External {
245- // Written by an anonymous visitor: the untrusted policy.
246- h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
247- } else {
248- h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
249- }
250- if err != nil {
251- return nil, 0, err
252- }
253- p.Desc = h
254 }295 }
296+ p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest)
297+ p.References = issueRefViews(d.name(), res.References)
255 p.Opened.Name = s.who(i.Author)298 p.Opened.Name = s.who(i.Author)
256 if i.External {299 if i.External {
257 // The label is the visitor's, not the owner's: shown as plain text.300 // The label is the visitor's, not the owner's: shown as plain text.
@@ -295,6 +338,40 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
295 return IssueShow(l, p), http.StatusOK, nil338 return IssueShow(l, p), http.StatusOK, nil
296 }339 }
297 340
341+// issueBody renders the description of an issue; an issue from the public form
342+// was written by an anonymous visitor and gets the untrusted policy.
343+func (s *Server) issueBody(ctx context.Context, d *repoData, i domain.Issue) (template.HTML, error) {
344+ body := i.Body.String()
345+ if strings.TrimSpace(body) == "" {
346+ return "", nil
347+ }
348+ numbers := s.numberResolver(ctx, d.name())
349+ if i.External {
350+ return s.md.renderUntrusted([]byte(body), "issue-desc-", numbers)
351+ }
352+ return s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", numbers)
353+}
354+
355+// numberLink links merge request n of repo; nil for 0.
356+func numberLink(repo string, n int64) *Link {
357+ if n == 0 {
358+ return nil
359+ }
360+ return &Link{Label: "!" + strconv.FormatInt(n, 10), Href: mrHref(repo, n)}
361+}
362+
363+// issueRefViews are the merge requests that mention an issue.
364+func issueRefViews(repo string, refs []ports.IssueReference) []IssueRefView {
365+ views := make([]IssueRefView, 0, len(refs))
366+ for _, ref := range refs {
367+ views = append(views, IssueRefView{
368+ Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
369+ State: string(ref.State), Href: mrHref(repo, ref.Number), Closes: ref.Closes,
370+ })
371+ }
372+ return views
373+}
374+
298 // pageOrRedirect is page(), except that a page that is not found may be the375 // pageOrRedirect is page(), except that a page that is not found may be the
299 // page of the other kind of the number: target then names where to send the376 // page of the other kind of the number: target then names where to send the
300 // visitor ("" when there is none).377 // visitor ("" when there is none).
M internal/adapters/web/issues.templ +21 -0
@@ -149,6 +149,10 @@ templ IssueShow(l Layout, p IssuePage) {
149149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150150 }
151151 </dd>
152+ if p.ClosedBy != nil {
153+ <dt>closed by</dt>
154+ <dd><a class="ref" href={ templ.SafeURL(p.ClosedBy.Href) }>{ p.ClosedBy.Label }</a></dd>
155+ }
152156 if p.UpdatedAt != "" {
153157 <dt>updated</dt>
154158 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
@@ -178,6 +182,23 @@ templ IssueShow(l Layout, p IssuePage) {
178182 }
179183
180184 templ issueRail(p IssuePage) {
185+ if len(p.References) > 0 {
186+ <section aria-labelledby="r-refs">
187+ <h2 id="r-refs">Merge requests</h2>
188+ <ul>
189+ for _, r := range p.References {
190+ <li>
191+ <a class="ref" href={ templ.SafeURL(r.Href) }>{ r.Number }</a>
192+ { " " + r.Title + " " }
193+ <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194+ if r.Closes {
195+ { " · closes" }
196+ }
197+ </li>
198+ }
199+ </ul>
200+ </section>
201+ }
181202 if p.Transition != nil {
182203 <section aria-labelledby="r-act">
183204 <h2 id="r-act">Issue</h2>
@@ -149,6 +149,10 @@ templ IssueShow(l Layout, p IssuePage) {
149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150 }150 }
151 </dd>151 </dd>
152+ if p.ClosedBy != nil {
153+ <dt>closed by</dt>
154+ <dd><a class="ref" href={ templ.SafeURL(p.ClosedBy.Href) }>{ p.ClosedBy.Label }</a></dd>
155+ }
152 if p.UpdatedAt != "" {156 if p.UpdatedAt != "" {
153 <dt>updated</dt>157 <dt>updated</dt>
154 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>158 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
@@ -178,6 +182,23 @@ templ IssueShow(l Layout, p IssuePage) {
178 }182 }
179 183
180 templ issueRail(p IssuePage) {184 templ issueRail(p IssuePage) {
185+ if len(p.References) > 0 {
186+ <section aria-labelledby="r-refs">
187+ <h2 id="r-refs">Merge requests</h2>
188+ <ul>
189+ for _, r := range p.References {
190+ <li>
191+ <a class="ref" href={ templ.SafeURL(r.Href) }>{ r.Number }</a>
192+ { " " + r.Title + " " }
193+ <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194+ if r.Closes {
195+ { " · closes" }
196+ }
197+ </li>
198+ }
199+ </ul>
200+ </section>
201+ }
181 if p.Transition != nil {202 if p.Transition != nil {
182 <section aria-labelledby="r-act">203 <section aria-labelledby="r-act">
183 <h2 id="r-act">Issue</h2>204 <h2 id="r-act">Issue</h2>
M internal/adapters/web/issues_templ.go +164 -36 generated file: collapsed
M internal/adapters/web/issues_test.go +45 -0
@@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) {
309309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 5, MergeRequest: 2}, "issue", "5", "a merge request mentions this issue"},
313+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 6, MergeRequest: 2}, "issue", "5", ""},
312314 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313315 {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314316 }
@@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) {
330332 require.NoError(t, err)
331333 assert.Empty(t, got)
332334 }
335+
336+func TestIssueShowReferences(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesPublic, true)
339+ title, err := domain.ParseMRTitle("fix <b>it</b>")
340+ require.NoError(t, err)
341+ issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
342+ issue.ClosedByMergeRequest = 2
343+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344+ Issue: issue,
345+ References: []ports.IssueReference{
346+ {Number: 2, Title: title, State: domain.MRStateMerged, Closes: true},
347+ {Number: 3, Title: title, State: domain.MRStateOpen},
348+ },
349+ }, nil)
350+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(7)).Return(domain.NumberMergeRequest, nil)
351+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(8)).Return(domain.NumberNone, nil)
352+
353+ rec := r.do("GET", "/proj/issues/5", false, nil)
354+ require.Equal(t, 200, rec.Code, rec.Body.String())
355+ body := rec.Body.String()
356+ assert.Contains(t, body, "closed by")
357+ assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358+ assert.Contains(t, body, ">!2<")
359+ assert.Contains(t, body, ">!3<")
360+ assert.Contains(t, body, `id="r-refs"`)
361+ assert.Contains(t, body, "closes")
362+ assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363+ assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364+ assert.Contains(t, body, `title="merge request 7"`)
365+ assert.NotContains(t, body, `/issues/8"`)
366+}
367+
368+func TestIssueShowWithoutReferences(t *testing.T) {
369+ t.Parallel()
370+ r := newIssueRig(t, domain.IssuesPublic, false)
371+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
372+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", ""),
373+ }, nil)
374+ body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375+ assert.NotContains(t, body, "r-refs")
376+ assert.NotContains(t, body, "closed by")
377+}
@@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) {
309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 5, MergeRequest: 2}, "issue", "5", "a merge request mentions this issue"},
313+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 6, MergeRequest: 2}, "issue", "5", ""},
312 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},314 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313 {domain.IssueClosed{Issue: issue}, "summary", "", ""},315 {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314 }316 }
@@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) {
330 require.NoError(t, err)332 require.NoError(t, err)
331 assert.Empty(t, got)333 assert.Empty(t, got)
332 }334 }
335+
336+func TestIssueShowReferences(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesPublic, true)
339+ title, err := domain.ParseMRTitle("fix <b>it</b>")
340+ require.NoError(t, err)
341+ issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
342+ issue.ClosedByMergeRequest = 2
343+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344+ Issue: issue,
345+ References: []ports.IssueReference{
346+ {Number: 2, Title: title, State: domain.MRStateMerged, Closes: true},
347+ {Number: 3, Title: title, State: domain.MRStateOpen},
348+ },
349+ }, nil)
350+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(7)).Return(domain.NumberMergeRequest, nil)
351+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(8)).Return(domain.NumberNone, nil)
352+
353+ rec := r.do("GET", "/proj/issues/5", false, nil)
354+ require.Equal(t, 200, rec.Code, rec.Body.String())
355+ body := rec.Body.String()
356+ assert.Contains(t, body, "closed by")
357+ assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358+ assert.Contains(t, body, ">!2<")
359+ assert.Contains(t, body, ">!3<")
360+ assert.Contains(t, body, `id="r-refs"`)
361+ assert.Contains(t, body, "closes")
362+ assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363+ assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364+ assert.Contains(t, body, `title="merge request 7"`)
365+ assert.NotContains(t, body, `/issues/8"`)
366+}
367+
368+func TestIssueShowWithoutReferences(t *testing.T) {
369+ t.Parallel()
370+ r := newIssueRig(t, domain.IssuesPublic, false)
371+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
372+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", ""),
373+ }, nil)
374+ body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375+ assert.NotContains(t, body, "r-refs")
376+ assert.NotContains(t, body, "closed by")
377+}
M internal/adapters/web/markdown.go +73 -23
@@ -12,6 +12,7 @@ import (
1212 "strings"
1313 "time"
1414
15+ "bnema.dev/ermite/internal/domain"
1516 "github.com/yuin/goldmark"
1617 "github.com/yuin/goldmark/ast"
1718 "github.com/yuin/goldmark/extension"
@@ -65,15 +66,32 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6566 // #fragment links chosen, so that several documents on one page (the summaries
6667 // of a run) do not share ids.
6768 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
68- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
69+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, refResolver{})
6970 }
7071
71-// renderNote uses the repository Markdown policy, then adds local note links.
72-func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
73- h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
72+// renderNote uses the repository Markdown policy, then adds local note links
73+// and links to issues and merge requests.
74+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, refs refResolver) (template.HTML, error) {
75+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refs)
7476 return h, err
7577 }
7678
79+// renderIssue renders the trusted body of an issue: the policy of render, plus
80+// links to issues and merge requests.
81+func (m *markdown) renderIssue(source []byte, repo, ref, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
82+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refResolver{numbers: numbers})
83+ return h, err
84+}
85+
86+// refResolver tells which references of a text are links.
87+type refResolver struct {
88+ // notes tells whether ^N names a note of the page (nil: no ^N links).
89+ notes func(int64) bool
90+ // numbers gives the page of the issue or merge request #N, when it exists
91+ // (nil: no #N links). The href is built by the server, never from the text.
92+ numbers func(int64) (href string, kind domain.NumberKind, ok bool)
93+}
94+
7795 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
7896 // approved from the public form). On top of the policy of render, which already
7997 // drops raw HTML and never loads an image:
@@ -81,19 +99,19 @@ func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes f
8199 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
82100 // every other scheme lose their link and keep the text;
83101 // - nothing points into the repository.
84-func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
85- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
102+func (m *markdown) renderUntrusted(source []byte, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
103+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, refResolver{numbers: numbers})
86104 return h, err
87105 }
88106
89-func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
107+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) (template.HTML, []Heading, error) {
90108 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
91109 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
92110 pol.source = source
93111 // Collect eligible text before the policy unwraps rejected links: their
94112 // labels must not accidentally become references either.
95113 var refs []*ast.Text
96- if notes != nil {
114+ if res.notes != nil || res.numbers != nil {
97115 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
98116 if !entering {
99117 return ast.WalkContinue, nil
@@ -109,7 +127,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
109127 }
110128 pol.apply(doc)
111129 for _, t := range refs {
112- noteReferences(t, source, notes)
130+ references(t, source, res)
113131 }
114132
115133 doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
@@ -392,42 +410,74 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
392410 })
393411 }
394412
395-var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
413+// referencePattern matches ^N (groups 1-2) and #N (groups 3-4). The preceding
414+// character is not part of a word; #N also rejects & and # so that numeric
415+// entities such as &#39; stay text.
416+var referencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b|(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
396417
397-// noteReferences changes only text segments; code and link labels never enter
398-// this function. New fragment links deliberately bypass heading-prefix rewriting.
399-func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
418+// references changes only text segments; code and link labels never enter
419+// this function. New links deliberately bypass heading-prefix rewriting.
420+func references(t *ast.Text, source []byte, res refResolver) {
400421 parent := t.Parent()
401422 if parent == nil {
402423 return
403424 }
404425 value := t.Value(source)
405426 offset := 0
406- for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
407- id := string(value[match[4]:match[5]])
427+ for _, match := range referencePattern.FindAllSubmatchIndex(value, -1) {
428+ isNote := match[4] >= 0
429+ var from, to int
430+ if isNote {
431+ from, to = match[4], match[5]
432+ } else {
433+ from, to = match[8], match[9]
434+ }
435+ if (isNote && res.notes == nil) || (!isNote && res.numbers == nil) {
436+ continue
437+ }
438+ id := string(value[from:to])
408439 n, err := strconv.ParseInt(id, 10, 64)
409- if err != nil || !exists(n) {
440+ if err != nil {
410441 continue
411442 }
412- start := match[4] - 1
443+ start := from - 1
413444 // Markdown punctuation may split adjacent text into separate AST nodes.
414445 // Check the original preceding byte as well as the segment-local regex.
415446 if at := t.Segment.Start + start; at > 0 {
416447 prev := source[at-1]
417- if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
448+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' {
449+ continue
450+ }
451+ if isNote && prev == '^' || !isNote && (prev == '&' || prev == '#') {
418452 continue
419453 }
420454 }
455+ link := ast.NewLink()
456+ if isNote {
457+ if !res.notes(n) {
458+ continue
459+ }
460+ link.Destination = []byte("#c" + id)
461+ link.Title = []byte("note " + id)
462+ } else {
463+ href, kind, ok := res.numbers(n)
464+ if !ok {
465+ continue
466+ }
467+ link.Destination = []byte(href)
468+ title := "issue "
469+ if kind == domain.NumberMergeRequest {
470+ title = "merge request "
471+ }
472+ link.Title = []byte(title + id)
473+ }
421474 if start > offset {
422475 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
423476 }
424- link := ast.NewLink()
425- link.Destination = []byte("#c" + id)
426- link.Title = []byte("note " + id)
427477 link.SetAttributeString("class", []byte("ref"))
428- link.AppendChild(link, ast.NewString(value[start:match[5]]))
478+ link.AppendChild(link, ast.NewString(value[start:to]))
429479 parent.InsertBefore(parent, t, link)
430- offset = match[5]
480+ offset = to
431481 }
432482 if offset == 0 {
433483 return
@@ -12,6 +12,7 @@ import (
12 "strings"12 "strings"
13 "time"13 "time"
14 14
15+ "bnema.dev/ermite/internal/domain"
15 "github.com/yuin/goldmark"16 "github.com/yuin/goldmark"
16 "github.com/yuin/goldmark/ast"17 "github.com/yuin/goldmark/ast"
17 "github.com/yuin/goldmark/extension"18 "github.com/yuin/goldmark/extension"
@@ -65,15 +66,32 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
65 // #fragment links chosen, so that several documents on one page (the summaries66 // #fragment links chosen, so that several documents on one page (the summaries
66 // of a run) do not share ids.67 // of a run) do not share ids.
67 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {68 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
68- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)69+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, refResolver{})
69 }70 }
70 71
71-// renderNote uses the repository Markdown policy, then adds local note links.72+// renderNote uses the repository Markdown policy, then adds local note links
72-func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {73+// and links to issues and merge requests.
73- h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)74+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, refs refResolver) (template.HTML, error) {
75+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refs)
74 return h, err76 return h, err
75 }77 }
76 78
79+// renderIssue renders the trusted body of an issue: the policy of render, plus
80+// links to issues and merge requests.
81+func (m *markdown) renderIssue(source []byte, repo, ref, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
82+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refResolver{numbers: numbers})
83+ return h, err
84+}
85+
86+// refResolver tells which references of a text are links.
87+type refResolver struct {
88+ // notes tells whether ^N names a note of the page (nil: no ^N links).
89+ notes func(int64) bool
90+ // numbers gives the page of the issue or merge request #N, when it exists
91+ // (nil: no #N links). The href is built by the server, never from the text.
92+ numbers func(int64) (href string, kind domain.NumberKind, ok bool)
93+}
94+
77 // renderUntrusted renders Markdown written by an anonymous visitor (an issue95 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
78 // approved from the public form). On top of the policy of render, which already96 // approved from the public form). On top of the policy of render, which already
79 // drops raw HTML and never loads an image:97 // drops raw HTML and never loads an image:
@@ -81,19 +99,19 @@ func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes f
81 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and99 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
82 // every other scheme lose their link and keep the text;100 // every other scheme lose their link and keep the text;
83 // - nothing points into the repository.101 // - nothing points into the repository.
84-func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {102+func (m *markdown) renderUntrusted(source []byte, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
85- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)103+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, refResolver{numbers: numbers})
86 return h, err104 return h, err
87 }105 }
88 106
89-func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {107+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) (template.HTML, []Heading, error) {
90 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))108 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
91 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))109 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
92 pol.source = source110 pol.source = source
93 // Collect eligible text before the policy unwraps rejected links: their111 // Collect eligible text before the policy unwraps rejected links: their
94 // labels must not accidentally become references either.112 // labels must not accidentally become references either.
95 var refs []*ast.Text113 var refs []*ast.Text
96- if notes != nil {114+ if res.notes != nil || res.numbers != nil {
97 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {115 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
98 if !entering {116 if !entering {
99 return ast.WalkContinue, nil117 return ast.WalkContinue, nil
@@ -109,7 +127,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
109 }127 }
110 pol.apply(doc)128 pol.apply(doc)
111 for _, t := range refs {129 for _, t := range refs {
112- noteReferences(t, source, notes)130+ references(t, source, res)
113 }131 }
114 132
115 doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})133 doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
@@ -392,42 +410,74 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
392 })410 })
393 }411 }
394 412
395-var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)413+// referencePattern matches ^N (groups 1-2) and #N (groups 3-4). The preceding
414+// character is not part of a word; #N also rejects & and # so that numeric
415+// entities such as &#39; stay text.
416+var referencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b|(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
396 417
397-// noteReferences changes only text segments; code and link labels never enter418+// references changes only text segments; code and link labels never enter
398-// this function. New fragment links deliberately bypass heading-prefix rewriting.419+// this function. New links deliberately bypass heading-prefix rewriting.
399-func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {420+func references(t *ast.Text, source []byte, res refResolver) {
400 parent := t.Parent()421 parent := t.Parent()
401 if parent == nil {422 if parent == nil {
402 return423 return
403 }424 }
404 value := t.Value(source)425 value := t.Value(source)
405 offset := 0426 offset := 0
406- for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {427+ for _, match := range referencePattern.FindAllSubmatchIndex(value, -1) {
407- id := string(value[match[4]:match[5]])428+ isNote := match[4] >= 0
429+ var from, to int
430+ if isNote {
431+ from, to = match[4], match[5]
432+ } else {
433+ from, to = match[8], match[9]
434+ }
435+ if (isNote && res.notes == nil) || (!isNote && res.numbers == nil) {
436+ continue
437+ }
438+ id := string(value[from:to])
408 n, err := strconv.ParseInt(id, 10, 64)439 n, err := strconv.ParseInt(id, 10, 64)
409- if err != nil || !exists(n) {440+ if err != nil {
410 continue441 continue
411 }442 }
412- start := match[4] - 1443+ start := from - 1
413 // Markdown punctuation may split adjacent text into separate AST nodes.444 // Markdown punctuation may split adjacent text into separate AST nodes.
414 // Check the original preceding byte as well as the segment-local regex.445 // Check the original preceding byte as well as the segment-local regex.
415 if at := t.Segment.Start + start; at > 0 {446 if at := t.Segment.Start + start; at > 0 {
416 prev := source[at-1]447 prev := source[at-1]
417- if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {448+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' {
449+ continue
450+ }
451+ if isNote && prev == '^' || !isNote && (prev == '&' || prev == '#') {
418 continue452 continue
419 }453 }
420 }454 }
455+ link := ast.NewLink()
456+ if isNote {
457+ if !res.notes(n) {
458+ continue
459+ }
460+ link.Destination = []byte("#c" + id)
461+ link.Title = []byte("note " + id)
462+ } else {
463+ href, kind, ok := res.numbers(n)
464+ if !ok {
465+ continue
466+ }
467+ link.Destination = []byte(href)
468+ title := "issue "
469+ if kind == domain.NumberMergeRequest {
470+ title = "merge request "
471+ }
472+ link.Title = []byte(title + id)
473+ }
421 if start > offset {474 if start > offset {
422 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))475 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
423 }476 }
424- link := ast.NewLink()
425- link.Destination = []byte("#c" + id)
426- link.Title = []byte("note " + id)
427 link.SetAttributeString("class", []byte("ref"))477 link.SetAttributeString("class", []byte("ref"))
428- link.AppendChild(link, ast.NewString(value[start:match[5]]))478+ link.AppendChild(link, ast.NewString(value[start:to]))
429 parent.InsertBefore(parent, t, link)479 parent.InsertBefore(parent, t, link)
430- offset = match[5]480+ offset = to
431 }481 }
432 if offset == 0 {482 if offset == 0 {
433 return483 return
M internal/adapters/web/markdown_test.go +91 -4
@@ -6,7 +6,10 @@ import (
66 "strings"
77 "testing"
88
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
911 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
1013 "github.com/stretchr/testify/require"
1114 )
1215
@@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) {
139142
140143 func renderUntrustedMD(t *testing.T, src string) string {
141144 t.Helper()
142- h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
145+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil)
143146 require.NoError(t, err)
144147 return string(h)
145148 }
@@ -218,14 +221,14 @@ func TestNoteMarkdown(t *testing.T) {
218221 for _, tt := range tests {
219222 t.Run(tt.source, func(t *testing.T) {
220223 t.Parallel()
221- h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
224+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
222225 require.NoError(t, err)
223226 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224227 assert.NotContains(t, string(h), `href="#c99"`)
225228 assert.NotContains(t, string(h), "javascript:")
226229 })
227230 }
228- h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
231+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
229232 require.NoError(t, err)
230233 assert.Contains(t, string(h), `id="c1-heading"`)
231234 assert.Contains(t, string(h), "<strong>bold</strong>")
@@ -233,6 +236,90 @@ func TestNoteMarkdown(t *testing.T) {
233236 assert.Contains(t, string(h), `title="note 2"`)
234237 }
235238
239+func TestNumberReferences(t *testing.T) {
240+ t.Parallel()
241+ numbers := func(n int64) (string, domain.NumberKind, bool) {
242+ switch n {
243+ case 3:
244+ return "/demo/issues/3", domain.NumberIssue, true
245+ case 4:
246+ return "/demo/merge-requests/4", domain.NumberMergeRequest, true
247+ }
248+ return "", domain.NumberNone, false
249+ }
250+ tests := []struct {
251+ source string
252+ want []string
253+ not []string
254+ }{
255+ {"fix #3 and #4, not #9", []string{`<a href="/demo/issues/3" title="issue 3" class="ref">#3</a>`, `title="merge request 4"`}, []string{"/issues/9", "#9</a>"}},
256+ {"abc#3 _#3 ##3 1#3", nil, []string{"<a "}},
257+ {"&#39; &#3;", nil, []string{"<a "}},
258+ {"`#3`\n\n```\n#3\n```", nil, []string{"<a "}},
259+ {"[#3](https://example.org)", []string{`href="https://example.org"`}, []string{"/demo/issues/3"}},
260+ {"#3\n#3", []string{`href="/demo/issues/3"`}, nil},
261+ {"#03 #12345678901", nil, []string{"<a "}},
262+ }
263+ for _, tt := range tests {
264+ t.Run(tt.source, func(t *testing.T) {
265+ t.Parallel()
266+ h, err := newMarkdown().renderIssue([]byte(tt.source), "demo", "main", "i-", numbers)
267+ require.NoError(t, err)
268+ for _, w := range tt.want {
269+ assert.Contains(t, string(h), w)
270+ }
271+ for _, w := range tt.not {
272+ assert.NotContains(t, string(h), w)
273+ }
274+ u, err := newMarkdown().renderUntrusted([]byte(tt.source), "i-", numbers)
275+ require.NoError(t, err)
276+ assert.Equal(t, strings.Count(string(h), `class="ref"`), strings.Count(string(u), `class="ref"`))
277+ })
278+ }
279+ // Without a resolver nothing links.
280+ h, err := newMarkdown().renderUntrusted([]byte("#3"), "i-", nil)
281+ require.NoError(t, err)
282+ assert.NotContains(t, string(h), "<a ")
283+}
284+
285+func TestNumberAndNoteReferencesTogether(t *testing.T) {
286+ t.Parallel()
287+ h, err := newMarkdown().renderNote([]byte("see ^2 and #3."), "demo", "main", "c1-", refResolver{
288+ notes: func(n int64) bool { return n == 2 },
289+ numbers: func(n int64) (string, domain.NumberKind, bool) { return "/demo/issues/3", domain.NumberIssue, n == 3 },
290+ })
291+ require.NoError(t, err)
292+ assert.Contains(t, string(h), `href="#c2"`)
293+ assert.Contains(t, string(h), `href="/demo/issues/3"`)
294+ assert.Contains(t, string(h), "and ")
295+}
296+
297+func TestNumberResolverCapsLookups(t *testing.T) {
298+ t.Parallel()
299+ nums := mocks.NewIssueNumberResolver(t)
300+ nums.EXPECT().Kind(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.NumberIssue, nil).Times(maxNumberLookups)
301+ srv := &Server{cfg: Config{IssueNumbers: nums}}
302+ resolve := srv.numberResolver(t.Context(), "demo")
303+ require.NotNil(t, resolve)
304+ for n := int64(1); n <= maxNumberLookups+10; n++ {
305+ _, _, ok := resolve(n)
306+ assert.Equal(t, n <= maxNumberLookups, ok, n)
307+ }
308+ _, _, ok := resolve(1) // memoized
309+ assert.True(t, ok)
310+}
311+
312+func TestNumberReferenceTitleFromKindNotHref(t *testing.T) {
313+ t.Parallel()
314+ numbers := func(n int64) (string, domain.NumberKind, bool) {
315+ return "/owner/merge-requests/issues/3", domain.NumberIssue, true
316+ }
317+ h, err := newMarkdown().renderIssue([]byte("see #3"), "merge-requests", "main", "i-", numbers)
318+ require.NoError(t, err)
319+ assert.Contains(t, string(h), `title="issue 3"`)
320+ assert.NotContains(t, string(h), "merge request 3")
321+}
322+
236323 func TestMarkdownFencedHighlight(t *testing.T) {
237324 tests := []struct {
238325 name, info, code string
@@ -252,7 +339,7 @@ func TestMarkdownFencedHighlight(t *testing.T) {
252339 for _, untrusted := range []bool{false, true} {
253340 var h string
254341 if untrusted {
255- out, err := newMarkdown().renderUntrusted([]byte(src), "test-")
342+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-", nil)
256343 require.NoError(t, err)
257344 h = string(out)
258345 } else {
@@ -6,7 +6,10 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
9 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
11 )14 )
12 15
@@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) {
139 142
140 func renderUntrustedMD(t *testing.T, src string) string {143 func renderUntrustedMD(t *testing.T, src string) string {
141 t.Helper()144 t.Helper()
142- h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")145+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil)
143 require.NoError(t, err)146 require.NoError(t, err)
144 return string(h)147 return string(h)
145 }148 }
@@ -218,14 +221,14 @@ func TestNoteMarkdown(t *testing.T) {
218 for _, tt := range tests {221 for _, tt := range tests {
219 t.Run(tt.source, func(t *testing.T) {222 t.Run(tt.source, func(t *testing.T) {
220 t.Parallel()223 t.Parallel()
221- h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })224+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
222 require.NoError(t, err)225 require.NoError(t, err)
223 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))226 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224 assert.NotContains(t, string(h), `href="#c99"`)227 assert.NotContains(t, string(h), `href="#c99"`)
225 assert.NotContains(t, string(h), "javascript:")228 assert.NotContains(t, string(h), "javascript:")
226 })229 })
227 }230 }
228- h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })231+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
229 require.NoError(t, err)232 require.NoError(t, err)
230 assert.Contains(t, string(h), `id="c1-heading"`)233 assert.Contains(t, string(h), `id="c1-heading"`)
231 assert.Contains(t, string(h), "<strong>bold</strong>")234 assert.Contains(t, string(h), "<strong>bold</strong>")
@@ -233,6 +236,90 @@ func TestNoteMarkdown(t *testing.T) {
233 assert.Contains(t, string(h), `title="note 2"`)236 assert.Contains(t, string(h), `title="note 2"`)
234 }237 }
235 238
239+func TestNumberReferences(t *testing.T) {
240+ t.Parallel()
241+ numbers := func(n int64) (string, domain.NumberKind, bool) {
242+ switch n {
243+ case 3:
244+ return "/demo/issues/3", domain.NumberIssue, true
245+ case 4:
246+ return "/demo/merge-requests/4", domain.NumberMergeRequest, true
247+ }
248+ return "", domain.NumberNone, false
249+ }
250+ tests := []struct {
251+ source string
252+ want []string
253+ not []string
254+ }{
255+ {"fix #3 and #4, not #9", []string{`<a href="/demo/issues/3" title="issue 3" class="ref">#3</a>`, `title="merge request 4"`}, []string{"/issues/9", "#9</a>"}},
256+ {"abc#3 _#3 ##3 1#3", nil, []string{"<a "}},
257+ {"&#39; &#3;", nil, []string{"<a "}},
258+ {"`#3`\n\n```\n#3\n```", nil, []string{"<a "}},
259+ {"[#3](https://example.org)", []string{`href="https://example.org"`}, []string{"/demo/issues/3"}},
260+ {"#3\n#3", []string{`href="/demo/issues/3"`}, nil},
261+ {"#03 #12345678901", nil, []string{"<a "}},
262+ }
263+ for _, tt := range tests {
264+ t.Run(tt.source, func(t *testing.T) {
265+ t.Parallel()
266+ h, err := newMarkdown().renderIssue([]byte(tt.source), "demo", "main", "i-", numbers)
267+ require.NoError(t, err)
268+ for _, w := range tt.want {
269+ assert.Contains(t, string(h), w)
270+ }
271+ for _, w := range tt.not {
272+ assert.NotContains(t, string(h), w)
273+ }
274+ u, err := newMarkdown().renderUntrusted([]byte(tt.source), "i-", numbers)
275+ require.NoError(t, err)
276+ assert.Equal(t, strings.Count(string(h), `class="ref"`), strings.Count(string(u), `class="ref"`))
277+ })
278+ }
279+ // Without a resolver nothing links.
280+ h, err := newMarkdown().renderUntrusted([]byte("#3"), "i-", nil)
281+ require.NoError(t, err)
282+ assert.NotContains(t, string(h), "<a ")
283+}
284+
285+func TestNumberAndNoteReferencesTogether(t *testing.T) {
286+ t.Parallel()
287+ h, err := newMarkdown().renderNote([]byte("see ^2 and #3."), "demo", "main", "c1-", refResolver{
288+ notes: func(n int64) bool { return n == 2 },
289+ numbers: func(n int64) (string, domain.NumberKind, bool) { return "/demo/issues/3", domain.NumberIssue, n == 3 },
290+ })
291+ require.NoError(t, err)
292+ assert.Contains(t, string(h), `href="#c2"`)
293+ assert.Contains(t, string(h), `href="/demo/issues/3"`)
294+ assert.Contains(t, string(h), "and ")
295+}
296+
297+func TestNumberResolverCapsLookups(t *testing.T) {
298+ t.Parallel()
299+ nums := mocks.NewIssueNumberResolver(t)
300+ nums.EXPECT().Kind(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.NumberIssue, nil).Times(maxNumberLookups)
301+ srv := &Server{cfg: Config{IssueNumbers: nums}}
302+ resolve := srv.numberResolver(t.Context(), "demo")
303+ require.NotNil(t, resolve)
304+ for n := int64(1); n <= maxNumberLookups+10; n++ {
305+ _, _, ok := resolve(n)
306+ assert.Equal(t, n <= maxNumberLookups, ok, n)
307+ }
308+ _, _, ok := resolve(1) // memoized
309+ assert.True(t, ok)
310+}
311+
312+func TestNumberReferenceTitleFromKindNotHref(t *testing.T) {
313+ t.Parallel()
314+ numbers := func(n int64) (string, domain.NumberKind, bool) {
315+ return "/owner/merge-requests/issues/3", domain.NumberIssue, true
316+ }
317+ h, err := newMarkdown().renderIssue([]byte("see #3"), "merge-requests", "main", "i-", numbers)
318+ require.NoError(t, err)
319+ assert.Contains(t, string(h), `title="issue 3"`)
320+ assert.NotContains(t, string(h), "merge request 3")
321+}
322+
236 func TestMarkdownFencedHighlight(t *testing.T) {323 func TestMarkdownFencedHighlight(t *testing.T) {
237 tests := []struct {324 tests := []struct {
238 name, info, code string325 name, info, code string
@@ -252,7 +339,7 @@ func TestMarkdownFencedHighlight(t *testing.T) {
252 for _, untrusted := range []bool{false, true} {339 for _, untrusted := range []bool{false, true} {
253 var h string340 var h string
254 if untrusted {341 if untrusted {
255- out, err := newMarkdown().renderUntrusted([]byte(src), "test-")342+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-", nil)
256 require.NoError(t, err)343 require.NoError(t, err)
257 h = string(out)344 h = string(out)
258 } else {345 } else {
M internal/adapters/web/mr.go +4 -4
@@ -616,7 +616,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
616616 if canWrite {
617617 on = parseOn(r.URL.Query().Get("on"))
618618 }
619- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
619+ s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
620620 lines, invisible := diffLines(p.Files)
621621 p.Invisible = invisible
622622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -680,7 +680,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
680680 // Relative links point into the source branch as it is now (its head
681681 // commit); the heading ids carry their own prefix, apart from the
682682 // ids of the diff.
683- h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
683+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())})
684684 if err != nil {
685685 return err
686686 }
@@ -905,7 +905,7 @@ func parseOn(s string) *lineRef {
905905 // as links to the form of their line; on designates the line whose form shows.
906906 //
907907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
908-func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
908+func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
909909 now := s.cfg.Now()
910910 self := mrHref(repo, n)
911911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
@@ -953,7 +953,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
953953 return index[lineKey{path, side, line}]
954954 }
955955
956- exists := noteIDs(comments)
956+ exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)}
957957 children := map[int64][]domain.Comment{}
958958 for _, c := range comments {
959959 if c.ReplyTo != 0 && !c.Deleted {
@@ -616,7 +616,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
616 if canWrite {616 if canWrite {
617 on = parseOn(r.URL.Query().Get("on"))617 on = parseOn(r.URL.Query().Get("on"))
618 }618 }
619- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)619+ s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
620 lines, invisible := diffLines(p.Files)620 lines, invisible := diffLines(p.Files)
621 p.Invisible = invisible621 p.Invisible = invisible
622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -680,7 +680,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
680 // Relative links point into the source branch as it is now (its head680 // Relative links point into the source branch as it is now (its head
681 // commit); the heading ids carry their own prefix, apart from the681 // commit); the heading ids carry their own prefix, apart from the
682 // ids of the diff.682 // ids of the diff.
683- h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))683+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())})
684 if err != nil {684 if err != nil {
685 return err685 return err
686 }686 }
@@ -905,7 +905,7 @@ func parseOn(s string) *lineRef {
905 // as links to the form of their line; on designates the line whose form shows.905 // as links to the form of their line; on designates the line whose form shows.
906 //906 //
907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
908-func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {908+func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
909 now := s.cfg.Now()909 now := s.cfg.Now()
910 self := mrHref(repo, n)910 self := mrHref(repo, n)
911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
@@ -953,7 +953,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
953 return index[lineKey{path, side, line}]953 return index[lineKey{path, side, line}]
954 }954 }
955 955
956- exists := noteIDs(comments)956+ exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)}
957 children := map[int64][]domain.Comment{}957 children := map[int64][]domain.Comment{}
958 for _, c := range comments {958 for _, c := range comments {
959 if c.ReplyTo != 0 && !c.Deleted {959 if c.ReplyTo != 0 && !c.Deleted {
M internal/adapters/web/mr_files.go +1 -1
@@ -227,7 +227,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
227227 } else {
228228 collapseFiles(p.Files, res.Diff.Files, links)
229229 }
230- s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
230+ s.placeComments(ctx, &p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
231231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232232 if one {
233233 window = p.Files[from : from+1]
@@ -227,7 +227,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
227 } else {227 } else {
228 collapseFiles(p.Files, res.Diff.Files, links)228 collapseFiles(p.Files, res.Diff.Files, links)
229 }229 }
230- s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)230+ s.placeComments(ctx, &p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
231 window := p.Files[from:min(from+mrWindow, len(p.Files))]231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232 if one {232 if one {
233 window = p.Files[from : from+1]233 window = p.Files[from : from+1]
M internal/adapters/web/mr_notes_test.go +3 -3
@@ -43,7 +43,7 @@ func TestNoteThreads(t *testing.T) {
4343 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
4444 }
4545 p := MRPage{NoteAuthor: "alice"}
46- s.placeComments(&p, "demo", 1, comments, "", true, true, nil)
46+ s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
4747 require.Len(t, p.General, 3)
4848 assert.Equal(t, 4, p.NComments)
4949 assert.Equal(t, 1, p.NUnresolved)
@@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) {
117117 require.NoError(t, err)
118118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119119 p := MRPage{}
120- s.placeComments(&p, "demo", 1, []domain.Comment{
120+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122122 {ID: 2, Deleted: true},
123123 {ID: 3, ReplyTo: 2, Deleted: true},
@@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) {
147147 require.NoError(t, err)
148148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150- s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
150+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151151 notes := p.Files[0].Hunks[0].Lines[0].Notes
152152 require.Len(t, notes, 1)
153153 var b bytes.Buffer
@@ -43,7 +43,7 @@ func TestNoteThreads(t *testing.T) {
43 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},43 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44 }44 }
45 p := MRPage{NoteAuthor: "alice"}45 p := MRPage{NoteAuthor: "alice"}
46- s.placeComments(&p, "demo", 1, comments, "", true, true, nil)46+ s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
47 require.Len(t, p.General, 3)47 require.Len(t, p.General, 3)
48 assert.Equal(t, 4, p.NComments)48 assert.Equal(t, 4, p.NComments)
49 assert.Equal(t, 1, p.NUnresolved)49 assert.Equal(t, 1, p.NUnresolved)
@@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) {
117 require.NoError(t, err)117 require.NoError(t, err)
118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119 p := MRPage{}119 p := MRPage{}
120- s.placeComments(&p, "demo", 1, []domain.Comment{120+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122 {ID: 2, Deleted: true},122 {ID: 2, Deleted: true},
123 {ID: 3, ReplyTo: 2, Deleted: true},123 {ID: 3, ReplyTo: 2, Deleted: true},
@@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) {
147 require.NoError(t, err)147 require.NoError(t, err)
148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150- s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)150+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151 notes := p.Files[0].Hunks[0].Lines[0].Notes151 notes := p.Files[0].Hunks[0].Lines[0].Notes
152 require.Len(t, notes, 1)152 require.Len(t, notes, 1)
153 var b bytes.Buffer153 var b bytes.Buffer
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_issue.go +55 -9
@@ -57,6 +57,8 @@ type jsonIssue struct {
5757 CreatedAt *string `json:"created_at"`
5858 UpdatedAt *string `json:"updated_at"`
5959 CommentCount int32 `json:"comment_count"`
60+ // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
61+ ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
6062 }
6163
6264 func toJSONIssue(i *ermite.Issue) jsonIssue {
@@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue {
6466 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
6567 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
6668 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
69+ ClosedByMergeRequest: i.GetClosedByMergeRequest(),
6770 }
6871 }
6972
@@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
8285 // jsonIssueDetail is the stable JSON shape of `issue show --json`.
8386 type jsonIssueDetail struct {
8487 jsonIssue
85- Comments []jsonIssueComment `json:"comments"`
88+ Comments []jsonIssueComment `json:"comments"`
89+ References []jsonIssueReference `json:"references"`
90+}
91+
92+// jsonIssueReference is a merge request that names the issue.
93+type jsonIssueReference struct {
94+ Number int64 `json:"number"`
95+ Title string `json:"title"`
96+ State string `json:"state"`
97+ Closes bool `json:"closes"`
8698 }
8799
88100 const jsonIssueHelp = `
89101 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
90-(token label), created_at and updated_at (RFC 3339 UTC), comment_count.`
102+(token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103+the merge request whose merge closed the issue, 0 for none).`
91104
92105 const jsonIssueCommentHelp = `
93106 JSON fields of an issue comment: id, author, text, created_at.`
94107
108+const jsonIssueReferenceHelp = `
109+JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110+(true when merging it closes the issue).`
111+
95112 func newIssueCmd(env *cliEnv) *cobra.Command {
96113 issue := &cobra.Command{
97114 Use: "issue",
@@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
298315 cmd := &cobra.Command{
299316 Use: "show <repo> <number>",
300317 Short: "Show an issue with its comments",
301- Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +
302- "\n--json adds comments to the issue fields.",
318+ Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
319+ "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320+ "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322+ "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
303323 Args: cobra.ExactArgs(2),
304324 ValidArgsFunction: completeIssueRepoThenNumber(env),
305325 }
@@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
322342 for _, cm := range res.Msg.GetComments() {
323343 out.Comments = append(out.Comments, toJSONIssueComment(cm))
324344 }
345+ out.References = []jsonIssueReference{}
346+ for _, r := range res.Msg.GetReferences() {
347+ out.References = append(out.References, jsonIssueReference{
348+ Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(),
349+ })
350+ }
325351 return writeJSON(cmd.OutOrStdout(), out)
326352 }
327353 return printIssueDetail(cmd.OutOrStdout(), res.Msg)
@@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
335361 if body == "" {
336362 body = "-"
337363 }
338- err := fields(w, [][2]string{
364+ rows := [][2]string{
339365 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340366 {"title", i.GetTitle()},
341367 {"state", issueStateName(i.GetState())},
342- {"author", i.GetAuthor()},
343- {"created", shortTime(i.GetCreatedAt(), "-")},
344- {"updated", shortTime(i.GetUpdatedAt(), "-")},
345- })
368+ }
369+ if n := i.GetClosedByMergeRequest(); n > 0 {
370+ rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
371+ }
372+ rows = append(rows,
373+ [2]string{"author", i.GetAuthor()},
374+ [2]string{"created", shortTime(i.GetCreatedAt(), "-")},
375+ [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")},
376+ )
377+ err := fields(w, rows)
346378 if err != nil {
347379 return err
348380 }
349381 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350382 return err
351383 }
384+ if refs := d.GetReferences(); len(refs) > 0 {
385+ if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil {
386+ return err
387+ }
388+ for _, r := range refs {
389+ line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390+ if r.GetCloses() {
391+ line += " · closes"
392+ }
393+ if _, err := fmt.Fprintln(w, line); err != nil {
394+ return err
395+ }
396+ }
397+ }
352398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353399 return err
354400 }
@@ -57,6 +57,8 @@ type jsonIssue struct {
57 CreatedAt *string `json:"created_at"`57 CreatedAt *string `json:"created_at"`
58 UpdatedAt *string `json:"updated_at"`58 UpdatedAt *string `json:"updated_at"`
59 CommentCount int32 `json:"comment_count"`59 CommentCount int32 `json:"comment_count"`
60+ // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
61+ ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
60 }62 }
61 63
62 func toJSONIssue(i *ermite.Issue) jsonIssue {64 func toJSONIssue(i *ermite.Issue) jsonIssue {
@@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue {
64 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),66 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
65 State: issueStateName(i.GetState()), Author: i.GetAuthor(),67 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
66 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),68 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
69+ ClosedByMergeRequest: i.GetClosedByMergeRequest(),
67 }70 }
68 }71 }
69 72
@@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
82 // jsonIssueDetail is the stable JSON shape of `issue show --json`.85 // jsonIssueDetail is the stable JSON shape of `issue show --json`.
83 type jsonIssueDetail struct {86 type jsonIssueDetail struct {
84 jsonIssue87 jsonIssue
85- Comments []jsonIssueComment `json:"comments"`88+ Comments []jsonIssueComment `json:"comments"`
89+ References []jsonIssueReference `json:"references"`
90+}
91+
92+// jsonIssueReference is a merge request that names the issue.
93+type jsonIssueReference struct {
94+ Number int64 `json:"number"`
95+ Title string `json:"title"`
96+ State string `json:"state"`
97+ Closes bool `json:"closes"`
86 }98 }
87 99
88 const jsonIssueHelp = `100 const jsonIssueHelp = `
89 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author101 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
90-(token label), created_at and updated_at (RFC 3339 UTC), comment_count.`102+(token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103+the merge request whose merge closed the issue, 0 for none).`
91 104
92 const jsonIssueCommentHelp = `105 const jsonIssueCommentHelp = `
93 JSON fields of an issue comment: id, author, text, created_at.`106 JSON fields of an issue comment: id, author, text, created_at.`
94 107
108+const jsonIssueReferenceHelp = `
109+JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110+(true when merging it closes the issue).`
111+
95 func newIssueCmd(env *cliEnv) *cobra.Command {112 func newIssueCmd(env *cliEnv) *cobra.Command {
96 issue := &cobra.Command{113 issue := &cobra.Command{
97 Use: "issue",114 Use: "issue",
@@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
298 cmd := &cobra.Command{315 cmd := &cobra.Command{
299 Use: "show <repo> <number>",316 Use: "show <repo> <number>",
300 Short: "Show an issue with its comments",317 Short: "Show an issue with its comments",
301- Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +318+ Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
302- "\n--json adds comments to the issue fields.",319+ "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320+ "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322+ "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
303 Args: cobra.ExactArgs(2),323 Args: cobra.ExactArgs(2),
304 ValidArgsFunction: completeIssueRepoThenNumber(env),324 ValidArgsFunction: completeIssueRepoThenNumber(env),
305 }325 }
@@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
322 for _, cm := range res.Msg.GetComments() {342 for _, cm := range res.Msg.GetComments() {
323 out.Comments = append(out.Comments, toJSONIssueComment(cm))343 out.Comments = append(out.Comments, toJSONIssueComment(cm))
324 }344 }
345+ out.References = []jsonIssueReference{}
346+ for _, r := range res.Msg.GetReferences() {
347+ out.References = append(out.References, jsonIssueReference{
348+ Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(),
349+ })
350+ }
325 return writeJSON(cmd.OutOrStdout(), out)351 return writeJSON(cmd.OutOrStdout(), out)
326 }352 }
327 return printIssueDetail(cmd.OutOrStdout(), res.Msg)353 return printIssueDetail(cmd.OutOrStdout(), res.Msg)
@@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
335 if body == "" {361 if body == "" {
336 body = "-"362 body = "-"
337 }363 }
338- err := fields(w, [][2]string{364+ rows := [][2]string{
339 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},365 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340 {"title", i.GetTitle()},366 {"title", i.GetTitle()},
341 {"state", issueStateName(i.GetState())},367 {"state", issueStateName(i.GetState())},
342- {"author", i.GetAuthor()},368+ }
343- {"created", shortTime(i.GetCreatedAt(), "-")},369+ if n := i.GetClosedByMergeRequest(); n > 0 {
344- {"updated", shortTime(i.GetUpdatedAt(), "-")},370+ rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
345- })371+ }
372+ rows = append(rows,
373+ [2]string{"author", i.GetAuthor()},
374+ [2]string{"created", shortTime(i.GetCreatedAt(), "-")},
375+ [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")},
376+ )
377+ err := fields(w, rows)
346 if err != nil {378 if err != nil {
347 return err379 return err
348 }380 }
349 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {381 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350 return err382 return err
351 }383 }
384+ if refs := d.GetReferences(); len(refs) > 0 {
385+ if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil {
386+ return err
387+ }
388+ for _, r := range refs {
389+ line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390+ if r.GetCloses() {
391+ line += " · closes"
392+ }
393+ if _, err := fmt.Fprintln(w, line); err != nil {
394+ return err
395+ }
396+ }
397+ }
352 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353 return err399 return err
354 }400 }
M internal/app/cli_issue_e2e_test.go +7 -2
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "encoding/json"
67 "strings"
78 "testing"
89
@@ -24,7 +25,7 @@ type issueOut struct {
2425 CommentCount int `json:"comment_count"`
2526 }
2627
27-var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
2829
2930 type issueCommentOut struct {
3031 ID string `json:"id"`
@@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
106107 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107108 type showOut struct {
108109 issueOut
109- Comments []issueCommentOut `json:"comments"`
110+ Comments []issueCommentOut `json:"comments"`
111+ References []json.RawMessage `json:"references"`
110112 }
111113 show := decode[showOut](t, res.stdout)
112114 assert.Equal(t, 2, show.CommentCount)
@@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
115117 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116118 assert.Contains(t, res.stdout, "Crash on start")
117119 assert.Contains(t, res.stdout, "comments (2)")
120+ assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue")
121+ assert.NotNil(t, show.References, "references is always present")
122+ assert.Empty(t, show.References)
118123
119124 // close and reopen.
120125 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
@@ -3,6 +3,7 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "encoding/json"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 9
@@ -24,7 +25,7 @@ type issueOut struct {
24 CommentCount int `json:"comment_count"`25 CommentCount int `json:"comment_count"`
25 }26 }
26 27
27-var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}28+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28 29
29 type issueCommentOut struct {30 type issueCommentOut struct {
30 ID string `json:"id"`31 ID string `json:"id"`
@@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
106 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)107 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107 type showOut struct {108 type showOut struct {
108 issueOut109 issueOut
109- Comments []issueCommentOut `json:"comments"`110+ Comments []issueCommentOut `json:"comments"`
111+ References []json.RawMessage `json:"references"`
110 }112 }
111 show := decode[showOut](t, res.stdout)113 show := decode[showOut](t, res.stdout)
112 assert.Equal(t, 2, show.CommentCount)114 assert.Equal(t, 2, show.CommentCount)
@@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
115 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)117 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116 assert.Contains(t, res.stdout, "Crash on start")118 assert.Contains(t, res.stdout, "Crash on start")
117 assert.Contains(t, res.stdout, "comments (2)")119 assert.Contains(t, res.stdout, "comments (2)")
120+ assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue")
121+ assert.NotNil(t, show.References, "references is always present")
122+ assert.Empty(t, show.References)
118 123
119 // close and reopen.124 // close and reopen.
120 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)125 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
M internal/app/cli_issue_test.go +36 -1
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "os"
88 "path/filepath"
9+ "strings"
910 "testing"
1011
1112 "github.com/stretchr/testify/assert"
@@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
5152 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
5253
5354 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)
55+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout)
5556
5657 for _, args := range [][]string{
5758 {"issue", "create", "alpha"},
@@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
262263 }
263264 t.Fatal("never completed")
264265 }
266+
267+func TestIssueShowMergeRequestLinks(t *testing.T) {
268+ closed := &ermite.Issue{
269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
270+ ClosedByMergeRequest: 5,
271+ }
272+ f := &fakeAPI{
273+ getVersion: withIssues,
274+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
275+ return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{
276+ {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true},
277+ {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED},
278+ }}, nil
279+ },
280+ }
281+ url := f.serve(t)
282+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283+ assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
285+ assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286+
287+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289+
290+ // No references: no block in text, an empty array in JSON.
291+ f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
292+ return &ermite.GetIssueResponse{Issue: issueOne}, nil
293+ }
294+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295+ assert.NotContains(t, res.stdout, "merge requests")
296+ assert.NotContains(t, res.stdout, "closed by")
297+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298+ assert.Contains(t, res.stdout, `"references": []`)
299+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "testing"10 "testing"
10 11
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
51 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)52 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
52 53
53 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)54 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)55+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout)
55 56
56 for _, args := range [][]string{57 for _, args := range [][]string{
57 {"issue", "create", "alpha"},58 {"issue", "create", "alpha"},
@@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
262 }263 }
263 t.Fatal("never completed")264 t.Fatal("never completed")
264 }265 }
266+
267+func TestIssueShowMergeRequestLinks(t *testing.T) {
268+ closed := &ermite.Issue{
269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
270+ ClosedByMergeRequest: 5,
271+ }
272+ f := &fakeAPI{
273+ getVersion: withIssues,
274+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
275+ return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{
276+ {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true},
277+ {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED},
278+ }}, nil
279+ },
280+ }
281+ url := f.serve(t)
282+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283+ assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
285+ assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286+
287+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289+
290+ // No references: no block in text, an empty array in JSON.
291+ f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
292+ return &ermite.GetIssueResponse{Issue: issueOne}, nil
293+ }
294+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295+ assert.NotContains(t, res.stdout, "merge requests")
296+ assert.NotContains(t, res.stdout, "closed by")
297+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298+ assert.Contains(t, res.stdout, `"references": []`)
299+}
M internal/app/forge.go +4 -2
@@ -363,15 +363,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
364364 })
365365 f.Pushes.WithMergeability(f.Mergeability)
366+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
367+ f.Pushes.WithIssueLinks(links)
366368 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
367- Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
369+ Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
368370 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369371 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370372 })
371373 f.Mergeability.Start()
372374 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373375 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
374- Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
376+ Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
375377 })
376378 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
377379 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
@@ -363,15 +363,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,363 Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
364 })364 })
365 f.Pushes.WithMergeability(f.Mergeability)365 f.Pushes.WithMergeability(f.Mergeability)
366+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
367+ f.Pushes.WithIssueLinks(links)
366 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{368 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
367- Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,369+ Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
368 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,370 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,371 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370 })372 })
371 f.Mergeability.Start()373 f.Mergeability.Start()
372 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)374 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{375 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
374- Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,376+ Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
375 })377 })
376 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{378 f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
377 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,379 Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
M internal/app/issue_api_test.go +52 -0
@@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
243243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244244 require.NoError(t, err)
245245 }
246+
247+func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
248+ r := newMRRig(t, domain.VisibilityPublic)
249+ ctx := r.ctx
250+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
251+ require.NoError(t, err)
252+ for _, title := range []string{"Crash", "Slow"} {
253+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
254+ require.NoError(t, err)
255+ }
256+ // The merge request mentions #1 (closing) and #2 (plain).
257+ created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
258+ Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
259+ }))
260+ require.NoError(t, err)
261+ number := created.Msg.GetMergeRequest().GetNumber()
262+
263+ get := func(n int64) *pb.GetIssueResponse {
264+ res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
265+ require.NoError(t, err)
266+ return res.Msg
267+ }
268+ one := get(1)
269+ require.Len(t, one.GetReferences(), 1)
270+ ref := one.GetReferences()[0]
271+ assert.Equal(t, number, ref.GetNumber())
272+ assert.Equal(t, "Change a", ref.GetTitle())
273+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
274+ assert.True(t, ref.GetCloses())
275+ two := get(2)
276+ require.Len(t, two.GetReferences(), 1)
277+ assert.False(t, two.GetReferences()[0].GetCloses())
278+ assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
279+
280+ _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
281+ require.NoError(t, err)
282+ r.forge.MergeRequests.Wait(ctx)
283+
284+ one = get(1)
285+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
286+ assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
287+ require.Len(t, one.GetReferences(), 1)
288+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
289+ two = get(2)
290+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
291+ assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
292+
293+ // Reopening by hand clears "closed by".
294+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
295+ require.NoError(t, err)
296+ assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
297+}
@@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244 require.NoError(t, err)244 require.NoError(t, err)
245 }245 }
246+
247+func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
248+ r := newMRRig(t, domain.VisibilityPublic)
249+ ctx := r.ctx
250+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
251+ require.NoError(t, err)
252+ for _, title := range []string{"Crash", "Slow"} {
253+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
254+ require.NoError(t, err)
255+ }
256+ // The merge request mentions #1 (closing) and #2 (plain).
257+ created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
258+ Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
259+ }))
260+ require.NoError(t, err)
261+ number := created.Msg.GetMergeRequest().GetNumber()
262+
263+ get := func(n int64) *pb.GetIssueResponse {
264+ res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
265+ require.NoError(t, err)
266+ return res.Msg
267+ }
268+ one := get(1)
269+ require.Len(t, one.GetReferences(), 1)
270+ ref := one.GetReferences()[0]
271+ assert.Equal(t, number, ref.GetNumber())
272+ assert.Equal(t, "Change a", ref.GetTitle())
273+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
274+ assert.True(t, ref.GetCloses())
275+ two := get(2)
276+ require.Len(t, two.GetReferences(), 1)
277+ assert.False(t, two.GetReferences()[0].GetCloses())
278+ assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
279+
280+ _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
281+ require.NoError(t, err)
282+ r.forge.MergeRequests.Wait(ctx)
283+
284+ one = get(1)
285+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
286+ assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
287+ require.Len(t, one.GetReferences(), 1)
288+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
289+ two = get(2)
290+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
291+ assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
292+
293+ // Reopening by hand clears "closed by".
294+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
295+ require.NoError(t, err)
296+ assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
297+}
M internal/domain/event.go +12 -0
@@ -186,6 +186,18 @@ type IssueOpened struct {
186186 // EventKind implements Event.
187187 func (IssueOpened) EventKind() string { return "issue.opened" }
188188
189+// IssueReferenced refreshes an issue's merge request back-references.
190+type IssueReferenced struct {
191+ Repo RepoName
192+ Issue, MergeRequest int64
193+ At time.Time
194+ Visibility Visibility
195+ CreatedAt time.Time
196+}
197+
198+// EventKind implements Event.
199+func (IssueReferenced) EventKind() string { return "issue.referenced" }
200+
189201 // IssueClosed is published when an issue was closed.
190202 type IssueClosed struct {
191203 Issue Issue
@@ -186,6 +186,18 @@ type IssueOpened struct {
186 // EventKind implements Event.186 // EventKind implements Event.
187 func (IssueOpened) EventKind() string { return "issue.opened" }187 func (IssueOpened) EventKind() string { return "issue.opened" }
188 188
189+// IssueReferenced refreshes an issue's merge request back-references.
190+type IssueReferenced struct {
191+ Repo RepoName
192+ Issue, MergeRequest int64
193+ At time.Time
194+ Visibility Visibility
195+ CreatedAt time.Time
196+}
197+
198+// EventKind implements Event.
199+func (IssueReferenced) EventKind() string { return "issue.referenced" }
200+
189 // IssueClosed is published when an issue was closed.201 // IssueClosed is published when an issue was closed.
190 type IssueClosed struct {202 type IssueClosed struct {
191 Issue Issue203 Issue Issue
M internal/domain/issue.go +8 -0
@@ -73,11 +73,19 @@ type Issue struct {
7373 // External is true for an issue approved from the public form: its text
7474 // comes from an anonymous visitor and is rendered as untrusted.
7575 External bool
76+ // ClosedByMergeRequest is zero unless a merge closed the issue.
77+ ClosedByMergeRequest int64
7678 // Version is set by the store on read: Update fails with ErrConflict when
7779 // the stored issue changed since. Opaque to everyone else.
7880 Version uint64
7981 }
8082
83+// IssueLink is a merge request naming an issue in the same repository.
84+type IssueLink struct {
85+ Issue, MergeRequest int64
86+ Closes bool
87+}
88+
8189 // IsOpen reports whether the issue is open.
8290 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
8391
@@ -73,11 +73,19 @@ type Issue struct {
73 // External is true for an issue approved from the public form: its text73 // External is true for an issue approved from the public form: its text
74 // comes from an anonymous visitor and is rendered as untrusted.74 // comes from an anonymous visitor and is rendered as untrusted.
75 External bool75 External bool
76+ // ClosedByMergeRequest is zero unless a merge closed the issue.
77+ ClosedByMergeRequest int64
76 // Version is set by the store on read: Update fails with ErrConflict when78 // Version is set by the store on read: Update fails with ErrConflict when
77 // the stored issue changed since. Opaque to everyone else.79 // the stored issue changed since. Opaque to everyone else.
78 Version uint6480 Version uint64
79 }81 }
80 82
83+// IssueLink is a merge request naming an issue in the same repository.
84+type IssueLink struct {
85+ Issue, MergeRequest int64
86+ Closes bool
87+}
88+
81 // IsOpen reports whether the issue is open.89 // IsOpen reports whether the issue is open.
82 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }90 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
83 91
A internal/domain/reference.go +112 -0
@@ -0,0 +1,112 @@
1+package domain
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "strconv"
7+)
8+
9+// MaxReferenceTextBytes bounds each title, description, note and commit message scanned.
10+const MaxReferenceTextBytes = 16 << 10
11+
12+var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
13+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)*)`)
14+
15+// FindReferences returns the positive #N numbers in text, sorted and unique.
16+// Numbers glued to words, entities or another # are excluded, as are zero,
17+// leading zeroes and numbers longer than ten digits. Code is scanned as text;
18+// cross-repository references and !N are not supported.
19+func FindReferences(text string) []int64 {
20+ numbers := make(map[int64]struct{})
21+ // Resume at the end of the number, retaining its following delimiter for the
22+ // next match (for example, both references in "#1,#2").
23+ for offset := 0; offset < len(text); {
24+ match := numberRef.FindStringSubmatchIndex(text[offset:])
25+ if match == nil {
26+ break
27+ }
28+ start, end := offset+match[4], offset+match[5]
29+ // A sliced search must not manufacture a beginning-of-text boundary.
30+ if start < 2 || !referenceWord(text[start-2]) {
31+ n, _ := strconv.ParseInt(text[start:end], 10, 64)
32+ numbers[n] = struct{}{}
33+ }
34+ offset = end
35+ }
36+ return sortedReferences(numbers)
37+}
38+
39+func referenceWord(b byte) bool {
40+ return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#'
41+}
42+
43+// FindClosingReferences returns sorted, unique references following close,
44+// closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case
45+// insensitive). A colon is optional; lists use commas or "and", not spaces.
46+// Like FindReferences, this scans code spans and fences as ordinary text.
47+func FindClosingReferences(text string) []int64 {
48+ numbers := make(map[int64]struct{})
49+ for _, match := range closingRef.FindAllString(text, -1) {
50+ for _, n := range FindReferences(match) {
51+ numbers[n] = struct{}{}
52+ }
53+ }
54+ return sortedReferences(numbers)
55+}
56+
57+func sortedReferences(numbers map[int64]struct{}) []int64 {
58+ var result []int64
59+ for n := range numbers {
60+ result = append(result, n)
61+ }
62+ slices.Sort(result)
63+ return result
64+}
65+
66+// MergeRequestReferences describes the issues named by a merge request.
67+type MergeRequestReferences struct {
68+ Mentions []int64
69+ Closes []int64
70+}
71+
72+// ScanMergeRequest scans titles, descriptions, notes and commit messages for
73+// references. Notes can mention issues but cannot close them. Each individual
74+// text is truncated to MaxReferenceTextBytes, without a trailing partial token, before scanning; no Markdown or
75+// cross-repository interpretation is performed.
76+func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences {
77+ mentions, closes := make(map[int64]struct{}), make(map[int64]struct{})
78+ scan := func(text string, closing bool) {
79+ if len(text) > MaxReferenceTextBytes {
80+ // Drop the trailing partial token: back off to the last ASCII
81+ // whitespace at or before the limit.
82+ cut := -1
83+ for i := MaxReferenceTextBytes; i >= 0; i-- {
84+ if i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') {
85+ cut = i
86+ break
87+ }
88+ }
89+ if cut < 0 {
90+ return
91+ }
92+ text = text[:cut]
93+ }
94+ for _, n := range FindReferences(text) {
95+ mentions[n] = struct{}{}
96+ }
97+ if closing {
98+ for _, n := range FindClosingReferences(text) {
99+ closes[n] = struct{}{}
100+ }
101+ }
102+ }
103+ scan(title, true)
104+ scan(description, true)
105+ for _, text := range notes {
106+ scan(text, false)
107+ }
108+ for _, text := range commitMessages {
109+ scan(text, true)
110+ }
111+ return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)}
112+}
@@ -0,0 +1,112 @@
1+package domain
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "strconv"
7+)
8+
9+// MaxReferenceTextBytes bounds each title, description, note and commit message scanned.
10+const MaxReferenceTextBytes = 16 << 10
11+
12+var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
13+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)*)`)
14+
15+// FindReferences returns the positive #N numbers in text, sorted and unique.
16+// Numbers glued to words, entities or another # are excluded, as are zero,
17+// leading zeroes and numbers longer than ten digits. Code is scanned as text;
18+// cross-repository references and !N are not supported.
19+func FindReferences(text string) []int64 {
20+ numbers := make(map[int64]struct{})
21+ // Resume at the end of the number, retaining its following delimiter for the
22+ // next match (for example, both references in "#1,#2").
23+ for offset := 0; offset < len(text); {
24+ match := numberRef.FindStringSubmatchIndex(text[offset:])
25+ if match == nil {
26+ break
27+ }
28+ start, end := offset+match[4], offset+match[5]
29+ // A sliced search must not manufacture a beginning-of-text boundary.
30+ if start < 2 || !referenceWord(text[start-2]) {
31+ n, _ := strconv.ParseInt(text[start:end], 10, 64)
32+ numbers[n] = struct{}{}
33+ }
34+ offset = end
35+ }
36+ return sortedReferences(numbers)
37+}
38+
39+func referenceWord(b byte) bool {
40+ return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#'
41+}
42+
43+// FindClosingReferences returns sorted, unique references following close,
44+// closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case
45+// insensitive). A colon is optional; lists use commas or "and", not spaces.
46+// Like FindReferences, this scans code spans and fences as ordinary text.
47+func FindClosingReferences(text string) []int64 {
48+ numbers := make(map[int64]struct{})
49+ for _, match := range closingRef.FindAllString(text, -1) {
50+ for _, n := range FindReferences(match) {
51+ numbers[n] = struct{}{}
52+ }
53+ }
54+ return sortedReferences(numbers)
55+}
56+
57+func sortedReferences(numbers map[int64]struct{}) []int64 {
58+ var result []int64
59+ for n := range numbers {
60+ result = append(result, n)
61+ }
62+ slices.Sort(result)
63+ return result
64+}
65+
66+// MergeRequestReferences describes the issues named by a merge request.
67+type MergeRequestReferences struct {
68+ Mentions []int64
69+ Closes []int64
70+}
71+
72+// ScanMergeRequest scans titles, descriptions, notes and commit messages for
73+// references. Notes can mention issues but cannot close them. Each individual
74+// text is truncated to MaxReferenceTextBytes, without a trailing partial token, before scanning; no Markdown or
75+// cross-repository interpretation is performed.
76+func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences {
77+ mentions, closes := make(map[int64]struct{}), make(map[int64]struct{})
78+ scan := func(text string, closing bool) {
79+ if len(text) > MaxReferenceTextBytes {
80+ // Drop the trailing partial token: back off to the last ASCII
81+ // whitespace at or before the limit.
82+ cut := -1
83+ for i := MaxReferenceTextBytes; i >= 0; i-- {
84+ if i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') {
85+ cut = i
86+ break
87+ }
88+ }
89+ if cut < 0 {
90+ return
91+ }
92+ text = text[:cut]
93+ }
94+ for _, n := range FindReferences(text) {
95+ mentions[n] = struct{}{}
96+ }
97+ if closing {
98+ for _, n := range FindClosingReferences(text) {
99+ closes[n] = struct{}{}
100+ }
101+ }
102+ }
103+ scan(title, true)
104+ scan(description, true)
105+ for _, text := range notes {
106+ scan(text, false)
107+ }
108+ for _, text := range commitMessages {
109+ scan(text, true)
110+ }
111+ return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)}
112+}
A internal/domain/reference_test.go +78 -0
@@ -0,0 +1,78 @@
1+package domain
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestFindReferences(t *testing.T) {
10+ tests := []struct {
11+ text string
12+ mentions, closes []int64
13+ }{
14+ {"abc#3 a_b#3 1#3 &#39; ##3 #3abc #03 #0", nil, nil},
15+ {"(#3) see #3, \"#3\"", []int64{3}, nil},
16+ {"fixes #3.", []int64{3}, []int64{3}},
17+ {"Fixes: #3", []int64{3}, []int64{3}},
18+ {"fixes:#3", []int64{3}, []int64{3}},
19+ {"Closes #3", []int64{3}, []int64{3}},
20+ {"RESOLVED #3", []int64{3}, []int64{3}},
21+ {"fix #3", []int64{3}, []int64{3}},
22+ {"prefixes #3", []int64{3}, nil},
23+ {"fixes 3 fixes#3", nil, nil},
24+ {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}},
25+ {"fixes #3 #4", []int64{3, 4}, []int64{3}},
26+ {"#5,#3,#5", []int64{3, 5}, nil},
27+ {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil},
28+ {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}},
29+ {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.text, func(t *testing.T) {
33+ if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) {
34+ t.Errorf("mentions = %v, want %v", got, tt.mentions)
35+ }
36+ if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) {
37+ t.Errorf("closes = %v, want %v", got, tt.closes)
38+ }
39+ })
40+ }
41+}
42+
43+func TestScanMergeRequest(t *testing.T) {
44+ got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"})
45+ want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}}
46+ if !reflect.DeepEqual(got, want) {
47+ t.Fatalf("got %+v, want %+v", got, want)
48+ }
49+ for _, scan := range []func(string) MergeRequestReferences{
50+ func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) },
51+ func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) },
52+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) },
53+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) },
54+ } {
55+ if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 {
56+ t.Errorf("text beyond limit scanned: %+v", got)
57+ }
58+ }
59+}
60+
61+func TestScanMergeRequestTruncationDropsPartialToken(t *testing.T) {
62+ prefix := strings.Repeat(" ", MaxReferenceTextBytes-len("fixes #123"))
63+ got := ScanMergeRequest("", "", nil, []string{prefix + "fixes #12345"})
64+ if len(got.Mentions) != 0 || len(got.Closes) != 0 {
65+ t.Errorf("split number reported: %+v", got)
66+ }
67+ // Fully before the limit, followed by a split token.
68+ full := "fixes #7 " + strings.Repeat("a", MaxReferenceTextBytes)
69+ got = ScanMergeRequest("", "", nil, []string{full})
70+ if !reflect.DeepEqual(got, MergeRequestReferences{Mentions: []int64{7}, Closes: []int64{7}}) {
71+ t.Errorf("got %+v", got)
72+ }
73+ // No whitespace at all: nothing scanned.
74+ got = ScanMergeRequest("", "", nil, []string{strings.Repeat("a", MaxReferenceTextBytes-3) + " #1" + "2"})
75+ if len(got.Mentions) != 0 {
76+ t.Errorf("got %+v", got)
77+ }
78+}
@@ -0,0 +1,78 @@
1+package domain
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestFindReferences(t *testing.T) {
10+ tests := []struct {
11+ text string
12+ mentions, closes []int64
13+ }{
14+ {"abc#3 a_b#3 1#3 &#39; ##3 #3abc #03 #0", nil, nil},
15+ {"(#3) see #3, \"#3\"", []int64{3}, nil},
16+ {"fixes #3.", []int64{3}, []int64{3}},
17+ {"Fixes: #3", []int64{3}, []int64{3}},
18+ {"fixes:#3", []int64{3}, []int64{3}},
19+ {"Closes #3", []int64{3}, []int64{3}},
20+ {"RESOLVED #3", []int64{3}, []int64{3}},
21+ {"fix #3", []int64{3}, []int64{3}},
22+ {"prefixes #3", []int64{3}, nil},
23+ {"fixes 3 fixes#3", nil, nil},
24+ {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}},
25+ {"fixes #3 #4", []int64{3, 4}, []int64{3}},
26+ {"#5,#3,#5", []int64{3, 5}, nil},
27+ {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil},
28+ {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}},
29+ {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.text, func(t *testing.T) {
33+ if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) {
34+ t.Errorf("mentions = %v, want %v", got, tt.mentions)
35+ }
36+ if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) {
37+ t.Errorf("closes = %v, want %v", got, tt.closes)
38+ }
39+ })
40+ }
41+}
42+
43+func TestScanMergeRequest(t *testing.T) {
44+ got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"})
45+ want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}}
46+ if !reflect.DeepEqual(got, want) {
47+ t.Fatalf("got %+v, want %+v", got, want)
48+ }
49+ for _, scan := range []func(string) MergeRequestReferences{
50+ func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) },
51+ func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) },
52+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) },
53+ func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) },
54+ } {
55+ if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 {
56+ t.Errorf("text beyond limit scanned: %+v", got)
57+ }
58+ }
59+}
60+
61+func TestScanMergeRequestTruncationDropsPartialToken(t *testing.T) {
62+ prefix := strings.Repeat(" ", MaxReferenceTextBytes-len("fixes #123"))
63+ got := ScanMergeRequest("", "", nil, []string{prefix + "fixes #12345"})
64+ if len(got.Mentions) != 0 || len(got.Closes) != 0 {
65+ t.Errorf("split number reported: %+v", got)
66+ }
67+ // Fully before the limit, followed by a split token.
68+ full := "fixes #7 " + strings.Repeat("a", MaxReferenceTextBytes)
69+ got = ScanMergeRequest("", "", nil, []string{full})
70+ if !reflect.DeepEqual(got, MergeRequestReferences{Mentions: []int64{7}, Closes: []int64{7}}) {
71+ t.Errorf("got %+v", got)
72+ }
73+ // No whitespace at all: nothing scanned.
74+ got = ScanMergeRequest("", "", nil, []string{strings.Repeat("a", MaxReferenceTextBytes-3) + " #1" + "2"})
75+ if len(got.Mentions) != 0 {
76+ t.Errorf("got %+v", got)
77+ }
78+}
M internal/ports/dto.go +12 -2
@@ -242,9 +242,19 @@ type CreateIssueParams struct {
242242 Body string
243243 }
244244
245-// IssueDetail is an issue with its comments.
245+// IssueReference is a merge request of the same repository naming an issue.
246+type IssueReference struct {
247+ Number int64
248+ Title domain.MRTitle
249+ State domain.MRState
250+ Closes bool
251+}
252+
253+// IssueDetail is an issue with its comments and referencing merge requests.
246254 type IssueDetail struct {
247- Issue domain.Issue
255+ // References are ascending by number, at most 100.
256+ References []IssueReference
257+ Issue domain.Issue
248258 // Comments are oldest first.
249259 Comments []domain.IssueComment
250260 }
@@ -242,9 +242,19 @@ type CreateIssueParams struct {
242 Body string242 Body string
243 }243 }
244 244
245-// IssueDetail is an issue with its comments.245+// IssueReference is a merge request of the same repository naming an issue.
246+type IssueReference struct {
247+ Number int64
248+ Title domain.MRTitle
249+ State domain.MRState
250+ Closes bool
251+}
252+
253+// IssueDetail is an issue with its comments and referencing merge requests.
246 type IssueDetail struct {254 type IssueDetail struct {
247- Issue domain.Issue255+ // References are ascending by number, at most 100.
256+ References []IssueReference
257+ Issue domain.Issue
248 // Comments are oldest first.258 // Comments are oldest first.
249 Comments []domain.IssueComment259 Comments []domain.IssueComment
250 }260 }
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+}
A internal/ports/mocks/IssueLinkTrigger.go +262 -0
@@ -0,0 +1,262 @@
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+// NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. 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 NewIssueLinkTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkTrigger{}
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+// IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type
38+type IssueLinkTrigger struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter {
47+ return &IssueLinkTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type IssueLinkTrigger
51+func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) {
52+ _mock.Called(context1, repoName, refUpdates)
53+ return
54+}
55+
56+// IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type IssueLinkTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - context1 context.Context
63+// - repoName domain.RepoName
64+// - refUpdates []domain.RefUpdate
65+func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call {
66+ return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)}
67+}
68+
69+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger
103+func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) {
104+ _mock.Called(context1, repo, n)
105+ return
106+}
107+
108+// IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged'
109+type IssueLinkTrigger_OnMergeRequestChanged_Call struct {
110+ *mock.Call
111+}
112+
113+// OnMergeRequestChanged is a helper method to define mock.On call
114+// - context1 context.Context
115+// - repo domain.Repo
116+// - n int64
117+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call {
118+ return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)}
119+}
120+
121+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ var arg0 context.Context
124+ if args[0] != nil {
125+ arg0 = args[0].(context.Context)
126+ }
127+ var arg1 domain.Repo
128+ if args[1] != nil {
129+ arg1 = args[1].(domain.Repo)
130+ }
131+ var arg2 int64
132+ if args[2] != nil {
133+ arg2 = args[2].(int64)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ arg2,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call {
145+ _c.Call.Return()
146+ return _c
147+}
148+
149+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
150+ _c.Run(run)
151+ return _c
152+}
153+
154+// OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger
155+func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) {
156+ _mock.Called(context1, repo, mergeRequest)
157+ return
158+}
159+
160+// IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled'
161+type IssueLinkTrigger_OnMergeRequestSettled_Call struct {
162+ *mock.Call
163+}
164+
165+// OnMergeRequestSettled is a helper method to define mock.On call
166+// - context1 context.Context
167+// - repo domain.Repo
168+// - mergeRequest domain.MergeRequest
169+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call {
170+ return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)}
171+}
172+
173+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 context.Context
176+ if args[0] != nil {
177+ arg0 = args[0].(context.Context)
178+ }
179+ var arg1 domain.Repo
180+ if args[1] != nil {
181+ arg1 = args[1].(domain.Repo)
182+ }
183+ var arg2 domain.MergeRequest
184+ if args[2] != nil {
185+ arg2 = args[2].(domain.MergeRequest)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call {
197+ _c.Call.Return()
198+ return _c
199+}
200+
201+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
202+ _c.Run(run)
203+ return _c
204+}
205+
206+// OnMerged provides a mock function for the type IssueLinkTrigger
207+func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) {
208+ _mock.Called(context1, principal, repo, mergeRequest)
209+ return
210+}
211+
212+// IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged'
213+type IssueLinkTrigger_OnMerged_Call struct {
214+ *mock.Call
215+}
216+
217+// OnMerged is a helper method to define mock.On call
218+// - context1 context.Context
219+// - principal domain.Principal
220+// - repo domain.Repo
221+// - mergeRequest domain.MergeRequest
222+func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call {
223+ return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)}
224+}
225+
226+func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
227+ _c.Call.Run(func(args mock.Arguments) {
228+ var arg0 context.Context
229+ if args[0] != nil {
230+ arg0 = args[0].(context.Context)
231+ }
232+ var arg1 domain.Principal
233+ if args[1] != nil {
234+ arg1 = args[1].(domain.Principal)
235+ }
236+ var arg2 domain.Repo
237+ if args[2] != nil {
238+ arg2 = args[2].(domain.Repo)
239+ }
240+ var arg3 domain.MergeRequest
241+ if args[3] != nil {
242+ arg3 = args[3].(domain.MergeRequest)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ arg2,
248+ arg3,
249+ )
250+ })
251+ return _c
252+}
253+
254+func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call {
255+ _c.Call.Return()
256+ return _c
257+}
258+
259+func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
260+ _c.Run(run)
261+ return _c
262+}
@@ -0,0 +1,262 @@
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+// NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. 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 NewIssueLinkTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueLinkTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueLinkTrigger{}
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+// IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type
38+type IssueLinkTrigger struct {
39+ mock.Mock
40+}
41+
42+type IssueLinkTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter {
47+ return &IssueLinkTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type IssueLinkTrigger
51+func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) {
52+ _mock.Called(context1, repoName, refUpdates)
53+ return
54+}
55+
56+// IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type IssueLinkTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - context1 context.Context
63+// - repoName domain.RepoName
64+// - refUpdates []domain.RefUpdate
65+func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call {
66+ return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)}
67+}
68+
69+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger
103+func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) {
104+ _mock.Called(context1, repo, n)
105+ return
106+}
107+
108+// IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged'
109+type IssueLinkTrigger_OnMergeRequestChanged_Call struct {
110+ *mock.Call
111+}
112+
113+// OnMergeRequestChanged is a helper method to define mock.On call
114+// - context1 context.Context
115+// - repo domain.Repo
116+// - n int64
117+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call {
118+ return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)}
119+}
120+
121+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ var arg0 context.Context
124+ if args[0] != nil {
125+ arg0 = args[0].(context.Context)
126+ }
127+ var arg1 domain.Repo
128+ if args[1] != nil {
129+ arg1 = args[1].(domain.Repo)
130+ }
131+ var arg2 int64
132+ if args[2] != nil {
133+ arg2 = args[2].(int64)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ arg2,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call {
145+ _c.Call.Return()
146+ return _c
147+}
148+
149+func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call {
150+ _c.Run(run)
151+ return _c
152+}
153+
154+// OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger
155+func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) {
156+ _mock.Called(context1, repo, mergeRequest)
157+ return
158+}
159+
160+// IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled'
161+type IssueLinkTrigger_OnMergeRequestSettled_Call struct {
162+ *mock.Call
163+}
164+
165+// OnMergeRequestSettled is a helper method to define mock.On call
166+// - context1 context.Context
167+// - repo domain.Repo
168+// - mergeRequest domain.MergeRequest
169+func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call {
170+ return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)}
171+}
172+
173+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 context.Context
176+ if args[0] != nil {
177+ arg0 = args[0].(context.Context)
178+ }
179+ var arg1 domain.Repo
180+ if args[1] != nil {
181+ arg1 = args[1].(domain.Repo)
182+ }
183+ var arg2 domain.MergeRequest
184+ if args[2] != nil {
185+ arg2 = args[2].(domain.MergeRequest)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call {
197+ _c.Call.Return()
198+ return _c
199+}
200+
201+func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call {
202+ _c.Run(run)
203+ return _c
204+}
205+
206+// OnMerged provides a mock function for the type IssueLinkTrigger
207+func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) {
208+ _mock.Called(context1, principal, repo, mergeRequest)
209+ return
210+}
211+
212+// IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged'
213+type IssueLinkTrigger_OnMerged_Call struct {
214+ *mock.Call
215+}
216+
217+// OnMerged is a helper method to define mock.On call
218+// - context1 context.Context
219+// - principal domain.Principal
220+// - repo domain.Repo
221+// - mergeRequest domain.MergeRequest
222+func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call {
223+ return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)}
224+}
225+
226+func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
227+ _c.Call.Run(func(args mock.Arguments) {
228+ var arg0 context.Context
229+ if args[0] != nil {
230+ arg0 = args[0].(context.Context)
231+ }
232+ var arg1 domain.Principal
233+ if args[1] != nil {
234+ arg1 = args[1].(domain.Principal)
235+ }
236+ var arg2 domain.Repo
237+ if args[2] != nil {
238+ arg2 = args[2].(domain.Repo)
239+ }
240+ var arg3 domain.MergeRequest
241+ if args[3] != nil {
242+ arg3 = args[3].(domain.MergeRequest)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ arg2,
248+ arg3,
249+ )
250+ })
251+ return _c
252+}
253+
254+func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call {
255+ _c.Call.Return()
256+ return _c
257+}
258+
259+func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call {
260+ _c.Run(run)
261+ return _c
262+}
M internal/ports/ports.go +20 -0
@@ -536,7 +536,27 @@ type EventBus interface {
536536 Subscribe(ctx context.Context) <-chan domain.Event
537537 }
538538
539+// IssueLinkStore keeps which merge requests name which issues.
540+type IssueLinkStore interface {
541+ // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues).
542+ // Added includes new links and links whose closes flag turned on.
543+ // Returns ErrNotFound for a missing repository, ErrConflict on a race.
544+ ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error)
545+ // LinksOfIssue returns links ascending by MR number, at most limit.
546+ LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)
547+ // LinksOfMergeRequest returns the issues named by an MR.
548+ LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)
549+}
550+
539551 // Clock tells the time.
540552 type Clock interface {
541553 Now() time.Time
542554 }
555+
556+// IssueLinkTrigger updates derived links without failing the primary operation.
557+type IssueLinkTrigger interface {
558+ OnMergeRequestChanged(context.Context, domain.Repo, int64)
559+ OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest)
560+ OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate)
561+ OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest)
562+}
@@ -536,7 +536,27 @@ type EventBus interface {
536 Subscribe(ctx context.Context) <-chan domain.Event536 Subscribe(ctx context.Context) <-chan domain.Event
537 }537 }
538 538
539+// IssueLinkStore keeps which merge requests name which issues.
540+type IssueLinkStore interface {
541+ // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues).
542+ // Added includes new links and links whose closes flag turned on.
543+ // Returns ErrNotFound for a missing repository, ErrConflict on a race.
544+ ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error)
545+ // LinksOfIssue returns links ascending by MR number, at most limit.
546+ LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)
547+ // LinksOfMergeRequest returns the issues named by an MR.
548+ LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)
549+}
550+
539 // Clock tells the time.551 // Clock tells the time.
540 type Clock interface {552 type Clock interface {
541 Now() time.Time553 Now() time.Time
542 }554 }
555+
556+// IssueLinkTrigger updates derived links without failing the primary operation.
557+type IssueLinkTrigger interface {
558+ OnMergeRequestChanged(context.Context, domain.Repo, int64)
559+ OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest)
560+ OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate)
561+ OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest)
562+}
M internal/ports/services.go +2 -1
@@ -203,7 +203,8 @@ type IssueSubmissionService interface {
203203 // implements it.
204204 type IssueNumberResolver interface {
205205 // Kind needs the access of reading the repository and answers
206- // domain.ErrNotFound otherwise, also when issues are off.
206+ // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge
207+ // requests are resolved even when issues are off.
207208 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
208209 }
209210
@@ -203,7 +203,8 @@ type IssueSubmissionService interface {
203 // implements it.203 // implements it.
204 type IssueNumberResolver interface {204 type IssueNumberResolver interface {
205 // Kind needs the access of reading the repository and answers205 // Kind needs the access of reading the repository and answers
206- // domain.ErrNotFound otherwise, also when issues are off.206+ // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge
207+ // requests are resolved even when issues are off.
207 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)208 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
208 }209 }
209 210
M internal/usecase/events.go +7 -1
@@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
186186 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187187 case domain.CommentAdded:
188188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReferenced:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189191 case domain.IssueOpened:
190192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191193 case domain.IssueClosed:
@@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
537539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538540 case domain.CommentAdded:
539541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.IssueReferenced:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540544 case domain.IssueOpened:
541545 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
542546 case domain.IssueClosed:
@@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
637641 // isIssueEvent reports whether ev is about an issue.
638642 func isIssueEvent(ev domain.Event) bool {
639643 switch ev.(type) {
640- case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
644+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced:
641645 return true
642646 }
643647 return false
@@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
656660 // isItemEvent reports whether ev is about a merge request or an issue.
657661 func isItemEvent(ev domain.Event) bool {
658662 switch ev.(type) {
663+ case domain.IssueReferenced:
664+ return false // back-reference refreshes do not change the inbox
659665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
660666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
@@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
186 return repoSnapshot(ev.Visibility, ev.CreatedAt)186 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187 case domain.CommentAdded:187 case domain.CommentAdded:
188 return repoSnapshot(ev.Visibility, ev.CreatedAt)188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReferenced:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189 case domain.IssueOpened:191 case domain.IssueOpened:
190 return repoSnapshot(ev.Visibility, ev.CreatedAt)192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191 case domain.IssueClosed:193 case domain.IssueClosed:
@@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538 case domain.CommentAdded:540 case domain.CommentAdded:
539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.IssueReferenced:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540 case domain.IssueOpened:544 case domain.IssueOpened:
541 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)545 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
542 case domain.IssueClosed:546 case domain.IssueClosed:
@@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
637 // isIssueEvent reports whether ev is about an issue.641 // isIssueEvent reports whether ev is about an issue.
638 func isIssueEvent(ev domain.Event) bool {642 func isIssueEvent(ev domain.Event) bool {
639 switch ev.(type) {643 switch ev.(type) {
640- case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:644+ case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced:
641 return true645 return true
642 }646 }
643 return false647 return false
@@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
656 // isItemEvent reports whether ev is about a merge request or an issue.660 // isItemEvent reports whether ev is about a merge request or an issue.
657 func isItemEvent(ev domain.Event) bool {661 func isItemEvent(ev domain.Event) bool {
658 switch ev.(type) {662 switch ev.(type) {
663+ case domain.IssueReferenced:
664+ return false // back-reference refreshes do not change the inbox
659 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
660 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
M internal/usecase/events_test.go +24 -0
@@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
10101010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
10111011 })
10121012 }
1013+
1014+func TestIssueReferencedRepoOnly(t *testing.T) {
1015+ synctest.Test(t, func(t *testing.T) {
1016+ r := newEventRig(t, 16)
1017+ n := name(t, "linked")
1018+ settings := domain.DefaultRepoSettings()
1019+ settings.Issues = domain.IssuesOwner
1020+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe()
1021+ ctx, cancel := context.WithCancel(context.Background())
1022+ defer cancel()
1023+ repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}})
1024+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
1025+ synctest.Wait()
1026+ ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
1027+ r.feed <- ev
1028+ assert.Equal(t, ev, repo.next(t).Event)
1029+ synctest.Wait()
1030+ select {
1031+ case got := <-owner.got:
1032+ t.Fatalf("owner received %+v", got)
1033+ default:
1034+ }
1035+ })
1036+}
@@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
1010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)1010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
1011 })1011 })
1012 }1012 }
1013+
1014+func TestIssueReferencedRepoOnly(t *testing.T) {
1015+ synctest.Test(t, func(t *testing.T) {
1016+ r := newEventRig(t, 16)
1017+ n := name(t, "linked")
1018+ settings := domain.DefaultRepoSettings()
1019+ settings.Issues = domain.IssuesOwner
1020+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe()
1021+ ctx, cancel := context.WithCancel(context.Background())
1022+ defer cancel()
1023+ repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}})
1024+ owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}})
1025+ synctest.Wait()
1026+ ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now}
1027+ r.feed <- ev
1028+ assert.Equal(t, ev, repo.next(t).Event)
1029+ synctest.Wait()
1030+ select {
1031+ case got := <-owner.got:
1032+ t.Fatalf("owner received %+v", got)
1033+ default:
1034+ }
1035+ })
1036+}
A internal/usecase/issue_links.go +286 -0
@@ -0,0 +1,286 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "github.com/bnema/zerowrap"
16+)
17+
18+const MaxIssueLinkCommits = ports.MaxMergeRequestCommits
19+const MaxIssueLinksPerIssue = 100
20+const maxLinkedMRsPerPush = 50
21+
22+type IssueLinksConfig struct {
23+ Meta ports.RepoMetaStore
24+ Issues ports.IssueStore
25+ MRs ports.MergeRequestStore
26+ Links ports.IssueLinkStore
27+ Git ports.MergeGit
28+ Bus ports.EventBus
29+ Clock ports.Clock
30+}
31+
32+// IssueLinks maintains the derived index and applies closing references on merge.
33+type IssueLinks struct {
34+ cfg IssueLinksConfig
35+ mu sync.Mutex
36+ locks map[linkKey]*linkLock
37+}
38+
39+type linkKey struct {
40+ repo domain.RepoName
41+ number int64
42+}
43+
44+type linkLock struct {
45+ ch chan struct{}
46+ refs int
47+}
48+
49+// lockMR serializes reindexes of one merge request, waiting until ctx ends.
50+// Entries are removed when no one holds or waits for them.
51+func (l *IssueLinks) lockMR(ctx context.Context, repo domain.RepoName, number int64) (func(), error) {
52+ key := linkKey{repo, number}
53+ l.mu.Lock()
54+ e, ok := l.locks[key]
55+ if !ok {
56+ e = &linkLock{ch: make(chan struct{}, 1)}
57+ l.locks[key] = e
58+ }
59+ e.refs++
60+ l.mu.Unlock()
61+ release := func() {
62+ l.mu.Lock()
63+ if e.refs--; e.refs == 0 {
64+ delete(l.locks, key)
65+ }
66+ l.mu.Unlock()
67+ }
68+ select {
69+ case e.ch <- struct{}{}:
70+ return func() { <-e.ch; release() }, nil
71+ case <-ctx.Done():
72+ release()
73+ return nil, ctx.Err()
74+ }
75+}
76+
77+var _ ports.IssueLinkTrigger = (*IssueLinks)(nil)
78+
79+func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks {
80+ return &IssueLinks{cfg: cfg, locks: map[linkKey]*linkLock{}}
81+}
82+
83+// Reindex replaces links from the current texts and bounded commit range.
84+func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error {
85+ _, err := l.reindex(ctx, repo, number)
86+ return err
87+}
88+func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) {
89+ unlock, err := l.lockMR(ctx, repo.Name, number)
90+ if err != nil {
91+ return nil, err
92+ }
93+ defer unlock()
94+ mr, err := l.cfg.MRs.Get(ctx, repo.Name, number)
95+ if err != nil {
96+ return nil, err
97+ }
98+ notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number)
99+ if err != nil {
100+ return nil, err
101+ }
102+ var texts, messages []string
103+ for _, note := range notes {
104+ if !note.Deleted {
105+ texts = append(texts, note.Text.String())
106+ }
107+ }
108+ if mr.IsOpen() {
109+ mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source)
110+ if errors.Is(err, domain.ErrNotFound) {
111+ mr.HeadID = domain.ObjectID{}
112+ } else if err != nil {
113+ return nil, err
114+ }
115+ }
116+ exclude, _, err := mrRange(ctx, l.cfg.Git, mr)
117+ if err != nil {
118+ return nil, err
119+ }
120+ has := !mr.HeadID.IsZero()
121+ if has && !mr.IsOpen() {
122+ has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID)
123+ if err != nil {
124+ return nil, err
125+ }
126+ }
127+ if has {
128+ commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits)
129+ if err != nil {
130+ return nil, err
131+ }
132+ if more {
133+ log := zerowrap.FromCtx(ctx)
134+ log.Debug().Msg("issue link commit range truncated")
135+ }
136+ for _, commit := range commits {
137+ messages = append(messages, commit.Message)
138+ }
139+ }
140+ refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages)
141+ var mentions, closes []int64
142+ for _, n := range refs.Mentions {
143+ _, err := l.cfg.Issues.Get(ctx, repo.Name, n)
144+ if errors.Is(err, domain.ErrNotFound) {
145+ continue
146+ }
147+ if err != nil {
148+ return nil, err
149+ }
150+ mentions = append(mentions, n)
151+ if slices.Contains(refs.Closes, n) {
152+ closes = append(closes, n)
153+ }
154+ }
155+ for range maxWriteRetries {
156+ added, removed, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes)
157+ if errors.Is(err, domain.ErrConflict) {
158+ continue
159+ }
160+ if err != nil {
161+ return nil, err
162+ }
163+ type pair struct{ issue, mr int64 }
164+ sent := map[pair]struct{}{}
165+ for _, link := range slices.Concat(added, removed) {
166+ p := pair{link.Issue, link.MergeRequest}
167+ if _, ok := sent[p]; ok {
168+ continue
169+ }
170+ sent[p] = struct{}{}
171+ l.notify(ctx, repo, link)
172+ }
173+ return closes, nil
174+ }
175+ return nil, domain.ErrConflict
176+}
177+func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) {
178+ l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
179+}
180+func linkContext(ctx context.Context) (context.Context, context.CancelFunc) {
181+ return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
182+}
183+func linkWarning(ctx context.Context, err error) {
184+ if err != nil {
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Msg("issue links not updated")
187+ }
188+}
189+func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) {
190+ ctx, cancel := linkContext(ctx)
191+ defer cancel()
192+ linkWarning(ctx, l.Reindex(ctx, repo, number))
193+}
194+func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
195+ links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number)
196+ linkWarning(ctx, err)
197+ if err == nil {
198+ for _, link := range links {
199+ l.notify(ctx, repo, link)
200+ }
201+ }
202+}
203+func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
204+ ctx, cancel := linkContext(ctx)
205+ defer cancel()
206+ l.settled(ctx, repo, mr)
207+}
208+func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
209+ ctx, cancel := linkContext(ctx)
210+ defer cancel()
211+ var branches []string
212+ for _, up := range updates {
213+ if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() {
214+ branches = append(branches, b)
215+ }
216+ }
217+ if len(branches) == 0 {
218+ return
219+ }
220+ repo, err := l.cfg.Meta.Get(ctx, name)
221+ if err != nil {
222+ linkWarning(ctx, err)
223+ return
224+ }
225+ numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush)
226+ if err != nil {
227+ linkWarning(ctx, err)
228+ return
229+ }
230+ if truncated {
231+ log := zerowrap.FromCtx(ctx)
232+ log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated")
233+ }
234+ for _, n := range numbers {
235+ linkWarning(ctx, l.Reindex(ctx, repo, n))
236+ }
237+}
238+func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) {
239+ ctx, cancel := linkContext(ctx)
240+ defer cancel()
241+ defer l.settled(ctx, repo, mr)
242+ closes, err := l.reindex(ctx, repo, mr.Number)
243+ if err != nil {
244+ linkWarning(ctx, err)
245+ return
246+ }
247+ if !repo.Settings.IssuesEnabled() {
248+ log := zerowrap.FromCtx(ctx)
249+ log.Debug().Msg("merge issue closing disabled")
250+ return
251+ }
252+ for _, n := range closes {
253+ err := l.close(ctx, p, repo, mr.Number, n)
254+ if err != nil {
255+ log := zerowrap.FromCtx(ctx)
256+ log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge")
257+ }
258+ }
259+}
260+func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error {
261+ for range maxWriteRetries {
262+ issue, err := l.cfg.Issues.Get(ctx, repo.Name, n)
263+ if err != nil {
264+ return err
265+ }
266+ if !issue.IsOpen() {
267+ return nil
268+ }
269+ issue.State = domain.IssueStateClosed
270+ issue.ClosedByMergeRequest = mr
271+ issue.UpdatedAt = l.cfg.Clock.Now().UTC()
272+ err = l.cfg.Issues.Update(ctx, issue)
273+ if errors.Is(err, domain.ErrConflict) {
274+ continue
275+ }
276+ if err != nil {
277+ return err
278+ }
279+ recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt)
280+ log := zerowrap.FromCtx(ctx)
281+ log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge")
282+ l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
283+ return nil
284+ }
285+ return domain.ErrConflict
286+}
@@ -0,0 +1,286 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "github.com/bnema/zerowrap"
16+)
17+
18+const MaxIssueLinkCommits = ports.MaxMergeRequestCommits
19+const MaxIssueLinksPerIssue = 100
20+const maxLinkedMRsPerPush = 50
21+
22+type IssueLinksConfig struct {
23+ Meta ports.RepoMetaStore
24+ Issues ports.IssueStore
25+ MRs ports.MergeRequestStore
26+ Links ports.IssueLinkStore
27+ Git ports.MergeGit
28+ Bus ports.EventBus
29+ Clock ports.Clock
30+}
31+
32+// IssueLinks maintains the derived index and applies closing references on merge.
33+type IssueLinks struct {
34+ cfg IssueLinksConfig
35+ mu sync.Mutex
36+ locks map[linkKey]*linkLock
37+}
38+
39+type linkKey struct {
40+ repo domain.RepoName
41+ number int64
42+}
43+
44+type linkLock struct {
45+ ch chan struct{}
46+ refs int
47+}
48+
49+// lockMR serializes reindexes of one merge request, waiting until ctx ends.
50+// Entries are removed when no one holds or waits for them.
51+func (l *IssueLinks) lockMR(ctx context.Context, repo domain.RepoName, number int64) (func(), error) {
52+ key := linkKey{repo, number}
53+ l.mu.Lock()
54+ e, ok := l.locks[key]
55+ if !ok {
56+ e = &linkLock{ch: make(chan struct{}, 1)}
57+ l.locks[key] = e
58+ }
59+ e.refs++
60+ l.mu.Unlock()
61+ release := func() {
62+ l.mu.Lock()
63+ if e.refs--; e.refs == 0 {
64+ delete(l.locks, key)
65+ }
66+ l.mu.Unlock()
67+ }
68+ select {
69+ case e.ch <- struct{}{}:
70+ return func() { <-e.ch; release() }, nil
71+ case <-ctx.Done():
72+ release()
73+ return nil, ctx.Err()
74+ }
75+}
76+
77+var _ ports.IssueLinkTrigger = (*IssueLinks)(nil)
78+
79+func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks {
80+ return &IssueLinks{cfg: cfg, locks: map[linkKey]*linkLock{}}
81+}
82+
83+// Reindex replaces links from the current texts and bounded commit range.
84+func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error {
85+ _, err := l.reindex(ctx, repo, number)
86+ return err
87+}
88+func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) {
89+ unlock, err := l.lockMR(ctx, repo.Name, number)
90+ if err != nil {
91+ return nil, err
92+ }
93+ defer unlock()
94+ mr, err := l.cfg.MRs.Get(ctx, repo.Name, number)
95+ if err != nil {
96+ return nil, err
97+ }
98+ notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number)
99+ if err != nil {
100+ return nil, err
101+ }
102+ var texts, messages []string
103+ for _, note := range notes {
104+ if !note.Deleted {
105+ texts = append(texts, note.Text.String())
106+ }
107+ }
108+ if mr.IsOpen() {
109+ mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source)
110+ if errors.Is(err, domain.ErrNotFound) {
111+ mr.HeadID = domain.ObjectID{}
112+ } else if err != nil {
113+ return nil, err
114+ }
115+ }
116+ exclude, _, err := mrRange(ctx, l.cfg.Git, mr)
117+ if err != nil {
118+ return nil, err
119+ }
120+ has := !mr.HeadID.IsZero()
121+ if has && !mr.IsOpen() {
122+ has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID)
123+ if err != nil {
124+ return nil, err
125+ }
126+ }
127+ if has {
128+ commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits)
129+ if err != nil {
130+ return nil, err
131+ }
132+ if more {
133+ log := zerowrap.FromCtx(ctx)
134+ log.Debug().Msg("issue link commit range truncated")
135+ }
136+ for _, commit := range commits {
137+ messages = append(messages, commit.Message)
138+ }
139+ }
140+ refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages)
141+ var mentions, closes []int64
142+ for _, n := range refs.Mentions {
143+ _, err := l.cfg.Issues.Get(ctx, repo.Name, n)
144+ if errors.Is(err, domain.ErrNotFound) {
145+ continue
146+ }
147+ if err != nil {
148+ return nil, err
149+ }
150+ mentions = append(mentions, n)
151+ if slices.Contains(refs.Closes, n) {
152+ closes = append(closes, n)
153+ }
154+ }
155+ for range maxWriteRetries {
156+ added, removed, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes)
157+ if errors.Is(err, domain.ErrConflict) {
158+ continue
159+ }
160+ if err != nil {
161+ return nil, err
162+ }
163+ type pair struct{ issue, mr int64 }
164+ sent := map[pair]struct{}{}
165+ for _, link := range slices.Concat(added, removed) {
166+ p := pair{link.Issue, link.MergeRequest}
167+ if _, ok := sent[p]; ok {
168+ continue
169+ }
170+ sent[p] = struct{}{}
171+ l.notify(ctx, repo, link)
172+ }
173+ return closes, nil
174+ }
175+ return nil, domain.ErrConflict
176+}
177+func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) {
178+ l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
179+}
180+func linkContext(ctx context.Context) (context.Context, context.CancelFunc) {
181+ return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
182+}
183+func linkWarning(ctx context.Context, err error) {
184+ if err != nil {
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Msg("issue links not updated")
187+ }
188+}
189+func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) {
190+ ctx, cancel := linkContext(ctx)
191+ defer cancel()
192+ linkWarning(ctx, l.Reindex(ctx, repo, number))
193+}
194+func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
195+ links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number)
196+ linkWarning(ctx, err)
197+ if err == nil {
198+ for _, link := range links {
199+ l.notify(ctx, repo, link)
200+ }
201+ }
202+}
203+func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) {
204+ ctx, cancel := linkContext(ctx)
205+ defer cancel()
206+ l.settled(ctx, repo, mr)
207+}
208+func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
209+ ctx, cancel := linkContext(ctx)
210+ defer cancel()
211+ var branches []string
212+ for _, up := range updates {
213+ if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() {
214+ branches = append(branches, b)
215+ }
216+ }
217+ if len(branches) == 0 {
218+ return
219+ }
220+ repo, err := l.cfg.Meta.Get(ctx, name)
221+ if err != nil {
222+ linkWarning(ctx, err)
223+ return
224+ }
225+ numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush)
226+ if err != nil {
227+ linkWarning(ctx, err)
228+ return
229+ }
230+ if truncated {
231+ log := zerowrap.FromCtx(ctx)
232+ log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated")
233+ }
234+ for _, n := range numbers {
235+ linkWarning(ctx, l.Reindex(ctx, repo, n))
236+ }
237+}
238+func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) {
239+ ctx, cancel := linkContext(ctx)
240+ defer cancel()
241+ defer l.settled(ctx, repo, mr)
242+ closes, err := l.reindex(ctx, repo, mr.Number)
243+ if err != nil {
244+ linkWarning(ctx, err)
245+ return
246+ }
247+ if !repo.Settings.IssuesEnabled() {
248+ log := zerowrap.FromCtx(ctx)
249+ log.Debug().Msg("merge issue closing disabled")
250+ return
251+ }
252+ for _, n := range closes {
253+ err := l.close(ctx, p, repo, mr.Number, n)
254+ if err != nil {
255+ log := zerowrap.FromCtx(ctx)
256+ log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge")
257+ }
258+ }
259+}
260+func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error {
261+ for range maxWriteRetries {
262+ issue, err := l.cfg.Issues.Get(ctx, repo.Name, n)
263+ if err != nil {
264+ return err
265+ }
266+ if !issue.IsOpen() {
267+ return nil
268+ }
269+ issue.State = domain.IssueStateClosed
270+ issue.ClosedByMergeRequest = mr
271+ issue.UpdatedAt = l.cfg.Clock.Now().UTC()
272+ err = l.cfg.Issues.Update(ctx, issue)
273+ if errors.Is(err, domain.ErrConflict) {
274+ continue
275+ }
276+ if err != nil {
277+ return err
278+ }
279+ recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt)
280+ log := zerowrap.FromCtx(ctx)
281+ log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge")
282+ l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
283+ return nil
284+ }
285+ return domain.ErrConflict
286+}
A internal/usecase/issue_links_test.go +235 -0
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/usecase"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) {
20+ t.Helper()
21+ r := newMRRig(t)
22+ r.seed()
23+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil })
24+ require.NoError(t, err)
25+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}})
26+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links})
27+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}})
28+ return r, links, issues
29+}
30+
31+func TestIssueLinksTextsPushAndReferences(t *testing.T) {
32+ r, links, issues := linkedRig(t)
33+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
34+ require.NoError(t, err)
35+ r.feature("feat")
36+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
37+ require.NoError(t, err)
38+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
39+ require.NoError(t, err)
40+ require.Len(t, got.References, 1)
41+ assert.Equal(t, mr.Title, got.References[0].Title)
42+ other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
43+ require.NoError(t, err)
44+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)})
45+ require.NoError(t, err)
46+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
47+ require.NoError(t, err)
48+ require.Len(t, got.References, 1)
49+ assert.False(t, got.References[0].Closes)
50+ indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
51+ require.NoError(t, err)
52+ require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
53+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
54+ require.NoError(t, err)
55+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
56+ require.NoError(t, err)
57+ assert.Empty(t, got.References)
58+ head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
59+ r.push("feat")
60+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
61+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
62+ require.NoError(t, err)
63+ require.Len(t, got.References, 1)
64+ assert.True(t, got.References[0].Closes)
65+ stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
66+ require.NoError(t, err)
67+ require.Len(t, stored, 2)
68+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
69+ require.NoError(t, err)
70+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
71+ require.NoError(t, err)
72+ assert.True(t, got.Issue.IsOpen())
73+ assert.Equal(t, domain.MRStateClosed, got.References[0].State)
74+}
75+
76+func TestIssueLinksMergeClosing(t *testing.T) {
77+ for _, mode := range []string{"open", "off", "closed"} {
78+ t.Run(mode, func(t *testing.T) {
79+ r, _, issues := linkedRig(t)
80+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
81+ require.NoError(t, err)
82+ if mode == "closed" {
83+ issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
84+ require.NoError(t, err)
85+ }
86+ r.feature("feat")
87+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
88+ r.push("feat")
89+ mr := r.open("feat")
90+ if mode == "off" {
91+ _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
92+ require.NoError(t, err)
93+ }
94+ issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
95+ require.NoError(t, err)
96+ r.drain()
97+ _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
98+ require.NoError(t, err)
99+ events := r.drain()
100+ got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
101+ require.NoError(t, err)
102+ refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
103+ require.NoError(t, err)
104+ require.Len(t, refs, 1)
105+ assert.True(t, refs[0].Closes)
106+ closedEvents, referenced := 0, 0
107+ for _, ev := range events {
108+ switch ev.(type) {
109+ case domain.IssueClosed:
110+ closedEvents++
111+ case domain.IssueReferenced:
112+ referenced++
113+ }
114+ }
115+ assert.Positive(t, referenced)
116+ if mode == "open" {
117+ assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
118+ assert.False(t, got.IsOpen())
119+ assert.Equal(t, 1, closedEvents)
120+ reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
121+ require.NoError(t, err)
122+ assert.Zero(t, reopened.ClosedByMergeRequest)
123+ } else {
124+ assert.Zero(t, got.ClosedByMergeRequest)
125+ assert.Equal(t, 0, closedEvents)
126+ if mode == "closed" {
127+ assert.Equal(t, issue, got)
128+ } else {
129+ assert.True(t, got.IsOpen())
130+ }
131+ }
132+ })
133+ }
134+}
135+
136+type gatedBranchGit struct {
137+ ports.MergeGit
138+ entered atomic.Int32
139+ started chan struct{}
140+ gate chan struct{}
141+}
142+
143+func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) {
144+ if g.entered.Add(1) == 1 {
145+ close(g.started)
146+ <-g.gate
147+ }
148+ return g.MergeGit.BranchHead(ctx, name, branch)
149+}
150+
151+func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) {
152+ r, _, issues := linkedRig(t)
153+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
154+ require.NoError(t, err)
155+ r.feature("feat")
156+ mr := r.open("feat")
157+ g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})}
158+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}})
159+ repo, err := r.store.Get(r.ctx, r.repo)
160+ require.NoError(t, err)
161+ done := make(chan error, 2)
162+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
163+ <-g.started
164+ // The first reindex read the old texts; a note is added afterwards.
165+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
166+ require.NoError(t, err)
167+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
168+ time.Sleep(100 * time.Millisecond)
169+ assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first")
170+ close(g.gate)
171+ require.NoError(t, <-done)
172+ require.NoError(t, <-done)
173+ got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
174+ require.NoError(t, err)
175+ require.Len(t, got, 1, "the final index reflects the latest content")
176+}
177+
178+func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) {
179+ r, links, issues := linkedRig(t)
180+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
181+ require.NoError(t, err)
182+ r.feature("feat")
183+ mr := r.open("feat")
184+ referenced := func() (n int) {
185+ for _, ev := range r.drain() {
186+ if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number {
187+ n++
188+ }
189+ }
190+ return n
191+ }
192+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
193+ require.NoError(t, err)
194+ assert.Equal(t, 1, referenced())
195+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing")
196+ require.NoError(t, err)
197+ assert.Equal(t, 1, referenced(), "removing the last mention notifies")
198+
199+ // closes true -> false emits one event: a commit message fixes the issue,
200+ // then is rewritten to a plain mention.
201+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
202+ r.push("feat")
203+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
204+ referenced()
205+ r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number))
206+ r.push("feat")
207+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}})
208+ assert.Equal(t, 1, referenced(), "a closing flip notifies once")
209+}
210+
211+func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
212+ r, _, issues := linkedRig(t)
213+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
214+ require.NoError(t, err)
215+ r.sh("checkout", "-q", "-B", "x", "main")
216+ r.commit(author, "x.txt", "x", "work on x")
217+ r.push("x")
218+ r.sh("checkout", "-q", "-B", "feat", "x")
219+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
220+ r.push("feat")
221+ b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"})
222+ require.NoError(t, err)
223+ a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"})
224+ require.NoError(t, err)
225+ before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
226+ require.NoError(t, err)
227+ require.Empty(t, before)
228+ _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
229+ require.NoError(t, err)
230+ r.mrs.Wait(r.ctx)
231+ after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
232+ require.NoError(t, err)
233+ require.Len(t, after, 1)
234+ assert.Equal(t, issue.Number, after[0].Issue)
235+}
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/usecase"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) {
20+ t.Helper()
21+ r := newMRRig(t)
22+ r.seed()
23+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil })
24+ require.NoError(t, err)
25+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}})
26+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links})
27+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}})
28+ return r, links, issues
29+}
30+
31+func TestIssueLinksTextsPushAndReferences(t *testing.T) {
32+ r, links, issues := linkedRig(t)
33+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
34+ require.NoError(t, err)
35+ r.feature("feat")
36+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
37+ require.NoError(t, err)
38+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
39+ require.NoError(t, err)
40+ require.Len(t, got.References, 1)
41+ assert.Equal(t, mr.Title, got.References[0].Title)
42+ other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
43+ require.NoError(t, err)
44+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)})
45+ require.NoError(t, err)
46+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
47+ require.NoError(t, err)
48+ require.Len(t, got.References, 1)
49+ assert.False(t, got.References[0].Closes)
50+ indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
51+ require.NoError(t, err)
52+ require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
53+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
54+ require.NoError(t, err)
55+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
56+ require.NoError(t, err)
57+ assert.Empty(t, got.References)
58+ head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
59+ r.push("feat")
60+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
61+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
62+ require.NoError(t, err)
63+ require.Len(t, got.References, 1)
64+ assert.True(t, got.References[0].Closes)
65+ stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
66+ require.NoError(t, err)
67+ require.Len(t, stored, 2)
68+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
69+ require.NoError(t, err)
70+ got, err = issues.Get(r.ctx, root, r.repo, other.Number)
71+ require.NoError(t, err)
72+ assert.True(t, got.Issue.IsOpen())
73+ assert.Equal(t, domain.MRStateClosed, got.References[0].State)
74+}
75+
76+func TestIssueLinksMergeClosing(t *testing.T) {
77+ for _, mode := range []string{"open", "off", "closed"} {
78+ t.Run(mode, func(t *testing.T) {
79+ r, _, issues := linkedRig(t)
80+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
81+ require.NoError(t, err)
82+ if mode == "closed" {
83+ issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
84+ require.NoError(t, err)
85+ }
86+ r.feature("feat")
87+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
88+ r.push("feat")
89+ mr := r.open("feat")
90+ if mode == "off" {
91+ _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
92+ require.NoError(t, err)
93+ }
94+ issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
95+ require.NoError(t, err)
96+ r.drain()
97+ _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
98+ require.NoError(t, err)
99+ events := r.drain()
100+ got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
101+ require.NoError(t, err)
102+ refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
103+ require.NoError(t, err)
104+ require.Len(t, refs, 1)
105+ assert.True(t, refs[0].Closes)
106+ closedEvents, referenced := 0, 0
107+ for _, ev := range events {
108+ switch ev.(type) {
109+ case domain.IssueClosed:
110+ closedEvents++
111+ case domain.IssueReferenced:
112+ referenced++
113+ }
114+ }
115+ assert.Positive(t, referenced)
116+ if mode == "open" {
117+ assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
118+ assert.False(t, got.IsOpen())
119+ assert.Equal(t, 1, closedEvents)
120+ reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
121+ require.NoError(t, err)
122+ assert.Zero(t, reopened.ClosedByMergeRequest)
123+ } else {
124+ assert.Zero(t, got.ClosedByMergeRequest)
125+ assert.Equal(t, 0, closedEvents)
126+ if mode == "closed" {
127+ assert.Equal(t, issue, got)
128+ } else {
129+ assert.True(t, got.IsOpen())
130+ }
131+ }
132+ })
133+ }
134+}
135+
136+type gatedBranchGit struct {
137+ ports.MergeGit
138+ entered atomic.Int32
139+ started chan struct{}
140+ gate chan struct{}
141+}
142+
143+func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) {
144+ if g.entered.Add(1) == 1 {
145+ close(g.started)
146+ <-g.gate
147+ }
148+ return g.MergeGit.BranchHead(ctx, name, branch)
149+}
150+
151+func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) {
152+ r, _, issues := linkedRig(t)
153+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
154+ require.NoError(t, err)
155+ r.feature("feat")
156+ mr := r.open("feat")
157+ g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})}
158+ links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}})
159+ repo, err := r.store.Get(r.ctx, r.repo)
160+ require.NoError(t, err)
161+ done := make(chan error, 2)
162+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
163+ <-g.started
164+ // The first reindex read the old texts; a note is added afterwards.
165+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
166+ require.NoError(t, err)
167+ go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }()
168+ time.Sleep(100 * time.Millisecond)
169+ assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first")
170+ close(g.gate)
171+ require.NoError(t, <-done)
172+ require.NoError(t, <-done)
173+ got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
174+ require.NoError(t, err)
175+ require.Len(t, got, 1, "the final index reflects the latest content")
176+}
177+
178+func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) {
179+ r, links, issues := linkedRig(t)
180+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
181+ require.NoError(t, err)
182+ r.feature("feat")
183+ mr := r.open("feat")
184+ referenced := func() (n int) {
185+ for _, ev := range r.drain() {
186+ if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number {
187+ n++
188+ }
189+ }
190+ return n
191+ }
192+ note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
193+ require.NoError(t, err)
194+ assert.Equal(t, 1, referenced())
195+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing")
196+ require.NoError(t, err)
197+ assert.Equal(t, 1, referenced(), "removing the last mention notifies")
198+
199+ // closes true -> false emits one event: a commit message fixes the issue,
200+ // then is rewritten to a plain mention.
201+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
202+ r.push("feat")
203+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
204+ referenced()
205+ r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number))
206+ r.push("feat")
207+ links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}})
208+ assert.Equal(t, 1, referenced(), "a closing flip notifies once")
209+}
210+
211+func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
212+ r, _, issues := linkedRig(t)
213+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
214+ require.NoError(t, err)
215+ r.sh("checkout", "-q", "-B", "x", "main")
216+ r.commit(author, "x.txt", "x", "work on x")
217+ r.push("x")
218+ r.sh("checkout", "-q", "-B", "feat", "x")
219+ head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
220+ r.push("feat")
221+ b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"})
222+ require.NoError(t, err)
223+ a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"})
224+ require.NoError(t, err)
225+ before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
226+ require.NoError(t, err)
227+ require.Empty(t, before)
228+ _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
229+ require.NoError(t, err)
230+ r.mrs.Wait(r.ctx)
231+ after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
232+ require.NoError(t, err)
233+ require.Len(t, after, 1)
234+ assert.Equal(t, issue.Number, after[0].Issue)
235+}
M internal/usecase/issues.go +35 -9
@@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000
2626 // issue:write on it, which the branch prefixes of a token do not restrict:
2727 // an issue writes no ref.
2828 type Issues struct {
29+ links ports.IssueLinkStore
2930 meta ports.RepoMetaStore
3031 store ports.IssueStore
3132 mrs ports.MergeRequestStore
@@ -40,6 +41,7 @@ var (
4041
4142 // IssuesConfig holds what the issue use cases need. Every field is required.
4243 type IssuesConfig struct {
44+ Links ports.IssueLinkStore
4345 Meta ports.RepoMetaStore
4446 Store ports.IssueStore
4547 // MergeRequests tells which numbers name a merge request (Kind).
@@ -50,7 +52,7 @@ type IssuesConfig struct {
5052
5153 // NewIssues builds the issue use cases.
5254 func NewIssues(cfg IssuesConfig) *Issues {
53- return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
55+ return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
5456 }
5557
5658 // enabled looks the repository up and hides it when issues are off.
@@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa
159161 if err != nil {
160162 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
161163 }
162- return ports.IssueDetail{Issue: issue, Comments: comments}, nil
164+ detail := ports.IssueDetail{Issue: issue, Comments: comments}
165+ if i.links != nil {
166+ links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
167+ if err != nil {
168+ return ports.IssueDetail{}, err
169+ }
170+ for _, link := range links {
171+ mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
172+ if errors.Is(err, domain.ErrNotFound) {
173+ continue
174+ }
175+ if err != nil {
176+ return ports.IssueDetail{}, err
177+ }
178+ detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
179+ }
180+ }
181+ return detail, nil
163182 }
164183
165184 // AddComment adds a comment and publishes IssueCommented. The principal needs
@@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
251270 if issue.State != from {
252271 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
253272 }
273+ issue.ClosedByMergeRequest = 0
254274 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
255275 err = i.store.Update(ctx, issue)
256276 if errors.Is(err, domain.ErrConflict) {
@@ -274,16 +294,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
274294 }
275295
276296 // 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.
297+// access of reading the repository. Issues are resolved only when enabled;
298+// merge requests are resolved even when issues are off.
279299 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 {
300+ repo, err := lookupRepo(ctx, i.meta, name)
301+ if err != nil {
281302 return domain.NumberNone, err
282303 }
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)
304+ if !canRead(p, repo) {
305+ return domain.NumberNone, domain.ErrNotFound
306+ }
307+ if repo.Settings.IssuesEnabled() {
308+ if _, err := i.store.Get(ctx, name, n); err == nil {
309+ return domain.NumberIssue, nil
310+ } else if !errors.Is(err, domain.ErrNotFound) {
311+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
312+ }
287313 }
288314 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289315 return domain.NumberMergeRequest, nil
@@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000
26 // issue:write on it, which the branch prefixes of a token do not restrict:26 // issue:write on it, which the branch prefixes of a token do not restrict:
27 // an issue writes no ref.27 // an issue writes no ref.
28 type Issues struct {28 type Issues struct {
29+ links ports.IssueLinkStore
29 meta ports.RepoMetaStore30 meta ports.RepoMetaStore
30 store ports.IssueStore31 store ports.IssueStore
31 mrs ports.MergeRequestStore32 mrs ports.MergeRequestStore
@@ -40,6 +41,7 @@ var (
40 41
41 // IssuesConfig holds what the issue use cases need. Every field is required.42 // IssuesConfig holds what the issue use cases need. Every field is required.
42 type IssuesConfig struct {43 type IssuesConfig struct {
44+ Links ports.IssueLinkStore
43 Meta ports.RepoMetaStore45 Meta ports.RepoMetaStore
44 Store ports.IssueStore46 Store ports.IssueStore
45 // MergeRequests tells which numbers name a merge request (Kind).47 // MergeRequests tells which numbers name a merge request (Kind).
@@ -50,7 +52,7 @@ type IssuesConfig struct {
50 52
51 // NewIssues builds the issue use cases.53 // NewIssues builds the issue use cases.
52 func NewIssues(cfg IssuesConfig) *Issues {54 func NewIssues(cfg IssuesConfig) *Issues {
53- return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}55+ return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54 }56 }
55 57
56 // enabled looks the repository up and hides it when issues are off.58 // enabled looks the repository up and hides it when issues are off.
@@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa
159 if err != nil {161 if err != nil {
160 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)162 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
161 }163 }
162- return ports.IssueDetail{Issue: issue, Comments: comments}, nil164+ detail := ports.IssueDetail{Issue: issue, Comments: comments}
165+ if i.links != nil {
166+ links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
167+ if err != nil {
168+ return ports.IssueDetail{}, err
169+ }
170+ for _, link := range links {
171+ mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
172+ if errors.Is(err, domain.ErrNotFound) {
173+ continue
174+ }
175+ if err != nil {
176+ return ports.IssueDetail{}, err
177+ }
178+ detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
179+ }
180+ }
181+ return detail, nil
163 }182 }
164 183
165 // AddComment adds a comment and publishes IssueCommented. The principal needs184 // AddComment adds a comment and publishes IssueCommented. The principal needs
@@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
251 if issue.State != from {270 if issue.State != from {
252 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)271 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
253 }272 }
273+ issue.ClosedByMergeRequest = 0
254 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()274 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
255 err = i.store.Update(ctx, issue)275 err = i.store.Update(ctx, issue)
256 if errors.Is(err, domain.ErrConflict) {276 if errors.Is(err, domain.ErrConflict) {
@@ -274,16 +294,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
274 }294 }
275 295
276 // Kind tells what number n of the repository names. The principal needs the296 // 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 it297+// access of reading the repository. Issues are resolved only when enabled;
278-// is domain.ErrNotFound.298+// 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) {299 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 {300+ repo, err := lookupRepo(ctx, i.meta, name)
301+ if err != nil {
281 return domain.NumberNone, err302 return domain.NumberNone, err
282 }303 }
283- if _, err := i.store.Get(ctx, name, n); err == nil {304+ if !canRead(p, repo) {
284- return domain.NumberIssue, nil305+ return domain.NumberNone, domain.ErrNotFound
285- } else if !errors.Is(err, domain.ErrNotFound) {306+ }
286- return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)307+ if repo.Settings.IssuesEnabled() {
308+ if _, err := i.store.Get(ctx, name, n); err == nil {
309+ return domain.NumberIssue, nil
310+ } else if !errors.Is(err, domain.ErrNotFound) {
311+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
312+ }
287 }313 }
288 if _, err := i.mrs.Get(ctx, name, n); err == nil {314 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289 return domain.NumberMergeRequest, nil315 return domain.NumberMergeRequest, nil
M internal/usecase/issues_test.go +4 -2
@@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) {
6565 assert.ErrorIs(t, err, domain.ErrNotFound)
6666 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
6767 assert.ErrorIs(t, err, domain.ErrNotFound)
68- _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
69- assert.ErrorIs(t, err, domain.ErrNotFound)
68+ u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
69+ kind, err := u.issues.Kind(ctx, domain.Root(), n, 1)
70+ require.NoError(t, err)
71+ assert.Equal(t, domain.NumberMergeRequest, kind)
7072 }
7173 }
7274
@@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) {
65 assert.ErrorIs(t, err, domain.ErrNotFound)65 assert.ErrorIs(t, err, domain.ErrNotFound)
66 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)66 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
67 assert.ErrorIs(t, err, domain.ErrNotFound)67 assert.ErrorIs(t, err, domain.ErrNotFound)
68- _, err = u.issues.Kind(ctx, domain.Root(), n, 1)68+ u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
69- assert.ErrorIs(t, err, domain.ErrNotFound)69+ kind, err := u.issues.Kind(ctx, domain.Root(), n, 1)
70+ require.NoError(t, err)
71+ assert.Equal(t, domain.NumberMergeRequest, kind)
70 }72 }
71 }73 }
72 74
M internal/usecase/mergerequests.go +33 -3
@@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:"
4343 // merging mr:merge, on the repository (see scopeAccess), and that scope also
4444 // makes a private repository visible for the action (see writable).
4545 type MergeRequests struct {
46+ links ports.IssueLinkTrigger
4647 meta ports.RepoMetaStore
4748 store ports.MergeRequestStore
4849 repos ports.GitRepos
@@ -77,6 +78,7 @@ var (
7778 // MergeRequestsConfig holds what the merge request use cases need. Every field
7879 // is required.
7980 type MergeRequestsConfig struct {
81+ Links ports.IssueLinkTrigger // Optional derived issue links.
8082 Meta ports.RepoMetaStore
8183 Store ports.MergeRequestStore
8284 // Repos gives the default branch of a repository.
@@ -111,7 +113,7 @@ type MergeRequestsConfig struct {
111113 // NewMergeRequests builds the merge request use cases.
112114 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
113115 return &MergeRequests{
114- meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
116+ links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
115117 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
116118 locks: make(map[domain.RepoName]chan struct{}),
117119 }
@@ -288,6 +290,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
288290 }
289291 recordActivity(ctx, m.meta, repo.Name, now)
290292 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
293+ if m.links != nil {
294+ m.links.OnMergeRequestChanged(ctx, repo, mr.Number)
295+ }
291296 if m.mergeab != nil {
292297 m.mergeab.OnOpened(ctx, mr)
293298 }
@@ -430,6 +435,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
430435 }
431436 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
432437 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
438+ if m.links != nil {
439+ m.links.OnMergeRequestChanged(ctx, repo, number)
440+ }
433441 return m.withHead(ctx, mr)
434442 }
435443 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
@@ -647,17 +655,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
647655 // the head; both are zero when the target is gone or shares no history with
648656 // the head. A merged or closed request keeps the base it stored for both.
649657 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
658+ return mrRange(ctx, m.git, mr)
659+}
660+
661+func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
650662 if !mr.IsOpen() {
651663 return mr.BaseID, mr.BaseID, nil
652664 }
653665 if mr.HeadID.IsZero() {
654666 return domain.ObjectID{}, domain.ObjectID{}, nil
655667 }
656- tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)
668+ tip, err := git.BranchHead(ctx, mr.Repo, mr.Target)
669+ if errors.Is(err, domain.ErrNotFound) {
670+ return domain.ObjectID{}, domain.ObjectID{}, nil
671+ }
657672 if err != nil || tip.IsZero() {
658673 return domain.ObjectID{}, domain.ObjectID{}, err
659674 }
660- base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
675+ base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
661676 if errors.Is(err, domain.ErrNotFound) {
662677 return tip, domain.ObjectID{}, nil
663678 }
@@ -777,6 +792,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
777792 m.bus.Publish(ctx, domain.CommentAdded{
778793 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
779794 })
795+ if m.links != nil {
796+ m.links.OnMergeRequestChanged(ctx, repo, number)
797+ }
780798 return c, nil
781799 }
782800
@@ -912,6 +930,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
933+ if m.links != nil {
934+ m.links.OnMergeRequestSettled(ctx, repo, mr)
935+ }
915936 if m.pulls != nil {
916937 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917938 }
@@ -1168,6 +1189,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11681189 // never returned. mr is the merged request, tip the old target and merged the
11691190 // new one.
11701191 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1192+ if m.links != nil {
1193+ m.links.OnMerged(ctx, p, repo, mr)
1194+ }
11711195 name := repo.Name
11721196 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
11731197 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
@@ -1193,6 +1217,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11931217 if m.mergeab != nil {
11941218 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
11951219 }
1220+ if m.links != nil {
1221+ m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved
1222+ }
11961223 }
11971224
11981225 // deleteSource deletes the source branch of a merged request if it is still
@@ -1453,6 +1480,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
14531480 } else {
14541481 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
14551482 }
1483+ if m.links != nil {
1484+ m.links.OnMergeRequestChanged(ctx, repo, number)
1485+ }
14561486 return c, nil
14571487 }
14581488 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
@@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:"
43 // merging mr:merge, on the repository (see scopeAccess), and that scope also43 // merging mr:merge, on the repository (see scopeAccess), and that scope also
44 // makes a private repository visible for the action (see writable).44 // makes a private repository visible for the action (see writable).
45 type MergeRequests struct {45 type MergeRequests struct {
46+ links ports.IssueLinkTrigger
46 meta ports.RepoMetaStore47 meta ports.RepoMetaStore
47 store ports.MergeRequestStore48 store ports.MergeRequestStore
48 repos ports.GitRepos49 repos ports.GitRepos
@@ -77,6 +78,7 @@ var (
77 // MergeRequestsConfig holds what the merge request use cases need. Every field78 // MergeRequestsConfig holds what the merge request use cases need. Every field
78 // is required.79 // is required.
79 type MergeRequestsConfig struct {80 type MergeRequestsConfig struct {
81+ Links ports.IssueLinkTrigger // Optional derived issue links.
80 Meta ports.RepoMetaStore82 Meta ports.RepoMetaStore
81 Store ports.MergeRequestStore83 Store ports.MergeRequestStore
82 // Repos gives the default branch of a repository.84 // Repos gives the default branch of a repository.
@@ -111,7 +113,7 @@ type MergeRequestsConfig struct {
111 // NewMergeRequests builds the merge request use cases.113 // NewMergeRequests builds the merge request use cases.
112 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {114 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
113 return &MergeRequests{115 return &MergeRequests{
114- meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,116+ links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
115 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,117 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
116 locks: make(map[domain.RepoName]chan struct{}),118 locks: make(map[domain.RepoName]chan struct{}),
117 }119 }
@@ -288,6 +290,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
288 }290 }
289 recordActivity(ctx, m.meta, repo.Name, now)291 recordActivity(ctx, m.meta, repo.Name, now)
290 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})292 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
293+ if m.links != nil {
294+ m.links.OnMergeRequestChanged(ctx, repo, mr.Number)
295+ }
291 if m.mergeab != nil {296 if m.mergeab != nil {
292 m.mergeab.OnOpened(ctx, mr)297 m.mergeab.OnOpened(ctx, mr)
293 }298 }
@@ -430,6 +435,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
430 }435 }
431 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)436 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
432 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})437 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
438+ if m.links != nil {
439+ m.links.OnMergeRequestChanged(ctx, repo, number)
440+ }
433 return m.withHead(ctx, mr)441 return m.withHead(ctx, mr)
434 }442 }
435 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)443 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
@@ -647,17 +655,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
647 // the head; both are zero when the target is gone or shares no history with655 // the head; both are zero when the target is gone or shares no history with
648 // the head. A merged or closed request keeps the base it stored for both.656 // the head. A merged or closed request keeps the base it stored for both.
649 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {657 func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
658+ return mrRange(ctx, m.git, mr)
659+}
660+
661+func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
650 if !mr.IsOpen() {662 if !mr.IsOpen() {
651 return mr.BaseID, mr.BaseID, nil663 return mr.BaseID, mr.BaseID, nil
652 }664 }
653 if mr.HeadID.IsZero() {665 if mr.HeadID.IsZero() {
654 return domain.ObjectID{}, domain.ObjectID{}, nil666 return domain.ObjectID{}, domain.ObjectID{}, nil
655 }667 }
656- tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)668+ tip, err := git.BranchHead(ctx, mr.Repo, mr.Target)
669+ if errors.Is(err, domain.ErrNotFound) {
670+ return domain.ObjectID{}, domain.ObjectID{}, nil
671+ }
657 if err != nil || tip.IsZero() {672 if err != nil || tip.IsZero() {
658 return domain.ObjectID{}, domain.ObjectID{}, err673 return domain.ObjectID{}, domain.ObjectID{}, err
659 }674 }
660- base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)675+ base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
661 if errors.Is(err, domain.ErrNotFound) {676 if errors.Is(err, domain.ErrNotFound) {
662 return tip, domain.ObjectID{}, nil677 return tip, domain.ObjectID{}, nil
663 }678 }
@@ -777,6 +792,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
777 m.bus.Publish(ctx, domain.CommentAdded{792 m.bus.Publish(ctx, domain.CommentAdded{
778 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,793 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
779 })794 })
795+ if m.links != nil {
796+ m.links.OnMergeRequestChanged(ctx, repo, number)
797+ }
780 return c, nil798 return c, nil
781 }799 }
782 800
@@ -912,6 +930,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
933+ if m.links != nil {
934+ m.links.OnMergeRequestSettled(ctx, repo, mr)
935+ }
915 if m.pulls != nil {936 if m.pulls != nil {
916 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)937 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917 }938 }
@@ -1168,6 +1189,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1168 // never returned. mr is the merged request, tip the old target and merged the1189 // never returned. mr is the merged request, tip the old target and merged the
1169 // new one.1190 // new one.
1170 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {1191 func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1192+ if m.links != nil {
1193+ m.links.OnMerged(ctx, p, repo, mr)
1194+ }
1171 name := repo.Name1195 name := repo.Name
1172 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}1196 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1173 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {1197 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
@@ -1193,6 +1217,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
1193 if m.mergeab != nil {1217 if m.mergeab != nil {
1194 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target1218 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1195 }1219 }
1220+ if m.links != nil {
1221+ m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved
1222+ }
1196 }1223 }
1197 1224
1198 // deleteSource deletes the source branch of a merged request if it is still1225 // deleteSource deletes the source branch of a merged request if it is still
@@ -1453,6 +1480,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
1453 } else {1480 } else {
1454 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1481 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1455 }1482 }
1483+ if m.links != nil {
1484+ m.links.OnMergeRequestChanged(ctx, repo, number)
1485+ }
1456 return c, nil1486 return c, nil
1457 }1487 }
1458 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)1488 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
M internal/usecase/pushes.go +7 -0
@@ -17,6 +17,7 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20+ links ports.IssueLinkTrigger
2021 mirror ports.MirrorTrigger // nil: no outbound mirror
2122 meta ports.RepoMetaStore
2223 repos *Repos
@@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4445 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
4546 }
4647
48+// WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49+func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50+
4751 // WithMirror makes Record notify outbound mirrors. Call before the first push.
4852 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
4953 u.mirror = t
@@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
265269 if u.mergeab != nil {
266270 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
267271 }
272+ if u.links != nil {
273+ u.links.OnBranchesMoved(ctx, name, updates)
274+ }
268275 u.bus.Publish(ctx, domain.PushEvent{
269276 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
270277 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
@@ -17,6 +17,7 @@ import (
17 17
18 // Pushes are the use cases around a Git push.18 // Pushes are the use cases around a Git push.
19 type Pushes struct {19 type Pushes struct {
20+ links ports.IssueLinkTrigger
20 mirror ports.MirrorTrigger // nil: no outbound mirror21 mirror ports.MirrorTrigger // nil: no outbound mirror
21 meta ports.RepoMetaStore22 meta ports.RepoMetaStore
22 repos *Repos23 repos *Repos
@@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
44 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}45 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
45 }46 }
46 47
48+// WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49+func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50+
47 // WithMirror makes Record notify outbound mirrors. Call before the first push.51 // WithMirror makes Record notify outbound mirrors. Call before the first push.
48 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {52 func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
49 u.mirror = t53 u.mirror = t
@@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
265 if u.mergeab != nil {269 if u.mergeab != nil {
266 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only270 u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
267 }271 }
272+ if u.links != nil {
273+ u.links.OnBranchesMoved(ctx, name, updates)
274+ }
268 u.bus.Publish(ctx, domain.PushEvent{275 u.bus.Publish(ctx, domain.PushEvent{
269 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,276 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
270 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,277 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M proto/ermite/storage/issue.proto +12 -0
@@ -36,6 +36,18 @@ message Issue {
3636 // True for an issue approved from the public form: its text comes from
3737 // an anonymous visitor and is rendered as untrusted.
3838 bool external = 9;
39+ int64 closed_by_merge_request = 10;
40+}
41+
42+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
43+message IssueLink {
44+ bool closes = 1;
45+}
46+
47+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
48+message MergeRequestReferences {
49+ repeated int64 issues = 1;
50+ repeated int64 closes = 2;
3951 }
4052
4153 // IssueComment is stored under the key
@@ -36,6 +36,18 @@ message Issue {
36 // True for an issue approved from the public form: its text comes from36 // True for an issue approved from the public form: its text comes from
37 // an anonymous visitor and is rendered as untrusted.37 // an anonymous visitor and is rendered as untrusted.
38 bool external = 9;38 bool external = 9;
39+ int64 closed_by_merge_request = 10;
40+}
41+
42+// IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>.
43+message IssueLink {
44+ bool closes = 1;
45+}
46+
47+// MergeRequestReferences is stored under mrref/<repo>/<mr:010d>.
48+message MergeRequestReferences {
49+ repeated int64 issues = 1;
50+ repeated int64 closes = 2;
39 }51 }
40 52
41 // IssueComment is stored under the key53 // IssueComment is stored under the key

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close