detached 7dac988

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
6161
6262 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
6363 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references
6465
6566 ermite run list <repo> [--workflow …] [--branch …] [--status …]
6667 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
@@ -61,6 +61,7 @@ ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--del
61 61
62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references
64 65
65 ermite run list <repo> [--workflow …] [--branch …] [--status …]66 ermite run list <repo> [--workflow …] [--branch …] [--status …]
66 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]67 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
M docs/issues.md +12 -0
@@ -34,6 +34,18 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br
3434
3535 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
3636
37+## Merge request links
38+
39+A merge request and the issues of its repository are linked by `#N` in its text:
40+
41+- **Closing keywords** close the issue when the merge request is **merged**, and only then: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved` (any case), then `#N`, with an optional `:` after the keyword. A list works: `fixes #3, #4 and #5`. They count in the title, the description and the commit messages, not in notes. Closing the merge request without merging closes nothing.
42+- **Any `#N`** in the title, description, notes or commit messages shows the merge request on the issue (`ermite issue show`, the API `references`), with its state and whether it closes the issue.
43+- A closed issue records the merge request that closed it ("closed by !N"). Reopening or closing by hand clears it.
44+- The links are recomputed whenever the merge request changes (a note added, edited or deleted, a push to its branch, a merge), so removing a mention removes the link.
45+- Words glued to the number (`abc#3`), `&#39;` and `##3` are not references. Code spans are not skipped.
46+
47+Limits: the 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+
3749 ## Inbox
3850
3951 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
@@ -34,6 +34,18 @@ ermite token create claude --preset agent --scope issue:write --repo <repo> --br
34 34
35 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.35 Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
36 36
37+## Merge request links
38+
39+A merge request and the issues of its repository are linked by `#N` in its text:
40+
41+- **Closing keywords** close the issue when the merge request is **merged**, and only then: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved` (any case), then `#N`, with an optional `:` after the keyword. A list works: `fixes #3, #4 and #5`. They count in the title, the description and the commit messages, not in notes. Closing the merge request without merging closes nothing.
42+- **Any `#N`** in the title, description, notes or commit messages shows the merge request on the issue (`ermite issue show`, the API `references`), with its state and whether it closes the issue.
43+- A closed issue records the merge request that closed it ("closed by !N"). Reopening or closing by hand clears it.
44+- The links are recomputed whenever the merge request changes (a note added, edited or deleted, a push to its branch, a merge), so removing a mention removes the link.
45+- Words glued to the number (`abc#3`), `&#39;` and `##3` are not references. Code spans are not skipped.
46+
47+Limits: the 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 ## Inbox49 ## Inbox
38 50
39 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.51 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
M docs/merge-requests.md +2 -0
@@ -8,6 +8,8 @@ ermite mr show myrepo 1 --diff
88 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]
99 ```
1010
11+Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issues](issues.md#merge-request-links).
12+
1113 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
1214 merge request web URL on its own line. With `--json`, merge request objects
1315 (create, show, list, close and merge) include `url` alongside the other fields.
@@ -8,6 +8,8 @@ ermite mr show myrepo 1 --diff
8 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]8 ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]
9 ```9 ```
10 10
11+Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issues](issues.md#merge-request-links).
12+
11 `ermite mr create` and `ermite mr close` print a confirmation followed by the full13 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
12 merge request web URL on its own line. With `--json`, merge request objects14 merge request web URL on its own line. With `--json`, merge request objects
13 (create, show, list, close and merge) include `url` alongside the other fields.15 (create, show, list, close and merge) include `url` alongside the other fields.
M go.mod +3 -0
@@ -172,3 +172,6 @@ require (
172172 mvdan.cc/xurls/v2 v2.6.0 // indirect
173173 pluginrpc.com/pluginrpc v0.5.0 // indirect
174174 )
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 // indirect172 mvdan.cc/xurls/v2 v2.6.0 // indirect
173 pluginrpc.com/pluginrpc v0.5.0 // indirect173 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
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/issue.go +19 -9
@@ -70,15 +70,16 @@ func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
7070
7171 func issueToPB(i domain.Issue) *pb.Issue {
7272 return &pb.Issue{
73- Repo: i.Repo.String(),
74- Number: i.Number,
75- Title: i.Title.String(),
76- Body: i.Body.String(),
77- State: issueStateToPB(i.State),
78- Author: i.Author,
79- CreatedAt: timestamp(i.CreatedAt),
80- UpdatedAt: timestamp(i.UpdatedAt),
81- CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
73+ Repo: i.Repo.String(),
74+ Number: i.Number,
75+ Title: i.Title.String(),
76+ Body: i.Body.String(),
77+ State: issueStateToPB(i.State),
78+ Author: i.Author,
79+ CreatedAt: timestamp(i.CreatedAt),
80+ UpdatedAt: timestamp(i.UpdatedAt),
81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ ClosedByMergeRequest: i.ClosedByMergeRequest,
8283 }
8384 }
8485
@@ -138,6 +139,15 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
138139 for _, c := range d.Comments {
139140 out.Comments = append(out.Comments, issueCommentToPB(c))
140141 }
142+ out.References = make([]*pb.IssueReference, 0, len(d.References))
143+ for _, r := range d.References {
144+ out.References = append(out.References, &pb.IssueReference{
145+ Number: r.Number,
146+ Title: r.Title.String(),
147+ State: mergeRequestStateToPB(r.State),
148+ Closes: r.Closes,
149+ })
150+ }
141151 return connect.NewResponse(out), nil
142152 }
143153
@@ -70,15 +70,16 @@ func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
70 70
71 func issueToPB(i domain.Issue) *pb.Issue {71 func issueToPB(i domain.Issue) *pb.Issue {
72 return &pb.Issue{72 return &pb.Issue{
73- Repo: i.Repo.String(),73+ Repo: i.Repo.String(),
74- Number: i.Number,74+ Number: i.Number,
75- Title: i.Title.String(),75+ Title: i.Title.String(),
76- Body: i.Body.String(),76+ Body: i.Body.String(),
77- State: issueStateToPB(i.State),77+ State: issueStateToPB(i.State),
78- Author: i.Author,78+ Author: i.Author,
79- CreatedAt: timestamp(i.CreatedAt),79+ CreatedAt: timestamp(i.CreatedAt),
80- UpdatedAt: timestamp(i.UpdatedAt),80+ UpdatedAt: timestamp(i.UpdatedAt),
81- CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue81+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82+ ClosedByMergeRequest: i.ClosedByMergeRequest,
82 }83 }
83 }84 }
84 85
@@ -138,6 +139,15 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
138 for _, c := range d.Comments {139 for _, c := range d.Comments {
139 out.Comments = append(out.Comments, issueCommentToPB(c))140 out.Comments = append(out.Comments, issueCommentToPB(c))
140 }141 }
142+ out.References = make([]*pb.IssueReference, 0, len(d.References))
143+ for _, r := range d.References {
144+ out.References = append(out.References, &pb.IssueReference{
145+ Number: r.Number,
146+ Title: r.Title.String(),
147+ State: mergeRequestStateToPB(r.State),
148+ Closes: r.Closes,
149+ })
150+ }
141 return connect.NewResponse(out), nil151 return connect.NewResponse(out), nil
142 }152 }
143 153
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_issue.go +55 -9
@@ -57,6 +57,8 @@ type jsonIssue struct {
5757 CreatedAt *string `json:"created_at"`
5858 UpdatedAt *string `json:"updated_at"`
5959 CommentCount int32 `json:"comment_count"`
60+ // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
61+ ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
6062 }
6163
6264 func toJSONIssue(i *ermite.Issue) jsonIssue {
@@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue {
6466 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
6567 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
6668 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
69+ ClosedByMergeRequest: i.GetClosedByMergeRequest(),
6770 }
6871 }
6972
@@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
8285 // jsonIssueDetail is the stable JSON shape of `issue show --json`.
8386 type jsonIssueDetail struct {
8487 jsonIssue
85- Comments []jsonIssueComment `json:"comments"`
88+ Comments []jsonIssueComment `json:"comments"`
89+ References []jsonIssueReference `json:"references"`
90+}
91+
92+// jsonIssueReference is a merge request that names the issue.
93+type jsonIssueReference struct {
94+ Number int64 `json:"number"`
95+ Title string `json:"title"`
96+ State string `json:"state"`
97+ Closes bool `json:"closes"`
8698 }
8799
88100 const jsonIssueHelp = `
89101 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
90-(token label), created_at and updated_at (RFC 3339 UTC), comment_count.`
102+(token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103+the merge request whose merge closed the issue, 0 for none).`
91104
92105 const jsonIssueCommentHelp = `
93106 JSON fields of an issue comment: id, author, text, created_at.`
94107
108+const jsonIssueReferenceHelp = `
109+JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110+(true when merging it closes the issue).`
111+
95112 func newIssueCmd(env *cliEnv) *cobra.Command {
96113 issue := &cobra.Command{
97114 Use: "issue",
@@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
298315 cmd := &cobra.Command{
299316 Use: "show <repo> <number>",
300317 Short: "Show an issue with its comments",
301- Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +
302- "\n--json adds comments to the issue fields.",
318+ Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
319+ "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320+ "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322+ "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
303323 Args: cobra.ExactArgs(2),
304324 ValidArgsFunction: completeIssueRepoThenNumber(env),
305325 }
@@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
322342 for _, cm := range res.Msg.GetComments() {
323343 out.Comments = append(out.Comments, toJSONIssueComment(cm))
324344 }
345+ out.References = []jsonIssueReference{}
346+ for _, r := range res.Msg.GetReferences() {
347+ out.References = append(out.References, jsonIssueReference{
348+ Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(),
349+ })
350+ }
325351 return writeJSON(cmd.OutOrStdout(), out)
326352 }
327353 return printIssueDetail(cmd.OutOrStdout(), res.Msg)
@@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
335361 if body == "" {
336362 body = "-"
337363 }
338- err := fields(w, [][2]string{
364+ rows := [][2]string{
339365 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340366 {"title", i.GetTitle()},
341367 {"state", issueStateName(i.GetState())},
342- {"author", i.GetAuthor()},
343- {"created", shortTime(i.GetCreatedAt(), "-")},
344- {"updated", shortTime(i.GetUpdatedAt(), "-")},
345- })
368+ }
369+ if n := i.GetClosedByMergeRequest(); n > 0 {
370+ rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
371+ }
372+ rows = append(rows,
373+ [2]string{"author", i.GetAuthor()},
374+ [2]string{"created", shortTime(i.GetCreatedAt(), "-")},
375+ [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")},
376+ )
377+ err := fields(w, rows)
346378 if err != nil {
347379 return err
348380 }
349381 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350382 return err
351383 }
384+ if refs := d.GetReferences(); len(refs) > 0 {
385+ if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil {
386+ return err
387+ }
388+ for _, r := range refs {
389+ line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390+ if r.GetCloses() {
391+ line += " · closes"
392+ }
393+ if _, err := fmt.Fprintln(w, line); err != nil {
394+ return err
395+ }
396+ }
397+ }
352398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353399 return err
354400 }
@@ -57,6 +57,8 @@ type jsonIssue struct {
57 CreatedAt *string `json:"created_at"`57 CreatedAt *string `json:"created_at"`
58 UpdatedAt *string `json:"updated_at"`58 UpdatedAt *string `json:"updated_at"`
59 CommentCount int32 `json:"comment_count"`59 CommentCount int32 `json:"comment_count"`
60+ // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
61+ ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
60 }62 }
61 63
62 func toJSONIssue(i *ermite.Issue) jsonIssue {64 func toJSONIssue(i *ermite.Issue) jsonIssue {
@@ -64,6 +66,7 @@ func toJSONIssue(i *ermite.Issue) jsonIssue {
64 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),66 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
65 State: issueStateName(i.GetState()), Author: i.GetAuthor(),67 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
66 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),68 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
69+ ClosedByMergeRequest: i.GetClosedByMergeRequest(),
67 }70 }
68 }71 }
69 72
@@ -82,16 +85,30 @@ func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
82 // jsonIssueDetail is the stable JSON shape of `issue show --json`.85 // jsonIssueDetail is the stable JSON shape of `issue show --json`.
83 type jsonIssueDetail struct {86 type jsonIssueDetail struct {
84 jsonIssue87 jsonIssue
85- Comments []jsonIssueComment `json:"comments"`88+ Comments []jsonIssueComment `json:"comments"`
89+ References []jsonIssueReference `json:"references"`
90+}
91+
92+// jsonIssueReference is a merge request that names the issue.
93+type jsonIssueReference struct {
94+ Number int64 `json:"number"`
95+ Title string `json:"title"`
96+ State string `json:"state"`
97+ Closes bool `json:"closes"`
86 }98 }
87 99
88 const jsonIssueHelp = `100 const jsonIssueHelp = `
89 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author101 JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
90-(token label), created_at and updated_at (RFC 3339 UTC), comment_count.`102+(token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103+the merge request whose merge closed the issue, 0 for none).`
91 104
92 const jsonIssueCommentHelp = `105 const jsonIssueCommentHelp = `
93 JSON fields of an issue comment: id, author, text, created_at.`106 JSON fields of an issue comment: id, author, text, created_at.`
94 107
108+const jsonIssueReferenceHelp = `
109+JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110+(true when merging it closes the issue).`
111+
95 func newIssueCmd(env *cliEnv) *cobra.Command {112 func newIssueCmd(env *cliEnv) *cobra.Command {
96 issue := &cobra.Command{113 issue := &cobra.Command{
97 Use: "issue",114 Use: "issue",
@@ -298,8 +315,11 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
298 cmd := &cobra.Command{315 cmd := &cobra.Command{
299 Use: "show <repo> <number>",316 Use: "show <repo> <number>",
300 Short: "Show an issue with its comments",317 Short: "Show an issue with its comments",
301- Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +318+ Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
302- "\n--json adds comments to the issue fields.",319+ "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320+ "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322+ "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
303 Args: cobra.ExactArgs(2),323 Args: cobra.ExactArgs(2),
304 ValidArgsFunction: completeIssueRepoThenNumber(env),324 ValidArgsFunction: completeIssueRepoThenNumber(env),
305 }325 }
@@ -322,6 +342,12 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
322 for _, cm := range res.Msg.GetComments() {342 for _, cm := range res.Msg.GetComments() {
323 out.Comments = append(out.Comments, toJSONIssueComment(cm))343 out.Comments = append(out.Comments, toJSONIssueComment(cm))
324 }344 }
345+ out.References = []jsonIssueReference{}
346+ for _, r := range res.Msg.GetReferences() {
347+ out.References = append(out.References, jsonIssueReference{
348+ Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(),
349+ })
350+ }
325 return writeJSON(cmd.OutOrStdout(), out)351 return writeJSON(cmd.OutOrStdout(), out)
326 }352 }
327 return printIssueDetail(cmd.OutOrStdout(), res.Msg)353 return printIssueDetail(cmd.OutOrStdout(), res.Msg)
@@ -335,20 +361,40 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
335 if body == "" {361 if body == "" {
336 body = "-"362 body = "-"
337 }363 }
338- err := fields(w, [][2]string{364+ rows := [][2]string{
339 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},365 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340 {"title", i.GetTitle()},366 {"title", i.GetTitle()},
341 {"state", issueStateName(i.GetState())},367 {"state", issueStateName(i.GetState())},
342- {"author", i.GetAuthor()},368+ }
343- {"created", shortTime(i.GetCreatedAt(), "-")},369+ if n := i.GetClosedByMergeRequest(); n > 0 {
344- {"updated", shortTime(i.GetUpdatedAt(), "-")},370+ rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
345- })371+ }
372+ rows = append(rows,
373+ [2]string{"author", i.GetAuthor()},
374+ [2]string{"created", shortTime(i.GetCreatedAt(), "-")},
375+ [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")},
376+ )
377+ err := fields(w, rows)
346 if err != nil {378 if err != nil {
347 return err379 return err
348 }380 }
349 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {381 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350 return err382 return err
351 }383 }
384+ if refs := d.GetReferences(); len(refs) > 0 {
385+ if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil {
386+ return err
387+ }
388+ for _, r := range refs {
389+ line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390+ if r.GetCloses() {
391+ line += " · closes"
392+ }
393+ if _, err := fmt.Fprintln(w, line); err != nil {
394+ return err
395+ }
396+ }
397+ }
352 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353 return err399 return err
354 }400 }
M internal/app/cli_issue_e2e_test.go +7 -2
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "encoding/json"
67 "strings"
78 "testing"
89
@@ -24,7 +25,7 @@ type issueOut struct {
2425 CommentCount int `json:"comment_count"`
2526 }
2627
27-var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
2829
2930 type issueCommentOut struct {
3031 ID string `json:"id"`
@@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
106107 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107108 type showOut struct {
108109 issueOut
109- Comments []issueCommentOut `json:"comments"`
110+ Comments []issueCommentOut `json:"comments"`
111+ References []json.RawMessage `json:"references"`
110112 }
111113 show := decode[showOut](t, res.stdout)
112114 assert.Equal(t, 2, show.CommentCount)
@@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
115117 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116118 assert.Contains(t, res.stdout, "Crash on start")
117119 assert.Contains(t, res.stdout, "comments (2)")
120+ assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue")
121+ assert.NotNil(t, show.References, "references is always present")
122+ assert.Empty(t, show.References)
118123
119124 // close and reopen.
120125 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
@@ -3,6 +3,7 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "encoding/json"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 9
@@ -24,7 +25,7 @@ type issueOut struct {
24 CommentCount int `json:"comment_count"`25 CommentCount int `json:"comment_count"`
25 }26 }
26 27
27-var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}28+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28 29
29 type issueCommentOut struct {30 type issueCommentOut struct {
30 ID string `json:"id"`31 ID string `json:"id"`
@@ -106,7 +107,8 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
106 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)107 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107 type showOut struct {108 type showOut struct {
108 issueOut109 issueOut
109- Comments []issueCommentOut `json:"comments"`110+ Comments []issueCommentOut `json:"comments"`
111+ References []json.RawMessage `json:"references"`
110 }112 }
111 show := decode[showOut](t, res.stdout)113 show := decode[showOut](t, res.stdout)
112 assert.Equal(t, 2, show.CommentCount)114 assert.Equal(t, 2, show.CommentCount)
@@ -115,6 +117,9 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
115 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)117 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116 assert.Contains(t, res.stdout, "Crash on start")118 assert.Contains(t, res.stdout, "Crash on start")
117 assert.Contains(t, res.stdout, "comments (2)")119 assert.Contains(t, res.stdout, "comments (2)")
120+ assert.NotContains(t, res.stdout, "merge requests", "no merge request names the issue")
121+ assert.NotNil(t, show.References, "references is always present")
122+ assert.Empty(t, show.References)
118 123
119 // close and reopen.124 // close and reopen.
120 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)125 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
M internal/app/cli_issue_test.go +36 -1
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "os"
88 "path/filepath"
9+ "strings"
910 "testing"
1011
1112 "github.com/stretchr/testify/assert"
@@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
5152 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
5253
5354 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)
55+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout)
5556
5657 for _, args := range [][]string{
5758 {"issue", "create", "alpha"},
@@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
262263 }
263264 t.Fatal("never completed")
264265 }
266+
267+func TestIssueShowMergeRequestLinks(t *testing.T) {
268+ closed := &ermite.Issue{
269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
270+ ClosedByMergeRequest: 5,
271+ }
272+ f := &fakeAPI{
273+ getVersion: withIssues,
274+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
275+ return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{
276+ {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true},
277+ {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED},
278+ }}, nil
279+ },
280+ }
281+ url := f.serve(t)
282+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283+ assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
285+ assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286+
287+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289+
290+ // No references: no block in text, an empty array in JSON.
291+ f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
292+ return &ermite.GetIssueResponse{Issue: issueOne}, nil
293+ }
294+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295+ assert.NotContains(t, res.stdout, "merge requests")
296+ assert.NotContains(t, res.stdout, "closed by")
297+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298+ assert.Contains(t, res.stdout, `"references": []`)
299+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "testing"10 "testing"
10 11
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -51,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
51 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)52 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
52 53
53 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)54 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)55+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1,"closed_by_merge_request":0}`, res.stdout)
55 56
56 for _, args := range [][]string{57 for _, args := range [][]string{
57 {"issue", "create", "alpha"},58 {"issue", "create", "alpha"},
@@ -262,3 +263,37 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
262 }263 }
263 t.Fatal("never completed")264 t.Fatal("never completed")
264 }265 }
266+
267+func TestIssueShowMergeRequestLinks(t *testing.T) {
268+ closed := &ermite.Issue{
269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
270+ ClosedByMergeRequest: 5,
271+ }
272+ f := &fakeAPI{
273+ getVersion: withIssues,
274+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
275+ return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{
276+ {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true},
277+ {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED},
278+ }}, nil
279+ },
280+ }
281+ url := f.serve(t)
282+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283+ assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
285+ assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286+
287+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289+
290+ // No references: no block in text, an empty array in JSON.
291+ f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
292+ return &ermite.GetIssueResponse{Issue: issueOne}, nil
293+ }
294+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295+ assert.NotContains(t, res.stdout, "merge requests")
296+ assert.NotContains(t, res.stdout, "closed by")
297+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298+ assert.Contains(t, res.stdout, `"references": []`)
299+}
M internal/app/issue_api_test.go +52 -0
@@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
243243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244244 require.NoError(t, err)
245245 }
246+
247+func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
248+ r := newMRRig(t, domain.VisibilityPublic)
249+ ctx := r.ctx
250+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
251+ require.NoError(t, err)
252+ for _, title := range []string{"Crash", "Slow"} {
253+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
254+ require.NoError(t, err)
255+ }
256+ // The merge request mentions #1 (closing) and #2 (plain).
257+ created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
258+ Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
259+ }))
260+ require.NoError(t, err)
261+ number := created.Msg.GetMergeRequest().GetNumber()
262+
263+ get := func(n int64) *pb.GetIssueResponse {
264+ res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
265+ require.NoError(t, err)
266+ return res.Msg
267+ }
268+ one := get(1)
269+ require.Len(t, one.GetReferences(), 1)
270+ ref := one.GetReferences()[0]
271+ assert.Equal(t, number, ref.GetNumber())
272+ assert.Equal(t, "Change a", ref.GetTitle())
273+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
274+ assert.True(t, ref.GetCloses())
275+ two := get(2)
276+ require.Len(t, two.GetReferences(), 1)
277+ assert.False(t, two.GetReferences()[0].GetCloses())
278+ assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
279+
280+ _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
281+ require.NoError(t, err)
282+ r.forge.MergeRequests.Wait(ctx)
283+
284+ one = get(1)
285+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
286+ assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
287+ require.Len(t, one.GetReferences(), 1)
288+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
289+ two = get(2)
290+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
291+ assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
292+
293+ // Reopening by hand clears "closed by".
294+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
295+ require.NoError(t, err)
296+ assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
297+}
@@ -243,3 +243,55 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)243 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244 require.NoError(t, err)244 require.NoError(t, err)
245 }245 }
246+
247+func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
248+ r := newMRRig(t, domain.VisibilityPublic)
249+ ctx := r.ctx
250+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
251+ require.NoError(t, err)
252+ for _, title := range []string{"Crash", "Slow"} {
253+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
254+ require.NoError(t, err)
255+ }
256+ // The merge request mentions #1 (closing) and #2 (plain).
257+ created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
258+ Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
259+ }))
260+ require.NoError(t, err)
261+ number := created.Msg.GetMergeRequest().GetNumber()
262+
263+ get := func(n int64) *pb.GetIssueResponse {
264+ res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
265+ require.NoError(t, err)
266+ return res.Msg
267+ }
268+ one := get(1)
269+ require.Len(t, one.GetReferences(), 1)
270+ ref := one.GetReferences()[0]
271+ assert.Equal(t, number, ref.GetNumber())
272+ assert.Equal(t, "Change a", ref.GetTitle())
273+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
274+ assert.True(t, ref.GetCloses())
275+ two := get(2)
276+ require.Len(t, two.GetReferences(), 1)
277+ assert.False(t, two.GetReferences()[0].GetCloses())
278+ assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
279+
280+ _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
281+ require.NoError(t, err)
282+ r.forge.MergeRequests.Wait(ctx)
283+
284+ one = get(1)
285+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
286+ assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
287+ require.Len(t, one.GetReferences(), 1)
288+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
289+ two = get(2)
290+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
291+ assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
292+
293+ // Reopening by hand clears "closed by".
294+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
295+ require.NoError(t, err)
296+ assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
297+}

Keyboard shortcuts

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

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

Branches 2

Tags 41

↑↓ moveenter openesc close