detached 4bec9a0

#18 feat(issues): link issues and merge requests

state
merged · 4bec9a0 · fast-forward
branch
feat/issue-mr-links → main
opened
bnema ·
updated

Issue ↔ merge request links.

  • Closing: close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved (optional ':'), lists like 'fixes #3, #4 and #5', in the MR title, description or commit messages close the issue when the MR is merged (never at creation or on close). The issue shows 'closed by !M'; a manual reopen clears it. Issues off: nothing is closed.
  • Back-references: any #N naming an issue in the MR title, description, notes or commit messages lists the MR on the issue (web rail section, API references, 'ermite issue show' text and JSON). Links are recomputed per MR (create, ready, notes, push, merge, moved branches), so removing a mention removes it. Limits: 100 most recent commits, 16 KiB per text, existing issues only.
  • Markdown: #N in issue and MR text links to the issue or MR it names.
  • Live: issue pages get a reload notice when a link is added or removed.
  • Storage: isref/ and mrref/ keys, Issue.closed_by_merge_request; repo deletion cascades.

Depends on ermite-api!7. go.mod holds a temporary local replace for bnema.dev/ermite-api, so CI fails until ermite-api v0.13.0 is tagged and the replace is swapped for the version.

CommitSubjectAuthorDateSignatureStatus
4bec9a0refactor(web): render the issue body and its merge request links in their own helpersbriceGPG Verified
804d55bMerge branch 'main' into feat/issue-mr-linksbriceGPG Verified
8782b5abuild(deps): require bnema.dev/ermite-api v0.13.0 for issue referencesbriceGPG Verified
b58599afix(issues): serialize issue link reindexing, keep truncated references whole and refresh links after mergesbriceGPG Verified
29c90bbMerge branch 'feat/issue-mr-links-api' into feat/issue-mr-linksbriceGPG Verified
7dac988feat(issues): show merge request references and closing merge request in the API and CLIbriceGPG Verified
6859f89feat(web): link #N references in Markdown and show merge request links on issue pagesbriceGPG Verified
1386983feat(issues): index merge request mentions of issues and close linked issues on mergebriceGPG Verified
8a27537feat(issues): parse issue references and store issue to merge request linksbriceGPG Verified

Checks 8

48 files · +2515 -179
7/8
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
@@ -1,7 +1,7 @@
11 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
22 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.12.0 h1:lC2PzxYVkN2vZ9QKLUEJ125KhLmA7rgPvz+bqz0GD8Y=
4-bnema.dev/ermite-api v0.12.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
3+bnema.dev/ermite-api v0.13.0 h1:lw0XCunkMTdBRzVSnopc0NQSi5cMGlZC9nPDAmy4YRk=
4+bnema.dev/ermite-api v0.13.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
55 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
66 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
77 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
@@ -1,7 +1,7 @@
1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.12.0 h1:lC2PzxYVkN2vZ9QKLUEJ125KhLmA7rgPvz+bqz0GD8Y=3+bnema.dev/ermite-api v0.13.0 h1:lw0XCunkMTdBRzVSnopc0NQSi5cMGlZC9nPDAmy4YRk=
4-bnema.dev/ermite-api v0.12.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=4+bnema.dev/ermite-api v0.13.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
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
M internal/app/cli_issue_e2e_test.go +7 -2
M internal/app/cli_issue_test.go +36 -1
M internal/app/forge.go +4 -2
M internal/app/issue_api_test.go +52 -0
M internal/domain/event.go +12 -0
M internal/domain/issue.go +8 -0
A internal/domain/reference.go +112 -0
A internal/domain/reference_test.go +78 -0
M internal/ports/dto.go +12 -2
A internal/ports/mocks/IssueLinkStore.go +296 -0
A internal/ports/mocks/IssueLinkTrigger.go +262 -0
M internal/ports/ports.go +20 -0
M internal/ports/services.go +2 -1
M internal/usecase/events.go +7 -1
M internal/usecase/events_test.go +24 -0
A internal/usecase/issue_links.go +286 -0
A internal/usecase/issue_links_test.go +235 -0
M internal/usecase/issues.go +35 -9
M internal/usecase/issues_test.go +4 -2
M internal/usecase/mergerequests.go +33 -3
M internal/usecase/pushes.go +7 -0
M proto/ermite/storage/issue.proto +12 -0

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 3

Tags 41

↑↓ moveenter openesc close