diff --git a/gitee/domain.go b/gitee/domain.go new file mode 100644 index 0000000..3bef526 --- /dev/null +++ b/gitee/domain.go @@ -0,0 +1,254 @@ +package gitee + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/any-cli/kit/errs" +) + +// domain.go exposes gitee as a kit Domain: a driver that a multi-domain +// host enables with a single blank import, +// +// import _ "github.com/tamnd/gitee-cli/gitee" +// +// The init below registers it; the host then dereferences gitee:// URIs by +// routing to the operations Register installs. +func init() { kit.Register(Domain{}) } + +// Domain is the gitee kit driver. It carries no state. +type Domain struct{} + +// Info describes the scheme, the hostnames a pasted link is matched against, +// and the identity reused for the binary's help and version. +func (Domain) Info() kit.DomainInfo { + return kit.DomainInfo{ + Scheme: "gitee", + Hosts: []string{"gitee.com"}, + Identity: kit.Identity{ + Binary: "gitee", + Short: "A command line for Gitee.", + Long: `A command line for Gitee. + +gitee reads public Gitee data over plain HTTPS, shapes it into +clean records, and prints output that pipes into the rest of your tools. +No API key, nothing to run alongside it.`, + Site: "gitee.com", + Repo: "https://github.com/tamnd/gitee-cli", + }, + } +} + +// Register installs the client factory and every operation onto app. +func (Domain) Register(app *kit.App) { + app.SetClient(newKitClient) + + // trending: list trending repos from the Gitee explore endpoint. + kit.Handle(app, kit.OpMeta{ + Name: "trending", + Group: "read", + List: true, + Summary: "List trending Gitee repositories", + URIType: "repo", + }, listTrending) + + // search: search repos via the Gitee search API. + kit.Handle(app, kit.OpMeta{ + Name: "search", + Group: "read", + List: true, + Summary: "Search Gitee repositories", + URIType: "repo", + Args: []kit.Arg{{Name: "query", Help: "search query"}}, + }, listSearch) + + // user: fetch a single user profile. + kit.Handle(app, kit.OpMeta{ + Name: "user", + Group: "read", + Single: true, + Summary: "Get a Gitee user profile", + URIType: "user", + Resolver: true, + Args: []kit.Arg{{Name: "username", Help: "Gitee username"}}, + }, getUser) + + // repo: fetch a single repository. + kit.Handle(app, kit.OpMeta{ + Name: "repo", + Group: "read", + Single: true, + Summary: "Get a Gitee repository", + URIType: "repo", + Resolver: true, + Args: []kit.Arg{{Name: "ref", Help: "owner/repo or Gitee URL"}}, + }, getRepo) +} + +// newKitClient builds the client from the kit-resolved config. +func newKitClient(_ context.Context, cfg kit.Config) (any, error) { + c := DefaultConfig() + if cfg.UserAgent != "" { + c.UserAgent = cfg.UserAgent + } + if cfg.Rate > 0 { + c.Rate = cfg.Rate + } + if cfg.Retries > 0 { + c.Retries = cfg.Retries + } + if cfg.Timeout > 0 { + c.Timeout = cfg.Timeout + } + return NewClient(c), nil +} + +// --- inputs --- + +type trendingInput struct { + Lang string `kit:"flag" help:"filter by language"` + Sort string `kit:"flag" help:"sort: stars|newest|updated"` + Limit int `kit:"flag,inherit" help:"max results"` + Client *Client `kit:"inject"` +} + +type searchInput struct { + Query string `kit:"arg" help:"search query"` + Lang string `kit:"flag" help:"filter by language"` + Sort string `kit:"flag" help:"sort: stars|forks|updated"` + Limit int `kit:"flag,inherit" help:"max results"` + Client *Client `kit:"inject"` +} + +type userInput struct { + Username string `kit:"arg" help:"Gitee username"` + Client *Client `kit:"inject"` +} + +type repoInput struct { + Ref string `kit:"arg" help:"owner/repo or Gitee URL"` + Client *Client `kit:"inject"` +} + +// --- handlers --- + +func listTrending(ctx context.Context, in trendingInput, emit func(*Repo) error) error { + repos, err := in.Client.TrendingRepos(ctx, in.Lang, in.Sort, in.Limit) + if err != nil { + return err + } + for i := range repos { + if err := emit(&repos[i]); err != nil { + return err + } + } + return nil +} + +func listSearch(ctx context.Context, in searchInput, emit func(*Repo) error) error { + if strings.TrimSpace(in.Query) == "" { + return errs.Usage("query is required") + } + repos, err := in.Client.SearchRepos(ctx, in.Query, in.Lang, in.Sort, in.Limit) + if err != nil { + return err + } + for i := range repos { + if err := emit(&repos[i]); err != nil { + return err + } + } + return nil +} + +func getUser(ctx context.Context, in userInput, emit func(*User) error) error { + if strings.TrimSpace(in.Username) == "" { + return errs.Usage("username is required") + } + u, err := in.Client.GetUser(ctx, in.Username) + if err != nil { + return err + } + return emit(&u) +} + +func getRepo(ctx context.Context, in repoInput, emit func(*Repo) error) error { + owner, name, err := parseRepoRef(in.Ref) + if err != nil { + return errs.Usage("%s", err.Error()) + } + r, err := in.Client.GetRepo(ctx, owner, name) + if err != nil { + return err + } + return emit(&r) +} + +// --- Resolver: pure string functions, no network --- + +// Classify turns a Gitee URL or owner/repo path into the canonical (type, id). +func (Domain) Classify(input string) (uriType, id string, err error) { + input = strings.TrimSpace(input) + if u, parseErr := url.Parse(input); parseErr == nil && (u.Scheme == "http" || u.Scheme == "https") { + // Could be a user or repo URL. + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + switch len(parts) { + case 1: + if parts[0] != "" { + return "user", parts[0], nil + } + case 2: + if parts[0] != "" && parts[1] != "" { + return "repo", parts[0] + "/" + parts[1], nil + } + } + return "", "", errs.Usage("unrecognized Gitee URL: %q", input) + } + // bare owner/repo or username + parts := strings.Split(strings.Trim(input, "/"), "/") + switch len(parts) { + case 1: + if parts[0] != "" { + return "user", parts[0], nil + } + case 2: + if parts[0] != "" && parts[1] != "" { + return "repo", parts[0] + "/" + parts[1], nil + } + } + return "", "", errs.Usage("unrecognized Gitee reference: %q", input) +} + +// Locate is the inverse: the live https URL for a (type, id). +func (Domain) Locate(uriType, id string) (string, error) { + switch uriType { + case "user": + return "https://gitee.com/" + id, nil + case "repo": + return "https://gitee.com/" + id, nil + default: + return "", errs.Usage("gitee has no resource type %q", uriType) + } +} + +// --- helpers --- + +// parseRepoRef splits a ref like "owner/repo" or a full URL into owner and repo. +func parseRepoRef(ref string) (owner, name string, err error) { + ref = strings.TrimSpace(ref) + if u, parseErr := url.Parse(ref); parseErr == nil && (u.Scheme == "http" || u.Scheme == "https") { + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) >= 2 && parts[0] != "" && parts[1] != "" { + return parts[0], parts[1], nil + } + return "", "", fmt.Errorf("cannot extract owner/repo from URL: %q", ref) + } + parts := strings.SplitN(strings.Trim(ref, "/"), "/", 2) + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + return parts[0], parts[1], nil + } + return "", "", fmt.Errorf("expected owner/repo, got: %q", ref) +} diff --git a/gitee/domain_test.go b/gitee/domain_test.go new file mode 100644 index 0000000..2b4724b --- /dev/null +++ b/gitee/domain_test.go @@ -0,0 +1,91 @@ +package gitee + +import ( + "testing" + + "github.com/tamnd/any-cli/kit" +) + +// These tests are offline: they cover the kit domain's pure string functions +// and the host wiring, which need no network. + +func TestDomainInfo(t *testing.T) { + info := Domain{}.Info() + if info.Scheme != "gitee" { + t.Errorf("Scheme = %q, want gitee", info.Scheme) + } + if len(info.Hosts) == 0 || info.Hosts[0] != "gitee.com" { + t.Errorf("Hosts = %v, want [gitee.com]", info.Hosts) + } + if info.Identity.Binary != "gitee" { + t.Errorf("Identity.Binary = %q, want gitee", info.Identity.Binary) + } +} + +func TestClassify(t *testing.T) { + cases := []struct{ in, typ, id string }{ + {"torvalds", "user", "torvalds"}, + {"tamnd/gitee-cli", "repo", "tamnd/gitee-cli"}, + {"https://gitee.com/tamnd/gitee-cli", "repo", "tamnd/gitee-cli"}, + {"https://gitee.com/tamnd", "user", "tamnd"}, + } + for _, tc := range cases { + typ, id, err := Domain{}.Classify(tc.in) + if err != nil || typ != tc.typ || id != tc.id { + t.Errorf("Classify(%q) = (%q, %q, %v), want (%q, %q, nil)", + tc.in, typ, id, err, tc.typ, tc.id) + } + } +} + +func TestClassifyBad(t *testing.T) { + _, _, err := Domain{}.Classify("") + if err == nil { + t.Error("Classify('') expected error, got nil") + } +} + +func TestLocate(t *testing.T) { + cases := []struct{ typ, id, want string }{ + {"user", "tamnd", "https://gitee.com/tamnd"}, + {"repo", "tamnd/gitee-cli", "https://gitee.com/tamnd/gitee-cli"}, + } + for _, tc := range cases { + got, err := Domain{}.Locate(tc.typ, tc.id) + if err != nil || got != tc.want { + t.Errorf("Locate(%q, %q) = (%q, %v), want (%q, nil)", tc.typ, tc.id, got, err, tc.want) + } + } +} + +func TestLocateBadType(t *testing.T) { + _, err := Domain{}.Locate("unknown", "tamnd") + if err == nil { + t.Error("Locate(unknown) expected error, got nil") + } +} + +func TestParseRepoRef(t *testing.T) { + cases := []struct{ ref, owner, name string }{ + {"tamnd/gitee-cli", "tamnd", "gitee-cli"}, + {"https://gitee.com/tamnd/gitee-cli", "tamnd", "gitee-cli"}, + } + for _, tc := range cases { + o, n, err := parseRepoRef(tc.ref) + if err != nil || o != tc.owner || n != tc.name { + t.Errorf("parseRepoRef(%q) = (%q, %q, %v), want (%q, %q, nil)", + tc.ref, o, n, err, tc.owner, tc.name) + } + } +} + +func TestResolveOn(t *testing.T) { + h, err := kit.Open() + if err != nil { + t.Fatal(err) + } + got, err := h.ResolveOn("gitee", "tamnd") + if err != nil || got.String() != "gitee://user/tamnd" { + t.Errorf("ResolveOn = (%q, %v), want gitee://user/tamnd", got.String(), err) + } +} diff --git a/gitee/types.go b/gitee/types.go index 1eb5d4c..d72a810 100644 --- a/gitee/types.go +++ b/gitee/types.go @@ -10,35 +10,35 @@ import ( // 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"` + Rank int `json:"rank" table:"rank"` + FullName string `json:"full_name" kit:"id" table:"full_name"` + Description string `json:"description" table:"description"` + Language string `json:"language" table:"language"` + Stars int `json:"stars" table:"stars"` + Forks int `json:"forks" table:"forks"` + UpdatedAt string `json:"updated_at" table:"updated_at"` + URL string `json:"url" table:"url,url"` } // 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"` + Login string `json:"login" kit:"id" table:"login"` + Name string `json:"name" table:"name"` + Followers int `json:"followers" table:"followers"` + Following int `json:"following" table:"following"` + Repos int `json:"repos" table:"repos"` + Blog string `json:"blog" table:"blog"` + URL string `json:"url" table:"url,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"` + Rank int `json:"rank" table:"rank"` + TagName string `json:"tag_name" kit:"id" table:"tag_name"` + Name string `json:"name" table:"name"` + Prerelease bool `json:"prerelease" table:"prerelease"` + CreatedAt string `json:"created_at" table:"created_at"` + URL string `json:"url" table:"url,url"` } // ─── Wire types (internal) ──────────────────────────────────────────────────── diff --git a/go.mod b/go.mod index 07c6b03..7fbdc8e 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ 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-runewidth v0.0.19 // indirect @@ -28,10 +30,17 @@ require ( 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/pflag v1.0.9 // indirect + github.com/tamnd/any-cli v0.4.0 // 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..c5f139c 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,10 @@ 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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= @@ -47,8 +51,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,6 +66,8 @@ 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= @@ -65,10 +75,22 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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= 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/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/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=