fix(cli): print the full web URL of a new issue
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. | |||
| 154 | 154 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 155 | 155 | merge request web URL on its own line. With `--json`, merge request objects | |
| 156 | 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`. | |
| @@ -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 full | 154 | `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 objects | 155 | 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 | |||
| 12 | 12 | 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 | 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 | 20 | ## The `issues` setting | |
| @@ -12,6 +12,9 @@ ermite issue close myrepo 2 | |||||
| 12 | ermite issue reopen myrepo 2 | 12 | 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` setting | 20 | ## The `issues` setting | ||
M internal/app/cli_issue.go +20 -11
| @@ -49,6 +49,7 @@ func parseIssueState(s string) (ermite.IssueState, error) { | |||
| 49 | 49 | // jsonIssue is the stable JSON shape of an issue. | |
| 50 | 50 | type jsonIssue struct { | |
| 51 | 51 | Repo string `json:"repo"` | |
| 52 | + | URL string `json:"url"` | |
| 52 | 53 | Number int64 `json:"number"` | |
| 53 | 54 | Title string `json:"title"` | |
| 54 | 55 | Body string `json:"body"` | |
| @@ -61,8 +62,14 @@ type jsonIssue struct { | |||
| 61 | 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 | 71 | return jsonIssue{ | |
| 72 | + | URL: issueWebURL(baseURL, i.GetRepo(), i.GetNumber()), | |
| 66 | 73 | Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(), | |
| 67 | 74 | State: issueStateName(i.GetState()), Author: i.GetAuthor(), | |
| 68 | 75 | CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(), | |
| @@ -98,7 +105,7 @@ type jsonIssueReference struct { | |||
| 98 | 105 | } | |
| 99 | 106 | ||
| 100 | 107 | 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 | |
| 102 | 109 | (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of | |
| 103 | 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. | |
| 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 { | |
| 205 | 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 | 217 | return err | |
| 210 | 218 | } | |
| 211 | 219 | ||
| @@ -217,7 +225,8 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command { | |||
| 217 | 225 | Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" + | |
| 218 | 226 | "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" + | |
| 219 | 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 | 230 | Args: cobra.ExactArgs(1), | |
| 222 | 231 | ValidArgsFunction: completeRepoNames(env), | |
| 223 | 232 | } | |
| @@ -255,7 +264,7 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command { | |||
| 255 | 264 | if err != nil { | |
| 256 | 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 | 269 | return cmd | |
| 261 | 270 | } | |
| @@ -291,7 +300,7 @@ func newIssueListCmd(env *cliEnv) *cobra.Command { | |||
| 291 | 300 | if *asJSON { | |
| 292 | 301 | out := make([]jsonIssue, 0, len(issues)) | |
| 293 | 302 | for _, i := range issues { | |
| 294 | - | out = append(out, toJSONIssue(i)) | |
| 303 | + | out = append(out, toJSONIssue(i, c.URL())) | |
| 295 | 304 | } | |
| 296 | 305 | return writeJSON(cmd.OutOrStdout(), out) | |
| 297 | 306 | } | |
| @@ -338,7 +347,7 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||
| 338 | 347 | return c.Describe(err) | |
| 339 | 348 | } | |
| 340 | 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 | 351 | for _, cm := range res.Msg.GetComments() { | |
| 343 | 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 | 504 | if err != nil { | |
| 496 | 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 | 509 | return cmd | |
| 501 | 510 | } | |
| @@ -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"), author | 108 | + | 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 of | 109 | (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 err | 217 | 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 cmd | 269 | 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 cmd | 509 | return cmd | ||
| 501 | } | 510 | } | ||
M internal/app/cli_issue_e2e_test.go +5 -3
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | type issueOut struct { | |
| 17 | 17 | Repo string `json:"repo"` | |
| 18 | + | URL string `json:"url"` | |
| 18 | 19 | Number int64 `json:"number"` | |
| 19 | 20 | Title string `json:"title"` | |
| 20 | 21 | Body string `json:"body"` | |
| @@ -25,7 +26,7 @@ type issueOut struct { | |||
| 25 | 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 | 31 | type issueCommentOut struct { | |
| 31 | 32 | ID string `json:"id"` | |
| @@ -79,12 +80,13 @@ func TestIssuesCLIEndToEnd(t *testing.T) { | |||
| 79 | 80 | assert.Equal(t, issueKeys, keys(t, res.stdout)) | |
| 80 | 81 | issue := decode[issueOut](t, res.stdout) | |
| 81 | 82 | assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests") | |
| 83 | + | assert.Equal(t, c.url+"/proj/issues/2", issue.URL) | |
| 82 | 84 | assert.Equal(t, "open", issue.State) | |
| 83 | 85 | assert.Equal(t, "agent-7", issue.Author) | |
| 84 | 86 | assert.Equal(t, "It crashes.\nSecond line.", issue.Body) | |
| 85 | 87 | ||
| 86 | 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 | 91 | // list. | |
| 90 | 92 | res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t) | |
| @@ -131,7 +133,7 @@ func TestIssuesCLIEndToEnd(t *testing.T) { | |||
| 131 | 133 | res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t) | |
| 132 | 134 | assert.Len(t, decode[[]issueOut](t, res.stdout), 2) | |
| 133 | 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 | 138 | // An issue is not a merge request and the other way round. | |
| 137 | 139 | _, 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) { | |||
| 30 | 30 | }} | |
| 31 | 31 | url := f.serve(t) | |
| 32 | 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 | 34 | _, reqs := f.snapshot() | |
| 35 | 35 | got := reqs[1].(*ermite.CreateIssueRequest) | |
| 36 | 36 | assert.Equal(t, "alpha", got.GetRepo()) | |
| @@ -52,7 +52,7 @@ func TestIssueCreateMapsFlags(t *testing.T) { | |||
| 52 | 52 | assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies) | |
| 53 | 53 | ||
| 54 | 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 | 57 | for _, args := range [][]string{ | |
| 58 | 58 | {"issue", "create", "alpha"}, | |
| @@ -143,7 +143,7 @@ func TestIssueShowCommentCloseReopen(t *testing.T) { | |||
| 143 | 143 | require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err) | |
| 144 | 144 | ||
| 145 | 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 | 147 | res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t) | |
| 148 | 148 | assert.Contains(t, res.stdout, `"state": "open"`) | |
| 149 | 149 | ||
| @@ -285,7 +285,7 @@ func TestIssueShowMergeRequestLinks(t *testing.T) { | |||
| 285 | 285 | assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)")) | |
| 286 | 286 | ||
| 287 | 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 | 290 | // No references: no block in text, an empty array in JSON. | |
| 291 | 291 | f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |
| @@ -297,3 +297,8 @@ func TestIssueShowMergeRequestLinks(t *testing.T) { | |||
| 297 | 297 | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |
| 298 | 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 | + | } | |
| @@ -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 | |||
| 403 | 403 | // mrWebURL builds the web route with the repository as a single path segment. | |
| 404 | 404 | // Repository names currently forbid slashes, but escape all path delimiters defensively. | |
| 405 | 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 | 414 | // 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: | |||
| 92 | 92 | | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | | | |
| 93 | 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 | 99 | ## Releases | |
| 98 | 100 | ||
| @@ -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 | ## Releases | 99 | ## Releases | ||
| 98 | 100 | ||||