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 {
373 }) 373 })
374} 374}
375 375
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
376func runRepoShow(c *Ctx, args []string) int { 406func runRepoShow(c *Ctx, args []string) int {
377 if len(args) != 1 { 407 if len(args) != 1 {
378 return c.usage() 408 return c.usage()
@@ -381,32 +411,6 @@ func runRepoShow(c *Ctx, args []string) int {
381 if code >= 0 { 411 if code >= 0 {
382 return code 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 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) 414 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
411 topics, err := c.Store.ListTopics(repo.ID) 415 topics, err := c.Store.ListTopics(repo.ID)
412 if err != nil { 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 DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches, 428 DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
425 Archived: repo.Settings.Archived, Topics: topics, Domains: domains} 429 Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
426 if repo.ForkOf != 0 { 430 if repo.ForkOf != 0 {
@@ -442,14 +446,26 @@ func runRepoShow(c *Ctx, args []string) int {
442 return c.fail(protocol.ExitFailure, "%v", err) 446 return c.fail(protocol.ExitFailure, "%v", err)
443 } 447 }
444 for _, m := range ms { 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 var glance repoGlance 452 var glance repoGlance
453 var mrs []store.MR
454 var issues []store.Issue
455 var commits []CommitOut
449 if c.Term.Cols > 0 && !c.JSON { 456 if c.Term.Cols > 0 && !c.JSON {
450 glance = repoAtAGlance(c, repo) 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 bookmarked, archived := "", "" 469 bookmarked, archived := "", ""
454 if d.Bookmarked { 470 if d.Bookmarked {
455 bookmarked = "yes" 471 bookmarked = "yes"
@@ -458,41 +474,19 @@ func runRepoShow(c *Ctx, args []string) int {
458 archived = "yes" 474 archived = "yes"
459 } 475 }
460 v := c.view(w) 476 v := c.view(w)
461 if c.Term.Cols > 0 { 477 v.title(d.Path, d.Description, d.Visibility)
462 v.title(d.Path, "", d.Visibility) 478 v.fields(
463 v.text(d.Description) 479 "default branch", d.DefaultBranch,
464 v.fields( 480 "website", d.Website,
465 "clone", glance.clone, 481 "topics", strings.Join(d.Topics, ", "),
466 "issues", glance.issues, 482 "protected", strings.Join(d.ProtectedBranches, ", "),
467 "merge requests", glance.mrs, 483 "pages domains", strings.Join(d.Domains, ", "),
468 "release", glance.release, 484 "fork of", d.ForkOf,
469 "checks", glance.checks, 485 "watch", d.Watch,
470 "default branch", d.DefaultBranch, 486 "bookmarked", bookmarked,
471 "website", d.Website, 487 "archived", archived,
472 "topics", strings.Join(d.Topics, ", "), 488 "url", c.siteURL(d.Path),
473 "protected", strings.Join(d.ProtectedBranches, ", "), 489 )
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 }
496 if len(d.Mirrors) > 0 { 490 if len(d.Mirrors) > 0 {
497 v.section("mirror") 491 v.section("mirror")
498 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS") 492 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
@@ -508,13 +502,14 @@ func runRepoShow(c *Ctx, args []string) int {
508 } 502 }
509 tb.flush() 503 tb.flush()
510 } 504 }
511 }) 505 }, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) })
512} 506}
513 507
514// repoGlance is what repo show adds at a terminal: how to clone it and 508// repoGlance is what repo show adds at a terminal: how to clone it and
515// what is going on in it. 509// what is going on in it.
516type repoGlance struct { 510type repoGlance struct {
517 clone, issues, mrs, release, checks string 511 clone, release, checks string
512 issuesN, mrsN int
518} 513}
519 514
520// repoAtAGlance reads the glance fields. Each is left blank when it 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 host += ":" + strconv.Itoa(c.Cfg.SSH.Port) 520 host += ":" + strconv.Itoa(c.Cfg.SSH.Port)
526 } 521 }
527 g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"} 522 g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"}
528 issues, mrs := c.Store.OpenCounts(repo.ID) 523 g.issuesN, g.mrsN = c.Store.OpenCounts(repo.ID)
529 g.issues = fmt.Sprintf("%d open", issues)
530 g.mrs = fmt.Sprintf("%d open", mrs)
531 if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 { 524 if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 {
532 g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow()) 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 return protocol.ExitOK 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.
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