A CLI-first git forge. cli forge git self-hosted

https://gitbay.org

Commit e5dee0c2a4

e5dee0c2a4a8b36de5250096240d4355060384ba

parent: c6c5cb3409

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

cmc <hello@cleberg.net> · 2026-09-19 03:20 UTC

Review fixes: label counts, comment placement, wrapping

runMRLabel sat between runMRReviewRequest's doc comment and its body;
each comment is on its own function again. label list and org label list
print the merge request count beside the issue count, as the labels pages
do, and label remove's summary says what it takes the label off. The
mr_labels cascade from a deleted label is covered. Migration 0056 records
that labels now has two child tables. Users.org refilled to 71 columns.

Ref #231
.gitbay/wiki/Users.org +7 −8
@@ -372,11 +372,10 @@ gitbay milestone close v1.0 / reopen v1.0
372372An org holds labels and milestones every repository under it sees
373373beside its own. =issue label --add=, =mr label --add=, =issue
374374milestone= and =mr milestone= resolve the org's row first; a repository
375cannot create a
376label or milestone with a name its org holds. Creating an org label or
377milestone whose name repositories under the org already use folds them
378in: their issues and merge requests move to the org's row. Org admins
379manage them; counts span the repositories you can read.
375cannot create a label or milestone with a name its org holds. Creating
376an org label or milestone whose name repositories under the org already
377use folds them in: their issues and merge requests move to the org's
378row. Org admins manage them; counts span the repositories you can read.
380379
381380#+begin_src sh
382381gitbay org label set acme bug --color cf222e
@@ -395,9 +394,9 @@ form prefills its textarea, and =gitbay issue templates= lists them.
395394
396395Lists narrow the same way on every surface: =issue list --label bug
397396--assignee bob --author alice --milestone v1= (or =--milestone none=),
398=mr list --label bug --author bob --milestone v1=; the web's issue and merge request
399lists take the same names as query parameters, and each active filter
400shows with a link that drops it.
397=mr list --label bug --author bob --milestone v1=; the web's issue and
398merge request lists take the same names as query parameters, and each
399active filter shows with a link that drops it.
401400
402401Labels take a colour: =gitbay label set bug --color cf222e=; =label
403402list= shows each with its colour and how many issues and merge requests
e2e/label_test.go +3 −2
@@ -23,7 +23,7 @@ func TestLabelColors(t *testing.T) {
2323 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "label", "alice/app", "1", "--add", "bug"); code != 0 {
2424 t.Fatal("issue label failed")
2525 }
26 if out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app"); !strings.Contains(out, "bug\t\t1") {
26 if out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app"); !strings.Contains(out, "bug\t\t1\t0") {
2727 t.Fatalf("list after issue label:\n%s", out)
2828 }
2929 if _, _, code := inst.ssh(t, bobKey, "", "label", "set", "alice/app", "bug", "--color", "cf222e"); code != 4 {
@@ -39,7 +39,8 @@ func TestLabelColors(t *testing.T) {
3939 t.Fatal("create without colour failed")
4040 }
4141 out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json")
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1}`) || !strings.Contains(out, `{"name":"docs","issues":0}`) {
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1,"mrs":0}`) ||
43 !strings.Contains(out, `{"name":"docs","issues":0,"mrs":0}`) {
4344 t.Fatalf("list json:\n%s", out)
4445 }
4546 // The web paints the chip with the stored colour.
internal/control/label.go +3 −3
@@ -14,13 +14,13 @@ import (
1414
1515func init() {
1616 register(Command{Path: []string{"label", "list"},
17 Summary: "list a repository's issue labels with colour and use",
17 Summary: "list a repository's labels with colour and use",
1818 Usage: "label list <owner/name>", ReadOnly: true, Run: runLabelList})
1919 register(Command{Path: []string{"label", "set"},
2020 Summary: "create a label or set its colour",
2121 Usage: "label set <owner/name> <label> [--color rrggbb|'']", Run: runLabelSet})
2222 register(Command{Path: []string{"label", "remove"},
23 Summary: "remove a label from the repository and from every issue",
23 Summary: "remove a label from the repository and from every issue and merge request",
2424 Usage: "label remove <owner/name> <label>", Run: runLabelRemove})
2525}
2626
@@ -47,7 +47,7 @@ func runLabelList(c *Ctx, args []string) int {
4747 }
4848 return c.emit(labels, func(w io.Writer) {
4949 for _, l := range labels {
50 fmt.Fprintf(w, "%s\t%s\t%d%s\n", l.Name, l.Color, l.Issues, map[bool]string{true: "\torg"}[l.Org])
50 fmt.Fprintf(w, "%s\t%s\t%d\t%d%s\n", l.Name, l.Color, l.Issues, l.MRs, map[bool]string{true: "\torg"}[l.Org])
5151 }
5252 })
5353}
internal/control/mr.go +41 −39
@@ -881,45 +881,6 @@ func runMRReview(c *Ctx, args []string) int {
881881// runMRReviewRequest is issue assign's counterpart for merge requests: it
882882// pushes a merge request into a specific person's review queue and inbox
883883// directly, rather than waiting for them to be otherwise involved (#145).
884func runMRLabel(c *Ctx, args []string) int {
885 rest, adds, removes, err := addRemoveFlags(args)
886 if err != nil {
887 return c.failInput(err)
888 }
889 if len(adds)+len(removes) == 0 {
890 return c.usage()
891 }
892 repo, mr, code := mrRef(c, rest, policy.CanWrite)
893 if code >= 0 {
894 return code
895 }
896 if code := refuseArchived(c, repo); code >= 0 {
897 return code
898 }
899 for _, l := range adds {
900 if err := c.Store.SetMRLabel(repo, mr.ID, l, true); err != nil {
901 return c.fail(protocol.ExitFailure, "%v", err)
902 }
903 }
904 for _, l := range removes {
905 if err := c.Store.SetMRLabel(repo, mr.ID, l, false); err != nil {
906 if errors.Is(err, store.ErrNotFound) {
907 return c.fail(protocol.ExitNotFound, "%v", err)
908 }
909 return c.fail(protocol.ExitFailure, "%v", err)
910 }
911 }
912 updated, err := c.Store.MRByNumber(repo.ID, mr.Number)
913 if err != nil {
914 return c.fail(protocol.ExitFailure, "%v", err)
915 }
916 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.labeled",
917 fmt.Sprintf(`{"number":%d,"labels":%s}`, mr.Number, jsonStrings(updated.Labels)))
918 return c.emit(map[string]any{"number": mr.Number, "labels": updated.Labels}, func(w io.Writer) {
919 fmt.Fprintf(w, "labels on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.Labels, ", "))
920 })
921}
922
923884func runMRReviewRequest(c *Ctx, args []string) int {
924885 rest, adds, removes, err := addRemoveFlags(args)
925886 if err != nil {
@@ -1004,6 +965,47 @@ func runMRReviewRequest(c *Ctx, args []string) int {
1004965 })
1005966}
1006967
968// runMRLabel is issue label's counterpart for merge requests: the label
969// set is the repository's (or its org's), shared with the issues (#231).
970func runMRLabel(c *Ctx, args []string) int {
971 rest, adds, removes, err := addRemoveFlags(args)
972 if err != nil {
973 return c.failInput(err)
974 }
975 if len(adds)+len(removes) == 0 {
976 return c.usage()
977 }
978 repo, mr, code := mrRef(c, rest, policy.CanWrite)
979 if code >= 0 {
980 return code
981 }
982 if code := refuseArchived(c, repo); code >= 0 {
983 return code
984 }
985 for _, l := range adds {
986 if err := c.Store.SetMRLabel(repo, mr.ID, l, true); err != nil {
987 return c.fail(protocol.ExitFailure, "%v", err)
988 }
989 }
990 for _, l := range removes {
991 if err := c.Store.SetMRLabel(repo, mr.ID, l, false); err != nil {
992 if errors.Is(err, store.ErrNotFound) {
993 return c.fail(protocol.ExitNotFound, "%v", err)
994 }
995 return c.fail(protocol.ExitFailure, "%v", err)
996 }
997 }
998 updated, err := c.Store.MRByNumber(repo.ID, mr.Number)
999 if err != nil {
1000 return c.fail(protocol.ExitFailure, "%v", err)
1001 }
1002 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.labeled",
1003 fmt.Sprintf(`{"number":%d,"labels":%s}`, mr.Number, jsonStrings(updated.Labels)))
1004 return c.emit(map[string]any{"number": mr.Number, "labels": updated.Labels}, func(w io.Writer) {
1005 fmt.Fprintf(w, "labels on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.Labels, ", "))
1006 })
1007}
1008
10071009func runMRMerge(c *Ctx, args []string) int {
10081010 f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
10091011 if err != nil {
internal/control/orglabel.go +1 −1
@@ -129,7 +129,7 @@ func runOrgLabelList(c *Ctx, args []string) int {
129129 }
130130 return c.emit(labels, func(w io.Writer) {
131131 for _, l := range labels {
132 fmt.Fprintf(w, "%s\t%s\t%d\n", l.Name, l.Color, l.Issues)
132 fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", l.Name, l.Color, l.Issues, l.MRs)
133133 }
134134 })
135135}
internal/store/labels_test.go +10
@@ -260,6 +260,16 @@ func TestSetOrgLabelFoldsMRLabels(t *testing.T) {
260260 if len(rows) != 1 || rows[0].MRs != 2 {
261261 t.Fatalf("org label rows = %+v", rows)
262262 }
263 // Deleting the label takes it off every merge request: mr_labels
264 // cascades from labels, nothing unlinks them by hand.
265 if err := f.s.DeleteOrgLabel(f.org, "bug"); err != nil {
266 t.Fatal(err)
267 }
268 for _, repo := range []Repo{f.core, f.site} {
269 if got, err := f.s.ListMRLabels(repo); err != nil || len(got) != 0 {
270 t.Fatalf("%s MR labels after delete = %v, %v", repo.Name, got, err)
271 }
272 }
263273}
264274
265275func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) {
internal/store/migrations/0056_mr_labels.up.sql +5
@@ -1,5 +1,10 @@
11-- Labels on merge requests, carried the same way issues carry them
22-- (#231). The label rows themselves are shared: repo or org scoped.
3--
4-- labels now has two child tables, issue_labels and mr_labels: a future
5-- rebuild of labels must carry both. With foreign keys on, rebuilding a
6-- parent table drops its children's rows, which is what the first-line
7-- "-- foreign_keys: off" directive in 0052 is for.
38CREATE TABLE mr_labels (
49 mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
510 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
internal/web/templates/orglabels.html +2 −1
@@ -3,11 +3,12 @@
33<h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1>
44<p class="meta">Every repository under {{.Org}} sees these beside its own. Managed with <code>gitbay org label set {{.Org}} &lt;label&gt;</code>; counts span the repositories you can read.</p>
55{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th></tr>
6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col">merge requests</th></tr>
77{{range .Labels}}<tr>
88 <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td>
99 <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td>
1010 <td>{{.Issues}}</td>
11 <td>{{.MRs}}</td>
1112</tr>
1213{{end}}</table></div>
1314{{else}}<p class="none">No org labels yet.</p>{{end}}