Commit 4bfcb3893d

4bfcb3893d5ad29d5c6ba0725fd2291d20003e30

parent: 84e1f08f23

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 01:01 UTC

control: named payloads for what other surfaces decode

issue create, issue and mr comment, mr create, issue show and mr show
declared their output inline or as a map, so the web type-asserted map
keys and nothing tied what a command emits to what a client reads.
output.go names them: Created, MRCreated, IssueShow, MRShow and the
ReviewOut, CheckOut and CommitOut rows inside. The web decodes into
them. JSON is unchanged.

Ref #126

Layout: unified · split

internal/control/issue.go +2 −5
@@ -153,7 +153,7 @@ func runIssueCreate(c *Ctx, args []string) int {
153 notifyUsers(c, targets, issueSubject(repo, n, title), 153 notifyUsers(c, targets, issueSubject(repo, n, title),
154 notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n))) 154 notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
155 } 155 }
156 return c.emit(map[string]any{"number": n}, func(w io.Writer) { 156 return c.emit(Created{Number: n}, func(w io.Writer) {
157 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) 157 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
158 }) 158 })
159} 159}
@@ -216,10 +216,7 @@ func runIssueShow(c *Ctx, args []string) int {
216 for _, cm := range comments { 216 for _, cm := range comments {
217 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt}) 217 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
218 } 218 }
219 d := struct { 219 d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs}
220 issueOut
221 Comments []commentOut `json:"comments,omitempty"`
222 }{issueToOut(issue, true), cs}
223 _ = repo 220 _ = repo
224 return c.emit(d, func(w io.Writer) { 221 return c.emit(d, func(w io.Writer) {
225 fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author) 222 fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
internal/control/mr.go +12 −38
@@ -281,16 +281,14 @@ func runMRCreate(c *Ctx, args []string) int {
281 notifyUsers(c, targets, mrSubject(repo, n, title), 281 notifyUsers(c, targets, mrSubject(repo, n, title),
282 notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n))) 282 notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n)))
283 } 283 }
284 out := map[string]any{"number": n, "head_sha": headSHA} 284 out := MRCreated{Number: n, HeadSHA: headSHA}
285 var parent *stackRef
286 if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok { 285 if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok {
287 parent = &stackRef{p.Number, p.Title} 286 out.StackedOn = &stackRef{p.Number, p.Title}
288 out["stacked_on"] = parent
289 } 287 }
290 return c.emit(out, func(w io.Writer) { 288 return c.emit(out, func(w io.Writer) {
291 fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target) 289 fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
292 if parent != nil { 290 if out.StackedOn != nil {
293 fmt.Fprintf(w, "stacked on !%d %s\n", parent.Number, parent.Title) 291 fmt.Fprintf(w, "stacked on !%d %s\n", out.StackedOn.Number, out.StackedOn.Title)
294 } 292 }
295 }) 293 })
296} 294}
@@ -449,22 +447,9 @@ func runMRShow(c *Ctx, args []string) int {
449 if err != nil { 447 if err != nil {
450 return c.fail(protocol.ExitFailure, "%v", err) 448 return c.fail(protocol.ExitFailure, "%v", err)
451 } 449 }
452 type reviewOut struct { 450 var checks []CheckOut
453 Reviewer string `json:"reviewer"`
454 Verdict string `json:"verdict"`
455 Stale bool `json:"stale"`
456 CreatedAt string `json:"created_at"`
457 }
458 type checkOut struct {
459 Context string `json:"context"`
460 State string `json:"state"`
461 URL string `json:"url,omitempty"`
462 UpdatedAt string `json:"updated_at"`
463 Duration string `json:"duration,omitempty"` // CI checks only, once finished
464 }
465 var checks []checkOut
466 for _, st := range statuses { 451 for _, st := range statuses {
467 out := checkOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt} 452 out := CheckOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt}
468 if st.Duration > 0 { 453 if st.Duration > 0 {
469 out.Duration = st.Duration.String() 454 out.Duration = st.Duration.String()
470 } 455 }
@@ -474,16 +459,12 @@ func runMRShow(c *Ctx, args []string) int {
474 for _, cm := range comments { 459 for _, cm := range comments {
475 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt}) 460 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
476 } 461 }
477 var rs []reviewOut 462 var rs []ReviewOut
478 for _, r := range reviews { 463 for _, r := range reviews {
479 rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale, r.CreatedAt}) 464 rs = append(rs, ReviewOut{r.Reviewer, r.Verdict, r.Stale, r.CreatedAt})
480 } 465 }
481 // The commits this MR carries: base..head, the diff's range. 466 // The commits this MR carries: base..head, the diff's range.
482 type commitOut struct { 467 var commits []CommitOut
483 SHA string `json:"sha"`
484 Subject string `json:"subject"`
485 }
486 var commits []commitOut
487 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) 468 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
488 base := mr.MergedBase 469 base := mr.MergedBase
489 if base == "" { 470 if base == "" {
@@ -500,19 +481,12 @@ func runMRShow(c *Ctx, args []string) int {
500 subject = parsed.Subject 481 subject = parsed.Subject
501 } 482 }
502 } 483 }
503 commits = append(commits, commitOut{sha, subject}) 484 commits = append(commits, CommitOut{sha, subject})
504 } 485 }
505 } 486 }
506 } 487 }
507 d := struct { 488 d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined,
508 mrOut 489 UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs}
509 Checks []checkOut `json:"checks,omitempty"`
510 Combined string `json:"checks_combined,omitempty"`
511 UnresolvedThreads int `json:"unresolved_threads,omitempty"`
512 Commits []commitOut `json:"commits,omitempty"`
513 Comments []commentOut `json:"comments,omitempty"`
514 Reviews []reviewOut `json:"reviews,omitempty"`
515 }{mrToOut(repo, mr, true), checks, combined, unresolved, commits, cs, rs}
516 d.StackedOn, d.Stacked = stackOf(c, repo, mr) 490 d.StackedOn, d.Stacked = stackOf(c, repo, mr)
517 return c.emit(d, func(w io.Writer) { 491 return c.emit(d, func(w io.Writer) {
518 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA) 492 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
internal/control/output.go added +57
@@ -0,0 +1,57 @@
1package control
2
3// Named payloads for the commands other surfaces decode. A command used
4// to declare its output inline, so the web type-asserted map keys and
5// nothing tied what a command emits to what a client reads (#126).
6
7// Created is what issue create, issue comment and mr comment emit.
8type Created struct {
9 Number int64 `json:"number"`
10}
11
12// MRCreated is what mr create emits.
13type MRCreated struct {
14 Number int64 `json:"number"`
15 HeadSHA string `json:"head_sha"`
16 StackedOn *stackRef `json:"stacked_on,omitempty"`
17}
18
19// IssueShow is issue show's payload: the issue and its comments.
20type IssueShow struct {
21 issueOut
22 Comments []commentOut `json:"comments,omitempty"`
23}
24
25// MRShow is mr show's payload.
26type MRShow struct {
27 mrOut
28 Checks []CheckOut `json:"checks,omitempty"`
29 Combined string `json:"checks_combined,omitempty"`
30 UnresolvedThreads int `json:"unresolved_threads,omitempty"`
31 Commits []CommitOut `json:"commits,omitempty"`
32 Comments []commentOut `json:"comments,omitempty"`
33 Reviews []ReviewOut `json:"reviews,omitempty"`
34}
35
36// ReviewOut is one review on a merge request.
37type ReviewOut struct {
38 Reviewer string `json:"reviewer"`
39 Verdict string `json:"verdict"`
40 Stale bool `json:"stale"`
41 CreatedAt string `json:"created_at"`
42}
43
44// CheckOut is one commit status on a merge request head.
45type CheckOut struct {
46 Context string `json:"context"`
47 State string `json:"state"`
48 URL string `json:"url,omitempty"`
49 UpdatedAt string `json:"updated_at"`
50 Duration string `json:"duration,omitempty"` // CI checks only, once finished
51}
52
53// CommitOut is one commit a merge request carries.
54type CommitOut struct {
55 SHA string `json:"sha"`
56 Subject string `json:"subject"`
57}
internal/control/thread.go +1 −1
@@ -110,7 +110,7 @@ func runComment(c *Ctx, args []string, t thread, noun string,
110 notifyUsers(c, parts, subject, 110 notifyUsers(c, parts, subject,
111 notifyBody(c, fmt.Sprintf("commented on %s%d", t.symbol, number), body, fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number))) 111 notifyBody(c, fmt.Sprintf("commented on %s%d", t.symbol, number), body, fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)))
112 } 112 }
113 return c.emit(map[string]any{"number": number}, func(w io.Writer) { 113 return c.emit(Created{Number: number}, func(w io.Writer) {
114 fmt.Fprintf(w, "commented on %s%s%d\n", repo.Path(), t.symbol, number) 114 fmt.Fprintf(w, "commented on %s%s%d\n", repo.Path(), t.symbol, number)
115 }) 115 })
116} 116}
internal/httpd/accounts.go +3 −2
@@ -268,12 +268,13 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
268func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 268func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
269 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 269 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
270 title := strings.TrimSpace(r.FormValue("title")) 270 title := strings.TrimSpace(r.FormValue("title"))
271 code, data, msg := s.dispatchJSON(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body")) 271 var created control.Created
272 code, msg := s.dispatchIntoStdin(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"), &created)
272 if code != protocol.ExitOK { 273 if code != protocol.ExitOK {
273 http.Error(w, msg, statusForExit(code)) 274 http.Error(w, msg, statusForExit(code))
274 return 275 return
275 } 276 }
276 n := int64(data["number"].(float64)) 277 n := created.Number
277 // Labels need write access, matching the SSH rule; the command refuses 278 // Labels need write access, matching the SSH rule; the command refuses
278 // otherwise and the issue stands without them. 279 // otherwise and the issue stands without them.
279 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 { 280 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
internal/httpd/control.go +7 −1
@@ -113,6 +113,12 @@ func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (co
113} 113}
114 114
115func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) { 115func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
116 return s.dispatchIntoStdin(u, argv, "", target)
117}
118
119// dispatchIntoStdin is dispatchInto with a body on stdin, decoding the
120// command's named payload rather than a map (#126).
121func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, target any) (int, string) {
116 var stdout, stderr bytes.Buffer 122 var stdout, stderr bytes.Buffer
117 ctx := &control.Ctx{ 123 ctx := &control.Ctx{
118 User: u, 124 User: u,
@@ -120,7 +126,7 @@ func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, str
120 Scope: "full", 126 Scope: "full",
121 Store: s.st, 127 Store: s.st,
122 Cfg: s.cfg, 128 Cfg: s.cfg,
123 Stdin: strings.NewReader(""), 129 Stdin: strings.NewReader(stdin),
124 Stdout: &stdout, 130 Stdout: &stdout,
125 Stderr: &stderr, 131 Stderr: &stderr,
126 JSON: true, 132 JSON: true,
internal/httpd/mractions.go +4 −4
@@ -7,6 +7,7 @@ import (
7 "strconv" 7 "strconv"
8 "strings" 8 "strings"
9 9
10 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/gitutil" 11 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/store" 12 "gitbay.org/gitbay/internal/store"
12) 13)
@@ -191,11 +192,10 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
191 if body != "" { 192 if body != "" {
192 argv = append(argv, "--body", body) 193 argv = append(argv, "--body", body)
193 } 194 }
194 data, msg, ok := s.runControlJSON(u, argv) 195 var created control.MRCreated
195 if !ok { 196 if msg, ok := s.runControlInto(u, argv, &created); !ok {
196 back(msg) 197 back(msg)
197 return 198 return
198 } 199 }
199 n, _ := data["number"].(float64) 200 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", p.Repo.Path(), created.Number), http.StatusSeeOther)
200 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", p.Repo.Path(), int64(n)), http.StatusSeeOther)
201} 201}