control: show screens (CLI views stage 2a) !553
20 files changed, +880 −109
Layout: unified · split
.gitbay/wiki/Users.org +14 −1
| @@ -1034,7 +1034,20 @@ then prints: | ||
| 1034 | 1034 | both made safe like any user text, the log's own colour codes dropped; |
| 1035 | 1035 | - ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as |
| 1036 | 1036 | =53m43s=, where piped output keeps RFC3339, bytes and seconds; |
| 1037 | - =show= views with a title line, aligned fields, the body rendered | |
| 1037 | - =mr show=, =issue show=, =repo show= and =build show= drawn as | |
| 1038 | screens: a header block of =Label:= lines (labels dim), the | |
| 1039 | description, sections titled =Title (n)= in bold blue (=Commits=, | |
| 1040 | =Files=, =Discussion=, =Events=, =Steps=, open merge requests and | |
| 1041 | issues), and a legend under a rule of the commands that apply now, | |
| 1042 | grouped (=Unblock=, =Review=, =Merge=, =Read=, ...) and chosen from | |
| 1043 | the item's state and your access. Rows have no header and no indent: | |
| 1044 | a dim reference, a state glyph, the text, then dim facts joined by | |
| 1045 | =·=. The glyphs are =✓= passed, =✗= failed or blocked, =◐= still | |
| 1046 | going, =●= waiting on you, =○= closed or draft. A section cut short | |
| 1047 | ends with =+n more= and the command for the rest. Below 80 columns the | |
| 1048 | legend's groups stack; a suggested command is never cut, so it may run | |
| 1049 | past the width; | |
| 1050 | - other =show= views with a title line, aligned fields, the body rendered | |
| 1038 | 1051 | from markdown or org, one line per event, and comments under a rule; |
| 1039 | 1052 | a sub-table (labels, revisions, and the like) carries a section label |
| 1040 | 1053 | line in both terminal and piped output; timestamps as |
CHANGELOG.org +8
| @@ -12,6 +12,14 @@ anything beyond "replace the binary and restart" is needed. | ||
| 12 | 12 | sends the repository it inferred as =here== in =--term=, so suggested |
| 13 | 13 | commands leave it out; an older instance ignores the option. Piped |
| 14 | 14 | and =--json= output are unchanged. |
| 15 | - =mr show=, =issue show=, =repo show= and =build show= at a terminal | |
| 16 | are screens (#318): a header block, the description, counted | |
| 17 | sections, and a legend of the commands that apply, chosen from the | |
| 18 | item's state (=mr rebase= when behind, =mr merge= only when the gates | |
| 19 | pass, =issue close= only for the author or a writer). =mr show= adds | |
| 20 | the files with their line counts; =repo show= its open merge requests, | |
| 21 | open issues and latest commits; system events sit in their own | |
| 22 | =Events= section. Pending checks lead with =◐=. | |
| 15 | 23 | |
| 16 | 24 | * v1.41.0 — 2026-10-01 |
| 17 | 25 | |
internal/control/build.go +42 −21
| @@ -244,7 +244,13 @@ func runBuildShow(c *Ctx, args []string) int { | ||
| 244 | 244 | } |
| 245 | 245 | d := buildToOut(b) |
| 246 | 246 | json.Unmarshal([]byte(b.Steps), &d.Steps) |
| 247 | return c.emit(d, func(w io.Writer) { | |
| 247 | var mr *store.MR | |
| 248 | if c.Term.Cols > 0 && !c.JSON { | |
| 249 | if m, ok, err := c.Store.OpenMRBySource(repo.ID, d.Ref); err == nil && ok { | |
| 250 | mr = &m | |
| 251 | } | |
| 252 | } | |
| 253 | return c.emitView(d, func(w io.Writer) { | |
| 248 | 254 | failedStep, failed := "", "" |
| 249 | 255 | if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) { |
| 250 | 256 | step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n") |
| @@ -259,15 +265,6 @@ func runBuildShow(c *Ctx, args []string) int { | ||
| 259 | 265 | if d.DurationS > 0 { |
| 260 | 266 | duration = (time.Duration(d.DurationS) * time.Second).String() |
| 261 | 267 | } |
| 262 | // At a terminal: the open merge request the build ran for, and | |
| 263 | // the command that prints its log. | |
| 264 | mr, logCmd := "", "" | |
| 265 | if c.Term.Cols > 0 { | |
| 266 | if m, ok, err := c.Store.OpenMRBySource(repo.ID, d.Ref); err == nil && ok { | |
| 267 | mr = fmt.Sprintf("!%d %s", m.Number, m.Title) | |
| 268 | } | |
| 269 | logCmd = fmt.Sprintf("gitbay build log %s %d", repo.Path(), d.Number) | |
| 270 | } | |
| 271 | 268 | v := c.view(w) |
| 272 | 269 | v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) |
| 273 | 270 | v.fields( |
| @@ -278,20 +275,44 @@ func runBuildShow(c *Ctx, args []string) int { | ||
| 278 | 275 | "duration", duration, |
| 279 | 276 | "failed step", failedStep, |
| 280 | 277 | "failed", failed, |
| 281 | "mr", mr, | |
| 282 | "log", logCmd, | |
| 283 | 278 | "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), |
| 284 | 279 | ) |
| 285 | if c.Term.Cols > 0 && len(d.Steps) > 0 { | |
| 286 | v.section("steps") | |
| 287 | tb := c.table(w, "#", "STEP", "STATE") | |
| 288 | for i, step := range d.Steps { | |
| 289 | line, _, _ := strings.Cut(step, "\n") | |
| 290 | tb.row(cNum(int64(i+1)), cFlex(line), cState(stepState(d.Status, d.FailedStep, i+1))) | |
| 291 | } | |
| 292 | tb.flush() | |
| 280 | }, func() screen { return buildShowScreen(c, repo, d, mr) }) | |
| 281 | } | |
| 282 | ||
| 283 | // buildShowScreen is build show at a terminal: the build's outcome, what | |
| 284 | // it ran on, each step's outcome, and the command for its log. | |
| 285 | func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen { | |
| 286 | n := strconv.FormatInt(d.Number, 10) | |
| 287 | path := repo.Path() | |
| 288 | var s screen | |
| 289 | s.fields = append(s.fields, | |
| 290 | field{"Build", []cell{cLink(n, c.siteURL(path, "builds", n)), cText(d.Job)}}, | |
| 291 | field{"State", []cell{cGlyph(d.Status), cState(d.Status)}}, | |
| 292 | field{"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Subject)}}, | |
| 293 | field{"Ref", []cell{cText(d.Ref)}}, | |
| 294 | ) | |
| 295 | if mr != nil { | |
| 296 | s.fields = append(s.fields, field{"MR", []cell{cRef(fmt.Sprintf("!%d", mr.Number)), cText(mr.Title)}}) | |
| 297 | } | |
| 298 | if d.DurationS > 0 { | |
| 299 | s.fields = append(s.fields, field{"Duration", []cell{cText(c.Term.dur(d.DurationS))}}) | |
| 300 | } | |
| 301 | if !c.Term.Links { | |
| 302 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "builds", n))}}) | |
| 303 | } | |
| 304 | steps := section{title: "Steps", n: len(d.Steps)} | |
| 305 | for i, step := range d.Steps { | |
| 306 | line, _, _ := strings.Cut(step, "\n") | |
| 307 | meta := "" | |
| 308 | if i+1 == d.FailedStep { | |
| 309 | meta = d.FailedReason | |
| 293 | 310 | } |
| 294 | }) | |
| 311 | steps.rows = append(steps.rows, rowOf(cGlyph(stepState(d.Status, d.FailedStep, i+1)), cFlex(line), cMeta(meta))) | |
| 312 | } | |
| 313 | s.sections = []section{steps} | |
| 314 | s.actions = []action{{"Read", []string{"build", "log", path, n}}} | |
| 315 | return s | |
| 295 | 316 | } |
| 296 | 317 | |
| 297 | 318 | // stepState is what a finished build says about one of its steps: those |
internal/control/buildscreen_test.go added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strconv" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/protocol" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | func TestBuildShowPlainPinned(t *testing.T) { | |
| 13 | st, repo, uid := newQueueTestRepo(t) | |
| 14 | id, err := st.CreateBuild(repo.ID, "test", "a136534ff9a136534ff9a136534ff9a136534ff9", "main", `["go vet ./...","go test ./..."]`, "", "", true) | |
| 15 | if err != nil { | |
| 16 | t.Fatal(err) | |
| 17 | } | |
| 18 | if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok { | |
| 19 | t.Fatalf("claim: %v %v", ok, err) | |
| 20 | } | |
| 21 | if err := st.SetBuildFailure(id, 2, "exit 1"); err != nil { | |
| 22 | t.Fatal(err) | |
| 23 | } | |
| 24 | if err := st.FinishBuild(id, "failure"); err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | b, err := st.BuildByID(id) | |
| 28 | if err != nil { | |
| 29 | t.Fatal(err) | |
| 30 | } | |
| 31 | c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"}) | |
| 32 | if code := Dispatch(c, []string{"build", "show", repo.Path(), strconv.FormatInt(b.Number, 10)}); code != protocol.ExitOK { | |
| 33 | t.Fatalf("exit %d: %s", code, errOut) | |
| 34 | } | |
| 35 | pinPlain(t, "build-show", out.String()) | |
| 36 | } | |
| 37 | ||
| 38 | func TestBuildShowScreenFailed(t *testing.T) { | |
| 39 | c := screenCtx(100, false) | |
| 40 | d := BuildOut{Number: 83, Job: "daily-pull", Status: "failure", SHA: "355b04066b", Ref: "main", | |
| 41 | Steps: []string{"setup", "pull", "publish"}, FailedStep: 2, FailedReason: "exit 1", DurationS: 74} | |
| 42 | s := buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "omaha-metro-blotter"}, d, nil) | |
| 43 | steps := s.sections[0] | |
| 44 | if steps.title != "Steps" || steps.n != 3 { | |
| 45 | t.Fatalf("steps = %+v", steps) | |
| 46 | } | |
| 47 | for i, want := range []string{"✓", "✗", "○"} { | |
| 48 | if g := steps.rows[i].cells[0]; g.s != want { | |
| 49 | t.Errorf("step %d glyph = %q, want %q", i+1, g.s, want) | |
| 50 | } | |
| 51 | } | |
| 52 | if len(s.actions) != 1 || s.actions[0].argv[1] != "log" { | |
| 53 | t.Errorf("actions = %+v", s.actions) | |
| 54 | } | |
| 55 | out := renderString(c, s) | |
| 56 | if !strings.Contains(out, "✗ pull exit 1\n") { | |
| 57 | t.Errorf("failed step line:\n%s", out) | |
| 58 | } | |
| 59 | checkActions(t, s) | |
| 60 | } | |
| 61 | ||
| 62 | func TestBuildShowScreenNamesItsMR(t *testing.T) { | |
| 63 | c := screenCtx(100, false) | |
| 64 | d := BuildOut{Number: 1781, Job: "test", Status: "running", SHA: "ee77220399", Ref: "cli-views-1"} | |
| 65 | out := renderString(c, buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, d, &store.MR{Number: 552, Title: "screen renderer"})) | |
| 66 | if !strings.Contains(out, "MR: !552 screen renderer\n") || !strings.Contains(out, "State: ◐ running\n") { | |
| 67 | t.Errorf("render:\n%s", out) | |
| 68 | } | |
| 69 | } | |
internal/control/issue.go +54 −2
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "slices" | |
| 7 | 8 | "strconv" |
| 8 | 9 | "strings" |
| 9 | 10 | |
| @@ -399,7 +400,13 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 399 | 400 | cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) |
| 400 | 401 | } |
| 401 | 402 | d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs} |
| 402 | return c.emit(d, func(w io.Writer) { | |
| 403 | canWrite := false | |
| 404 | if c.Term.Cols > 0 && !c.JSON { | |
| 405 | if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil { | |
| 406 | canWrite = policy.CanWrite(c.User, repo, grant) | |
| 407 | } | |
| 408 | } | |
| 409 | return c.emitView(d, func(w io.Writer) { | |
| 403 | 410 | v := c.view(w) |
| 404 | 411 | v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) |
| 405 | 412 | v.fields( |
| @@ -429,7 +436,7 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 429 | 436 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) |
| 430 | 437 | v.reactions(cm.Reactions) |
| 431 | 438 | } |
| 432 | }) | |
| 439 | }, func() screen { return issueShowScreen(c, repo, d, canWrite) }) | |
| 433 | 440 | } |
| 434 | 441 | |
| 435 | 442 | func runIssueComment(c *Ctx, args []string) int { |
| @@ -717,3 +724,48 @@ func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []sto | ||
| 717 | 724 | } |
| 718 | 725 | return updated.Assignees, -1 |
| 719 | 726 | } |
| 727 | ||
| 728 | // issueShowScreen is issue show at a terminal. canWrite is write access, | |
| 729 | // which assigning and labelling need; the author may also close. | |
| 730 | func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen { | |
| 731 | n := strconv.FormatInt(d.Number, 10) | |
| 732 | path := repo.Path() | |
| 733 | s := screen{body: d.Body, format: d.BodyFormat} | |
| 734 | s.fields = append(s.fields, | |
| 735 | field{"Issue", []cell{cLink("#"+n, c.siteURL(path, "issues", n)), cText(d.Title)}}, | |
| 736 | field{"State", []cell{cState(d.State), cMeta(d.Author, relAge(d.CreatedAt, termNow()))}}, | |
| 737 | ) | |
| 738 | if len(d.Labels) > 0 { | |
| 739 | s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}}) | |
| 740 | } | |
| 741 | if len(d.Assignees) > 0 { | |
| 742 | v := cText(strings.Join(d.Assignees, ", ")) | |
| 743 | if slices.Contains(d.Assignees, c.User.Username) { | |
| 744 | v.sgr = sgrYellow | |
| 745 | } | |
| 746 | s.fields = append(s.fields, field{"Assignee", []cell{v}}) | |
| 747 | } | |
| 748 | if d.Milestone != "" { | |
| 749 | s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}}) | |
| 750 | } | |
| 751 | if !c.Term.Links { | |
| 752 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "issues", n))}}) | |
| 753 | } | |
| 754 | s.sections = []section{discussion(d.Comments), events(d.Comments)} | |
| 755 | ||
| 756 | s.actions = append(s.actions, action{"Discuss", []string{"issue", "comment", path, n}}) | |
| 757 | if canWrite { | |
| 758 | s.actions = append(s.actions, | |
| 759 | action{"Triage", []string{"issue", "assign", path, n, "--add", c.User.Username}}, | |
| 760 | action{"Triage", []string{"issue", "label", path, n, "--add", "<label>"}}, | |
| 761 | ) | |
| 762 | } | |
| 763 | if canWrite || d.Author == c.User.Username { | |
| 764 | verb := "close" | |
| 765 | if d.State == "closed" { | |
| 766 | verb = "reopen" | |
| 767 | } | |
| 768 | s.actions = append(s.actions, action{"State", []string{"issue", verb, path, n}}) | |
| 769 | } | |
| 770 | return s | |
| 771 | } | |
internal/control/issuescreen_test.go added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "slices" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/protocol" | |
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestIssueShowPlainPinned(t *testing.T) { | |
| 12 | st, repo, uid := newQueueTestRepo(t) | |
| 13 | owner := store.User{ID: uid, Username: "alice"} | |
| 14 | id, err := st.CreateIssue(repo.ID, uid, "Android app", "An app.", "md") | |
| 15 | if err != nil { | |
| 16 | t.Fatal(err) | |
| 17 | } | |
| 18 | if err := st.AddIssueComment(id, uid, "Started.", "md"); err != nil { | |
| 19 | t.Fatal(err) | |
| 20 | } | |
| 21 | c, out, errOut := mrTestCtx(st, owner) | |
| 22 | if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK { | |
| 23 | t.Fatalf("exit %d: %s", code, errOut) | |
| 24 | } | |
| 25 | pinPlain(t, "issue-show", out.String()) | |
| 26 | } | |
| 27 | ||
| 28 | func issueFixture() IssueShow { | |
| 29 | return IssueShow{issueOut: issueOut{Number: 12, Title: "Android app", State: "open", Author: "cmc", | |
| 30 | Labels: []string{"mobile"}, Assignees: []string{"alice"}, Body: "An app.", BodyFormat: "md", | |
| 31 | CreatedAt: "2026-08-21T10:00:00Z"}, | |
| 32 | Comments: []commentOut{ | |
| 33 | {ID: 1, Author: "cmc", Body: "Started.", BodyFormat: "md", CreatedAt: "2026-09-01T10:00:00Z", Kind: "comment"}, | |
| 34 | {ID: 2, Author: "cmc", Body: "labelled mobile", CreatedAt: "2026-09-01T10:00:00Z", Kind: "system"}, | |
| 35 | }} | |
| 36 | } | |
| 37 | ||
| 38 | func actionVerbs(s screen) []string { | |
| 39 | var v []string | |
| 40 | for _, a := range s.actions { | |
| 41 | v = append(v, a.argv[1]) | |
| 42 | } | |
| 43 | return v | |
| 44 | } | |
| 45 | ||
| 46 | func TestIssueShowScreenWriter(t *testing.T) { | |
| 47 | c := screenCtx(100, false) | |
| 48 | c.User = store.User{Username: "alice"} | |
| 49 | s := issueShowScreen(c, screenRepo, issueFixture(), true) | |
| 50 | if n := sectionCounts(s); n["Discussion"] != 1 || n["Events"] != 1 { | |
| 51 | t.Errorf("sections = %v", n) | |
| 52 | } | |
| 53 | if got, want := actionVerbs(s), []string{"comment", "assign", "label", "close"}; !slices.Equal(got, want) { | |
| 54 | t.Errorf("actions = %q, want %q", got, want) | |
| 55 | } | |
| 56 | checkActions(t, s) | |
| 57 | } | |
| 58 | ||
| 59 | func TestIssueShowScreenAuthorCanClose(t *testing.T) { | |
| 60 | c := screenCtx(100, false) | |
| 61 | c.User = store.User{Username: "cmc"} | |
| 62 | s := issueShowScreen(c, screenRepo, issueFixture(), false) | |
| 63 | if got, want := actionVerbs(s), []string{"comment", "close"}; !slices.Equal(got, want) { | |
| 64 | t.Errorf("actions = %q, want %q", got, want) | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | func TestIssueShowScreenReader(t *testing.T) { | |
| 69 | c := screenCtx(100, false) | |
| 70 | c.User = store.User{Username: "bob"} | |
| 71 | d := issueFixture() | |
| 72 | d.State = "closed" | |
| 73 | s := issueShowScreen(c, screenRepo, d, false) | |
| 74 | if got, want := actionVerbs(s), []string{"comment"}; !slices.Equal(got, want) { | |
| 75 | t.Errorf("actions = %q, want %q", got, want) | |
| 76 | } | |
| 77 | } | |
internal/control/marks.go +2 −2
| @@ -9,7 +9,7 @@ import ( | ||
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | 11 | // checksMark sums a commit's statuses for a list row at a terminal: |
| 12 | // "✗ 2 failed" red, "• 1 pending" dim, "✓ 3/3" green, blank with none. | |
| 12 | // "✗ 2 failed" red, "◐ 1 pending" dim, "✓ 3/3" green, blank with none. | |
| 13 | 13 | // The glyph leads so the column reads at a glance; the words stay for |
| 14 | 14 | // a terminal without colour. |
| 15 | 15 | func checksMark(sts []store.CommitStatus) cell { |
| @@ -30,7 +30,7 @@ func checksMark(sts []store.CommitStatus) cell { | ||
| 30 | 30 | case failed > 0: |
| 31 | 31 | return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed) |
| 32 | 32 | case pending > 0: |
| 33 | return cMark(fmt.Sprintf("• %d pending", pending), sgrDim) | |
| 33 | return cMark(fmt.Sprintf("◐ %d pending", pending), sgrDim) | |
| 34 | 34 | } |
| 35 | 35 | return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen) |
| 36 | 36 | } |
internal/control/marks_test.go +1 −1
| @@ -20,7 +20,7 @@ func TestChecksMark(t *testing.T) { | ||
| 20 | 20 | }{ |
| 21 | 21 | {nil, "", ""}, |
| 22 | 22 | {st("success", "success", "skipped"), "✓ 3/3", sgrGreen}, |
| 23 | {st("success", "pending"), "• 1 pending", sgrDim}, | |
| 23 | {st("success", "pending"), "◐ 1 pending", sgrDim}, | |
| 24 | 24 | {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed}, |
| 25 | 25 | } |
| 26 | 26 | for _, tc := range cases { |
internal/control/mr.go +94 −2
| @@ -816,7 +816,11 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 816 | 816 | } |
| 817 | 817 | } |
| 818 | 818 | } |
| 819 | return c.emit(d, func(w io.Writer) { | |
| 819 | var files []gitutil.NumStat | |
| 820 | if c.Term.Cols > 0 && !c.JSON && base != "" { | |
| 821 | files, _ = gitutil.DiffNumstat(dir, base, mrHeadRef(mr.Number)) | |
| 822 | } | |
| 823 | return c.emitView(d, func(w io.Writer) { | |
| 820 | 824 | state := d.State |
| 821 | 825 | if d.Draft { |
| 822 | 826 | state = "draft" |
| @@ -967,7 +971,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 967 | 971 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) |
| 968 | 972 | v.reactions(cm.Reactions) |
| 969 | 973 | } |
| 970 | }) | |
| 974 | }, func() screen { return mrShowScreen(c, repo, d, files) }) | |
| 971 | 975 | } |
| 972 | 976 | |
| 973 | 977 | // reviewLine renders one review as fields prose: "reviewer verdict |
| @@ -2196,3 +2200,91 @@ func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRRevie | ||
| 2196 | 2200 | } |
| 2197 | 2201 | return counts |
| 2198 | 2202 | } |
| 2203 | ||
| 2204 | // mrShowScreen is mr show at a terminal: where the merge request stands, | |
| 2205 | // its description, its commits, files and discussion, and what can be | |
| 2206 | // done about it now. | |
| 2207 | func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen { | |
| 2208 | n := strconv.FormatInt(d.Number, 10) | |
| 2209 | path := repo.Path() | |
| 2210 | state := d.State | |
| 2211 | if d.Draft { | |
| 2212 | state = "draft" | |
| 2213 | } | |
| 2214 | s := screen{body: d.Body, format: d.BodyFormat} | |
| 2215 | s.fields = append(s.fields, | |
| 2216 | field{"Merge", []cell{cLink("!"+n, c.siteURL(path, "mrs", n)), cText(d.Title)}}, | |
| 2217 | field{"State", []cell{cState(state), cMeta(d.Source+" → "+d.TargetRef, d.Author, relAge(d.CreatedAt, termNow()))}}, | |
| 2218 | ) | |
| 2219 | if len(d.Checks) > 0 { | |
| 2220 | sts := make([]store.CommitStatus, len(d.Checks)) | |
| 2221 | for i, x := range d.Checks { | |
| 2222 | sts[i] = store.CommitStatus{Context: x.Context, State: x.State} | |
| 2223 | } | |
| 2224 | s.fields = append(s.fields, field{"Checks", []cell{checksMark(sts)}}) | |
| 2225 | } | |
| 2226 | open := d.State == "open" || d.State == "source_gone" | |
| 2227 | behind := false | |
| 2228 | if g := d.Gates; g != nil && open { | |
| 2229 | behind = !g.FastForward | |
| 2230 | if len(g.Unmet) == 0 { | |
| 2231 | s.fields = append(s.fields, field{"Gates", []cell{cMark("✓ ready to merge", sgrGreen)}}) | |
| 2232 | } else { | |
| 2233 | parts := make([]string, len(g.Unmet)) | |
| 2234 | for i, u := range g.Unmet { | |
| 2235 | parts[i] = "✗ " + u | |
| 2236 | } | |
| 2237 | s.fields = append(s.fields, field{"Gates", []cell{cMark(strings.Join(parts, " · "), sgrRed)}}) | |
| 2238 | } | |
| 2239 | } | |
| 2240 | if len(d.ReviewRequests) > 0 { | |
| 2241 | v := cText("requested of " + strings.Join(d.ReviewRequests, ", ")) | |
| 2242 | if slices.Contains(d.ReviewRequests, c.User.Username) { | |
| 2243 | v.sgr = sgrYellow | |
| 2244 | } | |
| 2245 | s.fields = append(s.fields, field{"Review", []cell{v}}) | |
| 2246 | } | |
| 2247 | if d.Milestone != "" { | |
| 2248 | s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}}) | |
| 2249 | } | |
| 2250 | if len(d.Labels) > 0 { | |
| 2251 | s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}}) | |
| 2252 | } | |
| 2253 | if !c.Term.Links { | |
| 2254 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "mrs", n))}}) | |
| 2255 | } | |
| 2256 | ||
| 2257 | commits := section{title: "Commits", n: len(d.Commits)} | |
| 2258 | for _, cm := range d.Commits { | |
| 2259 | commits.rows = append(commits.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject))) | |
| 2260 | } | |
| 2261 | fs := section{title: "Files", n: len(files)} | |
| 2262 | add, del := 0, 0 | |
| 2263 | for _, f := range files { | |
| 2264 | counts := "binary" | |
| 2265 | if f.Added >= 0 { | |
| 2266 | add, del = add+f.Added, del+f.Deleted | |
| 2267 | counts = fmt.Sprintf("+%d −%d", f.Added, f.Deleted) | |
| 2268 | } | |
| 2269 | fs.rows = append(fs.rows, rowOf(cRef(f.Status), cFlex(f.Path), cMeta(counts))) | |
| 2270 | } | |
| 2271 | if len(files) > 0 { | |
| 2272 | fs.note = fmt.Sprintf("+%d −%d", add, del) | |
| 2273 | } | |
| 2274 | s.sections = []section{commits, fs, discussion(d.Comments), events(d.Comments)} | |
| 2275 | ||
| 2276 | if open { | |
| 2277 | if behind { | |
| 2278 | s.actions = append(s.actions, action{"Unblock", []string{"mr", "rebase", n}}) | |
| 2279 | } | |
| 2280 | s.actions = append(s.actions, | |
| 2281 | action{"Review", []string{"mr", "review", path, n, "--approve"}}, | |
| 2282 | action{"Review", []string{"mr", "comment", path, n}}, | |
| 2283 | ) | |
| 2284 | if d.Gates != nil && len(d.Gates.Unmet) == 0 { | |
| 2285 | s.actions = append(s.actions, action{"Merge", []string{"mr", "merge", path, n}}) | |
| 2286 | } | |
| 2287 | } | |
| 2288 | s.actions = append(s.actions, action{"Read", []string{"mr", "diff", path, n}}) | |
| 2289 | return s | |
| 2290 | } | |
internal/control/mrscreen_test.go added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | "time" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/gitutil" | |
| 9 | "gitbay.org/gitbay/internal/protocol" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | func TestMRShowPlainPinned(t *testing.T) { | |
| 14 | st, repo, owner := twoMRTestRepo(t) | |
| 15 | mr, err := st.MRByNumber(repo.ID, 1) | |
| 16 | if err != nil { | |
| 17 | t.Fatal(err) | |
| 18 | } | |
| 19 | if err := st.AddMRComment(mr.ID, owner.ID, "Looks right.", "md"); err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | for _, ctx := range []string{"test", "build"} { | |
| 23 | if err := st.SetCommitStatus(repo.ID, "abc111", ctx, "success", "", "", owner.ID); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | } | |
| 27 | c, out, errOut := mrTestCtx(st, owner) | |
| 28 | if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK { | |
| 29 | t.Fatalf("exit %d: %s", code, errOut) | |
| 30 | } | |
| 31 | pinPlain(t, "mr-show", out.String()) | |
| 32 | } | |
| 33 | ||
| 34 | func mrShowFixture() MRShow { | |
| 35 | d := MRShow{mrOut: mrOut{Number: 552, Title: "wire $PAGER through long views", State: "open", | |
| 36 | Author: "cmc", Source: "cli-pager", TargetRef: "main", Body: "Pages long views.", BodyFormat: "md", | |
| 37 | CreatedAt: "2026-10-01T11:40:00Z"}} | |
| 38 | d.Commits = []CommitOut{{"8f3a1c2aaaaaaaa", "cli: page long output"}, {"2b77e90bbbbbbbb", "control: mark views"}} | |
| 39 | d.Checks = []CheckOut{{Context: "test", State: "pending"}, {Context: "build", State: "success"}} | |
| 40 | d.Gates = &GatesOut{ApprovalsRequired: 1, FastForward: false, Unmet: []string{"needs 1 approval", "behind main"}} | |
| 41 | d.Comments = []commentOut{ | |
| 42 | {ID: 1, Author: "alice", Body: "Looks right.", BodyFormat: "md", CreatedAt: "2026-10-01T11:50:00Z", Kind: "comment"}, | |
| 43 | {ID: 2, Author: "cmc", Body: "marked ready", CreatedAt: "2026-10-01T11:51:00Z", Kind: "system"}, | |
| 44 | } | |
| 45 | return d | |
| 46 | } | |
| 47 | ||
| 48 | func actionArgvs(s screen, group string) [][]string { | |
| 49 | var out [][]string | |
| 50 | for _, a := range s.actions { | |
| 51 | if a.group == group { | |
| 52 | out = append(out, a.argv) | |
| 53 | } | |
| 54 | } | |
| 55 | return out | |
| 56 | } | |
| 57 | ||
| 58 | func sectionCounts(s screen) map[string]int { | |
| 59 | m := map[string]int{} | |
| 60 | for _, sec := range s.sections { | |
| 61 | m[sec.title] = sec.n | |
| 62 | } | |
| 63 | return m | |
| 64 | } | |
| 65 | ||
| 66 | var screenRepo = store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"} | |
| 67 | ||
| 68 | func TestMRShowScreenBehind(t *testing.T) { | |
| 69 | c := screenCtx(100, false) | |
| 70 | s := mrShowScreen(c, screenRepo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}}) | |
| 71 | if got := actionArgvs(s, "Unblock"); len(got) != 1 || got[0][1] != "rebase" { | |
| 72 | t.Errorf("Unblock = %q, want mr rebase", got) | |
| 73 | } | |
| 74 | if got := actionArgvs(s, "Merge"); len(got) != 0 { | |
| 75 | t.Errorf("Merge offered with unmet gates: %q", got) | |
| 76 | } | |
| 77 | if n := sectionCounts(s); n["Commits"] != 2 || n["Files"] != 1 || n["Discussion"] != 1 { | |
| 78 | t.Errorf("sections = %v", n) | |
| 79 | } | |
| 80 | checkActions(t, s) | |
| 81 | } | |
| 82 | ||
| 83 | func TestMRShowScreenReady(t *testing.T) { | |
| 84 | c := screenCtx(100, false) | |
| 85 | d := mrShowFixture() | |
| 86 | d.Gates = &GatesOut{FastForward: true} | |
| 87 | s := mrShowScreen(c, screenRepo, d, nil) | |
| 88 | if len(actionArgvs(s, "Unblock")) != 0 { | |
| 89 | t.Error("Unblock offered when nothing blocks") | |
| 90 | } | |
| 91 | if got := actionArgvs(s, "Merge"); len(got) != 1 || got[0][1] != "merge" { | |
| 92 | t.Errorf("Merge = %q", got) | |
| 93 | } | |
| 94 | checkActions(t, s) | |
| 95 | } | |
| 96 | ||
| 97 | func TestMRShowScreenMergedOffersOnlyReading(t *testing.T) { | |
| 98 | c := screenCtx(100, false) | |
| 99 | d := mrShowFixture() | |
| 100 | d.State, d.Gates = "merged", nil | |
| 101 | s := mrShowScreen(c, screenRepo, d, nil) | |
| 102 | for _, a := range s.actions { | |
| 103 | if a.group != "Read" { | |
| 104 | t.Errorf("merged MR offers %q", a.argv) | |
| 105 | } | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | func TestMRShowScreenRenders(t *testing.T) { | |
| 110 | termNow = func() time.Time { return time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) } | |
| 111 | t.Cleanup(func() { termNow = time.Now }) | |
| 112 | c := screenCtx(100, false) | |
| 113 | c.Term.Here = "krz/gitbay" | |
| 114 | s := mrShowScreen(c, screenRepo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}}) | |
| 115 | out := renderString(c, s) | |
| 116 | for _, want := range []string{ | |
| 117 | "Merge: !552 wire $PAGER through long views\n", | |
| 118 | "Gates: ✗ needs 1 approval · ✗ behind main\n", | |
| 119 | "Pages long views.\n", | |
| 120 | "Files (1) +41 −6\n", | |
| 121 | "alice 10m ago\n Looks right.\n", | |
| 122 | "gitbay mr rebase 552", | |
| 123 | "gitbay mr diff 552", | |
| 124 | } { | |
| 125 | if !strings.Contains(out, want) { | |
| 126 | t.Errorf("missing %q in:\n%s", want, out) | |
| 127 | } | |
| 128 | } | |
| 129 | if !strings.Contains(out, "Events (1)\n9m ago marked ready\n") { | |
| 130 | t.Errorf("system event not under Events:\n%s", out) | |
| 131 | } | |
| 132 | } | |
internal/control/repo.go +118 −69
| @@ -373,6 +373,36 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 373 | 373 | }) |
| 374 | 374 | } |
| 375 | 375 | |
| 376 | // repoMirrorOut is one mirror as repo show emits it, for admins. | |
| 377 | type repoMirrorOut struct { | |
| 378 | Direction string `json:"direction"` | |
| 379 | URL string `json:"url"` | |
| 380 | Pending bool `json:"pending"` | |
| 381 | LastSync string `json:"last_sync,omitempty"` | |
| 382 | LastError string `json:"last_error,omitempty"` | |
| 383 | } | |
| 384 | ||
| 385 | // repoShowOut is what repo show emits. | |
| 386 | type repoShowOut struct { | |
| 387 | Path string `json:"path"` | |
| 388 | Description string `json:"description,omitempty"` | |
| 389 | Website string `json:"website,omitempty"` | |
| 390 | Visibility string `json:"visibility"` | |
| 391 | DefaultBranch string `json:"default_branch"` | |
| 392 | ProtectedBranches []string `json:"protected_branches,omitempty"` | |
| 393 | Archived bool `json:"archived,omitempty"` | |
| 394 | Topics []string `json:"topics,omitempty"` | |
| 395 | Domains []string `json:"domains,omitempty"` | |
| 396 | Mirrors []repoMirrorOut `json:"mirrors,omitempty"` | |
| 397 | // ForkOf names the parent only when the caller can read it: a | |
| 398 | // private parent is not confirmed to exist, here as anywhere. | |
| 399 | ForkOf string `json:"fork_of,omitempty"` | |
| 400 | // Watch and Bookmarked are the caller's own state, so a client | |
| 401 | // can draw a toggle rather than two stateless buttons (#178). | |
| 402 | Watch string `json:"watch,omitempty"` // watching, muted, or absent | |
| 403 | Bookmarked bool `json:"bookmarked,omitempty"` | |
| 404 | } | |
| 405 | ||
| 376 | 406 | func runRepoShow(c *Ctx, args []string) int { |
| 377 | 407 | if len(args) != 1 { |
| 378 | 408 | return c.usage() |
| @@ -381,32 +411,6 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 381 | 411 | if code >= 0 { |
| 382 | 412 | return code |
| 383 | 413 | } |
| 384 | type mirrorOut struct { | |
| 385 | Direction string `json:"direction"` | |
| 386 | URL string `json:"url"` | |
| 387 | Pending bool `json:"pending"` | |
| 388 | LastSync string `json:"last_sync,omitempty"` | |
| 389 | LastError string `json:"last_error,omitempty"` | |
| 390 | } | |
| 391 | type out struct { | |
| 392 | Path string `json:"path"` | |
| 393 | Description string `json:"description,omitempty"` | |
| 394 | Website string `json:"website,omitempty"` | |
| 395 | Visibility string `json:"visibility"` | |
| 396 | DefaultBranch string `json:"default_branch"` | |
| 397 | ProtectedBranches []string `json:"protected_branches,omitempty"` | |
| 398 | Archived bool `json:"archived,omitempty"` | |
| 399 | Topics []string `json:"topics,omitempty"` | |
| 400 | Domains []string `json:"domains,omitempty"` | |
| 401 | Mirrors []mirrorOut `json:"mirrors,omitempty"` | |
| 402 | // ForkOf names the parent only when the caller can read it: a | |
| 403 | // private parent is not confirmed to exist, here as anywhere. | |
| 404 | ForkOf string `json:"fork_of,omitempty"` | |
| 405 | // Watch and Bookmarked are the caller's own state, so a client | |
| 406 | // can draw a toggle rather than two stateless buttons (#178). | |
| 407 | Watch string `json:"watch,omitempty"` // watching, muted, or absent | |
| 408 | Bookmarked bool `json:"bookmarked,omitempty"` | |
| 409 | } | |
| 410 | 414 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) |
| 411 | 415 | topics, err := c.Store.ListTopics(repo.ID) |
| 412 | 416 | if err != nil { |
| @@ -420,7 +424,7 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 420 | 424 | } |
| 421 | 425 | } |
| 422 | 426 | } |
| 423 | d := out{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility, | |
| 427 | d := repoShowOut{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility, | |
| 424 | 428 | DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches, |
| 425 | 429 | Archived: repo.Settings.Archived, Topics: topics, Domains: domains} |
| 426 | 430 | if repo.ForkOf != 0 { |
| @@ -442,14 +446,26 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 442 | 446 | return c.fail(protocol.ExitFailure, "%v", err) |
| 443 | 447 | } |
| 444 | 448 | for _, m := range ms { |
| 445 | d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError}) | |
| 449 | d.Mirrors = append(d.Mirrors, repoMirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError}) | |
| 446 | 450 | } |
| 447 | 451 | } |
| 448 | 452 | var glance repoGlance |
| 453 | var mrs []store.MR | |
| 454 | var issues []store.Issue | |
| 455 | var commits []CommitOut | |
| 449 | 456 | if c.Term.Cols > 0 && !c.JSON { |
| 450 | 457 | glance = repoAtAGlance(c, repo) |
| 458 | mrs, _ = c.Store.ListMRs(repo.ID, "open", 5, 0) | |
| 459 | issues, _ = c.Store.QueryIssues(repo.ID, store.IssueFilter{State: "open", Limit: 5}) | |
| 460 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 461 | if shas, err := gitutil.RevList(dir, "refs/heads/"+repo.DefaultBranch, 5); err == nil { | |
| 462 | subjects := gitutil.Subjects(dir, shas) | |
| 463 | for _, sha := range shas { | |
| 464 | commits = append(commits, CommitOut{sha, subjects[sha]}) | |
| 465 | } | |
| 466 | } | |
| 451 | 467 | } |
| 452 | return c.emit(d, func(w io.Writer) { | |
| 468 | return c.emitView(d, func(w io.Writer) { | |
| 453 | 469 | bookmarked, archived := "", "" |
| 454 | 470 | if d.Bookmarked { |
| 455 | 471 | bookmarked = "yes" |
| @@ -458,41 +474,19 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 458 | 474 | archived = "yes" |
| 459 | 475 | } |
| 460 | 476 | v := c.view(w) |
| 461 | if c.Term.Cols > 0 { | |
| 462 | v.title(d.Path, "", d.Visibility) | |
| 463 | v.text(d.Description) | |
| 464 | v.fields( | |
| 465 | "clone", glance.clone, | |
| 466 | "issues", glance.issues, | |
| 467 | "merge requests", glance.mrs, | |
| 468 | "release", glance.release, | |
| 469 | "checks", glance.checks, | |
| 470 | "default branch", d.DefaultBranch, | |
| 471 | "website", d.Website, | |
| 472 | "topics", strings.Join(d.Topics, ", "), | |
| 473 | "protected", strings.Join(d.ProtectedBranches, ", "), | |
| 474 | "pages domains", strings.Join(d.Domains, ", "), | |
| 475 | "fork of", d.ForkOf, | |
| 476 | "watch", d.Watch, | |
| 477 | "bookmarked", bookmarked, | |
| 478 | "archived", archived, | |
| 479 | "url", c.siteURL(d.Path), | |
| 480 | ) | |
| 481 | } else { | |
| 482 | v.title(d.Path, d.Description, d.Visibility) | |
| 483 | v.fields( | |
| 484 | "default branch", d.DefaultBranch, | |
| 485 | "website", d.Website, | |
| 486 | "topics", strings.Join(d.Topics, ", "), | |
| 487 | "protected", strings.Join(d.ProtectedBranches, ", "), | |
| 488 | "pages domains", strings.Join(d.Domains, ", "), | |
| 489 | "fork of", d.ForkOf, | |
| 490 | "watch", d.Watch, | |
| 491 | "bookmarked", bookmarked, | |
| 492 | "archived", archived, | |
| 493 | "url", c.siteURL(d.Path), | |
| 494 | ) | |
| 495 | } | |
| 477 | v.title(d.Path, d.Description, d.Visibility) | |
| 478 | v.fields( | |
| 479 | "default branch", d.DefaultBranch, | |
| 480 | "website", d.Website, | |
| 481 | "topics", strings.Join(d.Topics, ", "), | |
| 482 | "protected", strings.Join(d.ProtectedBranches, ", "), | |
| 483 | "pages domains", strings.Join(d.Domains, ", "), | |
| 484 | "fork of", d.ForkOf, | |
| 485 | "watch", d.Watch, | |
| 486 | "bookmarked", bookmarked, | |
| 487 | "archived", archived, | |
| 488 | "url", c.siteURL(d.Path), | |
| 489 | ) | |
| 496 | 490 | if len(d.Mirrors) > 0 { |
| 497 | 491 | v.section("mirror") |
| 498 | 492 | tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS") |
| @@ -508,13 +502,14 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 508 | 502 | } |
| 509 | 503 | tb.flush() |
| 510 | 504 | } |
| 511 | }) | |
| 505 | }, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) }) | |
| 512 | 506 | } |
| 513 | 507 | |
| 514 | 508 | // repoGlance is what repo show adds at a terminal: how to clone it and |
| 515 | 509 | // what is going on in it. |
| 516 | 510 | type repoGlance struct { |
| 517 | clone, issues, mrs, release, checks string | |
| 511 | clone, release, checks string | |
| 512 | issuesN, mrsN int | |
| 518 | 513 | } |
| 519 | 514 | |
| 520 | 515 | // repoAtAGlance reads the glance fields. Each is left blank when it |
| @@ -525,9 +520,7 @@ func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance { | ||
| 525 | 520 | host += ":" + strconv.Itoa(c.Cfg.SSH.Port) |
| 526 | 521 | } |
| 527 | 522 | g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"} |
| 528 | issues, mrs := c.Store.OpenCounts(repo.ID) | |
| 529 | g.issues = fmt.Sprintf("%d open", issues) | |
| 530 | g.mrs = fmt.Sprintf("%d open", mrs) | |
| 523 | g.issuesN, g.mrsN = c.Store.OpenCounts(repo.ID) | |
| 531 | 524 | if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 { |
| 532 | 525 | g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow()) |
| 533 | 526 | } |
| @@ -1374,3 +1367,59 @@ func runRepoDiff(c *Ctx, args []string) int { | ||
| 1374 | 1367 | } |
| 1375 | 1368 | return protocol.ExitOK |
| 1376 | 1369 | } |
| 1370 | ||
| 1371 | // repoShowScreen is repo show at a terminal: how to clone it, where the | |
| 1372 | // default branch stands, what is open, and the latest commits. | |
| 1373 | func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen { | |
| 1374 | s := screen{body: d.Description, format: "text"} | |
| 1375 | name := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility)} | |
| 1376 | if d.Archived { | |
| 1377 | name[1].s += ", archived" | |
| 1378 | } | |
| 1379 | s.fields = append(s.fields, field{"Repo", name}) | |
| 1380 | if g.clone != "" { | |
| 1381 | s.fields = append(s.fields, field{"Clone", []cell{cText(g.clone)}}) | |
| 1382 | } | |
| 1383 | head := []cell{cText(d.DefaultBranch)} | |
| 1384 | if g.checks != "" { | |
| 1385 | head = []cell{cText(d.DefaultBranch), cText(strings.TrimSuffix(g.checks, " on "+d.DefaultBranch))} | |
| 1386 | } | |
| 1387 | s.fields = append(s.fields, field{"Head", head}) | |
| 1388 | if g.release != "" { | |
| 1389 | s.fields = append(s.fields, field{"Release", []cell{cText(g.release)}}) | |
| 1390 | } | |
| 1391 | if len(d.Topics) > 0 { | |
| 1392 | s.fields = append(s.fields, field{"Topics", []cell{cText(strings.Join(d.Topics, ", "))}}) | |
| 1393 | } | |
| 1394 | if d.ForkOf != "" { | |
| 1395 | s.fields = append(s.fields, field{"Fork of", []cell{cRef(d.ForkOf)}}) | |
| 1396 | } | |
| 1397 | for _, m := range d.Mirrors { | |
| 1398 | if m.LastError != "" { | |
| 1399 | s.fields = append(s.fields, field{"Mirror", []cell{cGlyph("failed"), cText(m.Direction + " " + m.URL + ": " + m.LastError)}}) | |
| 1400 | } | |
| 1401 | } | |
| 1402 | if !c.Term.Links { | |
| 1403 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(d.Path))}}) | |
| 1404 | } | |
| 1405 | ||
| 1406 | ms := section{title: "Open merge requests", n: g.mrsN, more: []string{"mr", "list", d.Path}} | |
| 1407 | for _, m := range mrs { | |
| 1408 | ms.rows = append(ms.rows, rowOf(cRef(fmt.Sprintf("!%d", m.Number)), cFlex(m.Title), cAge(m.UpdatedAt))) | |
| 1409 | } | |
| 1410 | is := section{title: "Open issues", n: g.issuesN, more: []string{"issue", "list", d.Path}} | |
| 1411 | for _, i := range issues { | |
| 1412 | is.rows = append(is.rows, rowOf(cRef(fmt.Sprintf("#%d", i.Number)), cFlex(i.Title), cAge(i.UpdatedAt))) | |
| 1413 | } | |
| 1414 | cs := section{title: "Recent commits", n: len(commits), more: []string{"repo", "log", d.Path}} | |
| 1415 | for _, cm := range commits { | |
| 1416 | cs.rows = append(cs.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject))) | |
| 1417 | } | |
| 1418 | s.sections = []section{ms, is, cs} | |
| 1419 | s.actions = []action{ | |
| 1420 | {"Contribute", []string{"mr", "create", d.Path}}, | |
| 1421 | {"Contribute", []string{"issue", "create", d.Path}}, | |
| 1422 | {"Read", []string{"repo", "log", d.Path}}, | |
| 1423 | } | |
| 1424 | return s | |
| 1425 | } | |
internal/control/reposcreen_test.go added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/protocol" | |
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestRepoShowPlainPinned(t *testing.T) { | |
| 12 | st, repo, uid := newQueueTestRepo(t) | |
| 13 | c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"}) | |
| 14 | if code := Dispatch(c, []string{"repo", "show", repo.Path()}); code != protocol.ExitOK { | |
| 15 | t.Fatalf("exit %d: %s", code, errOut) | |
| 16 | } | |
| 17 | pinPlain(t, "repo-show", out.String()) | |
| 18 | } | |
| 19 | ||
| 20 | func TestRepoShowScreen(t *testing.T) { | |
| 21 | c := screenCtx(100, false) | |
| 22 | d := repoShowOut{Path: "krz/gitbay", Description: "A CLI-first git forge.", Visibility: "public", DefaultBranch: "main", | |
| 23 | Topics: []string{"cli", "forge"}} | |
| 24 | g := repoGlance{clone: "ssh://git@gitbay.org/krz/gitbay.git", release: "v1.41.0, 2h ago", checks: "✓ 2/2 on main", mrsN: 1, issuesN: 3} | |
| 25 | mrs := []store.MR{{Number: 552, Title: "wire $PAGER", UpdatedAt: "2026-10-01T11:40:00Z"}} | |
| 26 | issues := []store.Issue{{Number: 12, Title: "Android app", UpdatedAt: "2026-08-21T10:00:00Z"}} | |
| 27 | s := repoShowScreen(c, d, g, mrs, issues, []CommitOut{{"a136534ff9", "changelog: v1.41.0"}}) | |
| 28 | if n := sectionCounts(s); n["Open merge requests"] != 1 || n["Open issues"] != 3 || n["Recent commits"] != 1 { | |
| 29 | t.Errorf("sections = %v", n) | |
| 30 | } | |
| 31 | out := renderString(c, s) | |
| 32 | for _, want := range []string{ | |
| 33 | "Head: main ✓ 2/2\n", | |
| 34 | "A CLI-first git forge.\n", | |
| 35 | "+2 more gitbay issue list krz/gitbay\n", | |
| 36 | } { | |
| 37 | if !strings.Contains(out, want) { | |
| 38 | t.Errorf("missing %q in:\n%s", want, out) | |
| 39 | } | |
| 40 | } | |
| 41 | checkActions(t, s) | |
| 42 | } | |
| 43 | ||
| 44 | func TestRepoShowScreenMirrorErrorOnly(t *testing.T) { | |
| 45 | c := screenCtx(100, false) | |
| 46 | d := repoShowOut{Path: "krz/tap", Visibility: "public", DefaultBranch: "main", Mirrors: []repoMirrorOut{ | |
| 47 | {Direction: "push", URL: "https://github.com/x/tap.git"}, | |
| 48 | {Direction: "push", URL: "https://github.com/x/tap2.git", LastError: "exit status 1"}, | |
| 49 | }} | |
| 50 | out := renderString(c, repoShowScreen(c, d, repoGlance{}, nil, nil, nil)) | |
| 51 | if strings.Count(out, "Mirror:") != 1 || !strings.Contains(out, "tap2.git: exit status 1") { | |
| 52 | t.Errorf("mirror lines:\n%s", out) | |
| 53 | } | |
| 54 | } | |
internal/control/screen.go +39 −4
| @@ -27,12 +27,14 @@ type field struct { | ||
| 27 | 27 | value []cell |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | // section is a titled run of rows. n is the total, shown as "(n)"; when | |
| 31 | // it is more than the rows shown, more is the command for the rest. An | |
| 32 | // empty section is left out unless empty says to draw "(0)". | |
| 30 | // section is a titled run of rows. n is the total, shown as "(n)", and | |
| 31 | // note follows it dim ("+86 −12"); when n is more than the rows shown, | |
| 32 | // more is the command for the rest. An empty section is left out unless | |
| 33 | // empty says to draw "(0)". | |
| 33 | 34 | type section struct { |
| 34 | 35 | title string |
| 35 | 36 | n int |
| 37 | note string | |
| 36 | 38 | rows []row |
| 37 | 39 | more []string |
| 38 | 40 | empty bool |
| @@ -181,7 +183,11 @@ func (c *Ctx) renderSection(s section) string { | ||
| 181 | 183 | return "" |
| 182 | 184 | } |
| 183 | 185 | var b strings.Builder |
| 184 | b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n") | |
| 186 | b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n))) | |
| 187 | if s.note != "" { | |
| 188 | b.WriteString(" " + c.Term.paint(sgrDim, termSafe(s.note))) | |
| 189 | } | |
| 190 | b.WriteString("\n") | |
| 185 | 191 | if len(s.rows) > 0 { |
| 186 | 192 | tb := &table{term: c.Term, w: io.Discard} |
| 187 | 193 | for _, r := range s.rows { |
| @@ -277,3 +283,32 @@ func (c *Ctx) renderLegend(as []action) string { | ||
| 277 | 283 | } |
| 278 | 284 | return b.String() |
| 279 | 285 | } |
| 286 | ||
| 287 | // discussion is a thread's comments as a section, each author and age | |
| 288 | // with its body beneath. System events are events'. It is drawn when | |
| 289 | // empty, as "(0)". | |
| 290 | func discussion(cs []commentOut) section { | |
| 291 | sec := section{title: "Discussion", empty: true} | |
| 292 | for _, cm := range cs { | |
| 293 | if cm.Kind == "system" { | |
| 294 | continue | |
| 295 | } | |
| 296 | sec.n++ | |
| 297 | sec.rows = append(sec.rows, row{cells: []cell{cText(cm.Author), cAge(cm.CreatedAt)}, body: cm.Body, format: cm.BodyFormat}) | |
| 298 | } | |
| 299 | return sec | |
| 300 | } | |
| 301 | ||
| 302 | // events is a thread's system comments ("referenced in commit ...") as | |
| 303 | // a section, each its age and its text without link targets. | |
| 304 | func events(cs []commentOut) section { | |
| 305 | sec := section{title: "Events"} | |
| 306 | for _, cm := range cs { | |
| 307 | if cm.Kind != "system" { | |
| 308 | continue | |
| 309 | } | |
| 310 | sec.n++ | |
| 311 | sec.rows = append(sec.rows, rowOf(cAge(cm.CreatedAt), cFlex(termtext.Inline(cm.Body, cm.BodyFormat)))) | |
| 312 | } | |
| 313 | return sec | |
| 314 | } | |
internal/control/testdata/plain/build-show.txt added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | #1 test failure | |
| 2 | ||
| 3 | sha a136534ff9 | |
| 4 | ref main | |
| 5 | queued <time> | |
| 6 | finished <time> | |
| 7 | failed step 2/2 go test ./... (exit 1) | |
| 8 | url /alice/app/builds/1 | |
internal/control/testdata/plain/issue-show.txt added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | #1 Android app open | |
| 2 | ||
| 3 | author alice, <time> | |
| 4 | url /alice/app/issues/1 | |
| 5 | ||
| 6 | An app. | |
| 7 | ||
| 8 | ── alice, <time> (comment 1) | |
| 9 | ||
| 10 | Started. | |
internal/control/testdata/plain/mr-show.txt added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | !1 one open | |
| 2 | ||
| 3 | author alice, <time> | |
| 4 | source feature1 -> main | |
| 5 | head abc111 | |
| 6 | url /alice/app/mrs/1 | |
| 7 | ||
| 8 | checks (2): | |
| 9 | build success <time> | |
| 10 | test success <time> | |
| 11 | ||
| 12 | ── alice, <time> (comment 1) | |
| 13 | ||
| 14 | Looks right. | |
internal/control/testdata/plain/repo-show.txt added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | alice/app public | |
| 2 | ||
| 3 | default branch main | |
| 4 | url /alice/app | |
internal/control/view_test.go +13 −7
| @@ -55,10 +55,14 @@ func TestIssueShowTerminal(t *testing.T) { | ||
| 55 | 55 | if !strings.Contains(out, sgrGreen+"open"+sgrReset) { |
| 56 | 56 | t.Errorf("state not coloured:\n%s", out) |
| 57 | 57 | } |
| 58 | if !strings.Contains(out, " UTC") { | |
| 59 | t.Errorf("no web-format timestamp:\n%s", out) | |
| 58 | if !strings.Contains(stripSGR(out), "referenced in commit abc1234567 by alice") { | |
| 59 | t.Errorf("event missing:\n%s", out) | |
| 60 | 60 | } |
| 61 | 61 | for _, line := range strings.Split(stripSGR(out), "\n") { |
| 62 | // A suggested command is never cut: it has to paste whole. | |
| 63 | if strings.HasPrefix(line, "gitbay ") { | |
| 64 | continue | |
| 65 | } | |
| 62 | 66 | if cells(line) > 60 { |
| 63 | 67 | t.Errorf("line over 60 cells: %q", line) |
| 64 | 68 | } |
| @@ -95,21 +99,23 @@ func TestIssueShowNarrowWraps(t *testing.T) { | ||
| 95 | 99 | var titleLines []string |
| 96 | 100 | inTitle := false |
| 97 | 101 | for _, line := range strings.Split(stripSGR(out), "\n") { |
| 98 | if cells(line) > 40 { | |
| 102 | // A suggested command is never cut: it has to paste whole. | |
| 103 | if cells(line) > 40 && !strings.HasPrefix(line, "gitbay ") { | |
| 99 | 104 | t.Errorf("line over 40 cells: %q", line) |
| 100 | 105 | } |
| 106 | _, rest, isTitle := strings.Cut(line, "#1 ") | |
| 101 | 107 | switch { |
| 102 | case strings.HasPrefix(line, "#1 "): | |
| 108 | case strings.HasPrefix(line, "Issue:") && isTitle: | |
| 103 | 109 | inTitle = true |
| 104 | titleLines = append(titleLines, strings.TrimPrefix(line, "#1 ")) | |
| 105 | case inTitle && strings.HasPrefix(line, " "): | |
| 110 | titleLines = append(titleLines, rest) | |
| 111 | case inTitle && strings.HasPrefix(line, " "): | |
| 106 | 112 | titleLines = append(titleLines, strings.TrimSpace(line)) |
| 107 | 113 | default: |
| 108 | 114 | inTitle = false |
| 109 | 115 | } |
| 110 | 116 | } |
| 111 | 117 | got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ") |
| 112 | want := strings.Join(strings.Fields(title+" open"), " ") | |
| 118 | want := strings.Join(strings.Fields(title), " ") | |
| 113 | 119 | if got != want { |
| 114 | 120 | t.Errorf("title text lost across wrap: got %q, want %q", got, want) |
| 115 | 121 | } |
internal/gitutil/numstat.go added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "os/exec" | |
| 6 | "strconv" | |
| 7 | "strings" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/toolpath" | |
| 10 | ) | |
| 11 | ||
| 12 | // NumStat is one file's line counts between two commits. A binary file | |
| 13 | // counts -1 each way. Status is A, M, D or R. | |
| 14 | type NumStat struct { | |
| 15 | Path string | |
| 16 | Added int | |
| 17 | Deleted int | |
| 18 | Status string | |
| 19 | } | |
| 20 | ||
| 21 | // DiffNumstat lists the files changed from base to head with their line | |
| 22 | // counts and status, renames detected. A renamed file is listed under | |
| 23 | // its new path. | |
| 24 | func DiffNumstat(dir, base, head string) ([]NumStat, error) { | |
| 25 | run := func(args ...string) (string, error) { | |
| 26 | cmd := exec.Command(toolpath.Look("git"), append([]string{"-C", dir}, args...)...) | |
| 27 | out, err := cmd.Output() | |
| 28 | if err != nil { | |
| 29 | return "", fmt.Errorf("git %s %s: %w", args[0], args[1], err) | |
| 30 | } | |
| 31 | return string(out), nil | |
| 32 | } | |
| 33 | num, err := run("diff", "--numstat", "-z", "-M", "--end-of-options", base, head) | |
| 34 | if err != nil { | |
| 35 | return nil, err | |
| 36 | } | |
| 37 | names, err := run("diff", "--name-status", "-z", "-M", "--end-of-options", base, head) | |
| 38 | if err != nil { | |
| 39 | return nil, err | |
| 40 | } | |
| 41 | // --name-status -z: "M\0path\0", or "R100\0old\0new\0" for a rename | |
| 42 | // or copy. | |
| 43 | status := map[string]string{} | |
| 44 | f := strings.Split(strings.TrimSuffix(names, "\x00"), "\x00") | |
| 45 | for i := 0; i < len(f); { | |
| 46 | s := f[i] | |
| 47 | if s == "" { | |
| 48 | i++ | |
| 49 | continue | |
| 50 | } | |
| 51 | if s[0] == 'R' || s[0] == 'C' { | |
| 52 | if i+2 < len(f) { | |
| 53 | status[f[i+2]] = s[:1] | |
| 54 | } | |
| 55 | i += 3 | |
| 56 | continue | |
| 57 | } | |
| 58 | if i+1 < len(f) { | |
| 59 | status[f[i+1]] = s[:1] | |
| 60 | } | |
| 61 | i += 2 | |
| 62 | } | |
| 63 | // --numstat -z: "a\td\tpath\0", or "a\td\t\0old\0new\0" for a rename. | |
| 64 | var out []NumStat | |
| 65 | recs := strings.Split(strings.TrimSuffix(num, "\x00"), "\x00") | |
| 66 | for i := 0; i < len(recs); i++ { | |
| 67 | parts := strings.SplitN(recs[i], "\t", 3) | |
| 68 | if len(parts) != 3 { | |
| 69 | continue | |
| 70 | } | |
| 71 | path := parts[2] | |
| 72 | if path == "" && i+2 < len(recs) { | |
| 73 | path = recs[i+2] | |
| 74 | i += 2 | |
| 75 | } | |
| 76 | ns := NumStat{Path: path, Added: -1, Deleted: -1, Status: status[path]} | |
| 77 | if parts[0] != "-" { | |
| 78 | ns.Added, _ = strconv.Atoi(parts[0]) | |
| 79 | ns.Deleted, _ = strconv.Atoi(parts[1]) | |
| 80 | } | |
| 81 | out = append(out, ns) | |
| 82 | } | |
| 83 | return out, nil | |
| 84 | } | |
internal/gitutil/numstat_test.go added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "slices" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestDiffNumstat(t *testing.T) { | |
| 12 | dir := t.TempDir() | |
| 13 | git(t, dir, "init", "-q", "-b", "main") | |
| 14 | write(t, dir, "a.txt", "one\ntwo\nthree\n") | |
| 15 | write(t, dir, "old.txt", strings.Repeat("line\n", 20)) | |
| 16 | git(t, dir, "add", ".") | |
| 17 | git(t, dir, "commit", "-qm", "base") | |
| 18 | git(t, dir, "tag", "base") | |
| 19 | ||
| 20 | write(t, dir, "a.txt", "one\n2\n3\nthree\n") | |
| 21 | write(t, dir, "new.txt", "hello\n") | |
| 22 | if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0, 1, 2, 0, 3}, 0o644); err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | git(t, dir, "mv", "old.txt", "moved.txt") | |
| 26 | git(t, dir, "add", ".") | |
| 27 | git(t, dir, "commit", "-qm", "head") | |
| 28 | ||
| 29 | got, err := DiffNumstat(dir, "base", "HEAD") | |
| 30 | if err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | want := []NumStat{ | |
| 34 | {Path: "a.txt", Added: 2, Deleted: 1, Status: "M"}, | |
| 35 | {Path: "bin.dat", Added: -1, Deleted: -1, Status: "A"}, | |
| 36 | {Path: "moved.txt", Added: 0, Deleted: 0, Status: "R"}, | |
| 37 | {Path: "new.txt", Added: 1, Deleted: 0, Status: "A"}, | |
| 38 | } | |
| 39 | slices.SortFunc(got, func(a, b NumStat) int { return strings.Compare(a.Path, b.Path) }) | |
| 40 | if !slices.Equal(got, want) { | |
| 41 | t.Errorf("got %+v\nwant %+v", got, want) | |
| 42 | } | |
| 43 | } | |