Merge remote-tracking branch 'origin/main' into feat/mr-notes
# Conflicts: # internal/app/cli_mr_test.go # skills/ermite/CLI.md # skills/ermite/SKILL.md
13 files · +84 -28
M .github/workflows/ci.yml +8 -0
| @@ -1,9 +1,17 @@ | |||
| 1 | 1 | name: CI | |
| 2 | 2 | ||
| 3 | + | # A change that only touches these files skips the whole run: none of them | |
| 4 | + | # affects the build, the tests or the generated code. | |
| 3 | 5 | on: | |
| 4 | 6 | push: | |
| 5 | 7 | branches: [main] | |
| 8 | + | paths-ignore: &docs-only | |
| 9 | + | - '**.md' | |
| 10 | + | - '**.png' | |
| 11 | + | - 'docs/**' | |
| 12 | + | - 'LICENSE' | |
| 6 | 13 | pull_request: | |
| 14 | + | paths-ignore: *docs-only | |
| 7 | 15 | ||
| 8 | 16 | env: | |
| 9 | 17 | # Test the module alone, with the versions in go.mod (never a go.work). | |
| @@ -1,9 +1,17 @@ | |||||
| 1 | name: CI | 1 | name: CI | ||
| 2 | 2 | ||||
| 3 | + | # A change that only touches these files skips the whole run: none of them | |||
| 4 | + | # affects the build, the tests or the generated code. | |||
| 3 | on: | 5 | on: | ||
| 4 | push: | 6 | push: | ||
| 5 | branches: [main] | 7 | branches: [main] | ||
| 8 | + | paths-ignore: &docs-only | |||
| 9 | + | - '**.md' | |||
| 10 | + | - '**.png' | |||
| 11 | + | - 'docs/**' | |||
| 12 | + | - 'LICENSE' | |||
| 6 | pull_request: | 13 | pull_request: | ||
| 14 | + | paths-ignore: *docs-only | |||
| 7 | 15 | ||||
| 8 | env: | 16 | env: | ||
| 9 | # Test the module alone, with the versions in go.mod (never a go.work). | 17 | # Test the module alone, with the versions in go.mod (never a go.work). | ||
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
| @@ -88,3 +88,7 @@ ermite completion bash|zsh|fish | |||
| 88 | 88 | - `watch --json` prints one object per line. | |
| 89 | 89 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| 90 | 90 | - Shell completion completes repository names from the server. | |
| 91 | + | ||
| 92 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 93 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 94 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| @@ -88,3 +88,7 @@ ermite completion bash|zsh|fish | |||||
| 88 | - `watch --json` prints one object per line. | 88 | - `watch --json` prints one object per line. | ||
| 89 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | 89 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | ||
| 90 | - Shell completion completes repository names from the server. | 90 | - Shell completion completes repository names from the server. | ||
| 91 | + | ||||
| 92 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |||
| 93 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 94 | + | (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/adapters/web/static/app/apple-touch-icon.png +0 -0 binary file: no text diff
binary file: no text diff
M internal/adapters/web/static/app/icon-maskable-512.png +0 -0 binary file: no text diff
binary file: no text diff
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()), | |
| @@ -167,7 +170,7 @@ func toJSONComment(c *ermite.Comment) jsonComment { | |||
| 167 | 170 | } | |
| 168 | 171 | ||
| 169 | 172 | const jsonMRHelp = ` | |
| 170 | - | JSON fields of a merge request (--json): repo, number, title, description, source_branch, | |
| 173 | + | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | |
| 171 | 174 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |
| 172 | 175 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |
| 173 | 176 | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | |
| @@ -262,7 +265,8 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||
| 262 | 265 | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | |
| 263 | 266 | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | |
| 264 | 267 | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | |
| 265 | - | "already has the same source and target. Needs the mr:write scope." + jsonMRHelp, | |
| 268 | + | "already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" + | |
| 269 | + | "Needs the mr:write scope." + jsonMRHelp, | |
| 266 | 270 | Args: cobra.ExactArgs(1), | |
| 267 | 271 | ValidArgsFunction: completeRepoNames(env), | |
| 268 | 272 | } | |
| @@ -290,20 +294,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||
| 290 | 294 | if err != nil { | |
| 291 | 295 | return c.Describe(err) | |
| 292 | 296 | } | |
| 293 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened") | |
| 297 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL()) | |
| 294 | 298 | } | |
| 295 | 299 | return cmd | |
| 296 | 300 | } | |
| 297 | 301 | ||
| 298 | 302 | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | |
| 299 | 303 | ||
| 300 | - | // outputMR prints a merge request as JSON, or as one confirmation line. | |
| 301 | - | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error { | |
| 304 | + | // mrWebURL builds the web route with the repository as a single path segment. | |
| 305 | + | // Repository names currently forbid slashes, but escape all path delimiters defensively. | |
| 306 | + | func mrWebURL(baseURL, repo string, number int64) string { | |
| 307 | + | return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // outputMR prints a merge request as JSON, or a confirmation followed by its URL. | |
| 311 | + | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error { | |
| 302 | 312 | if asJSON { | |
| 303 | - | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | |
| 313 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL)) | |
| 304 | 314 | } | |
| 305 | - | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(), | |
| 306 | - | clean(m.GetSourceBranch()), clean(m.GetTargetBranch())) | |
| 315 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(), | |
| 316 | + | clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber())) | |
| 307 | 317 | return err | |
| 308 | 318 | } | |
| 309 | 319 | ||
| @@ -338,7 +348,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command { | |||
| 338 | 348 | if *asJSON { | |
| 339 | 349 | out := make([]jsonMergeRequest, 0, len(mrs)) | |
| 340 | 350 | for _, m := range mrs { | |
| 341 | - | out = append(out, toJSONMergeRequest(m)) | |
| 351 | + | out = append(out, toJSONMergeRequest(m, c.URL())) | |
| 342 | 352 | } | |
| 343 | 353 | return writeJSON(cmd.OutOrStdout(), out) | |
| 344 | 354 | } | |
| @@ -431,9 +441,9 @@ func lineKindName(k ermite.LineKind) string { | |||
| 431 | 441 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | |
| 432 | 442 | } | |
| 433 | 443 | ||
| 434 | - | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail { | |
| 444 | + | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | |
| 435 | 445 | out := jsonMRDetail{ | |
| 436 | - | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()), | |
| 446 | + | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | |
| 437 | 447 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | |
| 438 | 448 | DiffBaseID: d.GetDiffBaseId(), | |
| 439 | 449 | Stats: jsonStats{ | |
| @@ -517,7 +527,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 517 | 527 | } | |
| 518 | 528 | d := res.Msg | |
| 519 | 529 | if *asJSON { | |
| 520 | - | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff)) | |
| 530 | + | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL())) | |
| 521 | 531 | } else { | |
| 522 | 532 | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | |
| 523 | 533 | } | |
| @@ -1004,7 +1014,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1004 | 1014 | clean(m.GetSourceBranch())) | |
| 1005 | 1015 | } | |
| 1006 | 1016 | if *asJSON { | |
| 1007 | - | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted}) | |
| 1017 | + | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | |
| 1008 | 1018 | } | |
| 1009 | 1019 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 1010 | 1020 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | |
| @@ -1021,7 +1031,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 1021 | 1031 | Use: "close <repo> <number>", | |
| 1022 | 1032 | Short: "Close a merge request without merging it", | |
| 1023 | 1033 | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | |
| 1024 | - | "new one. Needs the mr:write scope." + jsonMRHelp, | |
| 1034 | + | "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp, | |
| 1025 | 1035 | Args: cobra.ExactArgs(2), | |
| 1026 | 1036 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 1027 | 1037 | } | |
| @@ -1039,7 +1049,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 1039 | 1049 | if err != nil { | |
| 1040 | 1050 | return c.Describe(err) | |
| 1041 | 1051 | } | |
| 1042 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed") | |
| 1052 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL()) | |
| 1043 | 1053 | } | |
| 1044 | 1054 | return cmd | |
| 1045 | 1055 | } | |
| @@ -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()), | ||
| @@ -167,7 +170,7 @@ func toJSONComment(c *ermite.Comment) jsonComment { | |||||
| 167 | } | 170 | } | ||
| 168 | 171 | ||||
| 169 | const jsonMRHelp = ` | 172 | const jsonMRHelp = ` | ||
| 170 | - | JSON fields of a merge request (--json): repo, number, title, description, source_branch, | 173 | + | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, |
| 171 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | 174 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | ||
| 172 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | 175 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | ||
| 173 | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | 176 | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | ||
| @@ -262,7 +265,8 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 262 | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | 265 | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | ||
| 263 | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | 266 | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | ||
| 264 | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | 267 | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | ||
| 265 | - | "already has the same source and target. Needs the mr:write scope." + jsonMRHelp, | 268 | + | "already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" + |
| 269 | + | "Needs the mr:write scope." + jsonMRHelp, | |||
| 266 | Args: cobra.ExactArgs(1), | 270 | Args: cobra.ExactArgs(1), | ||
| 267 | ValidArgsFunction: completeRepoNames(env), | 271 | ValidArgsFunction: completeRepoNames(env), | ||
| 268 | } | 272 | } | ||
| @@ -290,20 +294,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 290 | if err != nil { | 294 | if err != nil { | ||
| 291 | return c.Describe(err) | 295 | return c.Describe(err) | ||
| 292 | } | 296 | } | ||
| 293 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened") | 297 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL()) |
| 294 | } | 298 | } | ||
| 295 | return cmd | 299 | return cmd | ||
| 296 | } | 300 | } | ||
| 297 | 301 | ||||
| 298 | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | 302 | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | ||
| 299 | 303 | ||||
| 300 | - | // outputMR prints a merge request as JSON, or as one confirmation line. | 304 | + | // mrWebURL builds the web route with the repository as a single path segment. |
| 301 | - | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error { | 305 | + | // Repository names currently forbid slashes, but escape all path delimiters defensively. |
| 306 | + | func mrWebURL(baseURL, repo string, number int64) string { | |||
| 307 | + | return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // outputMR prints a merge request as JSON, or a confirmation followed by its URL. | |||
| 311 | + | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error { | |||
| 302 | if asJSON { | 312 | if asJSON { | ||
| 303 | - | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | 313 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL)) |
| 304 | } | 314 | } | ||
| 305 | - | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(), | 315 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(), |
| 306 | - | clean(m.GetSourceBranch()), clean(m.GetTargetBranch())) | 316 | + | clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber())) |
| 307 | return err | 317 | return err | ||
| 308 | } | 318 | } | ||
| 309 | 319 | ||||
| @@ -338,7 +348,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command { | |||||
| 338 | if *asJSON { | 348 | if *asJSON { | ||
| 339 | out := make([]jsonMergeRequest, 0, len(mrs)) | 349 | out := make([]jsonMergeRequest, 0, len(mrs)) | ||
| 340 | for _, m := range mrs { | 350 | for _, m := range mrs { | ||
| 341 | - | out = append(out, toJSONMergeRequest(m)) | 351 | + | out = append(out, toJSONMergeRequest(m, c.URL())) |
| 342 | } | 352 | } | ||
| 343 | return writeJSON(cmd.OutOrStdout(), out) | 353 | return writeJSON(cmd.OutOrStdout(), out) | ||
| 344 | } | 354 | } | ||
| @@ -431,9 +441,9 @@ func lineKindName(k ermite.LineKind) string { | |||||
| 431 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | 441 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | ||
| 432 | } | 442 | } | ||
| 433 | 443 | ||||
| 434 | - | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail { | 444 | + | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { |
| 435 | out := jsonMRDetail{ | 445 | out := jsonMRDetail{ | ||
| 436 | - | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()), | 446 | + | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), |
| 437 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | 447 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | ||
| 438 | DiffBaseID: d.GetDiffBaseId(), | 448 | DiffBaseID: d.GetDiffBaseId(), | ||
| 439 | Stats: jsonStats{ | 449 | Stats: jsonStats{ | ||
| @@ -517,7 +527,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||||
| 517 | } | 527 | } | ||
| 518 | d := res.Msg | 528 | d := res.Msg | ||
| 519 | if *asJSON { | 529 | if *asJSON { | ||
| 520 | - | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff)) | 530 | + | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL())) |
| 521 | } else { | 531 | } else { | ||
| 522 | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | 532 | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | ||
| 523 | } | 533 | } | ||
| @@ -1004,7 +1014,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1004 | clean(m.GetSourceBranch())) | 1014 | clean(m.GetSourceBranch())) | ||
| 1005 | } | 1015 | } | ||
| 1006 | if *asJSON { | 1016 | if *asJSON { | ||
| 1007 | - | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted}) | 1017 | + | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) |
| 1008 | } | 1018 | } | ||
| 1009 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | 1019 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | ||
| 1010 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | 1020 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | ||
| @@ -1021,7 +1031,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||||
| 1021 | Use: "close <repo> <number>", | 1031 | Use: "close <repo> <number>", | ||
| 1022 | Short: "Close a merge request without merging it", | 1032 | Short: "Close a merge request without merging it", | ||
| 1023 | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | 1033 | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | ||
| 1024 | - | "new one. Needs the mr:write scope." + jsonMRHelp, | 1034 | + | "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp, |
| 1025 | Args: cobra.ExactArgs(2), | 1035 | Args: cobra.ExactArgs(2), | ||
| 1026 | ValidArgsFunction: completeMRRepoThenNumber(env), | 1036 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
| 1027 | } | 1037 | } | ||
| @@ -1039,7 +1049,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||||
| 1039 | if err != nil { | 1049 | if err != nil { | ||
| 1040 | return c.Describe(err) | 1050 | return c.Describe(err) | ||
| 1041 | } | 1051 | } | ||
| 1042 | - | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed") | 1052 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL()) |
| 1043 | } | 1053 | } | ||
| 1044 | return cmd | 1054 | return cmd | ||
| 1045 | } | 1055 | } | ||
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 +21 -4
| @@ -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 | |
| @@ -447,7 +453,7 @@ func TestNoteDetailOutput(t *testing.T) { | |||
| 447 | 453 | assert.Contains(t, text, "(edited)") | |
| 448 | 454 | assert.Contains(t, text, "[deleted]") | |
| 449 | 455 | assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3")) | |
| 450 | - | got := toJSONDetail(d, false) | |
| 456 | + | got := toJSONDetail(d, false, "https://git.example.org") | |
| 451 | 457 | require.Len(t, got.Verdicts, 2) | |
| 452 | 458 | assert.True(t, got.Verdicts[0].Stale) | |
| 453 | 459 | assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict) | |
| @@ -470,3 +476,14 @@ func TestMRReviewScopeCompletion(t *testing.T) { | |||
| 470 | 476 | values, _ := complete(cmd, nil, "") | |
| 471 | 477 | assert.Contains(t, values, "mr:review") | |
| 472 | 478 | } | |
| 479 | + | ||
| 480 | + | func TestMRWebURL(t *testing.T) { | |
| 481 | + | for _, tt := range []struct{ name, base, repo, want string }{ | |
| 482 | + | {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"}, | |
| 483 | + | {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"}, | |
| 484 | + | {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"}, | |
| 485 | + | {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"}, | |
| 486 | + | } { | |
| 487 | + | t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) }) | |
| 488 | + | } | |
| 489 | + | } | |
| @@ -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 | ||
| @@ -447,7 +453,7 @@ func TestNoteDetailOutput(t *testing.T) { | |||||
| 447 | assert.Contains(t, text, "(edited)") | 453 | assert.Contains(t, text, "(edited)") | ||
| 448 | assert.Contains(t, text, "[deleted]") | 454 | assert.Contains(t, text, "[deleted]") | ||
| 449 | assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3")) | 455 | assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3")) | ||
| 450 | - | got := toJSONDetail(d, false) | 456 | + | got := toJSONDetail(d, false, "https://git.example.org") |
| 451 | require.Len(t, got.Verdicts, 2) | 457 | require.Len(t, got.Verdicts, 2) | ||
| 452 | assert.True(t, got.Verdicts[0].Stale) | 458 | assert.True(t, got.Verdicts[0].Stale) | ||
| 453 | assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict) | 459 | assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict) | ||
| @@ -470,3 +476,14 @@ func TestMRReviewScopeCompletion(t *testing.T) { | |||||
| 470 | values, _ := complete(cmd, nil, "") | 476 | values, _ := complete(cmd, nil, "") | ||
| 471 | assert.Contains(t, values, "mr:review") | 477 | assert.Contains(t, values, "mr:review") | ||
| 472 | } | 478 | } | ||
| 479 | + | ||||
| 480 | + | func TestMRWebURL(t *testing.T) { | |||
| 481 | + | for _, tt := range []struct{ name, base, repo, want string }{ | |||
| 482 | + | {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"}, | |||
| 483 | + | {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"}, | |||
| 484 | + | {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"}, | |||
| 485 | + | {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"}, | |||
| 486 | + | } { | |||
| 487 | + | t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) }) | |||
| 488 | + | } | |||
| 489 | + | } | |||
M internal/app/cli_watch.go +5 -5
| @@ -56,7 +56,7 @@ const ( | |||
| 56 | 56 | ||
| 57 | 57 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | |
| 58 | 58 | // which the contract says to ignore. | |
| 59 | - | func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |
| 59 | + | func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |
| 60 | 60 | out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())} | |
| 61 | 61 | switch k := ev.GetKind().(type) { | |
| 62 | 62 | case *ermite.Event_RepoCreated: | |
| @@ -74,13 +74,13 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||
| 74 | 74 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | |
| 75 | 75 | } | |
| 76 | 76 | case *ermite.Event_MergeRequestOpened: | |
| 77 | - | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest()) | |
| 77 | + | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL) | |
| 78 | 78 | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | |
| 79 | 79 | case *ermite.Event_MergeRequestMerged: | |
| 80 | - | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest()) | |
| 80 | + | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) | |
| 81 | 81 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | |
| 82 | 82 | case *ermite.Event_MergeRequestClosed: | |
| 83 | - | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest()) | |
| 83 | + | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) | |
| 84 | 84 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | |
| 85 | 85 | case *ermite.Event_CommentAdded: | |
| 86 | 86 | c := toJSONComment(k.CommentAdded.GetComment()) | |
| @@ -190,7 +190,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 190 | 190 | OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) }, | |
| 191 | 191 | } | |
| 192 | 192 | return c.Watch(ctx, opts, func(ev *ermite.Event) error { | |
| 193 | - | e, ok := toJSONEvent(ev) | |
| 193 | + | e, ok := toJSONEvent(ev, c.URL()) | |
| 194 | 194 | if !ok { | |
| 195 | 195 | return nil | |
| 196 | 196 | } | |
| @@ -56,7 +56,7 @@ const ( | |||||
| 56 | 56 | ||||
| 57 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | 57 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | ||
| 58 | // which the contract says to ignore. | 58 | // which the contract says to ignore. | ||
| 59 | - | func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | 59 | + | func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { |
| 60 | out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())} | 60 | out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())} | ||
| 61 | switch k := ev.GetKind().(type) { | 61 | switch k := ev.GetKind().(type) { | ||
| 62 | case *ermite.Event_RepoCreated: | 62 | case *ermite.Event_RepoCreated: | ||
| @@ -74,13 +74,13 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||||
| 74 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | 74 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | ||
| 75 | } | 75 | } | ||
| 76 | case *ermite.Event_MergeRequestOpened: | 76 | case *ermite.Event_MergeRequestOpened: | ||
| 77 | - | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest()) | 77 | + | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL) |
| 78 | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | 78 | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | ||
| 79 | case *ermite.Event_MergeRequestMerged: | 79 | case *ermite.Event_MergeRequestMerged: | ||
| 80 | - | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest()) | 80 | + | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) |
| 81 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | 81 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | ||
| 82 | case *ermite.Event_MergeRequestClosed: | 82 | case *ermite.Event_MergeRequestClosed: | ||
| 83 | - | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest()) | 83 | + | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) |
| 84 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | 84 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | ||
| 85 | case *ermite.Event_CommentAdded: | 85 | case *ermite.Event_CommentAdded: | ||
| 86 | c := toJSONComment(k.CommentAdded.GetComment()) | 86 | c := toJSONComment(k.CommentAdded.GetComment()) | ||
| @@ -190,7 +190,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||||
| 190 | OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) }, | 190 | OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) }, | ||
| 191 | } | 191 | } | ||
| 192 | return c.Watch(ctx, opts, func(ev *ermite.Event) error { | 192 | return c.Watch(ctx, opts, func(ev *ermite.Event) error { | ||
| 193 | - | e, ok := toJSONEvent(ev) | 193 | + | e, ok := toJSONEvent(ev, c.URL()) |
| 194 | if !ok { | 194 | if !ok { | ||
| 195 | return nil | 195 | return nil | ||
| 196 | } | 196 | } | ||
M internal/app/cli_watch_test.go +1 -1
| @@ -208,7 +208,7 @@ func TestNoteWatchEvents(t *testing.T) { | |||
| 208 | 208 | {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}}, | |
| 209 | 209 | } | |
| 210 | 210 | for i, ev := range events { | |
| 211 | - | got, ok := toJSONEvent(ev) | |
| 211 | + | got, ok := toJSONEvent(ev, "https://git.example.org") | |
| 212 | 212 | require.True(t, ok) | |
| 213 | 213 | assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type) | |
| 214 | 214 | assert.Equal(t, "alpha", got.Repo) | |
| @@ -208,7 +208,7 @@ func TestNoteWatchEvents(t *testing.T) { | |||||
| 208 | {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}}, | 208 | {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}}, | ||
| 209 | } | 209 | } | ||
| 210 | for i, ev := range events { | 210 | for i, ev := range events { | ||
| 211 | - | got, ok := toJSONEvent(ev) | 211 | + | got, ok := toJSONEvent(ev, "https://git.example.org") |
| 212 | require.True(t, ok) | 212 | require.True(t, ok) | ||
| 213 | assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type) | 213 | assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type) | ||
| 214 | assert.Equal(t, "alpha", got.Repo) | 214 | assert.Equal(t, "alpha", got.Repo) | ||
M skills/ermite/CLI.md +5 -1
| @@ -63,7 +63,11 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||
| 63 | 63 | | `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`) | | |
| 64 | 64 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | |
| 65 | 65 | ||
| 66 | - | 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`, `verdicts`, `last_read_id`, `last_read_lost`. | |
| 66 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 67 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 68 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 69 | + | ||
| 70 | + | 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`, `verdicts`, `last_read_id`, `last_read_lost`. | |
| 67 | 71 | ||
| 68 | 72 | Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N. | |
| 69 | 73 | ||
| @@ -63,7 +63,11 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||||
| 63 | | `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`) | | 63 | | `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`) | | ||
| 64 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | 64 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | ||
| 65 | 65 | ||||
| 66 | - | 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`, `verdicts`, `last_read_id`, `last_read_lost`. | 66 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full |
| 67 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 68 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |||
| 69 | + | ||||
| 70 | + | 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`, `verdicts`, `last_read_id`, `last_read_lost`. | |||
| 67 | 71 | ||||
| 68 | Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N. | 72 | Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N. | ||
| 69 | 73 | ||||
M skills/ermite/SKILL.md +4 -0
| @@ -49,6 +49,10 @@ On `changes_requested`: run `ermite mr show <repo> <n> --json`, read the open th | |||
| 49 | 49 | ||
| 50 | 50 | An approval is stale after a push; changes requested stay until the reviewer gives a new verdict. | |
| 51 | 51 | ||
| 52 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 53 | + | merge request web URL on its own line. With `--json`, merge request objects | |
| 54 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 55 | + | ||
| 52 | 56 | Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`. | |
| 53 | 57 | ||
| 54 | 58 | ## Tokens | |
| @@ -49,6 +49,10 @@ On `changes_requested`: run `ermite mr show <repo> <n> --json`, read the open th | |||||
| 49 | 49 | ||||
| 50 | An approval is stale after a push; changes requested stay until the reviewer gives a new verdict. | 50 | An approval is stale after a push; changes requested stay until the reviewer gives a new verdict. | ||
| 51 | 51 | ||||
| 52 | + | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |||
| 53 | + | merge request web URL on its own line. With `--json`, merge request objects | |||
| 54 | + | (create, show, list, close and merge) include `url` alongside the other fields. | |||
| 55 | + | ||||
| 52 | Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`. | 56 | Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`. | ||
| 53 | 57 | ||||
| 54 | ## Tokens | 58 | ## Tokens | ||