feat(issues): show merge request references and closing merge request in the API and CLI
- commit
- 7dac988da4b2f65a360d17d37f0bb5a90db816a0
- author
- brice · · 2026-10-20 15:18 UTC
- parent
- 1386983
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +190 -24
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 first 100 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 first 100 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 go.mod +3 -0
| @@ -172,3 +172,6 @@ require ( | |||
| 172 | 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | |
| 173 | 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | |
| 174 | 174 | ) | |
| 175 | + | ||
| 176 | + | // Temporary: removed once ermite-api is tagged. | |
| 177 | + | replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/issue-links | |
| @@ -172,3 +172,6 @@ require ( | |||||
| 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | ||
| 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | ||
| 174 | ) | 174 | ) | ||
| 175 | + | ||||
| 176 | + | // Temporary: removed once ermite-api is tagged. | |||
| 177 | + | replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/issue-links | |||
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"} | |
| 38 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"} | |
| 39 | 39 | ||
| 40 | 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"} | 38 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"} |
| 39 | 39 | ||||
| 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | ||
| 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | ||
M internal/adapters/rpc/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/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"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who) | |
| 200 | 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"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who) |
| 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
M internal/app/cli_e2e_test.go +1 -1
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 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"}, ver.Server.Features) | |
| 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features) | |
| 354 | 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"}, ver.Server.Features) | 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features) |
| 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 355 | stdout, stderr, code = c.bin(t, secret, "version") | 355 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 356 | require.Equal(t, 0, code, stderr) | 356 | require.Equal(t, 0, code, stderr) | ||
M internal/app/cli_issue.go +55 -9
| @@ -57,6 +57,8 @@ type jsonIssue struct { | |||
| 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/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 | + | } | |||