feat: add gitea skill discovery
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
177
internal/gitea/client.go
Normal file
177
internal/gitea/client.go
Normal file
@@ -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
|
||||
}
|
||||
83
internal/gitea/client_test.go
Normal file
83
internal/gitea/client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user