490 lines
16 KiB
Go
490 lines
16 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
const (
|
|
defaultRESTBaseURL = "https://api.github.com"
|
|
defaultGraphQLURL = "https://api.github.com/graphql"
|
|
defaultUserAgent = "ao-agent-orchestrator/scm-github"
|
|
)
|
|
|
|
// Sentinel errors. Provider-level callers should match on these via
|
|
// errors.Is; the orchestrator's lifecycle code is intentionally insulated
|
|
// from raw HTTP status codes.
|
|
var (
|
|
ErrNotFound = ports.ErrSCMNotFound
|
|
ErrAuthFailed = errors.New("github scm: authentication failed")
|
|
ErrRateLimited = errors.New("github scm: rate limited")
|
|
)
|
|
|
|
// RateLimitError carries the structured backoff hints from a rate-limit
|
|
// response. Callers that want to back off intelligently can extract
|
|
// ResetAt / RetryAfter via errors.As; callers that only need the category
|
|
// can use errors.Is(err, ErrRateLimited).
|
|
type RateLimitError struct {
|
|
ResetAt time.Time
|
|
RetryAfter time.Duration
|
|
Message string
|
|
}
|
|
|
|
// Error formats the rate-limit error for logs.
|
|
func (e *RateLimitError) Error() string {
|
|
if e == nil {
|
|
return ErrRateLimited.Error()
|
|
}
|
|
if e.Message != "" {
|
|
return "github scm: rate limited: " + e.Message
|
|
}
|
|
return ErrRateLimited.Error()
|
|
}
|
|
|
|
// Is lets errors.Is match a *RateLimitError against ErrRateLimited.
|
|
func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited }
|
|
|
|
// ClientOptions configures a Client. Production code sets Token alone;
|
|
// tests inject HTTPClient and the URL fields to point at an httptest fake.
|
|
type ClientOptions struct {
|
|
HTTPClient *http.Client
|
|
Token TokenSource
|
|
RESTBase string
|
|
GraphQLURL string
|
|
UserAgent string
|
|
}
|
|
|
|
// Client is the HTTP wrapper. It owns:
|
|
// - bearer-token injection (with cache invalidation on auth failures),
|
|
// - ETag cache for REST GETs (so the second observation of the same PR
|
|
// burns a free 304 instead of a fresh payload), and
|
|
// - sentinel-error classification so callers don't switch on status codes.
|
|
type Client struct {
|
|
http *http.Client
|
|
tokens TokenSource
|
|
restBase string
|
|
graphqlURL string
|
|
userAgent string
|
|
|
|
mu sync.Mutex
|
|
etagOut map[string]string // key (method+path+query) -> last-seen ETag
|
|
bodyOut map[string][]byte // key -> last-seen body for 304 replay
|
|
cacheLRU []string // insertion-order keys for FIFO eviction
|
|
}
|
|
|
|
// cacheMaxEntries caps the number of distinct (method,path,query) tuples
|
|
// the in-memory ETag cache will track. A single Provider observes one PR
|
|
// at a time today, but the follow-up poller will reuse one Provider for
|
|
// the whole daemon — without a cap, long-running daemons would grow this
|
|
// map forever.
|
|
const cacheMaxEntries = 512
|
|
|
|
// NewClient returns a Client. It is intentionally tolerant of nil
|
|
// dependencies: production passes a TokenSource; tests sometimes leave it
|
|
// nil and supply Bearer-less fakes.
|
|
func NewClient(opts ClientOptions) *Client {
|
|
c := &Client{
|
|
http: opts.HTTPClient,
|
|
tokens: opts.Token,
|
|
restBase: opts.RESTBase,
|
|
graphqlURL: opts.GraphQLURL,
|
|
userAgent: opts.UserAgent,
|
|
etagOut: map[string]string{},
|
|
bodyOut: map[string][]byte{},
|
|
}
|
|
if c.http == nil {
|
|
c.http = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
if c.restBase == "" {
|
|
c.restBase = defaultRESTBaseURL
|
|
}
|
|
if c.graphqlURL == "" {
|
|
c.graphqlURL = defaultGraphQLURL
|
|
}
|
|
if c.userAgent == "" {
|
|
c.userAgent = defaultUserAgent
|
|
}
|
|
return c
|
|
}
|
|
|
|
// RESTResponse is what doREST returns to the Provider. NotModified=true
|
|
// means the cached body is being served; the byte slice is unchanged from
|
|
// the previous fresh fetch.
|
|
type RESTResponse struct {
|
|
StatusCode int
|
|
NotModified bool
|
|
ETag string
|
|
Body []byte
|
|
}
|
|
|
|
// doRESTWithETag performs one REST GET with an explicit caller-owned ETag.
|
|
// Unlike doREST, it does not replay cached bodies or mutate the client's
|
|
// internal compatibility cache; it exists for the provider-neutral SCM observer,
|
|
// whose ETag cache belongs to the observer orchestration layer.
|
|
func (c *Client) doRESTWithETag(ctx context.Context, path string, q url.Values, etag string) (RESTResponse, error) {
|
|
u, err := c.restURL(path, q)
|
|
if err != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: build %s URL: %w", path, err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody)
|
|
if err != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: build GET %s request: %w", path, err)
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
if etag != "" {
|
|
req.Header.Set("If-None-Match", etag)
|
|
}
|
|
if err := c.authorize(ctx, req); err != nil {
|
|
return RESTResponse{}, err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: GET %s: %w", path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode == http.StatusNotModified {
|
|
return RESTResponse{StatusCode: resp.StatusCode, NotModified: true, ETag: firstNonEmptyHeader(resp.Header.Get("ETag"), etag)}, nil
|
|
}
|
|
b, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: read %s body: %w", path, readErr)
|
|
}
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return RESTResponse{StatusCode: resp.StatusCode, ETag: resp.Header.Get("ETag"), Body: b}, nil
|
|
}
|
|
err = classifyError(resp, b)
|
|
if errors.Is(err, ErrAuthFailed) {
|
|
c.invalidateToken()
|
|
}
|
|
return RESTResponse{StatusCode: resp.StatusCode, Body: b}, err
|
|
}
|
|
|
|
// doREST performs one REST request with ETag-aware caching. The cache is
|
|
// scoped to the (method, path, query) tuple so repeated PR observations
|
|
// against the same endpoint replay from the cache while observations of a
|
|
// different PR don't share state. Only GET requests participate in the
|
|
// cache — mutating methods would mis-replay 304s as the previous payload.
|
|
func (c *Client) doREST(ctx context.Context, method, path string, q url.Values, body any) (RESTResponse, error) {
|
|
cacheable := method == http.MethodGet
|
|
cacheKey := method + " " + path + "?" + q.Encode()
|
|
var prevETag string
|
|
var prevBody []byte
|
|
if cacheable {
|
|
c.mu.Lock()
|
|
prevETag = c.etagOut[cacheKey]
|
|
prevBody = c.bodyOut[cacheKey]
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: encode %s %s body: %w", method, path, err)
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
|
|
u, err := c.restURL(path, q)
|
|
if err != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: build %s URL: %w", path, err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, u, rdr)
|
|
if err != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: build %s %s request: %w", method, path, err)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
if prevETag != "" {
|
|
req.Header.Set("If-None-Match", prevETag)
|
|
}
|
|
if err := c.authorize(ctx, req); err != nil {
|
|
return RESTResponse{}, err
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: %s %s: %w", method, path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if cacheable && resp.StatusCode == http.StatusNotModified {
|
|
// Replay the cached body. Update the ETag if GitHub returned a
|
|
// fresher one — some endpoints rotate ETags on weak revalidation.
|
|
newETag := resp.Header.Get("ETag")
|
|
if newETag != "" && newETag != prevETag {
|
|
c.mu.Lock()
|
|
c.etagOut[cacheKey] = newETag
|
|
c.mu.Unlock()
|
|
}
|
|
return RESTResponse{StatusCode: resp.StatusCode, NotModified: true, ETag: newETag, Body: prevBody}, nil
|
|
}
|
|
|
|
b, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return RESTResponse{}, fmt.Errorf("github scm: read %s body: %w", path, readErr)
|
|
}
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
etag := resp.Header.Get("ETag")
|
|
if cacheable && etag != "" {
|
|
// Defensive copy: GitHub's HTTP body is owned by net/http's
|
|
// buffer pool. Holding the raw slice in our cache would let a
|
|
// later caller mutate or alias the same backing array.
|
|
c.storeCacheEntry(cacheKey, etag, append([]byte(nil), b...))
|
|
}
|
|
return RESTResponse{StatusCode: resp.StatusCode, ETag: etag, Body: b}, nil
|
|
}
|
|
|
|
err = classifyError(resp, b)
|
|
if errors.Is(err, ErrAuthFailed) {
|
|
c.invalidateToken()
|
|
}
|
|
return RESTResponse{StatusCode: resp.StatusCode, Body: b}, err
|
|
}
|
|
|
|
// doGraphQL posts one GraphQL request and returns the decoded data map
|
|
// (the "data" field). Top-level GraphQL errors are surfaced as Go errors
|
|
// classified by the same sentinels as REST.
|
|
func (c *Client) doGraphQL(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
|
|
payload := map[string]any{"query": query}
|
|
if variables != nil {
|
|
payload["variables"] = variables
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github scm: encode graphql body: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github scm: build graphql request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
if err := c.authorize(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github scm: POST graphql: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
respBody, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("github scm: read graphql body: %w", readErr)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
err := classifyError(resp, respBody)
|
|
if errors.Is(err, ErrAuthFailed) {
|
|
c.invalidateToken()
|
|
}
|
|
return nil, err
|
|
}
|
|
var decoded struct {
|
|
Data map[string]any `json:"data"`
|
|
Errors []struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &decoded); err != nil {
|
|
return nil, fmt.Errorf("github scm: decode graphql response: %w", err)
|
|
}
|
|
if len(decoded.Errors) > 0 {
|
|
msg := decoded.Errors[0].Message
|
|
low := strings.ToLower(msg)
|
|
switch {
|
|
case strings.Contains(low, "rate limit") || strings.Contains(low, "abuse"):
|
|
return decoded.Data, &RateLimitError{Message: msg}
|
|
case strings.Contains(low, "bad credentials") || strings.Contains(low, "credentials"):
|
|
c.invalidateToken()
|
|
return decoded.Data, fmt.Errorf("%w: %s", ErrAuthFailed, msg)
|
|
case strings.Contains(low, "could not resolve") || strings.Contains(low, "not found"):
|
|
return decoded.Data, fmt.Errorf("%w: %s", ErrNotFound, msg)
|
|
default:
|
|
return decoded.Data, fmt.Errorf("github scm: graphql error: %s", msg)
|
|
}
|
|
}
|
|
return decoded.Data, nil
|
|
}
|
|
|
|
// fetchPlainText is a small REST helper used for the job-log endpoint,
|
|
// which returns text/plain rather than JSON. It does NOT participate in
|
|
// the ETag cache (logs are append-only and tiny enough that re-fetch is
|
|
// cheap; caching would just inflate memory for no win).
|
|
func (c *Client) fetchPlainText(ctx context.Context, path string) ([]byte, error) {
|
|
u, err := c.restURL(path, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github scm: build %s URL: %w", path, err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github scm: build %s request: %w", path, err)
|
|
}
|
|
// The /actions/jobs/{id}/logs endpoint validates the Accept header
|
|
// before issuing its 302 to the log blob; sending text/plain here
|
|
// gets a 406. The canonical Accept for the GitHub REST API is the
|
|
// vnd.github+json media type — the redirected blob serves the
|
|
// actual text/plain regardless of what we asked for.
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
if err := c.authorize(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github scm: GET %s: %w", path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("github scm: read %s body: %w", path, readErr)
|
|
}
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return body, nil
|
|
}
|
|
return nil, classifyError(resp, body)
|
|
}
|
|
|
|
// storeCacheEntry records one (ETag, body) pair under cacheKey and evicts
|
|
// the oldest entry once cacheMaxEntries is exceeded. FIFO is intentional:
|
|
// the access pattern is "one PR per poll cycle"; an LRU would just add
|
|
// bookkeeping without changing eviction order in practice.
|
|
func (c *Client) storeCacheEntry(cacheKey, etag string, body []byte) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if _, exists := c.etagOut[cacheKey]; !exists {
|
|
c.cacheLRU = append(c.cacheLRU, cacheKey)
|
|
}
|
|
c.etagOut[cacheKey] = etag
|
|
c.bodyOut[cacheKey] = body
|
|
for len(c.cacheLRU) > cacheMaxEntries {
|
|
evict := c.cacheLRU[0]
|
|
c.cacheLRU = c.cacheLRU[1:]
|
|
delete(c.etagOut, evict)
|
|
delete(c.bodyOut, evict)
|
|
}
|
|
}
|
|
|
|
func (c *Client) authorize(ctx context.Context, req *http.Request) error {
|
|
if c.tokens == nil {
|
|
return nil
|
|
}
|
|
token, err := c.tokens.Token(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %w", ErrAuthFailed, err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) invalidateToken() {
|
|
if inv, ok := c.tokens.(tokenInvalidator); ok {
|
|
inv.InvalidateToken()
|
|
}
|
|
}
|
|
|
|
func (c *Client) restURL(path string, q url.Values) (string, error) {
|
|
base, err := url.Parse(c.restBase)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
base.Path = strings.TrimSuffix(base.Path, "/") + path
|
|
if q != nil {
|
|
base.RawQuery = q.Encode()
|
|
}
|
|
return base.String(), nil
|
|
}
|
|
|
|
func classifyError(resp *http.Response, body []byte) error {
|
|
msg := githubMessage(body)
|
|
switch resp.StatusCode {
|
|
case http.StatusNotFound:
|
|
return fmt.Errorf("%w: %s", ErrNotFound, msg)
|
|
case http.StatusTooManyRequests:
|
|
return rateLimited(resp, msg)
|
|
case http.StatusUnauthorized:
|
|
return fmt.Errorf("%w: %s", ErrAuthFailed, msg)
|
|
case http.StatusForbidden:
|
|
// GitHub returns 403 for primary rate-limit exhaustion, for
|
|
// secondary/abuse limits, and for genuine auth/permission failures.
|
|
// Disambiguate by signal: primary limit sets X-RateLimit-Remaining=0;
|
|
// secondary/abuse sets Retry-After (often without the Remaining
|
|
// header); either case mentions "rate limit" / "abuse" in the body.
|
|
// Everything else is an auth/permission failure.
|
|
if isRateLimited(resp, msg) {
|
|
return rateLimited(resp, msg)
|
|
}
|
|
return fmt.Errorf("%w: %s", ErrAuthFailed, msg)
|
|
}
|
|
return fmt.Errorf("github scm: %d %s", resp.StatusCode, msg)
|
|
}
|
|
|
|
func isRateLimited(resp *http.Response, msg string) bool {
|
|
if rem := resp.Header.Get("X-RateLimit-Remaining"); rem != "" {
|
|
if n, err := strconv.Atoi(rem); err == nil && n == 0 {
|
|
return true
|
|
}
|
|
}
|
|
if resp.Header.Get("Retry-After") != "" {
|
|
return true
|
|
}
|
|
low := strings.ToLower(msg)
|
|
return strings.Contains(low, "rate limit") || strings.Contains(low, "abuse detection") || strings.Contains(low, "secondary rate")
|
|
}
|
|
|
|
func rateLimited(resp *http.Response, msg string) error {
|
|
e := &RateLimitError{Message: msg}
|
|
if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" {
|
|
if sec, err := strconv.ParseInt(reset, 10, 64); err == nil && sec > 0 {
|
|
e.ResetAt = time.Unix(sec, 0)
|
|
}
|
|
}
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if sec, err := strconv.Atoi(ra); err == nil && sec >= 0 {
|
|
e.RetryAfter = time.Duration(sec) * time.Second
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
|
|
func githubMessage(body []byte) string {
|
|
var p struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if json.Unmarshal(body, &p) == nil && p.Message != "" {
|
|
return p.Message
|
|
}
|
|
return strings.TrimSpace(string(body))
|
|
}
|
|
|
|
func firstNonEmptyHeader(a, b string) string {
|
|
if a != "" {
|
|
return a
|
|
}
|
|
return b
|
|
}
|