detached eba40da

feat(cli): print the full web URL of merge requests

mr create and mr close print the MR web URL on its own line after the
confirmation. Every merge request JSON output gains a url field.
commit
eba40da6c2f55746079d8f7bf90538ee1c211b4f
author
brice · · 2026-10-19 05:28 UTC
parent
0039612
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
7/8
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
8282
8383 Pushing to a new name also creates a private repository.
8484
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+
8589 ## Agent workflow
8690
8791 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 workflow89 ## 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
8787 - `watch --json` prints one object per line.
8888 - Errors are one line on stderr (`ermite: …`) with exit status 1.
8989 - 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
88 ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
99 ```
1010
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+
1115 - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.
1216 - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.
1317 - 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 (
77 "errors"
88 "fmt"
99 "io"
10+ "net/url"
1011 "strconv"
1112 "strings"
1213
@@ -101,6 +102,7 @@ func parseSide(s string) (ermite.DiffSide, error) {
101102 // jsonMergeRequest is the stable JSON shape of a merge request.
102103 type jsonMergeRequest struct {
103104 Repo string `json:"repo"`
105+ URL string `json:"url"`
104106 Number int64 `json:"number"`
105107 Title string `json:"title"`
106108 Description string `json:"description"`
@@ -121,8 +123,9 @@ type jsonMerged struct {
121123 SourceBranchDeleted bool `json:"source_branch_deleted"`
122124 }
123125
124-func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest {
126+func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest {
125127 return jsonMergeRequest{
128+ URL: mrWebURL(baseURL, m.GetRepo(), m.GetNumber()),
126129 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),
127130 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
128131 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
@@ -151,7 +154,7 @@ func toJSONComment(c *ermite.Comment) jsonComment {
151154 }
152155
153156 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,
155158 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
156159 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
157160 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 {
245248 "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" +
246249 "neither may hold control characters (a description may hold line breaks) or bidirectional\n" +
247250 "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,
249253 Args: cobra.ExactArgs(1),
250254 ValidArgsFunction: completeRepoNames(env),
251255 }
@@ -273,20 +277,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
273277 if err != nil {
274278 return c.Describe(err)
275279 }
276- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened")
280+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL())
277281 }
278282 return cmd
279283 }
280284
281285 func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") }
282286
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 {
285295 if asJSON {
286- return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m))
296+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL))
287297 }
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()))
290300 return err
291301 }
292302
@@ -321,7 +331,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
321331 if *asJSON {
322332 out := make([]jsonMergeRequest, 0, len(mrs))
323333 for _, m := range mrs {
324- out = append(out, toJSONMergeRequest(m))
334+ out = append(out, toJSONMergeRequest(m, c.URL()))
325335 }
326336 return writeJSON(cmd.OutOrStdout(), out)
327337 }
@@ -404,9 +414,9 @@ func lineKindName(k ermite.LineKind) string {
404414 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
405415 }
406416
407-func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail {
417+func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
408418 out := jsonMRDetail{
409- jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()),
419+ jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
410420 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
411421 DiffBaseID: d.GetDiffBaseId(),
412422 Stats: jsonStats{
@@ -487,7 +497,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
487497 }
488498 d := res.Msg
489499 if *asJSON {
490- err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff))
500+ err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL()))
491501 } else {
492502 err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)
493503 }
@@ -803,7 +813,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
803813 clean(m.GetSourceBranch()))
804814 }
805815 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})
807817 }
808818 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
809819 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
@@ -820,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
820830 Use: "close <repo> <number>",
821831 Short: "Close a merge request without merging it",
822832 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,
824834 Args: cobra.ExactArgs(2),
825835 ValidArgsFunction: completeMRRepoThenNumber(env),
826836 }
@@ -838,7 +848,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
838848 if err != nil {
839849 return c.Describe(err)
840850 }
841- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed")
851+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL())
842852 }
843853 return cmd
844854 }
@@ -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 and158 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 the159 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 empty160 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 cmd282 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 err300 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.Msg498 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 cmd853 return cmd
844 }854 }
M internal/app/cli_mr_e2e_test.go +2 -1
@@ -18,6 +18,7 @@ import (
1818
1919 type mrOut struct {
2020 Repo string `json:"repo"`
21+ URL string `json:"url"`
2122 Number int64 `json:"number"`
2223 Title string `json:"title"`
2324 Description string `json:"description"`
@@ -32,7 +33,7 @@ type mrOut struct {
3233 MergeMode string `json:"merge_mode"`
3334 }
3435
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"}
3637
3738 type commentOut struct {
3839 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) {
3030 url := f.serve(t)
3131 res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.",
3232 "--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)
3434 _, reqs := f.snapshot()
3535 got := reqs[0].(*ermite.CreateMergeRequestRequest)
3636 assert.Equal(t, "alpha", got.GetRepo())
@@ -49,7 +49,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
4949 _ = res
5050
5151 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)
5353
5454 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
5555 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -85,6 +85,9 @@ func TestMRListTableJSONAndStates(t *testing.T) {
8585 res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t)
8686 assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo})
8787 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+ }
8891 }
8992
9093 func TestMRListFollowsPages(t *testing.T) {
@@ -153,6 +156,7 @@ func TestMRShow(t *testing.T) {
153156 assert.NotContains(t, js.stdout, `"hunks"`)
154157 js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)
155158 show := decode[showOut](t, js.stdout)
159+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL)
156160 require.Len(t, show.Files, 1)
157161 require.Len(t, show.Files[0].Hunks, 1)
158162 assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)
@@ -274,6 +278,7 @@ func TestMRResolveMergeAndClose(t *testing.T) {
274278 // The default mode is a fast-forward.
275279 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
276280 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)
277282 calls, reqs = f.snapshot()
278283 assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
279284 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
@@ -281,9 +286,10 @@ func TestMRResolveMergeAndClose(t *testing.T) {
281286 assert.Equal(t, headA, m.GetHeadId())
282287
283288 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)
285290 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)
286291 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)
287293 }
288294
289295 // ADR 0017: the merge is of the commit that was reviewed, so --head is
@@ -381,3 +387,14 @@ func TestSigningKeyShowOutput(t *testing.T) {
381387 res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
382388 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)
383389 }
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 _ = res49 _ = 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 is295 // 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 (
5353
5454 // toJSONEvent maps an event; ok is false for kinds this client does not know,
5555 // which the contract says to ignore.
56-func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
56+func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
5757 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
5858 switch k := ev.GetKind().(type) {
5959 case *ermite.Event_RepoCreated:
@@ -71,13 +71,13 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
7171 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
7272 }
7373 case *ermite.Event_MergeRequestOpened:
74- m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest())
74+ m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL)
7575 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m
7676 case *ermite.Event_MergeRequestMerged:
77- m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest())
77+ m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
7878 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
7979 case *ermite.Event_MergeRequestClosed:
80- m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest())
80+ m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
8181 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
8282 case *ermite.Event_CommentAdded:
8383 c := toJSONComment(k.CommentAdded.GetComment())
@@ -177,7 +177,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
177177 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },
178178 }
179179 return c.Watch(ctx, opts, func(ev *ermite.Event) error {
180- e, ok := toJSONEvent(ev)
180+ e, ok := toJSONEvent(ev, c.URL())
181181 if !ok {
182182 return nil
183183 }
@@ -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, &m75 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, &m78 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, &m81 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 nil182 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
6060 | `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`) |
6161 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6262
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`.
6468
6569 ## Workflows
6670
@@ -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 ## Workflows69 ## 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,
4040
4141 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.
4242
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+
4347 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
4448
4549 ## 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 ## Tokens49 ## Tokens

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close