Commit 94f804ba1e

94f804ba1e945a10c32d0178a4f4e8e2db76d011

parent: c19814239d

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:28 UTC

control: repo show as a screen

Ref #318

Layout: unified · split

internal/control/repo.go +118 −69
@@ -373,6 +373,36 @@ func runRepoList(c *Ctx, args []string) int {
373373 })
374374}
375375
376// repoMirrorOut is one mirror as repo show emits it, for admins.
377type 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.
386type 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
376406func runRepoShow(c *Ctx, args []string) int {
377407 if len(args) != 1 {
378408 return c.usage()
@@ -381,32 +411,6 @@ func runRepoShow(c *Ctx, args []string) int {
381411 if code >= 0 {
382412 return code
383413 }
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 }
410414 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
411415 topics, err := c.Store.ListTopics(repo.ID)
412416 if err != nil {
@@ -420,7 +424,7 @@ func runRepoShow(c *Ctx, args []string) int {
420424 }
421425 }
422426 }
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,
424428 DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
425429 Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
426430 if repo.ForkOf != 0 {
@@ -442,14 +446,26 @@ func runRepoShow(c *Ctx, args []string) int {
442446 return c.fail(protocol.ExitFailure, "%v", err)
443447 }
444448 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})
446450 }
447451 }
448452 var glance repoGlance
453 var mrs []store.MR
454 var issues []store.Issue
455 var commits []CommitOut
449456 if c.Term.Cols > 0 && !c.JSON {
450457 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 }
451467 }
452 return c.emit(d, func(w io.Writer) {
468 return c.emitView(d, func(w io.Writer) {
453469 bookmarked, archived := "", ""
454470 if d.Bookmarked {
455471 bookmarked = "yes"
@@ -458,41 +474,19 @@ func runRepoShow(c *Ctx, args []string) int {
458474 archived = "yes"
459475 }
460476 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 )
496490 if len(d.Mirrors) > 0 {
497491 v.section("mirror")
498492 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
@@ -508,13 +502,14 @@ func runRepoShow(c *Ctx, args []string) int {
508502 }
509503 tb.flush()
510504 }
511 })
505 }, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) })
512506}
513507
514508// repoGlance is what repo show adds at a terminal: how to clone it and
515509// what is going on in it.
516510type repoGlance struct {
517 clone, issues, mrs, release, checks string
511 clone, release, checks string
512 issuesN, mrsN int
518513}
519514
520515// repoAtAGlance reads the glance fields. Each is left blank when it
@@ -525,9 +520,7 @@ func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance {
525520 host += ":" + strconv.Itoa(c.Cfg.SSH.Port)
526521 }
527522 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)
531524 if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 {
532525 g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow())
533526 }
@@ -1374,3 +1367,59 @@ func runRepoDiff(c *Ctx, args []string) int {
13741367 }
13751368 return protocol.ExitOK
13761369}
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.
1373func 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 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8 "gitbay.org/gitbay/internal/store"
9)
10
11func 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
20func 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
44func 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/testdata/plain/repo-show.txt added +4
@@ -0,0 +1,4 @@
1alice/app public
2
3 default branch main
4 url /alice/app