mr: labels on merge requests !424

merged merged by cmc on 2026-09-19 03:35 UTC · krz/gitbay:mr-labels into main

25 files changed, +538 −129

Layout: unified · split

.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
.gitbay/wiki/Parity.org +9 −6
@@ -48,6 +48,8 @@ browser-only and the iOS build screen unable to say more than the log.
4848| create from a fork | yes | yes | yes |
4949| retarget | yes | yes | yes |
5050| milestone | yes | yes | yes |
51| labels | yes | yes | no |
52| filter by label | yes | yes | no |
5153| request a review | yes | yes | yes |
5254| choose body markup | yes | yes | yes |
5355| stacked merge requests | yes | yes | yes |
@@ -118,12 +120,13 @@ reviews, since an approval was of the diff against the old branch.
118120| org milestones: create, list, close, reopen | yes | list | yes |
119121| closes across repositories | yes | yes | yes |
120122
121Labels are created on the fly by =issue label --add= and managed by
122=label list=, =label set <label> --color rrggbb= and =label remove=,
123which takes the label off every issue. The web paints the stored colour
124on every chip and derives one from the name when none is set. The set
125itself is at =/<owner>/<repo>/labels=, linked from the issue list:
126create, recolour and remove, dispatching the same commands.
123Labels are created on the fly by =issue label --add= and =mr label
124--add=, and managed by =label list=, =label set <label> --color rrggbb=
125and =label remove=, which takes the label off every issue and merge
126request. One set serves both. The web paints the stored colour on every
127chip and derives one from the name when none is set. The set itself is
128at =/<owner>/<repo>/labels=, linked from the issue list: create,
129recolour and remove, dispatching the same commands.
127130
128131Org labels and milestones are managed on the CLI, the API and the iOS
129132client's org screen; =/<org>/-/labels= and =/<org>/-/milestones= show
.gitbay/wiki/Users.org +13 −12
@@ -370,12 +370,12 @@ gitbay milestone close v1.0 / reopen v1.0
370370#+end_src
371371
372372An org holds labels and milestones every repository under it sees
373beside its own. =issue label --add=, =issue milestone= and =mr
374milestone= resolve the org's row first; a repository cannot create a
375label or milestone with a name its org holds. Creating an org label or
376milestone whose name repositories under the org already use folds them
377in: their issues and merge requests move to the org's row. Org admins
378manage them; counts span the repositories you can read.
373beside its own. =issue label --add=, =mr label --add=, =issue
374milestone= and =mr milestone= resolve the org's row first; a repository
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.
379379
380380#+begin_src sh
381381gitbay org label set acme bug --color cf222e
@@ -394,14 +394,14 @@ form prefills its textarea, and =gitbay issue templates= lists them.
394394
395395Lists narrow the same way on every surface: =issue list --label bug
396396--assignee bob --author alice --milestone v1= (or =--milestone none=),
397=mr list --author bob --milestone v1=; the web's issue and merge request
398lists take the same names as query parameters, and each active filter
399shows 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.
400400
401401Labels take a colour: =gitbay label set bug --color cf222e=; =label
402list= shows each with its colour and how many issues carry it, and
403=label remove= takes one off every issue. =issue label --add= still
404creates a colourless label on the fly.
402list= shows each with its colour and how many issues and merge requests
403carry it, and =label remove= takes one off all of them. =issue label
404--add= and =mr label --add= still create a colourless label on the fly.
405405
406406* Merge requests
407407
@@ -411,6 +411,7 @@ gitbay mr create other/upstream --source you/fork:feature --target main --title
411411gitbay mr list / show 4 / diff 4
412412gitbay mr checkout 4 # local branch mr/4 from the MR head
413413gitbay mr review 4 --approve # or --request-changes / --comment
414gitbay mr label 4 --add bug --remove wontfix
414415gitbay mr merge 4 [--strategy ff|merge|squash|rebase]
415416gitbay mr close 4
416417#+end_src
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 )
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.
e2e/labelweb_test.go +2 −2
@@ -39,8 +39,8 @@ func TestLabelsWeb(t *testing.T) {
3939 t.Fatal("label recolour failed")
4040 }
4141 out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json")
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1}`) ||
43 !strings.Contains(out, `{"name":"docs","color":"#1f6feb","issues":0}`) {
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1,"mrs":0}`) ||
43 !strings.Contains(out, `{"name":"docs","color":"#1f6feb","issues":0,"mrs":0}`) {
4444 t.Fatalf("labels not as posted:\n%s", out)
4545 }
4646
e2e/mrweb_test.go +82
@@ -502,3 +502,85 @@ func TestMRSupersedes(t *testing.T) {
502502 t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2)
503503 }
504504}
505
506// TestMRWebLabels labels a merge request from the browser and filters the
507// list by it (#231). The CLI is the check that the page dispatched
508// mr label rather than writing its own rows.
509func TestMRWebLabels(t *testing.T) {
510 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
511 aliceKey := inst.newKey(t, "alice")
512 bobKey := inst.newKey(t, "bob")
513 inst.admin(t, "admin", "user", "create", "alice",
514 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
515 inst.admin(t, "admin", "user", "create", "bob",
516 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
517 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
518 t.Fatalf("repo create: %s", errOut)
519 }
520 env := inst.gitEnv(aliceKey)
521 work := t.TempDir()
522 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
523 dir := filepath.Join(work, "w")
524 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
525 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
526 mustGit(t, dir, env, "add", ".")
527 mustGit(t, dir, env, "commit", "-q", "-m", "base")
528 mustGit(t, dir, env, "push", "-q", "origin", "main")
529 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
530 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
531 mustGit(t, dir, env, "add", ".")
532 mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
533 mustGit(t, dir, env, "push", "-q", "origin", "topic")
534 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
535 "--source", "topic", "--target", "main", "--title", "feature"); code != 0 {
536 t.Fatalf("mr create: %s", errOut)
537 }
538
539 alice := inst.login(t, aliceKey)
540 mrURL := inst.base() + "/alice/app/mrs/1"
541
542 // The form is on the page, and applying it lands in the CLI's view.
543 if _, body := browserGet(t, alice, mrURL); !strings.Contains(body, `/mrs/1/label`) {
544 t.Fatalf("MR page has no label form:\n%s", body)
545 }
546 if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"add": {"bug ui"}}); status != 200 {
547 t.Fatalf("label post: %d", status)
548 }
549 out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
550 for _, want := range []string{`"bug"`, `"ui"`} {
551 if !strings.Contains(out, want) {
552 t.Fatalf("label %s did not land:\n%s", want, out)
553 }
554 }
555 _, body := browserGet(t, alice, mrURL)
556 if n := strings.Count(body, `class="chip label"`); n != 2 {
557 t.Fatalf("MR page shows %d label chips, want 2:\n%s", n, body)
558 }
559
560 // Removing works the same way.
561 if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"remove": {"ui"}}); status != 200 {
562 t.Fatalf("label remove: %d", status)
563 }
564 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); strings.Contains(out, `"ui"`) {
565 t.Fatalf("label not removed:\n%s", out)
566 }
567
568 // The list narrows by label, and says which one it is narrowed by.
569 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=bug")
570 if !strings.Contains(body, ">feature<") || !strings.Contains(body, `label: <span class="chip label"`) {
571 t.Fatalf("web label filter:\n%s", body)
572 }
573 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=ui")
574 if strings.Contains(body, ">feature<") {
575 t.Fatalf("removed label still lists the merge request:\n%s", body)
576 }
577
578 // A reader gets the chips and no form.
579 _, body = browserGet(t, inst.login(t, bobKey), mrURL)
580 if !strings.Contains(body, `class="chip label"`) {
581 t.Fatalf("reader sees no labels:\n%s", body)
582 }
583 if strings.Contains(body, `/mrs/1/label`) {
584 t.Fatalf("reader sees the label form:\n%s", body)
585 }
586}
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/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 +61 −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 }
@@ -958,6 +965,47 @@ func runMRReviewRequest(c *Ctx, args []string) int {
958965 })
959966}
960967
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
9611009func runMRMerge(c *Ctx, args []string) int {
9621010 f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
9631011 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/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}
internal/httpd/mractions.go +10
@@ -66,6 +66,16 @@ func (s *Server) mrReviewRequestSubmit(w http.ResponseWriter, r *http.Request, u
6666 s.done(w, r, code, msg, s.mrRedirect)
6767}
6868
69func (s *Server) mrLabelSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
70 args := append(fieldArgs("--add", r.FormValue("add")), fieldArgs("--remove", r.FormValue("remove"))...)
71 if len(args) == 0 {
72 s.mrRedirect(w, r, "name at least one label")
73 return
74 }
75 _, msg, code := s.runControlCode(u, mrArgs(r, "label", args...))
76 s.done(w, r, code, msg, s.mrRedirect)
77}
78
6979func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
7080 args := []string{}
7181 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" {
internal/httpd/routes.go +2
@@ -202,6 +202,8 @@ func (s *Server) Routes() []Route {
202202 Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))},
203203 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review-request", Mutating: true,
204204 Handler: s.checkOrigin(s.requireUser(s.mrReviewRequestSubmit))},
205 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/label", Mutating: true,
206 Handler: s.checkOrigin(s.requireUser(s.mrLabelSubmit))},
205207 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/merge", Mutating: true,
206208 Handler: s.checkOrigin(s.requireUser(s.mrMergeSubmit))},
207209 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/close", Mutating: true,
internal/httpd/web.go +17 −9
@@ -1814,8 +1814,8 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
18141814 state = "open"
18151815 }
18161816 qv := r.URL.Query()
1817 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1818 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1817 mf := store.MRFilter{State: state, Label: qv.Get("label"), Author: qv.Get("author"),
1818 Milestone: qv.Get("milestone"), Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
18191819 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
18201820 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
18211821 if err != nil {
@@ -1841,19 +1841,26 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
18411841 if err != nil {
18421842 comments = map[int64]int{}
18431843 }
1844 labels, err := s.st.ListMRLabels(p.Repo)
1845 if err != nil {
1846 labels = map[int64][]string{}
1847 }
18441848 rows := make([]mrRow, len(mrs))
18451849 for i, m := range mrs {
1850 m.Labels = labels[m.ID]
18461851 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
18471852 }
18481853 s.render(w, "mrs.html", struct {
18491854 repoPage
1850 State string
1851 Query string
1852 Filters []listFilter
1853 MRs []mrRow
1854 Older string
1855 State string
1856 Query string
1857 Filters []listFilter
1858 MRs []mrRow
1859 LabelColors map[string]template.CSS
1860 Older string
18551861 }{p, state, mf.Search,
1856 activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), rows, older})
1862 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
1863 rows, s.labelColors(p.Repo), older})
18571864}
18581865
18591866func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
@@ -2016,10 +2023,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
20162023 HeadMerged bool
20172024 HeadPruned bool
20182025 Base string
2026 LabelColors map[string]template.CSS
20192027 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
20202028 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
20212029 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2022 sourceGone(p, m), headMerged, headPruned, base})
2030 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo)})
20232031}
20242032
20252033// sourceGone reports whether an MR's source branch no longer exists: the
internal/store/issues.go +2 −54
@@ -3,7 +3,6 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "fmt"
76 "strings"
87)
98
@@ -275,26 +274,7 @@ func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error
275274// issue listing; ListIssues itself stays label-free for the CLI's lean
276275// list output.
277276func (s *Store) ListIssueLabels(repo Repo) (map[int64][]string, error) {
278 where, args := scopeClause("l", repo)
279 rows, err := s.DB.Query(`
280 SELECT il.issue_id, l.name FROM issue_labels il
281 JOIN labels l ON l.id = il.label_id
282 JOIN issues i ON i.id = il.issue_id
283 WHERE i.repo_id = ? AND `+where+` ORDER BY l.name`, append([]any{repo.ID}, args...)...)
284 if err != nil {
285 return nil, err
286 }
287 defer rows.Close()
288 out := map[int64][]string{}
289 for rows.Next() {
290 var id int64
291 var name string
292 if err := rows.Scan(&id, &name); err != nil {
293 return nil, err
294 }
295 out[id] = append(out[id], name)
296 }
297 return out, rows.Err()
277 return s.listItemLabels(issueLabelJoin, repo)
298278}
299279
300280// LabelColors returns the colours of the labels a repository sees, keyed
@@ -321,39 +301,7 @@ func (s *Store) LabelColors(repo Repo) (map[string]string, error) {
321301// resolves the org's row when the org has the name, else the repository's,
322302// creating that on first use.
323303func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
324 tx, err := s.DB.Begin()
325 if err != nil {
326 return err
327 }
328 defer tx.Rollback()
329 where, args := scopeClause("l", repo)
330 if add {
331 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
332 return err
333 } else if !held {
334 if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
335 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
336 return err
337 }
338 }
339 if _, err := tx.Exec(`INSERT INTO issue_labels (issue_id, label_id)
340 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
341 ORDER BY l.org_id IS NULL LIMIT 1
342 ON CONFLICT DO NOTHING`, append(append([]any{issueID}, args...), name)...); err != nil {
343 return err
344 }
345 } else {
346 res, err := tx.Exec(`DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
347 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
348 append(append([]any{issueID}, args...), name)...)
349 if err != nil {
350 return err
351 }
352 if n, _ := res.RowsAffected(); n == 0 {
353 return fmt.Errorf("label %q: %w", name, ErrNotFound)
354 }
355 }
356 return tx.Commit()
304 return s.setItemLabel(issueLabelJoin, repo, issueID, name, add)
357305}
358306
359307// SetIssueAssignee adds or removes an assignee by user id.
internal/store/labels.go +107 −19
@@ -3,28 +3,46 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "fmt"
67)
78
8// Label is an issue label with its colour, "" when none was set (the web
9// then derives one from the name), and how many issues carry it. Org is
10// true for a label the repository sees through its org.
9// Label is a label with its colour, "" when none was set (the web then
10// derives one from the name), and how many issues and merge requests
11// carry it. Org is true for a label the repository sees through its org.
1112type Label struct {
1213 Name string `json:"name"`
1314 Color string `json:"color,omitempty"`
1415 Org bool `json:"org,omitempty"`
1516 Issues int64 `json:"issues"`
17 MRs int64 `json:"mrs"`
1618}
1719
18// labelRows lists labels under where, with use counted over the issues of
19// the readable repositories only, so a private repository's issues do not
20// show in a count someone outside it can see.
20// labelJoin is where a labelled thing carries its labels. Issues and
21// merge requests attach them identically, differing only in the join
22// table, its column naming the thing, and the thing's own table.
23type labelJoin struct {
24 table string
25 item string
26 items string
27}
28
29var (
30 issueLabelJoin = labelJoin{"issue_labels", "issue_id", "issues"}
31 mrLabelJoin = labelJoin{"mr_labels", "mr_id", "merge_requests"}
32)
33
34// labelRows lists labels under where, with use counted over the issues
35// and merge requests of the readable repositories only, so a private
36// repository's does not show in a count someone outside it can see.
2137func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) {
2238 in, inArgs := inClause(readable)
2339 q := `SELECT l.name, l.color, l.org_id IS NOT NULL,
2440 (SELECT COUNT(*) FROM issue_labels il JOIN issues i ON i.id = il.issue_id
25 WHERE il.label_id = l.id AND i.repo_id IN ` + in + `)
41 WHERE il.label_id = l.id AND i.repo_id IN ` + in + `),
42 (SELECT COUNT(*) FROM mr_labels ml JOIN merge_requests m ON m.id = ml.mr_id
43 WHERE ml.label_id = l.id AND m.repo_id IN ` + in + `)
2644 FROM labels l WHERE ` + where + ` ORDER BY l.org_id IS NULL, l.name`
27 rows, err := s.DB.Query(q, append(inArgs, args...)...)
45 rows, err := s.DB.Query(q, append(append(append([]any{}, inArgs...), inArgs...), args...)...)
2846 if err != nil {
2947 return nil, err
3048 }
@@ -32,7 +50,7 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label,
3250 var out []Label
3351 for rows.Next() {
3452 var l Label
35 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues); err != nil {
53 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues, &l.MRs); err != nil {
3654 return nil, err
3755 }
3856 out = append(out, l)
@@ -40,6 +58,71 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label,
4058 return out, rows.Err()
4159}
4260
61// listItemLabels returns the label names attached to each of a
62// repository's issues or merge requests, keyed by its row id, the org's
63// labels included.
64func (s *Store) listItemLabels(j labelJoin, repo Repo) (map[int64][]string, error) {
65 where, args := scopeClause("l", repo)
66 rows, err := s.DB.Query(`
67 SELECT j.`+j.item+`, l.name FROM `+j.table+` j
68 JOIN labels l ON l.id = j.label_id
69 JOIN `+j.items+` t ON t.id = j.`+j.item+`
70 WHERE t.repo_id = ? AND `+where+` ORDER BY l.name`, append([]any{repo.ID}, args...)...)
71 if err != nil {
72 return nil, err
73 }
74 defer rows.Close()
75 out := map[int64][]string{}
76 for rows.Next() {
77 var id int64
78 var name string
79 if err := rows.Scan(&id, &name); err != nil {
80 return nil, err
81 }
82 out[id] = append(out[id], name)
83 }
84 return out, rows.Err()
85}
86
87// setItemLabel attaches (add) or detaches a label by name. Adding
88// resolves the org's row when the org has the name, else the
89// repository's, creating that on first use.
90func (s *Store) setItemLabel(j labelJoin, repo Repo, itemID int64, name string, add bool) error {
91 tx, err := s.DB.Begin()
92 if err != nil {
93 return err
94 }
95 defer tx.Rollback()
96 where, args := scopeClause("l", repo)
97 if add {
98 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
99 return err
100 } else if !held {
101 if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
102 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
103 return err
104 }
105 }
106 if _, err := tx.Exec(`INSERT INTO `+j.table+` (`+j.item+`, label_id)
107 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
108 ORDER BY l.org_id IS NULL LIMIT 1
109 ON CONFLICT DO NOTHING`, append(append([]any{itemID}, args...), name)...); err != nil {
110 return err
111 }
112 } else {
113 res, err := tx.Exec(`DELETE FROM `+j.table+` WHERE `+j.item+` = ? AND label_id IN
114 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
115 append(append([]any{itemID}, args...), name)...)
116 if err != nil {
117 return err
118 }
119 if n, _ := res.RowsAffected(); n == 0 {
120 return fmt.Errorf("label %q: %w", name, ErrNotFound)
121 }
122 }
123 return tx.Commit()
124}
125
43126// ListLabels lists the labels a repository sees: its org's first, then its
44127// own, each by name.
45128func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) {
@@ -102,8 +185,9 @@ func (s *Store) SetLabel(repo Repo, name, color string) error {
102185 return tx.Commit()
103186}
104187
105// DeleteLabel removes the repository's label and takes it off every issue.
106// An org's label is ErrOrgScoped; no label at all is ErrNotFound.
188// DeleteLabel removes the repository's label and takes it off every issue
189// and merge request. An org's label is ErrOrgScoped; no label at all is
190// ErrNotFound.
107191func (s *Store) DeleteLabel(repo Repo, name string) error {
108192 tx, err := s.DB.Begin()
109193 if err != nil {
@@ -127,8 +211,9 @@ func (s *Store) DeleteLabel(repo Repo, name string) error {
127211}
128212
129213// SetOrgLabel creates the org's label or sets its colour. Repositories
130// under the org that hold the name are folded in: their issues move to
131// the org's row and their rows go. folded is how many were.
214// under the org that hold the name are folded in: their issues and merge
215// requests move to the org's row and their rows go. folded is how many
216// were.
132217func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
133218 tx, err := s.DB.Begin()
134219 if err != nil {
@@ -162,19 +247,22 @@ func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
162247}
163248
164249// foldLabelRow moves a repository's label onto the org's row: every issue
165// carrying it gets the org row, then the repository row goes.
250// and merge request carrying it gets the org row, then the repository row
251// goes.
166252func foldLabelRow(tx *sql.Tx, orgRow, repoRow int64) error {
167 // OR IGNORE: an issue cannot carry both today, but the primary key
168 // makes the move safe if it ever did.
169 if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil {
170 return err
253 // OR IGNORE: nothing can carry both today, but the primary key makes
254 // the move safe if it ever did.
255 for _, table := range []string{issueLabelJoin.table, mrLabelJoin.table} {
256 if _, err := tx.Exec("UPDATE OR IGNORE "+table+" SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil {
257 return err
258 }
171259 }
172260 _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow)
173261 return err
174262}
175263
176264// DeleteOrgLabel removes an org's label from the org and from every issue
177// under it.
265// and merge request under it.
178266func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
179267 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
180268 if err != nil {
internal/store/labels_test.go +110
@@ -15,6 +15,8 @@ type acmeFixture struct {
1515 app Repo
1616 coreIssue int64
1717 siteIssue int64
18 coreMR int64
19 siteMR int64
1820}
1921
2022func newAcme(t *testing.T) acmeFixture {
@@ -63,6 +65,21 @@ func newAcme(t *testing.T) acmeFixture {
6365 }
6466 f.coreIssue = mkIssue(f.core, "c1")
6567 f.siteIssue = mkIssue(f.site, "s1")
68 // Same resolution for merge requests: CreateMR returns the per-repo
69 // number, mr_labels.mr_id references merge_requests.id.
70 mkMR := func(repo Repo, title string) int64 {
71 n, err := s.CreateMR(repo.ID, f.alice, repo.ID, "topic", "main", title, "", "deadbeef", "md", false)
72 if err != nil {
73 t.Fatal(err)
74 }
75 m, err := s.MRByNumber(repo.ID, n)
76 if err != nil {
77 t.Fatal(err)
78 }
79 return m.ID
80 }
81 f.coreMR = mkMR(f.core, "c!1")
82 f.siteMR = mkMR(f.site, "s!1")
6683 return f
6784}
6885
@@ -162,6 +179,99 @@ func TestListIssueLabelsIncludesOrgRows(t *testing.T) {
162179 }
163180}
164181
182// The web merge request list reads labels per repository, the same shape
183// the issue list reads them in; an org label attached to a merge request
184// comes back from there like the repository's own (#231).
185func TestListMRLabelsIncludesOrgRows(t *testing.T) {
186 f := newAcme(t)
187 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
188 t.Fatal(err)
189 }
190 if err := f.s.SetLabel(f.core, "docs", ""); err != nil {
191 t.Fatal(err)
192 }
193 for _, name := range []string{"bug", "docs"} {
194 if err := f.s.SetMRLabel(f.core, f.coreMR, name, true); err != nil {
195 t.Fatal(err)
196 }
197 }
198 got, err := f.s.ListMRLabels(f.core)
199 if err != nil || len(got[f.coreMR]) != 2 || got[f.coreMR][0] != "bug" || got[f.coreMR][1] != "docs" {
200 t.Fatalf("core MR labels = %v, %v", got, err)
201 }
202 // Another repository under the org does not pick up core's attachment.
203 if got, _ := f.s.ListMRLabels(f.site); len(got) != 0 {
204 t.Fatalf("site MR labels = %v", got)
205 }
206 // MRByNumber carries them, and the label listing counts them apart
207 // from issues.
208 m, err := f.s.MRByNumber(f.core.ID, 1)
209 if err != nil || len(m.Labels) != 2 || m.Labels[0] != "bug" {
210 t.Fatalf("MRByNumber labels = %v, %v", m.Labels, err)
211 }
212 rows, _ := f.s.ListLabels(f.core, f.orgRepos())
213 if len(rows) != 2 || rows[0].Name != "bug" || rows[0].MRs != 1 || rows[0].Issues != 0 {
214 t.Fatalf("label rows = %+v", rows)
215 }
216 // The filter narrows to the merge requests carrying the name.
217 mrs, err := f.s.QueryMRs(f.core.ID, MRFilter{State: "all", Label: "bug"})
218 if err != nil || len(mrs) != 1 || mrs[0].ID != f.coreMR {
219 t.Fatalf("QueryMRs by label = %+v, %v", mrs, err)
220 }
221 if mrs, _ := f.s.QueryMRs(f.core.ID, MRFilter{State: "all", Label: "nope"}); len(mrs) != 0 {
222 t.Fatalf("QueryMRs by absent label = %+v", mrs)
223 }
224 // Removing a name nothing carries is not found.
225 if err := f.s.SetMRLabel(f.core, f.coreMR, "nope", false); !errors.Is(err, ErrNotFound) {
226 t.Fatalf("remove of absent label: %v, want ErrNotFound", err)
227 }
228}
229
230// Folding a repository label onto its org's row moves the merge requests
231// carrying it, not only the issues.
232func TestSetOrgLabelFoldsMRLabels(t *testing.T) {
233 f := newAcme(t)
234 if err := f.s.SetMRLabel(f.core, f.coreMR, "bug", true); err != nil {
235 t.Fatal(err)
236 }
237 if err := f.s.SetMRLabel(f.site, f.siteMR, "bug", true); err != nil {
238 t.Fatal(err)
239 }
240 folded, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000")
241 if err != nil || folded != 2 {
242 t.Fatalf("SetOrgLabel folded %d, %v; want 2", folded, err)
243 }
244 var n int
245 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug'").Scan(&n)
246 if n != 1 {
247 t.Fatalf("labels named bug after folding: %d, want 1", n)
248 }
249 // Both merge requests still carry it, now through the org's row.
250 for _, c := range []struct {
251 repo Repo
252 mr int64
253 }{{f.core, f.coreMR}, {f.site, f.siteMR}} {
254 got, _ := f.s.ListMRLabels(c.repo)
255 if len(got[c.mr]) != 1 || got[c.mr][0] != "bug" {
256 t.Fatalf("%s MR labels after folding = %v", c.repo.Name, got)
257 }
258 }
259 rows, _ := f.s.ListOrgLabels(f.org, f.orgRepos())
260 if len(rows) != 1 || rows[0].MRs != 2 {
261 t.Fatalf("org label rows = %+v", rows)
262 }
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 }
273}
274
165275func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) {
166276 f := newAcme(t)
167277 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
internal/store/migrations/0056_mr_labels.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE mr_labels;
internal/store/migrations/0056_mr_labels.up.sql added +12
@@ -0,0 +1,12 @@
1-- Labels on merge requests, carried the same way issues carry them
2-- (#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.
8CREATE TABLE mr_labels (
9 mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
10 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
11 PRIMARY KEY (mr_id, label_id)
12);
internal/store/mrs.go +27
@@ -34,6 +34,7 @@ type MR struct {
3434 SupersededBy int64
3535 CreatedAt string
3636 UpdatedAt string
37 Labels []string
3738 // ReviewRequests is who has been asked, directly, for a review — the
3839 // mr review request counterpart of Issue.Assignees.
3940 ReviewRequests []string
@@ -121,12 +122,32 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
121122 if err != nil {
122123 return m, err
123124 }
125 if m.Labels, err = s.issueStrings(m.ID, `
126 SELECT l.name FROM mr_labels ml JOIN labels l ON l.id = ml.label_id
127 WHERE ml.mr_id = ? ORDER BY l.name`); err != nil {
128 return m, err
129 }
124130 m.ReviewRequests, err = s.issueStrings(m.ID, `
125131 SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id
126132 WHERE rr.mr_id = ? ORDER BY u.username`)
127133 return m, err
128134}
129135
136// ListMRLabels returns the label names attached to each merge request of
137// a repo, keyed by merge request id, its org's labels included. Used by
138// the web merge request listing; ListMRs itself stays label-free for the
139// CLI's lean list output.
140func (s *Store) ListMRLabels(repo Repo) (map[int64][]string, error) {
141 return s.listItemLabels(mrLabelJoin, repo)
142}
143
144// SetMRLabel attaches (add) or detaches a label by name, the issue rules
145// exactly: the org's row when the org has the name, else the
146// repository's, created on first use.
147func (s *Store) SetMRLabel(repo Repo, mrID int64, name string, add bool) error {
148 return s.setItemLabel(mrLabelJoin, repo, mrID, name, add)
149}
150
130151// SetMRReviewRequest adds or removes a review request by user id — the
131152// mr review request counterpart of SetIssueAssignee.
132153func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error {
@@ -160,6 +181,7 @@ func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) {
160181// too. Milestone "none" selects merge requests with no milestone.
161182type MRFilter struct {
162183 State string
184 Label string
163185 Author string
164186 Milestone string
165187 Search string // full-text over title and body
@@ -180,6 +202,11 @@ func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) {
180202 q += " AND m.state = ?"
181203 args = append(args, f.State)
182204 }
205 if f.Label != "" {
206 q += ` AND EXISTS (SELECT 1 FROM mr_labels ml JOIN labels l ON l.id = ml.label_id
207 WHERE ml.mr_id = m.id AND l.name = ?)`
208 args = append(args, f.Label)
209 }
183210 if f.Author != "" {
184211 q += " AND u.username = ?"
185212 args = append(args, f.Author)
internal/web/templates/labels.html +3 −2
@@ -3,7 +3,7 @@
33<h1>Labels</h1>
44{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
55{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th>{{if or $.CanWrite $.AnyColor}}<th scope="col">colour</th>{{end}}<th scope="col">issues</th><th scope="col"></th></tr>
6<tr class="cols"><th scope="col">label</th>{{if or $.CanWrite $.AnyColor}}<th scope="col">colour</th>{{end}}<th scope="col">issues</th><th scope="col">merge requests</th><th scope="col"></th></tr>
77{{range .Labels}}<tr>
88 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a>{{if .Org}} <span class="chip chip-neutral">org</span>{{end}}</td>
99 {{if or $.CanWrite $.AnyColor}}<td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
@@ -12,6 +12,7 @@
1212 <button type="submit" class="btn">Save</button>
1313 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>{{end}}
1414 <td>{{.Issues}}</td>
15 <td>{{.MRs}}</td>
1516 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
1617 <input type="hidden" name="action" value="remove">
1718 <input type="hidden" name="name" value="{{.Name}}">
@@ -32,6 +33,6 @@
3233 <button type="submit" class="btn">Create label</button>
3334 </form>
3435</details>
35<p class="meta">Removing a label takes it off every issue that carries it.</p>
36<p class="meta">Removing a label takes it off every issue and merge request that carries it.</p>
3637{{end}}
3738{{end}}
internal/web/templates/mr.html +12
@@ -166,6 +166,18 @@
166166 <h2>Milestone</h2>
167167 <p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a></p>
168168 </div>{{end}}
169 <div class="grp">
170 <h2>Labels</h2>
171 {{if .MR.Labels}}<p class="row">{{range .MR.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs?label={{.}}">{{.}}</a> {{end}}</p>
172 {{else}}<p class="none">none yet</p>{{end}}
173 {{if .CanWrite}}
174 <form method="post" action="{{$base}}/label" class="actions">
175 <input type="text" name="add" aria-label="Add labels" placeholder="add, space-separated">
176 <input type="text" name="remove" aria-label="Remove labels" placeholder="remove">
177 <button type="submit" class="btn">Apply</button>
178 </form>
179 {{end}}
180 </div>
169181</aside>
170182</div>
171183{{end}}
internal/web/templates/mrs.html +3 −2
@@ -13,13 +13,14 @@
1313 <button type="submit" class="btn">Search</button>
1414 <input type="hidden" name="state" value="{{.State}}">
1515 </form>
16 {{range .Filters}}<p class="meta">{{.Key}}: <b>{{.Value}}</b> <a href="{{.Clear}}">clear</a></p>{{end}}
16 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
1717</div>
1818{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
1919<ul class="issuelist">
2020{{range .MRs}}{{$n := .Number}}<li>
2121 <div class="issuemain">
22 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p>
22 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a>
23 {{range .Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="?label={{.}}">{{.}}</a> {{end}}</p>
2324 <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p>
2425 </div>
2526 <div class="issueside">
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}}