merge: main with issue and merge request links into feat/auto-merge
46 files · +2512 -176
M docs/cli.md +1 -0
| @@ -61,6 +61,7 @@ ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--del | |||
| 61 | 61 | ||
| 62 | 62 | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | |
| 63 | 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 | 66 | ermite run list <repo> [--workflow …] [--branch …] [--status …] | |
| 66 | 67 | 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 | |||
| 34 | 34 | ||
| 35 | 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`), `'` 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 | 49 | ## Inbox | |
| 38 | 50 | ||
| 39 | 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. | |
| @@ -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`), `'` 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 | ## Inbox | 49 | ## 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 | |||
| 8 | 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 | 13 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 12 | 14 | merge request web URL on its own line. With `--json`, merge request objects | |
| 13 | 15 | (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 full | 13 | `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 objects | 14 | 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
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) { | |||
| 72 | 72 | func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 73 | 73 | var keys [][]byte | |
| 74 | 74 | for _, p := range []string{ | |
| 75 | - | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), | |
| 75 | + | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/", | |
| 76 | 76 | } { | |
| 77 | 77 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 78 | 78 | keys = append(keys, slices.Clone(k)) | |
| @@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||
| 312 | 312 | return nil, err | |
| 313 | 313 | } | |
| 314 | 314 | return mustMarshal(&storagepb.Issue{ | |
| 315 | - | Number: issue.Number, | |
| 316 | - | Title: issue.Title.String(), | |
| 317 | - | Body: issue.Body.String(), | |
| 318 | - | State: state, | |
| 319 | - | Author: issue.Author, | |
| 320 | - | CreatedAt: encodeTime(issue.CreatedAt), | |
| 321 | - | UpdatedAt: encodeTime(issue.UpdatedAt), | |
| 322 | - | CommentSeq: issue.CommentSeq, | |
| 323 | - | External: issue.External, | |
| 315 | + | Number: issue.Number, | |
| 316 | + | Title: issue.Title.String(), | |
| 317 | + | Body: issue.Body.String(), | |
| 318 | + | State: state, | |
| 319 | + | Author: issue.Author, | |
| 320 | + | CreatedAt: encodeTime(issue.CreatedAt), | |
| 321 | + | UpdatedAt: encodeTime(issue.UpdatedAt), | |
| 322 | + | CommentSeq: issue.CommentSeq, | |
| 323 | + | External: issue.External, | |
| 324 | + | ClosedByMergeRequest: issue.ClosedByMergeRequest, | |
| 324 | 325 | }), nil | |
| 325 | 326 | } | |
| 326 | 327 | ||
| @@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |||
| 348 | 349 | Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state, | |
| 349 | 350 | Author: pb.GetAuthor(), | |
| 350 | 351 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |
| 351 | - | CommentSeq: pb.GetCommentSeq(), | |
| 352 | - | External: pb.GetExternal(), | |
| 353 | - | Version: item.Version, | |
| 352 | + | CommentSeq: pb.GetCommentSeq(), | |
| 353 | + | External: pb.GetExternal(), | |
| 354 | + | ClosedByMergeRequest: pb.GetClosedByMergeRequest(), | |
| 355 | + | Version: item.Version, | |
| 354 | 356 | }, nil | |
| 355 | 357 | } | |
| 356 | 358 | ||
| @@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) { | |||||
| 72 | func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | 72 | func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | ||
| 73 | var keys [][]byte | 73 | var keys [][]byte | ||
| 74 | for _, p := range []string{ | 74 | for _, p := range []string{ | ||
| 75 | - | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), | 75 | + | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/", |
| 76 | } { | 76 | } { | ||
| 77 | for k := range snap.Prefix(ctx, []byte(p)) { | 77 | for k := range snap.Prefix(ctx, []byte(p)) { | ||
| 78 | keys = append(keys, slices.Clone(k)) | 78 | keys = append(keys, slices.Clone(k)) | ||
| @@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||||
| 312 | return nil, err | 312 | return nil, err | ||
| 313 | } | 313 | } | ||
| 314 | return mustMarshal(&storagepb.Issue{ | 314 | return mustMarshal(&storagepb.Issue{ | ||
| 315 | - | Number: issue.Number, | 315 | + | Number: issue.Number, |
| 316 | - | Title: issue.Title.String(), | 316 | + | Title: issue.Title.String(), |
| 317 | - | Body: issue.Body.String(), | 317 | + | Body: issue.Body.String(), |
| 318 | - | State: state, | 318 | + | State: state, |
| 319 | - | Author: issue.Author, | 319 | + | Author: issue.Author, |
| 320 | - | CreatedAt: encodeTime(issue.CreatedAt), | 320 | + | CreatedAt: encodeTime(issue.CreatedAt), |
| 321 | - | UpdatedAt: encodeTime(issue.UpdatedAt), | 321 | + | UpdatedAt: encodeTime(issue.UpdatedAt), |
| 322 | - | CommentSeq: issue.CommentSeq, | 322 | + | CommentSeq: issue.CommentSeq, |
| 323 | - | External: issue.External, | 323 | + | External: issue.External, |
| 324 | + | ClosedByMergeRequest: issue.ClosedByMergeRequest, | |||
| 324 | }), nil | 325 | }), nil | ||
| 325 | } | 326 | } | ||
| 326 | 327 | ||||
| @@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |||||
| 348 | Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state, | 349 | Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state, | ||
| 349 | Author: pb.GetAuthor(), | 350 | Author: pb.GetAuthor(), | ||
| 350 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | 351 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | ||
| 351 | - | CommentSeq: pb.GetCommentSeq(), | 352 | + | CommentSeq: pb.GetCommentSeq(), |
| 352 | - | External: pb.GetExternal(), | 353 | + | External: pb.GetExternal(), |
| 353 | - | Version: item.Version, | 354 | + | ClosedByMergeRequest: pb.GetClosedByMergeRequest(), |
| 355 | + | Version: item.Version, | |||
| 354 | }, nil | 356 | }, nil | ||
| 355 | } | 357 | } | ||
| 356 | 358 | ||||
M internal/adapters/kvstore/mergerequests.go +1 -1
| @@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa | |||
| 181 | 181 | var keys [][]byte | |
| 182 | 182 | for _, p := range []string{ | |
| 183 | 183 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | |
| 184 | - | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", | |
| 184 | + | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/", | |
| 185 | 185 | mergePrefix + repo.String() + "/", | |
| 186 | 186 | } { | |
| 187 | 187 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| @@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa | |||||
| 181 | var keys [][]byte | 181 | var keys [][]byte | ||
| 182 | for _, p := range []string{ | 182 | for _, p := range []string{ | ||
| 183 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | 183 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | ||
| 184 | - | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", | 184 | + | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/", |
| 185 | mergePrefix + repo.String() + "/", | 185 | mergePrefix + repo.String() + "/", | ||
| 186 | } { | 186 | } { | ||
| 187 | for k := range snap.Prefix(ctx, []byte(p)) { | 187 | for k := range snap.Prefix(ctx, []byte(p)) { | ||
M internal/adapters/rpc/api.go +1 -1
| @@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 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", "merge_requests.auto_merge"} | |
| 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", "merge_requests.auto_merge"} | |
| 39 | 39 | ||
| 40 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // 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 top | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 36 | // 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", "merge_requests.auto_merge"} | 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", "merge_requests.auto_merge"} |
| 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
| @@ -536,8 +536,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 536 | 536 | out.Time = timestamp(ev.At) | |
| 537 | 537 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | |
| 538 | 538 | default: | |
| 539 | - | // Web-only events, such as domain.IssuePendingRemoved (the API has | |
| 540 | - | // no message for it; watchers list pending issues on demand). | |
| 539 | + | // Web-only events, such as domain.IssueReferenced and | |
| 540 | + | // domain.IssuePendingRemoved: the API has no messages for them. | |
| 541 | 541 | return nil | |
| 542 | 542 | } | |
| 543 | 543 | return out | |
| @@ -536,8 +536,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||||
| 536 | out.Time = timestamp(ev.At) | 536 | out.Time = timestamp(ev.At) | ||
| 537 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | 537 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | ||
| 538 | default: | 538 | default: | ||
| 539 | - | // Web-only events, such as domain.IssuePendingRemoved (the API has | 539 | + | // Web-only events, such as domain.IssueReferenced and |
| 540 | - | // no message for it; watchers list pending issues on demand). | 540 | + | // domain.IssuePendingRemoved: the API has no messages for them. |
| 541 | return nil | 541 | return nil | ||
| 542 | } | 542 | } | ||
| 543 | return out | 543 | return out | ||
M internal/adapters/rpc/issue.go +19 -9
| @@ -70,15 +70,16 @@ func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) { | |||
| 70 | 70 | ||
| 71 | 71 | func issueToPB(i domain.Issue) *pb.Issue { | |
| 72 | 72 | 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, | |
| 82 | 83 | } | |
| 83 | 84 | } | |
| 84 | 85 | ||
| @@ -138,6 +139,15 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get | |||
| 138 | 139 | for _, c := range d.Comments { | |
| 139 | 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 | 151 | return connect.NewResponse(out), nil | |
| 142 | 152 | } | |
| 143 | 153 | ||
| @@ -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.MaxCommentsPerIssue | 81 | + | 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), nil | 151 | 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 { | |||
| 63 | 63 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 64 | 64 | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 65 | 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 | 67 | return true | |
| 68 | 68 | } | |
| 69 | 69 | return false | |
| @@ -214,6 +214,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 214 | 214 | return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload") | |
| 215 | 215 | case domain.IssueCommented: | |
| 216 | 216 | return l.issueNotice(ctx, ev.Number, "a comment was added — reload") | |
| 217 | + | case domain.IssueReferenced: | |
| 218 | + | return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload") | |
| 217 | 219 | case domain.PushEvent: | |
| 218 | 220 | switch l.view { | |
| 219 | 221 | 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.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | 64 | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, 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 true | 67 | return true | ||
| 68 | } | 68 | } | ||
| 69 | return false | 69 | return false | ||
| @@ -214,6 +214,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||||
| 214 | return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload") | 214 | return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload") | ||
| 215 | case domain.IssueCommented: | 215 | case domain.IssueCommented: | ||
| 216 | return l.issueNotice(ctx, ev.Number, "a comment was added — reload") | 216 | return l.issueNotice(ctx, ev.Number, "a comment was added — reload") | ||
| 217 | + | case domain.IssueReferenced: | |||
| 218 | + | return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload") | |||
| 217 | case domain.PushEvent: | 219 | case domain.PushEvent: | ||
| 218 | switch l.view { | 220 | switch l.view { | ||
| 219 | case "mr": | 221 | case "mr": | ||
M internal/adapters/web/issues.go +90 -13
| @@ -83,6 +83,58 @@ type IssuePage struct { | |||
| 83 | 83 | Form *CommentForm | |
| 84 | 84 | Transition *IssueActions | |
| 85 | 85 | 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 | 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 | 290 | Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()), | |
| 239 | 291 | State: string(i.State), Open: i.IsOpen(), | |
| 240 | 292 | } | |
| 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 | |
| 254 | 295 | } | |
| 296 | + | p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest) | |
| 297 | + | p.References = issueRefViews(d.name(), res.References) | |
| 255 | 298 | p.Opened.Name = s.who(i.Author) | |
| 256 | 299 | if i.External { | |
| 257 | 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 | 338 | 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 | 375 | // pageOrRedirect is page(), except that a page that is not found may be the | |
| 299 | 376 | // page of the other kind of the number: target then names where to send the | |
| 300 | 377 | // visitor ("" when there is none). | |
| @@ -83,6 +83,58 @@ type IssuePage struct { | |||||
| 83 | Form *CommentForm | 83 | Form *CommentForm | ||
| 84 | Transition *IssueActions | 84 | Transition *IssueActions | ||
| 85 | Live bool | 85 | 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.HTML | 294 | + | 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, nil | 338 | 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 the | 375 | // 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 the | 376 | // 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) { | |||
| 149 | 149 | { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time> | |
| 150 | 150 | } | |
| 151 | 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 | 156 | if p.UpdatedAt != "" { | |
| 153 | 157 | <dt>updated</dt> | |
| 154 | 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 | 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 | 202 | if p.Transition != nil { | |
| 182 | 203 | <section aria-labelledby="r-act"> | |
| 183 | 204 | <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
| @@ -712,17 +712,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 712 | 712 | if templ_7745c5c3_Err != nil { | |
| 713 | 713 | return templ_7745c5c3_Err | |
| 714 | 714 | } | |
| 715 | - | if p.UpdatedAt != "" { | |
| 716 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>updated</dt><dd><time datetime=\"") | |
| 715 | + | if p.ClosedBy != nil { | |
| 716 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>closed by</dt><dd><a class=\"ref\" href=\"") | |
| 717 | 717 | if templ_7745c5c3_Err != nil { | |
| 718 | 718 | return templ_7745c5c3_Err | |
| 719 | 719 | } | |
| 720 | - | var templ_7745c5c3_Var36 string | |
| 721 | - | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt) | |
| 720 | + | var templ_7745c5c3_Var36 templ.SafeURL | |
| 721 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.ClosedBy.Href)) | |
| 722 | 722 | if templ_7745c5c3_Err != nil { | |
| 723 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 38} | |
| 723 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 62} | |
| 724 | 724 | } | |
| 725 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) | |
| 725 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 726 | 726 | if templ_7745c5c3_Err != nil { | |
| 727 | 727 | return templ_7745c5c3_Err | |
| 728 | 728 | } | |
| @@ -731,25 +731,57 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 731 | 731 | return templ_7745c5c3_Err | |
| 732 | 732 | } | |
| 733 | 733 | var templ_7745c5c3_Var37 string | |
| 734 | - | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated) | |
| 734 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.ClosedBy.Label) | |
| 735 | 735 | if templ_7745c5c3_Err != nil { | |
| 736 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 52} | |
| 736 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 83} | |
| 737 | 737 | } | |
| 738 | 738 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 739 | 739 | if templ_7745c5c3_Err != nil { | |
| 740 | 740 | return templ_7745c5c3_Err | |
| 741 | 741 | } | |
| 742 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time></dd>") | |
| 742 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</a></dd>") | |
| 743 | + | if templ_7745c5c3_Err != nil { | |
| 744 | + | return templ_7745c5c3_Err | |
| 745 | + | } | |
| 746 | + | } | |
| 747 | + | if p.UpdatedAt != "" { | |
| 748 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<dt>updated</dt><dd><time datetime=\"") | |
| 749 | + | if templ_7745c5c3_Err != nil { | |
| 750 | + | return templ_7745c5c3_Err | |
| 751 | + | } | |
| 752 | + | var templ_7745c5c3_Var38 string | |
| 753 | + | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt) | |
| 754 | + | if templ_7745c5c3_Err != nil { | |
| 755 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 38} | |
| 756 | + | } | |
| 757 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) | |
| 758 | + | if templ_7745c5c3_Err != nil { | |
| 759 | + | return templ_7745c5c3_Err | |
| 760 | + | } | |
| 761 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">") | |
| 762 | + | if templ_7745c5c3_Err != nil { | |
| 763 | + | return templ_7745c5c3_Err | |
| 764 | + | } | |
| 765 | + | var templ_7745c5c3_Var39 string | |
| 766 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated) | |
| 767 | + | if templ_7745c5c3_Err != nil { | |
| 768 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 52} | |
| 769 | + | } | |
| 770 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 771 | + | if templ_7745c5c3_Err != nil { | |
| 772 | + | return templ_7745c5c3_Err | |
| 773 | + | } | |
| 774 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</time></dd>") | |
| 743 | 775 | if templ_7745c5c3_Err != nil { | |
| 744 | 776 | return templ_7745c5c3_Err | |
| 745 | 777 | } | |
| 746 | 778 | } | |
| 747 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</dl></header>") | |
| 779 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dl></header>") | |
| 748 | 780 | if templ_7745c5c3_Err != nil { | |
| 749 | 781 | return templ_7745c5c3_Err | |
| 750 | 782 | } | |
| 751 | 783 | if p.Desc != "" { | |
| 752 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>") | |
| 784 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>") | |
| 753 | 785 | if templ_7745c5c3_Err != nil { | |
| 754 | 786 | return templ_7745c5c3_Err | |
| 755 | 787 | } | |
| @@ -757,12 +789,12 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 757 | 789 | if templ_7745c5c3_Err != nil { | |
| 758 | 790 | return templ_7745c5c3_Err | |
| 759 | 791 | } | |
| 760 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</article>") | |
| 792 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>") | |
| 761 | 793 | if templ_7745c5c3_Err != nil { | |
| 762 | 794 | return templ_7745c5c3_Err | |
| 763 | 795 | } | |
| 764 | 796 | } | |
| 765 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</section>") | |
| 797 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</section>") | |
| 766 | 798 | if templ_7745c5c3_Err != nil { | |
| 767 | 799 | return templ_7745c5c3_Err | |
| 768 | 800 | } | |
| @@ -772,20 +804,20 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 772 | 804 | return templ_7745c5c3_Err | |
| 773 | 805 | } | |
| 774 | 806 | } | |
| 775 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">") | |
| 807 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">") | |
| 776 | 808 | if templ_7745c5c3_Err != nil { | |
| 777 | 809 | return templ_7745c5c3_Err | |
| 778 | 810 | } | |
| 779 | - | var templ_7745c5c3_Var38 string | |
| 780 | - | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments))) | |
| 811 | + | var templ_7745c5c3_Var40 string | |
| 812 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments))) | |
| 781 | 813 | if templ_7745c5c3_Err != nil { | |
| 782 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 167, Col: 99} | |
| 814 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 171, Col: 99} | |
| 783 | 815 | } | |
| 784 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 816 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 785 | 817 | if templ_7745c5c3_Err != nil { | |
| 786 | 818 | return templ_7745c5c3_Err | |
| 787 | 819 | } | |
| 788 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></h2>") | |
| 820 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span></h2>") | |
| 789 | 821 | if templ_7745c5c3_Err != nil { | |
| 790 | 822 | return templ_7745c5c3_Err | |
| 791 | 823 | } | |
| @@ -795,17 +827,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 795 | 827 | return templ_7745c5c3_Err | |
| 796 | 828 | } | |
| 797 | 829 | } | |
| 798 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ") | |
| 830 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " ") | |
| 799 | 831 | if templ_7745c5c3_Err != nil { | |
| 800 | 832 | return templ_7745c5c3_Err | |
| 801 | 833 | } | |
| 802 | 834 | if len(p.Comments) == 0 { | |
| 803 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"dim\">no comments yet.</p>") | |
| 835 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<p class=\"dim\">no comments yet.</p>") | |
| 804 | 836 | if templ_7745c5c3_Err != nil { | |
| 805 | 837 | return templ_7745c5c3_Err | |
| 806 | 838 | } | |
| 807 | 839 | } | |
| 808 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ") | |
| 840 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ") | |
| 809 | 841 | if templ_7745c5c3_Err != nil { | |
| 810 | 842 | return templ_7745c5c3_Err | |
| 811 | 843 | } | |
| @@ -841,39 +873,135 @@ func issueRail(p IssuePage) templ.Component { | |||
| 841 | 873 | }() | |
| 842 | 874 | } | |
| 843 | 875 | ctx = templ.InitializeContext(ctx) | |
| 844 | - | templ_7745c5c3_Var39 := templ.GetChildren(ctx) | |
| 845 | - | if templ_7745c5c3_Var39 == nil { | |
| 846 | - | templ_7745c5c3_Var39 = templ.NopComponent | |
| 876 | + | templ_7745c5c3_Var41 := templ.GetChildren(ctx) | |
| 877 | + | if templ_7745c5c3_Var41 == nil { | |
| 878 | + | templ_7745c5c3_Var41 = templ.NopComponent | |
| 847 | 879 | } | |
| 848 | 880 | ctx = templ.ClearChildren(ctx) | |
| 881 | + | if len(p.References) > 0 { | |
| 882 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Merge requests</h2><ul>") | |
| 883 | + | if templ_7745c5c3_Err != nil { | |
| 884 | + | return templ_7745c5c3_Err | |
| 885 | + | } | |
| 886 | + | for _, r := range p.References { | |
| 887 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li><a class=\"ref\" href=\"") | |
| 888 | + | if templ_7745c5c3_Err != nil { | |
| 889 | + | return templ_7745c5c3_Err | |
| 890 | + | } | |
| 891 | + | var templ_7745c5c3_Var42 templ.SafeURL | |
| 892 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 893 | + | if templ_7745c5c3_Err != nil { | |
| 894 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 49} | |
| 895 | + | } | |
| 896 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 897 | + | if templ_7745c5c3_Err != nil { | |
| 898 | + | return templ_7745c5c3_Err | |
| 899 | + | } | |
| 900 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">") | |
| 901 | + | if templ_7745c5c3_Err != nil { | |
| 902 | + | return templ_7745c5c3_Err | |
| 903 | + | } | |
| 904 | + | var templ_7745c5c3_Var43 string | |
| 905 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number) | |
| 906 | + | if templ_7745c5c3_Err != nil { | |
| 907 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 62} | |
| 908 | + | } | |
| 909 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 910 | + | if templ_7745c5c3_Err != nil { | |
| 911 | + | return templ_7745c5c3_Err | |
| 912 | + | } | |
| 913 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</a> ") | |
| 914 | + | if templ_7745c5c3_Err != nil { | |
| 915 | + | return templ_7745c5c3_Err | |
| 916 | + | } | |
| 917 | + | var templ_7745c5c3_Var44 string | |
| 918 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(" " + r.Title + " ") | |
| 919 | + | if templ_7745c5c3_Err != nil { | |
| 920 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 192, Col: 27} | |
| 921 | + | } | |
| 922 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 923 | + | if templ_7745c5c3_Err != nil { | |
| 924 | + | return templ_7745c5c3_Err | |
| 925 | + | } | |
| 926 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " <span class=\"mr-state\" data-state=\"") | |
| 927 | + | if templ_7745c5c3_Err != nil { | |
| 928 | + | return templ_7745c5c3_Err | |
| 929 | + | } | |
| 930 | + | var templ_7745c5c3_Var45 string | |
| 931 | + | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.State) | |
| 932 | + | if templ_7745c5c3_Err != nil { | |
| 933 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 49} | |
| 934 | + | } | |
| 935 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) | |
| 936 | + | if templ_7745c5c3_Err != nil { | |
| 937 | + | return templ_7745c5c3_Err | |
| 938 | + | } | |
| 939 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">") | |
| 940 | + | if templ_7745c5c3_Err != nil { | |
| 941 | + | return templ_7745c5c3_Err | |
| 942 | + | } | |
| 943 | + | var templ_7745c5c3_Var46 string | |
| 944 | + | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |
| 945 | + | if templ_7745c5c3_Err != nil { | |
| 946 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 61} | |
| 947 | + | } | |
| 948 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |
| 949 | + | if templ_7745c5c3_Err != nil { | |
| 950 | + | return templ_7745c5c3_Err | |
| 951 | + | } | |
| 952 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> ") | |
| 953 | + | if templ_7745c5c3_Err != nil { | |
| 954 | + | return templ_7745c5c3_Err | |
| 955 | + | } | |
| 956 | + | if r.Closes { | |
| 957 | + | var templ_7745c5c3_Var47 string | |
| 958 | + | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · closes") | |
| 959 | + | if templ_7745c5c3_Err != nil { | |
| 960 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 195, Col: 21} | |
| 961 | + | } | |
| 962 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |
| 963 | + | if templ_7745c5c3_Err != nil { | |
| 964 | + | return templ_7745c5c3_Err | |
| 965 | + | } | |
| 966 | + | } | |
| 967 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</li>") | |
| 968 | + | if templ_7745c5c3_Err != nil { | |
| 969 | + | return templ_7745c5c3_Err | |
| 970 | + | } | |
| 971 | + | } | |
| 972 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></section>") | |
| 973 | + | if templ_7745c5c3_Err != nil { | |
| 974 | + | return templ_7745c5c3_Err | |
| 975 | + | } | |
| 976 | + | } | |
| 849 | 977 | if p.Transition != nil { | |
| 850 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"") | |
| 978 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"") | |
| 851 | 979 | if templ_7745c5c3_Err != nil { | |
| 852 | 980 | return templ_7745c5c3_Err | |
| 853 | 981 | } | |
| 854 | - | var templ_7745c5c3_Var40 templ.SafeURL | |
| 855 | - | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action)) | |
| 982 | + | var templ_7745c5c3_Var48 templ.SafeURL | |
| 983 | + | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action)) | |
| 856 | 984 | if templ_7745c5c3_Err != nil { | |
| 857 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 184, Col: 66} | |
| 985 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 205, Col: 66} | |
| 858 | 986 | } | |
| 859 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 987 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 860 | 988 | if templ_7745c5c3_Err != nil { | |
| 861 | 989 | return templ_7745c5c3_Err | |
| 862 | 990 | } | |
| 863 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"><button type=\"submit\" class=\"btn\">") | |
| 991 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"><button type=\"submit\" class=\"btn\">") | |
| 864 | 992 | if templ_7745c5c3_Err != nil { | |
| 865 | 993 | return templ_7745c5c3_Err | |
| 866 | 994 | } | |
| 867 | - | var templ_7745c5c3_Var41 string | |
| 868 | - | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label) | |
| 995 | + | var templ_7745c5c3_Var49 string | |
| 996 | + | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label) | |
| 869 | 997 | if templ_7745c5c3_Err != nil { | |
| 870 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 185, Col: 58} | |
| 998 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 206, Col: 58} | |
| 871 | 999 | } | |
| 872 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 1000 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 873 | 1001 | if templ_7745c5c3_Err != nil { | |
| 874 | 1002 | return templ_7745c5c3_Err | |
| 875 | 1003 | } | |
| 876 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</button></form></section>") | |
| 1004 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></form></section>") | |
| 877 | 1005 | if templ_7745c5c3_Err != nil { | |
| 878 | 1006 | return templ_7745c5c3_Err | |
| 879 | 1007 | } | |
| @@ -712,17 +712,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 712 | if templ_7745c5c3_Err != nil { | 712 | if templ_7745c5c3_Err != nil { | ||
| 713 | return templ_7745c5c3_Err | 713 | return templ_7745c5c3_Err | ||
| 714 | } | 714 | } | ||
| 715 | - | if p.UpdatedAt != "" { | 715 | + | if p.ClosedBy != nil { |
| 716 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>updated</dt><dd><time datetime=\"") | 716 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>closed by</dt><dd><a class=\"ref\" href=\"") |
| 717 | if templ_7745c5c3_Err != nil { | 717 | if templ_7745c5c3_Err != nil { | ||
| 718 | return templ_7745c5c3_Err | 718 | return templ_7745c5c3_Err | ||
| 719 | } | 719 | } | ||
| 720 | - | var templ_7745c5c3_Var36 string | 720 | + | var templ_7745c5c3_Var36 templ.SafeURL |
| 721 | - | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt) | 721 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.ClosedBy.Href)) |
| 722 | if templ_7745c5c3_Err != nil { | 722 | if templ_7745c5c3_Err != nil { | ||
| 723 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 38} | 723 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 62} |
| 724 | } | 724 | } | ||
| 725 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) | 725 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) |
| 726 | if templ_7745c5c3_Err != nil { | 726 | if templ_7745c5c3_Err != nil { | ||
| 727 | return templ_7745c5c3_Err | 727 | return templ_7745c5c3_Err | ||
| 728 | } | 728 | } | ||
| @@ -731,25 +731,57 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 731 | return templ_7745c5c3_Err | 731 | return templ_7745c5c3_Err | ||
| 732 | } | 732 | } | ||
| 733 | var templ_7745c5c3_Var37 string | 733 | var templ_7745c5c3_Var37 string | ||
| 734 | - | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated) | 734 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.ClosedBy.Label) |
| 735 | if templ_7745c5c3_Err != nil { | 735 | if templ_7745c5c3_Err != nil { | ||
| 736 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 52} | 736 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 83} |
| 737 | } | 737 | } | ||
| 738 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | 738 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | ||
| 739 | if templ_7745c5c3_Err != nil { | 739 | if templ_7745c5c3_Err != nil { | ||
| 740 | return templ_7745c5c3_Err | 740 | return templ_7745c5c3_Err | ||
| 741 | } | 741 | } | ||
| 742 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time></dd>") | 742 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</a></dd>") |
| 743 | + | if templ_7745c5c3_Err != nil { | |||
| 744 | + | return templ_7745c5c3_Err | |||
| 745 | + | } | |||
| 746 | + | } | |||
| 747 | + | if p.UpdatedAt != "" { | |||
| 748 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<dt>updated</dt><dd><time datetime=\"") | |||
| 749 | + | if templ_7745c5c3_Err != nil { | |||
| 750 | + | return templ_7745c5c3_Err | |||
| 751 | + | } | |||
| 752 | + | var templ_7745c5c3_Var38 string | |||
| 753 | + | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt) | |||
| 754 | + | if templ_7745c5c3_Err != nil { | |||
| 755 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 38} | |||
| 756 | + | } | |||
| 757 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) | |||
| 758 | + | if templ_7745c5c3_Err != nil { | |||
| 759 | + | return templ_7745c5c3_Err | |||
| 760 | + | } | |||
| 761 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">") | |||
| 762 | + | if templ_7745c5c3_Err != nil { | |||
| 763 | + | return templ_7745c5c3_Err | |||
| 764 | + | } | |||
| 765 | + | var templ_7745c5c3_Var39 string | |||
| 766 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated) | |||
| 767 | + | if templ_7745c5c3_Err != nil { | |||
| 768 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 52} | |||
| 769 | + | } | |||
| 770 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |||
| 771 | + | if templ_7745c5c3_Err != nil { | |||
| 772 | + | return templ_7745c5c3_Err | |||
| 773 | + | } | |||
| 774 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</time></dd>") | |||
| 743 | if templ_7745c5c3_Err != nil { | 775 | if templ_7745c5c3_Err != nil { | ||
| 744 | return templ_7745c5c3_Err | 776 | return templ_7745c5c3_Err | ||
| 745 | } | 777 | } | ||
| 746 | } | 778 | } | ||
| 747 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</dl></header>") | 779 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dl></header>") |
| 748 | if templ_7745c5c3_Err != nil { | 780 | if templ_7745c5c3_Err != nil { | ||
| 749 | return templ_7745c5c3_Err | 781 | return templ_7745c5c3_Err | ||
| 750 | } | 782 | } | ||
| 751 | if p.Desc != "" { | 783 | if p.Desc != "" { | ||
| 752 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>") | 784 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>") |
| 753 | if templ_7745c5c3_Err != nil { | 785 | if templ_7745c5c3_Err != nil { | ||
| 754 | return templ_7745c5c3_Err | 786 | return templ_7745c5c3_Err | ||
| 755 | } | 787 | } | ||
| @@ -757,12 +789,12 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 757 | if templ_7745c5c3_Err != nil { | 789 | if templ_7745c5c3_Err != nil { | ||
| 758 | return templ_7745c5c3_Err | 790 | return templ_7745c5c3_Err | ||
| 759 | } | 791 | } | ||
| 760 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</article>") | 792 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>") |
| 761 | if templ_7745c5c3_Err != nil { | 793 | if templ_7745c5c3_Err != nil { | ||
| 762 | return templ_7745c5c3_Err | 794 | return templ_7745c5c3_Err | ||
| 763 | } | 795 | } | ||
| 764 | } | 796 | } | ||
| 765 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</section>") | 797 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</section>") |
| 766 | if templ_7745c5c3_Err != nil { | 798 | if templ_7745c5c3_Err != nil { | ||
| 767 | return templ_7745c5c3_Err | 799 | return templ_7745c5c3_Err | ||
| 768 | } | 800 | } | ||
| @@ -772,20 +804,20 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 772 | return templ_7745c5c3_Err | 804 | return templ_7745c5c3_Err | ||
| 773 | } | 805 | } | ||
| 774 | } | 806 | } | ||
| 775 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">") | 807 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">") |
| 776 | if templ_7745c5c3_Err != nil { | 808 | if templ_7745c5c3_Err != nil { | ||
| 777 | return templ_7745c5c3_Err | 809 | return templ_7745c5c3_Err | ||
| 778 | } | 810 | } | ||
| 779 | - | var templ_7745c5c3_Var38 string | 811 | + | var templ_7745c5c3_Var40 string |
| 780 | - | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments))) | 812 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments))) |
| 781 | if templ_7745c5c3_Err != nil { | 813 | if templ_7745c5c3_Err != nil { | ||
| 782 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 167, Col: 99} | 814 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 171, Col: 99} |
| 783 | } | 815 | } | ||
| 784 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | 816 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) |
| 785 | if templ_7745c5c3_Err != nil { | 817 | if templ_7745c5c3_Err != nil { | ||
| 786 | return templ_7745c5c3_Err | 818 | return templ_7745c5c3_Err | ||
| 787 | } | 819 | } | ||
| 788 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></h2>") | 820 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span></h2>") |
| 789 | if templ_7745c5c3_Err != nil { | 821 | if templ_7745c5c3_Err != nil { | ||
| 790 | return templ_7745c5c3_Err | 822 | return templ_7745c5c3_Err | ||
| 791 | } | 823 | } | ||
| @@ -795,17 +827,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 795 | return templ_7745c5c3_Err | 827 | return templ_7745c5c3_Err | ||
| 796 | } | 828 | } | ||
| 797 | } | 829 | } | ||
| 798 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ") | 830 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " ") |
| 799 | if templ_7745c5c3_Err != nil { | 831 | if templ_7745c5c3_Err != nil { | ||
| 800 | return templ_7745c5c3_Err | 832 | return templ_7745c5c3_Err | ||
| 801 | } | 833 | } | ||
| 802 | if len(p.Comments) == 0 { | 834 | if len(p.Comments) == 0 { | ||
| 803 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"dim\">no comments yet.</p>") | 835 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<p class=\"dim\">no comments yet.</p>") |
| 804 | if templ_7745c5c3_Err != nil { | 836 | if templ_7745c5c3_Err != nil { | ||
| 805 | return templ_7745c5c3_Err | 837 | return templ_7745c5c3_Err | ||
| 806 | } | 838 | } | ||
| 807 | } | 839 | } | ||
| 808 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ") | 840 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ") |
| 809 | if templ_7745c5c3_Err != nil { | 841 | if templ_7745c5c3_Err != nil { | ||
| 810 | return templ_7745c5c3_Err | 842 | return templ_7745c5c3_Err | ||
| 811 | } | 843 | } | ||
| @@ -841,39 +873,135 @@ func issueRail(p IssuePage) templ.Component { | |||||
| 841 | }() | 873 | }() | ||
| 842 | } | 874 | } | ||
| 843 | ctx = templ.InitializeContext(ctx) | 875 | ctx = templ.InitializeContext(ctx) | ||
| 844 | - | templ_7745c5c3_Var39 := templ.GetChildren(ctx) | 876 | + | templ_7745c5c3_Var41 := templ.GetChildren(ctx) |
| 845 | - | if templ_7745c5c3_Var39 == nil { | 877 | + | if templ_7745c5c3_Var41 == nil { |
| 846 | - | templ_7745c5c3_Var39 = templ.NopComponent | 878 | + | templ_7745c5c3_Var41 = templ.NopComponent |
| 847 | } | 879 | } | ||
| 848 | ctx = templ.ClearChildren(ctx) | 880 | ctx = templ.ClearChildren(ctx) | ||
| 881 | + | if len(p.References) > 0 { | |||
| 882 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Merge requests</h2><ul>") | |||
| 883 | + | if templ_7745c5c3_Err != nil { | |||
| 884 | + | return templ_7745c5c3_Err | |||
| 885 | + | } | |||
| 886 | + | for _, r := range p.References { | |||
| 887 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li><a class=\"ref\" href=\"") | |||
| 888 | + | if templ_7745c5c3_Err != nil { | |||
| 889 | + | return templ_7745c5c3_Err | |||
| 890 | + | } | |||
| 891 | + | var templ_7745c5c3_Var42 templ.SafeURL | |||
| 892 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |||
| 893 | + | if templ_7745c5c3_Err != nil { | |||
| 894 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 49} | |||
| 895 | + | } | |||
| 896 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |||
| 897 | + | if templ_7745c5c3_Err != nil { | |||
| 898 | + | return templ_7745c5c3_Err | |||
| 899 | + | } | |||
| 900 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">") | |||
| 901 | + | if templ_7745c5c3_Err != nil { | |||
| 902 | + | return templ_7745c5c3_Err | |||
| 903 | + | } | |||
| 904 | + | var templ_7745c5c3_Var43 string | |||
| 905 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number) | |||
| 906 | + | if templ_7745c5c3_Err != nil { | |||
| 907 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 62} | |||
| 908 | + | } | |||
| 909 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |||
| 910 | + | if templ_7745c5c3_Err != nil { | |||
| 911 | + | return templ_7745c5c3_Err | |||
| 912 | + | } | |||
| 913 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</a> ") | |||
| 914 | + | if templ_7745c5c3_Err != nil { | |||
| 915 | + | return templ_7745c5c3_Err | |||
| 916 | + | } | |||
| 917 | + | var templ_7745c5c3_Var44 string | |||
| 918 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(" " + r.Title + " ") | |||
| 919 | + | if templ_7745c5c3_Err != nil { | |||
| 920 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 192, Col: 27} | |||
| 921 | + | } | |||
| 922 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |||
| 923 | + | if templ_7745c5c3_Err != nil { | |||
| 924 | + | return templ_7745c5c3_Err | |||
| 925 | + | } | |||
| 926 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " <span class=\"mr-state\" data-state=\"") | |||
| 927 | + | if templ_7745c5c3_Err != nil { | |||
| 928 | + | return templ_7745c5c3_Err | |||
| 929 | + | } | |||
| 930 | + | var templ_7745c5c3_Var45 string | |||
| 931 | + | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.State) | |||
| 932 | + | if templ_7745c5c3_Err != nil { | |||
| 933 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 49} | |||
| 934 | + | } | |||
| 935 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) | |||
| 936 | + | if templ_7745c5c3_Err != nil { | |||
| 937 | + | return templ_7745c5c3_Err | |||
| 938 | + | } | |||
| 939 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">") | |||
| 940 | + | if templ_7745c5c3_Err != nil { | |||
| 941 | + | return templ_7745c5c3_Err | |||
| 942 | + | } | |||
| 943 | + | var templ_7745c5c3_Var46 string | |||
| 944 | + | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |||
| 945 | + | if templ_7745c5c3_Err != nil { | |||
| 946 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 61} | |||
| 947 | + | } | |||
| 948 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |||
| 949 | + | if templ_7745c5c3_Err != nil { | |||
| 950 | + | return templ_7745c5c3_Err | |||
| 951 | + | } | |||
| 952 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> ") | |||
| 953 | + | if templ_7745c5c3_Err != nil { | |||
| 954 | + | return templ_7745c5c3_Err | |||
| 955 | + | } | |||
| 956 | + | if r.Closes { | |||
| 957 | + | var templ_7745c5c3_Var47 string | |||
| 958 | + | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · closes") | |||
| 959 | + | if templ_7745c5c3_Err != nil { | |||
| 960 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 195, Col: 21} | |||
| 961 | + | } | |||
| 962 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |||
| 963 | + | if templ_7745c5c3_Err != nil { | |||
| 964 | + | return templ_7745c5c3_Err | |||
| 965 | + | } | |||
| 966 | + | } | |||
| 967 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</li>") | |||
| 968 | + | if templ_7745c5c3_Err != nil { | |||
| 969 | + | return templ_7745c5c3_Err | |||
| 970 | + | } | |||
| 971 | + | } | |||
| 972 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></section>") | |||
| 973 | + | if templ_7745c5c3_Err != nil { | |||
| 974 | + | return templ_7745c5c3_Err | |||
| 975 | + | } | |||
| 976 | + | } | |||
| 849 | if p.Transition != nil { | 977 | if p.Transition != nil { | ||
| 850 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"") | 978 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"") |
| 851 | if templ_7745c5c3_Err != nil { | 979 | if templ_7745c5c3_Err != nil { | ||
| 852 | return templ_7745c5c3_Err | 980 | return templ_7745c5c3_Err | ||
| 853 | } | 981 | } | ||
| 854 | - | var templ_7745c5c3_Var40 templ.SafeURL | 982 | + | var templ_7745c5c3_Var48 templ.SafeURL |
| 855 | - | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action)) | 983 | + | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action)) |
| 856 | if templ_7745c5c3_Err != nil { | 984 | if templ_7745c5c3_Err != nil { | ||
| 857 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 184, Col: 66} | 985 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 205, Col: 66} |
| 858 | } | 986 | } | ||
| 859 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | 987 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) |
| 860 | if templ_7745c5c3_Err != nil { | 988 | if templ_7745c5c3_Err != nil { | ||
| 861 | return templ_7745c5c3_Err | 989 | return templ_7745c5c3_Err | ||
| 862 | } | 990 | } | ||
| 863 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"><button type=\"submit\" class=\"btn\">") | 991 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"><button type=\"submit\" class=\"btn\">") |
| 864 | if templ_7745c5c3_Err != nil { | 992 | if templ_7745c5c3_Err != nil { | ||
| 865 | return templ_7745c5c3_Err | 993 | return templ_7745c5c3_Err | ||
| 866 | } | 994 | } | ||
| 867 | - | var templ_7745c5c3_Var41 string | 995 | + | var templ_7745c5c3_Var49 string |
| 868 | - | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label) | 996 | + | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label) |
| 869 | if templ_7745c5c3_Err != nil { | 997 | if templ_7745c5c3_Err != nil { | ||
| 870 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 185, Col: 58} | 998 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 206, Col: 58} |
| 871 | } | 999 | } | ||
| 872 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | 1000 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) |
| 873 | if templ_7745c5c3_Err != nil { | 1001 | if templ_7745c5c3_Err != nil { | ||
| 874 | return templ_7745c5c3_Err | 1002 | return templ_7745c5c3_Err | ||
| 875 | } | 1003 | } | ||
| 876 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</button></form></section>") | 1004 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></form></section>") |
| 877 | if templ_7745c5c3_Err != nil { | 1005 | if templ_7745c5c3_Err != nil { | ||
| 878 | return templ_7745c5c3_Err | 1006 | return templ_7745c5c3_Err | ||
| 879 | } | 1007 | } | ||
M internal/adapters/web/issues_test.go +45 -0
| @@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) { | |||
| 309 | 309 | {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"}, | |
| 310 | 310 | {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"}, | |
| 311 | 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 | 314 | {domain.IssueClosed{Issue: issue}, "mrs", "", ""}, | |
| 313 | 315 | {domain.IssueClosed{Issue: issue}, "summary", "", ""}, | |
| 314 | 316 | } | |
| @@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) { | |||
| 330 | 332 | require.NoError(t, err) | |
| 331 | 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 <b>it</b>") | |
| 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 <b>it</b>") | |||
| 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 ( | |||
| 12 | 12 | "strings" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | 16 | "github.com/yuin/goldmark" | |
| 16 | 17 | "github.com/yuin/goldmark/ast" | |
| 17 | 18 | "github.com/yuin/goldmark/extension" | |
| @@ -65,15 +66,32 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, | |||
| 65 | 66 | // #fragment links chosen, so that several documents on one page (the summaries | |
| 66 | 67 | // of a run) do not share ids. | |
| 67 | 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 | - | 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) | |
| 74 | 76 | 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 | 95 | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | |
| 78 | 96 | // approved from the public form). On top of the policy of render, which already | |
| 79 | 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 | 99 | // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and | |
| 82 | 100 | // every other scheme lose their link and keep the text; | |
| 83 | 101 | // - 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}) | |
| 86 | 104 | 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 | 108 | pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()})) | |
| 91 | 109 | doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc)) | |
| 92 | 110 | pol.source = source | |
| 93 | 111 | // Collect eligible text before the policy unwraps rejected links: their | |
| 94 | 112 | // labels must not accidentally become references either. | |
| 95 | 113 | var refs []*ast.Text | |
| 96 | - | if notes != nil { | |
| 114 | + | if res.notes != nil || res.numbers != nil { | |
| 97 | 115 | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 98 | 116 | if !entering { | |
| 99 | 117 | return ast.WalkContinue, nil | |
| @@ -109,7 +127,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) | |||
| 109 | 127 | } | |
| 110 | 128 | pol.apply(doc) | |
| 111 | 129 | for _, t := range refs { | |
| 112 | - | noteReferences(t, source, notes) | |
| 130 | + | references(t, source, res) | |
| 113 | 131 | } | |
| 114 | 132 | ||
| 115 | 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 ' 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 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) { | |
| 400 | 421 | parent := t.Parent() | |
| 401 | 422 | if parent == nil { | |
| 402 | 423 | return | |
| 403 | 424 | } | |
| 404 | 425 | value := t.Value(source) | |
| 405 | 426 | 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]) | |
| 408 | 439 | n, err := strconv.ParseInt(id, 10, 64) | |
| 409 | - | if err != nil || !exists(n) { | |
| 440 | + | if err != nil { | |
| 410 | 441 | continue | |
| 411 | 442 | } | |
| 412 | - | start := match[4] - 1 | |
| 443 | + | start := from - 1 | |
| 413 | 444 | // Markdown punctuation may split adjacent text into separate AST nodes. | |
| 414 | 445 | // Check the original preceding byte as well as the segment-local regex. | |
| 415 | 446 | if at := t.Segment.Start + start; at > 0 { | |
| 416 | 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 | 452 | 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 | 474 | if start > offset { | |
| 422 | 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 | 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 | 479 | parent.InsertBefore(parent, t, link) | |
| 430 | - | offset = match[5] | |
| 480 | + | offset = to | |
| 431 | 481 | } | |
| 432 | 482 | if offset == 0 { | |
| 433 | 483 | 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 summaries | 66 | // #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, err | 76 | 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 issue | 95 | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | ||
| 78 | // approved from the public form). On top of the policy of render, which already | 96 | // 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 and | 99 | // 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, err | 104 | 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 = source | 110 | pol.source = source | ||
| 93 | // Collect eligible text before the policy unwraps rejected links: their | 111 | // 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.Text | 113 | 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, nil | 117 | 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 ' 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 enter | 418 | + | // 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 | return | 423 | return | ||
| 403 | } | 424 | } | ||
| 404 | value := t.Value(source) | 425 | value := t.Value(source) | ||
| 405 | offset := 0 | 426 | 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 | continue | 441 | continue | ||
| 411 | } | 442 | } | ||
| 412 | - | start := match[4] - 1 | 443 | + | 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 | continue | 452 | 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 | return | 483 | return | ||
M internal/adapters/web/markdown_test.go +91 -4
| @@ -6,7 +6,10 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 9 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 10 | 13 | "github.com/stretchr/testify/require" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| @@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) { | |||
| 139 | 142 | ||
| 140 | 143 | func renderUntrustedMD(t *testing.T, src string) string { | |
| 141 | 144 | t.Helper() | |
| 142 | - | h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-") | |
| 145 | + | h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil) | |
| 143 | 146 | require.NoError(t, err) | |
| 144 | 147 | return string(h) | |
| 145 | 148 | } | |
| @@ -218,14 +221,14 @@ func TestNoteMarkdown(t *testing.T) { | |||
| 218 | 221 | for _, tt := range tests { | |
| 219 | 222 | t.Run(tt.source, func(t *testing.T) { | |
| 220 | 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 | 225 | require.NoError(t, err) | |
| 223 | 226 | assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h)) | |
| 224 | 227 | assert.NotContains(t, string(h), `href="#c99"`) | |
| 225 | 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 | 232 | require.NoError(t, err) | |
| 230 | 233 | assert.Contains(t, string(h), `id="c1-heading"`) | |
| 231 | 234 | assert.Contains(t, string(h), "<strong>bold</strong>") | |
| @@ -233,6 +236,90 @@ func TestNoteMarkdown(t *testing.T) { | |||
| 233 | 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 | + | {"' ", 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 | 323 | func TestMarkdownFencedHighlight(t *testing.T) { | |
| 237 | 324 | tests := []struct { | |
| 238 | 325 | name, info, code string | |
| @@ -252,7 +339,7 @@ func TestMarkdownFencedHighlight(t *testing.T) { | |||
| 252 | 339 | for _, untrusted := range []bool{false, true} { | |
| 253 | 340 | var h string | |
| 254 | 341 | if untrusted { | |
| 255 | - | out, err := newMarkdown().renderUntrusted([]byte(src), "test-") | |
| 342 | + | out, err := newMarkdown().renderUntrusted([]byte(src), "test-", nil) | |
| 256 | 343 | require.NoError(t, err) | |
| 257 | 344 | h = string(out) | |
| 258 | 345 | } 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 | + | {"' ", 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 string | 325 | 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 string | 340 | 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
| @@ -629,7 +629,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 629 | 629 | if canWrite { | |
| 630 | 630 | on = parseOn(r.URL.Query().Get("on")) | |
| 631 | 631 | } | |
| 632 | - | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | |
| 632 | + | s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on) | |
| 633 | 633 | lines, invisible := diffLines(p.Files) | |
| 634 | 634 | p.Invisible = invisible | |
| 635 | 635 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| @@ -704,7 +704,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M | |||
| 704 | 704 | // Relative links point into the source branch as it is now (its head | |
| 705 | 705 | // commit); the heading ids carry their own prefix, apart from the | |
| 706 | 706 | // ids of the diff. | |
| 707 | - | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments)) | |
| 707 | + | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())}) | |
| 708 | 708 | if err != nil { | |
| 709 | 709 | return err | |
| 710 | 710 | } | |
| @@ -931,7 +931,7 @@ func parseOn(s string) *lineRef { | |||
| 931 | 931 | // as links to the form of their line; on designates the line whose form shows. | |
| 932 | 932 | // | |
| 933 | 933 | //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only | |
| 934 | - | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) { | |
| 934 | + | func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) { | |
| 935 | 935 | now := s.cfg.Now() | |
| 936 | 936 | self := mrHref(repo, n) | |
| 937 | 937 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | |
| @@ -979,7 +979,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 979 | 979 | return index[lineKey{path, side, line}] | |
| 980 | 980 | } | |
| 981 | 981 | ||
| 982 | - | exists := noteIDs(comments) | |
| 982 | + | exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)} | |
| 983 | 983 | children := map[int64][]domain.Comment{} | |
| 984 | 984 | for _, c := range comments { | |
| 985 | 985 | if c.ReplyTo != 0 && !c.Deleted { | |
| @@ -629,7 +629,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 629 | if canWrite { | 629 | if canWrite { | ||
| 630 | on = parseOn(r.URL.Query().Get("on")) | 630 | on = parseOn(r.URL.Query().Get("on")) | ||
| 631 | } | 631 | } | ||
| 632 | - | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | 632 | + | s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on) |
| 633 | lines, invisible := diffLines(p.Files) | 633 | lines, invisible := diffLines(p.Files) | ||
| 634 | p.Invisible = invisible | 634 | p.Invisible = invisible | ||
| 635 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | 635 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | ||
| @@ -704,7 +704,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M | |||||
| 704 | // Relative links point into the source branch as it is now (its head | 704 | // Relative links point into the source branch as it is now (its head | ||
| 705 | // commit); the heading ids carry their own prefix, apart from the | 705 | // commit); the heading ids carry their own prefix, apart from the | ||
| 706 | // ids of the diff. | 706 | // ids of the diff. | ||
| 707 | - | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments)) | 707 | + | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())}) |
| 708 | if err != nil { | 708 | if err != nil { | ||
| 709 | return err | 709 | return err | ||
| 710 | } | 710 | } | ||
| @@ -931,7 +931,7 @@ func parseOn(s string) *lineRef { | |||||
| 931 | // as links to the form of their line; on designates the line whose form shows. | 931 | // as links to the form of their line; on designates the line whose form shows. | ||
| 932 | // | 932 | // | ||
| 933 | //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only | 933 | //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only | ||
| 934 | - | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) { | 934 | + | func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) { |
| 935 | now := s.cfg.Now() | 935 | now := s.cfg.Now() | ||
| 936 | self := mrHref(repo, n) | 936 | self := mrHref(repo, n) | ||
| 937 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | 937 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | ||
| @@ -979,7 +979,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||||
| 979 | return index[lineKey{path, side, line}] | 979 | return index[lineKey{path, side, line}] | ||
| 980 | } | 980 | } | ||
| 981 | 981 | ||||
| 982 | - | exists := noteIDs(comments) | 982 | + | exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)} |
| 983 | children := map[int64][]domain.Comment{} | 983 | children := map[int64][]domain.Comment{} | ||
| 984 | for _, c := range comments { | 984 | for _, c := range comments { | ||
| 985 | if c.ReplyTo != 0 && !c.Deleted { | 985 | 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, | |||
| 227 | 227 | } else { | |
| 228 | 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 | 231 | window := p.Files[from:min(from+mrWindow, len(p.Files))] | |
| 232 | 232 | if one { | |
| 233 | 233 | 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) { | |||
| 43 | 43 | {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true}, | |
| 44 | 44 | } | |
| 45 | 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 | 47 | require.Len(t, p.General, 3) | |
| 48 | 48 | assert.Equal(t, 4, p.NComments) | |
| 49 | 49 | assert.Equal(t, 1, p.NUnresolved) | |
| @@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) { | |||
| 117 | 117 | require.NoError(t, err) | |
| 118 | 118 | s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()} | |
| 119 | 119 | p := MRPage{} | |
| 120 | - | s.placeComments(&p, "demo", 1, []domain.Comment{ | |
| 120 | + | s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{ | |
| 121 | 121 | {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove}, | |
| 122 | 122 | {ID: 2, Deleted: true}, | |
| 123 | 123 | {ID: 3, ReplyTo: 2, Deleted: true}, | |
| @@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) { | |||
| 147 | 147 | require.NoError(t, err) | |
| 148 | 148 | s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()} | |
| 149 | 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 | 151 | notes := p.Files[0].Hunks[0].Lines[0].Notes | |
| 152 | 152 | require.Len(t, notes, 1) | |
| 153 | 153 | 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].Notes | 151 | 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.Buffer | 153 | var b bytes.Buffer | ||
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 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", "merge_requests.auto_merge"}, 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", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -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", "merge_requests.auto_merge"}, 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", "merge_requests.auto_merge"}, 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) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 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", "merge_requests.auto_merge"}, 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", "merge_requests.auto_merge"}, ver.Server.Features) | |
| 354 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | 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", "merge_requests.auto_merge"}, 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", "merge_requests.auto_merge"}, ver.Server.Features) |
| 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 355 | stdout, stderr, code = c.bin(t, secret, "version") | 355 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 356 | require.Equal(t, 0, code, stderr) | 356 | require.Equal(t, 0, code, stderr) | ||
M internal/app/cli_issue.go +55 -9
| @@ -57,6 +57,8 @@ type jsonIssue struct { | |||
| 57 | 57 | CreatedAt *string `json:"created_at"` | |
| 58 | 58 | UpdatedAt *string `json:"updated_at"` | |
| 59 | 59 | CommentCount int32 `json:"comment_count"` | |
| 60 | + | // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none. | |
| 61 | + | ClosedByMergeRequest int64 `json:"closed_by_merge_request"` | |
| 60 | 62 | } | |
| 61 | 63 | ||
| 62 | 64 | func toJSONIssue(i *ermite.Issue) jsonIssue { | |
| @@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue { | |||
| 64 | 66 | Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(), | |
| 65 | 67 | State: issueStateName(i.GetState()), Author: i.GetAuthor(), | |
| 66 | 68 | CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(), | |
| 69 | + | ClosedByMergeRequest: i.GetClosedByMergeRequest(), | |
| 67 | 70 | } | |
| 68 | 71 | } | |
| 69 | 72 | ||
| @@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment { | |||
| 82 | 85 | // jsonIssueDetail is the stable JSON shape of `issue show --json`. | |
| 83 | 86 | type jsonIssueDetail struct { | |
| 84 | 87 | jsonIssue | |
| 85 | - | Comments []jsonIssueComment `json:"comments"` | |
| 88 | + | Comments []jsonIssueComment `json:"comments"` | |
| 89 | + | References []jsonIssueReference `json:"references"` | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // jsonIssueReference is a merge request that names the issue. | |
| 93 | + | type jsonIssueReference struct { | |
| 94 | + | Number int64 `json:"number"` | |
| 95 | + | Title string `json:"title"` | |
| 96 | + | State string `json:"state"` | |
| 97 | + | Closes bool `json:"closes"` | |
| 86 | 98 | } | |
| 87 | 99 | ||
| 88 | 100 | const jsonIssueHelp = ` | |
| 89 | 101 | JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author | |
| 90 | - | (token label), created_at and updated_at (RFC 3339 UTC), comment_count.` | |
| 102 | + | (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of | |
| 103 | + | the merge request whose merge closed the issue, 0 for none).` | |
| 91 | 104 | ||
| 92 | 105 | const jsonIssueCommentHelp = ` | |
| 93 | 106 | JSON fields of an issue comment: id, author, text, created_at.` | |
| 94 | 107 | ||
| 108 | + | const jsonIssueReferenceHelp = ` | |
| 109 | + | JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes | |
| 110 | + | (true when merging it closes the issue).` | |
| 111 | + | ||
| 95 | 112 | func newIssueCmd(env *cliEnv) *cobra.Command { | |
| 96 | 113 | issue := &cobra.Command{ | |
| 97 | 114 | Use: "issue", | |
| @@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||
| 298 | 315 | cmd := &cobra.Command{ | |
| 299 | 316 | Use: "show <repo> <number>", | |
| 300 | 317 | Short: "Show an issue with its comments", | |
| 301 | - | Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp + | |
| 302 | - | "\n--json adds comments to the issue fields.", | |
| 318 | + | Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" + | |
| 319 | + | "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" + | |
| 320 | + | "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." + | |
| 321 | + | jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp + | |
| 322 | + | "\n--json adds comments and references (always present, possibly empty) to the issue fields.", | |
| 303 | 323 | Args: cobra.ExactArgs(2), | |
| 304 | 324 | ValidArgsFunction: completeIssueRepoThenNumber(env), | |
| 305 | 325 | } | |
| @@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||
| 322 | 342 | for _, cm := range res.Msg.GetComments() { | |
| 323 | 343 | out.Comments = append(out.Comments, toJSONIssueComment(cm)) | |
| 324 | 344 | } | |
| 345 | + | out.References = []jsonIssueReference{} | |
| 346 | + | for _, r := range res.Msg.GetReferences() { | |
| 347 | + | out.References = append(out.References, jsonIssueReference{ | |
| 348 | + | Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(), | |
| 349 | + | }) | |
| 350 | + | } | |
| 325 | 351 | return writeJSON(cmd.OutOrStdout(), out) | |
| 326 | 352 | } | |
| 327 | 353 | return printIssueDetail(cmd.OutOrStdout(), res.Msg) | |
| @@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error { | |||
| 335 | 361 | if body == "" { | |
| 336 | 362 | body = "-" | |
| 337 | 363 | } | |
| 338 | - | err := fields(w, [][2]string{ | |
| 364 | + | rows := [][2]string{ | |
| 339 | 365 | {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())}, | |
| 340 | 366 | {"title", i.GetTitle()}, | |
| 341 | 367 | {"state", issueStateName(i.GetState())}, | |
| 342 | - | {"author", i.GetAuthor()}, | |
| 343 | - | {"created", shortTime(i.GetCreatedAt(), "-")}, | |
| 344 | - | {"updated", shortTime(i.GetUpdatedAt(), "-")}, | |
| 345 | - | }) | |
| 368 | + | } | |
| 369 | + | if n := i.GetClosedByMergeRequest(); n > 0 { | |
| 370 | + | rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)}) | |
| 371 | + | } | |
| 372 | + | rows = append(rows, | |
| 373 | + | [2]string{"author", i.GetAuthor()}, | |
| 374 | + | [2]string{"created", shortTime(i.GetCreatedAt(), "-")}, | |
| 375 | + | [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")}, | |
| 376 | + | ) | |
| 377 | + | err := fields(w, rows) | |
| 346 | 378 | if err != nil { | |
| 347 | 379 | return err | |
| 348 | 380 | } | |
| 349 | 381 | if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil { | |
| 350 | 382 | return err | |
| 351 | 383 | } | |
| 384 | + | if refs := d.GetReferences(); len(refs) > 0 { | |
| 385 | + | if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil { | |
| 386 | + | return err | |
| 387 | + | } | |
| 388 | + | for _, r := range refs { | |
| 389 | + | line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle())) | |
| 390 | + | if r.GetCloses() { | |
| 391 | + | line += " · closes" | |
| 392 | + | } | |
| 393 | + | if _, err := fmt.Fprintln(w, line); err != nil { | |
| 394 | + | return err | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | } | |
| 352 | 398 | if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil { | |
| 353 | 399 | return err | |
| 354 | 400 | } | |
| @@ -57,6 +57,8 @@ type jsonIssue struct { | |||||
| 57 | CreatedAt *string `json:"created_at"` | 57 | CreatedAt *string `json:"created_at"` | ||
| 58 | UpdatedAt *string `json:"updated_at"` | 58 | UpdatedAt *string `json:"updated_at"` | ||
| 59 | CommentCount int32 `json:"comment_count"` | 59 | CommentCount int32 `json:"comment_count"` | ||
| 60 | + | // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none. | |||
| 61 | + | ClosedByMergeRequest int64 `json:"closed_by_merge_request"` | |||
| 60 | } | 62 | } | ||
| 61 | 63 | ||||
| 62 | func toJSONIssue(i *ermite.Issue) jsonIssue { | 64 | func toJSONIssue(i *ermite.Issue) jsonIssue { | ||
| @@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue { | |||||
| 64 | Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(), | 66 | Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(), | ||
| 65 | State: issueStateName(i.GetState()), Author: i.GetAuthor(), | 67 | State: issueStateName(i.GetState()), Author: i.GetAuthor(), | ||
| 66 | CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(), | 68 | CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(), | ||
| 69 | + | ClosedByMergeRequest: i.GetClosedByMergeRequest(), | |||
| 67 | } | 70 | } | ||
| 68 | } | 71 | } | ||
| 69 | 72 | ||||
| @@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment { | |||||
| 82 | // jsonIssueDetail is the stable JSON shape of `issue show --json`. | 85 | // jsonIssueDetail is the stable JSON shape of `issue show --json`. | ||
| 83 | type jsonIssueDetail struct { | 86 | type jsonIssueDetail struct { | ||
| 84 | jsonIssue | 87 | jsonIssue | ||
| 85 | - | Comments []jsonIssueComment `json:"comments"` | 88 | + | Comments []jsonIssueComment `json:"comments"` |
| 89 | + | References []jsonIssueReference `json:"references"` | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // jsonIssueReference is a merge request that names the issue. | |||
| 93 | + | type jsonIssueReference struct { | |||
| 94 | + | Number int64 `json:"number"` | |||
| 95 | + | Title string `json:"title"` | |||
| 96 | + | State string `json:"state"` | |||
| 97 | + | Closes bool `json:"closes"` | |||
| 86 | } | 98 | } | ||
| 87 | 99 | ||||
| 88 | const jsonIssueHelp = ` | 100 | const jsonIssueHelp = ` | ||
| 89 | JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author | 101 | JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author | ||
| 90 | - | (token label), created_at and updated_at (RFC 3339 UTC), comment_count.` | 102 | + | (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of |
| 103 | + | the merge request whose merge closed the issue, 0 for none).` | |||
| 91 | 104 | ||||
| 92 | const jsonIssueCommentHelp = ` | 105 | const jsonIssueCommentHelp = ` | ||
| 93 | JSON fields of an issue comment: id, author, text, created_at.` | 106 | JSON fields of an issue comment: id, author, text, created_at.` | ||
| 94 | 107 | ||||
| 108 | + | const jsonIssueReferenceHelp = ` | |||
| 109 | + | JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes | |||
| 110 | + | (true when merging it closes the issue).` | |||
| 111 | + | ||||
| 95 | func newIssueCmd(env *cliEnv) *cobra.Command { | 112 | func newIssueCmd(env *cliEnv) *cobra.Command { | ||
| 96 | issue := &cobra.Command{ | 113 | issue := &cobra.Command{ | ||
| 97 | Use: "issue", | 114 | Use: "issue", | ||
| @@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||||
| 298 | cmd := &cobra.Command{ | 315 | cmd := &cobra.Command{ | ||
| 299 | Use: "show <repo> <number>", | 316 | Use: "show <repo> <number>", | ||
| 300 | Short: "Show an issue with its comments", | 317 | Short: "Show an issue with its comments", | ||
| 301 | - | Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp + | 318 | + | Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" + |
| 302 | - | "\n--json adds comments to the issue fields.", | 319 | + | "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" + |
| 320 | + | "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." + | |||
| 321 | + | jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp + | |||
| 322 | + | "\n--json adds comments and references (always present, possibly empty) to the issue fields.", | |||
| 303 | Args: cobra.ExactArgs(2), | 323 | Args: cobra.ExactArgs(2), | ||
| 304 | ValidArgsFunction: completeIssueRepoThenNumber(env), | 324 | ValidArgsFunction: completeIssueRepoThenNumber(env), | ||
| 305 | } | 325 | } | ||
| @@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||||
| 322 | for _, cm := range res.Msg.GetComments() { | 342 | for _, cm := range res.Msg.GetComments() { | ||
| 323 | out.Comments = append(out.Comments, toJSONIssueComment(cm)) | 343 | out.Comments = append(out.Comments, toJSONIssueComment(cm)) | ||
| 324 | } | 344 | } | ||
| 345 | + | out.References = []jsonIssueReference{} | |||
| 346 | + | for _, r := range res.Msg.GetReferences() { | |||
| 347 | + | out.References = append(out.References, jsonIssueReference{ | |||
| 348 | + | Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(), | |||
| 349 | + | }) | |||
| 350 | + | } | |||
| 325 | return writeJSON(cmd.OutOrStdout(), out) | 351 | return writeJSON(cmd.OutOrStdout(), out) | ||
| 326 | } | 352 | } | ||
| 327 | return printIssueDetail(cmd.OutOrStdout(), res.Msg) | 353 | return printIssueDetail(cmd.OutOrStdout(), res.Msg) | ||
| @@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error { | |||||
| 335 | if body == "" { | 361 | if body == "" { | ||
| 336 | body = "-" | 362 | body = "-" | ||
| 337 | } | 363 | } | ||
| 338 | - | err := fields(w, [][2]string{ | 364 | + | rows := [][2]string{ |
| 339 | {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())}, | 365 | {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())}, | ||
| 340 | {"title", i.GetTitle()}, | 366 | {"title", i.GetTitle()}, | ||
| 341 | {"state", issueStateName(i.GetState())}, | 367 | {"state", issueStateName(i.GetState())}, | ||
| 342 | - | {"author", i.GetAuthor()}, | 368 | + | } |
| 343 | - | {"created", shortTime(i.GetCreatedAt(), "-")}, | 369 | + | if n := i.GetClosedByMergeRequest(); n > 0 { |
| 344 | - | {"updated", shortTime(i.GetUpdatedAt(), "-")}, | 370 | + | rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)}) |
| 345 | - | }) | 371 | + | } |
| 372 | + | rows = append(rows, | |||
| 373 | + | [2]string{"author", i.GetAuthor()}, | |||
| 374 | + | [2]string{"created", shortTime(i.GetCreatedAt(), "-")}, | |||
| 375 | + | [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")}, | |||
| 376 | + | ) | |||
| 377 | + | err := fields(w, rows) | |||
| 346 | if err != nil { | 378 | if err != nil { | ||
| 347 | return err | 379 | return err | ||
| 348 | } | 380 | } | ||
| 349 | if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil { | 381 | if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil { | ||
| 350 | return err | 382 | return err | ||
| 351 | } | 383 | } | ||
| 384 | + | if refs := d.GetReferences(); len(refs) > 0 { | |||
| 385 | + | if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil { | |||
| 386 | + | return err | |||
| 387 | + | } | |||
| 388 | + | for _, r := range refs { | |||
| 389 | + | line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle())) | |||
| 390 | + | if r.GetCloses() { | |||
| 391 | + | line += " · closes" | |||
| 392 | + | } | |||
| 393 | + | if _, err := fmt.Fprintln(w, line); err != nil { | |||
| 394 | + | return err | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 352 | if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil { | 398 | if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil { | ||
| 353 | return err | 399 | return err | ||
| 354 | } | 400 | } | ||
M internal/app/cli_issue_e2e_test.go +7 -2
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/json" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| @@ -24,7 +25,7 @@ type issueOut struct { | |||
| 24 | 25 | CommentCount int `json:"comment_count"` | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | - | var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"} | |
| 28 | + | var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"} | |
| 28 | 29 | ||
| 29 | 30 | type issueCommentOut struct { | |
| 30 | 31 | ID string `json:"id"` | |
| @@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) { | |||
| 106 | 107 | res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t) | |
| 107 | 108 | type showOut struct { | |
| 108 | 109 | issueOut | |
| 109 | - | Comments []issueCommentOut `json:"comments"` | |
| 110 | + | Comments []issueCommentOut `json:"comments"` | |
| 111 | + | References []json.RawMessage `json:"references"` | |
| 110 | 112 | } | |
| 111 | 113 | show := decode[showOut](t, res.stdout) | |
| 112 | 114 | assert.Equal(t, 2, show.CommentCount) | |
| @@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) { | |||
| 115 | 117 | res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t) | |
| 116 | 118 | assert.Contains(t, res.stdout, "Crash on start") | |
| 117 | 119 | assert.Contains(t, res.stdout, "comments (2)") | |
| 120 | + | assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue") | |
| 121 | + | assert.NotNil(t, show.References, "references is always present") | |
| 122 | + | assert.Empty(t, show.References) | |
| 118 | 123 | ||
| 119 | 124 | // close and reopen. | |
| 120 | 125 | res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t) | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "encoding/json" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| @@ -24,7 +25,7 @@ type issueOut struct { | |||||
| 24 | CommentCount int `json:"comment_count"` | 25 | CommentCount int `json:"comment_count"` | ||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | - | var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"} | 28 | + | var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"} |
| 28 | 29 | ||||
| 29 | type issueCommentOut struct { | 30 | type issueCommentOut struct { | ||
| 30 | ID string `json:"id"` | 31 | ID string `json:"id"` | ||
| @@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) { | |||||
| 106 | res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t) | 107 | res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t) | ||
| 107 | type showOut struct { | 108 | type showOut struct { | ||
| 108 | issueOut | 109 | issueOut | ||
| 109 | - | Comments []issueCommentOut `json:"comments"` | 110 | + | Comments []issueCommentOut `json:"comments"` |
| 111 | + | References []json.RawMessage `json:"references"` | |||
| 110 | } | 112 | } | ||
| 111 | show := decode[showOut](t, res.stdout) | 113 | show := decode[showOut](t, res.stdout) | ||
| 112 | assert.Equal(t, 2, show.CommentCount) | 114 | assert.Equal(t, 2, show.CommentCount) | ||
| @@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) { | |||||
| 115 | res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t) | 117 | res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t) | ||
| 116 | assert.Contains(t, res.stdout, "Crash on start") | 118 | assert.Contains(t, res.stdout, "Crash on start") | ||
| 117 | assert.Contains(t, res.stdout, "comments (2)") | 119 | assert.Contains(t, res.stdout, "comments (2)") | ||
| 120 | + | assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue") | |||
| 121 | + | assert.NotNil(t, show.References, "references is always present") | |||
| 122 | + | assert.Empty(t, show.References) | |||
| 118 | 123 | ||||
| 119 | // close and reopen. | 124 | // close and reopen. | ||
| 120 | res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t) | 125 | res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t) | ||
M internal/app/cli_issue_test.go +36 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/stretchr/testify/assert" | |
| @@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) { | |||
| 51 | 52 | assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies) | |
| 52 | 53 | ||
| 53 | 54 | res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout) | |
| 55 | + | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout) | |
| 55 | 56 | ||
| 56 | 57 | for _, args := range [][]string{ | |
| 57 | 58 | {"issue", "create", "alpha"}, | |
| @@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) { | |||
| 262 | 263 | } | |
| 263 | 264 | t.Fatal("never completed") | |
| 264 | 265 | } | |
| 266 | + | ||
| 267 | + | func TestIssueShowMergeRequestLinks(t *testing.T) { | |
| 268 | + | closed := &ermite.Issue{ | |
| 269 | + | Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED, | |
| 270 | + | ClosedByMergeRequest: 5, | |
| 271 | + | } | |
| 272 | + | f := &fakeAPI{ | |
| 273 | + | getVersion: withIssues, | |
| 274 | + | getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |
| 275 | + | return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{ | |
| 276 | + | {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true}, | |
| 277 | + | {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED}, | |
| 278 | + | }}, nil | |
| 279 | + | }, | |
| 280 | + | } | |
| 281 | + | url := f.serve(t) | |
| 282 | + | res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t) | |
| 283 | + | assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout) | |
| 284 | + | assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n") | |
| 285 | + | assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)")) | |
| 286 | + | ||
| 287 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |
| 288 | + | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout) | |
| 289 | + | ||
| 290 | + | // No references: no block in text, an empty array in JSON. | |
| 291 | + | f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |
| 292 | + | return &ermite.GetIssueResponse{Issue: issueOne}, nil | |
| 293 | + | } | |
| 294 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t) | |
| 295 | + | assert.NotContains(t, res.stdout, "merge requests") | |
| 296 | + | assert.NotContains(t, res.stdout, "closed by") | |
| 297 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |
| 298 | + | assert.Contains(t, res.stdout, `"references": []`) | |
| 299 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | 11 | ||||
| 11 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| @@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) { | |||||
| 51 | assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies) | 52 | assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies) | ||
| 52 | 53 | ||||
| 53 | res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t) | 54 | res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t) | ||
| 54 | - | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout) | 55 | + | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout) |
| 55 | 56 | ||||
| 56 | for _, args := range [][]string{ | 57 | for _, args := range [][]string{ | ||
| 57 | {"issue", "create", "alpha"}, | 58 | {"issue", "create", "alpha"}, | ||
| @@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) { | |||||
| 262 | } | 263 | } | ||
| 263 | t.Fatal("never completed") | 264 | t.Fatal("never completed") | ||
| 264 | } | 265 | } | ||
| 266 | + | ||||
| 267 | + | func TestIssueShowMergeRequestLinks(t *testing.T) { | |||
| 268 | + | closed := &ermite.Issue{ | |||
| 269 | + | Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED, | |||
| 270 | + | ClosedByMergeRequest: 5, | |||
| 271 | + | } | |||
| 272 | + | f := &fakeAPI{ | |||
| 273 | + | getVersion: withIssues, | |||
| 274 | + | getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |||
| 275 | + | return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{ | |||
| 276 | + | {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true}, | |||
| 277 | + | {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED}, | |||
| 278 | + | }}, nil | |||
| 279 | + | }, | |||
| 280 | + | } | |||
| 281 | + | url := f.serve(t) | |||
| 282 | + | res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t) | |||
| 283 | + | assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout) | |||
| 284 | + | assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n") | |||
| 285 | + | assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)")) | |||
| 286 | + | ||||
| 287 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |||
| 288 | + | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout) | |||
| 289 | + | ||||
| 290 | + | // No references: no block in text, an empty array in JSON. | |||
| 291 | + | f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |||
| 292 | + | return &ermite.GetIssueResponse{Issue: issueOne}, nil | |||
| 293 | + | } | |||
| 294 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t) | |||
| 295 | + | assert.NotContains(t, res.stdout, "merge requests") | |||
| 296 | + | assert.NotContains(t, res.stdout, "closed by") | |||
| 297 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |||
| 298 | + | assert.Contains(t, res.stdout, `"references": []`) | |||
| 299 | + | } | |||
M internal/app/forge.go +4 -2
| @@ -364,8 +364,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 364 | 364 | Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock, | |
| 365 | 365 | }) | |
| 366 | 366 | f.Pushes.WithMergeability(f.Mergeability) | |
| 367 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock}) | |
| 368 | + | f.Pushes.WithIssueLinks(links) | |
| 367 | 369 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 368 | - | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 370 | + | Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 369 | 371 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 370 | 372 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | |
| 371 | 373 | }) | |
| @@ -380,7 +382,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 380 | 382 | f.Mergeability.Start() | |
| 381 | 383 | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) | |
| 382 | 384 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | |
| 383 | - | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | |
| 385 | + | Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | |
| 384 | 386 | }) | |
| 385 | 387 | f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{ | |
| 386 | 388 | Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock, | |
| @@ -364,8 +364,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 364 | Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock, | 364 | Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock, | ||
| 365 | }) | 365 | }) | ||
| 366 | f.Pushes.WithMergeability(f.Mergeability) | 366 | f.Pushes.WithMergeability(f.Mergeability) | ||
| 367 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock}) | |||
| 368 | + | f.Pushes.WithIssueLinks(links) | |||
| 367 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | 369 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | ||
| 368 | - | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | 370 | + | Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, |
| 369 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | 371 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | ||
| 370 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | 372 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | ||
| 371 | }) | 373 | }) | ||
| @@ -380,7 +382,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 380 | f.Mergeability.Start() | 382 | f.Mergeability.Start() | ||
| 381 | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) | 383 | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) | ||
| 382 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | 384 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | ||
| 383 | - | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | 385 | + | Links: store.IssueLinks(), Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, |
| 384 | }) | 386 | }) | ||
| 385 | f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{ | 387 | f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{ | ||
| 386 | Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock, | 388 | Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock, | ||
M internal/app/issue_api_test.go +52 -0
| @@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) { | |||
| 243 | 243 | _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF) | |
| 244 | 244 | require.NoError(t, err) | |
| 245 | 245 | } | |
| 246 | + | ||
| 247 | + | func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) { | |
| 248 | + | r := newMRRig(t, domain.VisibilityPublic) | |
| 249 | + | ctx := r.ctx | |
| 250 | + | _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)}) | |
| 251 | + | require.NoError(t, err) | |
| 252 | + | for _, title := range []string{"Crash", "Slow"} { | |
| 253 | + | _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title})) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | } | |
| 256 | + | // The merge request mentions #1 (closing) and #2 (plain). | |
| 257 | + | created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | |
| 258 | + | Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat", | |
| 259 | + | })) | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | number := created.Msg.GetMergeRequest().GetNumber() | |
| 262 | + | ||
| 263 | + | get := func(n int64) *pb.GetIssueResponse { | |
| 264 | + | res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n})) | |
| 265 | + | require.NoError(t, err) | |
| 266 | + | return res.Msg | |
| 267 | + | } | |
| 268 | + | one := get(1) | |
| 269 | + | require.Len(t, one.GetReferences(), 1) | |
| 270 | + | ref := one.GetReferences()[0] | |
| 271 | + | assert.Equal(t, number, ref.GetNumber()) | |
| 272 | + | assert.Equal(t, "Change a", ref.GetTitle()) | |
| 273 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState()) | |
| 274 | + | assert.True(t, ref.GetCloses()) | |
| 275 | + | two := get(2) | |
| 276 | + | require.Len(t, two.GetReferences(), 1) | |
| 277 | + | assert.False(t, two.GetReferences()[0].GetCloses()) | |
| 278 | + | assert.Zero(t, one.GetIssue().GetClosedByMergeRequest()) | |
| 279 | + | ||
| 280 | + | _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head})) | |
| 281 | + | require.NoError(t, err) | |
| 282 | + | r.forge.MergeRequests.Wait(ctx) | |
| 283 | + | ||
| 284 | + | one = get(1) | |
| 285 | + | assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState()) | |
| 286 | + | assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest()) | |
| 287 | + | require.Len(t, one.GetReferences(), 1) | |
| 288 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState()) | |
| 289 | + | two = get(2) | |
| 290 | + | assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close") | |
| 291 | + | assert.Zero(t, two.GetIssue().GetClosedByMergeRequest()) | |
| 292 | + | ||
| 293 | + | // Reopening by hand clears "closed by". | |
| 294 | + | reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1})) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest()) | |
| 297 | + | } | |
| @@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) { | |||||
| 243 | _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF) | 243 | _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF) | ||
| 244 | require.NoError(t, err) | 244 | require.NoError(t, err) | ||
| 245 | } | 245 | } | ||
| 246 | + | ||||
| 247 | + | func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) { | |||
| 248 | + | r := newMRRig(t, domain.VisibilityPublic) | |||
| 249 | + | ctx := r.ctx | |||
| 250 | + | _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)}) | |||
| 251 | + | require.NoError(t, err) | |||
| 252 | + | for _, title := range []string{"Crash", "Slow"} { | |||
| 253 | + | _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title})) | |||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | } | |||
| 256 | + | // The merge request mentions #1 (closing) and #2 (plain). | |||
| 257 | + | created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | |||
| 258 | + | Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat", | |||
| 259 | + | })) | |||
| 260 | + | require.NoError(t, err) | |||
| 261 | + | number := created.Msg.GetMergeRequest().GetNumber() | |||
| 262 | + | ||||
| 263 | + | get := func(n int64) *pb.GetIssueResponse { | |||
| 264 | + | res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n})) | |||
| 265 | + | require.NoError(t, err) | |||
| 266 | + | return res.Msg | |||
| 267 | + | } | |||
| 268 | + | one := get(1) | |||
| 269 | + | require.Len(t, one.GetReferences(), 1) | |||
| 270 | + | ref := one.GetReferences()[0] | |||
| 271 | + | assert.Equal(t, number, ref.GetNumber()) | |||
| 272 | + | assert.Equal(t, "Change a", ref.GetTitle()) | |||
| 273 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState()) | |||
| 274 | + | assert.True(t, ref.GetCloses()) | |||
| 275 | + | two := get(2) | |||
| 276 | + | require.Len(t, two.GetReferences(), 1) | |||
| 277 | + | assert.False(t, two.GetReferences()[0].GetCloses()) | |||
| 278 | + | assert.Zero(t, one.GetIssue().GetClosedByMergeRequest()) | |||
| 279 | + | ||||
| 280 | + | _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head})) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | r.forge.MergeRequests.Wait(ctx) | |||
| 283 | + | ||||
| 284 | + | one = get(1) | |||
| 285 | + | assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState()) | |||
| 286 | + | assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest()) | |||
| 287 | + | require.Len(t, one.GetReferences(), 1) | |||
| 288 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState()) | |||
| 289 | + | two = get(2) | |||
| 290 | + | assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close") | |||
| 291 | + | assert.Zero(t, two.GetIssue().GetClosedByMergeRequest()) | |||
| 292 | + | ||||
| 293 | + | // Reopening by hand clears "closed by". | |||
| 294 | + | reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1})) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest()) | |||
| 297 | + | } | |||
M internal/domain/event.go +12 -0
| @@ -214,6 +214,18 @@ type IssueOpened struct { | |||
| 214 | 214 | // EventKind implements Event. | |
| 215 | 215 | func (IssueOpened) EventKind() string { return "issue.opened" } | |
| 216 | 216 | ||
| 217 | + | // IssueReferenced refreshes an issue's merge request back-references. | |
| 218 | + | type IssueReferenced struct { | |
| 219 | + | Repo RepoName | |
| 220 | + | Issue, MergeRequest int64 | |
| 221 | + | At time.Time | |
| 222 | + | Visibility Visibility | |
| 223 | + | CreatedAt time.Time | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // EventKind implements Event. | |
| 227 | + | func (IssueReferenced) EventKind() string { return "issue.referenced" } | |
| 228 | + | ||
| 217 | 229 | // IssueClosed is published when an issue was closed. | |
| 218 | 230 | type IssueClosed struct { | |
| 219 | 231 | Issue Issue | |
| @@ -214,6 +214,18 @@ type IssueOpened struct { | |||||
| 214 | // EventKind implements Event. | 214 | // EventKind implements Event. | ||
| 215 | func (IssueOpened) EventKind() string { return "issue.opened" } | 215 | func (IssueOpened) EventKind() string { return "issue.opened" } | ||
| 216 | 216 | ||||
| 217 | + | // IssueReferenced refreshes an issue's merge request back-references. | |||
| 218 | + | type IssueReferenced struct { | |||
| 219 | + | Repo RepoName | |||
| 220 | + | Issue, MergeRequest int64 | |||
| 221 | + | At time.Time | |||
| 222 | + | Visibility Visibility | |||
| 223 | + | CreatedAt time.Time | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // EventKind implements Event. | |||
| 227 | + | func (IssueReferenced) EventKind() string { return "issue.referenced" } | |||
| 228 | + | ||||
| 217 | // IssueClosed is published when an issue was closed. | 229 | // IssueClosed is published when an issue was closed. | ||
| 218 | type IssueClosed struct { | 230 | type IssueClosed struct { | ||
| 219 | Issue Issue | 231 | Issue Issue | ||
M internal/domain/issue.go +8 -0
| @@ -73,11 +73,19 @@ type Issue struct { | |||
| 73 | 73 | // External is true for an issue approved from the public form: its text | |
| 74 | 74 | // comes from an anonymous visitor and is rendered as untrusted. | |
| 75 | 75 | External bool | |
| 76 | + | // ClosedByMergeRequest is zero unless a merge closed the issue. | |
| 77 | + | ClosedByMergeRequest int64 | |
| 76 | 78 | // Version is set by the store on read: Update fails with ErrConflict when | |
| 77 | 79 | // the stored issue changed since. Opaque to everyone else. | |
| 78 | 80 | Version uint64 | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 83 | + | // IssueLink is a merge request naming an issue in the same repository. | |
| 84 | + | type IssueLink struct { | |
| 85 | + | Issue, MergeRequest int64 | |
| 86 | + | Closes bool | |
| 87 | + | } | |
| 88 | + | ||
| 81 | 89 | // IsOpen reports whether the issue is open. | |
| 82 | 90 | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | |
| 83 | 91 | ||
| @@ -73,11 +73,19 @@ type Issue struct { | |||||
| 73 | // External is true for an issue approved from the public form: its text | 73 | // External is true for an issue approved from the public form: its text | ||
| 74 | // comes from an anonymous visitor and is rendered as untrusted. | 74 | // comes from an anonymous visitor and is rendered as untrusted. | ||
| 75 | External bool | 75 | External bool | ||
| 76 | + | // ClosedByMergeRequest is zero unless a merge closed the issue. | |||
| 77 | + | ClosedByMergeRequest int64 | |||
| 76 | // Version is set by the store on read: Update fails with ErrConflict when | 78 | // Version is set by the store on read: Update fails with ErrConflict when | ||
| 77 | // the stored issue changed since. Opaque to everyone else. | 79 | // the stored issue changed since. Opaque to everyone else. | ||
| 78 | Version uint64 | 80 | Version uint64 | ||
| 79 | } | 81 | } | ||
| 80 | 82 | ||||
| 83 | + | // IssueLink is a merge request naming an issue in the same repository. | |||
| 84 | + | type IssueLink struct { | |||
| 85 | + | Issue, MergeRequest int64 | |||
| 86 | + | Closes bool | |||
| 87 | + | } | |||
| 88 | + | ||||
| 81 | // IsOpen reports whether the issue is open. | 89 | // IsOpen reports whether the issue is open. | ||
| 82 | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | 90 | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | ||
| 83 | 91 | ||||
A internal/domain/reference.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "slices" | |
| 6 | + | "strconv" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // MaxReferenceTextBytes bounds each title, description, note and commit message scanned. | |
| 10 | + | const MaxReferenceTextBytes = 16 << 10 | |
| 11 | + | ||
| 12 | + | var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`) | |
| 13 | + | var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`) | |
| 14 | + | ||
| 15 | + | // FindReferences returns the positive #N numbers in text, sorted and unique. | |
| 16 | + | // Numbers glued to words, entities or another # are excluded, as are zero, | |
| 17 | + | // leading zeroes and numbers longer than ten digits. Code is scanned as text; | |
| 18 | + | // cross-repository references and !N are not supported. | |
| 19 | + | func FindReferences(text string) []int64 { | |
| 20 | + | numbers := make(map[int64]struct{}) | |
| 21 | + | // Resume at the end of the number, retaining its following delimiter for the | |
| 22 | + | // next match (for example, both references in "#1,#2"). | |
| 23 | + | for offset := 0; offset < len(text); { | |
| 24 | + | match := numberRef.FindStringSubmatchIndex(text[offset:]) | |
| 25 | + | if match == nil { | |
| 26 | + | break | |
| 27 | + | } | |
| 28 | + | start, end := offset+match[4], offset+match[5] | |
| 29 | + | // A sliced search must not manufacture a beginning-of-text boundary. | |
| 30 | + | if start < 2 || !referenceWord(text[start-2]) { | |
| 31 | + | n, _ := strconv.ParseInt(text[start:end], 10, 64) | |
| 32 | + | numbers[n] = struct{}{} | |
| 33 | + | } | |
| 34 | + | offset = end | |
| 35 | + | } | |
| 36 | + | return sortedReferences(numbers) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func referenceWord(b byte) bool { | |
| 40 | + | return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#' | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // FindClosingReferences returns sorted, unique references following close, | |
| 44 | + | // closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case | |
| 45 | + | // insensitive). A colon is optional; lists use commas or "and", not spaces. | |
| 46 | + | // Like FindReferences, this scans code spans and fences as ordinary text. | |
| 47 | + | func FindClosingReferences(text string) []int64 { | |
| 48 | + | numbers := make(map[int64]struct{}) | |
| 49 | + | for _, match := range closingRef.FindAllString(text, -1) { | |
| 50 | + | for _, n := range FindReferences(match) { | |
| 51 | + | numbers[n] = struct{}{} | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return sortedReferences(numbers) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func sortedReferences(numbers map[int64]struct{}) []int64 { | |
| 58 | + | var result []int64 | |
| 59 | + | for n := range numbers { | |
| 60 | + | result = append(result, n) | |
| 61 | + | } | |
| 62 | + | slices.Sort(result) | |
| 63 | + | return result | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // MergeRequestReferences describes the issues named by a merge request. | |
| 67 | + | type MergeRequestReferences struct { | |
| 68 | + | Mentions []int64 | |
| 69 | + | Closes []int64 | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // ScanMergeRequest scans titles, descriptions, notes and commit messages for | |
| 73 | + | // references. Notes can mention issues but cannot close them. Each individual | |
| 74 | + | // text is truncated to MaxReferenceTextBytes, without a trailing partial token, before scanning; no Markdown or | |
| 75 | + | // cross-repository interpretation is performed. | |
| 76 | + | func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences { | |
| 77 | + | mentions, closes := make(map[int64]struct{}), make(map[int64]struct{}) | |
| 78 | + | scan := func(text string, closing bool) { | |
| 79 | + | if len(text) > MaxReferenceTextBytes { | |
| 80 | + | // Drop the trailing partial token: back off to the last ASCII | |
| 81 | + | // whitespace at or before the limit. | |
| 82 | + | cut := -1 | |
| 83 | + | for i := MaxReferenceTextBytes; i >= 0; i-- { | |
| 84 | + | if i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') { | |
| 85 | + | cut = i | |
| 86 | + | break | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | if cut < 0 { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | text = text[:cut] | |
| 93 | + | } | |
| 94 | + | for _, n := range FindReferences(text) { | |
| 95 | + | mentions[n] = struct{}{} | |
| 96 | + | } | |
| 97 | + | if closing { | |
| 98 | + | for _, n := range FindClosingReferences(text) { | |
| 99 | + | closes[n] = struct{}{} | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | scan(title, true) | |
| 104 | + | scan(description, true) | |
| 105 | + | for _, text := range notes { | |
| 106 | + | scan(text, false) | |
| 107 | + | } | |
| 108 | + | for _, text := range commitMessages { | |
| 109 | + | scan(text, true) | |
| 110 | + | } | |
| 111 | + | return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)} | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "slices" | |||
| 6 | + | "strconv" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // MaxReferenceTextBytes bounds each title, description, note and commit message scanned. | |||
| 10 | + | const MaxReferenceTextBytes = 16 << 10 | |||
| 11 | + | ||||
| 12 | + | var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`) | |||
| 13 | + | var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`) | |||
| 14 | + | ||||
| 15 | + | // FindReferences returns the positive #N numbers in text, sorted and unique. | |||
| 16 | + | // Numbers glued to words, entities or another # are excluded, as are zero, | |||
| 17 | + | // leading zeroes and numbers longer than ten digits. Code is scanned as text; | |||
| 18 | + | // cross-repository references and !N are not supported. | |||
| 19 | + | func FindReferences(text string) []int64 { | |||
| 20 | + | numbers := make(map[int64]struct{}) | |||
| 21 | + | // Resume at the end of the number, retaining its following delimiter for the | |||
| 22 | + | // next match (for example, both references in "#1,#2"). | |||
| 23 | + | for offset := 0; offset < len(text); { | |||
| 24 | + | match := numberRef.FindStringSubmatchIndex(text[offset:]) | |||
| 25 | + | if match == nil { | |||
| 26 | + | break | |||
| 27 | + | } | |||
| 28 | + | start, end := offset+match[4], offset+match[5] | |||
| 29 | + | // A sliced search must not manufacture a beginning-of-text boundary. | |||
| 30 | + | if start < 2 || !referenceWord(text[start-2]) { | |||
| 31 | + | n, _ := strconv.ParseInt(text[start:end], 10, 64) | |||
| 32 | + | numbers[n] = struct{}{} | |||
| 33 | + | } | |||
| 34 | + | offset = end | |||
| 35 | + | } | |||
| 36 | + | return sortedReferences(numbers) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func referenceWord(b byte) bool { | |||
| 40 | + | return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#' | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // FindClosingReferences returns sorted, unique references following close, | |||
| 44 | + | // closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case | |||
| 45 | + | // insensitive). A colon is optional; lists use commas or "and", not spaces. | |||
| 46 | + | // Like FindReferences, this scans code spans and fences as ordinary text. | |||
| 47 | + | func FindClosingReferences(text string) []int64 { | |||
| 48 | + | numbers := make(map[int64]struct{}) | |||
| 49 | + | for _, match := range closingRef.FindAllString(text, -1) { | |||
| 50 | + | for _, n := range FindReferences(match) { | |||
| 51 | + | numbers[n] = struct{}{} | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | return sortedReferences(numbers) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func sortedReferences(numbers map[int64]struct{}) []int64 { | |||
| 58 | + | var result []int64 | |||
| 59 | + | for n := range numbers { | |||
| 60 | + | result = append(result, n) | |||
| 61 | + | } | |||
| 62 | + | slices.Sort(result) | |||
| 63 | + | return result | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // MergeRequestReferences describes the issues named by a merge request. | |||
| 67 | + | type MergeRequestReferences struct { | |||
| 68 | + | Mentions []int64 | |||
| 69 | + | Closes []int64 | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // ScanMergeRequest scans titles, descriptions, notes and commit messages for | |||
| 73 | + | // references. Notes can mention issues but cannot close them. Each individual | |||
| 74 | + | // text is truncated to MaxReferenceTextBytes, without a trailing partial token, before scanning; no Markdown or | |||
| 75 | + | // cross-repository interpretation is performed. | |||
| 76 | + | func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences { | |||
| 77 | + | mentions, closes := make(map[int64]struct{}), make(map[int64]struct{}) | |||
| 78 | + | scan := func(text string, closing bool) { | |||
| 79 | + | if len(text) > MaxReferenceTextBytes { | |||
| 80 | + | // Drop the trailing partial token: back off to the last ASCII | |||
| 81 | + | // whitespace at or before the limit. | |||
| 82 | + | cut := -1 | |||
| 83 | + | for i := MaxReferenceTextBytes; i >= 0; i-- { | |||
| 84 | + | if i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') { | |||
| 85 | + | cut = i | |||
| 86 | + | break | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | if cut < 0 { | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | text = text[:cut] | |||
| 93 | + | } | |||
| 94 | + | for _, n := range FindReferences(text) { | |||
| 95 | + | mentions[n] = struct{}{} | |||
| 96 | + | } | |||
| 97 | + | if closing { | |||
| 98 | + | for _, n := range FindClosingReferences(text) { | |||
| 99 | + | closes[n] = struct{}{} | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | scan(title, true) | |||
| 104 | + | scan(description, true) | |||
| 105 | + | for _, text := range notes { | |||
| 106 | + | scan(text, false) | |||
| 107 | + | } | |||
| 108 | + | for _, text := range commitMessages { | |||
| 109 | + | scan(text, true) | |||
| 110 | + | } | |||
| 111 | + | return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)} | |||
| 112 | + | } | |||
A internal/domain/reference_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFindReferences(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | text string | |
| 12 | + | mentions, closes []int64 | |
| 13 | + | }{ | |
| 14 | + | {"abc#3 a_b#3 1#3 ' ##3 #3abc #03 #0", nil, nil}, | |
| 15 | + | {"(#3) see #3, \"#3\"", []int64{3}, nil}, | |
| 16 | + | {"fixes #3.", []int64{3}, []int64{3}}, | |
| 17 | + | {"Fixes: #3", []int64{3}, []int64{3}}, | |
| 18 | + | {"fixes:#3", []int64{3}, []int64{3}}, | |
| 19 | + | {"Closes #3", []int64{3}, []int64{3}}, | |
| 20 | + | {"RESOLVED #3", []int64{3}, []int64{3}}, | |
| 21 | + | {"fix #3", []int64{3}, []int64{3}}, | |
| 22 | + | {"prefixes #3", []int64{3}, nil}, | |
| 23 | + | {"fixes 3 fixes#3", nil, nil}, | |
| 24 | + | {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}}, | |
| 25 | + | {"fixes #3 #4", []int64{3, 4}, []int64{3}}, | |
| 26 | + | {"#5,#3,#5", []int64{3, 5}, nil}, | |
| 27 | + | {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil}, | |
| 28 | + | {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}}, | |
| 29 | + | {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}}, | |
| 30 | + | } | |
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.text, func(t *testing.T) { | |
| 33 | + | if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) { | |
| 34 | + | t.Errorf("mentions = %v, want %v", got, tt.mentions) | |
| 35 | + | } | |
| 36 | + | if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) { | |
| 37 | + | t.Errorf("closes = %v, want %v", got, tt.closes) | |
| 38 | + | } | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestScanMergeRequest(t *testing.T) { | |
| 44 | + | got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"}) | |
| 45 | + | want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}} | |
| 46 | + | if !reflect.DeepEqual(got, want) { | |
| 47 | + | t.Fatalf("got %+v, want %+v", got, want) | |
| 48 | + | } | |
| 49 | + | for _, scan := range []func(string) MergeRequestReferences{ | |
| 50 | + | func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) }, | |
| 51 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) }, | |
| 52 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) }, | |
| 53 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) }, | |
| 54 | + | } { | |
| 55 | + | if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 { | |
| 56 | + | t.Errorf("text beyond limit scanned: %+v", got) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestScanMergeRequestTruncationDropsPartialToken(t *testing.T) { | |
| 62 | + | prefix := strings.Repeat(" ", MaxReferenceTextBytes-len("fixes #123")) | |
| 63 | + | got := ScanMergeRequest("", "", nil, []string{prefix + "fixes #12345"}) | |
| 64 | + | if len(got.Mentions) != 0 || len(got.Closes) != 0 { | |
| 65 | + | t.Errorf("split number reported: %+v", got) | |
| 66 | + | } | |
| 67 | + | // Fully before the limit, followed by a split token. | |
| 68 | + | full := "fixes #7 " + strings.Repeat("a", MaxReferenceTextBytes) | |
| 69 | + | got = ScanMergeRequest("", "", nil, []string{full}) | |
| 70 | + | if !reflect.DeepEqual(got, MergeRequestReferences{Mentions: []int64{7}, Closes: []int64{7}}) { | |
| 71 | + | t.Errorf("got %+v", got) | |
| 72 | + | } | |
| 73 | + | // No whitespace at all: nothing scanned. | |
| 74 | + | got = ScanMergeRequest("", "", nil, []string{strings.Repeat("a", MaxReferenceTextBytes-3) + " #1" + "2"}) | |
| 75 | + | if len(got.Mentions) != 0 { | |
| 76 | + | t.Errorf("got %+v", got) | |
| 77 | + | } | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFindReferences(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | text string | |||
| 12 | + | mentions, closes []int64 | |||
| 13 | + | }{ | |||
| 14 | + | {"abc#3 a_b#3 1#3 ' ##3 #3abc #03 #0", nil, nil}, | |||
| 15 | + | {"(#3) see #3, \"#3\"", []int64{3}, nil}, | |||
| 16 | + | {"fixes #3.", []int64{3}, []int64{3}}, | |||
| 17 | + | {"Fixes: #3", []int64{3}, []int64{3}}, | |||
| 18 | + | {"fixes:#3", []int64{3}, []int64{3}}, | |||
| 19 | + | {"Closes #3", []int64{3}, []int64{3}}, | |||
| 20 | + | {"RESOLVED #3", []int64{3}, []int64{3}}, | |||
| 21 | + | {"fix #3", []int64{3}, []int64{3}}, | |||
| 22 | + | {"prefixes #3", []int64{3}, nil}, | |||
| 23 | + | {"fixes 3 fixes#3", nil, nil}, | |||
| 24 | + | {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}}, | |||
| 25 | + | {"fixes #3 #4", []int64{3, 4}, []int64{3}}, | |||
| 26 | + | {"#5,#3,#5", []int64{3, 5}, nil}, | |||
| 27 | + | {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil}, | |||
| 28 | + | {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}}, | |||
| 29 | + | {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}}, | |||
| 30 | + | } | |||
| 31 | + | for _, tt := range tests { | |||
| 32 | + | t.Run(tt.text, func(t *testing.T) { | |||
| 33 | + | if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) { | |||
| 34 | + | t.Errorf("mentions = %v, want %v", got, tt.mentions) | |||
| 35 | + | } | |||
| 36 | + | if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) { | |||
| 37 | + | t.Errorf("closes = %v, want %v", got, tt.closes) | |||
| 38 | + | } | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestScanMergeRequest(t *testing.T) { | |||
| 44 | + | got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"}) | |||
| 45 | + | want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}} | |||
| 46 | + | if !reflect.DeepEqual(got, want) { | |||
| 47 | + | t.Fatalf("got %+v, want %+v", got, want) | |||
| 48 | + | } | |||
| 49 | + | for _, scan := range []func(string) MergeRequestReferences{ | |||
| 50 | + | func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) }, | |||
| 51 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) }, | |||
| 52 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) }, | |||
| 53 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) }, | |||
| 54 | + | } { | |||
| 55 | + | if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 { | |||
| 56 | + | t.Errorf("text beyond limit scanned: %+v", got) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestScanMergeRequestTruncationDropsPartialToken(t *testing.T) { | |||
| 62 | + | prefix := strings.Repeat(" ", MaxReferenceTextBytes-len("fixes #123")) | |||
| 63 | + | got := ScanMergeRequest("", "", nil, []string{prefix + "fixes #12345"}) | |||
| 64 | + | if len(got.Mentions) != 0 || len(got.Closes) != 0 { | |||
| 65 | + | t.Errorf("split number reported: %+v", got) | |||
| 66 | + | } | |||
| 67 | + | // Fully before the limit, followed by a split token. | |||
| 68 | + | full := "fixes #7 " + strings.Repeat("a", MaxReferenceTextBytes) | |||
| 69 | + | got = ScanMergeRequest("", "", nil, []string{full}) | |||
| 70 | + | if !reflect.DeepEqual(got, MergeRequestReferences{Mentions: []int64{7}, Closes: []int64{7}}) { | |||
| 71 | + | t.Errorf("got %+v", got) | |||
| 72 | + | } | |||
| 73 | + | // No whitespace at all: nothing scanned. | |||
| 74 | + | got = ScanMergeRequest("", "", nil, []string{strings.Repeat("a", MaxReferenceTextBytes-3) + " #1" + "2"}) | |||
| 75 | + | if len(got.Mentions) != 0 { | |||
| 76 | + | t.Errorf("got %+v", got) | |||
| 77 | + | } | |||
| 78 | + | } | |||
M internal/ports/dto.go +12 -2
| @@ -253,9 +253,19 @@ type CreateIssueParams struct { | |||
| 253 | 253 | Body string | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | - | // IssueDetail is an issue with its comments. | |
| 256 | + | // IssueReference is a merge request of the same repository naming an issue. | |
| 257 | + | type IssueReference struct { | |
| 258 | + | Number int64 | |
| 259 | + | Title domain.MRTitle | |
| 260 | + | State domain.MRState | |
| 261 | + | Closes bool | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // IssueDetail is an issue with its comments and referencing merge requests. | |
| 257 | 265 | type IssueDetail struct { | |
| 258 | - | Issue domain.Issue | |
| 266 | + | // References are ascending by number, at most 100. | |
| 267 | + | References []IssueReference | |
| 268 | + | Issue domain.Issue | |
| 259 | 269 | // Comments are oldest first. | |
| 260 | 270 | Comments []domain.IssueComment | |
| 261 | 271 | } | |
| @@ -253,9 +253,19 @@ type CreateIssueParams struct { | |||||
| 253 | Body string | 253 | Body string | ||
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | - | // IssueDetail is an issue with its comments. | 256 | + | // IssueReference is a merge request of the same repository naming an issue. |
| 257 | + | type IssueReference struct { | |||
| 258 | + | Number int64 | |||
| 259 | + | Title domain.MRTitle | |||
| 260 | + | State domain.MRState | |||
| 261 | + | Closes bool | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | // IssueDetail is an issue with its comments and referencing merge requests. | |||
| 257 | type IssueDetail struct { | 265 | type IssueDetail struct { | ||
| 258 | - | Issue domain.Issue | 266 | + | // References are ascending by number, at most 100. |
| 267 | + | References []IssueReference | |||
| 268 | + | Issue domain.Issue | |||
| 259 | // Comments are oldest first. | 269 | // Comments are oldest first. | ||
| 260 | Comments []domain.IssueComment | 270 | Comments []domain.IssueComment | ||
| 261 | } | 271 | } | ||
A internal/ports/mocks/IssueLinkStore.go +296 -0
| @@ -0,0 +1,296 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewIssueLinkStore(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *IssueLinkStore { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &IssueLinkStore{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // IssueLinkStore is an autogenerated mock type for the IssueLinkStore type | |
| 38 | + | type IssueLinkStore struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type IssueLinkStore_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter { | |
| 47 | + | return &IssueLinkStore_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // LinksOfIssue provides a mock function for the type IssueLinkStore | |
| 51 | + | func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) { | |
| 52 | + | ret := _mock.Called(ctx, repo, issue, limit) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for LinksOfIssue") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 []domain.IssueLink | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok { | |
| 61 | + | return returnFunc(ctx, repo, issue, limit) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok { | |
| 64 | + | r0 = returnFunc(ctx, repo, issue, limit) | |
| 65 | + | } else { | |
| 66 | + | if ret.Get(0) != nil { | |
| 67 | + | r0 = ret.Get(0).([]domain.IssueLink) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, repo, issue, limit) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue' | |
| 79 | + | type IssueLinkStore_LinksOfIssue_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // LinksOfIssue is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - issue int64 | |
| 87 | + | // - limit int | |
| 88 | + | func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call { | |
| 89 | + | return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 context.Context | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(context.Context) | |
| 97 | + | } | |
| 98 | + | var arg1 domain.RepoName | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.RepoName) | |
| 101 | + | } | |
| 102 | + | var arg2 int64 | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(int64) | |
| 105 | + | } | |
| 106 | + | var arg3 int | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(int) | |
| 109 | + | } | |
| 110 | + | run( | |
| 111 | + | arg0, | |
| 112 | + | arg1, | |
| 113 | + | arg2, | |
| 114 | + | arg3, | |
| 115 | + | ) | |
| 116 | + | }) | |
| 117 | + | return _c | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call { | |
| 121 | + | _c.Call.Return(issueLinks, err) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call { | |
| 126 | + | _c.Call.Return(run) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // LinksOfMergeRequest provides a mock function for the type IssueLinkStore | |
| 131 | + | func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) { | |
| 132 | + | ret := _mock.Called(ctx, repo, mr) | |
| 133 | + | ||
| 134 | + | if len(ret) == 0 { | |
| 135 | + | panic("no return value specified for LinksOfMergeRequest") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | var r0 []domain.IssueLink | |
| 139 | + | var r1 error | |
| 140 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok { | |
| 141 | + | return returnFunc(ctx, repo, mr) | |
| 142 | + | } | |
| 143 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok { | |
| 144 | + | r0 = returnFunc(ctx, repo, mr) | |
| 145 | + | } else { | |
| 146 | + | if ret.Get(0) != nil { | |
| 147 | + | r0 = ret.Get(0).([]domain.IssueLink) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 151 | + | r1 = returnFunc(ctx, repo, mr) | |
| 152 | + | } else { | |
| 153 | + | r1 = ret.Error(1) | |
| 154 | + | } | |
| 155 | + | return r0, r1 | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest' | |
| 159 | + | type IssueLinkStore_LinksOfMergeRequest_Call struct { | |
| 160 | + | *mock.Call | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // LinksOfMergeRequest is a helper method to define mock.On call | |
| 164 | + | // - ctx context.Context | |
| 165 | + | // - repo domain.RepoName | |
| 166 | + | // - mr int64 | |
| 167 | + | func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 168 | + | return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)} | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 173 | + | var arg0 context.Context | |
| 174 | + | if args[0] != nil { | |
| 175 | + | arg0 = args[0].(context.Context) | |
| 176 | + | } | |
| 177 | + | var arg1 domain.RepoName | |
| 178 | + | if args[1] != nil { | |
| 179 | + | arg1 = args[1].(domain.RepoName) | |
| 180 | + | } | |
| 181 | + | var arg2 int64 | |
| 182 | + | if args[2] != nil { | |
| 183 | + | arg2 = args[2].(int64) | |
| 184 | + | } | |
| 185 | + | run( | |
| 186 | + | arg0, | |
| 187 | + | arg1, | |
| 188 | + | arg2, | |
| 189 | + | ) | |
| 190 | + | }) | |
| 191 | + | return _c | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 195 | + | _c.Call.Return(issueLinks, err) | |
| 196 | + | return _c | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 200 | + | _c.Call.Return(run) | |
| 201 | + | return _c | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // ReplaceLinks provides a mock function for the type IssueLinkStore | |
| 205 | + | func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) { | |
| 206 | + | ret := _mock.Called(ctx, repo, mr, issues, closes) | |
| 207 | + | ||
| 208 | + | if len(ret) == 0 { | |
| 209 | + | panic("no return value specified for ReplaceLinks") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | var r0 []domain.IssueLink | |
| 213 | + | var r1 []domain.IssueLink | |
| 214 | + | var r2 error | |
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok { | |
| 216 | + | return returnFunc(ctx, repo, mr, issues, closes) | |
| 217 | + | } | |
| 218 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |
| 219 | + | r0 = returnFunc(ctx, repo, mr, issues, closes) | |
| 220 | + | } else { | |
| 221 | + | if ret.Get(0) != nil { | |
| 222 | + | r0 = ret.Get(0).([]domain.IssueLink) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |
| 226 | + | r1 = returnFunc(ctx, repo, mr, issues, closes) | |
| 227 | + | } else { | |
| 228 | + | if ret.Get(1) != nil { | |
| 229 | + | r1 = ret.Get(1).([]domain.IssueLink) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok { | |
| 233 | + | r2 = returnFunc(ctx, repo, mr, issues, closes) | |
| 234 | + | } else { | |
| 235 | + | r2 = ret.Error(2) | |
| 236 | + | } | |
| 237 | + | return r0, r1, r2 | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks' | |
| 241 | + | type IssueLinkStore_ReplaceLinks_Call struct { | |
| 242 | + | *mock.Call | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // ReplaceLinks is a helper method to define mock.On call | |
| 246 | + | // - ctx context.Context | |
| 247 | + | // - repo domain.RepoName | |
| 248 | + | // - mr int64 | |
| 249 | + | // - issues []int64 | |
| 250 | + | // - closes []int64 | |
| 251 | + | func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call { | |
| 252 | + | return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)} | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call { | |
| 256 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 257 | + | var arg0 context.Context | |
| 258 | + | if args[0] != nil { | |
| 259 | + | arg0 = args[0].(context.Context) | |
| 260 | + | } | |
| 261 | + | var arg1 domain.RepoName | |
| 262 | + | if args[1] != nil { | |
| 263 | + | arg1 = args[1].(domain.RepoName) | |
| 264 | + | } | |
| 265 | + | var arg2 int64 | |
| 266 | + | if args[2] != nil { | |
| 267 | + | arg2 = args[2].(int64) | |
| 268 | + | } | |
| 269 | + | var arg3 []int64 | |
| 270 | + | if args[3] != nil { | |
| 271 | + | arg3 = args[3].([]int64) | |
| 272 | + | } | |
| 273 | + | var arg4 []int64 | |
| 274 | + | if args[4] != nil { | |
| 275 | + | arg4 = args[4].([]int64) | |
| 276 | + | } | |
| 277 | + | run( | |
| 278 | + | arg0, | |
| 279 | + | arg1, | |
| 280 | + | arg2, | |
| 281 | + | arg3, | |
| 282 | + | arg4, | |
| 283 | + | ) | |
| 284 | + | }) | |
| 285 | + | return _c | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call { | |
| 289 | + | _c.Call.Return(added, removed, err) | |
| 290 | + | return _c | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call { | |
| 294 | + | _c.Call.Return(run) | |
| 295 | + | return _c | |
| 296 | + | } | |
| @@ -0,0 +1,296 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewIssueLinkStore(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *IssueLinkStore { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &IssueLinkStore{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // IssueLinkStore is an autogenerated mock type for the IssueLinkStore type | |||
| 38 | + | type IssueLinkStore struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type IssueLinkStore_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter { | |||
| 47 | + | return &IssueLinkStore_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // LinksOfIssue provides a mock function for the type IssueLinkStore | |||
| 51 | + | func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) { | |||
| 52 | + | ret := _mock.Called(ctx, repo, issue, limit) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for LinksOfIssue") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 []domain.IssueLink | |||
| 59 | + | var r1 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok { | |||
| 61 | + | return returnFunc(ctx, repo, issue, limit) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok { | |||
| 64 | + | r0 = returnFunc(ctx, repo, issue, limit) | |||
| 65 | + | } else { | |||
| 66 | + | if ret.Get(0) != nil { | |||
| 67 | + | r0 = ret.Get(0).([]domain.IssueLink) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok { | |||
| 71 | + | r1 = returnFunc(ctx, repo, issue, limit) | |||
| 72 | + | } else { | |||
| 73 | + | r1 = ret.Error(1) | |||
| 74 | + | } | |||
| 75 | + | return r0, r1 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue' | |||
| 79 | + | type IssueLinkStore_LinksOfIssue_Call struct { | |||
| 80 | + | *mock.Call | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // LinksOfIssue is a helper method to define mock.On call | |||
| 84 | + | // - ctx context.Context | |||
| 85 | + | // - repo domain.RepoName | |||
| 86 | + | // - issue int64 | |||
| 87 | + | // - limit int | |||
| 88 | + | func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call { | |||
| 89 | + | return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)} | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call { | |||
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 94 | + | var arg0 context.Context | |||
| 95 | + | if args[0] != nil { | |||
| 96 | + | arg0 = args[0].(context.Context) | |||
| 97 | + | } | |||
| 98 | + | var arg1 domain.RepoName | |||
| 99 | + | if args[1] != nil { | |||
| 100 | + | arg1 = args[1].(domain.RepoName) | |||
| 101 | + | } | |||
| 102 | + | var arg2 int64 | |||
| 103 | + | if args[2] != nil { | |||
| 104 | + | arg2 = args[2].(int64) | |||
| 105 | + | } | |||
| 106 | + | var arg3 int | |||
| 107 | + | if args[3] != nil { | |||
| 108 | + | arg3 = args[3].(int) | |||
| 109 | + | } | |||
| 110 | + | run( | |||
| 111 | + | arg0, | |||
| 112 | + | arg1, | |||
| 113 | + | arg2, | |||
| 114 | + | arg3, | |||
| 115 | + | ) | |||
| 116 | + | }) | |||
| 117 | + | return _c | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call { | |||
| 121 | + | _c.Call.Return(issueLinks, err) | |||
| 122 | + | return _c | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call { | |||
| 126 | + | _c.Call.Return(run) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // LinksOfMergeRequest provides a mock function for the type IssueLinkStore | |||
| 131 | + | func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) { | |||
| 132 | + | ret := _mock.Called(ctx, repo, mr) | |||
| 133 | + | ||||
| 134 | + | if len(ret) == 0 { | |||
| 135 | + | panic("no return value specified for LinksOfMergeRequest") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | var r0 []domain.IssueLink | |||
| 139 | + | var r1 error | |||
| 140 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok { | |||
| 141 | + | return returnFunc(ctx, repo, mr) | |||
| 142 | + | } | |||
| 143 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok { | |||
| 144 | + | r0 = returnFunc(ctx, repo, mr) | |||
| 145 | + | } else { | |||
| 146 | + | if ret.Get(0) != nil { | |||
| 147 | + | r0 = ret.Get(0).([]domain.IssueLink) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |||
| 151 | + | r1 = returnFunc(ctx, repo, mr) | |||
| 152 | + | } else { | |||
| 153 | + | r1 = ret.Error(1) | |||
| 154 | + | } | |||
| 155 | + | return r0, r1 | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest' | |||
| 159 | + | type IssueLinkStore_LinksOfMergeRequest_Call struct { | |||
| 160 | + | *mock.Call | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // LinksOfMergeRequest is a helper method to define mock.On call | |||
| 164 | + | // - ctx context.Context | |||
| 165 | + | // - repo domain.RepoName | |||
| 166 | + | // - mr int64 | |||
| 167 | + | func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 168 | + | return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)} | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 173 | + | var arg0 context.Context | |||
| 174 | + | if args[0] != nil { | |||
| 175 | + | arg0 = args[0].(context.Context) | |||
| 176 | + | } | |||
| 177 | + | var arg1 domain.RepoName | |||
| 178 | + | if args[1] != nil { | |||
| 179 | + | arg1 = args[1].(domain.RepoName) | |||
| 180 | + | } | |||
| 181 | + | var arg2 int64 | |||
| 182 | + | if args[2] != nil { | |||
| 183 | + | arg2 = args[2].(int64) | |||
| 184 | + | } | |||
| 185 | + | run( | |||
| 186 | + | arg0, | |||
| 187 | + | arg1, | |||
| 188 | + | arg2, | |||
| 189 | + | ) | |||
| 190 | + | }) | |||
| 191 | + | return _c | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 195 | + | _c.Call.Return(issueLinks, err) | |||
| 196 | + | return _c | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 200 | + | _c.Call.Return(run) | |||
| 201 | + | return _c | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // ReplaceLinks provides a mock function for the type IssueLinkStore | |||
| 205 | + | func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) { | |||
| 206 | + | ret := _mock.Called(ctx, repo, mr, issues, closes) | |||
| 207 | + | ||||
| 208 | + | if len(ret) == 0 { | |||
| 209 | + | panic("no return value specified for ReplaceLinks") | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | var r0 []domain.IssueLink | |||
| 213 | + | var r1 []domain.IssueLink | |||
| 214 | + | var r2 error | |||
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok { | |||
| 216 | + | return returnFunc(ctx, repo, mr, issues, closes) | |||
| 217 | + | } | |||
| 218 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |||
| 219 | + | r0 = returnFunc(ctx, repo, mr, issues, closes) | |||
| 220 | + | } else { | |||
| 221 | + | if ret.Get(0) != nil { | |||
| 222 | + | r0 = ret.Get(0).([]domain.IssueLink) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |||
| 226 | + | r1 = returnFunc(ctx, repo, mr, issues, closes) | |||
| 227 | + | } else { | |||
| 228 | + | if ret.Get(1) != nil { | |||
| 229 | + | r1 = ret.Get(1).([]domain.IssueLink) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok { | |||
| 233 | + | r2 = returnFunc(ctx, repo, mr, issues, closes) | |||
| 234 | + | } else { | |||
| 235 | + | r2 = ret.Error(2) | |||
| 236 | + | } | |||
| 237 | + | return r0, r1, r2 | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks' | |||
| 241 | + | type IssueLinkStore_ReplaceLinks_Call struct { | |||
| 242 | + | *mock.Call | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // ReplaceLinks is a helper method to define mock.On call | |||
| 246 | + | // - ctx context.Context | |||
| 247 | + | // - repo domain.RepoName | |||
| 248 | + | // - mr int64 | |||
| 249 | + | // - issues []int64 | |||
| 250 | + | // - closes []int64 | |||
| 251 | + | func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call { | |||
| 252 | + | return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)} | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call { | |||
| 256 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 257 | + | var arg0 context.Context | |||
| 258 | + | if args[0] != nil { | |||
| 259 | + | arg0 = args[0].(context.Context) | |||
| 260 | + | } | |||
| 261 | + | var arg1 domain.RepoName | |||
| 262 | + | if args[1] != nil { | |||
| 263 | + | arg1 = args[1].(domain.RepoName) | |||
| 264 | + | } | |||
| 265 | + | var arg2 int64 | |||
| 266 | + | if args[2] != nil { | |||
| 267 | + | arg2 = args[2].(int64) | |||
| 268 | + | } | |||
| 269 | + | var arg3 []int64 | |||
| 270 | + | if args[3] != nil { | |||
| 271 | + | arg3 = args[3].([]int64) | |||
| 272 | + | } | |||
| 273 | + | var arg4 []int64 | |||
| 274 | + | if args[4] != nil { | |||
| 275 | + | arg4 = args[4].([]int64) | |||
| 276 | + | } | |||
| 277 | + | run( | |||
| 278 | + | arg0, | |||
| 279 | + | arg1, | |||
| 280 | + | arg2, | |||
| 281 | + | arg3, | |||
| 282 | + | arg4, | |||
| 283 | + | ) | |||
| 284 | + | }) | |||
| 285 | + | return _c | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call { | |||
| 289 | + | _c.Call.Return(added, removed, err) | |||
| 290 | + | return _c | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call { | |||
| 294 | + | _c.Call.Return(run) | |||
| 295 | + | return _c | |||
| 296 | + | } | |||
A internal/ports/mocks/IssueLinkTrigger.go +262 -0
| @@ -0,0 +1,262 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewIssueLinkTrigger(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *IssueLinkTrigger { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &IssueLinkTrigger{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type | |
| 38 | + | type IssueLinkTrigger struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type IssueLinkTrigger_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter { | |
| 47 | + | return &IssueLinkTrigger_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // OnBranchesMoved provides a mock function for the type IssueLinkTrigger | |
| 51 | + | func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) { | |
| 52 | + | _mock.Called(context1, repoName, refUpdates) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved' | |
| 57 | + | type IssueLinkTrigger_OnBranchesMoved_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // OnBranchesMoved is a helper method to define mock.On call | |
| 62 | + | // - context1 context.Context | |
| 63 | + | // - repoName domain.RepoName | |
| 64 | + | // - refUpdates []domain.RefUpdate | |
| 65 | + | func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call { | |
| 66 | + | return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call { | |
| 70 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 71 | + | var arg0 context.Context | |
| 72 | + | if args[0] != nil { | |
| 73 | + | arg0 = args[0].(context.Context) | |
| 74 | + | } | |
| 75 | + | var arg1 domain.RepoName | |
| 76 | + | if args[1] != nil { | |
| 77 | + | arg1 = args[1].(domain.RepoName) | |
| 78 | + | } | |
| 79 | + | var arg2 []domain.RefUpdate | |
| 80 | + | if args[2] != nil { | |
| 81 | + | arg2 = args[2].([]domain.RefUpdate) | |
| 82 | + | } | |
| 83 | + | run( | |
| 84 | + | arg0, | |
| 85 | + | arg1, | |
| 86 | + | arg2, | |
| 87 | + | ) | |
| 88 | + | }) | |
| 89 | + | return _c | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call { | |
| 93 | + | _c.Call.Return() | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call { | |
| 98 | + | _c.Run(run) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger | |
| 103 | + | func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) { | |
| 104 | + | _mock.Called(context1, repo, n) | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged' | |
| 109 | + | type IssueLinkTrigger_OnMergeRequestChanged_Call struct { | |
| 110 | + | *mock.Call | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // OnMergeRequestChanged is a helper method to define mock.On call | |
| 114 | + | // - context1 context.Context | |
| 115 | + | // - repo domain.Repo | |
| 116 | + | // - n int64 | |
| 117 | + | func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call { | |
| 118 | + | return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)} | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call { | |
| 122 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 123 | + | var arg0 context.Context | |
| 124 | + | if args[0] != nil { | |
| 125 | + | arg0 = args[0].(context.Context) | |
| 126 | + | } | |
| 127 | + | var arg1 domain.Repo | |
| 128 | + | if args[1] != nil { | |
| 129 | + | arg1 = args[1].(domain.Repo) | |
| 130 | + | } | |
| 131 | + | var arg2 int64 | |
| 132 | + | if args[2] != nil { | |
| 133 | + | arg2 = args[2].(int64) | |
| 134 | + | } | |
| 135 | + | run( | |
| 136 | + | arg0, | |
| 137 | + | arg1, | |
| 138 | + | arg2, | |
| 139 | + | ) | |
| 140 | + | }) | |
| 141 | + | return _c | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call { | |
| 145 | + | _c.Call.Return() | |
| 146 | + | return _c | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call { | |
| 150 | + | _c.Run(run) | |
| 151 | + | return _c | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger | |
| 155 | + | func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) { | |
| 156 | + | _mock.Called(context1, repo, mergeRequest) | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled' | |
| 161 | + | type IssueLinkTrigger_OnMergeRequestSettled_Call struct { | |
| 162 | + | *mock.Call | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // OnMergeRequestSettled is a helper method to define mock.On call | |
| 166 | + | // - context1 context.Context | |
| 167 | + | // - repo domain.Repo | |
| 168 | + | // - mergeRequest domain.MergeRequest | |
| 169 | + | func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call { | |
| 170 | + | return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)} | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call { | |
| 174 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 175 | + | var arg0 context.Context | |
| 176 | + | if args[0] != nil { | |
| 177 | + | arg0 = args[0].(context.Context) | |
| 178 | + | } | |
| 179 | + | var arg1 domain.Repo | |
| 180 | + | if args[1] != nil { | |
| 181 | + | arg1 = args[1].(domain.Repo) | |
| 182 | + | } | |
| 183 | + | var arg2 domain.MergeRequest | |
| 184 | + | if args[2] != nil { | |
| 185 | + | arg2 = args[2].(domain.MergeRequest) | |
| 186 | + | } | |
| 187 | + | run( | |
| 188 | + | arg0, | |
| 189 | + | arg1, | |
| 190 | + | arg2, | |
| 191 | + | ) | |
| 192 | + | }) | |
| 193 | + | return _c | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call { | |
| 197 | + | _c.Call.Return() | |
| 198 | + | return _c | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call { | |
| 202 | + | _c.Run(run) | |
| 203 | + | return _c | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // OnMerged provides a mock function for the type IssueLinkTrigger | |
| 207 | + | func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) { | |
| 208 | + | _mock.Called(context1, principal, repo, mergeRequest) | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged' | |
| 213 | + | type IssueLinkTrigger_OnMerged_Call struct { | |
| 214 | + | *mock.Call | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // OnMerged is a helper method to define mock.On call | |
| 218 | + | // - context1 context.Context | |
| 219 | + | // - principal domain.Principal | |
| 220 | + | // - repo domain.Repo | |
| 221 | + | // - mergeRequest domain.MergeRequest | |
| 222 | + | func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call { | |
| 223 | + | return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)} | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call { | |
| 227 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 228 | + | var arg0 context.Context | |
| 229 | + | if args[0] != nil { | |
| 230 | + | arg0 = args[0].(context.Context) | |
| 231 | + | } | |
| 232 | + | var arg1 domain.Principal | |
| 233 | + | if args[1] != nil { | |
| 234 | + | arg1 = args[1].(domain.Principal) | |
| 235 | + | } | |
| 236 | + | var arg2 domain.Repo | |
| 237 | + | if args[2] != nil { | |
| 238 | + | arg2 = args[2].(domain.Repo) | |
| 239 | + | } | |
| 240 | + | var arg3 domain.MergeRequest | |
| 241 | + | if args[3] != nil { | |
| 242 | + | arg3 = args[3].(domain.MergeRequest) | |
| 243 | + | } | |
| 244 | + | run( | |
| 245 | + | arg0, | |
| 246 | + | arg1, | |
| 247 | + | arg2, | |
| 248 | + | arg3, | |
| 249 | + | ) | |
| 250 | + | }) | |
| 251 | + | return _c | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call { | |
| 255 | + | _c.Call.Return() | |
| 256 | + | return _c | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call { | |
| 260 | + | _c.Run(run) | |
| 261 | + | return _c | |
| 262 | + | } | |
| @@ -0,0 +1,262 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewIssueLinkTrigger creates a new instance of IssueLinkTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewIssueLinkTrigger(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *IssueLinkTrigger { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &IssueLinkTrigger{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // IssueLinkTrigger is an autogenerated mock type for the IssueLinkTrigger type | |||
| 38 | + | type IssueLinkTrigger struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type IssueLinkTrigger_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *IssueLinkTrigger) EXPECT() *IssueLinkTrigger_Expecter { | |||
| 47 | + | return &IssueLinkTrigger_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // OnBranchesMoved provides a mock function for the type IssueLinkTrigger | |||
| 51 | + | func (_mock *IssueLinkTrigger) OnBranchesMoved(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate) { | |||
| 52 | + | _mock.Called(context1, repoName, refUpdates) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // IssueLinkTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved' | |||
| 57 | + | type IssueLinkTrigger_OnBranchesMoved_Call struct { | |||
| 58 | + | *mock.Call | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // OnBranchesMoved is a helper method to define mock.On call | |||
| 62 | + | // - context1 context.Context | |||
| 63 | + | // - repoName domain.RepoName | |||
| 64 | + | // - refUpdates []domain.RefUpdate | |||
| 65 | + | func (_e *IssueLinkTrigger_Expecter) OnBranchesMoved(context1 any, repoName any, refUpdates any) *IssueLinkTrigger_OnBranchesMoved_Call { | |||
| 66 | + | return &IssueLinkTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", context1, repoName, refUpdates)} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Run(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call { | |||
| 70 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 71 | + | var arg0 context.Context | |||
| 72 | + | if args[0] != nil { | |||
| 73 | + | arg0 = args[0].(context.Context) | |||
| 74 | + | } | |||
| 75 | + | var arg1 domain.RepoName | |||
| 76 | + | if args[1] != nil { | |||
| 77 | + | arg1 = args[1].(domain.RepoName) | |||
| 78 | + | } | |||
| 79 | + | var arg2 []domain.RefUpdate | |||
| 80 | + | if args[2] != nil { | |||
| 81 | + | arg2 = args[2].([]domain.RefUpdate) | |||
| 82 | + | } | |||
| 83 | + | run( | |||
| 84 | + | arg0, | |||
| 85 | + | arg1, | |||
| 86 | + | arg2, | |||
| 87 | + | ) | |||
| 88 | + | }) | |||
| 89 | + | return _c | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *IssueLinkTrigger_OnBranchesMoved_Call) Return() *IssueLinkTrigger_OnBranchesMoved_Call { | |||
| 93 | + | _c.Call.Return() | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (_c *IssueLinkTrigger_OnBranchesMoved_Call) RunAndReturn(run func(context1 context.Context, repoName domain.RepoName, refUpdates []domain.RefUpdate)) *IssueLinkTrigger_OnBranchesMoved_Call { | |||
| 98 | + | _c.Run(run) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // OnMergeRequestChanged provides a mock function for the type IssueLinkTrigger | |||
| 103 | + | func (_mock *IssueLinkTrigger) OnMergeRequestChanged(context1 context.Context, repo domain.Repo, n int64) { | |||
| 104 | + | _mock.Called(context1, repo, n) | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // IssueLinkTrigger_OnMergeRequestChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestChanged' | |||
| 109 | + | type IssueLinkTrigger_OnMergeRequestChanged_Call struct { | |||
| 110 | + | *mock.Call | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // OnMergeRequestChanged is a helper method to define mock.On call | |||
| 114 | + | // - context1 context.Context | |||
| 115 | + | // - repo domain.Repo | |||
| 116 | + | // - n int64 | |||
| 117 | + | func (_e *IssueLinkTrigger_Expecter) OnMergeRequestChanged(context1 any, repo any, n any) *IssueLinkTrigger_OnMergeRequestChanged_Call { | |||
| 118 | + | return &IssueLinkTrigger_OnMergeRequestChanged_Call{Call: _e.mock.On("OnMergeRequestChanged", context1, repo, n)} | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Run(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call { | |||
| 122 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 123 | + | var arg0 context.Context | |||
| 124 | + | if args[0] != nil { | |||
| 125 | + | arg0 = args[0].(context.Context) | |||
| 126 | + | } | |||
| 127 | + | var arg1 domain.Repo | |||
| 128 | + | if args[1] != nil { | |||
| 129 | + | arg1 = args[1].(domain.Repo) | |||
| 130 | + | } | |||
| 131 | + | var arg2 int64 | |||
| 132 | + | if args[2] != nil { | |||
| 133 | + | arg2 = args[2].(int64) | |||
| 134 | + | } | |||
| 135 | + | run( | |||
| 136 | + | arg0, | |||
| 137 | + | arg1, | |||
| 138 | + | arg2, | |||
| 139 | + | ) | |||
| 140 | + | }) | |||
| 141 | + | return _c | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) Return() *IssueLinkTrigger_OnMergeRequestChanged_Call { | |||
| 145 | + | _c.Call.Return() | |||
| 146 | + | return _c | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (_c *IssueLinkTrigger_OnMergeRequestChanged_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, n int64)) *IssueLinkTrigger_OnMergeRequestChanged_Call { | |||
| 150 | + | _c.Run(run) | |||
| 151 | + | return _c | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // OnMergeRequestSettled provides a mock function for the type IssueLinkTrigger | |||
| 155 | + | func (_mock *IssueLinkTrigger) OnMergeRequestSettled(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest) { | |||
| 156 | + | _mock.Called(context1, repo, mergeRequest) | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // IssueLinkTrigger_OnMergeRequestSettled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMergeRequestSettled' | |||
| 161 | + | type IssueLinkTrigger_OnMergeRequestSettled_Call struct { | |||
| 162 | + | *mock.Call | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // OnMergeRequestSettled is a helper method to define mock.On call | |||
| 166 | + | // - context1 context.Context | |||
| 167 | + | // - repo domain.Repo | |||
| 168 | + | // - mergeRequest domain.MergeRequest | |||
| 169 | + | func (_e *IssueLinkTrigger_Expecter) OnMergeRequestSettled(context1 any, repo any, mergeRequest any) *IssueLinkTrigger_OnMergeRequestSettled_Call { | |||
| 170 | + | return &IssueLinkTrigger_OnMergeRequestSettled_Call{Call: _e.mock.On("OnMergeRequestSettled", context1, repo, mergeRequest)} | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Run(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call { | |||
| 174 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 175 | + | var arg0 context.Context | |||
| 176 | + | if args[0] != nil { | |||
| 177 | + | arg0 = args[0].(context.Context) | |||
| 178 | + | } | |||
| 179 | + | var arg1 domain.Repo | |||
| 180 | + | if args[1] != nil { | |||
| 181 | + | arg1 = args[1].(domain.Repo) | |||
| 182 | + | } | |||
| 183 | + | var arg2 domain.MergeRequest | |||
| 184 | + | if args[2] != nil { | |||
| 185 | + | arg2 = args[2].(domain.MergeRequest) | |||
| 186 | + | } | |||
| 187 | + | run( | |||
| 188 | + | arg0, | |||
| 189 | + | arg1, | |||
| 190 | + | arg2, | |||
| 191 | + | ) | |||
| 192 | + | }) | |||
| 193 | + | return _c | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) Return() *IssueLinkTrigger_OnMergeRequestSettled_Call { | |||
| 197 | + | _c.Call.Return() | |||
| 198 | + | return _c | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (_c *IssueLinkTrigger_OnMergeRequestSettled_Call) RunAndReturn(run func(context1 context.Context, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMergeRequestSettled_Call { | |||
| 202 | + | _c.Run(run) | |||
| 203 | + | return _c | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // OnMerged provides a mock function for the type IssueLinkTrigger | |||
| 207 | + | func (_mock *IssueLinkTrigger) OnMerged(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest) { | |||
| 208 | + | _mock.Called(context1, principal, repo, mergeRequest) | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // IssueLinkTrigger_OnMerged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerged' | |||
| 213 | + | type IssueLinkTrigger_OnMerged_Call struct { | |||
| 214 | + | *mock.Call | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // OnMerged is a helper method to define mock.On call | |||
| 218 | + | // - context1 context.Context | |||
| 219 | + | // - principal domain.Principal | |||
| 220 | + | // - repo domain.Repo | |||
| 221 | + | // - mergeRequest domain.MergeRequest | |||
| 222 | + | func (_e *IssueLinkTrigger_Expecter) OnMerged(context1 any, principal any, repo any, mergeRequest any) *IssueLinkTrigger_OnMerged_Call { | |||
| 223 | + | return &IssueLinkTrigger_OnMerged_Call{Call: _e.mock.On("OnMerged", context1, principal, repo, mergeRequest)} | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (_c *IssueLinkTrigger_OnMerged_Call) Run(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call { | |||
| 227 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 228 | + | var arg0 context.Context | |||
| 229 | + | if args[0] != nil { | |||
| 230 | + | arg0 = args[0].(context.Context) | |||
| 231 | + | } | |||
| 232 | + | var arg1 domain.Principal | |||
| 233 | + | if args[1] != nil { | |||
| 234 | + | arg1 = args[1].(domain.Principal) | |||
| 235 | + | } | |||
| 236 | + | var arg2 domain.Repo | |||
| 237 | + | if args[2] != nil { | |||
| 238 | + | arg2 = args[2].(domain.Repo) | |||
| 239 | + | } | |||
| 240 | + | var arg3 domain.MergeRequest | |||
| 241 | + | if args[3] != nil { | |||
| 242 | + | arg3 = args[3].(domain.MergeRequest) | |||
| 243 | + | } | |||
| 244 | + | run( | |||
| 245 | + | arg0, | |||
| 246 | + | arg1, | |||
| 247 | + | arg2, | |||
| 248 | + | arg3, | |||
| 249 | + | ) | |||
| 250 | + | }) | |||
| 251 | + | return _c | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (_c *IssueLinkTrigger_OnMerged_Call) Return() *IssueLinkTrigger_OnMerged_Call { | |||
| 255 | + | _c.Call.Return() | |||
| 256 | + | return _c | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func (_c *IssueLinkTrigger_OnMerged_Call) RunAndReturn(run func(context1 context.Context, principal domain.Principal, repo domain.Repo, mergeRequest domain.MergeRequest)) *IssueLinkTrigger_OnMerged_Call { | |||
| 260 | + | _c.Run(run) | |||
| 261 | + | return _c | |||
| 262 | + | } | |||
M internal/ports/ports.go +20 -0
| @@ -543,7 +543,27 @@ type EventBus interface { | |||
| 543 | 543 | Subscribe(ctx context.Context) <-chan domain.Event | |
| 544 | 544 | } | |
| 545 | 545 | ||
| 546 | + | // IssueLinkStore keeps which merge requests name which issues. | |
| 547 | + | type IssueLinkStore interface { | |
| 548 | + | // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues). | |
| 549 | + | // Added includes new links and links whose closes flag turned on. | |
| 550 | + | // Returns ErrNotFound for a missing repository, ErrConflict on a race. | |
| 551 | + | ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) | |
| 552 | + | // LinksOfIssue returns links ascending by MR number, at most limit. | |
| 553 | + | LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) | |
| 554 | + | // LinksOfMergeRequest returns the issues named by an MR. | |
| 555 | + | LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) | |
| 556 | + | } | |
| 557 | + | ||
| 546 | 558 | // Clock tells the time. | |
| 547 | 559 | type Clock interface { | |
| 548 | 560 | Now() time.Time | |
| 549 | 561 | } | |
| 562 | + | ||
| 563 | + | // IssueLinkTrigger updates derived links without failing the primary operation. | |
| 564 | + | type IssueLinkTrigger interface { | |
| 565 | + | OnMergeRequestChanged(context.Context, domain.Repo, int64) | |
| 566 | + | OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest) | |
| 567 | + | OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate) | |
| 568 | + | OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest) | |
| 569 | + | } | |
| @@ -543,7 +543,27 @@ type EventBus interface { | |||||
| 543 | Subscribe(ctx context.Context) <-chan domain.Event | 543 | Subscribe(ctx context.Context) <-chan domain.Event | ||
| 544 | } | 544 | } | ||
| 545 | 545 | ||||
| 546 | + | // IssueLinkStore keeps which merge requests name which issues. | |||
| 547 | + | type IssueLinkStore interface { | |||
| 548 | + | // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues). | |||
| 549 | + | // Added includes new links and links whose closes flag turned on. | |||
| 550 | + | // Returns ErrNotFound for a missing repository, ErrConflict on a race. | |||
| 551 | + | ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) | |||
| 552 | + | // LinksOfIssue returns links ascending by MR number, at most limit. | |||
| 553 | + | LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) | |||
| 554 | + | // LinksOfMergeRequest returns the issues named by an MR. | |||
| 555 | + | LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) | |||
| 556 | + | } | |||
| 557 | + | ||||
| 546 | // Clock tells the time. | 558 | // Clock tells the time. | ||
| 547 | type Clock interface { | 559 | type Clock interface { | ||
| 548 | Now() time.Time | 560 | Now() time.Time | ||
| 549 | } | 561 | } | ||
| 562 | + | ||||
| 563 | + | // IssueLinkTrigger updates derived links without failing the primary operation. | |||
| 564 | + | type IssueLinkTrigger interface { | |||
| 565 | + | OnMergeRequestChanged(context.Context, domain.Repo, int64) | |||
| 566 | + | OnMergeRequestSettled(context.Context, domain.Repo, domain.MergeRequest) | |||
| 567 | + | OnBranchesMoved(context.Context, domain.RepoName, []domain.RefUpdate) | |||
| 568 | + | OnMerged(context.Context, domain.Principal, domain.Repo, domain.MergeRequest) | |||
| 569 | + | } | |||
M internal/ports/services.go +2 -1
| @@ -208,7 +208,8 @@ type IssueSubmissionService interface { | |||
| 208 | 208 | // implements it. | |
| 209 | 209 | type IssueNumberResolver interface { | |
| 210 | 210 | // Kind needs the access of reading the repository and answers | |
| 211 | - | // domain.ErrNotFound otherwise, also when issues are off. | |
| 211 | + | // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge | |
| 212 | + | // requests are resolved even when issues are off. | |
| 212 | 213 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | |
| 213 | 214 | } | |
| 214 | 215 | ||
| @@ -208,7 +208,8 @@ type IssueSubmissionService interface { | |||||
| 208 | // implements it. | 208 | // implements it. | ||
| 209 | type IssueNumberResolver interface { | 209 | type IssueNumberResolver interface { | ||
| 210 | // Kind needs the access of reading the repository and answers | 210 | // Kind needs the access of reading the repository and answers | ||
| 211 | - | // domain.ErrNotFound otherwise, also when issues are off. | 211 | + | // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge |
| 212 | + | // requests are resolved even when issues are off. | |||
| 212 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | 213 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | ||
| 213 | } | 214 | } | ||
| 214 | 215 | ||||
M internal/usecase/events.go +7 -1
| @@ -190,6 +190,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 190 | 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 191 | 191 | case domain.CommentAdded: | |
| 192 | 192 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 193 | + | case domain.IssueReferenced: | |
| 194 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 193 | 195 | case domain.IssueOpened: | |
| 194 | 196 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 195 | 197 | case domain.IssueClosed: | |
| @@ -545,6 +547,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 545 | 547 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 546 | 548 | case domain.CommentAdded: | |
| 547 | 549 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 550 | + | case domain.IssueReferenced: | |
| 551 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 548 | 552 | case domain.IssueOpened: | |
| 549 | 553 | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | |
| 550 | 554 | case domain.IssueClosed: | |
| @@ -645,7 +649,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||
| 645 | 649 | // isIssueEvent reports whether ev is about an issue. | |
| 646 | 650 | func isIssueEvent(ev domain.Event) bool { | |
| 647 | 651 | switch ev.(type) { | |
| 648 | - | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented: | |
| 652 | + | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced: | |
| 649 | 653 | return true | |
| 650 | 654 | } | |
| 651 | 655 | return false | |
| @@ -664,6 +668,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||
| 664 | 668 | // isItemEvent reports whether ev is about a merge request or an issue. | |
| 665 | 669 | func isItemEvent(ev domain.Event) bool { | |
| 666 | 670 | switch ev.(type) { | |
| 671 | + | case domain.IssueReferenced: | |
| 672 | + | return false // back-reference refreshes do not change the inbox | |
| 667 | 673 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | |
| 668 | 674 | domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, | |
| 669 | 675 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | |
| @@ -190,6 +190,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||||
| 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 191 | case domain.CommentAdded: | 191 | case domain.CommentAdded: | ||
| 192 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 192 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 193 | + | case domain.IssueReferenced: | |||
| 194 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 193 | case domain.IssueOpened: | 195 | case domain.IssueOpened: | ||
| 194 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 196 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 195 | case domain.IssueClosed: | 197 | case domain.IssueClosed: | ||
| @@ -545,6 +547,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||||
| 545 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | 547 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | ||
| 546 | case domain.CommentAdded: | 548 | case domain.CommentAdded: | ||
| 547 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | 549 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | ||
| 550 | + | case domain.IssueReferenced: | |||
| 551 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |||
| 548 | case domain.IssueOpened: | 552 | case domain.IssueOpened: | ||
| 549 | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | 553 | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | ||
| 550 | case domain.IssueClosed: | 554 | case domain.IssueClosed: | ||
| @@ -645,7 +649,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||||
| 645 | // isIssueEvent reports whether ev is about an issue. | 649 | // isIssueEvent reports whether ev is about an issue. | ||
| 646 | func isIssueEvent(ev domain.Event) bool { | 650 | func isIssueEvent(ev domain.Event) bool { | ||
| 647 | switch ev.(type) { | 651 | switch ev.(type) { | ||
| 648 | - | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented: | 652 | + | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced: |
| 649 | return true | 653 | return true | ||
| 650 | } | 654 | } | ||
| 651 | return false | 655 | return false | ||
| @@ -664,6 +668,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||||
| 664 | // isItemEvent reports whether ev is about a merge request or an issue. | 668 | // isItemEvent reports whether ev is about a merge request or an issue. | ||
| 665 | func isItemEvent(ev domain.Event) bool { | 669 | func isItemEvent(ev domain.Event) bool { | ||
| 666 | switch ev.(type) { | 670 | switch ev.(type) { | ||
| 671 | + | case domain.IssueReferenced: | |||
| 672 | + | return false // back-reference refreshes do not change the inbox | |||
| 667 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | 673 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | ||
| 668 | domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, | 674 | domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, | ||
| 669 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | 675 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | ||
M internal/usecase/events_test.go +24 -0
| @@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) { | |||
| 1010 | 1010 | assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo) | |
| 1011 | 1011 | }) | |
| 1012 | 1012 | } | |
| 1013 | + | ||
| 1014 | + | func TestIssueReferencedRepoOnly(t *testing.T) { | |
| 1015 | + | synctest.Test(t, func(t *testing.T) { | |
| 1016 | + | r := newEventRig(t, 16) | |
| 1017 | + | n := name(t, "linked") | |
| 1018 | + | settings := domain.DefaultRepoSettings() | |
| 1019 | + | settings.Issues = domain.IssuesOwner | |
| 1020 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe() | |
| 1021 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1022 | + | defer cancel() | |
| 1023 | + | repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}}) | |
| 1024 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 1025 | + | synctest.Wait() | |
| 1026 | + | ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now} | |
| 1027 | + | r.feed <- ev | |
| 1028 | + | assert.Equal(t, ev, repo.next(t).Event) | |
| 1029 | + | synctest.Wait() | |
| 1030 | + | select { | |
| 1031 | + | case got := <-owner.got: | |
| 1032 | + | t.Fatalf("owner received %+v", got) | |
| 1033 | + | default: | |
| 1034 | + | } | |
| 1035 | + | }) | |
| 1036 | + | } | |
| @@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) { | |||||
| 1010 | assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo) | 1010 | assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo) | ||
| 1011 | }) | 1011 | }) | ||
| 1012 | } | 1012 | } | ||
| 1013 | + | ||||
| 1014 | + | func TestIssueReferencedRepoOnly(t *testing.T) { | |||
| 1015 | + | synctest.Test(t, func(t *testing.T) { | |||
| 1016 | + | r := newEventRig(t, 16) | |||
| 1017 | + | n := name(t, "linked") | |||
| 1018 | + | settings := domain.DefaultRepoSettings() | |||
| 1019 | + | settings.Issues = domain.IssuesOwner | |||
| 1020 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe() | |||
| 1021 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1022 | + | defer cancel() | |||
| 1023 | + | repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}}) | |||
| 1024 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |||
| 1025 | + | synctest.Wait() | |||
| 1026 | + | ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now} | |||
| 1027 | + | r.feed <- ev | |||
| 1028 | + | assert.Equal(t, ev, repo.next(t).Event) | |||
| 1029 | + | synctest.Wait() | |||
| 1030 | + | select { | |||
| 1031 | + | case got := <-owner.got: | |||
| 1032 | + | t.Fatalf("owner received %+v", got) | |||
| 1033 | + | default: | |||
| 1034 | + | } | |||
| 1035 | + | }) | |||
| 1036 | + | } | |||
A internal/usecase/issue_links.go +286 -0
| @@ -0,0 +1,286 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "slices" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "github.com/bnema/zerowrap" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const MaxIssueLinkCommits = ports.MaxMergeRequestCommits | |
| 19 | + | const MaxIssueLinksPerIssue = 100 | |
| 20 | + | const maxLinkedMRsPerPush = 50 | |
| 21 | + | ||
| 22 | + | type IssueLinksConfig struct { | |
| 23 | + | Meta ports.RepoMetaStore | |
| 24 | + | Issues ports.IssueStore | |
| 25 | + | MRs ports.MergeRequestStore | |
| 26 | + | Links ports.IssueLinkStore | |
| 27 | + | Git ports.MergeGit | |
| 28 | + | Bus ports.EventBus | |
| 29 | + | Clock ports.Clock | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // IssueLinks maintains the derived index and applies closing references on merge. | |
| 33 | + | type IssueLinks struct { | |
| 34 | + | cfg IssueLinksConfig | |
| 35 | + | mu sync.Mutex | |
| 36 | + | locks map[linkKey]*linkLock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type linkKey struct { | |
| 40 | + | repo domain.RepoName | |
| 41 | + | number int64 | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type linkLock struct { | |
| 45 | + | ch chan struct{} | |
| 46 | + | refs int | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // lockMR serializes reindexes of one merge request, waiting until ctx ends. | |
| 50 | + | // Entries are removed when no one holds or waits for them. | |
| 51 | + | func (l *IssueLinks) lockMR(ctx context.Context, repo domain.RepoName, number int64) (func(), error) { | |
| 52 | + | key := linkKey{repo, number} | |
| 53 | + | l.mu.Lock() | |
| 54 | + | e, ok := l.locks[key] | |
| 55 | + | if !ok { | |
| 56 | + | e = &linkLock{ch: make(chan struct{}, 1)} | |
| 57 | + | l.locks[key] = e | |
| 58 | + | } | |
| 59 | + | e.refs++ | |
| 60 | + | l.mu.Unlock() | |
| 61 | + | release := func() { | |
| 62 | + | l.mu.Lock() | |
| 63 | + | if e.refs--; e.refs == 0 { | |
| 64 | + | delete(l.locks, key) | |
| 65 | + | } | |
| 66 | + | l.mu.Unlock() | |
| 67 | + | } | |
| 68 | + | select { | |
| 69 | + | case e.ch <- struct{}{}: | |
| 70 | + | return func() { <-e.ch; release() }, nil | |
| 71 | + | case <-ctx.Done(): | |
| 72 | + | release() | |
| 73 | + | return nil, ctx.Err() | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | var _ ports.IssueLinkTrigger = (*IssueLinks)(nil) | |
| 78 | + | ||
| 79 | + | func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks { | |
| 80 | + | return &IssueLinks{cfg: cfg, locks: map[linkKey]*linkLock{}} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Reindex replaces links from the current texts and bounded commit range. | |
| 84 | + | func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error { | |
| 85 | + | _, err := l.reindex(ctx, repo, number) | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) { | |
| 89 | + | unlock, err := l.lockMR(ctx, repo.Name, number) | |
| 90 | + | if err != nil { | |
| 91 | + | return nil, err | |
| 92 | + | } | |
| 93 | + | defer unlock() | |
| 94 | + | mr, err := l.cfg.MRs.Get(ctx, repo.Name, number) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, err | |
| 97 | + | } | |
| 98 | + | notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number) | |
| 99 | + | if err != nil { | |
| 100 | + | return nil, err | |
| 101 | + | } | |
| 102 | + | var texts, messages []string | |
| 103 | + | for _, note := range notes { | |
| 104 | + | if !note.Deleted { | |
| 105 | + | texts = append(texts, note.Text.String()) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if mr.IsOpen() { | |
| 109 | + | mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source) | |
| 110 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 111 | + | mr.HeadID = domain.ObjectID{} | |
| 112 | + | } else if err != nil { | |
| 113 | + | return nil, err | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | exclude, _, err := mrRange(ctx, l.cfg.Git, mr) | |
| 117 | + | if err != nil { | |
| 118 | + | return nil, err | |
| 119 | + | } | |
| 120 | + | has := !mr.HeadID.IsZero() | |
| 121 | + | if has && !mr.IsOpen() { | |
| 122 | + | has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, err | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | if has { | |
| 128 | + | commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits) | |
| 129 | + | if err != nil { | |
| 130 | + | return nil, err | |
| 131 | + | } | |
| 132 | + | if more { | |
| 133 | + | log := zerowrap.FromCtx(ctx) | |
| 134 | + | log.Debug().Msg("issue link commit range truncated") | |
| 135 | + | } | |
| 136 | + | for _, commit := range commits { | |
| 137 | + | messages = append(messages, commit.Message) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages) | |
| 141 | + | var mentions, closes []int64 | |
| 142 | + | for _, n := range refs.Mentions { | |
| 143 | + | _, err := l.cfg.Issues.Get(ctx, repo.Name, n) | |
| 144 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 145 | + | continue | |
| 146 | + | } | |
| 147 | + | if err != nil { | |
| 148 | + | return nil, err | |
| 149 | + | } | |
| 150 | + | mentions = append(mentions, n) | |
| 151 | + | if slices.Contains(refs.Closes, n) { | |
| 152 | + | closes = append(closes, n) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | for range maxWriteRetries { | |
| 156 | + | added, removed, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes) | |
| 157 | + | if errors.Is(err, domain.ErrConflict) { | |
| 158 | + | continue | |
| 159 | + | } | |
| 160 | + | if err != nil { | |
| 161 | + | return nil, err | |
| 162 | + | } | |
| 163 | + | type pair struct{ issue, mr int64 } | |
| 164 | + | sent := map[pair]struct{}{} | |
| 165 | + | for _, link := range slices.Concat(added, removed) { | |
| 166 | + | p := pair{link.Issue, link.MergeRequest} | |
| 167 | + | if _, ok := sent[p]; ok { | |
| 168 | + | continue | |
| 169 | + | } | |
| 170 | + | sent[p] = struct{}{} | |
| 171 | + | l.notify(ctx, repo, link) | |
| 172 | + | } | |
| 173 | + | return closes, nil | |
| 174 | + | } | |
| 175 | + | return nil, domain.ErrConflict | |
| 176 | + | } | |
| 177 | + | func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) { | |
| 178 | + | l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 179 | + | } | |
| 180 | + | func linkContext(ctx context.Context) (context.Context, context.CancelFunc) { | |
| 181 | + | return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) | |
| 182 | + | } | |
| 183 | + | func linkWarning(ctx context.Context, err error) { | |
| 184 | + | if err != nil { | |
| 185 | + | log := zerowrap.FromCtx(ctx) | |
| 186 | + | log.Warn().Err(err).Msg("issue links not updated") | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) { | |
| 190 | + | ctx, cancel := linkContext(ctx) | |
| 191 | + | defer cancel() | |
| 192 | + | linkWarning(ctx, l.Reindex(ctx, repo, number)) | |
| 193 | + | } | |
| 194 | + | func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) { | |
| 195 | + | links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number) | |
| 196 | + | linkWarning(ctx, err) | |
| 197 | + | if err == nil { | |
| 198 | + | for _, link := range links { | |
| 199 | + | l.notify(ctx, repo, link) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) { | |
| 204 | + | ctx, cancel := linkContext(ctx) | |
| 205 | + | defer cancel() | |
| 206 | + | l.settled(ctx, repo, mr) | |
| 207 | + | } | |
| 208 | + | func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) { | |
| 209 | + | ctx, cancel := linkContext(ctx) | |
| 210 | + | defer cancel() | |
| 211 | + | var branches []string | |
| 212 | + | for _, up := range updates { | |
| 213 | + | if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() { | |
| 214 | + | branches = append(branches, b) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | if len(branches) == 0 { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | repo, err := l.cfg.Meta.Get(ctx, name) | |
| 221 | + | if err != nil { | |
| 222 | + | linkWarning(ctx, err) | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush) | |
| 226 | + | if err != nil { | |
| 227 | + | linkWarning(ctx, err) | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | if truncated { | |
| 231 | + | log := zerowrap.FromCtx(ctx) | |
| 232 | + | log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated") | |
| 233 | + | } | |
| 234 | + | for _, n := range numbers { | |
| 235 | + | linkWarning(ctx, l.Reindex(ctx, repo, n)) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) { | |
| 239 | + | ctx, cancel := linkContext(ctx) | |
| 240 | + | defer cancel() | |
| 241 | + | defer l.settled(ctx, repo, mr) | |
| 242 | + | closes, err := l.reindex(ctx, repo, mr.Number) | |
| 243 | + | if err != nil { | |
| 244 | + | linkWarning(ctx, err) | |
| 245 | + | return | |
| 246 | + | } | |
| 247 | + | if !repo.Settings.IssuesEnabled() { | |
| 248 | + | log := zerowrap.FromCtx(ctx) | |
| 249 | + | log.Debug().Msg("merge issue closing disabled") | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | for _, n := range closes { | |
| 253 | + | err := l.close(ctx, p, repo, mr.Number, n) | |
| 254 | + | if err != nil { | |
| 255 | + | log := zerowrap.FromCtx(ctx) | |
| 256 | + | log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge") | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error { | |
| 261 | + | for range maxWriteRetries { | |
| 262 | + | issue, err := l.cfg.Issues.Get(ctx, repo.Name, n) | |
| 263 | + | if err != nil { | |
| 264 | + | return err | |
| 265 | + | } | |
| 266 | + | if !issue.IsOpen() { | |
| 267 | + | return nil | |
| 268 | + | } | |
| 269 | + | issue.State = domain.IssueStateClosed | |
| 270 | + | issue.ClosedByMergeRequest = mr | |
| 271 | + | issue.UpdatedAt = l.cfg.Clock.Now().UTC() | |
| 272 | + | err = l.cfg.Issues.Update(ctx, issue) | |
| 273 | + | if errors.Is(err, domain.ErrConflict) { | |
| 274 | + | continue | |
| 275 | + | } | |
| 276 | + | if err != nil { | |
| 277 | + | return err | |
| 278 | + | } | |
| 279 | + | recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt) | |
| 280 | + | log := zerowrap.FromCtx(ctx) | |
| 281 | + | log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge") | |
| 282 | + | l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 283 | + | return nil | |
| 284 | + | } | |
| 285 | + | return domain.ErrConflict | |
| 286 | + | } | |
| @@ -0,0 +1,286 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "slices" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | + | "github.com/bnema/zerowrap" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const MaxIssueLinkCommits = ports.MaxMergeRequestCommits | |||
| 19 | + | const MaxIssueLinksPerIssue = 100 | |||
| 20 | + | const maxLinkedMRsPerPush = 50 | |||
| 21 | + | ||||
| 22 | + | type IssueLinksConfig struct { | |||
| 23 | + | Meta ports.RepoMetaStore | |||
| 24 | + | Issues ports.IssueStore | |||
| 25 | + | MRs ports.MergeRequestStore | |||
| 26 | + | Links ports.IssueLinkStore | |||
| 27 | + | Git ports.MergeGit | |||
| 28 | + | Bus ports.EventBus | |||
| 29 | + | Clock ports.Clock | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // IssueLinks maintains the derived index and applies closing references on merge. | |||
| 33 | + | type IssueLinks struct { | |||
| 34 | + | cfg IssueLinksConfig | |||
| 35 | + | mu sync.Mutex | |||
| 36 | + | locks map[linkKey]*linkLock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type linkKey struct { | |||
| 40 | + | repo domain.RepoName | |||
| 41 | + | number int64 | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type linkLock struct { | |||
| 45 | + | ch chan struct{} | |||
| 46 | + | refs int | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // lockMR serializes reindexes of one merge request, waiting until ctx ends. | |||
| 50 | + | // Entries are removed when no one holds or waits for them. | |||
| 51 | + | func (l *IssueLinks) lockMR(ctx context.Context, repo domain.RepoName, number int64) (func(), error) { | |||
| 52 | + | key := linkKey{repo, number} | |||
| 53 | + | l.mu.Lock() | |||
| 54 | + | e, ok := l.locks[key] | |||
| 55 | + | if !ok { | |||
| 56 | + | e = &linkLock{ch: make(chan struct{}, 1)} | |||
| 57 | + | l.locks[key] = e | |||
| 58 | + | } | |||
| 59 | + | e.refs++ | |||
| 60 | + | l.mu.Unlock() | |||
| 61 | + | release := func() { | |||
| 62 | + | l.mu.Lock() | |||
| 63 | + | if e.refs--; e.refs == 0 { | |||
| 64 | + | delete(l.locks, key) | |||
| 65 | + | } | |||
| 66 | + | l.mu.Unlock() | |||
| 67 | + | } | |||
| 68 | + | select { | |||
| 69 | + | case e.ch <- struct{}{}: | |||
| 70 | + | return func() { <-e.ch; release() }, nil | |||
| 71 | + | case <-ctx.Done(): | |||
| 72 | + | release() | |||
| 73 | + | return nil, ctx.Err() | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | var _ ports.IssueLinkTrigger = (*IssueLinks)(nil) | |||
| 78 | + | ||||
| 79 | + | func NewIssueLinks(cfg IssueLinksConfig) *IssueLinks { | |||
| 80 | + | return &IssueLinks{cfg: cfg, locks: map[linkKey]*linkLock{}} | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Reindex replaces links from the current texts and bounded commit range. | |||
| 84 | + | func (l *IssueLinks) Reindex(ctx context.Context, repo domain.Repo, number int64) error { | |||
| 85 | + | _, err := l.reindex(ctx, repo, number) | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | func (l *IssueLinks) reindex(ctx context.Context, repo domain.Repo, number int64) ([]int64, error) { | |||
| 89 | + | unlock, err := l.lockMR(ctx, repo.Name, number) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return nil, err | |||
| 92 | + | } | |||
| 93 | + | defer unlock() | |||
| 94 | + | mr, err := l.cfg.MRs.Get(ctx, repo.Name, number) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return nil, err | |||
| 97 | + | } | |||
| 98 | + | notes, err := l.cfg.MRs.ListComments(ctx, repo.Name, number) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return nil, err | |||
| 101 | + | } | |||
| 102 | + | var texts, messages []string | |||
| 103 | + | for _, note := range notes { | |||
| 104 | + | if !note.Deleted { | |||
| 105 | + | texts = append(texts, note.Text.String()) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | if mr.IsOpen() { | |||
| 109 | + | mr.HeadID, err = l.cfg.Git.BranchHead(ctx, repo.Name, mr.Source) | |||
| 110 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 111 | + | mr.HeadID = domain.ObjectID{} | |||
| 112 | + | } else if err != nil { | |||
| 113 | + | return nil, err | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | exclude, _, err := mrRange(ctx, l.cfg.Git, mr) | |||
| 117 | + | if err != nil { | |||
| 118 | + | return nil, err | |||
| 119 | + | } | |||
| 120 | + | has := !mr.HeadID.IsZero() | |||
| 121 | + | if has && !mr.IsOpen() { | |||
| 122 | + | has, err = l.cfg.Git.HasCommit(ctx, repo.Name, mr.HeadID) | |||
| 123 | + | if err != nil { | |||
| 124 | + | return nil, err | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | if has { | |||
| 128 | + | commits, more, err := l.cfg.Git.ListRange(ctx, repo.Name, exclude, mr.HeadID, MaxIssueLinkCommits) | |||
| 129 | + | if err != nil { | |||
| 130 | + | return nil, err | |||
| 131 | + | } | |||
| 132 | + | if more { | |||
| 133 | + | log := zerowrap.FromCtx(ctx) | |||
| 134 | + | log.Debug().Msg("issue link commit range truncated") | |||
| 135 | + | } | |||
| 136 | + | for _, commit := range commits { | |||
| 137 | + | messages = append(messages, commit.Message) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | refs := domain.ScanMergeRequest(mr.Title.String(), mr.Description.String(), texts, messages) | |||
| 141 | + | var mentions, closes []int64 | |||
| 142 | + | for _, n := range refs.Mentions { | |||
| 143 | + | _, err := l.cfg.Issues.Get(ctx, repo.Name, n) | |||
| 144 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 145 | + | continue | |||
| 146 | + | } | |||
| 147 | + | if err != nil { | |||
| 148 | + | return nil, err | |||
| 149 | + | } | |||
| 150 | + | mentions = append(mentions, n) | |||
| 151 | + | if slices.Contains(refs.Closes, n) { | |||
| 152 | + | closes = append(closes, n) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | for range maxWriteRetries { | |||
| 156 | + | added, removed, err := l.cfg.Links.ReplaceLinks(ctx, repo.Name, number, mentions, closes) | |||
| 157 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 158 | + | continue | |||
| 159 | + | } | |||
| 160 | + | if err != nil { | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | type pair struct{ issue, mr int64 } | |||
| 164 | + | sent := map[pair]struct{}{} | |||
| 165 | + | for _, link := range slices.Concat(added, removed) { | |||
| 166 | + | p := pair{link.Issue, link.MergeRequest} | |||
| 167 | + | if _, ok := sent[p]; ok { | |||
| 168 | + | continue | |||
| 169 | + | } | |||
| 170 | + | sent[p] = struct{}{} | |||
| 171 | + | l.notify(ctx, repo, link) | |||
| 172 | + | } | |||
| 173 | + | return closes, nil | |||
| 174 | + | } | |||
| 175 | + | return nil, domain.ErrConflict | |||
| 176 | + | } | |||
| 177 | + | func (l *IssueLinks) notify(ctx context.Context, repo domain.Repo, link domain.IssueLink) { | |||
| 178 | + | l.cfg.Bus.Publish(ctx, domain.IssueReferenced{Repo: repo.Name, Issue: link.Issue, MergeRequest: link.MergeRequest, At: l.cfg.Clock.Now().UTC(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 179 | + | } | |||
| 180 | + | func linkContext(ctx context.Context) (context.Context, context.CancelFunc) { | |||
| 181 | + | return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) | |||
| 182 | + | } | |||
| 183 | + | func linkWarning(ctx context.Context, err error) { | |||
| 184 | + | if err != nil { | |||
| 185 | + | log := zerowrap.FromCtx(ctx) | |||
| 186 | + | log.Warn().Err(err).Msg("issue links not updated") | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | func (l *IssueLinks) OnMergeRequestChanged(ctx context.Context, repo domain.Repo, number int64) { | |||
| 190 | + | ctx, cancel := linkContext(ctx) | |||
| 191 | + | defer cancel() | |||
| 192 | + | linkWarning(ctx, l.Reindex(ctx, repo, number)) | |||
| 193 | + | } | |||
| 194 | + | func (l *IssueLinks) settled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) { | |||
| 195 | + | links, err := l.cfg.Links.LinksOfMergeRequest(ctx, repo.Name, mr.Number) | |||
| 196 | + | linkWarning(ctx, err) | |||
| 197 | + | if err == nil { | |||
| 198 | + | for _, link := range links { | |||
| 199 | + | l.notify(ctx, repo, link) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | func (l *IssueLinks) OnMergeRequestSettled(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) { | |||
| 204 | + | ctx, cancel := linkContext(ctx) | |||
| 205 | + | defer cancel() | |||
| 206 | + | l.settled(ctx, repo, mr) | |||
| 207 | + | } | |||
| 208 | + | func (l *IssueLinks) OnBranchesMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) { | |||
| 209 | + | ctx, cancel := linkContext(ctx) | |||
| 210 | + | defer cancel() | |||
| 211 | + | var branches []string | |||
| 212 | + | for _, up := range updates { | |||
| 213 | + | if b, ok := strings.CutPrefix(up.Ref, "refs/heads/"); ok && !up.IsDelete() { | |||
| 214 | + | branches = append(branches, b) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | if len(branches) == 0 { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | repo, err := l.cfg.Meta.Get(ctx, name) | |||
| 221 | + | if err != nil { | |||
| 222 | + | linkWarning(ctx, err) | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | numbers, truncated, err := l.cfg.MRs.OpenBySource(ctx, name, branches, maxLinkedMRsPerPush) | |||
| 226 | + | if err != nil { | |||
| 227 | + | linkWarning(ctx, err) | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | if truncated { | |||
| 231 | + | log := zerowrap.FromCtx(ctx) | |||
| 232 | + | log.Warn().Str("repo", name.String()).Msg("issue link merge requests truncated") | |||
| 233 | + | } | |||
| 234 | + | for _, n := range numbers { | |||
| 235 | + | linkWarning(ctx, l.Reindex(ctx, repo, n)) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | func (l *IssueLinks) OnMerged(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest) { | |||
| 239 | + | ctx, cancel := linkContext(ctx) | |||
| 240 | + | defer cancel() | |||
| 241 | + | defer l.settled(ctx, repo, mr) | |||
| 242 | + | closes, err := l.reindex(ctx, repo, mr.Number) | |||
| 243 | + | if err != nil { | |||
| 244 | + | linkWarning(ctx, err) | |||
| 245 | + | return | |||
| 246 | + | } | |||
| 247 | + | if !repo.Settings.IssuesEnabled() { | |||
| 248 | + | log := zerowrap.FromCtx(ctx) | |||
| 249 | + | log.Debug().Msg("merge issue closing disabled") | |||
| 250 | + | return | |||
| 251 | + | } | |||
| 252 | + | for _, n := range closes { | |||
| 253 | + | err := l.close(ctx, p, repo, mr.Number, n) | |||
| 254 | + | if err != nil { | |||
| 255 | + | log := zerowrap.FromCtx(ctx) | |||
| 256 | + | log.Warn().Err(err).Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr.Number).Msg("issue not closed by the merge") | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | func (l *IssueLinks) close(ctx context.Context, p domain.Principal, repo domain.Repo, mr, n int64) error { | |||
| 261 | + | for range maxWriteRetries { | |||
| 262 | + | issue, err := l.cfg.Issues.Get(ctx, repo.Name, n) | |||
| 263 | + | if err != nil { | |||
| 264 | + | return err | |||
| 265 | + | } | |||
| 266 | + | if !issue.IsOpen() { | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | issue.State = domain.IssueStateClosed | |||
| 270 | + | issue.ClosedByMergeRequest = mr | |||
| 271 | + | issue.UpdatedAt = l.cfg.Clock.Now().UTC() | |||
| 272 | + | err = l.cfg.Issues.Update(ctx, issue) | |||
| 273 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 274 | + | continue | |||
| 275 | + | } | |||
| 276 | + | if err != nil { | |||
| 277 | + | return err | |||
| 278 | + | } | |||
| 279 | + | recordActivity(ctx, l.cfg.Meta, repo.Name, issue.UpdatedAt) | |||
| 280 | + | log := zerowrap.FromCtx(ctx) | |||
| 281 | + | log.Info().Str("repo", repo.Name.String()).Int64("number", n).Int64("merge_request", mr).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue closed by merge") | |||
| 282 | + | l.cfg.Bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 283 | + | return nil | |||
| 284 | + | } | |||
| 285 | + | return domain.ErrConflict | |||
| 286 | + | } | |||
A internal/usecase/issue_links_test.go +235 -0
| @@ -0,0 +1,235 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "bnema.dev/ermite/internal/usecase" | |
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) { | |
| 20 | + | t.Helper() | |
| 21 | + | r := newMRRig(t) | |
| 22 | + | r.seed() | |
| 23 | + | _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil }) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}}) | |
| 26 | + | r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links}) | |
| 27 | + | issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}}) | |
| 28 | + | return r, links, issues | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestIssueLinksTextsPushAndReferences(t *testing.T) { | |
| 32 | + | r, links, issues := linkedRig(t) | |
| 33 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | r.feature("feat") | |
| 36 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)}) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | got, err := issues.Get(r.ctx, root, r.repo, issue.Number) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | require.Len(t, got.References, 1) | |
| 41 | + | assert.Equal(t, mr.Title, got.References[0].Title) | |
| 42 | + | other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"}) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)}) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Len(t, got.References, 1) | |
| 49 | + | assert.False(t, got.References[0].Closes) | |
| 50 | + | indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | require.Len(t, indexed, 2, "MR and missing numbers must not be stored") | |
| 53 | + | _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention") | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | assert.Empty(t, got.References) | |
| 58 | + | head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number)) | |
| 59 | + | r.push("feat") | |
| 60 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}}) | |
| 61 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.Len(t, got.References, 1) | |
| 64 | + | assert.True(t, got.References[0].Closes) | |
| 65 | + | stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.Len(t, stored, 2) | |
| 68 | + | _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | assert.True(t, got.Issue.IsOpen()) | |
| 73 | + | assert.Equal(t, domain.MRStateClosed, got.References[0].State) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestIssueLinksMergeClosing(t *testing.T) { | |
| 77 | + | for _, mode := range []string{"open", "off", "closed"} { | |
| 78 | + | t.Run(mode, func(t *testing.T) { | |
| 79 | + | r, _, issues := linkedRig(t) | |
| 80 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | if mode == "closed" { | |
| 83 | + | issue, err = issues.Close(r.ctx, root, r.repo, issue.Number) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | } | |
| 86 | + | r.feature("feat") | |
| 87 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) | |
| 88 | + | r.push("feat") | |
| 89 | + | mr := r.open("feat") | |
| 90 | + | if mode == "off" { | |
| 91 | + | _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil }) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | } | |
| 94 | + | issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | r.drain() | |
| 97 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true}) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | events := r.drain() | |
| 100 | + | got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | require.Len(t, refs, 1) | |
| 105 | + | assert.True(t, refs[0].Closes) | |
| 106 | + | closedEvents, referenced := 0, 0 | |
| 107 | + | for _, ev := range events { | |
| 108 | + | switch ev.(type) { | |
| 109 | + | case domain.IssueClosed: | |
| 110 | + | closedEvents++ | |
| 111 | + | case domain.IssueReferenced: | |
| 112 | + | referenced++ | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | assert.Positive(t, referenced) | |
| 116 | + | if mode == "open" { | |
| 117 | + | assert.Equal(t, mr.Number, got.ClosedByMergeRequest) | |
| 118 | + | assert.False(t, got.IsOpen()) | |
| 119 | + | assert.Equal(t, 1, closedEvents) | |
| 120 | + | reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | assert.Zero(t, reopened.ClosedByMergeRequest) | |
| 123 | + | } else { | |
| 124 | + | assert.Zero(t, got.ClosedByMergeRequest) | |
| 125 | + | assert.Equal(t, 0, closedEvents) | |
| 126 | + | if mode == "closed" { | |
| 127 | + | assert.Equal(t, issue, got) | |
| 128 | + | } else { | |
| 129 | + | assert.True(t, got.IsOpen()) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | }) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | type gatedBranchGit struct { | |
| 137 | + | ports.MergeGit | |
| 138 | + | entered atomic.Int32 | |
| 139 | + | started chan struct{} | |
| 140 | + | gate chan struct{} | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) { | |
| 144 | + | if g.entered.Add(1) == 1 { | |
| 145 | + | close(g.started) | |
| 146 | + | <-g.gate | |
| 147 | + | } | |
| 148 | + | return g.MergeGit.BranchHead(ctx, name, branch) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) { | |
| 152 | + | r, _, issues := linkedRig(t) | |
| 153 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | r.feature("feat") | |
| 156 | + | mr := r.open("feat") | |
| 157 | + | g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})} | |
| 158 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}}) | |
| 159 | + | repo, err := r.store.Get(r.ctx, r.repo) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | done := make(chan error, 2) | |
| 162 | + | go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }() | |
| 163 | + | <-g.started | |
| 164 | + | // The first reindex read the old texts; a note is added afterwards. | |
| 165 | + | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)}) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }() | |
| 168 | + | time.Sleep(100 * time.Millisecond) | |
| 169 | + | assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first") | |
| 170 | + | close(g.gate) | |
| 171 | + | require.NoError(t, <-done) | |
| 172 | + | require.NoError(t, <-done) | |
| 173 | + | got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.Len(t, got, 1, "the final index reflects the latest content") | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) { | |
| 179 | + | r, links, issues := linkedRig(t) | |
| 180 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | r.feature("feat") | |
| 183 | + | mr := r.open("feat") | |
| 184 | + | referenced := func() (n int) { | |
| 185 | + | for _, ev := range r.drain() { | |
| 186 | + | if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number { | |
| 187 | + | n++ | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | return n | |
| 191 | + | } | |
| 192 | + | note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)}) | |
| 193 | + | require.NoError(t, err) | |
| 194 | + | assert.Equal(t, 1, referenced()) | |
| 195 | + | _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing") | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | assert.Equal(t, 1, referenced(), "removing the last mention notifies") | |
| 198 | + | ||
| 199 | + | // closes true -> false emits one event: a commit message fixes the issue, | |
| 200 | + | // then is rewritten to a plain mention. | |
| 201 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) | |
| 202 | + | r.push("feat") | |
| 203 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}}) | |
| 204 | + | referenced() | |
| 205 | + | r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number)) | |
| 206 | + | r.push("feat") | |
| 207 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}}) | |
| 208 | + | assert.Equal(t, 1, referenced(), "a closing flip notifies once") | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) { | |
| 212 | + | r, _, issues := linkedRig(t) | |
| 213 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |
| 214 | + | require.NoError(t, err) | |
| 215 | + | r.sh("checkout", "-q", "-B", "x", "main") | |
| 216 | + | r.commit(author, "x.txt", "x", "work on x") | |
| 217 | + | r.push("x") | |
| 218 | + | r.sh("checkout", "-q", "-B", "feat", "x") | |
| 219 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) | |
| 220 | + | r.push("feat") | |
| 221 | + | b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"}) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"}) | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | require.Empty(t, before) | |
| 228 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward}) | |
| 229 | + | require.NoError(t, err) | |
| 230 | + | r.mrs.Wait(r.ctx) | |
| 231 | + | after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number) | |
| 232 | + | require.NoError(t, err) | |
| 233 | + | require.Len(t, after, 1) | |
| 234 | + | assert.Equal(t, issue.Number, after[0].Issue) | |
| 235 | + | } | |
| @@ -0,0 +1,235 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "bnema.dev/ermite/internal/usecase" | |||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) { | |||
| 20 | + | t.Helper() | |||
| 21 | + | r := newMRRig(t) | |||
| 22 | + | r.seed() | |||
| 23 | + | _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil }) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}}) | |||
| 26 | + | r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links}) | |||
| 27 | + | issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}}) | |||
| 28 | + | return r, links, issues | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestIssueLinksTextsPushAndReferences(t *testing.T) { | |||
| 32 | + | r, links, issues := linkedRig(t) | |||
| 33 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | r.feature("feat") | |||
| 36 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)}) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | got, err := issues.Get(r.ctx, root, r.repo, issue.Number) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | require.Len(t, got.References, 1) | |||
| 41 | + | assert.Equal(t, mr.Title, got.References[0].Title) | |||
| 42 | + | other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"}) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)}) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Len(t, got.References, 1) | |||
| 49 | + | assert.False(t, got.References[0].Closes) | |||
| 50 | + | indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | require.Len(t, indexed, 2, "MR and missing numbers must not be stored") | |||
| 53 | + | _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention") | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | assert.Empty(t, got.References) | |||
| 58 | + | head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number)) | |||
| 59 | + | r.push("feat") | |||
| 60 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}}) | |||
| 61 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | require.Len(t, got.References, 1) | |||
| 64 | + | assert.True(t, got.References[0].Closes) | |||
| 65 | + | stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | require.Len(t, stored, 2) | |||
| 68 | + | _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | assert.True(t, got.Issue.IsOpen()) | |||
| 73 | + | assert.Equal(t, domain.MRStateClosed, got.References[0].State) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestIssueLinksMergeClosing(t *testing.T) { | |||
| 77 | + | for _, mode := range []string{"open", "off", "closed"} { | |||
| 78 | + | t.Run(mode, func(t *testing.T) { | |||
| 79 | + | r, _, issues := linkedRig(t) | |||
| 80 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | if mode == "closed" { | |||
| 83 | + | issue, err = issues.Close(r.ctx, root, r.repo, issue.Number) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | } | |||
| 86 | + | r.feature("feat") | |||
| 87 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) | |||
| 88 | + | r.push("feat") | |||
| 89 | + | mr := r.open("feat") | |||
| 90 | + | if mode == "off" { | |||
| 91 | + | _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil }) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | } | |||
| 94 | + | issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | r.drain() | |||
| 97 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true}) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | events := r.drain() | |||
| 100 | + | got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | require.Len(t, refs, 1) | |||
| 105 | + | assert.True(t, refs[0].Closes) | |||
| 106 | + | closedEvents, referenced := 0, 0 | |||
| 107 | + | for _, ev := range events { | |||
| 108 | + | switch ev.(type) { | |||
| 109 | + | case domain.IssueClosed: | |||
| 110 | + | closedEvents++ | |||
| 111 | + | case domain.IssueReferenced: | |||
| 112 | + | referenced++ | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | assert.Positive(t, referenced) | |||
| 116 | + | if mode == "open" { | |||
| 117 | + | assert.Equal(t, mr.Number, got.ClosedByMergeRequest) | |||
| 118 | + | assert.False(t, got.IsOpen()) | |||
| 119 | + | assert.Equal(t, 1, closedEvents) | |||
| 120 | + | reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | assert.Zero(t, reopened.ClosedByMergeRequest) | |||
| 123 | + | } else { | |||
| 124 | + | assert.Zero(t, got.ClosedByMergeRequest) | |||
| 125 | + | assert.Equal(t, 0, closedEvents) | |||
| 126 | + | if mode == "closed" { | |||
| 127 | + | assert.Equal(t, issue, got) | |||
| 128 | + | } else { | |||
| 129 | + | assert.True(t, got.IsOpen()) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | }) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | type gatedBranchGit struct { | |||
| 137 | + | ports.MergeGit | |||
| 138 | + | entered atomic.Int32 | |||
| 139 | + | started chan struct{} | |||
| 140 | + | gate chan struct{} | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) { | |||
| 144 | + | if g.entered.Add(1) == 1 { | |||
| 145 | + | close(g.started) | |||
| 146 | + | <-g.gate | |||
| 147 | + | } | |||
| 148 | + | return g.MergeGit.BranchHead(ctx, name, branch) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) { | |||
| 152 | + | r, _, issues := linkedRig(t) | |||
| 153 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |||
| 154 | + | require.NoError(t, err) | |||
| 155 | + | r.feature("feat") | |||
| 156 | + | mr := r.open("feat") | |||
| 157 | + | g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})} | |||
| 158 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}}) | |||
| 159 | + | repo, err := r.store.Get(r.ctx, r.repo) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | done := make(chan error, 2) | |||
| 162 | + | go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }() | |||
| 163 | + | <-g.started | |||
| 164 | + | // The first reindex read the old texts; a note is added afterwards. | |||
| 165 | + | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)}) | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }() | |||
| 168 | + | time.Sleep(100 * time.Millisecond) | |||
| 169 | + | assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first") | |||
| 170 | + | close(g.gate) | |||
| 171 | + | require.NoError(t, <-done) | |||
| 172 | + | require.NoError(t, <-done) | |||
| 173 | + | got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | require.Len(t, got, 1, "the final index reflects the latest content") | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) { | |||
| 179 | + | r, links, issues := linkedRig(t) | |||
| 180 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | r.feature("feat") | |||
| 183 | + | mr := r.open("feat") | |||
| 184 | + | referenced := func() (n int) { | |||
| 185 | + | for _, ev := range r.drain() { | |||
| 186 | + | if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number { | |||
| 187 | + | n++ | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | return n | |||
| 191 | + | } | |||
| 192 | + | note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)}) | |||
| 193 | + | require.NoError(t, err) | |||
| 194 | + | assert.Equal(t, 1, referenced()) | |||
| 195 | + | _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing") | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | assert.Equal(t, 1, referenced(), "removing the last mention notifies") | |||
| 198 | + | ||||
| 199 | + | // closes true -> false emits one event: a commit message fixes the issue, | |||
| 200 | + | // then is rewritten to a plain mention. | |||
| 201 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) | |||
| 202 | + | r.push("feat") | |||
| 203 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}}) | |||
| 204 | + | referenced() | |||
| 205 | + | r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number)) | |||
| 206 | + | r.push("feat") | |||
| 207 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}}) | |||
| 208 | + | assert.Equal(t, 1, referenced(), "a closing flip notifies once") | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) { | |||
| 212 | + | r, _, issues := linkedRig(t) | |||
| 213 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |||
| 214 | + | require.NoError(t, err) | |||
| 215 | + | r.sh("checkout", "-q", "-B", "x", "main") | |||
| 216 | + | r.commit(author, "x.txt", "x", "work on x") | |||
| 217 | + | r.push("x") | |||
| 218 | + | r.sh("checkout", "-q", "-B", "feat", "x") | |||
| 219 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) | |||
| 220 | + | r.push("feat") | |||
| 221 | + | b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"}) | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"}) | |||
| 224 | + | require.NoError(t, err) | |||
| 225 | + | before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | require.Empty(t, before) | |||
| 228 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward}) | |||
| 229 | + | require.NoError(t, err) | |||
| 230 | + | r.mrs.Wait(r.ctx) | |||
| 231 | + | after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number) | |||
| 232 | + | require.NoError(t, err) | |||
| 233 | + | require.Len(t, after, 1) | |||
| 234 | + | assert.Equal(t, issue.Number, after[0].Issue) | |||
| 235 | + | } | |||
M internal/usecase/issues.go +35 -9
| @@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000 | |||
| 26 | 26 | // issue:write on it, which the branch prefixes of a token do not restrict: | |
| 27 | 27 | // an issue writes no ref. | |
| 28 | 28 | type Issues struct { | |
| 29 | + | links ports.IssueLinkStore | |
| 29 | 30 | meta ports.RepoMetaStore | |
| 30 | 31 | store ports.IssueStore | |
| 31 | 32 | mrs ports.MergeRequestStore | |
| @@ -40,6 +41,7 @@ var ( | |||
| 40 | 41 | ||
| 41 | 42 | // IssuesConfig holds what the issue use cases need. Every field is required. | |
| 42 | 43 | type IssuesConfig struct { | |
| 44 | + | Links ports.IssueLinkStore | |
| 43 | 45 | Meta ports.RepoMetaStore | |
| 44 | 46 | Store ports.IssueStore | |
| 45 | 47 | // MergeRequests tells which numbers name a merge request (Kind). | |
| @@ -50,7 +52,7 @@ type IssuesConfig struct { | |||
| 50 | 52 | ||
| 51 | 53 | // NewIssues builds the issue use cases. | |
| 52 | 54 | func NewIssues(cfg IssuesConfig) *Issues { | |
| 53 | - | return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock} | |
| 55 | + | return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock} | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | 58 | // enabled looks the repository up and hides it when issues are off. | |
| @@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa | |||
| 159 | 161 | if err != nil { | |
| 160 | 162 | return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err) | |
| 161 | 163 | } | |
| 162 | - | return ports.IssueDetail{Issue: issue, Comments: comments}, nil | |
| 164 | + | detail := ports.IssueDetail{Issue: issue, Comments: comments} | |
| 165 | + | if i.links != nil { | |
| 166 | + | links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue) | |
| 167 | + | if err != nil { | |
| 168 | + | return ports.IssueDetail{}, err | |
| 169 | + | } | |
| 170 | + | for _, link := range links { | |
| 171 | + | mr, err := i.mrs.Get(ctx, name, link.MergeRequest) | |
| 172 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | if err != nil { | |
| 176 | + | return ports.IssueDetail{}, err | |
| 177 | + | } | |
| 178 | + | detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes}) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | return detail, nil | |
| 163 | 182 | } | |
| 164 | 183 | ||
| 165 | 184 | // AddComment adds a comment and publishes IssueCommented. The principal needs | |
| @@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||
| 251 | 270 | if issue.State != from { | |
| 252 | 271 | return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State) | |
| 253 | 272 | } | |
| 273 | + | issue.ClosedByMergeRequest = 0 | |
| 254 | 274 | issue.State, issue.UpdatedAt = to, i.clock.Now().UTC() | |
| 255 | 275 | err = i.store.Update(ctx, issue) | |
| 256 | 276 | if errors.Is(err, domain.ErrConflict) { | |
| @@ -274,16 +294,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||
| 274 | 294 | } | |
| 275 | 295 | ||
| 276 | 296 | // Kind tells what number n of the repository names. The principal needs the | |
| 277 | - | // access of reading the repository, and issues must be on there: otherwise it | |
| 278 | - | // is domain.ErrNotFound. | |
| 297 | + | // access of reading the repository. Issues are resolved only when enabled; | |
| 298 | + | // merge requests are resolved even when issues are off. | |
| 279 | 299 | func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | |
| 280 | - | if _, err := i.readable(ctx, p, name); err != nil { | |
| 300 | + | repo, err := lookupRepo(ctx, i.meta, name) | |
| 301 | + | if err != nil { | |
| 281 | 302 | return domain.NumberNone, err | |
| 282 | 303 | } | |
| 283 | - | if _, err := i.store.Get(ctx, name, n); err == nil { | |
| 284 | - | return domain.NumberIssue, nil | |
| 285 | - | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 286 | - | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | |
| 304 | + | if !canRead(p, repo) { | |
| 305 | + | return domain.NumberNone, domain.ErrNotFound | |
| 306 | + | } | |
| 307 | + | if repo.Settings.IssuesEnabled() { | |
| 308 | + | if _, err := i.store.Get(ctx, name, n); err == nil { | |
| 309 | + | return domain.NumberIssue, nil | |
| 310 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 311 | + | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | |
| 312 | + | } | |
| 287 | 313 | } | |
| 288 | 314 | if _, err := i.mrs.Get(ctx, name, n); err == nil { | |
| 289 | 315 | return domain.NumberMergeRequest, nil | |
| @@ -26,6 +26,7 @@ const MaxCommentsPerIssue = 1000 | |||||
| 26 | // issue:write on it, which the branch prefixes of a token do not restrict: | 26 | // issue:write on it, which the branch prefixes of a token do not restrict: | ||
| 27 | // an issue writes no ref. | 27 | // an issue writes no ref. | ||
| 28 | type Issues struct { | 28 | type Issues struct { | ||
| 29 | + | links ports.IssueLinkStore | |||
| 29 | meta ports.RepoMetaStore | 30 | meta ports.RepoMetaStore | ||
| 30 | store ports.IssueStore | 31 | store ports.IssueStore | ||
| 31 | mrs ports.MergeRequestStore | 32 | mrs ports.MergeRequestStore | ||
| @@ -40,6 +41,7 @@ var ( | |||||
| 40 | 41 | ||||
| 41 | // IssuesConfig holds what the issue use cases need. Every field is required. | 42 | // IssuesConfig holds what the issue use cases need. Every field is required. | ||
| 42 | type IssuesConfig struct { | 43 | type IssuesConfig struct { | ||
| 44 | + | Links ports.IssueLinkStore | |||
| 43 | Meta ports.RepoMetaStore | 45 | Meta ports.RepoMetaStore | ||
| 44 | Store ports.IssueStore | 46 | Store ports.IssueStore | ||
| 45 | // MergeRequests tells which numbers name a merge request (Kind). | 47 | // MergeRequests tells which numbers name a merge request (Kind). | ||
| @@ -50,7 +52,7 @@ type IssuesConfig struct { | |||||
| 50 | 52 | ||||
| 51 | // NewIssues builds the issue use cases. | 53 | // NewIssues builds the issue use cases. | ||
| 52 | func NewIssues(cfg IssuesConfig) *Issues { | 54 | func NewIssues(cfg IssuesConfig) *Issues { | ||
| 53 | - | return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock} | 55 | + | return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock} |
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
| 56 | // enabled looks the repository up and hides it when issues are off. | 58 | // enabled looks the repository up and hides it when issues are off. | ||
| @@ -159,7 +161,24 @@ func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoNa | |||||
| 159 | if err != nil { | 161 | if err != nil { | ||
| 160 | return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err) | 162 | return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err) | ||
| 161 | } | 163 | } | ||
| 162 | - | return ports.IssueDetail{Issue: issue, Comments: comments}, nil | 164 | + | detail := ports.IssueDetail{Issue: issue, Comments: comments} |
| 165 | + | if i.links != nil { | |||
| 166 | + | links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue) | |||
| 167 | + | if err != nil { | |||
| 168 | + | return ports.IssueDetail{}, err | |||
| 169 | + | } | |||
| 170 | + | for _, link := range links { | |||
| 171 | + | mr, err := i.mrs.Get(ctx, name, link.MergeRequest) | |||
| 172 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 173 | + | continue | |||
| 174 | + | } | |||
| 175 | + | if err != nil { | |||
| 176 | + | return ports.IssueDetail{}, err | |||
| 177 | + | } | |||
| 178 | + | detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes}) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return detail, nil | |||
| 163 | } | 182 | } | ||
| 164 | 183 | ||||
| 165 | // AddComment adds a comment and publishes IssueCommented. The principal needs | 184 | // AddComment adds a comment and publishes IssueCommented. The principal needs | ||
| @@ -251,6 +270,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||||
| 251 | if issue.State != from { | 270 | if issue.State != from { | ||
| 252 | return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State) | 271 | return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State) | ||
| 253 | } | 272 | } | ||
| 273 | + | issue.ClosedByMergeRequest = 0 | |||
| 254 | issue.State, issue.UpdatedAt = to, i.clock.Now().UTC() | 274 | issue.State, issue.UpdatedAt = to, i.clock.Now().UTC() | ||
| 255 | err = i.store.Update(ctx, issue) | 275 | err = i.store.Update(ctx, issue) | ||
| 256 | if errors.Is(err, domain.ErrConflict) { | 276 | if errors.Is(err, domain.ErrConflict) { | ||
| @@ -274,16 +294,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||||
| 274 | } | 294 | } | ||
| 275 | 295 | ||||
| 276 | // Kind tells what number n of the repository names. The principal needs the | 296 | // Kind tells what number n of the repository names. The principal needs the | ||
| 277 | - | // access of reading the repository, and issues must be on there: otherwise it | 297 | + | // access of reading the repository. Issues are resolved only when enabled; |
| 278 | - | // is domain.ErrNotFound. | 298 | + | // merge requests are resolved even when issues are off. |
| 279 | func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | 299 | func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | ||
| 280 | - | if _, err := i.readable(ctx, p, name); err != nil { | 300 | + | repo, err := lookupRepo(ctx, i.meta, name) |
| 301 | + | if err != nil { | |||
| 281 | return domain.NumberNone, err | 302 | return domain.NumberNone, err | ||
| 282 | } | 303 | } | ||
| 283 | - | if _, err := i.store.Get(ctx, name, n); err == nil { | 304 | + | if !canRead(p, repo) { |
| 284 | - | return domain.NumberIssue, nil | 305 | + | return domain.NumberNone, domain.ErrNotFound |
| 285 | - | } else if !errors.Is(err, domain.ErrNotFound) { | 306 | + | } |
| 286 | - | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | 307 | + | if repo.Settings.IssuesEnabled() { |
| 308 | + | if _, err := i.store.Get(ctx, name, n); err == nil { | |||
| 309 | + | return domain.NumberIssue, nil | |||
| 310 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |||
| 311 | + | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | |||
| 312 | + | } | |||
| 287 | } | 313 | } | ||
| 288 | if _, err := i.mrs.Get(ctx, name, n); err == nil { | 314 | if _, err := i.mrs.Get(ctx, name, n); err == nil { | ||
| 289 | return domain.NumberMergeRequest, nil | 315 | return domain.NumberMergeRequest, nil | ||
M internal/usecase/issues_test.go +4 -2
| @@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) { | |||
| 65 | 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 66 | 66 | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | |
| 67 | 67 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 68 | - | _, err = u.issues.Kind(ctx, domain.Root(), n, 1) | |
| 69 | - | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 68 | + | u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil) | |
| 69 | + | kind, err := u.issues.Kind(ctx, domain.Root(), n, 1) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | assert.Equal(t, domain.NumberMergeRequest, kind) | |
| 70 | 72 | } | |
| 71 | 73 | } | |
| 72 | 74 | ||
| @@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) { | |||||
| 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 66 | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | 66 | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | ||
| 67 | assert.ErrorIs(t, err, domain.ErrNotFound) | 67 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 68 | - | _, err = u.issues.Kind(ctx, domain.Root(), n, 1) | 68 | + | u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil) |
| 69 | - | assert.ErrorIs(t, err, domain.ErrNotFound) | 69 | + | kind, err := u.issues.Kind(ctx, domain.Root(), n, 1) |
| 70 | + | require.NoError(t, err) | |||
| 71 | + | assert.Equal(t, domain.NumberMergeRequest, kind) | |||
| 70 | } | 72 | } | ||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
M internal/usecase/mergerequests.go +33 -3
| @@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:" | |||
| 43 | 43 | // merging mr:merge, on the repository (see scopeAccess), and that scope also | |
| 44 | 44 | // makes a private repository visible for the action (see writable). | |
| 45 | 45 | type MergeRequests struct { | |
| 46 | + | links ports.IssueLinkTrigger | |
| 46 | 47 | meta ports.RepoMetaStore | |
| 47 | 48 | store ports.MergeRequestStore | |
| 48 | 49 | repos ports.GitRepos | |
| @@ -78,6 +79,7 @@ var ( | |||
| 78 | 79 | // MergeRequestsConfig holds what the merge request use cases need. Every field | |
| 79 | 80 | // is required. | |
| 80 | 81 | type MergeRequestsConfig struct { | |
| 82 | + | Links ports.IssueLinkTrigger // Optional derived issue links. | |
| 81 | 83 | Meta ports.RepoMetaStore | |
| 82 | 84 | Store ports.MergeRequestStore | |
| 83 | 85 | // Repos gives the default branch of a repository. | |
| @@ -112,7 +114,7 @@ type MergeRequestsConfig struct { | |||
| 112 | 114 | // NewMergeRequests builds the merge request use cases. | |
| 113 | 115 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |
| 114 | 116 | return &MergeRequests{ | |
| 115 | - | meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | |
| 117 | + | links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | |
| 116 | 118 | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability, | |
| 117 | 119 | locks: make(map[domain.RepoName]chan struct{}), | |
| 118 | 120 | } | |
| @@ -292,6 +294,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom | |||
| 292 | 294 | } | |
| 293 | 295 | recordActivity(ctx, m.meta, repo.Name, now) | |
| 294 | 296 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 297 | + | if m.links != nil { | |
| 298 | + | m.links.OnMergeRequestChanged(ctx, repo, mr.Number) | |
| 299 | + | } | |
| 295 | 300 | if m.mergeab != nil { | |
| 296 | 301 | m.mergeab.OnOpened(ctx, mr) | |
| 297 | 302 | } | |
| @@ -434,6 +439,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name | |||
| 434 | 439 | } | |
| 435 | 440 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 436 | 441 | m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 442 | + | if m.links != nil { | |
| 443 | + | m.links.OnMergeRequestChanged(ctx, repo, number) | |
| 444 | + | } | |
| 437 | 445 | return m.withHead(ctx, mr) | |
| 438 | 446 | } | |
| 439 | 447 | return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict) | |
| @@ -651,17 +659,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 651 | 659 | // the head; both are zero when the target is gone or shares no history with | |
| 652 | 660 | // the head. A merged or closed request keeps the base it stored for both. | |
| 653 | 661 | func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) { | |
| 662 | + | return mrRange(ctx, m.git, mr) | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) { | |
| 654 | 666 | if !mr.IsOpen() { | |
| 655 | 667 | return mr.BaseID, mr.BaseID, nil | |
| 656 | 668 | } | |
| 657 | 669 | if mr.HeadID.IsZero() { | |
| 658 | 670 | return domain.ObjectID{}, domain.ObjectID{}, nil | |
| 659 | 671 | } | |
| 660 | - | tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target) | |
| 672 | + | tip, err := git.BranchHead(ctx, mr.Repo, mr.Target) | |
| 673 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 674 | + | return domain.ObjectID{}, domain.ObjectID{}, nil | |
| 675 | + | } | |
| 661 | 676 | if err != nil || tip.IsZero() { | |
| 662 | 677 | return domain.ObjectID{}, domain.ObjectID{}, err | |
| 663 | 678 | } | |
| 664 | - | base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID) | |
| 679 | + | base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID) | |
| 665 | 680 | if errors.Is(err, domain.ErrNotFound) { | |
| 666 | 681 | return tip, domain.ObjectID{}, nil | |
| 667 | 682 | } | |
| @@ -781,6 +796,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name | |||
| 781 | 796 | m.bus.Publish(ctx, domain.CommentAdded{ | |
| 782 | 797 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 783 | 798 | }) | |
| 799 | + | if m.links != nil { | |
| 800 | + | m.links.OnMergeRequestChanged(ctx, repo, number) | |
| 801 | + | } | |
| 784 | 802 | return c, nil | |
| 785 | 803 | } | |
| 786 | 804 | ||
| @@ -921,6 +939,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||
| 921 | 939 | if hadAutoMerge { | |
| 922 | 940 | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 923 | 941 | } | |
| 942 | + | if m.links != nil { | |
| 943 | + | m.links.OnMergeRequestSettled(ctx, repo, mr) | |
| 944 | + | } | |
| 924 | 945 | if m.pulls != nil { | |
| 925 | 946 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | |
| 926 | 947 | } | |
| @@ -1219,6 +1240,9 @@ func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name do | |||
| 1219 | 1240 | // never returned. mr is the merged request, tip the old target and merged the | |
| 1220 | 1241 | // new one. | |
| 1221 | 1242 | func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) { | |
| 1243 | + | if m.links != nil { | |
| 1244 | + | m.links.OnMerged(ctx, p, repo, mr) | |
| 1245 | + | } | |
| 1222 | 1246 | name := repo.Name | |
| 1223 | 1247 | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | |
| 1224 | 1248 | if deleteSource && m.deleteSource(ctx, p, name, mr, head) { | |
| @@ -1244,6 +1268,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo | |||
| 1244 | 1268 | if m.mergeab != nil { | |
| 1245 | 1269 | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | |
| 1246 | 1270 | } | |
| 1271 | + | if m.links != nil { | |
| 1272 | + | m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved | |
| 1273 | + | } | |
| 1247 | 1274 | } | |
| 1248 | 1275 | ||
| 1249 | 1276 | // deleteSource deletes the source branch of a merged request if it is still | |
| @@ -1518,6 +1545,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n | |||
| 1518 | 1545 | } else { | |
| 1519 | 1546 | m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 1520 | 1547 | } | |
| 1548 | + | if m.links != nil { | |
| 1549 | + | m.links.OnMergeRequestChanged(ctx, repo, number) | |
| 1550 | + | } | |
| 1521 | 1551 | return c, nil | |
| 1522 | 1552 | } | |
| 1523 | 1553 | return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict) | |
| @@ -43,6 +43,7 @@ const mrTokenPrefix = "mp1:" | |||||
| 43 | // merging mr:merge, on the repository (see scopeAccess), and that scope also | 43 | // merging mr:merge, on the repository (see scopeAccess), and that scope also | ||
| 44 | // makes a private repository visible for the action (see writable). | 44 | // makes a private repository visible for the action (see writable). | ||
| 45 | type MergeRequests struct { | 45 | type MergeRequests struct { | ||
| 46 | + | links ports.IssueLinkTrigger | |||
| 46 | meta ports.RepoMetaStore | 47 | meta ports.RepoMetaStore | ||
| 47 | store ports.MergeRequestStore | 48 | store ports.MergeRequestStore | ||
| 48 | repos ports.GitRepos | 49 | repos ports.GitRepos | ||
| @@ -78,6 +79,7 @@ var ( | |||||
| 78 | // MergeRequestsConfig holds what the merge request use cases need. Every field | 79 | // MergeRequestsConfig holds what the merge request use cases need. Every field | ||
| 79 | // is required. | 80 | // is required. | ||
| 80 | type MergeRequestsConfig struct { | 81 | type MergeRequestsConfig struct { | ||
| 82 | + | Links ports.IssueLinkTrigger // Optional derived issue links. | |||
| 81 | Meta ports.RepoMetaStore | 83 | Meta ports.RepoMetaStore | ||
| 82 | Store ports.MergeRequestStore | 84 | Store ports.MergeRequestStore | ||
| 83 | // Repos gives the default branch of a repository. | 85 | // Repos gives the default branch of a repository. | ||
| @@ -112,7 +114,7 @@ type MergeRequestsConfig struct { | |||||
| 112 | // NewMergeRequests builds the merge request use cases. | 114 | // NewMergeRequests builds the merge request use cases. | ||
| 113 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | 115 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | ||
| 114 | return &MergeRequests{ | 116 | return &MergeRequests{ | ||
| 115 | - | meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | 117 | + | links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, |
| 116 | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability, | 118 | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability, | ||
| 117 | locks: make(map[domain.RepoName]chan struct{}), | 119 | locks: make(map[domain.RepoName]chan struct{}), | ||
| 118 | } | 120 | } | ||
| @@ -292,6 +294,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom | |||||
| 292 | } | 294 | } | ||
| 293 | recordActivity(ctx, m.meta, repo.Name, now) | 295 | recordActivity(ctx, m.meta, repo.Name, now) | ||
| 294 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 296 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 297 | + | if m.links != nil { | |||
| 298 | + | m.links.OnMergeRequestChanged(ctx, repo, mr.Number) | |||
| 299 | + | } | |||
| 295 | if m.mergeab != nil { | 300 | if m.mergeab != nil { | ||
| 296 | m.mergeab.OnOpened(ctx, mr) | 301 | m.mergeab.OnOpened(ctx, mr) | ||
| 297 | } | 302 | } | ||
| @@ -434,6 +439,9 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name | |||||
| 434 | } | 439 | } | ||
| 435 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | 440 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | ||
| 436 | m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 441 | m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 442 | + | if m.links != nil { | |||
| 443 | + | m.links.OnMergeRequestChanged(ctx, repo, number) | |||
| 444 | + | } | |||
| 437 | return m.withHead(ctx, mr) | 445 | return m.withHead(ctx, mr) | ||
| 438 | } | 446 | } | ||
| 439 | return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict) | 447 | return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict) | ||
| @@ -651,17 +659,24 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||||
| 651 | // the head; both are zero when the target is gone or shares no history with | 659 | // the head; both are zero when the target is gone or shares no history with | ||
| 652 | // the head. A merged or closed request keeps the base it stored for both. | 660 | // the head. A merged or closed request keeps the base it stored for both. | ||
| 653 | func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) { | 661 | func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) { | ||
| 662 | + | return mrRange(ctx, m.git, mr) | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) { | |||
| 654 | if !mr.IsOpen() { | 666 | if !mr.IsOpen() { | ||
| 655 | return mr.BaseID, mr.BaseID, nil | 667 | return mr.BaseID, mr.BaseID, nil | ||
| 656 | } | 668 | } | ||
| 657 | if mr.HeadID.IsZero() { | 669 | if mr.HeadID.IsZero() { | ||
| 658 | return domain.ObjectID{}, domain.ObjectID{}, nil | 670 | return domain.ObjectID{}, domain.ObjectID{}, nil | ||
| 659 | } | 671 | } | ||
| 660 | - | tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target) | 672 | + | tip, err := git.BranchHead(ctx, mr.Repo, mr.Target) |
| 673 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 674 | + | return domain.ObjectID{}, domain.ObjectID{}, nil | |||
| 675 | + | } | |||
| 661 | if err != nil || tip.IsZero() { | 676 | if err != nil || tip.IsZero() { | ||
| 662 | return domain.ObjectID{}, domain.ObjectID{}, err | 677 | return domain.ObjectID{}, domain.ObjectID{}, err | ||
| 663 | } | 678 | } | ||
| 664 | - | base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID) | 679 | + | base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID) |
| 665 | if errors.Is(err, domain.ErrNotFound) { | 680 | if errors.Is(err, domain.ErrNotFound) { | ||
| 666 | return tip, domain.ObjectID{}, nil | 681 | return tip, domain.ObjectID{}, nil | ||
| 667 | } | 682 | } | ||
| @@ -781,6 +796,9 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name | |||||
| 781 | m.bus.Publish(ctx, domain.CommentAdded{ | 796 | m.bus.Publish(ctx, domain.CommentAdded{ | ||
| 782 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 797 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
| 783 | }) | 798 | }) | ||
| 799 | + | if m.links != nil { | |||
| 800 | + | m.links.OnMergeRequestChanged(ctx, repo, number) | |||
| 801 | + | } | |||
| 784 | return c, nil | 802 | return c, nil | ||
| 785 | } | 803 | } | ||
| 786 | 804 | ||||
| @@ -921,6 +939,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||||
| 921 | if hadAutoMerge { | 939 | if hadAutoMerge { | ||
| 922 | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 940 | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 923 | } | 941 | } | ||
| 942 | + | if m.links != nil { | |||
| 943 | + | m.links.OnMergeRequestSettled(ctx, repo, mr) | |||
| 944 | + | } | |||
| 924 | if m.pulls != nil { | 945 | if m.pulls != nil { | ||
| 925 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | 946 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | ||
| 926 | } | 947 | } | ||
| @@ -1219,6 +1240,9 @@ func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name do | |||||
| 1219 | // never returned. mr is the merged request, tip the old target and merged the | 1240 | // never returned. mr is the merged request, tip the old target and merged the | ||
| 1220 | // new one. | 1241 | // new one. | ||
| 1221 | func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) { | 1242 | func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) { | ||
| 1243 | + | if m.links != nil { | |||
| 1244 | + | m.links.OnMerged(ctx, p, repo, mr) | |||
| 1245 | + | } | |||
| 1222 | name := repo.Name | 1246 | name := repo.Name | ||
| 1223 | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | 1247 | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | ||
| 1224 | if deleteSource && m.deleteSource(ctx, p, name, mr, head) { | 1248 | if deleteSource && m.deleteSource(ctx, p, name, mr, head) { | ||
| @@ -1244,6 +1268,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo | |||||
| 1244 | if m.mergeab != nil { | 1268 | if m.mergeab != nil { | ||
| 1245 | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | 1269 | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | ||
| 1246 | } | 1270 | } | ||
| 1271 | + | if m.links != nil { | |||
| 1272 | + | m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved | |||
| 1273 | + | } | |||
| 1247 | } | 1274 | } | ||
| 1248 | 1275 | ||||
| 1249 | // deleteSource deletes the source branch of a merged request if it is still | 1276 | // deleteSource deletes the source branch of a merged request if it is still | ||
| @@ -1518,6 +1545,9 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n | |||||
| 1518 | } else { | 1545 | } else { | ||
| 1519 | m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 1546 | m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 1520 | } | 1547 | } | ||
| 1548 | + | if m.links != nil { | |||
| 1549 | + | m.links.OnMergeRequestChanged(ctx, repo, number) | |||
| 1550 | + | } | |||
| 1521 | return c, nil | 1551 | return c, nil | ||
| 1522 | } | 1552 | } | ||
| 1523 | return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict) | 1553 | return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict) | ||
M internal/usecase/pushes.go +7 -0
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // Pushes are the use cases around a Git push. | |
| 19 | 19 | type Pushes struct { | |
| 20 | + | links ports.IssueLinkTrigger | |
| 20 | 21 | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 21 | 22 | meta ports.RepoMetaStore | |
| 22 | 23 | repos *Repos | |
| @@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||
| 44 | 45 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | |
| 45 | 46 | } | |
| 46 | 47 | ||
| 48 | + | // WithIssueLinks enables synchronous best-effort issue indexing before the first push. | |
| 49 | + | func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u } | |
| 50 | + | ||
| 47 | 51 | // WithMirror makes Record notify outbound mirrors. Call before the first push. | |
| 48 | 52 | func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes { | |
| 49 | 53 | u.mirror = t | |
| @@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 265 | 269 | if u.mergeab != nil { | |
| 266 | 270 | u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only | |
| 267 | 271 | } | |
| 272 | + | if u.links != nil { | |
| 273 | + | u.links.OnBranchesMoved(ctx, name, updates) | |
| 274 | + | } | |
| 268 | 275 | u.bus.Publish(ctx, domain.PushEvent{ | |
| 269 | 276 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 270 | 277 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // Pushes are the use cases around a Git push. | 18 | // Pushes are the use cases around a Git push. | ||
| 19 | type Pushes struct { | 19 | type Pushes struct { | ||
| 20 | + | links ports.IssueLinkTrigger | |||
| 20 | mirror ports.MirrorTrigger // nil: no outbound mirror | 21 | mirror ports.MirrorTrigger // nil: no outbound mirror | ||
| 21 | meta ports.RepoMetaStore | 22 | meta ports.RepoMetaStore | ||
| 22 | repos *Repos | 23 | repos *Repos | ||
| @@ -44,6 +45,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||||
| 44 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | 45 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | ||
| 45 | } | 46 | } | ||
| 46 | 47 | ||||
| 48 | + | // WithIssueLinks enables synchronous best-effort issue indexing before the first push. | |||
| 49 | + | func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u } | |||
| 50 | + | ||||
| 47 | // WithMirror makes Record notify outbound mirrors. Call before the first push. | 51 | // WithMirror makes Record notify outbound mirrors. Call before the first push. | ||
| 48 | func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes { | 52 | func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes { | ||
| 49 | u.mirror = t | 53 | u.mirror = t | ||
| @@ -265,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||||
| 265 | if u.mergeab != nil { | 269 | if u.mergeab != nil { | ||
| 266 | u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only | 270 | u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only | ||
| 267 | } | 271 | } | ||
| 272 | + | if u.links != nil { | |||
| 273 | + | u.links.OnBranchesMoved(ctx, name, updates) | |||
| 274 | + | } | |||
| 268 | u.bus.Publish(ctx, domain.PushEvent{ | 275 | u.bus.Publish(ctx, domain.PushEvent{ | ||
| 269 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | 276 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | ||
| 270 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 277 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
M proto/ermite/storage/issue.proto +12 -0
| @@ -36,6 +36,18 @@ message Issue { | |||
| 36 | 36 | // True for an issue approved from the public form: its text comes from | |
| 37 | 37 | // an anonymous visitor and is rendered as untrusted. | |
| 38 | 38 | bool external = 9; | |
| 39 | + | int64 closed_by_merge_request = 10; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>. | |
| 43 | + | message IssueLink { | |
| 44 | + | bool closes = 1; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // MergeRequestReferences is stored under mrref/<repo>/<mr:010d>. | |
| 48 | + | message MergeRequestReferences { | |
| 49 | + | repeated int64 issues = 1; | |
| 50 | + | repeated int64 closes = 2; | |
| 39 | 51 | } | |
| 40 | 52 | ||
| 41 | 53 | // IssueComment is stored under the key | |
| @@ -36,6 +36,18 @@ message Issue { | |||||
| 36 | // True for an issue approved from the public form: its text comes from | 36 | // True for an issue approved from the public form: its text comes from | ||
| 37 | // an anonymous visitor and is rendered as untrusted. | 37 | // an anonymous visitor and is rendered as untrusted. | ||
| 38 | bool external = 9; | 38 | bool external = 9; | ||
| 39 | + | int64 closed_by_merge_request = 10; | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>. | |||
| 43 | + | message IssueLink { | |||
| 44 | + | bool closes = 1; | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // MergeRequestReferences is stored under mrref/<repo>/<mr:010d>. | |||
| 48 | + | message MergeRequestReferences { | |||
| 49 | + | repeated int64 issues = 1; | |||
| 50 | + | repeated int64 closes = 2; | |||
| 39 | } | 51 | } | ||
| 40 | 52 | ||||
| 41 | // IssueComment is stored under the key | 53 | // IssueComment is stored under the key | ||