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 {
153153 notifyUsers(c, targets, issueSubject(repo, n, title),
154154 notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
155155 }
156 return c.emit(map[string]any{"number": n}, func(w io.Writer) {
156 return c.emit(Created{Number: n}, func(w io.Writer) {
157157 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
158158 })
159159}
@@ -216,10 +216,7 @@ func runIssueShow(c *Ctx, args []string) int {
216216 for _, cm := range comments {
217217 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
218218 }
219 d := struct {
220 issueOut
221 Comments []commentOut `json:"comments,omitempty"`
222 }{issueToOut(issue, true), cs}
219 d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs}
223220 _ = repo
224221 return c.emit(d, func(w io.Writer) {
225222 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 {
281281 notifyUsers(c, targets, mrSubject(repo, n, title),
282282 notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n)))
283283 }
284 out := map[string]any{"number": n, "head_sha": headSHA}
285 var parent *stackRef
284 out := MRCreated{Number: n, HeadSHA: headSHA}
286285 if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok {
287 parent = &stackRef{p.Number, p.Title}
288 out["stacked_on"] = parent
286 out.StackedOn = &stackRef{p.Number, p.Title}
289287 }
290288 return c.emit(out, func(w io.Writer) {
291289 fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
292 if parent != nil {
293 fmt.Fprintf(w, "stacked on !%d %s\n", parent.Number, parent.Title)
290 if out.StackedOn != nil {
291 fmt.Fprintf(w, "stacked on !%d %s\n", out.StackedOn.Number, out.StackedOn.Title)
294292 }
295293 })
296294}
@@ -449,22 +447,9 @@ func runMRShow(c *Ctx, args []string) int {
449447 if err != nil {
450448 return c.fail(protocol.ExitFailure, "%v", err)
451449 }
452 type reviewOut struct {
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
450 var checks []CheckOut
466451 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}
468453 if st.Duration > 0 {
469454 out.Duration = st.Duration.String()
470455 }
@@ -474,16 +459,12 @@ func runMRShow(c *Ctx, args []string) int {
474459 for _, cm := range comments {
475460 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
476461 }
477 var rs []reviewOut
462 var rs []ReviewOut
478463 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})
480465 }
481466 // The commits this MR carries: base..head, the diff's range.
482 type commitOut struct {
483 SHA string `json:"sha"`
484 Subject string `json:"subject"`
485 }
486 var commits []commitOut
467 var commits []CommitOut
487468 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
488469 base := mr.MergedBase
489470 if base == "" {
@@ -500,19 +481,12 @@ func runMRShow(c *Ctx, args []string) int {
500481 subject = parsed.Subject
501482 }
502483 }
503 commits = append(commits, commitOut{sha, subject})
484 commits = append(commits, CommitOut{sha, subject})
504485 }
505486 }
506487 }
507 d := struct {
508 mrOut
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}
488 d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined,
489 UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs}
516490 d.StackedOn, d.Stacked = stackOf(c, repo, mr)
517491 return c.emit(d, func(w io.Writer) {
518492 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,
110110 notifyUsers(c, parts, subject,
111111 notifyBody(c, fmt.Sprintf("commented on %s%d", t.symbol, number), body, fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)))
112112 }
113 return c.emit(map[string]any{"number": number}, func(w io.Writer) {
113 return c.emit(Created{Number: number}, func(w io.Writer) {
114114 fmt.Fprintf(w, "commented on %s%s%d\n", repo.Path(), t.symbol, number)
115115 })
116116}
internal/httpd/accounts.go +3 −2
@@ -268,12 +268,13 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
268268func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
269269 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
270270 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)
272273 if code != protocol.ExitOK {
273274 http.Error(w, msg, statusForExit(code))
274275 return
275276 }
276 n := int64(data["number"].(float64))
277 n := created.Number
277278 // Labels need write access, matching the SSH rule; the command refuses
278279 // otherwise and the issue stands without them.
279280 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
113113}
114114
115115func (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) {
116122 var stdout, stderr bytes.Buffer
117123 ctx := &control.Ctx{
118124 User: u,
@@ -120,7 +126,7 @@ func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, str
120126 Scope: "full",
121127 Store: s.st,
122128 Cfg: s.cfg,
123 Stdin: strings.NewReader(""),
129 Stdin: strings.NewReader(stdin),
124130 Stdout: &stdout,
125131 Stderr: &stderr,
126132 JSON: true,
internal/httpd/mractions.go +4 −4
@@ -7,6 +7,7 @@ import (
77 "strconv"
88 "strings"
99
10 "gitbay.org/gitbay/internal/control"
1011 "gitbay.org/gitbay/internal/gitutil"
1112 "gitbay.org/gitbay/internal/store"
1213)
@@ -191,11 +192,10 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
191192 if body != "" {
192193 argv = append(argv, "--body", body)
193194 }
194 data, msg, ok := s.runControlJSON(u, argv)
195 if !ok {
195 var created control.MRCreated
196 if msg, ok := s.runControlInto(u, argv, &created); !ok {
196197 back(msg)
197198 return
198199 }
199 n, _ := data["number"].(float64)
200 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", p.Repo.Path(), int64(n)), http.StatusSeeOther)
200 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", p.Repo.Path(), created.Number), http.StatusSeeOther)
201201}