Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
154 changes: 117 additions & 37 deletions backend/internal/adapters/tracker/github/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
Expand Down Expand Up @@ -88,10 +87,9 @@ type Options struct {
// 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.
// The first AuthenticatedUser or Preflight call validates the token against
// GitHub and caches the returned identity. Failures are intentionally not
// cached so a transient startup glitch remains recoverable.
type Tracker struct {
http *http.Client
tokens TokenSource
Expand All @@ -103,13 +101,12 @@ type Tracker struct {
// eviction is needed here.
listCacheMu sync.Mutex
listCache map[string]listCacheEntry
labelCache map[string]labelCacheEntry

// 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
// identityMu serializes the one-time GET /user call and protects the cached
// identity so the observer and API can share one adapter safely.
identityMu sync.Mutex
authenticatedUser *domain.TrackerUser
}

type listCacheEntry struct {
Expand All @@ -118,6 +115,12 @@ type listCacheEntry struct {
nextPath string
}

type labelCacheEntry struct {
etag string
labels []domain.TrackerLabel
nextPath string
}

// 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) {
Expand All @@ -129,11 +132,12 @@ func New(opts Options) (*Tracker, error) {
return nil, err
}
t := &Tracker{
http: opts.HTTPClient,
tokens: src,
baseURL: opts.BaseURL,
userAgent: opts.UserAgent,
listCache: map[string]listCacheEntry{},
http: opts.HTTPClient,
tokens: src,
baseURL: opts.BaseURL,
userAgent: opts.UserAgent,
listCache: map[string]listCacheEntry{},
labelCache: map[string]labelCacheEntry{},
}
if t.http == nil {
t.http = &http.Client{Timeout: 30 * time.Second}
Expand Down Expand Up @@ -172,7 +176,9 @@ type ghIssue struct {
}

type ghLabel struct {
Name string `json:"name"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
}

type ghUser struct {
Expand Down Expand Up @@ -389,10 +395,101 @@ func cloneIssues(in []domain.Issue) []domain.Issue {
return out
}

// ListLabels returns every label configured on a GitHub repository. Each page
// is conditionally revalidated with its ETag so callers can refresh cheaply.
func (t *Tracker) ListLabels(ctx context.Context, repo domain.TrackerRepo) ([]domain.TrackerLabel, 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
}
path := fmt.Sprintf("/repos/%s/%s/labels?per_page=%d", owner, repoName, listPageSize)
labels := make([]domain.TrackerLabel, 0)
for page := 0; path != ""; page++ {
if page >= maxListPages {
return nil, fmt.Errorf("github tracker: label pagination exceeded %d pages", maxListPages)
}
t.listCacheMu.Lock()
cached, hasCached := t.labelCache[path]
t.listCacheMu.Unlock()

resp, etag, nextPath, notModified, err := t.roundTrip(ctx, http.MethodGet, path, nil, cached.etag)
if err != nil {
return nil, err
}
if notModified {
if !hasCached {
return nil, fmt.Errorf("github tracker: unexpected 304 for uncached labels")
}
labels = append(labels, cloneLabels(cached.labels)...)
path = cached.nextPath
continue
}
var raw []ghLabel
if err := json.Unmarshal(resp, &raw); err != nil {
return nil, fmt.Errorf("github tracker: decode labels: %w", err)
}
pageLabels := make([]domain.TrackerLabel, 0, len(raw))
for _, label := range raw {
if strings.TrimSpace(label.Name) == "" {
continue
}
pageLabels = append(pageLabels, domain.TrackerLabel{
Name: label.Name,
Color: label.Color,
Description: label.Description,
})
}
t.listCacheMu.Lock()
if etag == "" {
delete(t.labelCache, path)
} else {
t.labelCache[path] = labelCacheEntry{etag: etag, labels: cloneLabels(pageLabels), nextPath: nextPath}
}
t.listCacheMu.Unlock()
labels = append(labels, pageLabels...)
path = nextPath
}
return labels, nil
}

func cloneLabels(in []domain.TrackerLabel) []domain.TrackerLabel {
out := make([]domain.TrackerLabel, len(in))
copy(out, in)
return out
}

// ---------------------------------------------------------------------------
// Preflight
// ---------------------------------------------------------------------------

// AuthenticatedUser returns the login accepted by GitHub's GET /user endpoint.
// Successful checks are cached for the lifetime of the Tracker. Failures are
// not cached so callers can recover from transient GitHub errors.
func (t *Tracker) AuthenticatedUser(ctx context.Context) (domain.TrackerUser, error) {
t.identityMu.Lock()
defer t.identityMu.Unlock()
if t.authenticatedUser != nil {
return *t.authenticatedUser, nil
}
body, err := t.do(ctx, http.MethodGet, "/user", nil)
if err != nil {
return domain.TrackerUser{}, err
}
var user domain.TrackerUser
if err := json.Unmarshal(body, &user); err != nil {
return domain.TrackerUser{}, fmt.Errorf("github tracker: decode authenticated user: %w", err)
}
user.Login = strings.TrimSpace(user.Login)
if user.Login == "" {
return domain.TrackerUser{}, errors.New("github tracker: authenticated user response has no login")
}
t.authenticatedUser = &user
return user, nil
}

// 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
Expand All @@ -401,27 +498,10 @@ func cloneIssues(in []domain.Issue) []domain.Issue {
// "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.
// Successful checks share the cached identity used by AuthenticatedUser.
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
_, err := t.AuthenticatedUser(ctx)
return err
}

// ---------------------------------------------------------------------------
Expand Down
71 changes: 71 additions & 0 deletions backend/internal/adapters/tracker/github/tracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,37 @@ func TestPreflight_HappyPath(t *testing.T) {
}
}

func TestAuthenticatedUser_ReturnsAndCachesLogin(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/user", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"login":"octocat","id":1}`))
})
tr := newTrackerForTest(t, f)
for i := 0; i < 2; i++ {
user, err := tr.AuthenticatedUser(ctx())
if err != nil {
t.Fatalf("AuthenticatedUser #%d: %v", i, err)
}
if user.Login != "octocat" {
t.Fatalf("AuthenticatedUser #%d login = %q, want octocat", i, user.Login)
}
}
if got := len(f.calls()); got != 1 {
t.Fatalf("HTTP calls = %d, want 1 (identity should be cached)", got)
}
}

