Commit d35194d66f
Verified · cmc
Layout: unified · split
internal/control/build.go +18 −2
| @@ -213,13 +213,29 @@ func runBuildList(c *Ctx, args []string) int { | ||
| 213 | 213 | for i := range ds { |
| 214 | 214 | ds[i].Subject = subjects[ds[i].SHA] |
| 215 | 215 | } |
| 216 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 216 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 217 | 217 | tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE") |
| 218 | 218 | for _, d := range ds { |
| 219 | 219 | tb.row(cLink(fmt.Sprintf("%d", d.Number), c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10))), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject)) |
| 220 | 220 | } |
| 221 | 221 | tb.flush() |
| 222 | }) | |
| 222 | }, func() screen { return buildListScreen(c, repo, ds) }) | |
| 223 | } | |
| 224 | ||
| 225 | // buildListScreen is build list at a terminal: one section of builds, | |
| 226 | // each led by its outcome's mark, then job, ref, commit subject and age. | |
| 227 | func buildListScreen(c *Ctx, repo store.Repo, ds []BuildOut) screen { | |
| 228 | sec := section{title: "Builds", n: len(ds)} | |
| 229 | for _, d := range ds { | |
| 230 | n := strconv.FormatInt(d.Number, 10) | |
| 231 | sec.rows = append(sec.rows, rowOf(cLink(n, c.siteURL(repo.Path(), "builds", n)), cGlyph(d.Status), | |
| 232 | cText(d.Job), cText(d.Ref), cFlex(d.Subject), cAge(d.CreatedAt))) | |
| 233 | } | |
| 234 | s := screen{sections: []section{sec}} | |
| 235 | if len(ds) > 0 { | |
| 236 | s.actions = []action{{"Read", []string{"build", "show", repo.Path(), strconv.FormatInt(ds[0].Number, 10)}}} | |
| 237 | } | |
| 238 | return s | |
| 223 | 239 | } |
| 224 | 240 | |
| 225 | 241 | // buildSubjects reads the commit subject of each distinct sha on a page |
internal/control/issue.go +38 −18
| @@ -354,29 +354,13 @@ func runIssueList(c *Ctx, args []string) int { | ||
| 354 | 354 | return c.fail(protocol.ExitFailure, "%v", err) |
| 355 | 355 | } |
| 356 | 356 | } |
| 357 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 358 | if c.Term.Cols > 0 { | |
| 359 | // At a terminal: who it waits on and how much it has moved, | |
| 360 | // in place of who opened it. | |
| 361 | tb := c.table(w, "#", "STATE", "TITLE", "LABELS", "ASSIGNEE", "COMMENTS", "UPDATED") | |
| 362 | for i, d := range ds { | |
| 363 | n := "" | |
| 364 | if k := comments[issues[i].ID]; k > 0 { | |
| 365 | n = strconv.Itoa(k) | |
| 366 | } | |
| 367 | tb.row(cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))), cState(d.State), cFlex(d.Title), | |
| 368 | cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username), | |
| 369 | cText(n), cAge(issues[i].UpdatedAt)) | |
| 370 | } | |
| 371 | tb.flush() | |
| 372 | return | |
| 373 | } | |
| 357 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 374 | 358 | tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR") |
| 375 | 359 | for _, d := range ds { |
| 376 | 360 | tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author)) |
| 377 | 361 | } |
| 378 | 362 | tb.flush() |
| 379 | }) | |
| 363 | }, func() screen { return issueListScreen(c, repo, f.State, issues, ds, comments, labels, assignees) }) | |
| 380 | 364 | } |
| 381 | 365 | |
| 382 | 366 | func runIssueShow(c *Ctx, args []string) int { |
| @@ -769,3 +753,39 @@ func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen | ||
| 769 | 753 | } |
| 770 | 754 | return s |
| 771 | 755 | } |
| 756 | ||
| 757 | // issueListScreen is issue list at a terminal: one section of issues, | |
| 758 | // each led by ● when it is assigned to the viewer, then title, labels, | |
| 759 | // assignees, comments and age. | |
| 760 | func issueListScreen(c *Ctx, repo store.Repo, state string, issues []store.Issue, ds []issueOut, comments map[int64]int, labels, assignees map[int64][]string) screen { | |
| 761 | title, ok := map[string]string{"open": "Open issues", "closed": "Closed issues"}[state] | |
| 762 | if !ok { | |
| 763 | title = "Issues" | |
| 764 | } | |
| 765 | sec := section{title: title, n: len(ds)} | |
| 766 | for i, d := range ds { | |
| 767 | id := issues[i].ID | |
| 768 | lead := cGlyph(d.State) | |
| 769 | if slices.Contains(assignees[id], c.User.Username) { | |
| 770 | lead = cYou() | |
| 771 | } | |
| 772 | n := "" | |
| 773 | switch k := comments[id]; { | |
| 774 | case k == 1: | |
| 775 | n = "1 comment" | |
| 776 | case k > 1: | |
| 777 | n = fmt.Sprintf("%d comments", k) | |
| 778 | } | |
| 779 | ref := cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))) | |
| 780 | sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), | |
| 781 | cMeta(labelsMark(labels[id]), labelsMark(assignees[id]), n, relAge(issues[i].UpdatedAt, termNow())))) | |
| 782 | } | |
| 783 | other := "closed" | |
| 784 | if state != "open" { | |
| 785 | other = "open" | |
| 786 | } | |
| 787 | return screen{sections: []section{sec}, actions: []action{ | |
| 788 | {"New", []string{"issue", "create", repo.Path()}}, | |
| 789 | {"Filter", []string{"issue", "list", repo.Path(), "--state", other}}, | |
| 790 | }} | |
| 791 | } | |
internal/control/listscreen_test.go added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "slices" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/protocol" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | func TestListsPlainPinned(t *testing.T) { | |
| 13 | st, repo, owner := twoMRTestRepo(t) | |
| 14 | if _, err := st.CreateIssue(repo.ID, owner.ID, "Android app", "", "md"); err != nil { | |
| 15 | t.Fatal(err) | |
| 16 | } | |
| 17 | if _, err := st.CreateBuild(repo.ID, "test", "abc111", "main", `["go test ./..."]`, "", "", true); err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | for name, argv := range map[string][]string{ | |
| 21 | "mr-list": {"mr", "list", repo.Path()}, | |
| 22 | "issue-list": {"issue", "list", repo.Path()}, | |
| 23 | "build-list": {"build", "list", repo.Path()}, | |
| 24 | } { | |
| 25 | c, out, errOut := mrTestCtx(st, owner) | |
| 26 | if code := Dispatch(c, argv); code != protocol.ExitOK { | |
| 27 | t.Fatalf("%s: exit %d: %s", name, code, errOut) | |
| 28 | } | |
| 29 | pinPlain(t, name, out.String()) | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | func TestMRListScreen(t *testing.T) { | |
| 34 | c := screenCtx(100, false) | |
| 35 | c.User = store.User{Username: "alice"} | |
| 36 | mrs := []store.MR{{ID: 1, Number: 552, UpdatedAt: "2026-10-01T11:40:00Z"}, {ID: 2, Number: 551, UpdatedAt: "2026-10-01T10:00:00Z"}} | |
| 37 | ds := []mrOut{ | |
| 38 | {Number: 552, Title: "wire $PAGER", State: "open", Source: "cli-pager", TargetRef: "main"}, | |
| 39 | {Number: 551, Title: "changelog", State: "open", Source: "release", TargetRef: "stable"}, | |
| 40 | } | |
| 41 | checks := map[int64]cell{1: cMark("✓ 2/2", sgrGreen), 2: cMark("✗ 1 failed", sgrRed)} | |
| 42 | review := map[int64]cell{1: cMark("review requested", sgrYellow)} | |
| 43 | s := mrListScreen(c, screenRepo, "open", mrs, ds, checks, review) | |
| 44 | if len(s.sections) != 1 || s.sections[0].title != "Open merge requests" || s.sections[0].n != 2 { | |
| 45 | t.Fatalf("sections = %+v", s.sections) | |
| 46 | } | |
| 47 | if g := s.sections[0].rows[0].cells[1]; g.s != "●" { | |
| 48 | t.Errorf("review requested of the viewer should lead with ●, got %q", g.s) | |
| 49 | } | |
| 50 | if g := s.sections[0].rows[1].cells[1]; g.s != "✗" || g.sgr != sgrRed { | |
| 51 | t.Errorf("failed checks should lead with a red ✗, got %+v", g) | |
| 52 | } | |
| 53 | out := renderString(c, s) | |
| 54 | if !strings.Contains(out, "release → stable") { | |
| 55 | t.Errorf("non-default target not shown:\n%s", out) | |
| 56 | } | |
| 57 | var verbs []string | |
| 58 | for _, a := range s.actions { | |
| 59 | verbs = append(verbs, strings.Join(a.argv[:2], " ")) | |
| 60 | } | |
| 61 | if !slices.Equal(verbs, []string{"mr create", "mr list"}) { | |
| 62 | t.Errorf("actions = %q", verbs) | |
| 63 | } | |
| 64 | checkActions(t, s) | |
| 65 | } | |
| 66 | ||
| 67 | func TestIssueListScreen(t *testing.T) { | |
| 68 | c := screenCtx(100, false) | |
| 69 | c.User = store.User{Username: "alice"} | |
| 70 | issues := []store.Issue{{ID: 1, Number: 12, UpdatedAt: "2026-09-01T10:00:00Z"}, {ID: 2, Number: 13, UpdatedAt: "2026-09-02T10:00:00Z"}} | |
| 71 | ds := []issueOut{{Number: 12, Title: "Android app", State: "open"}, {Number: 13, Title: "Docs", State: "open"}} | |
| 72 | s := issueListScreen(c, screenRepo, "open", issues, ds, map[int64]int{1: 3}, map[int64][]string{1: {"mobile"}}, map[int64][]string{1: {"alice"}}) | |
| 73 | sec := s.sections[0] | |
| 74 | if sec.title != "Open issues" || sec.n != 2 { | |
| 75 | t.Fatalf("section = %+v", sec) | |
| 76 | } | |
| 77 | if g := sec.rows[0].cells[1]; g.s != "●" { | |
| 78 | t.Errorf("assigned to the viewer should lead with ●, got %q", g.s) | |
| 79 | } | |
| 80 | if out := renderString(c, s); !strings.Contains(out, "mobile · alice · 3 comments") { | |
| 81 | t.Errorf("meta:\n%s", out) | |
| 82 | } | |
| 83 | var verbs []string | |
| 84 | for _, a := range s.actions { | |
| 85 | verbs = append(verbs, strings.Join(a.argv, " ")) | |
| 86 | } | |
| 87 | if !slices.Equal(verbs, []string{"issue create krz/gitbay", "issue list krz/gitbay --state closed"}) { | |
| 88 | t.Errorf("actions = %q", verbs) | |
| 89 | } | |
| 90 | checkActions(t, s) | |
| 91 | } | |
| 92 | ||
| 93 | func TestBuildListScreen(t *testing.T) { | |
| 94 | c := screenCtx(100, false) | |
| 95 | ds := []BuildOut{ | |
| 96 | {Number: 1784, Job: "test", Status: "running", Ref: "cli-views-2a", Subject: "docs: screens", CreatedAt: "2026-10-01T12:00:00Z"}, | |
| 97 | {Number: 1783, Job: "build", Status: "success", Ref: "cli-views-2a", Subject: "docs: screens", CreatedAt: "2026-10-01T12:00:00Z"}, | |
| 98 | } | |
| 99 | s := buildListScreen(c, screenRepo, ds) | |
| 100 | sec := s.sections[0] | |
| 101 | if sec.title != "Builds" || sec.n != 2 { | |
| 102 | t.Fatalf("section = %+v", sec) | |
| 103 | } | |
| 104 | if g := sec.rows[0].cells[1]; g.s != "◐" { | |
| 105 | t.Errorf("running build glyph = %q", g.s) | |
| 106 | } | |
| 107 | if len(s.actions) != 1 || strings.Join(s.actions[0].argv, " ") != "build show krz/gitbay 1784" { | |
| 108 | t.Errorf("actions = %+v", s.actions) | |
| 109 | } | |
| 110 | checkActions(t, s) | |
| 111 | } | |
internal/control/marks.go −9
| @@ -115,12 +115,3 @@ func labelsMark(labels []string) string { | ||
| 115 | 115 | } |
| 116 | 116 | return fmt.Sprintf("%s, +%d", strings.Join(labels[:2], ", "), len(labels)-2) |
| 117 | 117 | } |
| 118 | ||
| 119 | // assigneesMark is a row's assignees, yellow when the viewer is one. | |
| 120 | func assigneesMark(assignees []string, viewer string) cell { | |
| 121 | s := labelsMark(assignees) | |
| 122 | if slices.Contains(assignees, viewer) { | |
| 123 | return cMark(s, sgrYellow) | |
| 124 | } | |
| 125 | return cText(s) | |
| 126 | } | |
internal/control/marks_test.go +1 −7
| @@ -59,16 +59,10 @@ func TestReviewMark(t *testing.T) { | ||
| 59 | 59 | } |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | func TestLabelsAndAssignees(t *testing.T) { | |
| 62 | func TestLabelsMark(t *testing.T) { | |
| 63 | 63 | if got := labelsMark([]string{"a", "b", "c", "d"}); got != "a, b, +2" { |
| 64 | 64 | t.Errorf("labelsMark = %q", got) |
| 65 | 65 | } |
| 66 | if got := assigneesMark([]string{"me"}, "me"); got.sgr != sgrYellow { | |
| 67 | t.Errorf("viewer's assignment not yellow: %+v", got) | |
| 68 | } | |
| 69 | if got := assigneesMark([]string{"bob"}, "me"); got.sgr != "" { | |
| 70 | t.Errorf("someone else's assignment coloured: %+v", got) | |
| 71 | } | |
| 72 | 66 | } |
| 73 | 67 | |
| 74 | 68 | func TestFailingBuilds(t *testing.T) { |
internal/control/mr.go +35 −20
| @@ -685,11 +685,7 @@ func runMRList(c *Ctx, args []string) int { | ||
| 685 | 685 | return c.fail(protocol.ExitFailure, "%v", err) |
| 686 | 686 | } |
| 687 | 687 | } |
| 688 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 689 | if c.Term.Cols > 0 { | |
| 690 | mrListTerm(c, w, repo, mrs, ds, checks, review) | |
| 691 | return | |
| 692 | } | |
| 688 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 693 | 689 | tb := c.table(w, "!", "STATE", "TITLE", "REF") |
| 694 | 690 | for _, d := range ds { |
| 695 | 691 | state := d.State |
| @@ -703,31 +699,50 @@ func runMRList(c *Ctx, args []string) int { | ||
| 703 | 699 | tb.row(cells...) |
| 704 | 700 | } |
| 705 | 701 | tb.flush() |
| 706 | }) | |
| 702 | }, func() screen { return mrListScreen(c, repo, f.State, mrs, ds, checks, review) }) | |
| 707 | 703 | } |
| 708 | 704 | |
| 709 | // mrListTerm is mr list at a terminal: where checks and review stand | |
| 710 | // for each open merge request, and when it last changed. The source | |
| 705 | // mrListScreen is mr list at a terminal: one section of merge requests, | |
| 706 | // each led by ● when its review waits on the viewer, otherwise by its | |
| 707 | // checks' mark, then title, review state, branch and age. The source | |
| 711 | 708 | // branch alone names a merge request into the default branch. |
| 712 | func mrListTerm(c *Ctx, w io.Writer, repo store.Repo, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) { | |
| 713 | tb := c.table(w, "!", "STATE", "TITLE", "BRANCH", "CHECKS", "REVIEW", "UPDATED") | |
| 709 | func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen { | |
| 710 | title, ok := map[string]string{"open": "Open merge requests", "closed": "Closed merge requests", "merged": "Merged merge requests"}[state] | |
| 711 | if !ok { | |
| 712 | title = "Merge requests" | |
| 713 | } | |
| 714 | sec := section{title: title, n: len(ds)} | |
| 714 | 715 | for i, d := range ds { |
| 715 | state := d.State | |
| 716 | if d.Draft { | |
| 717 | state = "draft" | |
| 716 | m := mrs[i] | |
| 717 | lead := cell{kind: kindGlyph} | |
| 718 | if ch := checks[m.ID]; ch.s != "" { | |
| 719 | g, _, _ := strings.Cut(ch.s, " ") | |
| 720 | lead = cell{kind: kindGlyph, s: g, sgr: ch.sgr} | |
| 718 | 721 | } |
| 719 | ref := d.Source | |
| 722 | if review[m.ID].sgr == sgrYellow { | |
| 723 | lead = cYou() | |
| 724 | } | |
| 725 | branch := d.Source | |
| 720 | 726 | if d.TargetRef != repo.DefaultBranch { |
| 721 | ref += " -> " + d.TargetRef | |
| 727 | branch += " → " + d.TargetRef | |
| 722 | 728 | } |
| 723 | 729 | if d.StackedOn != nil { |
| 724 | ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number) | |
| 730 | branch += fmt.Sprintf(" (on !%d)", d.StackedOn.Number) | |
| 725 | 731 | } |
| 726 | m := mrs[i] | |
| 727 | tb.row(cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))), cState(state), cFlex(d.Title), cText(ref), | |
| 728 | checks[m.ID], review[m.ID], cAge(m.UpdatedAt)) | |
| 732 | if d.Draft { | |
| 733 | branch = "draft · " + branch | |
| 734 | } | |
| 735 | ref := cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))) | |
| 736 | sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), review[m.ID], cMeta(branch, relAge(m.UpdatedAt, termNow())))) | |
| 737 | } | |
| 738 | other := "all" | |
| 739 | if state == "all" { | |
| 740 | other = "open" | |
| 729 | 741 | } |
| 730 | tb.flush() | |
| 742 | return screen{sections: []section{sec}, actions: []action{ | |
| 743 | {"New", []string{"mr", "create", repo.Path()}}, | |
| 744 | {"Filter", []string{"mr", "list", repo.Path(), "--state", other}}, | |
| 745 | }} | |
| 731 | 746 | } |
| 732 | 747 | |
| 733 | 748 | // byWhom renders " by <user>", or nothing when the actor is unknown — an |
internal/control/testdata/plain/build-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | 1 test pending abc111 main | |
internal/control/testdata/plain/issue-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | #1 open Android app alice | |
internal/control/testdata/plain/mr-list.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | !2 open two feature2 -> main | |
| 2 | !1 open one feature1 -> main | |