514 lines
17 KiB
Go
514 lines
17 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://api.github.com"
|
|
defaultUserAgent = "ao-agent-orchestrator/tracker-github"
|
|
|
|
// Status labels used by humans (and other tooling) on GitHub Issues.
|
|
// Get's reverse mapping recognizes them so an externally-labeled issue
|
|
// reports as in_progress / review. The adapter does NOT write these
|
|
// labels in v1 — see issue #40 for the write-side work.
|
|
labelInProgress = "in-progress"
|
|
labelInReview = "in-review"
|
|
|
|
stateClosedGH = "closed"
|
|
reasonNotPlan = "not_planned"
|
|
|
|
// List pagination — GitHub's per_page maxes at 100. We default to 30
|
|
// (matching the legacy gh CLI default) when the caller passes 0.
|
|
defaultListLimit = 30
|
|
maxListLimit = 100
|
|
)
|
|
|
|
// Sentinel errors. Adapter-level callers should match on these via
|
|
// errors.Is; the orchestrator's lifecycle code is intentionally insulated
|
|
// from raw HTTP status codes.
|
|
var (
|
|
ErrNotFound = errors.New("github tracker: issue not found")
|
|
ErrRateLimited = errors.New("github tracker: rate limited")
|
|
ErrAuthFailed = errors.New("github tracker: authentication failed")
|
|
ErrWrongProvider = errors.New("github tracker: id is not a github tracker id")
|
|
ErrBadID = errors.New("github tracker: malformed native id")
|
|
)
|
|
|
|
// RateLimitError is returned when GitHub reports the request was rate-limited.
|
|
// 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
|
|
}
|
|
|
|
func (e *RateLimitError) Error() string {
|
|
if e == nil {
|
|
return ErrRateLimited.Error()
|
|
}
|
|
if e.Message != "" {
|
|
return "github tracker: rate limited: " + e.Message
|
|
}
|
|
return ErrRateLimited.Error()
|
|
}
|
|
|
|
// Is lets errors.Is match a *RateLimitError against the ErrRateLimited sentinel.
|
|
func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited }
|
|
|
|
// Options configures a Tracker. All fields except Token are optional —
|
|
// production code typically sets Token alone; tests inject HTTPClient and
|
|
// BaseURL to point at an httptest fake.
|
|
type Options struct {
|
|
Token TokenSource
|
|
HTTPClient *http.Client
|
|
BaseURL string
|
|
UserAgent string
|
|
}
|
|
|
|
// Tracker implements ports.Tracker against the GitHub REST API.
|
|
//
|
|
// Construction performs a fail-fast token presence check (no network call).
|
|
// The first Preflight call validates the token against GitHub itself; a
|
|
// successful preflight is cached for the lifetime of the Tracker so repeat
|
|
// calls are free, while failures are intentionally NOT cached so a
|
|
// transient startup glitch doesn't permanently brick the adapter.
|
|
type Tracker struct {
|
|
http *http.Client
|
|
tokens TokenSource
|
|
baseURL string
|
|
userAgent string
|
|
|
|
// preflightOK is the fast-path: once a Preflight succeeds, every
|
|
// subsequent call short-circuits via atomic.Load without touching the
|
|
// mutex. preflightMu serializes the one-time network call so concurrent
|
|
// first-callers don't all fire GET /user against GitHub.
|
|
preflightOK atomic.Bool
|
|
preflightMu sync.Mutex
|
|
}
|
|
|
|
// New returns a Tracker. It fails fast when no token can be obtained so
|
|
// daemons crash at startup rather than at first issue lookup.
|
|
func New(opts Options) (*Tracker, error) {
|
|
src := opts.Token
|
|
if src == nil {
|
|
return nil, ErrNoToken
|
|
}
|
|
if _, err := src.Token(context.Background()); err != nil {
|
|
return nil, err
|
|
}
|
|
t := &Tracker{
|
|
http: opts.HTTPClient,
|
|
tokens: src,
|
|
baseURL: opts.BaseURL,
|
|
userAgent: opts.UserAgent,
|
|
}
|
|
if t.http == nil {
|
|
t.http = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
if t.baseURL == "" {
|
|
t.baseURL = defaultBaseURL
|
|
}
|
|
if t.userAgent == "" {
|
|
t.userAgent = defaultUserAgent
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// Statically assert Tracker satisfies the port. If this stops compiling, the
|
|
// port shape changed and the adapter needs to follow.
|
|
var _ ports.Tracker = (*Tracker)(nil)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Get
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ghIssue is the subset of fields we read off the REST issue payload.
|
|
// PullRequest is present (non-nil) iff GitHub considers this row a PR —
|
|
// the /repos/{o}/{r}/issues endpoint conflates the two. List uses it to
|
|
// filter PRs out client-side so the SM never sees a PR number as an issue.
|
|
type ghIssue struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
StateReason string `json:"state_reason"`
|
|
HTMLURL string `json:"html_url"`
|
|
Labels []ghLabel `json:"labels"`
|
|
Assignees []ghUser `json:"assignees"`
|
|
PullRequest *json.RawMessage `json:"pull_request,omitempty"`
|
|
}
|
|
|
|
type ghLabel struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type ghUser struct {
|
|
Login string `json:"login"`
|
|
}
|
|
|
|
// Get fetches a single issue by id and maps it onto the normalized domain.Issue.
|
|
func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) {
|
|
owner, repo, number, err := t.parseID(id)
|
|
if err != nil {
|
|
return domain.Issue{}, err
|
|
}
|
|
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number)
|
|
|
|
resp, err := t.do(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return domain.Issue{}, err
|
|
}
|
|
var raw ghIssue
|
|
if err := json.Unmarshal(resp, &raw); err != nil {
|
|
return domain.Issue{}, fmt.Errorf("github tracker: decode issue: %w", err)
|
|
}
|
|
return issueFromGH(owner, repo, raw), nil
|
|
}
|
|
|
|
// issueFromGH projects a raw GitHub issue payload into the normalized
|
|
// domain.Issue. owner and repo are passed in because the TrackerID.Native
|
|
// shape is "owner/repo#N" and we want the returned ID to round-trip
|
|
// through the same adapter even if the original caller used a zero
|
|
// Provider.
|
|
func issueFromGH(owner, repo string, raw ghIssue) domain.Issue {
|
|
labels := make([]string, 0, len(raw.Labels))
|
|
for _, l := range raw.Labels {
|
|
labels = append(labels, l.Name)
|
|
}
|
|
assignees := make([]string, 0, len(raw.Assignees))
|
|
for _, a := range raw.Assignees {
|
|
assignees = append(assignees, a.Login)
|
|
}
|
|
out := domain.Issue{
|
|
ID: domain.TrackerID{
|
|
Provider: domain.TrackerProviderGitHub,
|
|
Native: fmt.Sprintf("%s/%s#%d", owner, repo, raw.Number),
|
|
},
|
|
Title: raw.Title,
|
|
Body: raw.Body,
|
|
State: mapStateFromGitHub(raw.State, raw.StateReason, labels),
|
|
URL: raw.HTMLURL,
|
|
Labels: labels,
|
|
Assignees: assignees,
|
|
}
|
|
if len(out.Labels) == 0 {
|
|
out.Labels = nil
|
|
}
|
|
if len(out.Assignees) == 0 {
|
|
out.Assignees = nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mapStateFromGitHub projects GitHub's open/closed + state_reason + labels
|
|
// surface onto the normalized state. "in-review" wins over "in-progress"
|
|
// when both labels are present (the workflow is progress -> review -> done).
|
|
func mapStateFromGitHub(state, reason string, labels []string) domain.NormalizedIssueState {
|
|
if strings.EqualFold(state, stateClosedGH) {
|
|
if strings.EqualFold(reason, reasonNotPlan) {
|
|
return domain.IssueCancelled
|
|
}
|
|
return domain.IssueDone
|
|
}
|
|
var hasProgress, hasReview bool
|
|
for _, l := range labels {
|
|
switch {
|
|
case strings.EqualFold(l, labelInProgress):
|
|
hasProgress = true
|
|
case strings.EqualFold(l, labelInReview):
|
|
hasReview = true
|
|
}
|
|
}
|
|
switch {
|
|
case hasReview:
|
|
return domain.IssueInReview
|
|
case hasProgress:
|
|
return domain.IssueInProgress
|
|
default:
|
|
return domain.IssueOpen
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// List returns issues for a repo, filtered by state/labels/assignee. PRs
|
|
// that GitHub's /issues endpoint conflates into the response are filtered
|
|
// out client-side. Pagination is intentionally NOT implemented in v1 —
|
|
// callers get one page bounded by ListFilter.Limit (default 30, max 100).
|
|
func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) {
|
|
if repo.Provider != domain.TrackerProviderGitHub {
|
|
return nil, fmt.Errorf("%w: provider=%q", ErrWrongProvider, repo.Provider)
|
|
}
|
|
owner, repoName, err := parseGitHubRepo(repo.Native)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
q := url.Values{}
|
|
switch filter.State {
|
|
case domain.ListOpen:
|
|
q.Set("state", "open")
|
|
case domain.ListClosed:
|
|
q.Set("state", "closed")
|
|
default:
|
|
q.Set("state", "all")
|
|
}
|
|
if len(filter.Labels) > 0 {
|
|
q.Set("labels", strings.Join(filter.Labels, ","))
|
|
}
|
|
if filter.Assignee != "" {
|
|
q.Set("assignee", filter.Assignee)
|
|
}
|
|
limit := filter.Limit
|
|
if limit <= 0 {
|
|
limit = defaultListLimit
|
|
}
|
|
if limit > maxListLimit {
|
|
limit = maxListLimit
|
|
}
|
|
q.Set("per_page", strconv.Itoa(limit))
|
|
|
|
path := fmt.Sprintf("/repos/%s/%s/issues?%s", owner, repoName, q.Encode())
|
|
resp, err := t.do(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw []ghIssue
|
|
if err := json.Unmarshal(resp, &raw); err != nil {
|
|
return nil, fmt.Errorf("github tracker: decode list: %w", err)
|
|
}
|
|
out := make([]domain.Issue, 0, len(raw))
|
|
for _, r := range raw {
|
|
if r.PullRequest != nil {
|
|
continue
|
|
}
|
|
out = append(out, issueFromGH(owner, repoName, r))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Preflight
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Preflight verifies the configured token is currently accepted by GitHub
|
|
// (one GET /user). It does NOT prove the token has the repo scope or
|
|
// visibility needed for any specific Get/List call — those may still fail
|
|
// with ErrAuthFailed even after a successful Preflight. The guarantee is
|
|
// "token exists and is valid against GitHub's identity endpoint", not
|
|
// "token can do everything the SM will ask of it." Per-repo authorization
|
|
// is detected lazily at the first Get/List against that repo.
|
|
//
|
|
// Successful checks are cached for the lifetime of the Tracker via a
|
|
// double-checked atomic+mutex pattern: the hot path is one atomic.Load
|
|
// with no contention; concurrent first-callers serialize on the mutex so
|
|
// only one GET /user is in flight. Failures are intentionally NOT cached
|
|
// so a transient startup glitch is recoverable on a subsequent call.
|
|
func (t *Tracker) Preflight(ctx context.Context) error {
|
|
if t.preflightOK.Load() {
|
|
return nil
|
|
}
|
|
t.preflightMu.Lock()
|
|
defer t.preflightMu.Unlock()
|
|
// Re-check after acquiring the lock — another goroutine may have raced
|
|
// us through the network call and stored success while we were waiting.
|
|
if t.preflightOK.Load() {
|
|
return nil
|
|
}
|
|
if _, err := t.do(ctx, http.MethodGet, "/user", nil); err != nil {
|
|
return err
|
|
}
|
|
t.preflightOK.Store(true)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP plumbing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte, error) {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github tracker: encode body: %w", err)
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, rdr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github tracker: build request: %w", 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", t.userAgent)
|
|
tok, err := t.tokens.Token(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
|
|
resp, err := t.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
respBody, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("github tracker: read response body: %w", readErr)
|
|
}
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return respBody, nil
|
|
}
|
|
return respBody, classifyError(resp, respBody)
|
|
}
|
|
|
|
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:
|
|
// 401 is unambiguously an auth failure. GitHub never uses 401 for
|
|
// rate limiting; that's always 403 or 429.
|
|
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 (token missing the
|
|
// right scope, repo not visible to this token, etc).
|
|
if isRateLimited(resp, msg) {
|
|
return rateLimited(resp, msg)
|
|
}
|
|
return fmt.Errorf("%w: %s", ErrAuthFailed, msg)
|
|
}
|
|
return fmt.Errorf("github tracker: %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")
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ID parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (t *Tracker) parseID(id domain.TrackerID) (owner, repo string, number int, err error) {
|
|
// Strict: the Session Manager picks an adapter by Provider, so reaching
|
|
// this adapter with a non-github Provider is a routing bug, not user
|
|
// input. Empty Provider is treated the same way — it would round-trip
|
|
// to an Issue whose ID can't be re-routed.
|
|
if id.Provider != domain.TrackerProviderGitHub {
|
|
return "", "", 0, fmt.Errorf("%w: provider=%q", ErrWrongProvider, id.Provider)
|
|
}
|
|
return parseGitHubID(id.Native)
|
|
}
|
|
|
|
// parseGitHubID accepts "owner/repo#NUM" and returns the three components.
|
|
// Forms like "owner/repo/issues/NUM" or bare numbers are intentionally
|
|
// rejected so the rest of the system has one canonical id shape.
|
|
func parseGitHubID(native string) (owner, repo string, number int, err error) {
|
|
hash := strings.IndexByte(native, '#')
|
|
if hash < 0 {
|
|
return "", "", 0, fmt.Errorf("%w: missing #issue", ErrBadID)
|
|
}
|
|
repoPart := native[:hash]
|
|
numPart := native[hash+1:]
|
|
owner, repo, err = parseGitHubRepo(repoPart)
|
|
if err != nil {
|
|
return "", "", 0, err
|
|
}
|
|
n, parseErr := strconv.Atoi(numPart)
|
|
if parseErr != nil || n <= 0 {
|
|
return "", "", 0, fmt.Errorf("%w: bad issue number %q", ErrBadID, numPart)
|
|
}
|
|
return owner, repo, n, nil
|
|
}
|
|
|
|
// parseGitHubRepo accepts "owner/repo" and rejects empty segments,
|
|
// embedded slashes, "#", and whitespace. Leading dots are kept legal —
|
|
// "owner/.github" is a real GitHub convention for repo-level config repos.
|
|
func parseGitHubRepo(native string) (owner, repo string, err error) {
|
|
if native == "" {
|
|
return "", "", fmt.Errorf("%w: empty repo", ErrBadID)
|
|
}
|
|
slash := strings.IndexByte(native, '/')
|
|
if slash < 0 {
|
|
return "", "", fmt.Errorf("%w: missing owner/repo separator", ErrBadID)
|
|
}
|
|
owner = native[:slash]
|
|
repo = native[slash+1:]
|
|
if owner == "" || repo == "" {
|
|
return "", "", fmt.Errorf("%w: empty owner or repo segment", ErrBadID)
|
|
}
|
|
if strings.ContainsAny(owner, "/# \t\n\r") {
|
|
return "", "", fmt.Errorf("%w: invalid owner segment %q", ErrBadID, owner)
|
|
}
|
|
if strings.ContainsAny(repo, "/# \t\n\r") {
|
|
return "", "", fmt.Errorf("%w: invalid repo segment %q", ErrBadID, repo)
|
|
}
|
|
return owner, repo, nil
|
|
}
|