108 lines
4.4 KiB
Go
108 lines
4.4 KiB
Go
// 从 GitHub 安装与同步技能:解析仓库地址、下载归档、按提交号记录版本。
|
|
|
|
package skills
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/kernel"
|
|
"infinite-canvas/backend/internal/outbound"
|
|
)
|
|
|
|
func parseGitHubSkillURL(rawURL string, requestedRef string, requestedSubdir string) (githubSkillSpec, error) {
|
|
target, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || target.Scheme != "https" || !strings.EqualFold(target.Hostname(), "github.com") || target.User != nil {
|
|
return githubSkillSpec{}, kernel.BadAuthRequest("请输入公开的 https://github.com 仓库地址")
|
|
}
|
|
parts := strings.Split(strings.Trim(target.Path, "/"), "/")
|
|
if len(parts) < 2 {
|
|
return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须包含 owner/repository")
|
|
}
|
|
spec := githubSkillSpec{Owner: parts[0], Repo: strings.TrimSuffix(parts[1], ".git"), Ref: strings.TrimSpace(requestedRef), Subdir: strings.Trim(strings.ReplaceAll(strings.TrimSpace(requestedSubdir), "\\", "/"), "/")}
|
|
if len(parts) >= 4 && parts[2] == "tree" {
|
|
if spec.Ref == "" {
|
|
spec.Ref = parts[3]
|
|
}
|
|
if spec.Subdir == "" && len(parts) > 4 {
|
|
spec.Subdir = strings.Join(parts[4:], "/")
|
|
}
|
|
}
|
|
if len(parts) > 2 && parts[2] != "tree" {
|
|
return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须指向仓库根目录或 tree 子目录")
|
|
}
|
|
if spec.Owner == "" || spec.Repo == "" {
|
|
return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 仓库地址无效")
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
func fetchGitHubSkillArchive(ctx context.Context, spec githubSkillSpec) (skillPackageArchive, string, string, string, error) {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resolvedRef := spec.Ref
|
|
if resolvedRef == "" {
|
|
var repoInfo struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo)), &repoInfo); err != nil {
|
|
return skillPackageArchive{}, "", "", "", err
|
|
}
|
|
resolvedRef = repoInfo.DefaultBranch
|
|
}
|
|
var commitInfo struct {
|
|
SHA string `json:"sha"`
|
|
}
|
|
if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(resolvedRef)), &commitInfo); err != nil {
|
|
return skillPackageArchive{}, "", "", "", err
|
|
}
|
|
if commitInfo.SHA == "" {
|
|
return skillPackageArchive{}, "", "", "", errors.New("GitHub 未返回提交版本")
|
|
}
|
|
downloadURL := fmt.Sprintf("https://codeload.github.com/%s/%s/zip/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(commitInfo.SHA))
|
|
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
|
request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent)
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: HTTP %d", response.StatusCode)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(response.Body, maxSkillPackageBytes+1))
|
|
if err != nil {
|
|
return skillPackageArchive{}, "", "", "", err
|
|
}
|
|
if len(data) > maxSkillPackageBytes {
|
|
return skillPackageArchive{}, "", "", "", kernel.BadAuthRequest("GitHub 技能包超过 20MB")
|
|
}
|
|
archive, err := archiveFromZip(data, spec.Subdir)
|
|
if err != nil {
|
|
return skillPackageArchive{}, "", "", "", err
|
|
}
|
|
canonicalURL := fmt.Sprintf("https://github.com/%s/%s", spec.Owner, spec.Repo)
|
|
return archive, commitInfo.SHA, canonicalURL, resolvedRef, nil
|
|
}
|
|
|
|
func githubJSON(ctx context.Context, client *http.Client, target string, output any) error {
|
|
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
request.Header.Set("Accept", "application/vnd.github+json")
|
|
request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent)
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return fmt.Errorf("读取 GitHub 信息失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return fmt.Errorf("读取 GitHub 信息失败: HTTP %d", response.StatusCode)
|
|
}
|
|
return json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(output)
|
|
}
|