mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
319 lines
9.5 KiB
Go
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)
|
|
}
|