skillhub/github-sync-go/internal/github/client.go
XiaoSeS 2df4c913cb feat(github-sync): add continuous public repository discovery and skill sync
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-10-20 14:40:46 +08:00

319 lines
9.5 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
const (
apiBaseURL = "https://api.github.com"
apiVersion = "2026-03-10"
maxResponseBytes = 9 << 20
)
var ErrResponseTooLarge = errors.New("GitHub API response exceeds size limit")
var (
ownerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
repoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,100}$`)
shaPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
)
type Client struct {
http *http.Client
token string
quotaObserver func(context.Context, Quota)
beforeRequest func(context.Context) error
afterRequest func(context.Context, Quota, error) error
}
func (c *Client) SetQuotaObserver(observer func(context.Context, Quota)) {
c.quotaObserver = observer
}
// SetRequestPolicy installs fail-closed hooks for official acquisition only.
func (c *Client) SetRequestPolicy(before func(context.Context) error, after func(context.Context, Quota, error) error) {
c.beforeRequest, c.afterRequest = before, after
}
func NewClient(token string) *Client {
return &Client{
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConnsPerHost: 4,
ResponseHeaderTimeout: 20 * time.Second,
},
CheckRedirect: func(request *http.Request, previous []*http.Request) error {
if len(previous) >= 4 || request.URL.Scheme != "https" ||
request.URL.Host != "api.github.com" || request.URL.User != nil ||
request.URL.Fragment != "" {
return errors.New("unsafe GitHub API redirect")
}
return nil
},
},
token: token,
}
}
type Repository struct {
ID int64 `json:"id"`
Name string `json:"name"`
Private bool `json:"private"`
DefaultBranch string `json:"default_branch"`
Owner struct {
Login string `json:"login"`
ID int64 `json:"id"`
} `json:"owner"`
}
type Reference struct {
Object struct {
Type string `json:"type"`
SHA string `json:"sha"`
} `json:"object"`
}
type Commit struct {
SHA string `json:"sha"`
Tree struct {
SHA string `json:"sha"`
} `json:"tree"`
}
type TreeEntry struct {
Path string `json:"path"`
Mode string `json:"mode"`
Type string `json:"type"`
Size int64 `json:"size"`
SHA string `json:"sha"`
}
type Tree struct {
SHA string `json:"sha"`
Truncated bool `json:"truncated"`
Entries []TreeEntry `json:"tree"`
}
type Quota struct {
Resource string
Remaining int
ResetAt time.Time
}
type APIError struct {
Status int
Code string
RetryAt time.Time
Quota Quota
SecondaryRateLimit bool
}
func (e *APIError) Error() string {
return fmt.Sprintf("GitHub API returned HTTP %d (%s)", e.Status, e.Code)
}
func (c *Client) GetRepository(ctx context.Context, owner, name string) (Repository, Quota, error) {
if err := validateRepository(owner, name); err != nil {
return Repository{}, Quota{}, err
}
var repository Repository
quota, err := c.get(ctx, "/repos/"+owner+"/"+name, &repository)
if err != nil {
return Repository{}, quota, err
}
if repository.Private || repository.ID <= 0 || repository.Owner.Login == "" || repository.Name == "" {
return Repository{}, quota, errors.New("repository is private or has incomplete metadata")
}
return repository, quota, nil
}
func (c *Client) GetHead(ctx context.Context, repository Repository) (string, Quota, error) {
if err := validateRepository(repository.Owner.Login, repository.Name); err != nil {
return "", Quota{}, err
}
if repository.DefaultBranch == "" {
return "", Quota{}, errors.New("repository has no default branch")
}
// Branch names may contain '/', so escape each segment while retaining the separator.
segments := strings.Split(repository.DefaultBranch, "/")
for i, segment := range segments {
if segment == "" || segment == "." || segment == ".." {
return "", Quota{}, errors.New("invalid default branch")
}
segments[i] = url.PathEscape(segment)
}
var reference Reference
quota, err := c.get(ctx, "/repos/"+repository.Owner.Login+"/"+repository.Name+
"/git/ref/heads/"+strings.Join(segments, "/"), &reference)
if err != nil {
return "", quota, err
}
if reference.Object.Type != "commit" || !shaPattern.MatchString(reference.Object.SHA) {
return "", quota, errors.New("default branch did not resolve to a commit")
}
return reference.Object.SHA, quota, nil
}
func (c *Client) GetCommitTreeSHA(ctx context.Context, owner, name, commitSHA string) (string, Quota, error) {
if err := validateRepository(owner, name); err != nil {
return "", Quota{}, err
}
if !shaPattern.MatchString(commitSHA) {
return "", Quota{}, errors.New("invalid commit SHA")
}
var commit Commit
quota, err := c.get(ctx, "/repos/"+owner+"/"+name+"/git/commits/"+commitSHA, &commit)
if err != nil {
return "", quota, err
}
if commit.SHA != commitSHA || !shaPattern.MatchString(commit.Tree.SHA) {
return "", quota, errors.New("GitHub commit does not match the fixed revision")
}
return commit.Tree.SHA, quota, nil
}
func (c *Client) GetTree(ctx context.Context, owner, name, treeSHA string, recursive bool) (Tree, Quota, error) {
if err := validateRepository(owner, name); err != nil {
return Tree{}, Quota{}, err
}
if !shaPattern.MatchString(treeSHA) {
return Tree{}, Quota{}, errors.New("invalid tree SHA")
}
path := "/repos/" + owner + "/" + name + "/git/trees/" + treeSHA
if recursive {
path += "?recursive=1"
}
var tree Tree
quota, err := c.get(ctx, path, &tree)
if err != nil {
return Tree{}, quota, err
}
if tree.SHA != treeSHA {
return Tree{}, quota, errors.New("GitHub tree does not match the fixed revision")
}
return tree, quota, nil
}
func validateRepository(owner, name string) error {
if !ownerPattern.MatchString(owner) || !repoPattern.MatchString(name) || name == "." || name == ".." {
return errors.New("invalid GitHub repository coordinate")
}
return nil
}
// PublicCloneURL never embeds a credential in the remote URL.
func PublicCloneURL(owner, name string) (string, error) {
if err := validateRepository(owner, name); err != nil {
return "", err
}
return "https://github.com/" + owner + "/" + name + ".git", nil
}
func (c *Client) get(ctx context.Context, path string, target any) (quota Quota, resultErr error) {
quota.Remaining = -1
if err := CheckAcquisitionControl(ctx); err != nil {
return quota, err
}
if c.beforeRequest != nil {
if err := c.beforeRequest(ctx); err != nil {
return quota, err
}
}
if c.afterRequest != nil {
defer func() {
if err := c.afterRequest(ctx, quota, resultErr); err != nil {
resultErr = err
}
}()
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL+path, nil)
if err != nil {
return Quota{}, err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("X-GitHub-Api-Version", apiVersion)
request.Header.Set("User-Agent", "SkillHub-GitHub-Sync")
if c.token != "" {
request.Header.Set("Authorization", "Bearer "+c.token)
}
response, err := c.http.Do(request)
if err != nil {
return quota, err
}
defer response.Body.Close()
quota = parseQuota(response.Header)
if c.quotaObserver != nil && quota.Remaining >= 0 && !quota.ResetAt.IsZero() {
c.quotaObserver(ctx, quota)
}
if response.StatusCode != http.StatusOK {
return quota, classifyError(response, quota)
}
contents, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
if err != nil {
return quota, err
}
if len(contents) > maxResponseBytes {
return quota, ErrResponseTooLarge
}
if err := json.Unmarshal(contents, target); err != nil {
return quota, fmt.Errorf("decode GitHub API response: %w", err)
}
return quota, nil
}
func parseQuota(header http.Header) Quota {
quota := Quota{Resource: header.Get("X-RateLimit-Resource"), Remaining: -1}
if remaining, err := strconv.Atoi(header.Get("X-RateLimit-Remaining")); err == nil {
quota.Remaining = remaining
}
if reset, err := strconv.ParseInt(header.Get("X-RateLimit-Reset"), 10, 64); err == nil {
quota.ResetAt = time.Unix(reset, 0).UTC()
}
return quota
}
func classifyError(response *http.Response, quota Quota) error {
status := response.StatusCode
classified := &APIError{Status: status, Quota: quota, Code: "RETRYABLE_FAILURE"}
var payload struct {
Message string `json:"message"`
}
_ = json.NewDecoder(io.LimitReader(response.Body, 8192)).Decode(&payload)
if status == http.StatusUnauthorized {
classified.Code = "AUTH_INVALID"
} else if status == http.StatusNotFound || status == http.StatusForbidden {
classified.Code = "SOURCE_UNREADABLE"
}
if status == http.StatusTooManyRequests ||
(status == http.StatusForbidden && (response.Header.Get("X-RateLimit-Remaining") == "0" ||
response.Header.Get("Retry-After") != "" || strings.Contains(strings.ToLower(payload.Message), "rate limit"))) {
classified.Code = "GITHUB_RATE_LIMITED"
classified.SecondaryRateLimit = response.Header.Get("Retry-After") != "" ||
strings.Contains(strings.ToLower(payload.Message), "secondary rate limit") || quota.Remaining > 0
classified.RetryAt = rateLimitRetryAt(response.Header, quota)
}
return classified
}
func rateLimitRetryAt(header http.Header, quota Quota) time.Time {
if seconds, err := strconv.Atoi(header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Now().Add(time.Duration(seconds) * time.Second)
}
if !quota.ResetAt.IsZero() && quota.Remaining == 0 {
return quota.ResetAt
}
return time.Now().Add(time.Minute)
}