From 690cfe95e133e9f4d98f21b0b76a6f70bd0671c9 Mon Sep 17 00:00:00 2001 From: Duc-Tam Nguyen Date: Mon, 15 Jun 2026 23:44:05 +0700 Subject: [PATCH] migrate to any-cli/kit, add 20 commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace cobra/fang/pkg/render with the any-cli/kit framework. Drop cli/cmd_*.go, cli/output.go, cli/errors.go, cli/version.go, pkg/render/. gitee/ gains api.go (20 typed API methods), wire.go (all wire→public converters, wireLicense dual-form, base64 README decode), ids.go (ParseRepoSlug), domain.go (kit.Domain + Session + newClient + MapErr), ops.go (20 kit.Handle registrations: user, user repos, followers, following, repo, commits, branches, tags, releases, issues, pulls, readme, tree, stargazers, forks, contributors, search repos, search users, org, org repos). gitee.go gets Token field + addToken(), ErrUnauthorized, ErrRateLimit. types.go fully rewritten with table/kit struct tags. Test suite updated; ids_test.go added. --- cli/cmd_releases.go | 26 -- cli/cmd_repo.go | 40 --- cli/cmd_search.go | 29 -- cli/cmd_trending.go | 28 -- cli/cmd_user.go | 35 -- cli/errors.go | 11 - cli/output.go | 25 -- cli/root.go | 150 +-------- cli/version.go | 27 -- cmd/gitee/main.go | 15 +- gitee/api.go | 596 ++++++++++++++++++++++++++++++++++ gitee/domain.go | 104 ++++++ gitee/gitee.go | 230 ++------------ gitee/gitee_test.go | 173 +++++----- gitee/ids.go | 29 ++ gitee/ids_test.go | 50 +++ gitee/ops.go | 741 +++++++++++++++++++++++++++++++++++++++++++ gitee/types.go | 329 ++++++++++--------- gitee/wire.go | 488 ++++++++++++++++++++++++++++ go.mod | 21 +- go.sum | 59 +++- pkg/render/render.go | 350 -------------------- 22 files changed, 2380 insertions(+), 1176 deletions(-) delete mode 100644 cli/cmd_releases.go delete mode 100644 cli/cmd_repo.go delete mode 100644 cli/cmd_search.go delete mode 100644 cli/cmd_trending.go delete mode 100644 cli/cmd_user.go delete mode 100644 cli/errors.go delete mode 100644 cli/output.go delete mode 100644 cli/version.go create mode 100644 gitee/api.go create mode 100644 gitee/domain.go create mode 100644 gitee/ids.go create mode 100644 gitee/ids_test.go create mode 100644 gitee/ops.go create mode 100644 gitee/wire.go delete mode 100644 pkg/render/render.go diff --git a/cli/cmd_releases.go b/cli/cmd_releases.go deleted file mode 100644 index eb1f73f..0000000 --- a/cli/cmd_releases.go +++ /dev/null @@ -1,26 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -func (a *App) releasesCmd() *cobra.Command { - return &cobra.Command{ - Use: "releases ", - Short: "List releases for a repository", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - owner, repo, err := parseOwnerRepo(args[0]) - if err != nil { - return codeError(exitUsage, err) - } - n := a.effectiveLimit(10) - a.progressf("fetching releases for %s/%s...", owner, repo) - releases, err := a.client.ListReleases(cmd.Context(), owner, repo, n) - if err != nil { - return mapFetchErr(err) - } - return a.renderOrEmpty(releases, len(releases)) - }, - } -} diff --git a/cli/cmd_repo.go b/cli/cmd_repo.go deleted file mode 100644 index c72ef9b..0000000 --- a/cli/cmd_repo.go +++ /dev/null @@ -1,40 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" -) - -func (a *App) repoCmd() *cobra.Command { - return &cobra.Command{ - Use: "repo ", - Short: "Show single repo details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - owner, repo, err := parseOwnerRepo(args[0]) - if err != nil { - return codeError(exitUsage, err) - } - a.progressf("fetching %s/%s...", owner, repo) - r, err := a.client.GetRepo(cmd.Context(), owner, repo) - if err != nil { - return mapFetchErr(err) - } - return a.render(r) - }, - } -} - -// parseOwnerRepo accepts "owner/repo" or a full gitee.com URL. -func parseOwnerRepo(s string) (owner, repo string, err error) { - s = strings.TrimPrefix(s, "https://gitee.com/") - s = strings.TrimPrefix(s, "http://gitee.com/") - s = strings.TrimSuffix(s, "/") - parts := strings.SplitN(s, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repo %q: want owner/repo", s) - } - return parts[0], parts[1], nil -} diff --git a/cli/cmd_search.go b/cli/cmd_search.go deleted file mode 100644 index 2cc8d59..0000000 --- a/cli/cmd_search.go +++ /dev/null @@ -1,29 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -func (a *App) searchCmd() *cobra.Command { - var ( - lang string - sort string - ) - cmd := &cobra.Command{ - Use: "search ", - Short: "Search Gitee repositories", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - n := a.effectiveLimit(20) - a.progressf("searching for %q...", args[0]) - repos, err := a.client.SearchRepos(cmd.Context(), args[0], lang, sort, n) - if err != nil { - return mapFetchErr(err) - } - return a.renderOrEmpty(repos, len(repos)) - }, - } - cmd.Flags().StringVar(&lang, "lang", "", "filter by programming language") - cmd.Flags().StringVar(&sort, "sort", "stars", "sort order: stars|forks|updated") - return cmd -} diff --git a/cli/cmd_trending.go b/cli/cmd_trending.go deleted file mode 100644 index c22d504..0000000 --- a/cli/cmd_trending.go +++ /dev/null @@ -1,28 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -func (a *App) trendingCmd() *cobra.Command { - var ( - lang string - sort string - ) - cmd := &cobra.Command{ - Use: "trending", - Short: "Explore trending Gitee repositories", - RunE: func(cmd *cobra.Command, _ []string) error { - n := a.effectiveLimit(20) - a.progressf("fetching trending repos (sort=%s)...", sort) - repos, err := a.client.TrendingRepos(cmd.Context(), lang, sort, n) - if err != nil { - return mapFetchErr(err) - } - return a.renderOrEmpty(repos, len(repos)) - }, - } - cmd.Flags().StringVar(&lang, "lang", "", "filter by programming language") - cmd.Flags().StringVar(&sort, "sort", "stars", "sort order: stars|newest|updated") - return cmd -} diff --git a/cli/cmd_user.go b/cli/cmd_user.go deleted file mode 100644 index 1c261b8..0000000 --- a/cli/cmd_user.go +++ /dev/null @@ -1,35 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" - "github.com/tamnd/gitee-cli/gitee" -) - -func (a *App) userCmd() *cobra.Command { - return &cobra.Command{ - Use: "user ", - Short: "Show a Gitee user profile and their repos", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - username := args[0] - a.progressf("fetching user %q...", username) - user, err := a.client.GetUser(cmd.Context(), username) - if err != nil { - return mapFetchErr(err) - } - if err := a.render([]gitee.User{user}); err != nil { - return err - } - n := a.effectiveLimit(20) - a.progressf("fetching repos for %q (limit %d)...", username, n) - repos, err := a.client.UserRepos(cmd.Context(), username, n) - if err != nil { - return mapFetchErr(err) - } - if len(repos) > 0 { - return a.render(repos) - } - return nil - }, - } -} diff --git a/cli/errors.go b/cli/errors.go deleted file mode 100644 index f2797d9..0000000 --- a/cli/errors.go +++ /dev/null @@ -1,11 +0,0 @@ -package cli - -import ( - "errors" - - "github.com/tamnd/gitee-cli/gitee" -) - -func isNotFound(err error) bool { - return errors.Is(err, gitee.ErrNotFound) -} diff --git a/cli/output.go b/cli/output.go deleted file mode 100644 index f81afba..0000000 --- a/cli/output.go +++ /dev/null @@ -1,25 +0,0 @@ -package cli - -import ( - "io" - - "github.com/tamnd/gitee-cli/pkg/render" -) - -// Format aliases so command code reads cleanly. -type Format = render.Format - -const ( - FormatTable = render.FormatTable - FormatJSON = render.FormatJSON - FormatJSONL = render.FormatJSONL - FormatCSV = render.FormatCSV - FormatTSV = render.FormatTSV - FormatURL = render.FormatURL - FormatRaw = render.FormatRaw -) - -// NewRenderer builds a renderer writing to w. -func NewRenderer(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *render.Renderer { - return render.New(w, format, fields, noHeader, tmpl) -} diff --git a/cli/root.go b/cli/root.go index 6316e90..636eb85 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,152 +1,24 @@ -// Package cli builds the gitee command tree on top of the gitee library. +// Package cli assembles the gitee command tree from the gitee domain on top of +// the any-cli/kit framework. package cli import ( - "fmt" - "os" - - "github.com/mattn/go-isatty" - "github.com/spf13/cobra" + "github.com/tamnd/any-cli/kit" "github.com/tamnd/gitee-cli/gitee" ) -// Build metadata, injected via -ldflags at release time. +// Build metadata, set via -ldflags at release time. var ( Version = "dev" Commit = "none" Date = "unknown" ) -// exit codes. -const ( - exitError = 1 - exitUsage = 2 - exitNoData = 3 -) - -// ExitError carries a process exit code up to main. -type ExitError struct { - Code int - Err error -} - -func (e *ExitError) Error() string { - if e.Err != nil { - return e.Err.Error() - } - return fmt.Sprintf("exit %d", e.Code) -} - -func (e *ExitError) Unwrap() error { return e.Err } - -func codeError(code int, err error) error { return &ExitError{Code: code, Err: err} } - -// App holds shared state threaded through every command. -type App struct { - client *gitee.Client - cfg gitee.Config - - output string - fields []string - noHeader bool - template string - limit int - quiet bool -} - -// Root builds the root command and its subtree. -func Root() *cobra.Command { - app := &App{cfg: gitee.DefaultConfig()} - - root := &cobra.Command{ - Use: "gitee", - Short: "Browse Gitee repositories and users", - Long: `gitee reads Gitee — China's largest Git hosting platform — through its -official public REST API. No authentication token is required. It returns -records as table, JSON, JSONL, CSV, TSV, or URLs. - -gitee is an independent tool and is not affiliated with Gitee or Oschina.`, - SilenceUsage: true, - SilenceErrors: true, - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - return app.setup() - }, - } - - pf := root.PersistentFlags() - pf.StringVarP(&app.output, "output", "o", "auto", "output: table|json|jsonl|csv|tsv|url|raw (auto=table on TTY, jsonl piped)") - pf.StringSliceVar(&app.fields, "fields", nil, "comma-separated columns to include") - pf.BoolVar(&app.noHeader, "no-header", false, "omit the header row in table/csv/tsv") - pf.StringVar(&app.template, "template", "", "Go text/template applied per record") - pf.IntVarP(&app.limit, "limit", "n", 0, "limit number of records (0 = command default)") - pf.BoolVarP(&app.quiet, "quiet", "q", false, "suppress progress on stderr") - - pf.DurationVar(&app.cfg.Rate, "delay", app.cfg.Rate, "minimum spacing between requests") - pf.DurationVar(&app.cfg.Timeout, "timeout", app.cfg.Timeout, "per-request timeout") - pf.IntVar(&app.cfg.Retries, "retries", app.cfg.Retries, "retry attempts on 429/5xx") - pf.StringVar(&app.cfg.UserAgent, "user-agent", app.cfg.UserAgent, "User-Agent sent with each request") - - root.AddCommand( - app.searchCmd(), - app.repoCmd(), - app.trendingCmd(), - app.userCmd(), - app.releasesCmd(), - newVersionCmd(), - ) - return root -} - -func (a *App) setup() error { - if a.output == "" || a.output == "auto" { - if isatty.IsTerminal(os.Stdout.Fd()) { - a.output = string(FormatTable) - } else { - a.output = string(FormatJSONL) - } - } - if !Format(a.output).Valid() { - return codeError(exitUsage, fmt.Errorf("unknown output format %q", a.output)) - } - a.client = gitee.NewClient(a.cfg) - return nil -} - -func (a *App) render(records any) error { - r := NewRenderer(os.Stdout, Format(a.output), a.fields, a.noHeader, a.template) - return r.Render(records) -} - -func (a *App) renderOrEmpty(records any, n int) error { - if err := a.render(records); err != nil { - return err - } - if n == 0 { - return codeError(exitNoData, nil) - } - return nil -} - -func (a *App) progressf(format string, args ...any) { - if a.quiet { - return - } - _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) -} - -func mapFetchErr(err error) error { - if err == nil { - return nil - } - if isNotFound(err) { - return codeError(exitNoData, err) - } - return codeError(exitError, err) -} - -func (a *App) effectiveLimit(def int) int { - if a.limit > 0 { - return a.limit - } - return def +// NewApp assembles the kit application from the gitee domain. +func NewApp() *kit.App { + id := gitee.BaseIdentity() + id.Version = Version + app := kit.New(id, kit.WithDefaults(gitee.Defaults)) + gitee.Register(app) + return app } diff --git a/cli/version.go b/cli/version.go deleted file mode 100644 index 1e1b3af..0000000 --- a/cli/version.go +++ /dev/null @@ -1,27 +0,0 @@ -package cli - -import ( - "fmt" - "runtime" - - "github.com/spf13/cobra" -) - -func newVersionCmd() *cobra.Command { - var short bool - cmd := &cobra.Command{ - Use: "version", - Short: "Print version information", - RunE: func(c *cobra.Command, _ []string) error { - if short { - _, _ = fmt.Fprintln(c.OutOrStdout(), Version) - return nil - } - _, _ = fmt.Fprintf(c.OutOrStdout(), "gitee %s (commit %s, built %s, %s/%s, %s)\n", - Version, Commit, Date, runtime.GOOS, runtime.GOARCH, runtime.Version()) - return nil - }, - } - cmd.Flags().BoolVar(&short, "short", false, "print just the version number") - return cmd -} diff --git a/cmd/gitee/main.go b/cmd/gitee/main.go index 6e5836c..60b5be2 100644 --- a/cmd/gitee/main.go +++ b/cmd/gitee/main.go @@ -1,4 +1,4 @@ -// Command gitee is a single-binary command line for gitee. +// Command gitee is a single-binary command line for Gitee (码云). package main import ( @@ -7,21 +7,12 @@ import ( "os/signal" "syscall" - "github.com/charmbracelet/fang" + "github.com/tamnd/any-cli/kit" "github.com/tamnd/gitee-cli/cli" ) func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - - root := cli.Root() - // fang gives styled help, errors, and shell completion for free; the command - // tree and its exit-code mapping stay in the cli package. - if err := fang.Execute(ctx, root, - fang.WithVersion(cli.Version), - fang.WithNotifySignal(os.Interrupt, syscall.SIGTERM), - ); err != nil { - os.Exit(1) - } + os.Exit(kit.Run(ctx, cli.NewApp())) } diff --git a/gitee/api.go b/gitee/api.go new file mode 100644 index 0000000..f00df8d --- /dev/null +++ b/gitee/api.go @@ -0,0 +1,596 @@ +package gitee + +import ( + "context" + "net/url" + "strconv" +) + +const maxPerPage = 100 + +// GetUser fetches a user profile. +func (c *Client) GetUser(ctx context.Context, username string) (User, error) { + rawURL := c.cfg.BaseURL + "/users/" + url.PathEscape(username) + var w wireUser + if err := c.getJSON(ctx, rawURL, &w); err != nil { + return User{}, err + } + return w.toPublic(), nil +} + +// UserRepos fetches a user's public repositories. +func (c *Client) UserRepos(ctx context.Context, username, sort, direction, typ string, limit int) ([]Repo, error) { + if limit <= 0 { + limit = 20 + } + if sort == "" { + sort = "updated" + } + if direction == "" { + direction = "desc" + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Repo + for page := 1; ; page++ { + params := url.Values{} + params.Set("sort", sort) + params.Set("direction", direction) + if typ != "" { + params.Set("type", typ) + } + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/users/" + url.PathEscape(username) + "/repos?" + params.Encode() + var repos []wireRepo + if err := c.getJSON(ctx, rawURL, &repos); err != nil { + return out, err + } + for _, wr := range repos { + out = append(out, wr.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(repos) < pageSize { + break + } + } + return out, nil +} + +// Followers fetches a user's followers. +func (c *Client) Followers(ctx context.Context, username string, limit int) ([]User, error) { + return c.fetchUsers(ctx, "/users/"+url.PathEscape(username)+"/followers", limit) +} + +// Following fetches users that a user follows. +func (c *Client) Following(ctx context.Context, username string, limit int) ([]User, error) { + return c.fetchUsers(ctx, "/users/"+url.PathEscape(username)+"/following", limit) +} + +func (c *Client) fetchUsers(ctx context.Context, path string, limit int) ([]User, error) { + if limit <= 0 { + limit = 20 + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []User + for page := 1; ; page++ { + params := url.Values{} + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + path + "?" + params.Encode() + var users []wireUser + if err := c.getJSON(ctx, rawURL, &users); err != nil { + return out, err + } + for _, w := range users { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(users) < pageSize { + break + } + } + return out, nil +} + +// GetRepo fetches a single repository. +func (c *Client) GetRepo(ctx context.Context, owner, repo string) (Repo, error) { + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + var w wireRepo + if err := c.getJSON(ctx, rawURL, &w); err != nil { + return Repo{}, err + } + return w.toPublic(), nil +} + +// Commits fetches commits for a repository. +func (c *Client) Commits(ctx context.Context, owner, repo, sha, path, author, since, until string, limit int) ([]Commit, error) { + if limit <= 0 { + limit = 20 + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Commit + for page := 1; ; page++ { + params := url.Values{} + if sha != "" { + params.Set("sha", sha) + } + if path != "" { + params.Set("path", path) + } + if author != "" { + params.Set("author", author) + } + if since != "" { + params.Set("since", since) + } + if until != "" { + params.Set("until", until) + } + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/commits?" + params.Encode() + var commits []wireCommit + if err := c.getJSON(ctx, rawURL, &commits); err != nil { + return out, err + } + for _, w := range commits { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(commits) < pageSize { + break + } + } + return out, nil +} + +// Branches fetches branches for a repository. +func (c *Client) Branches(ctx context.Context, owner, repo string, limit int) ([]Branch, error) { + if limit <= 0 { + limit = 20 + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Branch + for page := 1; ; page++ { + params := url.Values{} + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/branches?" + params.Encode() + var branches []wireBranch + if err := c.getJSON(ctx, rawURL, &branches); err != nil { + return out, err + } + for _, w := range branches { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(branches) < pageSize { + break + } + } + return out, nil +} + +// Tags fetches tags for a repository. +func (c *Client) Tags(ctx context.Context, owner, repo, sort, direction string, limit int) ([]Tag, error) { + if limit <= 0 { + limit = 20 + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Tag + for page := 1; ; page++ { + params := url.Values{} + if sort != "" { + params.Set("sort_by", sort) + } + if direction != "" { + params.Set("direction", direction) + } + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/tags?" + params.Encode() + var tags []wireTag + if err := c.getJSON(ctx, rawURL, &tags); err != nil { + return out, err + } + for _, w := range tags { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(tags) < pageSize { + break + } + } + return out, nil +} + +// Releases fetches releases for a repository. +func (c *Client) Releases(ctx context.Context, owner, repo string, limit int) ([]Release, error) { + if limit <= 0 { + limit = 10 + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Release + for page := 1; ; page++ { + params := url.Values{} + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/releases?" + params.Encode() + var releases []wireRelease + if err := c.getJSON(ctx, rawURL, &releases); err != nil { + return out, err + } + for _, w := range releases { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(releases) < pageSize { + break + } + } + return out, nil +} + +// Issues fetches issues for a repository. +func (c *Client) Issues(ctx context.Context, owner, repo, state, sort, direction string, limit int) ([]Issue, error) { + if limit <= 0 { + limit = 20 + } + if state == "" { + state = "open" + } + if sort == "" { + sort = "created" + } + if direction == "" { + direction = "desc" + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Issue + for page := 1; ; page++ { + params := url.Values{} + params.Set("state", state) + params.Set("sort", sort) + params.Set("direction", direction) + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/issues?" + params.Encode() + var issues []wireIssue + if err := c.getJSON(ctx, rawURL, &issues); err != nil { + return out, err + } + for _, w := range issues { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(issues) < pageSize { + break + } + } + return out, nil +} + +// Pulls fetches pull requests for a repository. +func (c *Client) Pulls(ctx context.Context, owner, repo, state, sort, direction string, limit int) ([]PullRequest, error) { + if limit <= 0 { + limit = 20 + } + if state == "" { + state = "open" + } + if sort == "" { + sort = "created" + } + if direction == "" { + direction = "desc" + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []PullRequest + for page := 1; ; page++ { + params := url.Values{} + params.Set("state", state) + params.Set("sort", sort) + params.Set("direction", direction) + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls?" + params.Encode() + var pulls []wirePullRequest + if err := c.getJSON(ctx, rawURL, &pulls); err != nil { + return out, err + } + for _, w := range pulls { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(pulls) < pageSize { + break + } + } + return out, nil +} + +// Readme fetches the README for a repository. +func (c *Client) Readme(ctx context.Context, owner, repo, ref string) (ReadmeFile, error) { + params := url.Values{} + if ref != "" { + params.Set("ref", ref) + } + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/readme" + if len(params) > 0 { + rawURL += "?" + params.Encode() + } + var w wireReadme + if err := c.getJSON(ctx, rawURL, &w); err != nil { + return ReadmeFile{}, err + } + return w.toPublic(), nil +} + +// Tree fetches the git tree for a repository. +func (c *Client) Tree(ctx context.Context, owner, repo, ref string, recursive bool) ([]TreeEntry, error) { + if ref == "" { + ref = "HEAD" + } + params := url.Values{} + if recursive { + params.Set("recursive", "1") + } + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/git/trees/" + url.PathEscape(ref) + if len(params) > 0 { + rawURL += "?" + params.Encode() + } + var w wireTree + if err := c.getJSON(ctx, rawURL, &w); err != nil { + return nil, err + } + return w.toPublic(), nil +} + +// Stargazers fetches users who starred a repository. +func (c *Client) Stargazers(ctx context.Context, owner, repo string, limit int) ([]User, error) { + return c.fetchUsers(ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/stargazers", limit) +} + +// Forks fetches forks of a repository. +func (c *Client) Forks(ctx context.Context, owner, repo, sort string, limit int) ([]Repo, error) { + if limit <= 0 { + limit = 20 + } + if sort == "" { + sort = "newest" + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Repo + for page := 1; ; page++ { + params := url.Values{} + params.Set("sort", sort) + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/forks?" + params.Encode() + var repos []wireRepo + if err := c.getJSON(ctx, rawURL, &repos); err != nil { + return out, err + } + for _, w := range repos { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(repos) < pageSize { + break + } + } + return out, nil +} + +// Contributors fetches contributors to a repository. +func (c *Client) Contributors(ctx context.Context, owner, repo, typ string, limit int) ([]Contributor, error) { + if limit <= 0 { + limit = 20 + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Contributor + for page := 1; ; page++ { + params := url.Values{} + if typ != "" { + params.Set("type", typ) + } + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/contributors?" + params.Encode() + var contributors []wireContributor + if err := c.getJSON(ctx, rawURL, &contributors); err != nil { + return out, err + } + for _, w := range contributors { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(contributors) < pageSize { + break + } + } + return out, nil +} + +// SearchRepos searches Gitee repositories using Elasticsearch-style from/size. +func (c *Client) SearchRepos(ctx context.Context, query, sort string, limit int) ([]Repo, error) { + if limit <= 0 { + limit = 20 + } + if sort == "" { + sort = "stars_count" + } else { + switch sort { + case "forks": + sort = "forks_count" + case "updated": + sort = "updated" + case "stars": + sort = "stars_count" + } + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Repo + for from := 0; ; from += pageSize { + params := url.Values{} + params.Set("q", query) + params.Set("sort", sort) + params.Set("order", "desc") + params.Set("from", strconv.Itoa(from)) + params.Set("size", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/search/repositories?" + params.Encode() + var repos []wireRepo + if err := c.getJSON(ctx, rawURL, &repos); err != nil { + return out, err + } + for _, w := range repos { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(repos) < pageSize { + break + } + } + return out, nil +} + +// SearchUsers searches Gitee users using Elasticsearch-style from/size. +func (c *Client) SearchUsers(ctx context.Context, query string, limit int) ([]User, error) { + if limit <= 0 { + limit = 20 + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []User + for from := 0; ; from += pageSize { + params := url.Values{} + params.Set("q", query) + params.Set("from", strconv.Itoa(from)) + params.Set("size", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/search/users?" + params.Encode() + var users []wireUser + if err := c.getJSON(ctx, rawURL, &users); err != nil { + return out, err + } + for _, w := range users { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(users) < pageSize { + break + } + } + return out, nil +} + +// GetOrg fetches an organization profile. +func (c *Client) GetOrg(ctx context.Context, name string) (OrgProfile, error) { + rawURL := c.cfg.BaseURL + "/orgs/" + url.PathEscape(name) + var w wireOrg + if err := c.getJSON(ctx, rawURL, &w); err != nil { + return OrgProfile{}, err + } + return w.toPublic(), nil +} + +// OrgRepos fetches repositories for an organization. +func (c *Client) OrgRepos(ctx context.Context, name, typ, sort, direction string, limit int) ([]Repo, error) { + if limit <= 0 { + limit = 20 + } + if sort == "" { + sort = "updated" + } + if direction == "" { + direction = "desc" + } + pageSize := limit + if pageSize > maxPerPage { + pageSize = maxPerPage + } + var out []Repo + for page := 1; ; page++ { + params := url.Values{} + if typ != "" { + params.Set("type", typ) + } + params.Set("sort", sort) + params.Set("direction", direction) + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", strconv.Itoa(pageSize)) + rawURL := c.cfg.BaseURL + "/orgs/" + url.PathEscape(name) + "/repos?" + params.Encode() + var repos []wireRepo + if err := c.getJSON(ctx, rawURL, &repos); err != nil { + return out, err + } + for _, w := range repos { + out = append(out, w.toPublic()) + if len(out) >= limit { + return out, nil + } + } + if len(repos) < pageSize { + break + } + } + return out, nil +} diff --git a/gitee/domain.go b/gitee/domain.go new file mode 100644 index 0000000..1962b54 --- /dev/null +++ b/gitee/domain.go @@ -0,0 +1,104 @@ +package gitee + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/any-cli/kit/errs" +) + +func init() { kit.Register(Domain{}) } + +type Domain struct{} + +func (Domain) Info() kit.DomainInfo { + return kit.DomainInfo{ + Scheme: "gitee", + Hosts: []string{"gitee.com"}, + Identity: BaseIdentity(), + } +} + +func BaseIdentity() kit.Identity { + return kit.Identity{ + Binary: "gitee", + Short: "A command-line for Gitee (码云) — China's Git hosting platform.", + Long: `gitee reads public data from Gitee (码云) via the official REST API v5. + +Browse users, repositories, commits, issues, pull requests, releases, and more. +All commands work without a token. Set GITEE_TOKEN for higher rate limits. + +gitee is an independent tool and is not affiliated with Gitee or OSChina.`, + Site: "https://gitee.com", + Repo: "https://github.com/tamnd/gitee-cli", + } +} + +func Defaults(c *kit.Config) { + d := DefaultConfig() + c.Rate = d.Rate + c.Timeout = d.Timeout + c.Retries = d.Retries + c.UserAgent = d.UserAgent +} + +func (Domain) Register(app *kit.App) { + app.SetClient(newClient) + registerOps(app) +} + +func Register(app *kit.App) { Domain{}.Register(app) } + +type Session struct { + Client *Client + Quiet bool +} + +func (s *Session) Progressf(format string, args ...any) { + if s == nil || s.Quiet { + return + } + _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) +} + +func newClient(_ context.Context, c kit.Config) (any, error) { + cfg := DefaultConfig() + if c.UserAgent != "" { + cfg.UserAgent = c.UserAgent + } + if c.Rate > 0 { + cfg.Rate = c.Rate + } + if c.Timeout > 0 { + cfg.Timeout = c.Timeout + } + if c.Retries > 0 { + cfg.Retries = c.Retries + } + cfg.Token = os.Getenv("GITEE_TOKEN") + return &Session{Client: NewClient(cfg), Quiet: c.Quiet}, nil +} + +func MapErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, ErrNotFound) { + return errs.NotFound("%s", err) + } + if errors.Is(err, ErrUnauthorized) { + return errs.Usage("authentication required — set GITEE_TOKEN") + } + return err +} + +func (Domain) Classify(input string) (uriType, id string, err error) { + return "", "", errs.Usage("unrecognised gitee reference: %q", input) +} + +func (Domain) Locate(uriType, id string) (string, error) { + return "", errs.Usage("gitee has no resource type %q", uriType) +} diff --git a/gitee/gitee.go b/gitee/gitee.go index 305b966..a0880e3 100644 --- a/gitee/gitee.go +++ b/gitee/gitee.go @@ -8,23 +8,16 @@ package gitee import ( "context" "encoding/json" - "errors" "fmt" "io" "net/http" - "net/url" - "strconv" "strings" "sync" "time" ) -// DefaultUserAgent identifies the client to Gitee. const DefaultUserAgent = "gitee/dev (+https://github.com/tamnd/gitee-cli)" -// ErrNotFound is returned when the API returns null for an object. -var ErrNotFound = errors.New("not found") - // Config holds constructor parameters. type Config struct { BaseURL string @@ -32,6 +25,7 @@ type Config struct { Rate time.Duration Retries int Timeout time.Duration + Token string } // DefaultConfig returns sensible defaults. @@ -61,6 +55,17 @@ func NewClient(cfg Config) *Client { } } +// addToken appends the access_token query param if a token is configured. +func (c *Client) addToken(rawURL string) string { + if c.cfg.Token == "" { + return rawURL + } + if strings.Contains(rawURL, "?") { + return rawURL + "&access_token=" + c.cfg.Token + } + return rawURL + "?access_token=" + c.cfg.Token +} + // get fetches a URL with pacing and retries. func (c *Client) get(ctx context.Context, rawURL string) ([]byte, error) { var lastErr error @@ -86,7 +91,8 @@ func (c *Client) get(ctx context.Context, rawURL string) ([]byte, error) { func (c *Client) do(ctx context.Context, rawURL string) ([]byte, bool, error) { c.pace() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + tokenURL := c.addToken(rawURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) if err != nil { return nil, false, err } @@ -99,10 +105,16 @@ func (c *Client) do(ctx context.Context, rawURL string) ([]byte, bool, error) { } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + switch { + case resp.StatusCode == http.StatusUnauthorized: + return nil, false, ErrUnauthorized + case resp.StatusCode == http.StatusNotFound: + return nil, false, ErrNotFound + case resp.StatusCode == http.StatusTooManyRequests: + return nil, true, ErrRateLimit + case resp.StatusCode >= 500: return nil, true, fmt.Errorf("http %d", resp.StatusCode) - } - if resp.StatusCode != http.StatusOK { + case resp.StatusCode != http.StatusOK: return nil, false, fmt.Errorf("http %d", resp.StatusCode) } b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) @@ -147,199 +159,3 @@ func (c *Client) getJSON(ctx context.Context, rawURL string, v any) error { } return nil } - -// ─── API methods ───────────────────────────────────────────────────────────── - -// SearchRepos searches Gitee repositories. sort: "stars", "forks", "updated". -// The Gitee search endpoint returns a plain JSON array (not a wrapper object). -func (c *Client) SearchRepos(ctx context.Context, query, lang, sort string, limit int) ([]Repo, error) { - if limit <= 0 { - limit = 20 - } - giteeSort := "stars_count" - switch sort { - case "forks": - giteeSort = "forks_count" - case "updated": - giteeSort = "updated" - } - q := query - if lang != "" { - q += "+language:" + lang - } - - pageSize := limit - if pageSize > 100 { - pageSize = 100 - } - - var out []Repo - page := 1 - for { - params := url.Values{} - params.Set("q", q) - params.Set("sort", giteeSort) - params.Set("order", "desc") - params.Set("page", strconv.Itoa(page)) - params.Set("per_page", strconv.Itoa(pageSize)) - - rawURL := c.cfg.BaseURL + "/search/repositories?" + params.Encode() - var repos []wireRepo - if err := c.getJSON(ctx, rawURL, &repos); err != nil { - return out, err - } - for _, wr := range repos { - out = append(out, wireRepoToRepo(wr, len(out)+1)) - if len(out) >= limit { - return out, nil - } - } - if len(repos) == 0 { - break - } - page++ - } - return out, nil -} - -// GetRepo fetches a single repository. -func (c *Client) GetRepo(ctx context.Context, owner, repo string) (Repo, error) { - rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) - var wr wireRepo - if err := c.getJSON(ctx, rawURL, &wr); err != nil { - return Repo{}, err - } - return wireRepoToRepo(wr, 0), nil -} - -// TrendingRepos fetches trending repos from the explore endpoint. -// sort: "stars" (default), "newest", "updated". -func (c *Client) TrendingRepos(ctx context.Context, lang, sort string, limit int) ([]Repo, error) { - if limit <= 0 { - limit = 20 - } - giteeSort := "most_stars" - switch sort { - case "newest": - giteeSort = "newest" - case "updated": - giteeSort = "recently_updated" - } - - pageSize := limit - if pageSize > 100 { - pageSize = 100 - } - - var out []Repo - page := 1 - for { - params := url.Values{} - params.Set("sort", giteeSort) - params.Set("page", strconv.Itoa(page)) - params.Set("per_page", strconv.Itoa(pageSize)) - if lang != "" { - params.Set("language", lang) - } - - rawURL := c.cfg.BaseURL + "/repos/explore?" + params.Encode() - var repos []wireRepo - if err := c.getJSON(ctx, rawURL, &repos); err != nil { - return out, err - } - for _, wr := range repos { - out = append(out, wireRepoToRepo(wr, len(out)+1)) - if len(out) >= limit { - return out, nil - } - } - if len(repos) == 0 { - break - } - page++ - } - return out, nil -} - -// GetUser fetches a user profile. -func (c *Client) GetUser(ctx context.Context, username string) (User, error) { - rawURL := c.cfg.BaseURL + "/users/" + url.PathEscape(username) - var wu wireUser - if err := c.getJSON(ctx, rawURL, &wu); err != nil { - return User{}, err - } - return wireUserToUser(wu), nil -} - -// UserRepos fetches a user's public repositories. -func (c *Client) UserRepos(ctx context.Context, username string, limit int) ([]Repo, error) { - if limit <= 0 { - limit = 20 - } - pageSize := limit - if pageSize > 100 { - pageSize = 100 - } - - var out []Repo - page := 1 - for { - params := url.Values{} - params.Set("sort", "updated") - params.Set("page", strconv.Itoa(page)) - params.Set("per_page", strconv.Itoa(pageSize)) - - rawURL := c.cfg.BaseURL + "/users/" + url.PathEscape(username) + "/repos?" + params.Encode() - var repos []wireRepo - if err := c.getJSON(ctx, rawURL, &repos); err != nil { - return out, err - } - for _, wr := range repos { - out = append(out, wireRepoToRepo(wr, len(out)+1)) - if len(out) >= limit { - return out, nil - } - } - if len(repos) == 0 { - break - } - page++ - } - return out, nil -} - -// ListReleases fetches releases for a repository. -func (c *Client) ListReleases(ctx context.Context, owner, repo string, limit int) ([]Release, error) { - if limit <= 0 { - limit = 10 - } - pageSize := limit - if pageSize > 100 { - pageSize = 100 - } - - var out []Release - page := 1 - for { - params := url.Values{} - params.Set("page", strconv.Itoa(page)) - params.Set("per_page", strconv.Itoa(pageSize)) - - rawURL := c.cfg.BaseURL + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/releases?" + params.Encode() - var releases []wireRelease - if err := c.getJSON(ctx, rawURL, &releases); err != nil { - return out, err - } - for _, wr := range releases { - out = append(out, wireReleaseToRelease(wr, owner, repo, len(out)+1)) - if len(out) >= limit { - return out, nil - } - } - if len(releases) == 0 { - break - } - page++ - } - return out, nil -} diff --git a/gitee/gitee_test.go b/gitee/gitee_test.go index ed5329c..23643f9 100644 --- a/gitee/gitee_test.go +++ b/gitee/gitee_test.go @@ -83,45 +83,44 @@ func TestGetNullReturnsNotFound(t *testing.T) { } } -func TestSearchRepos(t *testing.T) { - items := []wireRepo{ - {FullName: "foo/bar", StargazersCount: 100, HTMLURL: "https://gitee.com/foo/bar.git"}, - {FullName: "baz/qux", StargazersCount: 50, HTMLURL: "https://gitee.com/baz/qux.git"}, +func TestGetUser(t *testing.T) { + wu := wireUser{ + ID: 42, + Login: "testuser", + Name: "Test User", + HTMLURL: "https://gitee.com/testuser", + Followers: 42, + Following: 10, + PublicRepos: 7, + Blog: "https://example.com", } - calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ w.Header().Set("Content-Type", "application/json") - if calls > 1 { - _ = json.NewEncoder(w).Encode([]wireRepo{}) - return - } - _ = json.NewEncoder(w).Encode(items) + _ = json.NewEncoder(w).Encode(wu) })) defer srv.Close() c := newTestClient(srv.URL) - repos, err := c.SearchRepos(context.Background(), "test", "", "stars", 10) + user, err := c.GetUser(context.Background(), "testuser") if err != nil { t.Fatal(err) } - if len(repos) != 2 { - t.Fatalf("got %d repos, want 2", len(repos)) - } - if repos[0].FullName != "foo/bar" { - t.Errorf("first repo = %q, want %q", repos[0].FullName, "foo/bar") + if user.Login != "testuser" { + t.Errorf("login = %q", user.Login) } - if repos[0].Stars != 100 { - t.Errorf("stars = %d, want 100", repos[0].Stars) + if user.Followers != 42 { + t.Errorf("followers = %d", user.Followers) } - if repos[0].Rank != 1 { - t.Errorf("rank = %d, want 1", repos[0].Rank) + if user.HTMLURL != "https://gitee.com/testuser" { + t.Errorf("html_url = %q", user.HTMLURL) } } func TestGetRepo(t *testing.T) { wr := wireRepo{ + ID: 1, FullName: "gitee/gitee", + Name: "gitee", StargazersCount: 999, HTMLURL: "https://gitee.com/gitee/gitee.git", Language: "Ruby", @@ -143,15 +142,15 @@ func TestGetRepo(t *testing.T) { if repo.URL != "https://gitee.com/gitee/gitee" { t.Errorf("url = %q, want https://gitee.com/gitee/gitee", repo.URL) } - if repo.Stars != 999 { - t.Errorf("stars = %d", repo.Stars) + if repo.StargazersCount != 999 { + t.Errorf("stars = %d", repo.StargazersCount) } } -func TestTrendingRepos(t *testing.T) { - repos := []wireRepo{ - {FullName: "a/b", StargazersCount: 200, HTMLURL: "https://gitee.com/a/b"}, - {FullName: "c/d", StargazersCount: 100, HTMLURL: "https://gitee.com/c/d"}, +func TestSearchRepos(t *testing.T) { + items := []wireRepo{ + {ID: 1, FullName: "foo/bar", StargazersCount: 100, HTMLURL: "https://gitee.com/foo/bar.git"}, + {ID: 2, FullName: "baz/qux", StargazersCount: 50, HTMLURL: "https://gitee.com/baz/qux.git"}, } calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -161,61 +160,63 @@ func TestTrendingRepos(t *testing.T) { _ = json.NewEncoder(w).Encode([]wireRepo{}) return } - _ = json.NewEncoder(w).Encode(repos) + _ = json.NewEncoder(w).Encode(items) })) defer srv.Close() c := newTestClient(srv.URL) - out, err := c.TrendingRepos(context.Background(), "", "stars", 5) + repos, err := c.SearchRepos(context.Background(), "test", "stars", 10) if err != nil { t.Fatal(err) } - if len(out) != 2 { - t.Fatalf("got %d repos, want 2", len(out)) + if len(repos) != 2 { + t.Fatalf("got %d repos, want 2", len(repos)) } - if out[0].Rank != 1 { - t.Errorf("rank = %d, want 1", out[0].Rank) + if repos[0].FullName != "foo/bar" { + t.Errorf("first repo = %q, want %q", repos[0].FullName, "foo/bar") } - if out[1].Rank != 2 { - t.Errorf("rank = %d, want 2", out[1].Rank) + if repos[0].StargazersCount != 100 { + t.Errorf("stars = %d, want 100", repos[0].StargazersCount) } } -func TestGetUser(t *testing.T) { - wu := wireUser{ - Login: "testuser", - Name: "Test User", - Followers: 42, - Following: 10, - PublicRepos: 7, - Blog: "https://example.com", +func TestReleases(t *testing.T) { + releases := []wireRelease{ + {ID: 1, TagName: "v1.0.0", Name: "First Release", Prerelease: false, CreatedAt: "2024-01-01T00:00:00+08:00"}, + {ID: 2, TagName: "v0.9.0", Name: "Beta", Prerelease: true, CreatedAt: "2023-12-01T00:00:00+08:00"}, } + calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(wu) + if calls > 1 { + _ = json.NewEncoder(w).Encode([]wireRelease{}) + return + } + _ = json.NewEncoder(w).Encode(releases) })) defer srv.Close() c := newTestClient(srv.URL) - user, err := c.GetUser(context.Background(), "testuser") + out, err := c.Releases(context.Background(), "owner", "repo", 10) if err != nil { t.Fatal(err) } - if user.Login != "testuser" { - t.Errorf("login = %q", user.Login) + if len(out) != 2 { + t.Fatalf("got %d releases, want 2", len(out)) } - if user.Followers != 42 { - t.Errorf("followers = %d", user.Followers) + if out[0].TagName != "v1.0.0" { + t.Errorf("tag = %q", out[0].TagName) } - if user.URL != "https://gitee.com/testuser" { - t.Errorf("url = %q", user.URL) + if !out[1].Prerelease { + t.Errorf("prerelease = %v, want true", out[1].Prerelease) } } func TestUserRepos(t *testing.T) { repos := []wireRepo{ - {FullName: "testuser/alpha", StargazersCount: 10, HTMLURL: "https://gitee.com/testuser/alpha"}, - {FullName: "testuser/beta", StargazersCount: 5, HTMLURL: "https://gitee.com/testuser/beta"}, + {ID: 1, FullName: "testuser/alpha", StargazersCount: 10, HTMLURL: "https://gitee.com/testuser/alpha"}, + {ID: 2, FullName: "testuser/beta", StargazersCount: 5, HTMLURL: "https://gitee.com/testuser/beta"}, } calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -230,7 +231,7 @@ func TestUserRepos(t *testing.T) { defer srv.Close() c := newTestClient(srv.URL) - out, err := c.UserRepos(context.Background(), "testuser", 10) + out, err := c.UserRepos(context.Background(), "testuser", "", "", "", 10) if err != nil { t.Fatal(err) } @@ -242,39 +243,41 @@ func TestUserRepos(t *testing.T) { } } -func TestListReleases(t *testing.T) { - releases := []wireRelease{ - {TagName: "v1.0.0", Name: "First Release", Prerelease: false, CreatedAt: "2024-01-01T00:00:00+08:00"}, - {TagName: "v0.9.0", Name: "Beta", Prerelease: true, CreatedAt: "2023-12-01T00:00:00+08:00"}, - } - calls := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - w.Header().Set("Content-Type", "application/json") - if calls > 1 { - _ = json.NewEncoder(w).Encode([]wireRelease{}) - return - } - _ = json.NewEncoder(w).Encode(releases) - })) - defer srv.Close() +func TestAddToken(t *testing.T) { + cfg := DefaultConfig() + cfg.Token = "mytoken" + c := NewClient(cfg) - c := newTestClient(srv.URL) - out, err := c.ListReleases(context.Background(), "owner", "repo", 10) - if err != nil { - t.Fatal(err) + got := c.addToken("https://gitee.com/api/v5/users/foo") + want := "https://gitee.com/api/v5/users/foo?access_token=mytoken" + if got != want { + t.Errorf("addToken = %q, want %q", got, want) } - if len(out) != 2 { - t.Fatalf("got %d releases, want 2", len(out)) - } - if out[0].TagName != "v1.0.0" { - t.Errorf("tag = %q", out[0].TagName) - } - wantURL := "https://gitee.com/owner/repo/releases/tag/v1.0.0" - if out[0].URL != wantURL { - t.Errorf("url = %q, want %q", out[0].URL, wantURL) + + got2 := c.addToken("https://gitee.com/api/v5/users/foo?page=1") + want2 := "https://gitee.com/api/v5/users/foo?page=1&access_token=mytoken" + if got2 != want2 { + t.Errorf("addToken with existing param = %q, want %q", got2, want2) } - if out[1].Prerelease != true { - t.Errorf("prerelease = %v, want true", out[1].Prerelease) +} + +func TestWireLicenseUnmarshal(t *testing.T) { + tests := []struct { + input string + want string + }{ + {`"MIT"`, "MIT"}, + {`{"spdx_id":"Apache-2.0","key":"apache-2.0","name":"Apache License 2.0"}`, "Apache-2.0"}, + {`null`, ""}, + } + for _, tc := range tests { + var l wireLicense + if err := json.Unmarshal([]byte(tc.input), &l); err != nil { + t.Errorf("unmarshal %q: %v", tc.input, err) + continue + } + if l.SPDXID != tc.want { + t.Errorf("unmarshal %q: got %q, want %q", tc.input, l.SPDXID, tc.want) + } } } diff --git a/gitee/ids.go b/gitee/ids.go new file mode 100644 index 0000000..2525425 --- /dev/null +++ b/gitee/ids.go @@ -0,0 +1,29 @@ +package gitee + +import ( + "fmt" + "strings" + + "github.com/tamnd/any-cli/kit/errs" +) + +type RepoSlug struct { + Owner string + Repo string +} + +func ParseRepoSlug(s string) (RepoSlug, error) { + // Strip common URL prefixes + s = strings.TrimPrefix(s, "https://gitee.com/") + s = strings.TrimPrefix(s, "http://gitee.com/") + s = strings.TrimSuffix(s, "/") + s = strings.TrimSpace(s) + + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return RepoSlug{}, errs.Usage("invalid repo %q: expected owner/repo", s) + } + return RepoSlug{Owner: parts[0], Repo: parts[1]}, nil +} + +func (r RepoSlug) String() string { return fmt.Sprintf("%s/%s", r.Owner, r.Repo) } diff --git a/gitee/ids_test.go b/gitee/ids_test.go new file mode 100644 index 0000000..d6b43bc --- /dev/null +++ b/gitee/ids_test.go @@ -0,0 +1,50 @@ +package gitee_test + +import ( + "testing" + + "github.com/tamnd/gitee-cli/gitee" +) + +func TestParseRepoSlug(t *testing.T) { + tests := []struct { + input string + wantOwner string + wantRepo string + wantErr bool + }{ + {"owner/repo", "owner", "repo", false}, + {"foo/bar-baz", "foo", "bar-baz", false}, + {"https://gitee.com/owner/repo", "owner", "repo", false}, + {"http://gitee.com/owner/repo", "owner", "repo", false}, + {"https://gitee.com/owner/repo/", "owner", "repo", false}, + // errors + {"", "", "", true}, + {"noslash", "", "", true}, + {"/repo", "", "", true}, + {"owner/", "", "", true}, + } + for _, tc := range tests { + slug, err := gitee.ParseRepoSlug(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("ParseRepoSlug(%q): want error, got %+v", tc.input, slug) + } + continue + } + if err != nil { + t.Errorf("ParseRepoSlug(%q): unexpected error: %v", tc.input, err) + continue + } + if slug.Owner != tc.wantOwner || slug.Repo != tc.wantRepo { + t.Errorf("ParseRepoSlug(%q) = {%q,%q}, want {%q,%q}", tc.input, slug.Owner, slug.Repo, tc.wantOwner, tc.wantRepo) + } + } +} + +func TestRepoSlugString(t *testing.T) { + s := gitee.RepoSlug{Owner: "foo", Repo: "bar"} + if got := s.String(); got != "foo/bar" { + t.Errorf("String() = %q, want %q", got, "foo/bar") + } +} diff --git a/gitee/ops.go b/gitee/ops.go new file mode 100644 index 0000000..30a5e6d --- /dev/null +++ b/gitee/ops.go @@ -0,0 +1,741 @@ +package gitee + +import ( + "context" + + "github.com/tamnd/any-cli/kit" +) + +func registerOps(app *kit.App) { + registerUser(app) + registerUserRepos(app) + registerFollowers(app) + registerFollowing(app) + registerRepo(app) + registerCommits(app) + registerBranches(app) + registerTags(app) + registerReleases(app) + registerIssues(app) + registerPulls(app) + registerReadme(app) + registerTree(app) + registerStargazers(app) + registerForks(app) + registerContributors(app) + registerSearchRepos(app) + registerSearchUsers(app) + registerOrg(app) + registerOrgRepos(app) +} + +// user ----------------------------------------------------------------------- + +type userIn struct { + Session *Session `kit:"inject"` + Username string `kit:"arg"` + Quiet bool `kit:"flag,inherit"` +} + +func registerUser(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "user", + Group: "read", + Single: true, + Summary: "Fetch a Gitee user profile", + Args: []kit.Arg{{Name: "username", Help: "Gitee login handle"}}, + }, func(ctx context.Context, in userIn, emit func(User) error) error { + in.Session.Quiet = in.Quiet + in.Session.Progressf("fetching user %s", in.Username) + u, err := in.Session.Client.GetUser(ctx, in.Username) + if err != nil { + return MapErr(err) + } + return emit(u) + }) +} + +// user repos ----------------------------------------------------------------- + +type userReposIn struct { + Session *Session `kit:"inject"` + Username string `kit:"arg"` + Sort string `kit:"flag"` + Direction string `kit:"flag"` + Type string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerUserRepos(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "repos", + Parent: "user", + Group: "read", + List: true, + Summary: "List a user's public repositories", + Args: []kit.Arg{{Name: "username", Help: "Gitee login handle"}}, + }, func(ctx context.Context, in userReposIn, emit func(Repo) error) error { + in.Session.Quiet = in.Quiet + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching repos for %s (limit %d)", in.Username, limit) + repos, err := in.Session.Client.UserRepos(ctx, in.Username, in.Sort, in.Direction, in.Type, limit) + if err != nil { + return MapErr(err) + } + return emitAll(repos, emit) + }) +} + +// followers ------------------------------------------------------------------ + +type followersIn struct { + Session *Session `kit:"inject"` + Username string `kit:"arg"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerFollowers(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "followers", + Group: "read", + List: true, + Summary: "List a user's followers", + Args: []kit.Arg{{Name: "username", Help: "Gitee login handle"}}, + }, func(ctx context.Context, in followersIn, emit func(User) error) error { + in.Session.Quiet = in.Quiet + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching followers for %s", in.Username) + users, err := in.Session.Client.Followers(ctx, in.Username, limit) + if err != nil { + return MapErr(err) + } + return emitAll(users, emit) + }) +} + +// following ------------------------------------------------------------------ + +type followingIn struct { + Session *Session `kit:"inject"` + Username string `kit:"arg"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerFollowing(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "following", + Group: "read", + List: true, + Summary: "List users that a user follows", + Args: []kit.Arg{{Name: "username", Help: "Gitee login handle"}}, + }, func(ctx context.Context, in followingIn, emit func(User) error) error { + in.Session.Quiet = in.Quiet + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching following for %s", in.Username) + users, err := in.Session.Client.Following(ctx, in.Username, limit) + if err != nil { + return MapErr(err) + } + return emitAll(users, emit) + }) +} + +// repo ----------------------------------------------------------------------- + +type repoIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Quiet bool `kit:"flag,inherit"` +} + +func registerRepo(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "repo", + Group: "read", + Single: true, + Summary: "Fetch a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug or gitee.com URL"}}, + }, func(ctx context.Context, in repoIn, emit func(Repo) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + in.Session.Progressf("fetching %s", slug) + r, err := in.Session.Client.GetRepo(ctx, slug.Owner, slug.Repo) + if err != nil { + return MapErr(err) + } + return emit(r) + }) +} + +// commits -------------------------------------------------------------------- + +type commitsIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + SHA string `kit:"flag"` + Path string `kit:"flag"` + Author string `kit:"flag"` + Since string `kit:"flag"` + Until string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerCommits(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "commits", + Group: "read", + List: true, + Summary: "List commits for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in commitsIn, emit func(Commit) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching commits for %s", slug) + commits, err := in.Session.Client.Commits(ctx, slug.Owner, slug.Repo, in.SHA, in.Path, in.Author, in.Since, in.Until, limit) + if err != nil { + return MapErr(err) + } + return emitAll(commits, emit) + }) +} + +// branches ------------------------------------------------------------------- + +type branchesIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerBranches(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "branches", + Group: "read", + List: true, + Summary: "List branches for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in branchesIn, emit func(Branch) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching branches for %s", slug) + branches, err := in.Session.Client.Branches(ctx, slug.Owner, slug.Repo, limit) + if err != nil { + return MapErr(err) + } + return emitAll(branches, emit) + }) +} + +// tags ----------------------------------------------------------------------- + +type tagsIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Sort string `kit:"flag"` + Direction string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerTags(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "tags", + Group: "read", + List: true, + Summary: "List tags for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in tagsIn, emit func(Tag) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching tags for %s", slug) + tags, err := in.Session.Client.Tags(ctx, slug.Owner, slug.Repo, in.Sort, in.Direction, limit) + if err != nil { + return MapErr(err) + } + return emitAll(tags, emit) + }) +} + +// releases ------------------------------------------------------------------- + +type releasesIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerReleases(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "releases", + Group: "read", + List: true, + Summary: "List releases for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in releasesIn, emit func(Release) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 10 + } + in.Session.Progressf("fetching releases for %s", slug) + releases, err := in.Session.Client.Releases(ctx, slug.Owner, slug.Repo, limit) + if err != nil { + return MapErr(err) + } + return emitAll(releases, emit) + }) +} + +// issues --------------------------------------------------------------------- + +type issuesIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + State string `kit:"flag"` + Sort string `kit:"flag"` + Direction string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerIssues(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "issues", + Group: "read", + List: true, + Summary: "List issues for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in issuesIn, emit func(Issue) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + state := in.State + if state == "" { + state = "open" + } + sort := in.Sort + if sort == "" { + sort = "created" + } + direction := in.Direction + if direction == "" { + direction = "desc" + } + in.Session.Progressf("fetching issues for %s", slug) + issues, err := in.Session.Client.Issues(ctx, slug.Owner, slug.Repo, state, sort, direction, limit) + if err != nil { + return MapErr(err) + } + return emitAll(issues, emit) + }) +} + +// pulls ---------------------------------------------------------------------- + +type pullsIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + State string `kit:"flag"` + Sort string `kit:"flag"` + Direction string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerPulls(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "pulls", + Group: "read", + List: true, + Summary: "List pull requests for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in pullsIn, emit func(PullRequest) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + state := in.State + if state == "" { + state = "open" + } + sort := in.Sort + if sort == "" { + sort = "created" + } + direction := in.Direction + if direction == "" { + direction = "desc" + } + in.Session.Progressf("fetching pull requests for %s", slug) + pulls, err := in.Session.Client.Pulls(ctx, slug.Owner, slug.Repo, state, sort, direction, limit) + if err != nil { + return MapErr(err) + } + return emitAll(pulls, emit) + }) +} + +// readme --------------------------------------------------------------------- + +type readmeIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Ref string `kit:"flag"` + Quiet bool `kit:"flag,inherit"` +} + +func registerReadme(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "readme", + Group: "read", + Single: true, + Summary: "Fetch the README for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in readmeIn, emit func(ReadmeFile) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + in.Session.Progressf("fetching README for %s", slug) + readme, err := in.Session.Client.Readme(ctx, slug.Owner, slug.Repo, in.Ref) + if err != nil { + return MapErr(err) + } + return emit(readme) + }) +} + +// tree ----------------------------------------------------------------------- + +type treeIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Ref string `kit:"flag"` + Recursive bool `kit:"flag"` + Quiet bool `kit:"flag,inherit"` +} + +func registerTree(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "tree", + Group: "read", + List: true, + Summary: "List the git tree for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in treeIn, emit func(TreeEntry) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + ref := in.Ref + if ref == "" { + ref = "HEAD" + } + in.Session.Progressf("fetching tree for %s @ %s", slug, ref) + entries, err := in.Session.Client.Tree(ctx, slug.Owner, slug.Repo, ref, in.Recursive) + if err != nil { + return MapErr(err) + } + return emitAll(entries, emit) + }) +} + +// stargazers ----------------------------------------------------------------- + +type stargazersIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerStargazers(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "stargazers", + Group: "read", + List: true, + Summary: "List users who starred a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in stargazersIn, emit func(User) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching stargazers for %s", slug) + users, err := in.Session.Client.Stargazers(ctx, slug.Owner, slug.Repo, limit) + if err != nil { + return MapErr(err) + } + return emitAll(users, emit) + }) +} + +// forks ---------------------------------------------------------------------- + +type forksIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Sort string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerForks(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "forks", + Group: "read", + List: true, + Summary: "List forks of a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in forksIn, emit func(Repo) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching forks for %s", slug) + repos, err := in.Session.Client.Forks(ctx, slug.Owner, slug.Repo, in.Sort, limit) + if err != nil { + return MapErr(err) + } + return emitAll(repos, emit) + }) +} + +// contributors --------------------------------------------------------------- + +type contributorsIn struct { + Session *Session `kit:"inject"` + Slug string `kit:"arg"` + Type string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerContributors(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "contributors", + Group: "read", + List: true, + Summary: "List contributors to a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in contributorsIn, emit func(Contributor) error) error { + in.Session.Quiet = in.Quiet + slug, err := ParseRepoSlug(in.Slug) + if err != nil { + return err + } + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching contributors for %s", slug) + contributors, err := in.Session.Client.Contributors(ctx, slug.Owner, slug.Repo, in.Type, limit) + if err != nil { + return MapErr(err) + } + return emitAll(contributors, emit) + }) +} + +// search repos --------------------------------------------------------------- + +type searchReposIn struct { + Session *Session `kit:"inject"` + Query string `kit:"arg"` + Sort string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerSearchRepos(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "repos", + Parent: "search", + Group: "read", + List: true, + Summary: "Search Gitee repositories", + Args: []kit.Arg{{Name: "query", Help: "search keywords"}}, + }, func(ctx context.Context, in searchReposIn, emit func(Repo) error) error { + in.Session.Quiet = in.Quiet + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("searching repos for %q", in.Query) + repos, err := in.Session.Client.SearchRepos(ctx, in.Query, in.Sort, limit) + if err != nil { + return MapErr(err) + } + return emitAll(repos, emit) + }) +} + +// search users --------------------------------------------------------------- + +type searchUsersIn struct { + Session *Session `kit:"inject"` + Query string `kit:"arg"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerSearchUsers(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "users", + Parent: "search", + Group: "read", + List: true, + Summary: "Search Gitee users", + Args: []kit.Arg{{Name: "query", Help: "search keywords"}}, + }, func(ctx context.Context, in searchUsersIn, emit func(User) error) error { + in.Session.Quiet = in.Quiet + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("searching users for %q", in.Query) + users, err := in.Session.Client.SearchUsers(ctx, in.Query, limit) + if err != nil { + return MapErr(err) + } + return emitAll(users, emit) + }) +} + +// org ------------------------------------------------------------------------ + +type orgIn struct { + Session *Session `kit:"inject"` + Name string `kit:"arg"` + Quiet bool `kit:"flag,inherit"` +} + +func registerOrg(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "org", + Group: "read", + Single: true, + Summary: "Fetch a Gitee organization profile", + Args: []kit.Arg{{Name: "name", Help: "organization login name"}}, + }, func(ctx context.Context, in orgIn, emit func(OrgProfile) error) error { + in.Session.Quiet = in.Quiet + in.Session.Progressf("fetching org %s", in.Name) + org, err := in.Session.Client.GetOrg(ctx, in.Name) + if err != nil { + return MapErr(err) + } + return emit(org) + }) +} + +// org repos ------------------------------------------------------------------ + +type orgReposIn struct { + Session *Session `kit:"inject"` + Name string `kit:"arg"` + Type string `kit:"flag"` + Sort string `kit:"flag"` + Direction string `kit:"flag"` + Limit int `kit:"flag,inherit"` + Quiet bool `kit:"flag,inherit"` +} + +func registerOrgRepos(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "repos", + Parent: "org", + Group: "read", + List: true, + Summary: "List repositories for an organization", + Args: []kit.Arg{{Name: "name", Help: "organization login name"}}, + }, func(ctx context.Context, in orgReposIn, emit func(Repo) error) error { + in.Session.Quiet = in.Quiet + limit := in.Limit + if limit <= 0 { + limit = 20 + } + in.Session.Progressf("fetching repos for org %s", in.Name) + repos, err := in.Session.Client.OrgRepos(ctx, in.Name, in.Type, in.Sort, in.Direction, limit) + if err != nil { + return MapErr(err) + } + return emitAll(repos, emit) + }) +} + +// helpers -------------------------------------------------------------------- + +func emitAll[T any](items []T, emit func(T) error) error { + for _, item := range items { + if err := emit(item); err != nil { + return err + } + } + return nil +} diff --git a/gitee/types.go b/gitee/types.go index 1eb5d4c..4f0826c 100644 --- a/gitee/types.go +++ b/gitee/types.go @@ -1,164 +1,195 @@ package gitee -import ( - "encoding/json" - "fmt" - "strings" +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") + ErrRateLimit = errors.New("rate limit exceeded") ) -// ─── Exported record types ──────────────────────────────────────────────────── +type User struct { + ID int `json:"id" table:"ID"` + Login string `json:"login" table:"Login"` + Name string `json:"name" table:"Name"` + AvatarURL string `json:"avatar_url" table:"-"` + URL string `json:"url" table:"-"` + HTMLURL string `json:"html_url" table:"URL" kit:"url"` + Bio string `json:"bio" table:"-"` + Blog string `json:"blog" table:"Blog"` + Weibo string `json:"weibo" table:"-"` + Company string `json:"company" table:"Company"` + Profession string `json:"profession" table:"-"` + Email string `json:"email" table:"Email"` + PublicRepos int `json:"public_repos" table:"Repos"` + Followers int `json:"followers" table:"Followers"` + Following int `json:"following" table:"Following"` + Stared int `json:"stared" table:"Starred"` // API typo — frozen + CreatedAt string `json:"created_at" table:"Created"` + UpdatedAt string `json:"updated_at" table:"-"` +} -// Repo is the canonical output record for any repository surface. type Repo struct { - Rank int `json:"rank"` - FullName string `json:"full_name"` - Description string `json:"description"` - Language string `json:"language"` - Stars int `json:"stars"` - Forks int `json:"forks"` - UpdatedAt string `json:"updated_at"` - URL string `json:"url"` + ID int `json:"id" table:"ID"` + FullName string `json:"full_name" table:"Full Name"` + Name string `json:"name" table:"Name"` + Owner *User `json:"owner" table:"-"` + Namespace *Namespace `json:"namespace" table:"-"` + Description string `json:"description" table:"Description"` + Private bool `json:"private" table:"-"` + Fork bool `json:"fork" table:"Fork"` + URL string `json:"url" table:"URL" kit:"url"` // html_url .git stripped + SSHURL string `json:"ssh_url" table:"-"` + Recommend bool `json:"recommend" table:"-"` + GVP bool `json:"gvp" table:"GVP"` + Language string `json:"language" table:"Lang"` + ForksCount int `json:"forks_count" table:"Forks"` + StargazersCount int `json:"stargazers_count" table:"Stars"` + DefaultBranch string `json:"default_branch" table:"Branch"` + OpenIssuesCount int `json:"open_issues_count" table:"Issues"` + License *License `json:"license" table:"-"` + PushedAt string `json:"pushed_at" table:"Pushed"` + CreatedAt string `json:"created_at" table:"Created"` + UpdatedAt string `json:"updated_at" table:"-"` + Parent *Repo `json:"parent" table:"-"` } -// User is the output record for a Gitee user profile. -type User struct { - Login string `json:"login"` - Name string `json:"name"` - Followers int `json:"followers"` - Following int `json:"following"` - Repos int `json:"repos"` - Blog string `json:"blog"` - URL string `json:"url"` +type Namespace struct { + ID int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + HTMLURL string `json:"html_url"` +} + +type License struct { + Key string `json:"key"` + Name string `json:"name"` + SPDXID string `json:"spdx_id"` +} + +type Commit struct { + SHA string `json:"sha" table:"SHA"` + Author CommitAuthor `json:"author" table:"-"` + Message string `json:"message" table:"Message"` + URL string `json:"url" table:"URL" kit:"url"` +} + +type CommitAuthor struct { + Name string `json:"name"` + Email string `json:"email"` + Date string `json:"date"` +} + +type Branch struct { + Name string `json:"name" table:"Name"` + SHA string `json:"sha" table:"SHA"` + Protected bool `json:"protected" table:"Protected"` +} + +type Tag struct { + Name string `json:"name" table:"Name"` + SHA string `json:"sha" table:"SHA"` + Message string `json:"message" table:"Message"` + URL string `json:"url" table:"URL" kit:"url"` } -// Release is the output record for a repository release. type Release struct { - Rank int `json:"rank"` - TagName string `json:"tag_name"` - Name string `json:"name"` - Prerelease bool `json:"prerelease"` - CreatedAt string `json:"created_at"` - URL string `json:"url"` -} - -// ─── Wire types (internal) ──────────────────────────────────────────────────── - -type wireRepo struct { - ID int `json:"id"` - FullName string `json:"full_name"` - HumanName string `json:"human_name"` - Name string `json:"name"` - Path string `json:"path"` - Description string `json:"description"` - Private bool `json:"private"` - Fork bool `json:"fork"` - HTMLURL string `json:"html_url"` - ForksCount int `json:"forks_count"` - StargazersCount int `json:"stargazers_count"` - WatchersCount int `json:"watchers_count"` - OpenIssuesCount int `json:"open_issues_count"` - DefaultBranch string `json:"default_branch"` - Language string `json:"language"` - License *wireLicense `json:"license"` - PushedAt string `json:"pushed_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Owner wireOwner `json:"owner"` -} - -// wireLicense can appear as either an object {"spdx_id":"MIT"} or a bare -// string "MIT" depending on which Gitee endpoint returns the repo. -type wireLicense struct { - SPDXID string -} - -func (l *wireLicense) UnmarshalJSON(b []byte) error { - if len(b) == 0 || string(b) == "null" { - return nil - } - // bare string form - if b[0] == '"' { - return json.Unmarshal(b, &l.SPDXID) - } - // object form - var obj struct { - SPDXID string `json:"spdx_id"` - } - if err := json.Unmarshal(b, &obj); err != nil { - return err - } - l.SPDXID = obj.SPDXID - return nil -} - -type wireOwner struct { - Login string `json:"login"` + ID int `json:"id" table:"ID"` + TagName string `json:"tag_name" table:"Tag"` + Name string `json:"name" table:"Name"` + Body string `json:"body" table:"-"` + Prerelease bool `json:"prerelease" table:"Pre"` + Assets []ReleaseAsset `json:"assets" table:"-"` + AssetsCount int `json:"assets_count" table:"Assets"` + Author *User `json:"author" table:"-"` + TargetCommitish string `json:"target_commitish" table:"Branch"` + CreatedAt string `json:"created_at" table:"Created"` +} + +type ReleaseAsset struct { + ID int `json:"id" table:"ID"` + Name string `json:"name" table:"Name"` + Size int `json:"size" table:"Size"` + DownloadCount int `json:"download_count" table:"Downloads"` + BrowserDownloadURL string `json:"browser_download_url" table:"URL" kit:"url"` +} + +// Issue.Number is STRING like "IJUFI0" — Gitee API contract, NOT integer. +type Issue struct { + Number string `json:"number" table:"Number"` + Title string `json:"title" table:"Title"` + State string `json:"state" table:"State"` + Body string `json:"body" table:"-"` + User *User `json:"user" table:"-"` + Assignee *User `json:"assignee" table:"-"` + Labels []Label `json:"labels" table:"-"` + Comments int `json:"comments" table:"Comments"` + URL string `json:"html_url" table:"URL" kit:"url"` + CreatedAt string `json:"created_at" table:"Created"` + UpdatedAt string `json:"updated_at" table:"-"` +} + +type Label struct { + ID int `json:"id"` Name string `json:"name"` + Color string `json:"color"` +} + +type PullRequest struct { + Number int `json:"number" table:"Number"` + Title string `json:"title" table:"Title"` + State string `json:"state" table:"State"` + Body string `json:"body" table:"-"` + Head PRRef `json:"head" table:"-"` + Base PRRef `json:"base" table:"-"` + User *User `json:"user" table:"-"` + Labels []Label `json:"labels" table:"-"` + Merged bool `json:"merged" table:"Merged"` + Comments int `json:"comments" table:"Comments"` + URL string `json:"html_url" table:"URL" kit:"url"` + CreatedAt string `json:"created_at" table:"Created"` + UpdatedAt string `json:"updated_at" table:"-"` +} + +type PRRef struct { + Label string `json:"label"` + Ref string `json:"ref"` + SHA string `json:"sha"` +} + +type Contributor struct { + Name string `json:"name" table:"Name"` + Email string `json:"email" table:"Email"` + Contributions int `json:"contributions" table:"Commits"` +} + +type TreeEntry struct { + Path string `json:"path" table:"Path"` + Mode string `json:"mode" table:"Mode"` + Type string `json:"type" table:"Type"` + SHA string `json:"sha" table:"SHA"` + Size int `json:"size" table:"Size"` +} + +type ReadmeFile struct { + Name string `json:"name" table:"Name"` + SHA string `json:"sha" table:"SHA"` + Size int `json:"size" table:"Size"` + DecodedContent string `json:"decoded_content" table:"-"` + HTMLURL string `json:"html_url" table:"URL" kit:"url"` + DownloadURL string `json:"download_url" table:"-"` } -type wireUser struct { - Login string `json:"login"` - Name string `json:"name"` - AvatarURL string `json:"avatar_url"` - Bio string `json:"bio"` - Blog string `json:"blog"` - Followers int `json:"followers"` - Following int `json:"following"` - PublicRepos int `json:"public_repos"` - HTMLURL string `json:"html_url"` - CreatedAt string `json:"created_at"` -} - -type wireRelease struct { - ID int `json:"id"` - TagName string `json:"tag_name"` - Name string `json:"name"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` - Body string `json:"body"` - CreatedAt string `json:"created_at"` - Author wireOwner `json:"author"` -} - -// ─── Conversion helpers ─────────────────────────────────────────────────────── - -func wireRepoToRepo(wr wireRepo, rank int) Repo { - // The API html_url includes a trailing .git; strip it for the web URL. - webURL := strings.TrimSuffix(wr.HTMLURL, ".git") - if webURL == "" && wr.FullName != "" { - webURL = "https://gitee.com/" + wr.FullName - } - return Repo{ - Rank: rank, - FullName: wr.FullName, - Description: wr.Description, - Language: wr.Language, - Stars: wr.StargazersCount, - Forks: wr.ForksCount, - UpdatedAt: wr.UpdatedAt, - URL: webURL, - } -} - -func wireUserToUser(wu wireUser) User { - return User{ - Login: wu.Login, - Name: wu.Name, - Followers: wu.Followers, - Following: wu.Following, - Repos: wu.PublicRepos, - Blog: wu.Blog, - URL: fmt.Sprintf("https://gitee.com/%s", wu.Login), - } -} - -func wireReleaseToRelease(wr wireRelease, owner, repo string, rank int) Release { - return Release{ - Rank: rank, - TagName: wr.TagName, - Name: wr.Name, - Prerelease: wr.Prerelease, - CreatedAt: wr.CreatedAt, - URL: fmt.Sprintf("https://gitee.com/%s/%s/releases/tag/%s", owner, repo, wr.TagName), - } +type OrgProfile struct { + ID int `json:"id" table:"ID"` + Login string `json:"login" table:"Login"` + Name string `json:"name" table:"Name"` + HTMLURL string `json:"html_url" table:"URL" kit:"url"` + Description string `json:"description" table:"Description"` + Blog string `json:"blog" table:"Blog"` + Email string `json:"email" table:"Email"` + PublicRepos int `json:"public_repos" table:"Repos"` + Followers int `json:"followers" table:"Followers"` + CreatedAt string `json:"created_at" table:"Created"` } diff --git a/gitee/wire.go b/gitee/wire.go new file mode 100644 index 0000000..10b441f --- /dev/null +++ b/gitee/wire.go @@ -0,0 +1,488 @@ +package gitee + +import ( + "encoding/base64" + "encoding/json" + "strings" +) + +// ─── wire types ────────────────────────────────────────────────────────────── + +type wireUser struct { + ID int `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + Bio string `json:"bio"` + Blog string `json:"blog"` + Weibo string `json:"weibo"` + Company string `json:"company"` + Profession string `json:"profession"` + Email string `json:"email"` + PublicRepos int `json:"public_repos"` + Followers int `json:"followers"` + Following int `json:"following"` + Stared int `json:"stared"` // API typo — not "starred" + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (w wireUser) toPublic() User { + return User{ + ID: w.ID, + Login: w.Login, + Name: w.Name, + AvatarURL: w.AvatarURL, + URL: w.URL, + HTMLURL: w.HTMLURL, + Bio: w.Bio, + Blog: w.Blog, + Weibo: w.Weibo, + Company: w.Company, + Profession: w.Profession, + Email: w.Email, + PublicRepos: w.PublicRepos, + Followers: w.Followers, + Following: w.Following, + Stared: w.Stared, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + } +} + +type wireNamespace struct { + ID int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + HTMLURL string `json:"html_url"` +} + +// wireLicense can appear as either an object {"spdx_id":"MIT"} or a bare +// string "MIT" depending on which Gitee endpoint returns the repo. +type wireLicense struct { + Key string + Name string + SPDXID string +} + +func (l *wireLicense) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" { + return nil + } + if b[0] == '"' { + return json.Unmarshal(b, &l.SPDXID) + } + var obj struct { + Key string `json:"key"` + Name string `json:"name"` + SPDXID string `json:"spdx_id"` + } + if err := json.Unmarshal(b, &obj); err != nil { + return err + } + l.Key = obj.Key + l.Name = obj.Name + l.SPDXID = obj.SPDXID + return nil +} + +type wireRepo struct { + ID int `json:"id"` + FullName string `json:"full_name"` + Name string `json:"name"` + Owner *wireUser `json:"owner"` + Namespace *wireNamespace `json:"namespace"` + Description string `json:"description"` + Private bool `json:"private"` + Fork bool `json:"fork"` + HTMLURL string `json:"html_url"` + SSHURL string `json:"ssh_url"` + Recommend bool `json:"recommend"` + GVP bool `json:"gvp"` + Language string `json:"language"` + ForksCount int `json:"forks_count"` + StargazersCount int `json:"stargazers_count"` + DefaultBranch string `json:"default_branch"` + OpenIssuesCount int `json:"open_issues_count"` + License *wireLicense `json:"license"` + PushedAt string `json:"pushed_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Parent *wireRepo `json:"parent"` +} + +func (w wireRepo) toPublic() Repo { + webURL := strings.TrimSuffix(w.HTMLURL, ".git") + if webURL == "" && w.FullName != "" { + webURL = "https://gitee.com/" + w.FullName + } + + var owner *User + if w.Owner != nil { + u := w.Owner.toPublic() + owner = &u + } + + var ns *Namespace + if w.Namespace != nil { + n := Namespace{ + ID: w.Namespace.ID, + Type: w.Namespace.Type, + Name: w.Namespace.Name, + Path: w.Namespace.Path, + HTMLURL: w.Namespace.HTMLURL, + } + ns = &n + } + + var lic *License + if w.License != nil { + l := License{ + Key: w.License.Key, + Name: w.License.Name, + SPDXID: w.License.SPDXID, + } + lic = &l + } + + var parent *Repo + if w.Parent != nil { + p := w.Parent.toPublic() + parent = &p + } + + return Repo{ + ID: w.ID, + FullName: w.FullName, + Name: w.Name, + Owner: owner, + Namespace: ns, + Description: w.Description, + Private: w.Private, + Fork: w.Fork, + URL: webURL, + SSHURL: w.SSHURL, + Recommend: w.Recommend, + GVP: w.GVP, + Language: w.Language, + ForksCount: w.ForksCount, + StargazersCount: w.StargazersCount, + DefaultBranch: w.DefaultBranch, + OpenIssuesCount: w.OpenIssuesCount, + License: lic, + PushedAt: w.PushedAt, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + Parent: parent, + } +} + +type wireCommitInner struct { + Author CommitAuthor `json:"author"` + Message string `json:"message"` +} + +type wireCommit struct { + SHA string `json:"sha"` + Commit wireCommitInner `json:"commit"` + URL string `json:"html_url"` +} + +func (w wireCommit) toPublic() Commit { + return Commit{ + SHA: w.SHA, + Author: w.Commit.Author, + Message: w.Commit.Message, + URL: w.URL, + } +} + +type wireBranchCommit struct { + SHA string `json:"sha"` +} + +type wireBranch struct { + Name string `json:"name"` + Commit wireBranchCommit `json:"commit"` + Protected bool `json:"protected"` +} + +func (w wireBranch) toPublic() Branch { + return Branch{ + Name: w.Name, + SHA: w.Commit.SHA, + Protected: w.Protected, + } +} + +type wireTagCommit struct { + SHA string `json:"sha"` + URL string `json:"url"` +} + +type wireTag struct { + Name string `json:"name"` + Message string `json:"message"` + Commit wireTagCommit `json:"commit"` +} + +func (w wireTag) toPublic() Tag { + return Tag{ + Name: w.Name, + SHA: w.Commit.SHA, + Message: w.Message, + URL: w.Commit.URL, + } +} + +type wireReleaseAsset struct { + ID int `json:"id"` + Name string `json:"name"` + Size int `json:"size"` + DownloadCount int `json:"download_count"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type wireRelease struct { + ID int `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Prerelease bool `json:"prerelease"` + Assets []wireReleaseAsset `json:"assets"` + AssetsCount int `json:"assets_count"` + Author *wireUser `json:"author"` + TargetCommitish string `json:"target_commitish"` + CreatedAt string `json:"created_at"` +} + +func (w wireRelease) toPublic() Release { + var author *User + if w.Author != nil { + u := w.Author.toPublic() + author = &u + } + assets := make([]ReleaseAsset, len(w.Assets)) + for i, a := range w.Assets { + assets[i] = ReleaseAsset{ + ID: a.ID, + Name: a.Name, + Size: a.Size, + DownloadCount: a.DownloadCount, + BrowserDownloadURL: a.BrowserDownloadURL, + } + } + return Release{ + ID: w.ID, + TagName: w.TagName, + Name: w.Name, + Body: w.Body, + Prerelease: w.Prerelease, + Assets: assets, + AssetsCount: w.AssetsCount, + Author: author, + TargetCommitish: w.TargetCommitish, + CreatedAt: w.CreatedAt, + } +} + +type wireLabel struct { + ID int `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +type wireIssue struct { + Number string `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + User *wireUser `json:"user"` + Assignee *wireUser `json:"assignee"` + Labels []wireLabel `json:"labels"` + Comments int `json:"comments"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (w wireIssue) toPublic() Issue { + var user *User + if w.User != nil { + u := w.User.toPublic() + user = &u + } + var assignee *User + if w.Assignee != nil { + a := w.Assignee.toPublic() + assignee = &a + } + labels := make([]Label, len(w.Labels)) + for i, l := range w.Labels { + labels[i] = Label{ID: l.ID, Name: l.Name, Color: l.Color} + } + return Issue{ + Number: w.Number, + Title: w.Title, + State: w.State, + Body: w.Body, + User: user, + Assignee: assignee, + Labels: labels, + Comments: w.Comments, + URL: w.HTMLURL, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + } +} + +type wirePRRef struct { + Label string `json:"label"` + Ref string `json:"ref"` + SHA string `json:"sha"` +} + +type wirePullRequest struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + Head wirePRRef `json:"head"` + Base wirePRRef `json:"base"` + User *wireUser `json:"user"` + Labels []wireLabel `json:"labels"` + Merged bool `json:"merged"` + Comments int `json:"comments"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (w wirePullRequest) toPublic() PullRequest { + var user *User + if w.User != nil { + u := w.User.toPublic() + user = &u + } + labels := make([]Label, len(w.Labels)) + for i, l := range w.Labels { + labels[i] = Label{ID: l.ID, Name: l.Name, Color: l.Color} + } + return PullRequest{ + Number: w.Number, + Title: w.Title, + State: w.State, + Body: w.Body, + Head: PRRef{Label: w.Head.Label, Ref: w.Head.Ref, SHA: w.Head.SHA}, + Base: PRRef{Label: w.Base.Label, Ref: w.Base.Ref, SHA: w.Base.SHA}, + User: user, + Labels: labels, + Merged: w.Merged, + Comments: w.Comments, + URL: w.HTMLURL, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + } +} + +type wireContributor struct { + Name string `json:"name"` + Email string `json:"email"` + Contributions int `json:"contributions"` +} + +func (w wireContributor) toPublic() Contributor { + return Contributor{ + Name: w.Name, + Email: w.Email, + Contributions: w.Contributions, + } +} + +type wireTreeEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Type string `json:"type"` + SHA string `json:"sha"` + Size int `json:"size"` +} + +type wireTree struct { + SHA string `json:"sha"` + URL string `json:"url"` + Tree []wireTreeEntry `json:"tree"` + Truncated bool `json:"truncated"` +} + +func (w wireTree) toPublic() []TreeEntry { + out := make([]TreeEntry, len(w.Tree)) + for i, e := range w.Tree { + out[i] = TreeEntry{ + Path: e.Path, + Mode: e.Mode, + Type: e.Type, + SHA: e.SHA, + Size: e.Size, + } + } + return out +} + +type wireReadme struct { + Name string `json:"name"` + SHA string `json:"sha"` + Size int `json:"size"` + Encoding string `json:"encoding"` + Content string `json:"content"` + HTMLURL string `json:"html_url"` + DownloadURL string `json:"download_url"` +} + +func (w wireReadme) toPublic() ReadmeFile { + decoded := "" + if w.Content != "" { + clean := strings.ReplaceAll(w.Content, "\n", "") + if b, err := base64.StdEncoding.DecodeString(clean); err == nil { + decoded = string(b) + } + } + return ReadmeFile{ + Name: w.Name, + SHA: w.SHA, + Size: w.Size, + DecodedContent: decoded, + HTMLURL: w.HTMLURL, + DownloadURL: w.DownloadURL, + } +} + +type wireOrg struct { + ID int `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Description string `json:"description"` + Blog string `json:"blog"` + Email string `json:"email"` + PublicRepos int `json:"public_repos"` + Followers int `json:"followers"` + CreatedAt string `json:"created_at"` +} + +func (w wireOrg) toPublic() OrgProfile { + return OrgProfile{ + ID: w.ID, + Login: w.Login, + Name: w.Name, + HTMLURL: w.HTMLURL, + Description: w.Description, + Blog: w.Blog, + Email: w.Email, + PublicRepos: w.PublicRepos, + Followers: w.Followers, + CreatedAt: w.CreatedAt, + } +} diff --git a/go.mod b/go.mod index 07c6b03..6d07c4e 100644 --- a/go.mod +++ b/go.mod @@ -2,15 +2,12 @@ module github.com/tamnd/gitee-cli go 1.26 -require ( - github.com/charmbracelet/fang v1.0.0 - github.com/mattn/go-isatty v0.0.22 - github.com/spf13/cobra v1.10.2 -) +require github.com/tamnd/any-cli v0.4.0 require ( charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/fang v1.0.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect github.com/charmbracelet/x/ansi v0.11.0 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect @@ -20,18 +17,28 @@ require ( github.com/clipperhouse/displaywidth v0.4.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.24.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect ) diff --git a/go.sum b/go.sum index e7d4564..ff75dd4 100644 --- a/go.sum +++ b/go.sum @@ -29,12 +29,20 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -47,8 +55,12 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -58,17 +70,52 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tamnd/any-cli v0.4.0 h1:ngyRJBvjZ2X1iBlwlmDLvY2S9aQWlDjVE7CiOwxtt5Y= +github.com/tamnd/any-cli v0.4.0/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/pkg/render/render.go b/pkg/render/render.go deleted file mode 100644 index 34393b6..0000000 --- a/pkg/render/render.go +++ /dev/null @@ -1,350 +0,0 @@ -// Package render turns slices of record structs into one of the output formats -// hackernews-cli supports: table, json, jsonl, csv, tsv, url, and raw. It works -// off struct reflection and json tags, so any record type renders without -// per-type code. -package render - -import ( - "encoding/csv" - "encoding/json" - "fmt" - "io" - "reflect" - "strconv" - "strings" - "text/tabwriter" - "text/template" - "time" -) - -// Format is an output rendering format. -type Format string - -const ( - FormatTable Format = "table" - FormatJSON Format = "json" - FormatJSONL Format = "jsonl" - FormatCSV Format = "csv" - FormatTSV Format = "tsv" - FormatURL Format = "url" - FormatRaw Format = "raw" -) - -// Valid reports whether f is one of the supported formats. -func (f Format) Valid() bool { - switch f { - case FormatTable, FormatJSON, FormatJSONL, FormatCSV, FormatTSV, FormatURL, FormatRaw: - return true - } - return false -} - -// Renderer writes records in a chosen format. -type Renderer struct { - Format Format - Fields []string - NoHeader bool - Template string - w io.Writer -} - -// New builds a Renderer writing to w. -func New(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *Renderer { - return &Renderer{Format: format, Fields: fields, NoHeader: noHeader, Template: tmpl, w: w} -} - -// Render writes records (a slice of structs, or a single struct) in the configured format. -func (r *Renderer) Render(records any) error { - rv := reflect.ValueOf(records) - if rv.Kind() == reflect.Pointer { - rv = rv.Elem() - } - if rv.Kind() != reflect.Slice { - s := reflect.MakeSlice(reflect.SliceOf(rv.Type()), 1, 1) - s.Index(0).Set(rv) - rv = s - } - n := rv.Len() - items := make([]any, n) - for i := 0; i < n; i++ { - items[i] = rv.Index(i).Interface() - } - - if r.Template != "" { - return r.renderTemplate(items) - } - switch r.Format { - case FormatJSON: - return r.renderJSON(items) - case FormatJSONL: - return r.renderJSONL(items) - case FormatCSV: - return r.renderDelimited(items, ',') - case FormatTSV: - return r.renderDelimited(items, '\t') - case FormatURL: - return r.renderURL(items) - case FormatRaw: - return r.renderRaw(items) - default: - return r.renderTable(items) - } -} - -func (r *Renderer) renderJSON(items []any) error { - enc := json.NewEncoder(r.w) - enc.SetIndent("", " ") - if len(items) == 1 { - return enc.Encode(items[0]) - } - return enc.Encode(items) -} - -func (r *Renderer) renderJSONL(items []any) error { - enc := json.NewEncoder(r.w) - for _, it := range items { - if err := enc.Encode(it); err != nil { - return err - } - } - return nil -} - -func (r *Renderer) renderTemplate(items []any) error { - t, err := template.New("row").Funcs(template.FuncMap{ - "join": func(sep string, v any) string { return joinAny(sep, v) }, - }).Parse(r.Template) - if err != nil { - return fmt.Errorf("parse --template: %w", err) - } - for _, it := range items { - if err := t.Execute(r.w, toAnyMap(it)); err != nil { - return err - } - _, _ = fmt.Fprintln(r.w) - } - return nil -} - -func (r *Renderer) renderURL(items []any) error { - for _, it := range items { - m := toMap(it) - if u := firstNonEmpty(m["url"], m["hn_url"], m["permalink"]); u != "" { - _, _ = fmt.Fprintln(r.w, u) - } - } - return nil -} - -func (r *Renderer) renderRaw(items []any) error { - cols := r.columns(items) - for _, it := range items { - m := toMap(it) - vals := make([]string, 0, len(cols)) - for _, c := range cols { - vals = append(vals, m[c]) - } - _, _ = fmt.Fprintln(r.w, strings.Join(vals, " ")) - } - return nil -} - -func (r *Renderer) renderTable(items []any) error { - if len(items) == 0 { - return nil - } - cols := r.columns(items) - tw := tabwriter.NewWriter(r.w, 0, 4, 2, ' ', 0) - if !r.NoHeader { - _, _ = fmt.Fprintln(tw, strings.Join(upperAll(cols), "\t")) - } - for _, it := range items { - m := toMap(it) - cells := make([]string, len(cols)) - for i, c := range cols { - cells[i] = truncate(m[c], 60) - } - _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t")) - } - return tw.Flush() -} - -func (r *Renderer) renderDelimited(items []any, comma rune) error { - if len(items) == 0 { - return nil - } - cols := r.columns(items) - cw := csv.NewWriter(r.w) - cw.Comma = comma - if !r.NoHeader { - if err := cw.Write(cols); err != nil { - return err - } - } - for _, it := range items { - m := toMap(it) - row := make([]string, len(cols)) - for i, c := range cols { - row[i] = m[c] - } - if err := cw.Write(row); err != nil { - return err - } - } - cw.Flush() - return cw.Error() -} - -func (r *Renderer) columns(items []any) []string { - if len(r.Fields) > 0 { - return r.Fields - } - if len(items) == 0 { - return nil - } - return structJSONKeys(items[0]) -} - -func toAnyMap(v any) any { - data, err := json.Marshal(v) - if err != nil { - return v - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return v - } - return m -} - -func joinAny(sep string, v any) string { - switch vv := v.(type) { - case nil: - return "" - case []string: - return strings.Join(vv, sep) - case []any: - parts := make([]string, len(vv)) - for i, e := range vv { - parts[i] = fmt.Sprintf("%v", e) - } - return strings.Join(parts, sep) - default: - return fmt.Sprintf("%v", v) - } -} - -func toMap(v any) map[string]string { - out := map[string]string{} - rv := reflect.ValueOf(v) - if rv.Kind() == reflect.Pointer { - rv = rv.Elem() - } - if rv.Kind() != reflect.Struct { - return out - } - rt := rv.Type() - for i := 0; i < rt.NumField(); i++ { - f := rt.Field(i) - if f.PkgPath != "" { - continue - } - key := jsonKey(f) - if key == "-" { - continue - } - out[key] = formatValue(rv.Field(i)) - } - return out -} - -func structJSONKeys(v any) []string { - rv := reflect.ValueOf(v) - if rv.Kind() == reflect.Pointer { - rv = rv.Elem() - } - if rv.Kind() != reflect.Struct { - return nil - } - rt := rv.Type() - var keys []string - for i := 0; i < rt.NumField(); i++ { - f := rt.Field(i) - if f.PkgPath != "" { - continue - } - key := jsonKey(f) - if key == "-" { - continue - } - keys = append(keys, key) - } - return keys -} - -func jsonKey(f reflect.StructField) string { - tag := f.Tag.Get("json") - if tag == "" { - return f.Name - } - name := strings.Split(tag, ",")[0] - if name == "" { - return f.Name - } - return name -} - -func formatValue(v reflect.Value) string { - switch v.Kind() { - case reflect.String: - return v.String() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return strconv.FormatInt(v.Int(), 10) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return strconv.FormatUint(v.Uint(), 10) - case reflect.Float32, reflect.Float64: - return strconv.FormatFloat(v.Float(), 'g', -1, 64) - case reflect.Bool: - return strconv.FormatBool(v.Bool()) - case reflect.Slice: - parts := make([]string, v.Len()) - for i := 0; i < v.Len(); i++ { - parts[i] = formatValue(v.Index(i)) - } - return strings.Join(parts, ";") - case reflect.Struct: - if t, ok := v.Interface().(time.Time); ok { - if t.IsZero() { - return "" - } - return t.Format(time.RFC3339) - } - } - return fmt.Sprintf("%v", v.Interface()) -} - -func upperAll(ss []string) []string { - out := make([]string, len(ss)) - for i, s := range ss { - out[i] = strings.ToUpper(s) - } - return out -} - -func firstNonEmpty(ss ...string) string { - for _, s := range ss { - if s != "" { - return s - } - } - return "" -} - -func truncate(s string, n int) string { - s = strings.ReplaceAll(s, "\n", " ") - if len([]rune(s)) <= n { - return s - } - rs := []rune(s) - return string(rs[:n-1]) + "..." -}