From eedbe9893dcc4d9584868678148ed0a9a3c38d98 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:15:26 +0200 Subject: [PATCH] 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 --- internal/app/app_test.go | 5 - internal/app/repos.go | 290 +++++++++++++++----- internal/app/repos_test.go | 102 ++++++- internal/app/search.go | 99 +++---- internal/app/validation_test.go | 6 +- internal/services/convert.go | 2 + internal/services/convert_test.go | 5 +- internal/services/repositories.go | 41 +++ internal/services/repository_search.go | 48 ++-- internal/services/repository_search_test.go | 10 +- 10 files changed, 456 insertions(+), 152 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0cad2a05..f006a166 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -511,13 +511,10 @@ const ( repo delete repo edit repo fork - repo list repo migrate repo set-default - repo view search issue search pr - search repo status time create time delete @@ -543,8 +540,6 @@ const ( pr comment release release asset - repo - search time wiki ` diff --git a/internal/app/repos.go b/internal/app/repos.go index 6331aa0b..563cc461 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -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 [] 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 []", 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 []", + 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 []", + 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) +} diff --git a/internal/app/repos_test.go b/internal/app/repos_test.go index 65ed986c..94e34567 100644 --- a/internal/app/repos_test.go +++ b/internal/app/repos_test.go @@ -5,14 +5,114 @@ package app import ( "bytes" + "context" + "net/http" "testing" + "time" + + "github.com/stretchr/testify/require" ) -// repoTTYDeps returns serverDeps for a 100-column terminal without color or links, as people read tea's output. +// repoTTYDeps returns serverDeps for a 100-column terminal without color or links, as people read +// tea's output, at a fixed time. func repoTTYDeps(t *testing.T, serverURL string) (Dependencies, *bytes.Buffer, *bytes.Buffer) { t.Helper() deps, out, diagnostics := serverDeps(t, serverURL) deps.Getenv = serverEnv(serverURL, map[string]string{"TERM": "dumb"}) deps.Streams.OutTTY, deps.Streams.Width = true, 100 + deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) } return deps, out, diagnostics } + +const listedRepositories = `[ + {"full_name":"alice/old-stuff","description":"Archived repository","archived":true,"html_url":"https://gitea.test/alice/old-stuff","updated_at":"2026-10-11T18:00:00Z"}, + {"full_name":"acme/service","private":true,"fork":true,"html_url":"https://gitea.test/acme/service","updated_at":"2026-10-09T18:00:00Z"} +]` + +func TestRepoListShowsColumns(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/user/repos", r.URL.Path) + w.Header().Set("X-Total-Count", "2") + _, _ = w.Write([]byte(listedRepositories)) + })) + defer server.Close() + deps, out, _ := repoTTYDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "list"})) + require.Equal(t, `Showing 2 of 2 repositories you can access + +NAME DESCRIPTION INFO UPDATED +alice/old-stuff Archived repository public, archived about 2 hours ago +acme/service private, fork about 2 days ago +`, out.String()) + deps, out, _ = serverDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "list"})) + require.Equal(t, "alice/old-stuff\tArchived repository\tpublic, archived\t2026-10-11T18:00:00Z\thttps://gitea.test/alice/old-stuff\n"+ + "acme/service\t\tprivate, fork\t2026-10-09T18:00:00Z\thttps://gitea.test/acme/service\n", out.String()) +} + +func TestRepoListReportsAnEmptyCollection(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/orgs/acme": + _, _ = w.Write([]byte(`{"id":3,"username":"acme"}`)) + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + defer server.Close() + deps, out, diagnostics := repoTTYDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "list", "acme", "--type", "fork"})) + require.Empty(t, out.String()) + require.Equal(t, "no forks owned by acme\n", diagnostics.String()) +} + +func TestRepoViewShowsStateAndReadme(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/repos/bob/project": + _, _ = w.Write([]byte(`{"full_name":"bob/project","description":"Demo","fork":true,"archived":true,"default_branch":"main", + "stars_count":1,"forks_count":0,"open_issues_count":2,"open_pr_counter":1,"updated_at":"2026-10-11T19:00:00Z", + "html_url":"https://gitea.test/bob/project","clone_url":"https://gitea.test/bob/project.git", + "parent":{"full_name":"alice/project"}}`)) + case "/api/v1/repos/bob/project/contents/": + require.Equal(t, "develop", r.URL.Query().Get("ref")) + _, _ = w.Write([]byte(`[{"name":"docs","type":"dir"},{"name":"README","type":"file"},{"name":"readme.md","type":"file"}]`)) + case "/api/v1/repos/bob/project/raw/readme.md": + _, _ = w.Write([]byte("Hello **there**")) + default: + t.Errorf("unexpected request %s", r.URL) + } + })) + defer server.Close() + deps, out, _ := repoTTYDeps(t, server.URL) + deps.Getenv = serverEnv(server.URL, map[string]string{"TERM": "dumb", "TEA_MARKDOWN": "raw"}) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "view", "bob/project", "--branch", "develop"})) + require.Equal(t, `bob/project +public • fork • archived (read-only) • ★ 1 · 0 forks · 2 open issues · 1 open pull request · updated about 1 hour ago +Description: Demo +Forked from: alice/project +Default branch: main +Clone: https://gitea.test/bob/project.git + +Hello **there** + +View this repository on Gitea: https://gitea.test/bob/project +`, out.String()) + deps, out, _ = serverDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "view", "bob/project", "-b", "develop"})) + require.Equal(t, "title:\tbob/project\ndescription:\tDemo\nforked from:\talice/project\nmirror of:\t\ndefault branch:\tmain\nwebsite:\t\ntopics:\t\n"+ + "clone:\thttps://gitea.test/bob/project.git\nssh:\t\nurl:\thttps://gitea.test/bob/project\n--\nHello **there**\n", out.String()) +} + +func TestRepoViewOfAnEmptyRepositorySkipsTheReadme(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/alice/empty", r.URL.Path) + _, _ = w.Write([]byte(`{"full_name":"alice/empty","private":true,"empty":true,"html_url":"https://gitea.test/alice/empty"}`)) + })) + defer server.Close() + deps, out, _ := repoTTYDeps(t, server.URL) + deps.Getenv = serverEnv(server.URL, map[string]string{"TERM": "dumb", "TEA_MARKDOWN": "raw"}) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "view", "alice/empty"})) + require.Contains(t, out.String(), "private • empty • ★ 0") + require.Contains(t, out.String(), "_This repository is empty._") +} diff --git a/internal/app/search.go b/internal/app/search.go index 19a67800..09059c2d 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -4,69 +4,70 @@ package app import ( - "errors" "strings" - "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" - "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) -func validateRepositoryKind(kind string) error { - switch kind { - case "", "fork", "source", "mirror": - return nil - default: - return clierr.Wrap(clierr.Usage, errors.New("--type must be fork, source, or mirror")) - } -} - func (a *application) searchCommand() *cobra.Command { - group := &cobra.Command{Use: "search", Short: "Search within one selected Gitea instance"} + group := &cobra.Command{ + Use: "search", + Short: "Search within one selected Gitea instance", + Long: `Search the repositories, issues and pull requests of the selected Gitea instance that your +account can see.`, + } var opts services.RepositorySearchOptions var private, archived bool - command := &cobra.Command{Use: "repo [query]...", Short: "Search repository names, descriptions or topics", RunE: func(cmd *cobra.Command, args []string) error { - if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea search repo --owner " + owner }); err != nil { - return err - } - if err := validateRepositoryKind(opts.Kind); err != nil { - return err - } - if err := oneOf("sort", opts.Sort, "alpha", "created", "updated", "size", "id"); err != nil { - return err - } - if err := oneOf("order", opts.Order, "asc", "desc"); err != nil { - return err - } - if cmd.Flags().Changed("private") { - opts.Private = &private - } - if cmd.Flags().Changed("archived") { - opts.Archived = &archived - } - opts.Query = strings.Join(args, " ") - target, err := a.resolve(cmd.Context(), false) - if err != nil { - return err - } - items, err := (services.Repositories{Client: target.client}).Search(cmd.Context(), opts) - if err != nil { - return err - } - return writeList(a, cmd, items, func(item services.Repository) []terminal.Cell { - return terminal.Row(item.FullName, item.Description, item.URL) - }) - }} + command := &cobra.Command{ + Use: "repo [...]", + Short: "Search repositories", + Long: "Search the names and descriptions of repositories, or their topics with --topic.", + Example: `# Search repositories about a CLI +$ tea search repo command line + +# Search the archived repositories of an organization +$ tea search repo --owner acme --archived + +# Search by topic, most recently updated first +$ tea search repo go --topic --sort updated --order desc`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea search repo --owner " + owner }); err != nil { + return err + } + if cmd.Flags().Changed("private") { + opts.Private = &private + } + if cmd.Flags().Changed("archived") { + opts.Archived = &archived + } + opts.Query = strings.Join(args, " ") + target, err := a.resolve(cmd.Context(), false) + if err != nil { + return err + } + result, err := (services.Repositories{Client: target.client}).Search(cmd.Context(), opts) + if err != nil { + return err + } + what := "repositories" + if opts.Query != "" { + what += " matching " + opts.Query + } + spec := listSpec[services.Repository]{Columns: repositoryColumns, Row: repositoryRow, Context: what, Empty: "no " + what} + return writeTable(a, cmd, result.Items, spec, listPage{Number: opts.Page, More: result.More, Total: result.Total, Counted: result.Counted}) + }, + } flags := command.Flags() pageFlags(command, &opts.Limit, &opts.Page) - flags.StringVar(&opts.Owner, "owner", "", "Repository owner") - flags.StringVar(&opts.Kind, "type", "", "Repository kind: fork, source, mirror") - flags.StringVar(&opts.Sort, "sort", "alpha", "Sort by alpha, created, updated, size, id") - flags.StringVar(&opts.Order, "order", "asc", "Sort order: asc, desc") + flags.StringVar(&opts.Owner, "owner", "", "Only repositories of this user or organization") + enumFlag(command, &opts.Kind, "type", "", "", "Only repositories of this kind", "fork", "source", "mirror") + enumFlag(command, &opts.Sort, "sort", "", "alpha", "Sort by", "alpha", "created", "updated", "size", "id") + enumFlag(command, &opts.Order, "order", "", "asc", "Sort order", "asc", "desc") flags.BoolVar(&opts.Topic, "topic", false, "Match repository topics") flags.BoolVar(&private, "private", false, "Only private repositories (use =false for public)") flags.BoolVar(&archived, "archived", false, "Only archived repositories (use =false for active)") + _ = command.RegisterFlagCompletionFunc("owner", a.completeOrgs()) group.AddCommand(command, a.discussionSearchCommand(false), a.discussionSearchCommand(true)) return group } diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index b8b61b46..3e48b730 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -166,9 +166,9 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"repo", "edit", "--default-branch="}, "default branch cannot be empty"}, {[]string{"repo", "archive", "alice/project", "-R", "alice/other", "--yes"}, "supply the repository as an argument or --repo, not both"}, {[]string{"repo", "view", "alice/project", "--repo", "alice/other"}, "supply the repository as an argument or --repo, not both"}, - {[]string{"repo", "view", "alice/project", "alice/other"}, "too many arguments\nUsage: tea repo view [repository] [flags]"}, - {[]string{"repo", "list", "--owner", "account", "--starred"}, "--owner, --starred, and --watched select mutually exclusive collections"}, - {[]string{"repo", "list", "--starred", "--watched"}, "--owner, --starred, and --watched select mutually exclusive collections"}, + {[]string{"repo", "view", "alice/project", "alice/other"}, "too many arguments\nUsage: tea repo view [] [flags]"}, + {[]string{"repo", "list", "--owner", "account", "--starred"}, "an owner, --starred and --watched select different collections; pass one"}, + {[]string{"repo", "list", "--starred", "--watched"}, "an owner, --starred and --watched select different collections; pass one"}, {[]string{"repo", "list", "--type", "invalid"}, "--type must be fork, source, or mirror"}, {[]string{"repo", "set-default", "--json"}, "set-default configures Git"}, {[]string{"repo", "sync", "--branch="}, "--branch cannot be empty"}, diff --git a/internal/services/convert.go b/internal/services/convert.go index 18fd1715..29c476c2 100644 --- a/internal/services/convert.go +++ b/internal/services/convert.go @@ -26,6 +26,8 @@ func repositoryFrom(repo *gitea.Repository) Repository { FullName: repo.FullName, Description: repo.Description, URL: repo.HTMLURL, Private: repo.Private, DefaultBranch: repo.DefaultBranch, CloneURL: repo.CloneURL, SSHURL: repo.SSHURL, Archived: repo.Archived, Template: repo.Template, Fork: repo.Fork, Mirror: repo.Mirror, OriginalURL: repo.OriginalURL, + Empty: repo.Empty, Stars: repo.Stars, Forks: repo.Forks, OpenIssues: repo.OpenIssues, OpenPulls: repo.OpenPulls, + Topics: repo.Topics, UpdatedAt: timestamp(repo.Updated), } if repo.Parent != nil { parent := repositoryFrom(repo.Parent) diff --git a/internal/services/convert_test.go b/internal/services/convert_test.go index 1bd05666..58b2ada9 100644 --- a/internal/services/convert_test.go +++ b/internal/services/convert_test.go @@ -39,11 +39,14 @@ func TestRepositoryFromMapsParents(t *testing.T) { "id":2,"name":"fork","full_name":"bob/fork","description":"Copy","html_url":"https://gitea.test/bob/fork", "private":true,"default_branch":"main","clone_url":"https://gitea.test/bob/fork.git","ssh_url":"git@gitea.test:bob/fork.git", "archived":true,"template":true,"fork":true,"mirror":true,"website":"https://example.test","has_actions":true, - "original_url":"https://source.test/repo","parent":{"id":1,"name":"project","full_name":"alice/project"}}`) + "original_url":"https://source.test/repo","parent":{"id":1,"name":"project","full_name":"alice/project"}, + "empty":true,"stars_count":3,"forks_count":2,"open_issues_count":5,"open_pr_counter":1,"topics":["cli"], + "updated_at":"2026-10-02T12:00:00Z"}`) require.Equal(t, Repository{ ActionsEnabled: true, Website: "https://example.test", ID: 2, Name: "fork", FullName: "bob/fork", Description: "Copy", URL: "https://gitea.test/bob/fork", Private: true, DefaultBranch: "main", CloneURL: "https://gitea.test/bob/fork.git", SSHURL: "git@gitea.test:bob/fork.git", Archived: true, Template: true, Fork: true, Mirror: true, + Empty: true, Stars: 3, Forks: 2, OpenIssues: 5, OpenPulls: 1, Topics: []string{"cli"}, UpdatedAt: "2026-10-02T12:00:00Z", Parent: &Repository{ID: 1, Name: "project", FullName: "alice/project"}, OriginalURL: "https://source.test/repo", }, repositoryFrom(repo)) } diff --git a/internal/services/repositories.go b/internal/services/repositories.go index d1309b95..f35c277c 100644 --- a/internal/services/repositories.go +++ b/internal/services/repositories.go @@ -5,6 +5,10 @@ package services import ( "context" + "errors" + "net/http" + "slices" + "strings" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" @@ -30,6 +34,13 @@ type Repository struct { Template bool `json:"template"` Fork bool `json:"fork"` Mirror bool `json:"mirror"` + Empty bool `json:"empty"` + Stars int `json:"stars_count"` + Forks int `json:"forks_count"` + OpenIssues int `json:"open_issues_count"` + OpenPulls int `json:"open_pr_count"` + Topics []string `json:"topics"` + UpdatedAt string `json:"updated_at"` Parent *Repository `json:"parent,omitempty"` // OriginalURL stays out of JSON output: migration sources may embed credentials. OriginalURL string `json:"-"` @@ -78,6 +89,36 @@ func (s Repositories) get(ctx context.Context, repo string) (*gitea.Repository, }) } +// readmeNames are the README file names that Gitea shows, in order of preference. +var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"} + +// Readme returns the README in the top directory of the repository at ref: its file name and its +// content, or "" when there is none. +func (s Repositories) Readme(ctx context.Context, repo, ref string) (name, content string, err error) { + entries, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.ContentsResponse, *gitea.Response, error) { + return c.Repositories.ListContents(ctx, owner, name, ref, "") + }) + if err != nil { + if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound { + return "", "", nil + } + return "", "", err + } + rank := len(readmeNames) + for _, entry := range entries { + if i := slices.Index(readmeNames, strings.ToLower(entry.Name)); entry.Type == "file" && i >= 0 && i < rank { + name, rank = entry.Name, i + } + } + if name == "" { + return "", "", nil + } + data, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]byte, *gitea.Response, error) { + return c.Repositories.GetFile(ctx, owner, repo, ref, name) + }) + return name, string(data), err +} + // RepositoryPermissions holds server-side access flags used only for local decisions. // It is intentionally omitted from the stable Repository JSON schema. type RepositoryPermissions struct { diff --git a/internal/services/repository_search.go b/internal/services/repository_search.go index 17b683fb..138b763f 100644 --- a/internal/services/repository_search.go +++ b/internal/services/repository_search.go @@ -54,24 +54,30 @@ func repositoryKind(item Repository, kind string) bool { // List returns repositories accessible to the account, or its selected collection. // It reports whether more repositories follow the selected page. func (s Repositories) List(ctx context.Context, opts RepositoryListOptions) ([]Repository, bool, error) { + page, err := s.ListPage(ctx, opts) + return page.Items, page.More, err +} + +// ListPage is List that also reports the size of the collection when the server counts it. +// A kind filter applies to the page, so the collection is not counted then. +func (s Repositories) ListPage(ctx context.Context, opts RepositoryListOptions) (Page[Repository], error) { isOrg := false var err error if opts.Owner != "" { if _, isOrg, err = s.ownerID(ctx, opts.Owner); err != nil { - return nil, false, err + return Page[Repository]{}, err } } - var items []*gitea.Repository - var more bool + var window Page[*gitea.Repository] if opts.Owner == "" && (opts.Starred || opts.Watched) { // TODO(sdk): GetMyStarredRepos and GetMyWatchedRepos accept no ListOptions. endpoint := "user/subscriptions" if opts.Starred { endpoint = "user/starred" } - items, more, err = rawWindow[*gitea.Repository](ctx, s.Client, endpoint, nil, opts.Limit, opts.Page) + window, err = rawPage[*gitea.Repository](ctx, s.Client, endpoint, nil, opts.Limit, opts.Page) } else { - items, more, err = listWindow(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Repository, http.Header, error) { + window, err = listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Repository, http.Header, error) { items, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) { switch { case isOrg: @@ -86,22 +92,22 @@ func (s Repositories) List(ctx context.Context, opts RepositoryListOptions) ([]R }) } if err != nil { - return nil, false, err + return Page[Repository]{}, err } - result := make([]Repository, 0, len(items)) - for _, item := range items { + result := Page[Repository]{Items: make([]Repository, 0, len(window.Items)), More: window.More, Total: window.Total, Counted: window.Counted && opts.Kind == ""} + for _, item := range window.Items { if repo := repositoryFrom(item); repositoryKind(repo, opts.Kind) { - result = append(result, repo) + result.Items = append(result.Items, repo) } } - return result, more, nil + return result, nil } // Search searches repository names, descriptions or topics within one instance. -func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions) ([]Repository, error) { +func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions) (Page[Repository], error) { options := gitea.SearchRepoOptions{ - ListOptions: listOptions(opts.Limit, opts.Page), Keyword: opts.Query, KeywordIsTopic: opts.Topic, - KeywordInDescription: true, IsPrivate: opts.Private, IsArchived: opts.Archived, Type: gitea.RepoType(opts.Kind), + Keyword: opts.Query, KeywordIsTopic: opts.Topic, KeywordInDescription: true, + IsPrivate: opts.Private, IsArchived: opts.Archived, Type: gitea.RepoType(opts.Kind), } if opts.Sort != "" { options.Sort, options.Order = opts.Sort, opts.Order @@ -109,7 +115,7 @@ func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions) if opts.Owner != "" { id, _, err := s.ownerID(ctx, opts.Owner) if err != nil { - return nil, err + return Page[Repository]{}, err } options.OwnerID = id } @@ -117,14 +123,18 @@ func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions) return c.Users.GetMyUserInfo(ctx) }) if err != nil { - return nil, err + return Page[Repository]{}, err } options.PrioritizedByOwnerID = account.ID - items, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) { - return c.Repositories.SearchRepos(ctx, options) + window, err := listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Repository, http.Header, error) { + options.ListOptions = listOptions(limit, page) + items, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) { + return c.Repositories.SearchRepos(ctx, options) + }) + return items, headerOf(resp), err }) if err != nil { - return nil, err + return Page[Repository]{}, err } - return convertAll(items, repositoryFrom), nil + return Page[Repository]{Items: convertAll(window.Items, repositoryFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil } diff --git a/internal/services/repository_search_test.go b/internal/services/repository_search_test.go index dc1bc409..32b5d673 100644 --- a/internal/services/repository_search_test.go +++ b/internal/services/repository_search_test.go @@ -112,10 +112,10 @@ func TestRepositorySearchSendsExplicitFalseFilters(t *testing.T) { _, _ = w.Write([]byte(`{"data":[]}`)) })) value := false - items, err := (Repositories{Client: client}).Search(context.Background(), RepositorySearchOptions{Query: "two words", Private: &value, Archived: &value, Topic: true, Limit: 30, Page: 1}) + page, err := (Repositories{Client: client}).Search(context.Background(), RepositorySearchOptions{Query: "two words", Private: &value, Archived: &value, Topic: true, Limit: 30, Page: 1}) require.NoError(t, err) - require.NotNil(t, items) - require.Empty(t, items) + require.NotNil(t, page.Items) + require.Empty(t, page.Items) } func TestRepositorySearchScopesOwner(t *testing.T) { @@ -136,7 +136,7 @@ func TestRepositorySearchScopesOwner(t *testing.T) { _, _ = w.Write([]byte(`{"data":[{"id":4,"full_name":"acme/tool"}]}`)) } })) - items, err := (Repositories{Client: client}).Search(context.Background(), RepositorySearchOptions{Owner: "acme", Kind: "fork", Sort: "updated", Order: "desc", Limit: 30, Page: 1}) + page, err := (Repositories{Client: client}).Search(context.Background(), RepositorySearchOptions{Owner: "acme", Kind: "fork", Sort: "updated", Order: "desc", Limit: 30, Page: 1}) require.NoError(t, err) - require.Equal(t, []Repository{{ID: 4, FullName: "acme/tool"}}, items) + require.Equal(t, []Repository{{ID: 4, FullName: "acme/tool"}}, page.Items) }