Skip to content

Commit 6bf22f9

Browse files
authored
Add kit domain layer with trending, search, user, and repo ops (#1)
Wire the gitee package into any-cli/kit: register Domain{}, expose four ops: trending (repos/explore), search (search/repositories), user (users/USERNAME), and repo (repos/OWNER/REPO). Add kit:"id" and table: tags to Repo, User, and Release. Add domain.go + domain_test.go covering Classify, Locate, parseRepoRef, and host wiring via ResolveOn.
1 parent 0a386c2 commit 6bf22f9

5 files changed

Lines changed: 399 additions & 23 deletions

File tree

gitee/domain.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package gitee
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
"strings"
8+
9+
"github.com/tamnd/any-cli/kit"
10+
"github.com/tamnd/any-cli/kit/errs"
11+
)
12+
13+
// domain.go exposes gitee as a kit Domain: a driver that a multi-domain
14+
// host enables with a single blank import,
15+
//
16+
// import _ "github.com/tamnd/gitee-cli/gitee"
17+
//
18+
// The init below registers it; the host then dereferences gitee:// URIs by
19+
// routing to the operations Register installs.
20+
func init() { kit.Register(Domain{}) }
21+
22+
// Domain is the gitee kit driver. It carries no state.
23+
type Domain struct{}
24+
25+
// Info describes the scheme, the hostnames a pasted link is matched against,
26+
// and the identity reused for the binary's help and version.
27+
func (Domain) Info() kit.DomainInfo {
28+
return kit.DomainInfo{
29+
Scheme: "gitee",
30+
Hosts: []string{"gitee.com"},
31+
Identity: kit.Identity{
32+
Binary: "gitee",
33+
Short: "A command line for Gitee.",
34+
Long: `A command line for Gitee.
35+
36+
gitee reads public Gitee data over plain HTTPS, shapes it into
37+
clean records, and prints output that pipes into the rest of your tools.
38+
No API key, nothing to run alongside it.`,
39+
Site: "gitee.com",
40+
Repo: "https://github.com/tamnd/gitee-cli",
41+
},
42+
}
43+
}
44+
45+
// Register installs the client factory and every operation onto app.
46+
func (Domain) Register(app *kit.App) {
47+
app.SetClient(newKitClient)
48+
49+
// trending: list trending repos from the Gitee explore endpoint.
50+
kit.Handle(app, kit.OpMeta{
51+
Name: "trending",
52+
Group: "read",
53+
List: true,
54+
Summary: "List trending Gitee repositories",
55+
URIType: "repo",
56+
}, listTrending)
57+
58+
// search: search repos via the Gitee search API.
59+
kit.Handle(app, kit.OpMeta{
60+
Name: "search",
61+
Group: "read",
62+
List: true,
63+
Summary: "Search Gitee repositories",
64+
URIType: "repo",
65+
Args: []kit.Arg{{Name: "query", Help: "search query"}},
66+
}, listSearch)
67+
68+
// user: fetch a single user profile.
69+
kit.Handle(app, kit.OpMeta{
70+
Name: "user",
71+
Group: "read",
72+
Single: true,
73+
Summary: "Get a Gitee user profile",
74+
URIType: "user",
75+
Resolver: true,
76+
Args: []kit.Arg{{Name: "username", Help: "Gitee username"}},
77+
}, getUser)
78+
79+
// repo: fetch a single repository.
80+
kit.Handle(app, kit.OpMeta{
81+
Name: "repo",
82+
Group: "read",
83+
Single: true,
84+
Summary: "Get a Gitee repository",
85+
URIType: "repo",
86+
Resolver: true,
87+
Args: []kit.Arg{{Name: "ref", Help: "owner/repo or Gitee URL"}},
88+
}, getRepo)
89+
}
90+
91+
// newKitClient builds the client from the kit-resolved config.
92+
func newKitClient(_ context.Context, cfg kit.Config) (any, error) {
93+
c := DefaultConfig()
94+
if cfg.UserAgent != "" {
95+
c.UserAgent = cfg.UserAgent
96+
}
97+
if cfg.Rate > 0 {
98+
c.Rate = cfg.Rate
99+
}
100+
if cfg.Retries > 0 {
101+
c.Retries = cfg.Retries
102+
}
103+
if cfg.Timeout > 0 {
104+
c.Timeout = cfg.Timeout
105+
}
106+
return NewClient(c), nil
107+
}
108+
109+
// --- inputs ---
110+
111+
type trendingInput struct {
112+
Lang string `kit:"flag" help:"filter by language"`
113+
Sort string `kit:"flag" help:"sort: stars|newest|updated"`
114+
Limit int `kit:"flag,inherit" help:"max results"`
115+
Client *Client `kit:"inject"`
116+
}
117+
118+
type searchInput struct {
119+
Query string `kit:"arg" help:"search query"`
120+
Lang string `kit:"flag" help:"filter by language"`
121+
Sort string `kit:"flag" help:"sort: stars|forks|updated"`
122+
Limit int `kit:"flag,inherit" help:"max results"`
123+
Client *Client `kit:"inject"`
124+
}
125+
126+
type userInput struct {
127+
Username string `kit:"arg" help:"Gitee username"`
128+
Client *Client `kit:"inject"`
129+
}
130+
131+
type repoInput struct {
132+
Ref string `kit:"arg" help:"owner/repo or Gitee URL"`
133+
Client *Client `kit:"inject"`
134+
}
135+
136+
// --- handlers ---
137+
138+
func listTrending(ctx context.Context, in trendingInput, emit func(*Repo) error) error {
139+
repos, err := in.Client.TrendingRepos(ctx, in.Lang, in.Sort, in.Limit)
140+
if err != nil {
141+
return err
142+
}
143+
for i := range repos {
144+
if err := emit(&repos[i]); err != nil {
145+
return err
146+
}
147+
}
148+
return nil
149+
}
150+
151+
func listSearch(ctx context.Context, in searchInput, emit func(*Repo) error) error {
152+
if strings.TrimSpace(in.Query) == "" {
153+
return errs.Usage("query is required")
154+
}
155+
repos, err := in.Client.SearchRepos(ctx, in.Query, in.Lang, in.Sort, in.Limit)
156+
if err != nil {
157+
return err
158+
}
159+
for i := range repos {
160+
if err := emit(&repos[i]); err != nil {
161+
return err
162+
}
163+
}
164+
return nil
165+
}
166+
167+
func getUser(ctx context.Context, in userInput, emit func(*User) error) error {
168+
if strings.TrimSpace(in.Username) == "" {
169+
return errs.Usage("username is required")
170+
}
171+
u, err := in.Client.GetUser(ctx, in.Username)
172+
if err != nil {
173+
return err
174+
}
175+
return emit(&u)
176+
}
177+
178+
func getRepo(ctx context.Context, in repoInput, emit func(*Repo) error) error {
179+
owner, name, err := parseRepoRef(in.Ref)
180+
if err != nil {
181+
return errs.Usage("%s", err.Error())
182+
}
183+
r, err := in.Client.GetRepo(ctx, owner, name)
184+
if err != nil {
185+
return err
186+
}
187+
return emit(&r)
188+
}
189+
190+
// --- Resolver: pure string functions, no network ---
191+
192+
// Classify turns a Gitee URL or owner/repo path into the canonical (type, id).
193+
func (Domain) Classify(input string) (uriType, id string, err error) {
194+
input = strings.TrimSpace(input)
195+
if u, parseErr := url.Parse(input); parseErr == nil && (u.Scheme == "http" || u.Scheme == "https") {
196+
// Could be a user or repo URL.
197+
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
198+
switch len(parts) {
199+
case 1:
200+
if parts[0] != "" {
201+
return "user", parts[0], nil
202+
}
203+
case 2:
204+
if parts[0] != "" && parts[1] != "" {
205+
return "repo", parts[0] + "/" + parts[1], nil
206+
}
207+
}
208+
return "", "", errs.Usage("unrecognized Gitee URL: %q", input)
209+
}
210+
// bare owner/repo or username
211+
parts := strings.Split(strings.Trim(input, "/"), "/")
212+
switch len(parts) {
213+
case 1:
214+
if parts[0] != "" {
215+
return "user", parts[0], nil
216+
}
217+
case 2:
218+
if parts[0] != "" && parts[1] != "" {
219+
return "repo", parts[0] + "/" + parts[1], nil
220+
}
221+
}
222+
return "", "", errs.Usage("unrecognized Gitee reference: %q", input)
223+
}
224+
225+
// Locate is the inverse: the live https URL for a (type, id).
226+
func (Domain) Locate(uriType, id string) (string, error) {
227+
switch uriType {
228+
case "user":
229+
return "https://gitee.com/" + id, nil
230+
case "repo":
231+
return "https://gitee.com/" + id, nil
232+
default:
233+
return "", errs.Usage("gitee has no resource type %q", uriType)
234+
}
235+
}
236+
237+
// --- helpers ---
238+
239+
// parseRepoRef splits a ref like "owner/repo" or a full URL into owner and repo.
240+
func parseRepoRef(ref string) (owner, name string, err error) {
241+
ref = strings.TrimSpace(ref)
242+
if u, parseErr := url.Parse(ref); parseErr == nil && (u.Scheme == "http" || u.Scheme == "https") {
243+
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
244+
if len(parts) >= 2 && parts[0] != "" && parts[1] != "" {
245+
return parts[0], parts[1], nil
246+
}
247+
return "", "", fmt.Errorf("cannot extract owner/repo from URL: %q", ref)
248+
}
249+
parts := strings.SplitN(strings.Trim(ref, "/"), "/", 2)
250+
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
251+
return parts[0], parts[1], nil
252+
}
253+
return "", "", fmt.Errorf("expected owner/repo, got: %q", ref)
254+
}

