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.
146 =issue.assigned= (assignees), =issue.milestoned= (milestone) 146 =issue.assigned= (assignees), =issue.milestoned= (milestone)
147- merge requests: =mr.created=, =mr.edited=, =mr.commented=, 147- merge requests: =mr.created=, =mr.edited=, =mr.commented=,
148 =mr.reviewed= (verdict), =mr.draft= (draft), =mr.retargeted= (from, 148 =mr.reviewed= (verdict), =mr.draft= (draft), =mr.retargeted= (from,
149 to), =mr.milestoned= (milestone), =mr.merged= (number, sha), 149 to), =mr.labeled= (labels), =mr.milestoned= (milestone), =mr.merged=
150 =mr.closed= 150 (number, sha), =mr.closed=
151- releases: =release.created= (tag), =release.deleted= (tag) 151- releases: =release.created= (tag), =release.deleted= (tag)
152- CI: =status=, =build.success=, =build.failure=, =build.cancelled= 152- CI: =status=, =build.success=, =build.failure=, =build.cancelled=
153 153
.gitbay/wiki/Parity.org +9 −6
@@ -48,6 +48,8 @@ browser-only and the iOS build screen unable to say more than the log.
48| create from a fork | yes | yes | yes | 48| create from a fork | yes | yes | yes |
49| retarget | yes | yes | yes | 49| retarget | yes | yes | yes |
50| milestone | yes | yes | yes | 50| milestone | yes | yes | yes |
51| labels | yes | yes | no |
52| filter by label | yes | yes | no |
51| request a review | yes | yes | yes | 53| request a review | yes | yes | yes |
52| choose body markup | yes | yes | yes | 54| choose body markup | yes | yes | yes |
53| stacked merge requests | yes | yes | yes | 55| stacked merge requests | yes | yes | yes |
@@ -118,12 +120,13 @@ reviews, since an approval was of the diff against the old branch.
118| org milestones: create, list, close, reopen | yes | list | yes | 120| org milestones: create, list, close, reopen | yes | list | yes |
119| closes across repositories | yes | yes | yes | 121| closes across repositories | yes | yes | yes |
120 122
121Labels are created on the fly by =issue label --add= and managed by 123Labels are created on the fly by =issue label --add= and =mr label
122=label list=, =label set <label> --color rrggbb= and =label remove=, 124--add=, and managed by =label list=, =label set <label> --color rrggbb=
123which takes the label off every issue. The web paints the stored colour 125and =label remove=, which takes the label off every issue and merge
124on every chip and derives one from the name when none is set. The set 126request. One set serves both. The web paints the stored colour on every
125itself is at =/<owner>/<repo>/labels=, linked from the issue list: 127chip and derives one from the name when none is set. The set itself is
126create, recolour and remove, dispatching the same commands. 128at =/<owner>/<repo>/labels=, linked from the issue list: create,
129recolour and remove, dispatching the same commands.
127 130
128Org labels and milestones are managed on the CLI, the API and the iOS 131Org labels and milestones are managed on the CLI, the API and the iOS
129client's org screen; =/<org>/-/labels= and =/<org>/-/milestones= show 132client'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
370#+end_src 370#+end_src
371 371
372An org holds labels and milestones every repository under it sees 372An org holds labels and milestones every repository under it sees
373beside its own. =issue label --add=, =issue milestone= and =mr 373beside its own. =issue label --add=, =mr label --add=, =issue
374milestone= resolve the org's row first; a repository cannot create a 374milestone= and =mr milestone= resolve the org's row first; a repository
375label or milestone with a name its org holds. Creating an org label or 375cannot create a label or milestone with a name its org holds. Creating
376milestone whose name repositories under the org already use folds them 376an org label or milestone whose name repositories under the org already
377in: their issues and merge requests move to the org's row. Org admins 377use folds them in: their issues and merge requests move to the org's
378manage them; counts span the repositories you can read. 378row. Org admins manage them; counts span the repositories you can read.
379 379
380#+begin_src sh 380#+begin_src sh
381gitbay org label set acme bug --color cf222e 381gitbay org label set acme bug --color cf222e
@@ -394,14 +394,14 @@ form prefills its textarea, and =gitbay issue templates= lists them.
394 394
395Lists narrow the same way on every surface: =issue list --label bug 395Lists narrow the same way on every surface: =issue list --label bug
396--assignee bob --author alice --milestone v1= (or =--milestone none=), 396--assignee bob --author alice --milestone v1= (or =--milestone none=),
397=mr list --author bob --milestone v1=; the web's issue and merge request 397=mr list --label bug --author bob --milestone v1=; the web's issue and
398lists take the same names as query parameters, and each active filter 398merge request lists take the same names as query parameters, and each
399shows with a link that drops it. 399active filter shows with a link that drops it.
400 400
401Labels take a colour: =gitbay label set bug --color cf222e=; =label 401Labels take a colour: =gitbay label set bug --color cf222e=; =label
402list= shows each with its colour and how many issues carry it, and 402list= shows each with its colour and how many issues and merge requests
403=label remove= takes one off every issue. =issue label --add= still 403carry it, and =label remove= takes one off all of them. =issue label
404creates a colourless label on the fly. 404--add= and =mr label --add= still create a colourless label on the fly.
405 405
406* Merge requests 406* Merge requests
407 407
@@ -411,6 +411,7 @@ gitbay mr create other/upstream --source you/fork:feature --target main --title
411gitbay mr list / show 4 / diff 4 411gitbay mr list / show 4 / diff 4
412gitbay mr checkout 4 # local branch mr/4 from the MR head 412gitbay mr checkout 4 # local branch mr/4 from the MR head
413gitbay mr review 4 --approve # or --request-changes / --comment 413gitbay mr review 4 --approve # or --request-changes / --comment
414gitbay mr label 4 --add bug --remove wontfix
414gitbay mr merge 4 [--strategy ff|merge|squash|rebase] 415gitbay mr merge 4 [--strategy ff|merge|squash|rebase]
415gitbay mr close 4 416gitbay mr close 4
416#+end_src 417#+end_src
cmd/gitbay/main.go +2 −1
@@ -580,7 +580,7 @@ func mrCmd() *cobra.Command {
580 return group("mr", "merge requests", 580 return group("mr", "merge requests",
581 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", 581 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
582 passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request", inferSource: true}), 582 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}),
584 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), 584 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
585 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), 585 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
586 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), 586 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
@@ -598,6 +598,7 @@ func mrCmd() *cobra.Command {
598 pass("draft", "mark as work in progress", passOpts{server: []string{"mr", "draft"}, needsRepo: true}), 598 pass("draft", "mark as work in progress", passOpts{server: []string{"mr", "draft"}, needsRepo: true}),
599 pass("ready", "take the draft mark off, so it can merge", passOpts{server: []string{"mr", "ready"}, needsRepo: true}), 599 pass("ready", "take the draft mark off, so it can merge", passOpts{server: []string{"mr", "ready"}, needsRepo: true}),
600 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}), 600 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}),
601 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}), 602 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
602 pass("retarget", "retarget onto another branch: <n> <branch>", passOpts{server: []string{"mr", "retarget"}, needsRepo: true}), 603 pass("retarget", "retarget onto another branch: <n> <branch>", passOpts{server: []string{"mr", "retarget"}, needsRepo: true}),
603 ) 604 )
e2e/label_test.go +3 −2
@@ -23,7 +23,7 @@ func TestLabelColors(t *testing.T) {
23 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "label", "alice/app", "1", "--add", "bug"); code != 0 { 23 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "label", "alice/app", "1", "--add", "bug"); code != 0 {
24 t.Fatal("issue label failed") 24 t.Fatal("issue label failed")
25 } 25 }
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") {
27 t.Fatalf("list after issue label:\n%s", out) 27 t.Fatalf("list after issue label:\n%s", out)
28 } 28 }
29 if _, _, code := inst.ssh(t, bobKey, "", "label", "set", "alice/app", "bug", "--color", "cf222e"); code != 4 { 29 if _, _, code := inst.ssh(t, bobKey, "", "label", "set", "alice/app", "bug", "--color", "cf222e"); code != 4 {
@@ -39,7 +39,8 @@ func TestLabelColors(t *testing.T) {
39 t.Fatal("create without colour failed") 39 t.Fatal("create without colour failed")
40 } 40 }
41 out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json") 41 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}`) {
43 t.Fatalf("list json:\n%s", out) 44 t.Fatalf("list json:\n%s", out)
44 } 45 }
45 // The web paints the chip with the stored colour. 46 // The web paints the chip with the stored colour.
e2e/labelweb_test.go +2 −2
@@ -39,8 +39,8 @@ func TestLabelsWeb(t *testing.T) {
39 t.Fatal("label recolour failed") 39 t.Fatal("label recolour failed")
40 } 40 }
41 out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json") 41 out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json")
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1}`) || 42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1,"mrs":0}`) ||
43 !strings.Contains(out, `{"name":"docs","color":"#1f6feb","issues":0}`) { 43 !strings.Contains(out, `{"name":"docs","color":"#1f6feb","issues":0,"mrs":0}`) {
44 t.Fatalf("labels not as posted:\n%s", out) 44 t.Fatalf("labels not as posted:\n%s", out)
45 } 45 }
46 46
e2e/mrweb_test.go +82
@@ -502,3 +502,85 @@ func TestMRSupersedes(t *testing.T) {
502 t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2) 502 t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2)
503 } 503 }
504} 504}
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{
33 "mr.created", 33 "mr.created",
34 "mr.draft", 34 "mr.draft",
35 "mr.edited", 35 "mr.edited",
36 "mr.labeled",
36 "mr.merged", 37 "mr.merged",
37 "mr.milestoned", 38 "mr.milestoned",
38 "mr.retargeted", 39 "mr.retargeted",
internal/control/label.go +3 −3
@@ -14,13 +14,13 @@ import (
14 14
15func init() { 15func init() {
16 register(Command{Path: []string{"label", "list"}, 16 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",
18 Usage: "label list <owner/name>", ReadOnly: true, Run: runLabelList}) 18 Usage: "label list <owner/name>", ReadOnly: true, Run: runLabelList})
19 register(Command{Path: []string{"label", "set"}, 19 register(Command{Path: []string{"label", "set"},
20 Summary: "create a label or set its colour", 20 Summary: "create a label or set its colour",
21 Usage: "label set <owner/name> <label> [--color rrggbb|'']", Run: runLabelSet}) 21 Usage: "label set <owner/name> <label> [--color rrggbb|'']", Run: runLabelSet})
22 register(Command{Path: []string{"label", "remove"}, 22 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",
24 Usage: "label remove <owner/name> <label>", Run: runLabelRemove}) 24 Usage: "label remove <owner/name> <label>", Run: runLabelRemove})
25} 25}
26 26
@@ -47,7 +47,7 @@ func runLabelList(c *Ctx, args []string) int {
47 } 47 }
48 return c.emit(labels, func(w io.Writer) { 48 return c.emit(labels, func(w io.Writer) {
49 for _, l := range labels { 49 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])
51 } 51 }
52 }) 52 })
53} 53}
internal/control/mr.go +61 −13
@@ -59,7 +59,7 @@ func init() {
59 Usage: "mr ready <owner/name> <n>", Run: runMRReady}) 59 Usage: "mr ready <owner/name> <n>", Run: runMRReady})
60 register(Command{Path: []string{"mr", "list"}, 60 register(Command{Path: []string{"mr", "list"},
61 Summary: "list merge requests", 61 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})
63 register(Command{Path: []string{"mr", "show"}, 63 register(Command{Path: []string{"mr", "show"},
64 Summary: "show a merge request", 64 Summary: "show a merge request",
65 Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) 65 Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
@@ -83,6 +83,9 @@ func init() {
83 register(Command{Path: []string{"mr", "review", "request"}, 83 register(Command{Path: []string{"mr", "review", "request"},
84 Summary: "ask specific people for a review", 84 Summary: "ask specific people for a review",
85 Usage: "mr review request <owner/name> <n> [--add <user>]... [--remove <user>]...", Run: runMRReviewRequest}) 85 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})
86 register(Command{Path: []string{"mr", "merge"}, 89 register(Command{Path: []string{"mr", "merge"},
87 Summary: "merge", 90 Summary: "merge",
88 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge}) 91 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
@@ -365,14 +368,15 @@ type mrOut struct {
365 Title string `json:"title"` 368 Title string `json:"title"`
366 State string `json:"state"` 369 State string `json:"state"`
367 // Draft is an open merge request not asking to be merged yet. 370 // Draft is an open merge request not asking to be merged yet.
368 Draft bool `json:"draft,omitempty"` 371 Draft bool `json:"draft,omitempty"`
369 Author string `json:"author"` 372 Author string `json:"author"`
370 Source string `json:"source"` // owner/name:branch, or branch, "" if gone 373 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
371 TargetRef string `json:"target_ref"` 374 TargetRef string `json:"target_ref"`
372 HeadSHA string `json:"head_sha"` 375 HeadSHA string `json:"head_sha"`
373 Body string `json:"body,omitempty"` 376 Body string `json:"body,omitempty"`
374 BodyFormat string `json:"body_format,omitempty"` 377 BodyFormat string `json:"body_format,omitempty"`
375 Milestone string `json:"milestone,omitempty"` 378 Milestone string `json:"milestone,omitempty"`
379 Labels []string `json:"labels,omitempty"`
376 // ReviewRequests is who has been asked, directly, for a review. 380 // ReviewRequests is who has been asked, directly, for a review.
377 ReviewRequests []string `json:"review_requests,omitempty"` 381 ReviewRequests []string `json:"review_requests,omitempty"`
378 // StackedOn is the open merge request whose source branch this one 382 // 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 {
430 } 434 }
431 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Draft: m.Draft, Author: m.Author, 435 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Draft: m.Draft, Author: m.Author,
432 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, 436 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
433 ReviewRequests: m.ReviewRequests, 437 Labels: m.Labels, ReviewRequests: m.ReviewRequests,
434 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, 438 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
435 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy} 439 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy}
436 if withBody { 440 if withBody {
437 o.Body = m.Body 441 o.Body = m.Body
@@ -446,7 +450,7 @@ func runMRList(c *Ctx, args []string) int {
446 return code 450 return code
447 } 451 }
448 f := store.MRFilter{State: "open"} 452 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})
450 if err != nil { 454 if err != nil {
451 return c.fail(protocol.ExitUsage, "%v", err) 455 return c.fail(protocol.ExitUsage, "%v", err)
452 } 456 }
@@ -454,7 +458,7 @@ func runMRList(c *Ctx, args []string) int {
454 if fl.Has("--state") { 458 if fl.Has("--state") {
455 f.State = fl.Value("--state") 459 f.State = fl.Value("--state")
456 } 460 }
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")
458 f.Search = fl.Value("--search") 462 f.Search = fl.Value("--search")
459 if fl.Has("--search") { 463 if fl.Has("--search") {
460 if err := validQuery(f.Search); err != nil { 464 if err := validQuery(f.Search); err != nil {
@@ -586,6 +590,9 @@ func runMRShow(c *Ctx, args []string) int {
586 state = "draft" 590 state = "draft"
587 } 591 }
588 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) 592 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 }
589 if len(d.ReviewRequests) > 0 { 596 if len(d.ReviewRequests) > 0 {
590 fmt.Fprintf(w, "reviewers: %s\n", strings.Join(d.ReviewRequests, ", ")) 597 fmt.Fprintf(w, "reviewers: %s\n", strings.Join(d.ReviewRequests, ", "))
591 } 598 }
@@ -958,6 +965,47 @@ func runMRReviewRequest(c *Ctx, args []string) int {
958 }) 965 })
959} 966}
960 967
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
961func runMRMerge(c *Ctx, args []string) int { 1009func runMRMerge(c *Ctx, args []string) int {
962 f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"}) 1010 f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
963 if err != nil { 1011 if err != nil {
internal/control/orglabel.go +1 −1
@@ -129,7 +129,7 @@ func runOrgLabelList(c *Ctx, args []string) int {
129 } 129 }
130 return c.emit(labels, func(w io.Writer) { 130 return c.emit(labels, func(w io.Writer) {
131 for _, l := range labels { 131 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)
133 } 133 }
134 }) 134 })
135} 135}
internal/control/orgscope_test.go +51
@@ -174,3 +174,54 @@ func TestDuplicateMilestoneTitleFails(t *testing.T) {
174 t.Fatalf("duplicate org create: exit %d %s", code, out.String()) 174 t.Fatalf("duplicate org create: exit %d %s", code, out.String())
175 } 175 }
176} 176}
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
66 s.done(w, r, code, msg, s.mrRedirect) 66 s.done(w, r, code, msg, s.mrRedirect)
67} 67}
68 68
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
69func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 79func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
70 args := []string{} 80 args := []string{}
71 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" { 81 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" {
internal/httpd/routes.go +2
@@ -202,6 +202,8 @@ func (s *Server) Routes() []Route {
202 Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, 202 Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))},
203 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review-request", Mutating: true, 203 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review-request", Mutating: true,
204 Handler: s.checkOrigin(s.requireUser(s.mrReviewRequestSubmit))}, 204 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))},
205 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/merge", Mutating: true, 207 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/merge", Mutating: true,
206 Handler: s.checkOrigin(s.requireUser(s.mrMergeSubmit))}, 208 Handler: s.checkOrigin(s.requireUser(s.mrMergeSubmit))},
207 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/close", Mutating: true, 209 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) {
1814 state = "open" 1814 state = "open"
1815 } 1815 }
1816 qv := r.URL.Query() 1816 qv := r.URL.Query()
1817 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"), 1817 mf := store.MRFilter{State: state, Label: qv.Get("label"), Author: qv.Get("author"),
1818 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1} 1818 Milestone: qv.Get("milestone"), Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1819 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64) 1819 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1820 mrs, err := s.st.QueryMRs(p.Repo.ID, mf) 1820 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1821 if err != nil { 1821 if err != nil {
@@ -1841,19 +1841,26 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1841 if err != nil { 1841 if err != nil {
1842 comments = map[int64]int{} 1842 comments = map[int64]int{}
1843 } 1843 }
1844 labels, err := s.st.ListMRLabels(p.Repo)
1845 if err != nil {
1846 labels = map[int64][]string{}
1847 }
1844 rows := make([]mrRow, len(mrs)) 1848 rows := make([]mrRow, len(mrs))
1845 for i, m := range mrs { 1849 for i, m := range mrs {
1850 m.Labels = labels[m.ID]
1846 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]} 1851 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
1847 } 1852 }
1848 s.render(w, "mrs.html", struct { 1853 s.render(w, "mrs.html", struct {
1849 repoPage 1854 repoPage
1850 State string 1855 State string
1851 Query string 1856 Query string
1852 Filters []listFilter 1857 Filters []listFilter
1853 MRs []mrRow 1858 MRs []mrRow
1854 Older string 1859 LabelColors map[string]template.CSS
1860 Older string
1855 }{p, state, mf.Search, 1861 }{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})
1857} 1864}
1858 1865
1859func (s *Server) mr(w http.ResponseWriter, r *http.Request) { 1866func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
@@ -2016,10 +2023,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
2016 HeadMerged bool 2023 HeadMerged bool
2017 HeadPruned bool 2024 HeadPruned bool
2018 Base string 2025 Base string
2026 LabelColors map[string]template.CSS
2019 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 2027 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2020 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 2028 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2021 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, 2029 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)})
2023} 2031}
2024 2032
2025// sourceGone reports whether an MR's source branch no longer exists: the 2033// sourceGone reports whether an MR's source branch no longer exists: the
internal/store/issues.go +2 −54
@@ -3,7 +3,6 @@ package store
3import ( 3import (
4 "database/sql" 4 "database/sql"
5 "errors" 5 "errors"
6 "fmt"
7 "strings" 6 "strings"
8) 7)
9 8
@@ -275,26 +274,7 @@ func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error
275// issue listing; ListIssues itself stays label-free for the CLI's lean 274// issue listing; ListIssues itself stays label-free for the CLI's lean
276// list output. 275// list output.
277func (s *Store) ListIssueLabels(repo Repo) (map[int64][]string, error) { 276func (s *Store) ListIssueLabels(repo Repo) (map[int64][]string, error) {
278 where, args := scopeClause("l", repo) 277 return s.listItemLabels(issueLabelJoin, 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()
298} 278}
299 279
300// LabelColors returns the colours of the labels a repository sees, keyed 280// 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) {
321// resolves the org's row when the org has the name, else the repository's, 301// resolves the org's row when the org has the name, else the repository's,
322// creating that on first use. 302// creating that on first use.
323func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error { 303func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
324 tx, err := s.DB.Begin() 304 return s.setItemLabel(issueLabelJoin, repo, issueID, name, add)
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()
357} 305}
358 306
359// SetIssueAssignee adds or removes an assignee by user id. 307// SetIssueAssignee adds or removes an assignee by user id.
internal/store/labels.go +107 −19
@@ -3,28 +3,46 @@ package store
3import ( 3import (
4 "database/sql" 4 "database/sql"
5 "errors" 5 "errors"
6 "fmt"
6) 7)
7 8
8// Label is an issue label with its colour, "" when none was set (the web 9// Label is a label with its colour, "" when none was set (the web then
9// then derives one from the name), and how many issues carry it. Org is 10// derives one from the name), and how many issues and merge requests
10// true for a label the repository sees through its org. 11// carry it. Org is true for a label the repository sees through its org.
11type Label struct { 12type Label struct {
12 Name string `json:"name"` 13 Name string `json:"name"`
13 Color string `json:"color,omitempty"` 14 Color string `json:"color,omitempty"`
14 Org bool `json:"org,omitempty"` 15 Org bool `json:"org,omitempty"`
15 Issues int64 `json:"issues"` 16 Issues int64 `json:"issues"`
17 MRs int64 `json:"mrs"`
16} 18}
17 19
18// labelRows lists labels under where, with use counted over the issues of 20// labelJoin is where a labelled thing carries its labels. Issues and
19// the readable repositories only, so a private repository's issues do not 21// merge requests attach them identically, differing only in the join
20// show in a count someone outside it can see. 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.
21func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) { 37func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) {
22 in, inArgs := inClause(readable) 38 in, inArgs := inClause(readable)
23 q := `SELECT l.name, l.color, l.org_id IS NOT NULL, 39 q := `SELECT l.name, l.color, l.org_id IS NOT NULL,
24 (SELECT COUNT(*) FROM issue_labels il JOIN issues i ON i.id = il.issue_id 40 (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 + `)
26 FROM labels l WHERE ` + where + ` ORDER BY l.org_id IS NULL, l.name` 44 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...)...)
28 if err != nil { 46 if err != nil {
29 return nil, err 47 return nil, err
30 } 48 }
@@ -32,7 +50,7 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label,
32 var out []Label 50 var out []Label
33 for rows.Next() { 51 for rows.Next() {
34 var l Label 52 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 {
36 return nil, err 54 return nil, err
37 } 55 }
38 out = append(out, l) 56 out = append(out, l)
@@ -40,6 +58,71 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label,
40 return out, rows.Err() 58 return out, rows.Err()
41} 59}
42 60
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
43// ListLabels lists the labels a repository sees: its org's first, then its 126// ListLabels lists the labels a repository sees: its org's first, then its
44// own, each by name. 127// own, each by name.
45func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) { 128func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) {
@@ -102,8 +185,9 @@ func (s *Store) SetLabel(repo Repo, name, color string) error {
102 return tx.Commit() 185 return tx.Commit()
103} 186}
104 187
105// DeleteLabel removes the repository's label and takes it off every issue. 188// 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. 189// and merge request. An org's label is ErrOrgScoped; no label at all is
190// ErrNotFound.
107func (s *Store) DeleteLabel(repo Repo, name string) error { 191func (s *Store) DeleteLabel(repo Repo, name string) error {
108 tx, err := s.DB.Begin() 192 tx, err := s.DB.Begin()
109 if err != nil { 193 if err != nil {
@@ -127,8 +211,9 @@ func (s *Store) DeleteLabel(repo Repo, name string) error {
127} 211}
128 212
129// SetOrgLabel creates the org's label or sets its colour. Repositories 213// 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 214// under the org that hold the name are folded in: their issues and merge
131// the org's row and their rows go. folded is how many were. 215// requests move to the org's row and their rows go. folded is how many
216// were.
132func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) { 217func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
133 tx, err := s.DB.Begin() 218 tx, err := s.DB.Begin()
134 if err != nil { 219 if err != nil {
@@ -162,19 +247,22 @@ func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
162} 247}
163 248
164// foldLabelRow moves a repository's label onto the org's row: every issue 249// 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.
166func foldLabelRow(tx *sql.Tx, orgRow, repoRow int64) error { 252func foldLabelRow(tx *sql.Tx, orgRow, repoRow int64) error {
167 // OR IGNORE: an issue cannot carry both today, but the primary key 253 // OR IGNORE: nothing can carry both today, but the primary key makes
168 // makes the move safe if it ever did. 254 // 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 { 255 for _, table := range []string{issueLabelJoin.table, mrLabelJoin.table} {
170 return err 256 if _, err := tx.Exec("UPDATE OR IGNORE "+table+" SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil {
257 return err
258 }
171 } 259 }
172 _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow) 260 _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow)
173 return err 261 return err
174} 262}
175 263
176// DeleteOrgLabel removes an org's label from the org and from every issue 264// DeleteOrgLabel removes an org's label from the org and from every issue
177// under it. 265// and merge request under it.
178func (s *Store) DeleteOrgLabel(orgID int64, name string) error { 266func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
179 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name) 267 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
180 if err != nil { 268 if err != nil {
internal/store/labels_test.go +110
@@ -15,6 +15,8 @@ type acmeFixture struct {
15 app Repo 15 app Repo
16 coreIssue int64 16 coreIssue int64
17 siteIssue int64 17 siteIssue int64
18 coreMR int64
19 siteMR int64
18} 20}
19 21
20func newAcme(t *testing.T) acmeFixture { 22func newAcme(t *testing.T) acmeFixture {
@@ -63,6 +65,21 @@ func newAcme(t *testing.T) acmeFixture {
63 } 65 }
64 f.coreIssue = mkIssue(f.core, "c1") 66 f.coreIssue = mkIssue(f.core, "c1")
65 f.siteIssue = mkIssue(f.site, "s1") 67 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")
66 return f 83 return f
67} 84}
68 85
@@ -162,6 +179,99 @@ func TestListIssueLabelsIncludesOrgRows(t *testing.T) {
162 } 179 }
163} 180}
164 181
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
165func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) { 275func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) {
166 f := newAcme(t) 276 f := newAcme(t)
167 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil { 277 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 {
34 SupersededBy int64 34 SupersededBy int64
35 CreatedAt string 35 CreatedAt string
36 UpdatedAt string 36 UpdatedAt string
37 Labels []string
37 // ReviewRequests is who has been asked, directly, for a review — the 38 // ReviewRequests is who has been asked, directly, for a review — the
38 // mr review request counterpart of Issue.Assignees. 39 // mr review request counterpart of Issue.Assignees.
39 ReviewRequests []string 40 ReviewRequests []string
@@ -121,12 +122,32 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
121 if err != nil { 122 if err != nil {
122 return m, err 123 return m, err
123 } 124 }
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 }
124 m.ReviewRequests, err = s.issueStrings(m.ID, ` 130 m.ReviewRequests, err = s.issueStrings(m.ID, `
125 SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id 131 SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id
126 WHERE rr.mr_id = ? ORDER BY u.username`) 132 WHERE rr.mr_id = ? ORDER BY u.username`)
127 return m, err 133 return m, err
128} 134}
129 135
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
130// SetMRReviewRequest adds or removes a review request by user id — the 151// SetMRReviewRequest adds or removes a review request by user id — the
131// mr review request counterpart of SetIssueAssignee. 152// mr review request counterpart of SetIssueAssignee.
132func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error { 153func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error {
@@ -160,6 +181,7 @@ func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) {
160// too. Milestone "none" selects merge requests with no milestone. 181// too. Milestone "none" selects merge requests with no milestone.
161type MRFilter struct { 182type MRFilter struct {
162 State string 183 State string
184 Label string
163 Author string 185 Author string
164 Milestone string 186 Milestone string
165 Search string // full-text over title and body 187 Search string // full-text over title and body
@@ -180,6 +202,11 @@ func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) {
180 q += " AND m.state = ?" 202 q += " AND m.state = ?"
181 args = append(args, f.State) 203 args = append(args, f.State)
182 } 204 }
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 }
183 if f.Author != "" { 210 if f.Author != "" {
184 q += " AND u.username = ?" 211 q += " AND u.username = ?"
185 args = append(args, f.Author) 212 args = append(args, f.Author)
internal/web/templates/labels.html +3 −2
@@ -3,7 +3,7 @@
3<h1>Labels</h1> 3<h1>Labels</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Labels}}<div class="tablewrap"><table class="keys"> 5{{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>
7{{range .Labels}}<tr> 7{{range .Labels}}<tr>
8 <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> 8 <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>
9 {{if or $.CanWrite $.AnyColor}}<td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline"> 9 {{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 @@
12 <button type="submit" class="btn">Save</button> 12 <button type="submit" class="btn">Save</button>
13 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>{{end}} 13 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>{{end}}
14 <td>{{.Issues}}</td> 14 <td>{{.Issues}}</td>
15 <td>{{.MRs}}</td>
15 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline"> 16 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
16 <input type="hidden" name="action" value="remove"> 17 <input type="hidden" name="action" value="remove">
17 <input type="hidden" name="name" value="{{.Name}}"> 18 <input type="hidden" name="name" value="{{.Name}}">
@@ -32,6 +33,6 @@
32 <button type="submit" class="btn">Create label</button> 33 <button type="submit" class="btn">Create label</button>
33 </form> 34 </form>
34</details> 35</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>
36{{end}} 37{{end}}
37{{end}} 38{{end}}
internal/web/templates/mr.html +12
@@ -166,6 +166,18 @@
166 <h2>Milestone</h2> 166 <h2>Milestone</h2>
167 <p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a></p> 167 <p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a></p>
168 </div>{{end}} 168 </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>
169</aside> 181</aside>
170</div> 182</div>
171{{end}} 183{{end}}
internal/web/templates/mrs.html +3 −2
@@ -13,13 +13,14 @@
13 <button type="submit" class="btn">Search</button> 13 <button type="submit" class="btn">Search</button>
14 <input type="hidden" name="state" value="{{.State}}"> 14 <input type="hidden" name="state" value="{{.State}}">
15 </form> 15 </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}}
17</div> 17</div>
18{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} 18{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
19<ul class="issuelist"> 19<ul class="issuelist">
20{{range .MRs}}{{$n := .Number}}<li> 20{{range .MRs}}{{$n := .Number}}<li>
21 <div class="issuemain"> 21 <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>
23 <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p> 24 <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p>
24 </div> 25 </div>
25 <div class="issueside"> 26 <div class="issueside">
internal/web/templates/orglabels.html +2 −1
@@ -3,11 +3,12 @@
3<h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1> 3<h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1>
4<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> 4<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>
5{{if .Labels}}<div class="tablewrap"><table class="keys"> 5{{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>
7{{range .Labels}}<tr> 7{{range .Labels}}<tr>
8 <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td> 8 <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td>
9 <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td> 9 <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td>
10 <td>{{.Issues}}</td> 10 <td>{{.Issues}}</td>
11 <td>{{.MRs}}</td>
11</tr> 12</tr>
12{{end}}</table></div> 13{{end}}</table></div>
13{{else}}<p class="none">No org labels yet.</p>{{end}} 14{{else}}<p class="none">No org labels yet.</p>{{end}}