func TestAuthenticatedUser_RejectsEmptyLogin(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/user", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"id":1}`))
})
tr := newTrackerForTest(t, f)
if _, err := tr.AuthenticatedUser(ctx()); err == nil {
t.Fatal("AuthenticatedUser expected empty-login response to fail")
}
}

func TestPreflight_InvalidToken(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/user", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -542,6 +573,46 @@ func TestList_PaginatesAcrossLinkNext(t *testing.T) {
}
}

func TestListLabels_PaginatesAndRevalidatesWithETag(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/repos/o/r/labels", func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("per_page"); got != "100" {
t.Fatalf("per_page = %q, want 100", got)
}
page := r.URL.Query().Get("page")
etag := `"labels-` + page + `"`
if r.Header.Get("If-None-Match") == etag {
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
switch page {
case "":
w.Header().Set("Link", `<`+f.server.URL+`/repos/o/r/labels?per_page=100&page=2>; rel="next"`)
_, _ = w.Write([]byte(`[{"name":"bug","color":"d73a4a","description":"Something is broken"}]`))
case "2":
_, _ = w.Write([]byte(`[{"name":"ready","color":"4d8dff","description":"Ready for an agent"}]`))
default:
t.Fatalf("unexpected page %q", page)
}
})
tr := newTrackerForTest(t, f)
repo := domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"}
for i := 0; i < 2; i++ {
labels, err := tr.ListLabels(ctx(), repo)
if err != nil {
t.Fatalf("ListLabels #%d: %v", i, err)
}
if len(labels) != 2 || labels[0].Name != "bug" || labels[1].Description != "Ready for an agent" {
t.Fatalf("labels #%d = %#v", i, labels)
}
}
if got := len(f.calls()); got != 4 {
t.Fatalf("HTTP calls = %d, want 4", got)
}
}

func TestList_ConditionalRevalidationContinuesCachedPageChain(t *testing.T) {
f := newFakeGH(t)
pageCalls := map[string]int{}
Expand Down
16 changes: 8 additions & 8 deletions backend/internal/cli/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ type roleOverride struct {

// trackerIntakeConfig mirrors domain.TrackerIntakeConfig.
type trackerIntakeConfig struct {
Enabled bool `json:"enabled,omitempty"`
Provider string `json:"provider,omitempty"`
Repo string `json:"repo,omitempty"`
Assignee string `json:"assignee,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Provider string `json:"provider,omitempty"`
Repo string `json:"repo,omitempty"`
Labels []string `json:"labels,omitempty"`
}

// projectConfig mirrors the daemon's typed domain.ProjectConfig for the CLI
Expand Down Expand Up @@ -126,7 +126,7 @@ type projectSetConfigOptions struct {
postCreate []string
trackerIntake bool
trackerRepo string
trackerAssignee string
trackerLabels []string
configJSON string
clear bool
json bool
Expand Down Expand Up @@ -314,7 +314,7 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command {
f.StringArrayVar(&opts.postCreate, "post-create", nil, "Command to run after workspace creation (repeatable)")
f.BoolVar(&opts.trackerIntake, "tracker-intake", false, "Enable GitHub issue intake for matching issues")
f.StringVar(&opts.trackerRepo, "tracker-repo", "", "GitHub repo for issue intake (owner/repo; default: derive from git origin)")
f.StringVar(&opts.trackerAssignee, "tracker-assignee", "", "GitHub issue assignee required for intake eligibility")
f.StringArrayVar(&opts.trackerLabels, "tracker-label", nil, "GitHub label required for issue intake (repeatable)")
f.StringVar(&opts.configJSON, "config-json", "", "Full config as a JSON object (overrides field flags)")
f.BoolVar(&opts.clear, "clear", false, "Clear all config")
f.BoolVar(&opts.json, "json", false, "Output the updated project as JSON")
Expand Down Expand Up @@ -354,7 +354,7 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) {
Enabled: opts.trackerIntake,
Provider: trackerProviderForFlags(opts),
Repo: opts.trackerRepo,
Assignee: opts.trackerAssignee,
Labels: opts.trackerLabels,
},
}
if reflect.DeepEqual(cfg, projectConfig{}) {
Expand All @@ -364,7 +364,7 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) {
}

func trackerProviderForFlags(opts projectSetConfigOptions) string {
if opts.trackerIntake || opts.trackerRepo != "" || opts.trackerAssignee != "" {
if opts.trackerIntake || opts.trackerRepo != "" || len(opts.trackerLabels) > 0 {
return "github"
}
return ""
Expand Down
Loading
Loading