gitee/domain_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package gitee
2+
3+
import (
4+
"testing"
5+
6+
"github.com/tamnd/any-cli/kit"
7+
)
8+
9+
// These tests are offline: they cover the kit domain's pure string functions
10+
// and the host wiring, which need no network.
11+
12+
func TestDomainInfo(t *testing.T) {
13+
info := Domain{}.Info()
14+
if info.Scheme != "gitee" {
15+
t.Errorf("Scheme = %q, want gitee", info.Scheme)
16+
}
17+
if len(info.Hosts) == 0 || info.Hosts[0] != "gitee.com" {
18+
t.Errorf("Hosts = %v, want [gitee.com]", info.Hosts)
19+
}
20+
if info.Identity.Binary != "gitee" {
21+
t.Errorf("Identity.Binary = %q, want gitee", info.Identity.Binary)
22+
}
23+
}
24+
25+
func TestClassify(t *testing.T) {
26+
cases := []struct{ in, typ, id string }{
27+
{"torvalds", "user", "torvalds"},
28+
{"tamnd/gitee-cli", "repo", "tamnd/gitee-cli"},
29+
{"https://gitee.com/tamnd/gitee-cli", "repo", "tamnd/gitee-cli"},
30+
{"https://gitee.com/tamnd", "user", "tamnd"},
31+
}
32+
for _, tc := range cases {
33+
typ, id, err := Domain{}.Classify(tc.in)
34+
if err != nil || typ != tc.typ || id != tc.id {
35+
t.Errorf("Classify(%q) = (%q, %q, %v), want (%q, %q, nil)",
36+
tc.in, typ, id, err, tc.typ, tc.id)
37+
}
38+
}
39+
}
40+
41+
func TestClassifyBad(t *testing.T) {
42+
_, _, err := Domain{}.Classify("")
43+
if err == nil {
44+
t.Error("Classify('') expected error, got nil")
45+
}
46+
}
47+
48+
func TestLocate(t *testing.T) {
49+
cases := []struct{ typ, id, want string }{
50+
{"user", "tamnd", "https://gitee.com/tamnd"},
51+
{"repo", "tamnd/gitee-cli", "https://gitee.com/tamnd/gitee-cli"},
52+
}
53+
for _, tc := range cases {
54+
got, err := Domain{}.Locate(tc.typ, tc.id)
55+
if err != nil || got != tc.want {
56+
t.Errorf("Locate(%q, %q) = (%q, %v), want (%q, nil)", tc.typ, tc.id, got, err, tc.want)
57+
}
58+
}
59+
}
60+
61+
func TestLocateBadType(t *testing.T) {
62+
_, err := Domain{}.Locate("unknown", "tamnd")
63+
if err == nil {
64+
t.Error("Locate(unknown) expected error, got nil")
65+
}
66+
}
67+
68+
func TestParseRepoRef(t *testing.T) {
69+
cases := []struct{ ref, owner, name string }{
70+
{"tamnd/gitee-cli", "tamnd", "gitee-cli"},
71+
{"https://gitee.com/tamnd/gitee-cli", "tamnd", "gitee-cli"},
72+
}
73+
for _, tc := range cases {
74+
o, n, err := parseRepoRef(tc.ref)
75+
if err != nil || o != tc.owner || n != tc.name {
76+
t.Errorf("parseRepoRef(%q) = (%q, %q, %v), want (%q, %q, nil)",
77+
tc.ref, o, n, err, tc.owner, tc.name)
78+
}
79+
}
80+
}
81+
82+
func TestResolveOn(t *testing.T) {
83+
h, err := kit.Open()
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
got, err := h.ResolveOn("gitee", "tamnd")
88+
if err != nil || got.String() != "gitee://user/tamnd" {
89+
t.Errorf("ResolveOn = (%q, %v), want gitee://user/tamnd", got.String(), err)
90+
}
91+
}

0 commit comments

Comments
 (0)