Merge eba40da6c2f55746079d8f7bf90538ee1c211b4f into 00396122d39c11f2d7ecd211a94f823937ff76c0
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 | ||