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 }