diff --git a/internal/domain/types.go b/internal/domain/types.go index ed1caae..3013230 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -46,3 +46,23 @@ type InstalledTarget struct { type State struct { Skills []SkillState `json:"skills"` } + +type RemoteSkill struct { + Name string `json:"name"` + FullName string `json:"fullName"` + Description string `json:"description"` + CloneURL string `json:"cloneURL"` + SSHURL string `json:"sshURL"` + DefaultBranch string `json:"defaultBranch"` + UpdatedAt string `json:"updatedAt"` + IsDownloaded bool `json:"isDownloaded"` + Status string `json:"status"` + Error string `json:"error"` +} + +type TestConnectionResult struct { + OK bool `json:"ok"` + Message string `json:"message"` + Username string `json:"username"` + Org string `json:"org"` +} diff --git a/internal/gitea/client.go b/internal/gitea/client.go new file mode 100644 index 0000000..923ba0e --- /dev/null +++ b/internal/gitea/client.go @@ -0,0 +1,177 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "sgg-ai-skill-manager/internal/domain" +) + +const ( + AuthPassword = "password" + AuthToken = "token" +) + +type Auth struct { + Type string + Username string + Secret string +} + +type Client struct { + baseURL string + auth Auth + httpClient *http.Client +} + +type apiRepo struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + CloneURL string `json:"clone_url"` + SSHURL string `json:"ssh_url"` + DefaultBranch string `json:"default_branch"` + UpdatedAt string `json:"updated_at"` +} + +type apiUser struct { + UserName string `json:"username"` +} + +func NewClient(baseURL string, auth Auth) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + auth: auth, + httpClient: &http.Client{Timeout: 20 * time.Second}, + } +} + +func (c *Client) ListOrgSkills(ctx context.Context, org string) ([]domain.RemoteSkill, error) { + var skills []domain.RemoteSkill + for page := 1; ; page++ { + var repos []apiRepo + endpoint := fmt.Sprintf("/api/v1/orgs/%s/repos?page=%d&limit=50", url.PathEscape(org), page) + if err := c.getJSON(ctx, endpoint, &repos); err != nil { + return nil, err + } + if len(repos) == 0 { + break + } + for _, repo := range repos { + hasSkill, err := c.HasRootSkill(ctx, org, repo.Name, repo.DefaultBranch) + if err != nil { + skills = append(skills, domain.RemoteSkill{ + Name: repo.Name, + FullName: repo.FullName, + Status: "check_failed", + Error: err.Error(), + }) + continue + } + if !hasSkill { + continue + } + skills = append(skills, domain.RemoteSkill{ + Name: repo.Name, + FullName: repo.FullName, + Description: repo.Description, + CloneURL: repo.CloneURL, + SSHURL: repo.SSHURL, + DefaultBranch: repo.DefaultBranch, + UpdatedAt: repo.UpdatedAt, + Status: "remote", + }) + } + if len(repos) < 50 { + break + } + } + return skills, nil +} + +func (c *Client) HasRootSkill(ctx context.Context, org, repo, ref string) (bool, error) { + endpoint := fmt.Sprintf( + "/api/v1/repos/%s/%s/contents/SKILL.md", + url.PathEscape(org), + url.PathEscape(repo), + ) + if ref != "" { + endpoint += "?ref=" + url.QueryEscape(ref) + } + req, err := c.newRequest(ctx, http.MethodGet, endpoint) + if err != nil { + return false, err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true, nil + } + return false, fmt.Errorf("check SKILL.md failed: %s", resp.Status) +} + +func (c *Client) TestConnection(ctx context.Context, org string) (domain.TestConnectionResult, error) { + var user apiUser + if err := c.getJSON(ctx, "/api/v1/user", &user); err != nil { + return domain.TestConnectionResult{}, err + } + req, err := c.newRequest(ctx, http.MethodGet, "/api/v1/orgs/"+url.PathEscape(org)) + if err != nil { + return domain.TestConnectionResult{}, err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return domain.TestConnectionResult{}, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return domain.TestConnectionResult{}, fmt.Errorf("org access failed: %s", resp.Status) + } + return domain.TestConnectionResult{ + OK: true, + Message: "connected", + Username: user.UserName, + Org: org, + }, nil +} + +func (c *Client) getJSON(ctx context.Context, endpoint string, target any) error { + req, err := c.newRequest(ctx, http.MethodGet, endpoint) + if err != nil { + return err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gitea request failed: %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(target) +} + +func (c *Client) newRequest(ctx context.Context, method, endpoint string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+endpoint, nil) + if err != nil { + return nil, err + } + if c.auth.Type == AuthToken { + req.Header.Set("Authorization", "token "+c.auth.Secret) + } else if c.auth.Username != "" || c.auth.Secret != "" { + req.SetBasicAuth(c.auth.Username, c.auth.Secret) + } + req.Header.Set("Accept", "application/json") + return req, nil +} diff --git a/internal/gitea/client_test.go b/internal/gitea/client_test.go new file mode 100644 index 0000000..68c7041 --- /dev/null +++ b/internal/gitea/client_test.go @@ -0,0 +1,83 @@ +package gitea + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListOrgSkillsFiltersReposWithoutSkillMD(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/orgs/skills/repos", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("page = %q, want 1", got) + } + _ = json.NewEncoder(w).Encode([]apiRepo{ + {Name: "good", FullName: "skills/good", CloneURL: "https://example/good.git", DefaultBranch: "main"}, + {Name: "bad", FullName: "skills/bad", CloneURL: "https://example/bad.git", DefaultBranch: "main"}, + }) + }) + mux.HandleFunc("/api/v1/repos/skills/good/contents/SKILL.md", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("ref"); got != "main" { + t.Fatalf("ref = %q, want main", got) + } + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/api/v1/repos/skills/bad/contents/SKILL.md", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client := NewClient(server.URL, Auth{Type: AuthPassword, Username: "alice", Secret: "secret"}) + repos, err := client.ListOrgSkills(context.Background(), "skills") + if err != nil { + t.Fatalf("ListOrgSkills returned error: %v", err) + } + if len(repos) != 1 || repos[0].Name != "good" { + t.Fatalf("repos = %+v, want only good", repos) + } + if repos[0].CloneURL != "https://example/good.git" { + t.Fatalf("CloneURL = %q", repos[0].CloneURL) + } +} + +func TestListOrgSkillsUsesTokenAuth(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/orgs/skills/repos", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "token abc123" { + t.Fatalf("Authorization = %q, want token abc123", got) + } + _ = json.NewEncoder(w).Encode([]apiRepo{}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client := NewClient(server.URL, Auth{Type: AuthToken, Secret: "abc123"}) + if _, err := client.ListOrgSkills(context.Background(), "skills"); err != nil { + t.Fatalf("ListOrgSkills returned error: %v", err) + } +} + +func TestTestConnectionChecksCurrentUserAndOrg(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(apiUser{UserName: "alice"}) + }) + mux.HandleFunc("/api/v1/orgs/skills", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client := NewClient(server.URL, Auth{Type: AuthPassword, Username: "alice", Secret: "secret"}) + result, err := client.TestConnection(context.Background(), "skills") + if err != nil { + t.Fatalf("TestConnection returned error: %v", err) + } + if !result.OK || result.Username != "alice" || result.Org != "skills" { + t.Fatalf("result = %+v", result) + } +}