mr: labels on merge requests !424
25 files changed, +538 −129
Layout: unified · split
.gitbay/wiki/API.org +2 −2
| @@ -146,8 +146,8 @@ silently never fire. | ||
| 146 | 146 | =issue.assigned= (assignees), =issue.milestoned= (milestone) |
| 147 | 147 | - merge requests: =mr.created=, =mr.edited=, =mr.commented=, |
| 148 | 148 | =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= | |
| 151 | 151 | - releases: =release.created= (tag), =release.deleted= (tag) |
| 152 | 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 | 48 | | create from a fork | yes | yes | yes | |
| 49 | 49 | | retarget | yes | yes | yes | |
| 50 | 50 | | milestone | yes | yes | yes | |
| 51 | | labels | yes | yes | no | | |
| 52 | | filter by label | yes | yes | no | | |
| 51 | 53 | | request a review | yes | yes | yes | |
| 52 | 54 | | choose body markup | yes | yes | yes | |
| 53 | 55 | | stacked merge requests | yes | yes | yes | |
| @@ -118,12 +120,13 @@ reviews, since an approval was of the diff against the old branch. | ||
| 118 | 120 | | org milestones: create, list, close, reopen | yes | list | yes | |
| 119 | 121 | | closes across repositories | yes | yes | yes | |
| 120 | 122 | |
| 121 | Labels are created on the fly by =issue label --add= and managed by | |
| 122 | =label list=, =label set <label> --color rrggbb= and =label remove=, | |
| 123 | which takes the label off every issue. The web paints the stored colour | |
| 124 | on every chip and derives one from the name when none is set. The set | |
| 125 | itself is at =/<owner>/<repo>/labels=, linked from the issue list: | |
| 126 | create, recolour and remove, dispatching the same commands. | |
| 123 | Labels are created on the fly by =issue label --add= and =mr label | |
| 124 | --add=, and managed by =label list=, =label set <label> --color rrggbb= | |
| 125 | and =label remove=, which takes the label off every issue and merge | |
| 126 | request. One set serves both. The web paints the stored colour on every | |
| 127 | chip and derives one from the name when none is set. The set itself is | |
| 128 | at =/<owner>/<repo>/labels=, linked from the issue list: create, | |
| 129 | recolour and remove, dispatching the same commands. | |
| 127 | 130 | |
| 128 | 131 | Org labels and milestones are managed on the CLI, the API and the iOS |
| 129 | 132 | client'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 | 370 | #+end_src |
| 371 | 371 | |
| 372 | 372 | An org holds labels and milestones every repository under it sees |
| 373 | beside its own. =issue label --add=, =issue milestone= and =mr | |
| 374 | milestone= resolve the org's row first; a repository cannot create a | |
| 375 | label or milestone with a name its org holds. Creating an org label or | |
| 376 | milestone whose name repositories under the org already use folds them | |
| 377 | in: their issues and merge requests move to the org's row. Org admins | |
| 378 | manage them; counts span the repositories you can read. | |
| 373 | beside its own. =issue label --add=, =mr label --add=, =issue | |
| 374 | milestone= and =mr milestone= resolve the org's row first; a repository | |
| 375 | cannot create a label or milestone with a name its org holds. Creating | |
| 376 | an org label or milestone whose name repositories under the org already | |
| 377 | use folds them in: their issues and merge requests move to the org's | |
| 378 | row. Org admins manage them; counts span the repositories you can read. | |
| 379 | 379 | |
| 380 | 380 | #+begin_src sh |
| 381 | 381 | gitbay org label set acme bug --color cf222e |
| @@ -394,14 +394,14 @@ form prefills its textarea, and =gitbay issue templates= lists them. | ||
| 394 | 394 | |
| 395 | 395 | Lists narrow the same way on every surface: =issue list --label bug |
| 396 | 396 | --assignee bob --author alice --milestone v1= (or =--milestone none=), |
| 397 | =mr list --author bob --milestone v1=; the web's issue and merge request | |
| 398 | lists take the same names as query parameters, and each active filter | |
| 399 | shows with a link that drops it. | |
| 397 | =mr list --label bug --author bob --milestone v1=; the web's issue and | |
| 398 | merge request lists take the same names as query parameters, and each | |
| 399 | active filter shows with a link that drops it. | |
| 400 | 400 | |
| 401 | 401 | Labels take a colour: =gitbay label set bug --color cf222e=; =label |
| 402 | list= shows each with its colour and how many issues carry it, and | |
| 403 | =label remove= takes one off every issue. =issue label --add= still | |
| 404 | creates a colourless label on the fly. | |
| 402 | list= shows each with its colour and how many issues and merge requests | |
| 403 | carry 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. | |
| 405 | 405 | |
| 406 | 406 | * Merge requests |
| 407 | 407 | |
| @@ -411,6 +411,7 @@ gitbay mr create other/upstream --source you/fork:feature --target main --title | ||
| 411 | 411 | gitbay mr list / show 4 / diff 4 |
| 412 | 412 | gitbay mr checkout 4 # local branch mr/4 from the MR head |
| 413 | 413 | gitbay mr review 4 --approve # or --request-changes / --comment |
| 414 | gitbay mr label 4 --add bug --remove wontfix | |
| 414 | 415 | gitbay mr merge 4 [--strategy ff|merge|squash|rebase] |
| 415 | 416 | gitbay mr close 4 |
| 416 | 417 | #+end_src |
cmd/gitbay/main.go +2 −1
| @@ -580,7 +580,7 @@ func mrCmd() *cobra.Command { | ||
| 580 | 580 | return group("mr", "merge requests", |
| 581 | 581 | pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", |
| 582 | 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 | 584 | pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), |
| 585 | 585 | pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), |
| 586 | 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 | 598 | pass("draft", "mark as work in progress", passOpts{server: []string{"mr", "draft"}, needsRepo: true}), |
| 599 | 599 | pass("ready", "take the draft mark off, so it can merge", passOpts{server: []string{"mr", "ready"}, needsRepo: true}), |
| 600 | 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 | 602 | pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}), |
| 602 | 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 | 23 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "label", "alice/app", "1", "--add", "bug"); code != 0 { |
| 24 | 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 | 27 | t.Fatalf("list after issue label:\n%s", out) |
| 28 | 28 | } |
| 29 | 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 | 39 | t.Fatal("create without colour failed") |
| 40 | 40 | } |
| 41 | 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 | 44 | t.Fatalf("list json:\n%s", out) |
| 44 | 45 | } |
| 45 | 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 | 39 | t.Fatal("label recolour failed") |
| 40 | 40 | } |
| 41 | 41 | 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}`) { | |
| 44 | 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 | 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. | |
| 509 | func 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 | 33 | "mr.created", |
| 34 | 34 | "mr.draft", |
| 35 | 35 | "mr.edited", |
| 36 | "mr.labeled", | |
| 36 | 37 | "mr.merged", |
| 37 | 38 | "mr.milestoned", |
| 38 | 39 | "mr.retargeted", |
internal/control/label.go +3 −3
| @@ -14,13 +14,13 @@ import ( | ||
| 14 | 14 | |
| 15 | 15 | func init() { |
| 16 | 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 | 18 | Usage: "label list <owner/name>", ReadOnly: true, Run: runLabelList}) |
| 19 | 19 | register(Command{Path: []string{"label", "set"}, |
| 20 | 20 | Summary: "create a label or set its colour", |
| 21 | 21 | Usage: "label set <owner/name> <label> [--color rrggbb|'']", Run: runLabelSet}) |
| 22 | 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 | 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 | 48 | return c.emit(labels, func(w io.Writer) { |
| 49 | 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 | 59 | Usage: "mr ready <owner/name> <n>", Run: runMRReady}) |
| 60 | 60 | register(Command{Path: []string{"mr", "list"}, |
| 61 | 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 | 63 | register(Command{Path: []string{"mr", "show"}, |
| 64 | 64 | Summary: "show a merge request", |
| 65 | 65 | Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) |
| @@ -83,6 +83,9 @@ func init() { | ||
| 83 | 83 | register(Command{Path: []string{"mr", "review", "request"}, |
| 84 | 84 | Summary: "ask specific people for a review", |
| 85 | 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 | 89 | register(Command{Path: []string{"mr", "merge"}, |
| 87 | 90 | Summary: "merge", |
| 88 | 91 | Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge}) |
| @@ -365,14 +368,15 @@ type mrOut struct { | ||
| 365 | 368 | Title string `json:"title"` |
| 366 | 369 | State string `json:"state"` |
| 367 | 370 | // 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"` | |
| 376 | 380 | // ReviewRequests is who has been asked, directly, for a review. |
| 377 | 381 | ReviewRequests []string `json:"review_requests,omitempty"` |
| 378 | 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 | 435 | o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Draft: m.Draft, Author: m.Author, |
| 432 | 436 | 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, | |
| 435 | 439 | ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy} |
| 436 | 440 | if withBody { |
| 437 | 441 | o.Body = m.Body |
| @@ -446,7 +450,7 @@ func runMRList(c *Ctx, args []string) int { | ||
| 446 | 450 | return code |
| 447 | 451 | } |
| 448 | 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 | 454 | if err != nil { |
| 451 | 455 | return c.fail(protocol.ExitUsage, "%v", err) |
| 452 | 456 | } |
| @@ -454,7 +458,7 @@ func runMRList(c *Ctx, args []string) int { | ||
| 454 | 458 | if fl.Has("--state") { |
| 455 | 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 | 462 | f.Search = fl.Value("--search") |
| 459 | 463 | if fl.Has("--search") { |
| 460 | 464 | if err := validQuery(f.Search); err != nil { |
| @@ -586,6 +590,9 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 586 | 590 | state = "draft" |
| 587 | 591 | } |
| 588 | 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 | 596 | if len(d.ReviewRequests) > 0 { |
| 590 | 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). | |
| 970 | func 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 | ||
| 961 | 1009 | func runMRMerge(c *Ctx, args []string) int { |
| 962 | 1010 | f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"}) |
| 963 | 1011 | if err != nil { |
internal/control/orglabel.go +1 −1
| @@ -129,7 +129,7 @@ func runOrgLabelList(c *Ctx, args []string) int { | ||
| 129 | 129 | } |
| 130 | 130 | return c.emit(labels, func(w io.Writer) { |
| 131 | 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 | 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). | |
| 181 | func 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 | 66 | s.done(w, r, code, msg, s.mrRedirect) |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | func (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 | ||
| 69 | 79 | func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 70 | 80 | args := []string{} |
| 71 | 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 | 202 | Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, |
| 203 | 203 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review-request", Mutating: true, |
| 204 | 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 | 207 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/merge", Mutating: true, |
| 206 | 208 | Handler: s.checkOrigin(s.requireUser(s.mrMergeSubmit))}, |
| 207 | 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 | 1814 | state = "open" |
| 1815 | 1815 | } |
| 1816 | 1816 | 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} | |
| 1819 | 1819 | mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64) |
| 1820 | 1820 | mrs, err := s.st.QueryMRs(p.Repo.ID, mf) |
| 1821 | 1821 | if err != nil { |
| @@ -1841,19 +1841,26 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { | ||
| 1841 | 1841 | if err != nil { |
| 1842 | 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 | 1848 | rows := make([]mrRow, len(mrs)) |
| 1845 | 1849 | for i, m := range mrs { |
| 1850 | m.Labels = labels[m.ID] | |
| 1846 | 1851 | rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]} |
| 1847 | 1852 | } |
| 1848 | 1853 | s.render(w, "mrs.html", struct { |
| 1849 | 1854 | 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 | |
| 1855 | 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 | |
| 1859 | 1866 | func (s *Server) mr(w http.ResponseWriter, r *http.Request) { |
| @@ -2016,10 +2023,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 2016 | 2023 | HeadMerged bool |
| 2017 | 2024 | HeadPruned bool |
| 2018 | 2025 | Base string |
| 2026 | LabelColors map[string]template.CSS | |
| 2019 | 2027 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), |
| 2020 | 2028 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), |
| 2021 | 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 | 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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | "fmt" | |
| 7 | 6 | "strings" |
| 8 | 7 | ) |
| 9 | 8 | |
| @@ -275,26 +274,7 @@ func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error | ||
| 275 | 274 | // issue listing; ListIssues itself stays label-free for the CLI's lean |
| 276 | 275 | // list output. |
| 277 | 276 | func (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) | |
| 298 | 278 | } |
| 299 | 279 | |
| 300 | 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 | 301 | // resolves the org's row when the org has the name, else the repository's, |
| 322 | 302 | // creating that on first use. |
| 323 | 303 | func (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) | |
| 357 | 305 | } |
| 358 | 306 | |
| 359 | 307 | // SetIssueAssignee adds or removes an assignee by user id. |
internal/store/labels.go +107 −19
| @@ -3,28 +3,46 @@ package store | ||
| 3 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | "fmt" | |
| 6 | 7 | ) |
| 7 | 8 | |
| 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. | |
| 11 | 12 | type Label struct { |
| 12 | 13 | Name string `json:"name"` |
| 13 | 14 | Color string `json:"color,omitempty"` |
| 14 | 15 | Org bool `json:"org,omitempty"` |
| 15 | 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 | |
| 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. | |
| 23 | type labelJoin struct { | |
| 24 | table string | |
| 25 | item string | |
| 26 | items string | |
| 27 | } | |
| 28 | ||
| 29 | var ( | |
| 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. | |
| 21 | 37 | func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) { |
| 22 | 38 | in, inArgs := inClause(readable) |
| 23 | 39 | q := `SELECT l.name, l.color, l.org_id IS NOT NULL, |
| 24 | 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 | 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 | 46 | if err != nil { |
| 29 | 47 | return nil, err |
| 30 | 48 | } |
| @@ -32,7 +50,7 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, | ||
| 32 | 50 | var out []Label |
| 33 | 51 | for rows.Next() { |
| 34 | 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 | 54 | return nil, err |
| 37 | 55 | } |
| 38 | 56 | out = append(out, l) |
| @@ -40,6 +58,71 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, | ||
| 40 | 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. | |
| 64 | func (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. | |
| 90 | func (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 | 126 | // ListLabels lists the labels a repository sees: its org's first, then its |
| 44 | 127 | // own, each by name. |
| 45 | 128 | func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) { |
| @@ -102,8 +185,9 @@ func (s *Store) SetLabel(repo Repo, name, color string) error { | ||
| 102 | 185 | return tx.Commit() |
| 103 | 186 | } |
| 104 | 187 | |
| 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. | |
| 107 | 191 | func (s *Store) DeleteLabel(repo Repo, name string) error { |
| 108 | 192 | tx, err := s.DB.Begin() |
| 109 | 193 | if err != nil { |
| @@ -127,8 +211,9 @@ func (s *Store) DeleteLabel(repo Repo, name string) error { | ||
| 127 | 211 | } |
| 128 | 212 | |
| 129 | 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 | |
| 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. | |
| 132 | 217 | func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) { |
| 133 | 218 | tx, err := s.DB.Begin() |
| 134 | 219 | if err != nil { |
| @@ -162,19 +247,22 @@ func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) { | ||
| 162 | 247 | } |
| 163 | 248 | |
| 164 | 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. | |
| 166 | 252 | func 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 | } | |
| 171 | 259 | } |
| 172 | 260 | _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow) |
| 173 | 261 | return err |
| 174 | 262 | } |
| 175 | 263 | |
| 176 | 264 | // DeleteOrgLabel removes an org's label from the org and from every issue |
| 177 | // under it. | |
| 265 | // and merge request under it. | |
| 178 | 266 | func (s *Store) DeleteOrgLabel(orgID int64, name string) error { |
| 179 | 267 | res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name) |
| 180 | 268 | if err != nil { |
internal/store/labels_test.go +110
| @@ -15,6 +15,8 @@ type acmeFixture struct { | ||
| 15 | 15 | app Repo |
| 16 | 16 | coreIssue int64 |
| 17 | 17 | siteIssue int64 |
| 18 | coreMR int64 | |
| 19 | siteMR int64 | |
| 18 | 20 | } |
| 19 | 21 | |
| 20 | 22 | func newAcme(t *testing.T) acmeFixture { |
| @@ -63,6 +65,21 @@ func newAcme(t *testing.T) acmeFixture { | ||
| 63 | 65 | } |
| 64 | 66 | f.coreIssue = mkIssue(f.core, "c1") |
| 65 | 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 | 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). | |
| 185 | func 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. | |
| 232 | func 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 | ||
| 165 | 275 | func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) { |
| 166 | 276 | f := newAcme(t) |
| 167 | 277 | if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil { |
internal/store/migrations/0056_mr_labels.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | DROP 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. | |
| 8 | CREATE 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 | 34 | SupersededBy int64 |
| 35 | 35 | CreatedAt string |
| 36 | 36 | UpdatedAt string |
| 37 | Labels []string | |
| 37 | 38 | // ReviewRequests is who has been asked, directly, for a review — the |
| 38 | 39 | // mr review request counterpart of Issue.Assignees. |
| 39 | 40 | ReviewRequests []string |
| @@ -121,12 +122,32 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) { | ||
| 121 | 122 | if err != nil { |
| 122 | 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 | 130 | m.ReviewRequests, err = s.issueStrings(m.ID, ` |
| 125 | 131 | SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id |
| 126 | 132 | WHERE rr.mr_id = ? ORDER BY u.username`) |
| 127 | 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. | |
| 140 | func (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. | |
| 147 | func (s *Store) SetMRLabel(repo Repo, mrID int64, name string, add bool) error { | |
| 148 | return s.setItemLabel(mrLabelJoin, repo, mrID, name, add) | |
| 149 | } | |
| 150 | ||
| 130 | 151 | // SetMRReviewRequest adds or removes a review request by user id — the |
| 131 | 152 | // mr review request counterpart of SetIssueAssignee. |
| 132 | 153 | func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error { |
| @@ -160,6 +181,7 @@ func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) { | ||
| 160 | 181 | // too. Milestone "none" selects merge requests with no milestone. |
| 161 | 182 | type MRFilter struct { |
| 162 | 183 | State string |
| 184 | Label string | |
| 163 | 185 | Author string |
| 164 | 186 | Milestone string |
| 165 | 187 | Search string // full-text over title and body |
| @@ -180,6 +202,11 @@ func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) { | ||
| 180 | 202 | q += " AND m.state = ?" |
| 181 | 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 | 210 | if f.Author != "" { |
| 184 | 211 | q += " AND u.username = ?" |
| 185 | 212 | args = append(args, f.Author) |
internal/web/templates/labels.html +3 −2
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | <h1>Labels</h1> |
| 4 | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 5 | 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 | 7 | {{range .Labels}}<tr> |
| 8 | 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 | 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 | 12 | <button type="submit" class="btn">Save</button> |
| 13 | 13 | </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>{{end}} |
| 14 | 14 | <td>{{.Issues}}</td> |
| 15 | <td>{{.MRs}}</td> | |
| 15 | 16 | <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline"> |
| 16 | 17 | <input type="hidden" name="action" value="remove"> |
| 17 | 18 | <input type="hidden" name="name" value="{{.Name}}"> |
| @@ -32,6 +33,6 @@ | ||
| 32 | 33 | <button type="submit" class="btn">Create label</button> |
| 33 | 34 | </form> |
| 34 | 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 | 37 | {{end}} |
| 37 | 38 | {{end}} |
internal/web/templates/mr.html +12
| @@ -166,6 +166,18 @@ | ||
| 166 | 166 | <h2>Milestone</h2> |
| 167 | 167 | <p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a></p> |
| 168 | 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 | 181 | </aside> |
| 170 | 182 | </div> |
| 171 | 183 | {{end}} |
internal/web/templates/mrs.html +3 −2
| @@ -13,13 +13,14 @@ | ||
| 13 | 13 | <button type="submit" class="btn">Search</button> |
| 14 | 14 | <input type="hidden" name="state" value="{{.State}}"> |
| 15 | 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 | 17 | </div> |
| 18 | 18 | {{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} |
| 19 | 19 | <ul class="issuelist"> |
| 20 | 20 | {{range .MRs}}{{$n := .Number}}<li> |
| 21 | 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 | 24 | <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p> |
| 24 | 25 | </div> |
| 25 | 26 | <div class="issueside"> |
internal/web/templates/orglabels.html +2 −1
| @@ -3,11 +3,12 @@ | ||
| 3 | 3 | <h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1> |
| 4 | 4 | <p class="meta">Every repository under {{.Org}} sees these beside its own. Managed with <code>gitbay org label set {{.Org}} <label></code>; counts span the repositories you can read.</p> |
| 5 | 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 | 7 | {{range .Labels}}<tr> |
| 8 | 8 | <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td> |
| 9 | 9 | <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td> |
| 10 | 10 | <td>{{.Issues}}</td> |
| 11 | <td>{{.MRs}}</td> | |
| 11 | 12 | </tr> |
| 12 | 13 | {{end}}</table></div> |
| 13 | 14 | {{else}}<p class="none">No org labels yet.</p>{{end}} |