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

https://gitbay.org

Commit 7d5f1337c7

7d5f1337c7a9bd24266794db58077b3feeef34c3

parent: 06f7e4dfaa

Verified · cmc

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

control: mr label, and mr list --label

mr label <owner/name> <n> [--add <l>]... [--remove <l>]... mirrors issue
label: write access, archived refused, an mr.labeled event carrying the
resulting set. mr show prints the labels after the state line and mrOut
carries them.

Ref #231
.gitbay/wiki/API.org +2 −2
@@ -146,8 +146,8 @@ silently never fire.
146146 =issue.assigned= (assignees), =issue.milestoned= (milestone)
147147- merge requests: =mr.created=, =mr.edited=, =mr.commented=,
148148 =mr.reviewed= (verdict), =mr.draft= (draft), =mr.retargeted= (from,
149 to), =mr.milestoned= (milestone), =mr.merged= (number, sha),
150 =mr.closed=
149 to), =mr.labeled= (labels), =mr.milestoned= (milestone), =mr.merged=
150 (number, sha), =mr.closed=
151151- releases: =release.created= (tag), =release.deleted= (tag)
152152- CI: =status=, =build.success=, =build.failure=, =build.cancelled=
153153
cmd/gitbay/main.go +2 −1
@@ -580,7 +580,7 @@ func mrCmd() *cobra.Command {
580580 return group("mr", "merge requests",
581581 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
582582 passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request", inferSource: true}),
583 pass("list", "list merge requests [--state ...] [--author u] [--milestone m|none] [--limit n] [--cursor c]", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
583 pass("list", "list merge requests [--state ...] [--label l] [--author u] [--milestone m|none] [--limit n] [--cursor c]", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
584584 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
585585 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
586586 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
@@ -598,6 +598,7 @@ func mrCmd() *cobra.Command {
598598 pass("draft", "mark as work in progress", passOpts{server: []string{"mr", "draft"}, needsRepo: true}),
599599 pass("ready", "take the draft mark off, so it can merge", passOpts{server: []string{"mr", "ready"}, needsRepo: true}),
600600 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
601 pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"mr", "label"}, needsRepo: true}),
601602 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
602603 pass("retarget", "retarget onto another branch: <n> <branch>", passOpts{server: []string{"mr", "retarget"}, needsRepo: true}),
603604 )
internal/control/events.go +1
@@ -33,6 +33,7 @@ var EventKinds = []string{
3333 "mr.created",
3434 "mr.draft",
3535 "mr.edited",
36 "mr.labeled",
3637 "mr.merged",
3738 "mr.milestoned",
3839 "mr.retargeted",
internal/control/mr.go +59 −13
@@ -59,7 +59,7 @@ func init() {
5959 Usage: "mr ready <owner/name> <n>", Run: runMRReady})
6060 register(Command{Path: []string{"mr", "list"},
6161 Summary: "list merge requests",
62 Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList})
62 Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--label <l>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList})
6363 register(Command{Path: []string{"mr", "show"},
6464 Summary: "show a merge request",
6565 Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
@@ -83,6 +83,9 @@ func init() {
8383 register(Command{Path: []string{"mr", "review", "request"},
8484 Summary: "ask specific people for a review",
8585 Usage: "mr review request <owner/name> <n> [--add <user>]... [--remove <user>]...", Run: runMRReviewRequest})
86 register(Command{Path: []string{"mr", "label"},
87 Summary: "labels",
88 Usage: "mr label <owner/name> <n> [--add <l>]... [--remove <l>]...", Run: runMRLabel})
8689 register(Command{Path: []string{"mr", "merge"},
8790 Summary: "merge",
8891 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
@@ -365,14 +368,15 @@ type mrOut struct {
365368 Title string `json:"title"`
366369 State string `json:"state"`
367370 // Draft is an open merge request not asking to be merged yet.
368 Draft bool `json:"draft,omitempty"`
369 Author string `json:"author"`
370 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
371 TargetRef string `json:"target_ref"`
372 HeadSHA string `json:"head_sha"`
373 Body string `json:"body,omitempty"`
374 BodyFormat string `json:"body_format,omitempty"`
375 Milestone string `json:"milestone,omitempty"`
371 Draft bool `json:"draft,omitempty"`
372 Author string `json:"author"`
373 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
374 TargetRef string `json:"target_ref"`
375 HeadSHA string `json:"head_sha"`
376 Body string `json:"body,omitempty"`
377 BodyFormat string `json:"body_format,omitempty"`
378 Milestone string `json:"milestone,omitempty"`
379 Labels []string `json:"labels,omitempty"`
376380 // ReviewRequests is who has been asked, directly, for a review.
377381 ReviewRequests []string `json:"review_requests,omitempty"`
378382 // StackedOn is the open merge request whose source branch this one
@@ -430,8 +434,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
430434 }
431435 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Draft: m.Draft, Author: m.Author,
432436 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
433 ReviewRequests: m.ReviewRequests,
434 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
437 Labels: m.Labels, ReviewRequests: m.ReviewRequests,
438 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
435439 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy}
436440 if withBody {
437441 o.Body = m.Body
@@ -446,7 +450,7 @@ func runMRList(c *Ctx, args []string) int {
446450 return code
447451 }
448452 f := store.MRFilter{State: "open"}
449 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
453 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
450454 if err != nil {
451455 return c.fail(protocol.ExitUsage, "%v", err)
452456 }
@@ -454,7 +458,7 @@ func runMRList(c *Ctx, args []string) int {
454458 if fl.Has("--state") {
455459 f.State = fl.Value("--state")
456460 }
457 f.Author, f.Milestone = fl.Value("--author"), fl.Value("--milestone")
461 f.Label, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--author"), fl.Value("--milestone")
458462 f.Search = fl.Value("--search")
459463 if fl.Has("--search") {
460464 if err := validQuery(f.Search); err != nil {
@@ -586,6 +590,9 @@ func runMRShow(c *Ctx, args []string) int {
586590 state = "draft"
587591 }
588592 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, state, d.Author, d.Source, d.TargetRef, d.HeadSHA)
593 if len(d.Labels) > 0 {
594 fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
595 }
589596 if len(d.ReviewRequests) > 0 {
590597 fmt.Fprintf(w, "reviewers: %s\n", strings.Join(d.ReviewRequests, ", "))
591598 }
@@ -874,6 +881,45 @@ func runMRReview(c *Ctx, args []string) int {
874881// runMRReviewRequest is issue assign's counterpart for merge requests: it
875882// pushes a merge request into a specific person's review queue and inbox
876883// 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
877923func runMRReviewRequest(c *Ctx, args []string) int {
878924 rest, adds, removes, err := addRemoveFlags(args)
879925 if err != nil {
internal/control/orgscope_test.go +51
@@ -174,3 +174,54 @@ func TestDuplicateMilestoneTitleFails(t *testing.T) {
174174 t.Fatalf("duplicate org create: exit %d %s", code, out.String())
175175 }
176176}
177
178// mr label mirrors issue label: a name the org holds resolves to the org's
179// row, one neither scope has is created in the repository, removing what
180// is not there is not found, and someone with read only is denied (#231).
181func TestMRLabelCommandMirrorsIssueLabel(t *testing.T) {
182 f := newOrgFixture(t)
183 if _, err := f.st.SetOrgLabel(f.org, "bug", ""); err != nil {
184 t.Fatal(err)
185 }
186 if _, err := f.st.CreateMR(f.core.ID, f.alice, f.core.ID, "topic", "main", "c1", "", "deadbeef", "md", false); err != nil {
187 t.Fatal(err)
188 }
189 c, out := f.ctx(f.alice)
190 if code := runMRLabel(c, []string{"acme/core", "1", "--add", "bug", "--add", "docs"}); code != protocol.ExitOK ||
191 !strings.Contains(out.String(), `"labels":["bug","docs"]`) {
192 t.Fatalf("mr label --add: exit %d %s", code, out.String())
193 }
194 // bug is the org's one row; docs was created in the repository.
195 var n int
196 f.st.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug'").Scan(&n)
197 if n != 1 {
198 t.Fatalf("labels named bug: %d, want 1", n)
199 }
200 if l, err := f.st.LabelByName(f.core, "docs"); err != nil || l.Org {
201 t.Fatalf("docs = %+v, %v", l, err)
202 }
203 out.Reset()
204 if code := runMRList(c, []string{"acme/core", "--label", "bug"}); code != protocol.ExitOK ||
205 !strings.Contains(out.String(), `"number":1`) {
206 t.Fatalf("mr list --label bug: exit %d %s", code, out.String())
207 }
208 out.Reset()
209 if code := runMRList(c, []string{"acme/core", "--label", "nope"}); code != protocol.ExitOK ||
210 strings.Contains(out.String(), `"number":1`) {
211 t.Fatalf("mr list --label nope: exit %d %s", code, out.String())
212 }
213 out.Reset()
214 if code := runMRLabel(c, []string{"acme/core", "1", "--remove", "docs"}); code != protocol.ExitOK ||
215 !strings.Contains(out.String(), `"labels":["bug"]`) {
216 t.Fatalf("mr label --remove: exit %d %s", code, out.String())
217 }
218 out.Reset()
219 if code := runMRLabel(c, []string{"acme/core", "1", "--remove", "docs"}); code != protocol.ExitNotFound {
220 t.Fatalf("mr label --remove of an absent label: exit %d %s", code, out.String())
221 }
222 // carol reads acme/core and writes nothing.
223 rc, rout := f.ctx(f.carol)
224 if code := runMRLabel(rc, []string{"acme/core", "1", "--add", "bug"}); code != protocol.ExitDenied {
225 t.Fatalf("reader: exit %d %s", code, rout.String())
226 }
227}