detached 86ac875

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
commit
86ac875add464391373c8352a8777dc283bc2016
author
brice · · 2026-10-19 10:15 UTC
merge of
77b3426 + d9a8fff · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
7/8
13 files · +84 -28
M .github/workflows/ci.yml +8 -0
@@ -1,9 +1,17 @@
11 name: CI
22
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.
35 on:
46 push:
57 branches: [main]
8+ paths-ignore: &docs-only
9+ - '**.md'
10+ - '**.png'
11+ - 'docs/**'
12+ - 'LICENSE'
613 pull_request:
14+ paths-ignore: *docs-only
715
816 env:
917 # Test the module alone, with the versions in go.mod (never a go.work).
@@ -1,9 +1,17 @@
1 name: CI1 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
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
@@ -88,3 +88,7 @@ ermite completion bash|zsh|fish
8888 - `watch --json` prints one object per line.
8989 - Errors are one line on stderr (`ermite: …`) with exit status 1.
9090 - 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
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/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 (
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()),
@@ -167,7 +170,7 @@ func toJSONComment(c *ermite.Comment) jsonComment {
167170 }
168171
169172 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,
171174 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
172175 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
173176 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 {
262265 "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" +
263266 "neither may hold control characters (a description may hold line breaks) or bidirectional\n" +
264267 "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,
266270 Args: cobra.ExactArgs(1),
267271 ValidArgsFunction: completeRepoNames(env),
268272 }
@@ -290,20 +294,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
290294 if err != nil {
291295 return c.Describe(err)
292296 }
293- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened")
297+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL())
294298 }
295299 return cmd
296300 }
297301
298302 func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") }
299303
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 {
302312 if asJSON {
303- return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m))
313+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL))
304314 }
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()))
307317 return err
308318 }
309319
@@ -338,7 +348,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
338348 if *asJSON {
339349 out := make([]jsonMergeRequest, 0, len(mrs))
340350 for _, m := range mrs {
341- out = append(out, toJSONMergeRequest(m))
351+ out = append(out, toJSONMergeRequest(m, c.URL()))
342352 }
343353 return writeJSON(cmd.OutOrStdout(), out)
344354 }
@@ -431,9 +441,9 @@ func lineKindName(k ermite.LineKind) string {
431441 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
432442 }
433443
434-func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail {
444+func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
435445 out := jsonMRDetail{
436- jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()),
446+ jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
437447 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
438448 DiffBaseID: d.GetDiffBaseId(),
439449 Stats: jsonStats{
@@ -517,7 +527,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
517527 }
518528 d := res.Msg
519529 if *asJSON {
520- err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff))
530+ err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL()))
521531 } else {
522532 err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)
523533 }
@@ -1004,7 +1014,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10041014 clean(m.GetSourceBranch()))
10051015 }
10061016 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})
10081018 }
10091019 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
10101020 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
@@ -1021,7 +1031,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
10211031 Use: "close <repo> <number>",
10221032 Short: "Close a merge request without merging it",
10231033 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,
10251035 Args: cobra.ExactArgs(2),
10261036 ValidArgsFunction: completeMRRepoThenNumber(env),
10271037 }
@@ -1039,7 +1049,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
10391049 if err != nil {
10401050 return c.Describe(err)
10411051 }
1042- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed")
1052+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL())
10431053 }
10441054 return cmd
10451055 }
@@ -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 and174 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 the175 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 empty176 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 cmd299 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 err317 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.Msg528 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 cmd1054 return cmd
1045 }1055 }
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 +21 -4
@@ -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
@@ -447,7 +453,7 @@ func TestNoteDetailOutput(t *testing.T) {
447453 assert.Contains(t, text, "(edited)")
448454 assert.Contains(t, text, "[deleted]")
449455 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")
451457 require.Len(t, got.Verdicts, 2)
452458 assert.True(t, got.Verdicts[0].Stale)
453459 assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict)
@@ -470,3 +476,14 @@ func TestMRReviewScopeCompletion(t *testing.T) {
470476 values, _ := complete(cmd, nil, "")
471477 assert.Contains(t, values, "mr:review")
472478 }
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 _ = 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
@@ -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 (
5656
5757 // toJSONEvent maps an event; ok is false for kinds this client does not know,
5858 // which the contract says to ignore.
59-func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
59+func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
6060 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
6161 switch k := ev.GetKind().(type) {
6262 case *ermite.Event_RepoCreated:
@@ -74,13 +74,13 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
7474 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
7575 }
7676 case *ermite.Event_MergeRequestOpened:
77- m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest())
77+ m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL)
7878 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m
7979 case *ermite.Event_MergeRequestMerged:
80- m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest())
80+ m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
8181 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
8282 case *ermite.Event_MergeRequestClosed:
83- m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest())
83+ m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
8484 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
8585 case *ermite.Event_CommentAdded:
8686 c := toJSONComment(k.CommentAdded.GetComment())
@@ -190,7 +190,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
190190 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },
191191 }
192192 return c.Watch(ctx, opts, func(ev *ermite.Event) error {
193- e, ok := toJSONEvent(ev)
193+ e, ok := toJSONEvent(ev, c.URL())
194194 if !ok {
195195 return nil
196196 }
@@ -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, &m78 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, &m81 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, &m84 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 nil195 return nil
196 }196 }
M internal/app/cli_watch_test.go +1 -1
@@ -208,7 +208,7 @@ func TestNoteWatchEvents(t *testing.T) {
208208 {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}},
209209 }
210210 for i, ev := range events {
211- got, ok := toJSONEvent(ev)
211+ got, ok := toJSONEvent(ev, "https://git.example.org")
212212 require.True(t, ok)
213213 assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type)
214214 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
6363 | `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`) |
6464 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6565
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`.
6771
6872 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.
6973
@@ -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
4949
5050 An approval is stale after a push; changes requested stay until the reviewer gives a new verdict.
5151
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+
5256 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
5357
5458 ## 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 ## Tokens58 ## 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 42

↑↓ moveenter openesc close