Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions backend/internal/adapters/scm/github/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,44 @@ func (s EnvTokenSource) Token(context.Context) (string, error) {
return "", ErrNoToken
}

// FallbackTokenSource tries each source in order, returning the first token. A
// source that returns ErrNoToken is skipped; other errors are remembered and
// surfaced if no later source yields a token.
type FallbackTokenSource []TokenSource

// Token returns the first non-empty token from the configured sources.
func (s FallbackTokenSource) Token(ctx context.Context) (string, error) {
var firstErr error
for _, src := range s {
if src == nil {
continue
}
tok, err := src.Token(ctx)
if err == nil {
return tok, nil
}
if errors.Is(err, ErrNoToken) {
continue
}
if firstErr == nil {
firstErr = err
}
}
if firstErr != nil {
return "", firstErr
}
return "", ErrNoToken
}

// InvalidateToken forwards cache invalidation to sources that support it.
func (s FallbackTokenSource) InvalidateToken() {
for _, src := range s {
if inv, ok := src.(tokenInvalidator); ok {
inv.InvalidateToken()
}
}
}

const defaultGHTokenCacheTTL = 5 * time.Minute

// GHTokenSource shells out to `gh auth token` when env vars are not
Expand Down
55 changes: 54 additions & 1 deletion backend/internal/adapters/scm/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"

"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

const (
Expand All @@ -25,7 +27,7 @@ const (
// errors.Is; the orchestrator's lifecycle code is intentionally insulated
// from raw HTTP status codes.
var (
ErrNotFound = errors.New("github scm: not found")
ErrNotFound = ports.ErrSCMNotFound
ErrAuthFailed = errors.New("github scm: authentication failed")
ErrRateLimited = errors.New("github scm: rate limited")
)
Expand Down Expand Up @@ -127,6 +129,50 @@ type RESTResponse struct {
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
Expand Down Expand Up @@ -434,3 +480,10 @@ func githubMessage(body []byte) string {
}
return strings.TrimSpace(string(body))
}

func firstNonEmptyHeader(a, b string) string {
if a != "" {
return a
}
return b
}
20 changes: 12 additions & 8 deletions backend/internal/adapters/scm/github/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package github observes GitHub pull requests for the PR Manager.
// Package github observes GitHub pull requests for AO's SCM integrations.
//
// The exported surface is one function:
// The compatibility exported surface is:
//
// (*Provider).Observe(ctx, prURL) (ports.PRObservation, error)
//
Expand All @@ -11,9 +11,11 @@
// GET on /repos/{o}/{r}/actions/jobs/{job_id}/logs to splice the last 20
// lines of the failed job into the observation.
//
// The poller / cadence loop is intentionally NOT in this package — it is
// a follow-up PR. This adapter is the observation primitive that loop
// will call.
// The provider-neutral SCM observer uses the same Provider for lower-level
// primitives: repo/commit ETag guards, branch-to-PR detection, GraphQL PR
// batching, failed-job log tails, and review-thread pagination. The polling
// loop itself is intentionally not in this package; it lives in
// internal/observe/scm.
//
// # State mapping
//
Expand Down Expand Up @@ -105,16 +107,18 @@
//
// # Caching
//
// The Client maintains an in-memory ETag cache per (method, path, query).
// The legacy Observe path's Client maintains an in-memory ETag cache per
// (method, path, query).
// On the second observation of the same PR the REST GET sends
// If-None-Match and replays the cached body on a 304 — GraphQL is always
// re-fetched because it doesn't expose ETag-based revalidation.
//
// The provider-neutral observer owns its own ETag cache and calls explicit
// provider guard methods that do not mutate the legacy Client cache.
//
// # Out of scope (intentionally — these are different PRs / lanes)
//
// - The poller loop and cadence selection (issue #35).
// - Webhook ingestion (this package is polling-only).
// - Persistence (PR Manager owns the row mapping; see internal/service/pr).
// - Linear / GitLab providers (separate PRs).
// - Issue tracking (separate lane, see internal/adapters/tracker).
// - Comment-injection-into-session-context (Messenger lane, not SCM).
Expand Down
Loading
Loading