feat(cli): print the full web URL of merge requests
mr create and mr close print the MR web URL on its own line after the confirmation. Every merge request JSON output gains a url field.
9 files · +74 -26
M README.md +4 -0
| @@ -82,6 +82,10 @@ ermite web login # one-time link to sign in to the web interface | |||
| 82 | 82 | ||
| 83 | 83 | Pushing to a new name also creates a private repository. | |
| 84 | 84 | ||
| 85 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 86 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 87 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 88 | + | ||
| 85 | 89 | ## Agent workflow | |
| 86 | 90 | ||
| 87 | 91 | You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets. | |
| @@ -82,6 +82,10 @@ ermite web login # one-time link to sign in to the web interface | |||||
| 82 | 82 | ||||
| 83 | Pushing to a new name also creates a private repository. | 83 | Pushing to a new name also creates a private repository. | ||
| 84 | 84 | ||||
| 85 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |||
| 86 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 87 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |||
| 88 | + | ||||
| 85 | ## Agent workflow | 89 | ## Agent workflow | ||
| 86 | 90 | ||||
| 87 | You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets. | 91 | You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets. | ||
M docs/cli.md +4 -0
| @@ -87,3 +87,7 @@ ermite completion bash|zsh|fish | |||
| 87 | 87 | - `watch --json` prints one object per line. | |
| 88 | 88 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| 89 | 89 | - Shell completion completes repository names from the server. | |
| 90 | + | ||
| 91 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 92 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 93 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| @@ -87,3 +87,7 @@ ermite completion bash|zsh|fish | |||||
| 87 | - `watch --json` prints one object per line. | 87 | - `watch --json` prints one object per line. | ||
| 88 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | 88 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | ||
| 89 | - Shell completion completes repository names from the server. | 89 | - Shell completion completes repository names from the server. | ||
| 90 | + | ||||
| 91 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |||
| 92 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 93 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |||
M docs/merge-requests.md +4 -0
| @@ -8,6 +8,10 @@ ermite mr show myrepo 1 --diff | |||
| 8 | 8 | ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch] | |
| 9 | 9 | ``` | |
| 10 | 10 | ||
| 11 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 12 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 13 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 14 | + | ||
| 11 | 15 | - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key. | |
| 12 | 16 | - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it. | |
| 13 | 17 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | |
| @@ -8,6 +8,10 @@ ermite mr show myrepo 1 --diff | |||||
| 8 | ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch] | 8 | ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch] | ||
| 9 | ``` | 9 | ``` | ||
| 10 | 10 | ||||
| 11 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |||
| 12 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 13 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |||
| 14 | + | ||||
| 11 | - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key. | 15 | - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key. | ||
| 12 | - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it. | 16 | - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it. | ||
| 13 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | 17 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | ||
M internal/app/cli_mr.go +26 -16
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | + | "net/url" | |
| 10 | 11 | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | ||
| @@ -101,6 +102,7 @@ func parseSide(s string) (ermite.DiffSide, error) { | |||
| 101 | 102 | // jsonMergeRequest is the stable JSON shape of a merge request. | |
| 102 | 103 | type jsonMergeRequest struct { | |
| 103 | 104 | Repo string `json:"repo"` | |
| 105 | + | URL string `json:"url"` | |
| 104 | 106 | Number int64 `json:"number"` | |
| 105 | 107 | Title string `json:"title"` | |
| 106 | 108 | Description string `json:"description"` | |
| @@ -121,8 +123,9 @@ type jsonMerged struct { | |||
| 121 | 123 | SourceBranchDeleted bool `json:"source_branch_deleted"` | |
| 122 | 124 | } | |
| 123 | 125 | ||
| 124 | - | func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest { | |
| 126 | + | func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest { | |
| 125 | 127 | return jsonMergeRequest{ | |
| 128 | + | URL: mrWebURL(baseURL, m.GetRepo(), m.GetNumber()), | |
| 126 | 129 | Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(), | |
| 127 | 130 | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | |
| 128 | 131 | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | |
| @@ -151,7 +154,7 @@ func toJSONComment(c *ermite.Comment) jsonComment { | |||
| 151 | 154 | } | |
| 152 | 155 | ||
| 153 | 156 | const jsonMRHelp = ` | |
| 154 | - | JSON fields of a merge request (--json): repo, number, title, description, source_branch, | |
| 157 | + | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | |
| 155 | 158 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |
| 156 | 159 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |
| 157 | 160 | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | |
| @@ -245,7 +248,8 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||
| 245 | 248 | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | |
| 246 | 249 | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | |
| 247 | 250 | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | |
| 248 | - | "already has the same source and target. Needs the mr:write scope." + jsonMRHelp, | |
| 251 | + | "already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" + | |
| 252 | + | "Needs the mr:write scope." + jsonMRHelp, | |
| 249 | 253 | Args: cobra.ExactArgs(1), | |
| 250 | 254 | ValidArgsFunction: completeRepoNames(env), | |
| 251 | 255 | } | |
| @@ -273,20 +277,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||
| 273 | 277 | if err != nil { | |
| 274 | 278 | return c.Describe(err) | |
| 275 | 279 | } | |
| 276 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened") | |
| 280 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL()) | |
| 277 | 281 | } | |
| 278 | 282 | return cmd | |
| 279 | 283 | } | |
| 280 | 284 | ||
| 281 | 285 | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | |
| 282 | 286 | ||
| 283 | - | // outputMR prints a merge request as JSON, or as one confirmation line. | |
| 284 | - | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error { | |
| 287 | + | // mrWebURL builds the web route with the repository as a single path segment. | |
| 288 | + | // Repository names currently forbid slashes, but escape all path delimiters defensively. | |
| 289 | + | func mrWebURL(baseURL, repo string, number int64) string { | |
| 290 | + | return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10) | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // outputMR prints a merge request as JSON, or a confirmation followed by its URL. | |
| 294 | + | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error { | |
| 285 | 295 | if asJSON { | |
| 286 | - | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | |
| 296 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL)) | |
| 287 | 297 | } | |
| 288 | - | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(), | |
| 289 | - | clean(m.GetSourceBranch()), clean(m.GetTargetBranch())) | |
| 298 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(), | |
| 299 | + | clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber())) | |
| 290 | 300 | return err | |
| 291 | 301 | } | |
| 292 | 302 | ||
| @@ -321,7 +331,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command { | |||
| 321 | 331 | if *asJSON { | |
| 322 | 332 | out := make([]jsonMergeRequest, 0, len(mrs)) | |
| 323 | 333 | for _, m := range mrs { | |
| 324 | - | out = append(out, toJSONMergeRequest(m)) | |
| 334 | + | out = append(out, toJSONMergeRequest(m, c.URL())) | |
| 325 | 335 | } | |
| 326 | 336 | return writeJSON(cmd.OutOrStdout(), out) | |
| 327 | 337 | } | |
| @@ -404,9 +414,9 @@ func lineKindName(k ermite.LineKind) string { | |||
| 404 | 414 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | |
| 405 | 415 | } | |
| 406 | 416 | ||
| 407 | - | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail { | |
| 417 | + | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | |
| 408 | 418 | out := jsonMRDetail{ | |
| 409 | - | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()), | |
| 419 | + | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | |
| 410 | 420 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | |
| 411 | 421 | DiffBaseID: d.GetDiffBaseId(), | |
| 412 | 422 | Stats: jsonStats{ | |
| @@ -487,7 +497,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 487 | 497 | } | |
| 488 | 498 | d := res.Msg | |
| 489 | 499 | if *asJSON { | |
| 490 | - | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff)) | |
| 500 | + | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL())) | |
| 491 | 501 | } else { | |
| 492 | 502 | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | |
| 493 | 503 | } | |
| @@ -803,7 +813,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 803 | 813 | clean(m.GetSourceBranch())) | |
| 804 | 814 | } | |
| 805 | 815 | if *asJSON { | |
| 806 | - | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted}) | |
| 816 | + | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | |
| 807 | 817 | } | |
| 808 | 818 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 809 | 819 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | |
| @@ -820,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 820 | 830 | Use: "close <repo> <number>", | |
| 821 | 831 | Short: "Close a merge request without merging it", | |
| 822 | 832 | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | |
| 823 | - | "new one. Needs the mr:write scope." + jsonMRHelp, | |
| 833 | + | "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp, | |
| 824 | 834 | Args: cobra.ExactArgs(2), | |
| 825 | 835 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 826 | 836 | } | |
| @@ -838,7 +848,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 838 | 848 | if err != nil { | |
| 839 | 849 | return c.Describe(err) | |
| 840 | 850 | } | |
| 841 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed") | |
| 851 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL()) | |
| 842 | 852 | } | |
| 843 | 853 | return cmd | |
| 844 | 854 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | + | "net/url" | |||
| 10 | "strconv" | 11 | "strconv" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | 13 | ||||
| @@ -101,6 +102,7 @@ func parseSide(s string) (ermite.DiffSide, error) { | |||||
| 101 | // jsonMergeRequest is the stable JSON shape of a merge request. | 102 | // jsonMergeRequest is the stable JSON shape of a merge request. | ||
| 102 | type jsonMergeRequest struct { | 103 | type jsonMergeRequest struct { | ||
| 103 | Repo string `json:"repo"` | 104 | Repo string `json:"repo"` | ||
| 105 | + | URL string `json:"url"` | |||
| 104 | Number int64 `json:"number"` | 106 | Number int64 `json:"number"` | ||
| 105 | Title string `json:"title"` | 107 | Title string `json:"title"` | ||
| 106 | Description string `json:"description"` | 108 | Description string `json:"description"` | ||
| @@ -121,8 +123,9 @@ type jsonMerged struct { | |||||
| 121 | SourceBranchDeleted bool `json:"source_branch_deleted"` | 123 | SourceBranchDeleted bool `json:"source_branch_deleted"` | ||
| 122 | } | 124 | } | ||
| 123 | 125 | ||||
| 124 | - | func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest { | 126 | + | func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest { |
| 125 | return jsonMergeRequest{ | 127 | return jsonMergeRequest{ | ||
| 128 | + | URL: mrWebURL(baseURL, m.GetRepo(), m.GetNumber()), | |||
| 126 | Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(), | 129 | Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(), | ||
| 127 | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | 130 | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | ||
| 128 | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | 131 | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | ||
| @@ -151,7 +154,7 @@ func toJSONComment(c *ermite.Comment) jsonComment { | |||||
| 151 | } | 154 | } | ||
| 152 | 155 | ||||
| 153 | const jsonMRHelp = ` | 156 | const jsonMRHelp = ` | ||
| 154 | - | JSON fields of a merge request (--json): repo, number, title, description, source_branch, | 157 | + | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, |
| 155 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | 158 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | ||
| 156 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | 159 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | ||
| 157 | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | 160 | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | ||
| @@ -245,7 +248,8 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 245 | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | 248 | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | ||
| 246 | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | 249 | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | ||
| 247 | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | 250 | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | ||
| 248 | - | "already has the same source and target. Needs the mr:write scope." + jsonMRHelp, | 251 | + | "already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" + |
| 252 | + | "Needs the mr:write scope." + jsonMRHelp, | |||
| 249 | Args: cobra.ExactArgs(1), | 253 | Args: cobra.ExactArgs(1), | ||
| 250 | ValidArgsFunction: completeRepoNames(env), | 254 | ValidArgsFunction: completeRepoNames(env), | ||
| 251 | } | 255 | } | ||
| @@ -273,20 +277,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 273 | if err != nil { | 277 | if err != nil { | ||
| 274 | return c.Describe(err) | 278 | return c.Describe(err) | ||
| 275 | } | 279 | } | ||
| 276 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened") | 280 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL()) |
| 277 | } | 281 | } | ||
| 278 | return cmd | 282 | return cmd | ||
| 279 | } | 283 | } | ||
| 280 | 284 | ||||
| 281 | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | 285 | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | ||
| 282 | 286 | ||||
| 283 | - | // outputMR prints a merge request as JSON, or as one confirmation line. | 287 | + | // mrWebURL builds the web route with the repository as a single path segment. |
| 284 | - | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error { | 288 | + | // Repository names currently forbid slashes, but escape all path delimiters defensively. |
| 289 | + | func mrWebURL(baseURL, repo string, number int64) string { | |||
| 290 | + | return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // outputMR prints a merge request as JSON, or a confirmation followed by its URL. | |||
| 294 | + | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error { | |||
| 285 | if asJSON { | 295 | if asJSON { | ||
| 286 | - | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | 296 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL)) |
| 287 | } | 297 | } | ||
| 288 | - | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(), | 298 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(), |
| 289 | - | clean(m.GetSourceBranch()), clean(m.GetTargetBranch())) | 299 | + | clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber())) |
| 290 | return err | 300 | return err | ||
| 291 | } | 301 | } | ||
| 292 | 302 | ||||
| @@ -321,7 +331,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command { | |||||
| 321 | if *asJSON { | 331 | if *asJSON { | ||
| 322 | out := make([]jsonMergeRequest, 0, len(mrs)) | 332 | out := make([]jsonMergeRequest, 0, len(mrs)) | ||
| 323 | for _, m := range mrs { | 333 | for _, m := range mrs { | ||
| 324 | - | out = append(out, toJSONMergeRequest(m)) | 334 | + | out = append(out, toJSONMergeRequest(m, c.URL())) |
| 325 | } | 335 | } | ||
| 326 | return writeJSON(cmd.OutOrStdout(), out) | 336 | return writeJSON(cmd.OutOrStdout(), out) | ||
| 327 | } | 337 | } | ||
| @@ -404,9 +414,9 @@ func lineKindName(k ermite.LineKind) string { | |||||
| 404 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | 414 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | ||
| 405 | } | 415 | } | ||
| 406 | 416 | ||||
| 407 | - | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail { | 417 | + | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { |
| 408 | out := jsonMRDetail{ | 418 | out := jsonMRDetail{ | ||
| 409 | - | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()), | 419 | + | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), |
| 410 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | 420 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | ||
| 411 | DiffBaseID: d.GetDiffBaseId(), | 421 | DiffBaseID: d.GetDiffBaseId(), | ||
| 412 | Stats: jsonStats{ | 422 | Stats: jsonStats{ | ||
| @@ -487,7 +497,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||||
| 487 | } | 497 | } | ||
| 488 | d := res.Msg | 498 | d := res.Msg | ||
| 489 | if *asJSON { | 499 | if *asJSON { | ||
| 490 | - | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff)) | 500 | + | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL())) |
| 491 | } else { | 501 | } else { | ||
| 492 | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | 502 | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | ||
| 493 | } | 503 | } | ||
| @@ -803,7 +813,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 803 | clean(m.GetSourceBranch())) | 813 | clean(m.GetSourceBranch())) | ||
| 804 | } | 814 | } | ||
| 805 | if *asJSON { | 815 | if *asJSON { | ||
| 806 | - | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted}) | 816 | + | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) |
| 807 | } | 817 | } | ||
| 808 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | 818 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | ||
| 809 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | 819 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | ||
| @@ -820,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||||
| 820 | Use: "close <repo> <number>", | 830 | Use: "close <repo> <number>", | ||
| 821 | Short: "Close a merge request without merging it", | 831 | Short: "Close a merge request without merging it", | ||
| 822 | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | 832 | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | ||
| 823 | - | "new one. Needs the mr:write scope." + jsonMRHelp, | 833 | + | "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp, |
| 824 | Args: cobra.ExactArgs(2), | 834 | Args: cobra.ExactArgs(2), | ||
| 825 | ValidArgsFunction: completeMRRepoThenNumber(env), | 835 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
| 826 | } | 836 | } | ||
| @@ -838,7 +848,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||||
| 838 | if err != nil { | 848 | if err != nil { | ||
| 839 | return c.Describe(err) | 849 | return c.Describe(err) | ||
| 840 | } | 850 | } | ||
| 841 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed") | 851 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL()) |
| 842 | } | 852 | } | ||
| 843 | return cmd | 853 | return cmd | ||
| 844 | } | 854 | } | ||
M internal/app/cli_mr_e2e_test.go +2 -1
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | ||
| 19 | 19 | type mrOut struct { | |
| 20 | 20 | Repo string `json:"repo"` | |
| 21 | + | URL string `json:"url"` | |
| 21 | 22 | Number int64 `json:"number"` | |
| 22 | 23 | Title string `json:"title"` | |
| 23 | 24 | Description string `json:"description"` | |
| @@ -32,7 +33,7 @@ type mrOut struct { | |||
| 32 | 33 | MergeMode string `json:"merge_mode"` | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | - | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"} | |
| 36 | + | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"} | |
| 36 | 37 | ||
| 37 | 38 | type commentOut struct { | |
| 38 | 39 | ID string `json:"id"` | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | 18 | ||||
| 19 | type mrOut struct { | 19 | type mrOut struct { | ||
| 20 | Repo string `json:"repo"` | 20 | Repo string `json:"repo"` | ||
| 21 | + | URL string `json:"url"` | |||
| 21 | Number int64 `json:"number"` | 22 | Number int64 `json:"number"` | ||
| 22 | Title string `json:"title"` | 23 | Title string `json:"title"` | ||
| 23 | Description string `json:"description"` | 24 | Description string `json:"description"` | ||
| @@ -32,7 +33,7 @@ type mrOut struct { | |||||
| 32 | MergeMode string `json:"merge_mode"` | 33 | MergeMode string `json:"merge_mode"` | ||
| 33 | } | 34 | } | ||
| 34 | 35 | ||||
| 35 | - | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"} | 36 | + | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"} |
| 36 | 37 | ||||
| 37 | type commentOut struct { | 38 | type commentOut struct { | ||
| 38 | ID string `json:"id"` | 39 | ID string `json:"id"` | ||
M internal/app/cli_mr_test.go +20 -3
| @@ -30,7 +30,7 @@ func TestMRCreateMapsFlags(t *testing.T) { | |||
| 30 | 30 | url := f.serve(t) | |
| 31 | 31 | res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.", | |
| 32 | 32 | "--source", "refs/heads/feat/x", "--target", " main ").ok(t) | |
| 33 | - | assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout) | |
| 33 | + | assert.Equal(t, "opened alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout) | |
| 34 | 34 | _, reqs := f.snapshot() | |
| 35 | 35 | got := reqs[0].(*ermite.CreateMergeRequestRequest) | |
| 36 | 36 | assert.Equal(t, "alpha", got.GetRepo()) | |
| @@ -49,7 +49,7 @@ func TestMRCreateMapsFlags(t *testing.T) { | |||
| 49 | 49 | _ = res | |
| 50 | 50 | ||
| 51 | 51 | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | |
| 52 | - | assert.JSONEq(t, `{"repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout) | |
| 52 | + | assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout) | |
| 53 | 53 | ||
| 54 | 54 | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | |
| 55 | 55 | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |
| @@ -85,6 +85,9 @@ func TestMRListTableJSONAndStates(t *testing.T) { | |||
| 85 | 85 | res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t) | |
| 86 | 86 | assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo}) | |
| 87 | 87 | assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode) | |
| 88 | + | for i, m := range decode[[]mrOut](t, res.stdout) { | |
| 89 | + | assert.Equal(t, mrWebURL(url, "alpha", int64(2-i)), m.URL) | |
| 90 | + | } | |
| 88 | 91 | } | |
| 89 | 92 | ||
| 90 | 93 | func TestMRListFollowsPages(t *testing.T) { | |
| @@ -153,6 +156,7 @@ func TestMRShow(t *testing.T) { | |||
| 153 | 156 | assert.NotContains(t, js.stdout, `"hunks"`) | |
| 154 | 157 | js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t) | |
| 155 | 158 | show := decode[showOut](t, js.stdout) | |
| 159 | + | assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL) | |
| 156 | 160 | require.Len(t, show.Files, 1) | |
| 157 | 161 | require.Len(t, show.Files[0].Hunks, 1) | |
| 158 | 162 | assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind) | |
| @@ -274,6 +278,7 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||
| 274 | 278 | // The default mode is a fast-forward. | |
| 275 | 279 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | |
| 276 | 280 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | |
| 281 | + | assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL) | |
| 277 | 282 | calls, reqs = f.snapshot() | |
| 278 | 283 | assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1]) | |
| 279 | 284 | m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest) | |
| @@ -281,9 +286,10 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||
| 281 | 286 | assert.Equal(t, headA, m.GetHeadId()) | |
| 282 | 287 | ||
| 283 | 288 | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t) | |
| 284 | - | assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout) | |
| 289 | + | assert.Equal(t, "closed alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout) | |
| 285 | 290 | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t) | |
| 286 | 291 | assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State) | |
| 292 | + | assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL) | |
| 287 | 293 | } | |
| 288 | 294 | ||
| 289 | 295 | // ADR 0017: the merge is of the commit that was reviewed, so --head is | |
| @@ -381,3 +387,14 @@ func TestSigningKeyShowOutput(t *testing.T) { | |||
| 381 | 387 | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | |
| 382 | 388 | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | |
| 383 | 389 | } | |
| 390 | + | ||
| 391 | + | func TestMRWebURL(t *testing.T) { | |
| 392 | + | for _, tt := range []struct{ name, base, repo, want string }{ | |
| 393 | + | {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"}, | |
| 394 | + | {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"}, | |
| 395 | + | {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"}, | |
| 396 | + | {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"}, | |
| 397 | + | } { | |
| 398 | + | t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) }) | |
| 399 | + | } | |
| 400 | + | } | |
| @@ -30,7 +30,7 @@ func TestMRCreateMapsFlags(t *testing.T) { | |||||
| 30 | url := f.serve(t) | 30 | url := f.serve(t) | ||
| 31 | res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.", | 31 | res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.", | ||
| 32 | "--source", "refs/heads/feat/x", "--target", " main ").ok(t) | 32 | "--source", "refs/heads/feat/x", "--target", " main ").ok(t) | ||
| 33 | - | assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout) | 33 | + | assert.Equal(t, "opened alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout) |
| 34 | _, reqs := f.snapshot() | 34 | _, reqs := f.snapshot() | ||
| 35 | got := reqs[0].(*ermite.CreateMergeRequestRequest) | 35 | got := reqs[0].(*ermite.CreateMergeRequestRequest) | ||
| 36 | assert.Equal(t, "alpha", got.GetRepo()) | 36 | assert.Equal(t, "alpha", got.GetRepo()) | ||
| @@ -49,7 +49,7 @@ func TestMRCreateMapsFlags(t *testing.T) { | |||||
| 49 | _ = res | 49 | _ = res | ||
| 50 | 50 | ||||
| 51 | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | 51 | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | ||
| 52 | - | assert.JSONEq(t, `{"repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout) | 52 | + | assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout) |
| 53 | 53 | ||||
| 54 | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | 54 | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | ||
| 55 | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | 55 | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | ||
| @@ -85,6 +85,9 @@ func TestMRListTableJSONAndStates(t *testing.T) { | |||||
| 85 | res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t) | 85 | res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t) | ||
| 86 | assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo}) | 86 | assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo}) | ||
| 87 | assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode) | 87 | assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode) | ||
| 88 | + | for i, m := range decode[[]mrOut](t, res.stdout) { | |||
| 89 | + | assert.Equal(t, mrWebURL(url, "alpha", int64(2-i)), m.URL) | |||
| 90 | + | } | |||
| 88 | } | 91 | } | ||
| 89 | 92 | ||||
| 90 | func TestMRListFollowsPages(t *testing.T) { | 93 | func TestMRListFollowsPages(t *testing.T) { | ||
| @@ -153,6 +156,7 @@ func TestMRShow(t *testing.T) { | |||||
| 153 | assert.NotContains(t, js.stdout, `"hunks"`) | 156 | assert.NotContains(t, js.stdout, `"hunks"`) | ||
| 154 | js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t) | 157 | js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t) | ||
| 155 | show := decode[showOut](t, js.stdout) | 158 | show := decode[showOut](t, js.stdout) | ||
| 159 | + | assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL) | |||
| 156 | require.Len(t, show.Files, 1) | 160 | require.Len(t, show.Files, 1) | ||
| 157 | require.Len(t, show.Files[0].Hunks, 1) | 161 | require.Len(t, show.Files[0].Hunks, 1) | ||
| 158 | assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind) | 162 | assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind) | ||
| @@ -274,6 +278,7 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||||
| 274 | // The default mode is a fast-forward. | 278 | // The default mode is a fast-forward. | ||
| 275 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | 279 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | ||
| 276 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | 280 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | ||
| 281 | + | assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL) | |||
| 277 | calls, reqs = f.snapshot() | 282 | calls, reqs = f.snapshot() | ||
| 278 | assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1]) | 283 | assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1]) | ||
| 279 | m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest) | 284 | m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest) | ||
| @@ -281,9 +286,10 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||||
| 281 | assert.Equal(t, headA, m.GetHeadId()) | 286 | assert.Equal(t, headA, m.GetHeadId()) | ||
| 282 | 287 | ||||
| 283 | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t) | 288 | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t) | ||
| 284 | - | assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout) | 289 | + | assert.Equal(t, "closed alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout) |
| 285 | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t) | 290 | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t) | ||
| 286 | assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State) | 291 | assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State) | ||
| 292 | + | assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL) | |||
| 287 | } | 293 | } | ||
| 288 | 294 | ||||
| 289 | // ADR 0017: the merge is of the commit that was reviewed, so --head is | 295 | // ADR 0017: the merge is of the commit that was reviewed, so --head is | ||
| @@ -381,3 +387,14 @@ func TestSigningKeyShowOutput(t *testing.T) { | |||||
| 381 | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | 387 | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | ||
| 382 | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | 388 | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | ||
| 383 | } | 389 | } | ||
| 390 | + | ||||
| 391 | + | func TestMRWebURL(t *testing.T) { | |||
| 392 | + | for _, tt := range []struct{ name, base, repo, want string }{ | |||
| 393 | + | {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"}, | |||
| 394 | + | {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"}, | |||
| 395 | + | {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"}, | |||
| 396 | + | {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"}, | |||
| 397 | + | } { | |||
| 398 | + | t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) }) | |||
| 399 | + | } | |||
| 400 | + | } | |||
M internal/app/cli_watch.go +5 -5
| @@ -53,7 +53,7 @@ const ( | |||
| 53 | 53 | ||
| 54 | 54 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | |
| 55 | 55 | // which the contract says to ignore. | |
| 56 | - | func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |
| 56 | + | func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |
| 57 | 57 | out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())} | |
| 58 | 58 | switch k := ev.GetKind().(type) { | |
| 59 | 59 | case *ermite.Event_RepoCreated: | |
| @@ -71,13 +71,13 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||
| 71 | 71 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | |
| 72 | 72 | } | |
| 73 | 73 | case *ermite.Event_MergeRequestOpened: | |
| 74 | - | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest()) | |
| 74 | + | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL) | |
| 75 | 75 | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | |
| 76 | 76 | case *ermite.Event_MergeRequestMerged: | |
| 77 | - | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest()) | |
| 77 | + | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) | |
| 78 | 78 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | |
| 79 | 79 | case *ermite.Event_MergeRequestClosed: | |
| 80 | - | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest()) | |
| 80 | + | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) | |
| 81 | 81 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | |
| 82 | 82 | case *ermite.Event_CommentAdded: | |
| 83 | 83 | c := toJSONComment(k.CommentAdded.GetComment()) | |
| @@ -177,7 +177,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 177 | 177 | OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) }, | |
| 178 | 178 | } | |
| 179 | 179 | return c.Watch(ctx, opts, func(ev *ermite.Event) error { | |
| 180 | - | e, ok := toJSONEvent(ev) | |
| 180 | + | e, ok := toJSONEvent(ev, c.URL()) | |
| 181 | 181 | if !ok { | |
| 182 | 182 | return nil | |
| 183 | 183 | } | |
| @@ -53,7 +53,7 @@ const ( | |||||
| 53 | 53 | ||||
| 54 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | 54 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | ||
| 55 | // which the contract says to ignore. | 55 | // which the contract says to ignore. | ||
| 56 | - | func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | 56 | + | func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { |
| 57 | out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())} | 57 | out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())} | ||
| 58 | switch k := ev.GetKind().(type) { | 58 | switch k := ev.GetKind().(type) { | ||
| 59 | case *ermite.Event_RepoCreated: | 59 | case *ermite.Event_RepoCreated: | ||
| @@ -71,13 +71,13 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||||
| 71 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | 71 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | ||
| 72 | } | 72 | } | ||
| 73 | case *ermite.Event_MergeRequestOpened: | 73 | case *ermite.Event_MergeRequestOpened: | ||
| 74 | - | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest()) | 74 | + | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL) |
| 75 | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | 75 | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | ||
| 76 | case *ermite.Event_MergeRequestMerged: | 76 | case *ermite.Event_MergeRequestMerged: | ||
| 77 | - | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest()) | 77 | + | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) |
| 78 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | 78 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | ||
| 79 | case *ermite.Event_MergeRequestClosed: | 79 | case *ermite.Event_MergeRequestClosed: | ||
| 80 | - | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest()) | 80 | + | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) |
| 81 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | 81 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | ||
| 82 | case *ermite.Event_CommentAdded: | 82 | case *ermite.Event_CommentAdded: | ||
| 83 | c := toJSONComment(k.CommentAdded.GetComment()) | 83 | c := toJSONComment(k.CommentAdded.GetComment()) | ||
| @@ -177,7 +177,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||||
| 177 | OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) }, | 177 | OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) }, | ||
| 178 | } | 178 | } | ||
| 179 | return c.Watch(ctx, opts, func(ev *ermite.Event) error { | 179 | return c.Watch(ctx, opts, func(ev *ermite.Event) error { | ||
| 180 | - | e, ok := toJSONEvent(ev) | 180 | + | e, ok := toJSONEvent(ev, c.URL()) |
| 181 | if !ok { | 181 | if !ok { | ||
| 182 | return nil | 182 | return nil | ||
| 183 | } | 183 | } | ||
M skills/ermite/CLI.md +5 -1
| @@ -60,7 +60,11 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||
| 60 | 60 | | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) | | |
| 61 | 61 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | |
| 62 | 62 | ||
| 63 | - | Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | |
| 63 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 64 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 65 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 66 | + | ||
| 67 | + | Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | |
| 64 | 68 | ||
| 65 | 69 | ## Workflows | |
| 66 | 70 | ||
| @@ -60,7 +60,11 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||||
| 60 | | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) | | 60 | | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) | | ||
| 61 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | 61 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | ||
| 62 | 62 | ||||
| 63 | - | Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | 63 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full |
| 64 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 65 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |||
| 66 | + | ||||
| 67 | + | Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | |||
| 64 | 68 | ||||
| 65 | ## Workflows | 69 | ## Workflows | ||
| 66 | 70 | ||||
M skills/ermite/SKILL.md +4 -0
| @@ -40,6 +40,10 @@ git push https://git.example.com/<repo>.git main # password = token, | |||
| 40 | 40 | ||
| 41 | 41 | The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode. | |
| 42 | 42 | ||
| 43 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 44 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 45 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 46 | + | ||
| 43 | 47 | Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`. | |
| 44 | 48 | ||
| 45 | 49 | ## Tokens | |
| @@ -40,6 +40,10 @@ git push https://git.example.com/<repo>.git main # password = token, | |||||
| 40 | 40 | ||||
| 41 | The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode. | 41 | The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode. | ||
| 42 | 42 | ||||
| 43 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |||
| 44 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 45 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |||
| 46 | + | ||||
| 43 | Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`. | 47 | Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`. | ||
| 44 | 48 | ||||
| 45 | ## Tokens | 49 | ## Tokens | ||