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

https://gitbay.org

Commit c6c5cb3409

c6c5cb340929ee44650344d2fe40f5badb7bd65a

parent: f99b620c29

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

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

e2e, wiki: merge request labels

TestMRWebLabels labels a merge request from the browser, checks the CLI
sees it, filters the list by it, and checks a reader gets chips and no
form. label list --json now carries mrs, so labelweb's pinned rows do
too.

Closes #231
.gitbay/wiki/Parity.org +9 −6
@@ -48,6 +48,8 @@ browser-only and the iOS build screen unable to say more than the log.
4848| create from a fork | yes | yes | yes |
4949| retarget | yes | yes | yes |
5050| milestone | yes | yes | yes |
51| labels | yes | yes | no |
52| filter by label | yes | yes | no |
5153| request a review | yes | yes | yes |
5254| choose body markup | yes | yes | yes |
5355| stacked merge requests | yes | yes | yes |
@@ -118,12 +120,13 @@ reviews, since an approval was of the diff against the old branch.
118120| org milestones: create, list, close, reopen | yes | list | yes |
119121| closes across repositories | yes | yes | yes |
120122
121Labels are created on the fly by =issue label --add= and managed by
122=label list=, =label set <label> --color rrggbb= and =label remove=,
123which takes the label off every issue. The web paints the stored colour
124on every chip and derives one from the name when none is set. The set
125itself is at =/<owner>/<repo>/labels=, linked from the issue list:
126create, recolour and remove, dispatching the same commands.
123Labels are created on the fly by =issue label --add= and =mr label
124--add=, and managed by =label list=, =label set <label> --color rrggbb=
125and =label remove=, which takes the label off every issue and merge
126request. One set serves both. The web paints the stored colour on every
127chip and derives one from the name when none is set. The set itself is
128at =/<owner>/<repo>/labels=, linked from the issue list: create,
129recolour and remove, dispatching the same commands.
127130
128131Org labels and milestones are managed on the CLI, the API and the iOS
129132client's org screen; =/<org>/-/labels= and =/<org>/-/milestones= show
.gitbay/wiki/Users.org +8 −6
@@ -370,8 +370,9 @@ gitbay milestone close v1.0 / reopen v1.0
370370#+end_src
371371
372372An org holds labels and milestones every repository under it sees
373beside its own. =issue label --add=, =issue milestone= and =mr
374milestone= resolve the org's row first; a repository cannot create a
373beside its own. =issue label --add=, =mr label --add=, =issue
374milestone= and =mr milestone= resolve the org's row first; a repository
375cannot create a
375376label or milestone with a name its org holds. Creating an org label or
376377milestone whose name repositories under the org already use folds them
377378in: their issues and merge requests move to the org's row. Org admins
@@ -394,14 +395,14 @@ form prefills its textarea, and =gitbay issue templates= lists them.
394395
395396Lists narrow the same way on every surface: =issue list --label bug
396397--assignee bob --author alice --milestone v1= (or =--milestone none=),
397=mr list --author bob --milestone v1=; the web's issue and merge request
398=mr list --label bug --author bob --milestone v1=; the web's issue and merge request
398399lists take the same names as query parameters, and each active filter
399400shows with a link that drops it.
400401
401402Labels take a colour: =gitbay label set bug --color cf222e=; =label
402list= shows each with its colour and how many issues carry it, and
403=label remove= takes one off every issue. =issue label --add= still
404creates a colourless label on the fly.
403list= shows each with its colour and how many issues and merge requests
404carry it, and =label remove= takes one off all of them. =issue label
405--add= and =mr label --add= still create a colourless label on the fly.
405406
406407* Merge requests
407408
@@ -411,6 +412,7 @@ gitbay mr create other/upstream --source you/fork:feature --target main --title
411412gitbay mr list / show 4 / diff 4
412413gitbay mr checkout 4 # local branch mr/4 from the MR head
413414gitbay mr review 4 --approve # or --request-changes / --comment
415gitbay mr label 4 --add bug --remove wontfix
414416gitbay mr merge 4 [--strategy ff|merge|squash|rebase]
415417gitbay mr close 4
416418#+end_src
e2e/labelweb_test.go +2 −2
@@ -39,8 +39,8 @@ func TestLabelsWeb(t *testing.T) {
3939 t.Fatal("label recolour failed")
4040 }
4141 out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json")
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1}`) ||
43 !strings.Contains(out, `{"name":"docs","color":"#1f6feb","issues":0}`) {
42 if !strings.Contains(out, `{"name":"bug","color":"#cf222e","issues":1,"mrs":0}`) ||
43 !strings.Contains(out, `{"name":"docs","color":"#1f6feb","issues":0,"mrs":0}`) {
4444 t.Fatalf("labels not as posted:\n%s", out)
4545 }
4646
e2e/mrweb_test.go +82
@@ -502,3 +502,85 @@ func TestMRSupersedes(t *testing.T) {
502502 t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2)
503503 }
504504}
505
506// TestMRWebLabels labels a merge request from the browser and filters the
507// list by it (#231). The CLI is the check that the page dispatched
508// mr label rather than writing its own rows.
509func TestMRWebLabels(t *testing.T) {
510 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
511 aliceKey := inst.newKey(t, "alice")
512 bobKey := inst.newKey(t, "bob")
513 inst.admin(t, "admin", "user", "create", "alice",
514 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
515 inst.admin(t, "admin", "user", "create", "bob",
516 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
517 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
518 t.Fatalf("repo create: %s", errOut)
519 }
520 env := inst.gitEnv(aliceKey)
521 work := t.TempDir()
522 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
523 dir := filepath.Join(work, "w")
524 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
525 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
526 mustGit(t, dir, env, "add", ".")
527 mustGit(t, dir, env, "commit", "-q", "-m", "base")
528 mustGit(t, dir, env, "push", "-q", "origin", "main")
529 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
530 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
531 mustGit(t, dir, env, "add", ".")
532 mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
533 mustGit(t, dir, env, "push", "-q", "origin", "topic")
534 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
535 "--source", "topic", "--target", "main", "--title", "feature"); code != 0 {
536 t.Fatalf("mr create: %s", errOut)
537 }
538
539 alice := inst.login(t, aliceKey)
540 mrURL := inst.base() + "/alice/app/mrs/1"
541
542 // The form is on the page, and applying it lands in the CLI's view.
543 if _, body := browserGet(t, alice, mrURL); !strings.Contains(body, `/mrs/1/label`) {
544 t.Fatalf("MR page has no label form:\n%s", body)
545 }
546 if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"add": {"bug ui"}}); status != 200 {
547 t.Fatalf("label post: %d", status)
548 }
549 out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
550 for _, want := range []string{`"bug"`, `"ui"`} {
551 if !strings.Contains(out, want) {
552 t.Fatalf("label %s did not land:\n%s", want, out)
553 }
554 }
555 _, body := browserGet(t, alice, mrURL)
556 if n := strings.Count(body, `class="chip label"`); n != 2 {
557 t.Fatalf("MR page shows %d label chips, want 2:\n%s", n, body)
558 }
559
560 // Removing works the same way.
561 if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"remove": {"ui"}}); status != 200 {
562 t.Fatalf("label remove: %d", status)
563 }
564 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); strings.Contains(out, `"ui"`) {
565 t.Fatalf("label not removed:\n%s", out)
566 }
567
568 // The list narrows by label, and says which one it is narrowed by.
569 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=bug")
570 if !strings.Contains(body, ">feature<") || !strings.Contains(body, `label: <span class="chip label"`) {
571 t.Fatalf("web label filter:\n%s", body)
572 }
573 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=ui")
574 if strings.Contains(body, ">feature<") {
575 t.Fatalf("removed label still lists the merge request:\n%s", body)
576 }
577
578 // A reader gets the chips and no form.
579 _, body = browserGet(t, inst.login(t, bobKey), mrURL)
580 if !strings.Contains(body, `class="chip label"`) {
581 t.Fatalf("reader sees no labels:\n%s", body)
582 }
583 if strings.Contains(body, `/mrs/1/label`) {
584 t.Fatalf("reader sees the label form:\n%s", body)
585 }
586}