detached 78d5c6a

fix(cli): print the full web URL of a new issue

commit
78d5c6a3d2d19855eeb34d78e31ab8f4a59075d8
author
brice · · 2026-10-22 17:52 UTC
parent
1fb2421
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
4/4
refs
fix/issue-create-url
7 files · +49 -20
M docs/cli.md +3 -0
@@ -154,3 +154,6 @@ downloads are removed. Resume is within the command, not across invocations.
154154 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
155155 merge request web URL on its own line. With `--json`, merge request objects
156156 (create, show, list, close and merge) include `url` alongside the other fields.
157+
158+`ermite issue create` prints a confirmation followed by the full issue web URL on its own line
159+(`issue close` and `issue reopen` do the same). With `--json`, issue objects include `url`.
@@ -154,3 +154,6 @@ downloads are removed. Resume is within the command, not across invocations.
154 `ermite mr create` and `ermite mr close` print a confirmation followed by the full154 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
155 merge request web URL on its own line. With `--json`, merge request objects155 merge request web URL on its own line. With `--json`, merge request objects
156 (create, show, list, close and merge) include `url` alongside the other fields.156 (create, show, list, close and merge) include `url` alongside the other fields.
157+
158+`ermite issue create` prints a confirmation followed by the full issue web URL on its own line
159+(`issue close` and `issue reopen` do the same). With `--json`, issue objects include `url`.
M docs/issues.md +3 -0
@@ -12,6 +12,9 @@ ermite issue close myrepo 2
1212 ermite issue reopen myrepo 2
1313 ```
1414
15+`ermite issue create` prints a confirmation followed by the full issue web URL on its own line
16+(`issue close` and `issue reopen` do the same). With `--json`, issue objects include `url`.
17+
1518 All commands take `--json`. `--body -` and `--body-file -` read standard input. A server needs the `issues` feature (`ermite version`); without it the commands, `repo edit --issues` and `token create --scope issue:write` are refused.
1619
1720 ## The `issues` setting
@@ -12,6 +12,9 @@ ermite issue close myrepo 2
12 ermite issue reopen myrepo 212 ermite issue reopen myrepo 2
13 ```13 ```
14 14
15+`ermite issue create` prints a confirmation followed by the full issue web URL on its own line
16+(`issue close` and `issue reopen` do the same). With `--json`, issue objects include `url`.
17+
15 All commands take `--json`. `--body -` and `--body-file -` read standard input. A server needs the `issues` feature (`ermite version`); without it the commands, `repo edit --issues` and `token create --scope issue:write` are refused.18 All commands take `--json`. `--body -` and `--body-file -` read standard input. A server needs the `issues` feature (`ermite version`); without it the commands, `repo edit --issues` and `token create --scope issue:write` are refused.
16 19
17 ## The `issues` setting20 ## The `issues` setting
M internal/app/cli_issue.go +20 -11
@@ -49,6 +49,7 @@ func parseIssueState(s string) (ermite.IssueState, error) {
4949 // jsonIssue is the stable JSON shape of an issue.
5050 type jsonIssue struct {
5151 Repo string `json:"repo"`
52+ URL string `json:"url"`
5253 Number int64 `json:"number"`
5354 Title string `json:"title"`
5455 Body string `json:"body"`
@@ -61,8 +62,14 @@ type jsonIssue struct {
6162 ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
6263 }
6364
64-func toJSONIssue(i *ermite.Issue) jsonIssue {
65+// issueWebURL is the web page of an issue.
66+func issueWebURL(baseURL, repo string, number int64) string {
67+ return itemWebURL(baseURL, repo, "issues", number)
68+}
69+
70+func toJSONIssue(i *ermite.Issue, baseURL string) jsonIssue {
6571 return jsonIssue{
72+ URL: issueWebURL(baseURL, i.GetRepo(), i.GetNumber()),
6673 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
6774 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
6875 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
@@ -98,7 +105,7 @@ type jsonIssueReference struct {
98105 }
99106
100107 const jsonIssueHelp = `
101-JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
108+JSON fields of an issue (--json): repo, url (full web URL), number, title, body, state ("open" or "closed"), author
102109 (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103110 the merge request whose merge closed the issue, 0 for none).`
104111
@@ -200,12 +207,13 @@ func listIssues(ctx context.Context, c *client.Client, repo string, state ermite
200207 })
201208 }
202209
203-// outputIssue prints an issue as JSON, or as one confirmation line.
204-func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error {
210+// outputIssue prints an issue as JSON, or a confirmation followed by its URL.
211+func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb, baseURL string) error {
205212 if asJSON {
206- return writeJSON(cmd.OutOrStdout(), toJSONIssue(i))
213+ return writeJSON(cmd.OutOrStdout(), toJSONIssue(i, baseURL))
207214 }
208- _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()))
215+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n%s\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()),
216+ issueWebURL(baseURL, i.GetRepo(), i.GetNumber()))
209217 return err
210218 }
211219
@@ -217,7 +225,8 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command {
217225 Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
218226 "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
219227 "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
220- "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,
228+ "from a file otherwise. Prints a confirmation, then the full web URL on its own line.\n" +
229+ "Needs the issue:write scope." + jsonIssueHelp,
221230 Args: cobra.ExactArgs(1),
222231 ValidArgsFunction: completeRepoNames(env),
223232 }
@@ -255,7 +264,7 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command {
255264 if err != nil {
256265 return c.Describe(err)
257266 }
258- return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened")
267+ return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened", c.URL())
259268 }
260269 return cmd
261270 }
@@ -291,7 +300,7 @@ func newIssueListCmd(env *cliEnv) *cobra.Command {
291300 if *asJSON {
292301 out := make([]jsonIssue, 0, len(issues))
293302 for _, i := range issues {
294- out = append(out, toJSONIssue(i))
303+ out = append(out, toJSONIssue(i, c.URL()))
295304 }
296305 return writeJSON(cmd.OutOrStdout(), out)
297306 }
@@ -338,7 +347,7 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
338347 return c.Describe(err)
339348 }
340349 if *asJSON {
341- out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}
350+ out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue(), c.URL()), Comments: []jsonIssueComment{}}
342351 for _, cm := range res.Msg.GetComments() {
343352 out.Comments = append(out.Comments, toJSONIssueComment(cm))
344353 }
@@ -495,7 +504,7 @@ func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(contex
495504 if err != nil {
496505 return c.Describe(err)
497506 }
498- return outputIssue(cmd, i, *asJSON, verb)
507+ return outputIssue(cmd, i, *asJSON, verb, c.URL())
499508 }
500509 return cmd
501510 }
@@ -49,6 +49,7 @@ func parseIssueState(s string) (ermite.IssueState, error) {
49 // jsonIssue is the stable JSON shape of an issue.49 // jsonIssue is the stable JSON shape of an issue.
50 type jsonIssue struct {50 type jsonIssue struct {
51 Repo string `json:"repo"`51 Repo string `json:"repo"`
52+ URL string `json:"url"`
52 Number int64 `json:"number"`53 Number int64 `json:"number"`
53 Title string `json:"title"`54 Title string `json:"title"`
54 Body string `json:"body"`55 Body string `json:"body"`
@@ -61,8 +62,14 @@ type jsonIssue struct {
61 ClosedByMergeRequest int64 `json:"closed_by_merge_request"`62 ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
62 }63 }
63 64
64-func toJSONIssue(i *ermite.Issue) jsonIssue {65+// issueWebURL is the web page of an issue.
66+func issueWebURL(baseURL, repo string, number int64) string {
67+ return itemWebURL(baseURL, repo, "issues", number)
68+}
69+
70+func toJSONIssue(i *ermite.Issue, baseURL string) jsonIssue {
65 return jsonIssue{71 return jsonIssue{
72+ URL: issueWebURL(baseURL, i.GetRepo(), i.GetNumber()),
66 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),73 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
67 State: issueStateName(i.GetState()), Author: i.GetAuthor(),74 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
68 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),75 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
@@ -98,7 +105,7 @@ type jsonIssueReference struct {
98 }105 }
99 106
100 const jsonIssueHelp = `107 const jsonIssueHelp = `
101-JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author108+JSON fields of an issue (--json): repo, url (full web URL), number, title, body, state ("open" or "closed"), author
102 (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of109 (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).`110 the merge request whose merge closed the issue, 0 for none).`
104 111
@@ -200,12 +207,13 @@ func listIssues(ctx context.Context, c *client.Client, repo string, state ermite
200 })207 })
201 }208 }
202 209
203-// outputIssue prints an issue as JSON, or as one confirmation line.210+// outputIssue prints an issue as JSON, or a confirmation followed by its URL.
204-func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error {211+func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb, baseURL string) error {
205 if asJSON {212 if asJSON {
206- return writeJSON(cmd.OutOrStdout(), toJSONIssue(i))213+ return writeJSON(cmd.OutOrStdout(), toJSONIssue(i, baseURL))
207 }214 }
208- _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()))215+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n%s\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()),
216+ issueWebURL(baseURL, i.GetRepo(), i.GetNumber()))
209 return err217 return err
210 }218 }
211 219
@@ -217,7 +225,8 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command {
217 Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +225 Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
218 "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +226 "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
219 "--body - and --body-file - read the body from standard input; --body-file reads it\n" +227 "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
220- "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,228+ "from a file otherwise. Prints a confirmation, then the full web URL on its own line.\n" +
229+ "Needs the issue:write scope." + jsonIssueHelp,
221 Args: cobra.ExactArgs(1),230 Args: cobra.ExactArgs(1),
222 ValidArgsFunction: completeRepoNames(env),231 ValidArgsFunction: completeRepoNames(env),
223 }232 }
@@ -255,7 +264,7 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command {
255 if err != nil {264 if err != nil {
256 return c.Describe(err)265 return c.Describe(err)
257 }266 }
258- return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened")267+ return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened", c.URL())
259 }268 }
260 return cmd269 return cmd
261 }270 }
@@ -291,7 +300,7 @@ func newIssueListCmd(env *cliEnv) *cobra.Command {
291 if *asJSON {300 if *asJSON {
292 out := make([]jsonIssue, 0, len(issues))301 out := make([]jsonIssue, 0, len(issues))
293 for _, i := range issues {302 for _, i := range issues {
294- out = append(out, toJSONIssue(i))303+ out = append(out, toJSONIssue(i, c.URL()))
295 }304 }
296 return writeJSON(cmd.OutOrStdout(), out)305 return writeJSON(cmd.OutOrStdout(), out)
297 }306 }
@@ -338,7 +347,7 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
338 return c.Describe(err)347 return c.Describe(err)
339 }348 }
340 if *asJSON {349 if *asJSON {
341- out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}350+ out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue(), c.URL()), Comments: []jsonIssueComment{}}
342 for _, cm := range res.Msg.GetComments() {351 for _, cm := range res.Msg.GetComments() {
343 out.Comments = append(out.Comments, toJSONIssueComment(cm))352 out.Comments = append(out.Comments, toJSONIssueComment(cm))
344 }353 }
@@ -495,7 +504,7 @@ func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(contex
495 if err != nil {504 if err != nil {
496 return c.Describe(err)505 return c.Describe(err)
497 }506 }
498- return outputIssue(cmd, i, *asJSON, verb)507+ return outputIssue(cmd, i, *asJSON, verb, c.URL())
499 }508 }
500 return cmd509 return cmd
501 }510 }
M internal/app/cli_issue_e2e_test.go +5 -3
@@ -15,6 +15,7 @@ import (
1515
1616 type issueOut struct {
1717 Repo string `json:"repo"`
18+ URL string `json:"url"`
1819 Number int64 `json:"number"`
1920 Title string `json:"title"`
2021 Body string `json:"body"`
@@ -25,7 +26,7 @@ type issueOut struct {
2526 CommentCount int `json:"comment_count"`
2627 }
2728
28-var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
29+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at", "url"}
2930
3031 type issueCommentOut struct {
3132 ID string `json:"id"`
@@ -79,12 +80,13 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
7980 assert.Equal(t, issueKeys, keys(t, res.stdout))
8081 issue := decode[issueOut](t, res.stdout)
8182 assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests")
83+ assert.Equal(t, c.url+"/proj/issues/2", issue.URL)
8284 assert.Equal(t, "open", issue.State)
8385 assert.Equal(t, "agent-7", issue.Author)
8486 assert.Equal(t, "It crashes.\nSecond line.", issue.Body)
8587
8688 res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t)
87- assert.Equal(t, "opened proj#3 (Second)\n", res.stdout)
89+ assert.Equal(t, "opened proj#3 (Second)\n"+c.url+"/proj/issues/3\n", res.stdout)
8890
8991 // list.
9092 res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
@@ -131,7 +133,7 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
131133 res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t)
132134 assert.Len(t, decode[[]issueOut](t, res.stdout), 2)
133135 res = c.cli(t, agent, "issue", "reopen", "proj", "2").ok(t)
134- assert.Equal(t, "reopened proj#2 (Crash on start)\n", res.stdout)
136+ assert.Equal(t, "reopened proj#2 (Crash on start)\n"+c.url+"/proj/issues/2\n", res.stdout)
135137
136138 // An issue is not a merge request and the other way round.
137139 _, stderr, code := c.bin(t, adminToken, "issue", "show", "proj", "1")
@@ -15,6 +15,7 @@ import (
15 15
16 type issueOut struct {16 type issueOut struct {
17 Repo string `json:"repo"`17 Repo string `json:"repo"`
18+ URL string `json:"url"`
18 Number int64 `json:"number"`19 Number int64 `json:"number"`
19 Title string `json:"title"`20 Title string `json:"title"`
20 Body string `json:"body"`21 Body string `json:"body"`
@@ -25,7 +26,7 @@ type issueOut struct {
25 CommentCount int `json:"comment_count"`26 CommentCount int `json:"comment_count"`
26 }27 }
27 28
28-var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}29+var issueKeys = []string{"author", "body", "closed_by_merge_request", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at", "url"}
29 30
30 type issueCommentOut struct {31 type issueCommentOut struct {
31 ID string `json:"id"`32 ID string `json:"id"`
@@ -79,12 +80,13 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
79 assert.Equal(t, issueKeys, keys(t, res.stdout))80 assert.Equal(t, issueKeys, keys(t, res.stdout))
80 issue := decode[issueOut](t, res.stdout)81 issue := decode[issueOut](t, res.stdout)
81 assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests")82 assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests")
83+ assert.Equal(t, c.url+"/proj/issues/2", issue.URL)
82 assert.Equal(t, "open", issue.State)84 assert.Equal(t, "open", issue.State)
83 assert.Equal(t, "agent-7", issue.Author)85 assert.Equal(t, "agent-7", issue.Author)
84 assert.Equal(t, "It crashes.\nSecond line.", issue.Body)86 assert.Equal(t, "It crashes.\nSecond line.", issue.Body)
85 87
86 res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t)88 res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t)
87- assert.Equal(t, "opened proj#3 (Second)\n", res.stdout)89+ assert.Equal(t, "opened proj#3 (Second)\n"+c.url+"/proj/issues/3\n", res.stdout)
88 90
89 // list.91 // list.
90 res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)92 res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
@@ -131,7 +133,7 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
131 res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t)133 res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t)
132 assert.Len(t, decode[[]issueOut](t, res.stdout), 2)134 assert.Len(t, decode[[]issueOut](t, res.stdout), 2)
133 res = c.cli(t, agent, "issue", "reopen", "proj", "2").ok(t)135 res = c.cli(t, agent, "issue", "reopen", "proj", "2").ok(t)
134- assert.Equal(t, "reopened proj#2 (Crash on start)\n", res.stdout)136+ assert.Equal(t, "reopened proj#2 (Crash on start)\n"+c.url+"/proj/issues/2\n", res.stdout)
135 137
136 // An issue is not a merge request and the other way round.138 // An issue is not a merge request and the other way round.
137 _, stderr, code := c.bin(t, adminToken, "issue", "show", "proj", "1")139 _, stderr, code := c.bin(t, adminToken, "issue", "show", "proj", "1")
M internal/app/cli_issue_test.go +9 -4
@@ -30,7 +30,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
3030 }}
3131 url := f.serve(t)
3232 res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)
33- assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout)
33+ assert.Equal(t, "opened alpha#2 (Crash on start)\n"+url+"/alpha/issues/2\n", res.stdout)
3434 _, reqs := f.snapshot()
3535 got := reqs[1].(*ermite.CreateIssueRequest)
3636 assert.Equal(t, "alpha", got.GetRepo())
@@ -52,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
5252 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
5353
5454 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
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+ assert.JSONEq(t, `{"url":"`+url+`/alpha/issues/2","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)
5656
5757 for _, args := range [][]string{
5858 {"issue", "create", "alpha"},
@@ -143,7 +143,7 @@ func TestIssueShowCommentCloseReopen(t *testing.T) {
143143 require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)
144144
145145 res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)
146- assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout)
146+ assert.Equal(t, "closed alpha#2 (Crash on start)\n"+url+"/alpha/issues/2\n", res.stdout)
147147 res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)
148148 assert.Contains(t, res.stdout, `"state": "open"`)
149149
@@ -285,7 +285,7 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
285285 assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286286
287287 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)
288+ assert.JSONEq(t, `{"url":"`+url+`/alpha/issues/2","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)
289289
290290 // No references: no block in text, an empty array in JSON.
291291 f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
@@ -297,3 +297,8 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
297297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298298 assert.Contains(t, res.stdout, `"references": []`)
299299 }
300+
301+func TestIssueWebURL(t *testing.T) {
302+ assert.Equal(t, "https://git.example.org/my-repo/issues/42", issueWebURL("https://git.example.org/", "my-repo", 42))
303+ assert.Equal(t, "https://git.example.org/team%2Frepo%20%23%3F%25/issues/42", issueWebURL("https://git.example.org", "team/repo #?%", 42))
304+}
@@ -30,7 +30,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
30 }}30 }}
31 url := f.serve(t)31 url := f.serve(t)
32 res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)32 res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)
33- assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout)33+ assert.Equal(t, "opened alpha#2 (Crash on start)\n"+url+"/alpha/issues/2\n", res.stdout)
34 _, reqs := f.snapshot()34 _, reqs := f.snapshot()
35 got := reqs[1].(*ermite.CreateIssueRequest)35 got := reqs[1].(*ermite.CreateIssueRequest)
36 assert.Equal(t, "alpha", got.GetRepo())36 assert.Equal(t, "alpha", got.GetRepo())
@@ -52,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) {
52 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)
53 53
54 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)
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+ assert.JSONEq(t, `{"url":"`+url+`/alpha/issues/2","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)
56 56
57 for _, args := range [][]string{57 for _, args := range [][]string{
58 {"issue", "create", "alpha"},58 {"issue", "create", "alpha"},
@@ -143,7 +143,7 @@ func TestIssueShowCommentCloseReopen(t *testing.T) {
143 require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)143 require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)
144 144
145 res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)145 res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)
146- assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout)146+ assert.Equal(t, "closed alpha#2 (Crash on start)\n"+url+"/alpha/issues/2\n", res.stdout)
147 res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)147 res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)
148 assert.Contains(t, res.stdout, `"state": "open"`)148 assert.Contains(t, res.stdout, `"state": "open"`)
149 149
@@ -285,7 +285,7 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
285 assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))285 assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286 286
287 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)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)288+ assert.JSONEq(t, `{"url":"`+url+`/alpha/issues/2","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 289
290 // No references: no block in text, an empty array in JSON.290 // No references: no block in text, an empty array in JSON.
291 f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {291 f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
@@ -297,3 +297,8 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298 assert.Contains(t, res.stdout, `"references": []`)298 assert.Contains(t, res.stdout, `"references": []`)
299 }299 }
300+
301+func TestIssueWebURL(t *testing.T) {
302+ assert.Equal(t, "https://git.example.org/my-repo/issues/42", issueWebURL("https://git.example.org/", "my-repo", 42))
303+ assert.Equal(t, "https://git.example.org/team%2Frepo%20%23%3F%25/issues/42", issueWebURL("https://git.example.org", "team/repo #?%", 42))
304+}
M internal/app/cli_mr.go +6 -1
@@ -403,7 +403,12 @@ func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s
403403 // mrWebURL builds the web route with the repository as a single path segment.
404404 // Repository names currently forbid slashes, but escape all path delimiters defensively.
405405 func mrWebURL(baseURL, repo string, number int64) string {
406- return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10)
406+ return itemWebURL(baseURL, repo, "merge-requests", number)
407+}
408+
409+// itemWebURL builds <base>/<repo>/<kind>/<number>.
410+func itemWebURL(baseURL, repo, kind string, number int64) string {
411+ return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/" + kind + "/" + strconv.FormatInt(number, 10)
407412 }
408413
409414 // outputMR prints a merge request as JSON, or a confirmation followed by its URL.
@@ -403,7 +403,12 @@ func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s
403 // mrWebURL builds the web route with the repository as a single path segment.403 // mrWebURL builds the web route with the repository as a single path segment.
404 // Repository names currently forbid slashes, but escape all path delimiters defensively.404 // Repository names currently forbid slashes, but escape all path delimiters defensively.
405 func mrWebURL(baseURL, repo string, number int64) string {405 func mrWebURL(baseURL, repo string, number int64) string {
406- return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10)406+ return itemWebURL(baseURL, repo, "merge-requests", number)
407+}
408+
409+// itemWebURL builds <base>/<repo>/<kind>/<number>.
410+func itemWebURL(baseURL, repo, kind string, number int64) string {
411+ return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/" + kind + "/" + strconv.FormatInt(number, 10)
407 }412 }
408 413
409 // outputMR prints a merge request as JSON, or a confirmation followed by its URL.414 // outputMR prints a merge request as JSON, or a confirmation followed by its URL.
M skills/ermite/CLI.md +3 -1
@@ -92,7 +92,9 @@ Issues share the numbering of merge requests and must be enabled per repository:
9292 | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |
9393 | `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |
9494
95-Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.
95+`issue create`, `issue close` and `issue reopen` print a confirmation followed by the full issue web URL on its own line.
96+
97+Issue JSON: `repo`, `url` (full web URL), `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.
9698
9799 ## Releases
98100
@@ -92,7 +92,9 @@ Issues share the numbering of merge requests and must be enabled per repository:
92 | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |92 | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |
93 | `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |93 | `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |
94 94
95-Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.95+`issue create`, `issue close` and `issue reopen` print a confirmation followed by the full issue web URL on its own line.
96+
97+Issue JSON: `repo`, `url` (full web URL), `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.
96 98
97 ## Releases99 ## Releases
98 100

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 4

Tags 45

↑↓ moveenter openesc close