feat(repo): show repository columns, state and README

repo list and search repo printed bare names. They now render a table
with NAME, DESCRIPTION, INFO (public or private, fork, mirror, template,
archived, empty) and UPDATED under "Showing N of M repositories ...", an
empty-state message on stderr, and tab-separated rows with the URL when
piped. --type, --sort and --order complete and validate their values.

repo view shows the visibility and kind badges, stars, forks, open
issues and pull requests, the parent of a fork, the source of a mirror,
the default branch and the clone URLs, followed by the README of the
default branch (or --branch), rendered on a terminal. The repository
JSON gains empty, stars_count, forks_count, open_issues_count,
open_pr_count, topics and updated_at.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:51:45 +02:00
1 parent 2330d174ea
commit eedbe9893d
10 files changed
+456 -152

No files matched your search

+221 -69
View File
@@ -5,6 +5,7 @@ package app
import (
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/clierr"
@@ -14,6 +15,10 @@ import (
"github.com/spf13/cobra"
)
// repositoryArguments documents the [<repository>] argument of repository commands.
const repositoryArguments = `A repository can be supplied as NAME (one of yours), OWNER/NAME, HOST/OWNER/NAME or a URL.
Without an argument, the repository of the current Git clone is used.`
// repositoryArgument selects the repository from an optional positional owner/name or URL.
func (a *application) repositoryArgument(args []string) error {
if len(args) == 0 {
@@ -40,75 +45,13 @@ func (a *application) rejectRepoFlag(cmd *cobra.Command, suggest func(owner stri
}
func (a *application) repoCommand() *cobra.Command {
group := &cobra.Command{Use: "repo", Short: "Work with Gitea repositories"}
var opts services.RepositoryListOptions
list := &cobra.Command{Use: "list [<owner>]", Short: "List your repositories", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo list " + owner }); err != nil {
return err
}
if len(args) > 0 {
if cmd.Flags().Changed("owner") {
return clierr.Wrap(clierr.Usage, errors.New("supply the owner as an argument or --owner, not both"))
}
opts.Owner = args[0]
}
if err := validateRepositoryKind(opts.Kind); err != nil {
return err
}
scopes := 0
for _, selected := range []bool{opts.Owner != "", opts.Starred, opts.Watched} {
if selected {
scopes++
}
}
if scopes > 1 {
return clierr.Wrap(clierr.Usage, errors.New("--owner, --starred, and --watched select mutually exclusive collections"))
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
items, more, err := (services.Repositories{Client: target.client}).List(cmd.Context(), opts)
if err != nil {
return err
}
if err := writeList(a, cmd, items, func(item services.Repository) []terminal.Cell {
return []terminal.Cell{{Text: item.FullName, Link: item.URL}}
}); err != nil {
return err
}
return a.morePagesHint(cmd, more, opts.Page)
}}
pageFlags(list, &opts.Limit, &opts.Page)
list.Flags().StringVar(&opts.Owner, "owner", "", "List repositories belonging to this user or organization")
list.Flags().StringVar(&opts.Kind, "type", "", "Repository kind: fork, source, mirror")
list.Flags().BoolVar(&opts.Starred, "starred", false, "List your starred repositories")
list.Flags().BoolVar(&opts.Watched, "watched", false, "List your watched repositories")
group.AddCommand(list, a.repositorySyncCommand())
var web bool
view := &cobra.Command{Use: "view [repository]", Short: "View the selected repository", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, item.URL)
}
return writeRecord(a, cmd, item, func(item services.Repository) terminal.Record {
record := repositoryRecord(item)
record.Lines = append([]string{terminal.SafeLine(item.Description)}, record.Lines...)
return record
})
}}
view.Flags().BoolVar(&web, "web", false, "Open the repository in a browser")
group.AddCommand(view)
group := &cobra.Command{
Use: "repo",
Short: "Work with Gitea repositories",
Long: `Work with Gitea repositories: list and view them, create, fork, clone and migrate them, keep
local branches and forks in sync, and change their settings.`,
}
group.AddCommand(a.repoListCommand(), a.repoViewCommand(), a.repositorySyncCommand())
group.AddCommand(&cobra.Command{Use: "set-default [repository]", Short: "Save the selected repository in local Git configuration", Annotations: textOutput("set-default configures Git"), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil {
return err
@@ -123,3 +66,212 @@ func (a *application) repoCommand() *cobra.Command {
a.addRepositoryMutations(group)
return group
}
func (a *application) repoListCommand() *cobra.Command {
var opts services.RepositoryListOptions
list := &cobra.Command{
Use: "list [<owner>]",
Short: "List repositories",
Long: `List the repositories you can access, those of a user or organization, or the repositories you
starred or watch.`,
Example: `# List the repositories you can access
$ tea repo list
# List the forks of an organization
$ tea repo list acme --type fork
# List your starred repositories as JSON
$ tea repo list --starred --json full_name,url`,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo list " + owner }); err != nil {
return err
}
if len(args) > 0 {
if cmd.Flags().Changed("owner") {
return clierr.Wrap(clierr.Usage, errors.New("supply the owner as an argument or --owner, not both"))
}
opts.Owner = args[0]
}
scopes := 0
for _, selected := range []bool{opts.Owner != "", opts.Starred, opts.Watched} {
if selected {
scopes++
}
}
if scopes > 1 {
return clierr.Wrap(clierr.Usage, errors.New("an owner, --starred and --watched select different collections; pass one"))
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
result, err := (services.Repositories{Client: target.client}).ListPage(cmd.Context(), opts)
if err != nil {
return err
}
collection := repositoryCollection(opts)
spec := listSpec[services.Repository]{Columns: repositoryColumns, Row: repositoryRow, Context: collection, Empty: "no " + collection}
return writeTable(a, cmd, result.Items, spec, listPage{Number: opts.Page, More: result.More, Total: result.Total, Counted: result.Counted})
},
}
pageFlags(list, &opts.Limit, &opts.Page)
list.Flags().StringVar(&opts.Owner, "owner", "", "List the repositories of this user or organization")
_ = list.Flags().MarkHidden("owner")
enumFlag(list, &opts.Kind, "type", "", "", "Only list repositories of this kind", "fork", "source", "mirror")
list.Flags().BoolVar(&opts.Starred, "starred", false, "List your starred repositories")
list.Flags().BoolVar(&opts.Watched, "watched", false, "List the repositories you watch")
_ = list.RegisterFlagCompletionFunc("owner", a.completeOrgs())
list.ValidArgsFunction = a.completeOrgs()
return list
}
// repositoryCollection names the repositories that repo list selects, as in "forks owned by acme".
func repositoryCollection(opts services.RepositoryListOptions) string {
kinds := map[string]string{"fork": "forks", "mirror": "mirrors", "source": "source repositories"}
noun, ok := kinds[opts.Kind]
if !ok {
noun = "repositories"
}
switch {
case opts.Owner != "":
return noun + " owned by " + opts.Owner
case opts.Starred:
return "starred " + noun
case opts.Watched:
return "watched " + noun
}
return noun + " you can access"
}
// repositoryColumns head the rows of repositoryRow in repo list and search repo.
var repositoryColumns = []terminal.Column{{Header: "Name"}, {Header: "Description", Flex: true}, {Header: "Info"}, {Header: "Updated"}, {Header: "URL", PipedOnly: true}}
func repositoryRow(item services.Repository) []terminal.Cell {
return []terminal.Cell{{Text: item.FullName, Link: item.URL}, {Text: item.Description}, {Text: strings.Join(repositoryInfo(item), ", ")}, {Time: terminal.ParseTime(item.UpdatedAt)}, {Text: item.URL}}
}
// repositoryInfo describes the visibility and the kind of a repository: public or private, then
// fork, mirror, template, archived or empty.
func repositoryInfo(item services.Repository) []string {
info := []string{"public"}
if item.Private {
info[0] = "private"
}
for _, kind := range []struct {
is bool
name string
}{{item.Fork, "fork"}, {item.Mirror, "mirror"}, {item.Template, "template"}, {item.Archived, "archived"}, {item.Empty, "empty"}} {
if kind.is {
info = append(info, kind.name)
}
}
return info
}
func (a *application) repoViewCommand() *cobra.Command {
var web bool
var branch string
view := &cobra.Command{
Use: "view [<repository>]",
Short: "View a repository",
Long: `View a repository: its description, state, counts and clone URLs, followed by its README, which
is rendered on a terminal.`,
Example: `# View the repository of the current clone
$ tea repo view
# View the README of another branch
$ tea repo view alice/project --branch develop
# Open the repository in the browser
$ tea repo view alice/project --web`,
Annotations: map[string]string{"tea.arguments": repositoryArguments},
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Repositories{Client: target.client}
item, err := service.View(cmd.Context(), target.Repo)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, item.URL)
}
readme := ""
if a.options.json == "" && !item.Empty {
if _, readme, err = service.Readme(cmd.Context(), target.Repo, branch); err != nil {
return err
}
}
return writeView(a, cmd, item, func(item services.Repository) terminal.View { return a.repositoryView(item, readme) })
},
}
view.Flags().BoolVarP(&web, "web", "w", false, "Open the repository in the browser")
view.Flags().StringVarP(&branch, "branch", "b", "", "Show the README of this branch (default: the default branch)")
_ = view.RegisterFlagCompletionFunc("branch", a.completeBranches())
view.ValidArgsFunction = a.completeRepos()
return view
}
// repositoryView shows a repository with its README as the body.
func (a *application) repositoryView(item services.Repository, readme string) terminal.View {
badges := []terminal.Cell{{Text: "public", Color: "green"}}
if item.Private {
badges[0] = terminal.Cell{Text: "private", Color: "yellow"}
}
for _, kind := range repositoryInfo(item)[1:] {
badge := terminal.Cell{Text: kind, Muted: true}
if kind == "archived" {
badge = terminal.Cell{Text: "archived (read-only)", Color: "yellow"}
}
badges = append(badges, badge)
}
meta := []string{
fmt.Sprintf("★ %d", item.Stars), countNoun(item.Forks, "fork"), countNoun(item.OpenIssues, "open issue"),
countNoun(item.OpenPulls, "open pull request"),
}
if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() {
meta = append(meta, "updated "+terminal.RelativeTime(a.deps.Now(), updated))
}
view := terminal.View{
Title: item.FullName, Badges: badges, Meta: strings.Join(meta, " · "), Body: readme, Kind: "repository", URL: item.URL,
Fields: []terminal.Field{
{Label: "Description", Value: item.Description},
{Label: "Forked from", Value: parentName(item)},
{Label: "Mirror of", Value: mirrorSourceURL(item)},
{Label: "Default branch", Value: item.DefaultBranch},
{Label: "Website", Value: item.Website},
{Label: "Topics", Value: strings.Join(item.Topics, ", ")},
{Label: "Clone", Value: item.CloneURL},
{Label: "SSH", Value: item.SSHURL},
},
}
if a.deps.Streams.OutTTY && strings.TrimSpace(readme) == "" {
view.Body = "_This repository has no README._"
if item.Empty {
view.Body = "_This repository is empty._"
}
}
return view
}
func parentName(item services.Repository) string {
if item.Parent == nil {
return ""
}
return item.Parent.FullName
}
// countNoun counts n of a noun, as in "1 fork" or "3 forks".
func countNoun(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return fmt.Sprintf("%d %ss", n, noun)
}