Skip to content

Commit d3e5fd9

Browse files
anirudh5harmaclaude
andcommitted
feat(tracker): route intake observer per project's provider
Replace the observer's single ports.Tracker with a TrackerResolver interface so each project's poll is dispatched to the adapter that matches its configured provider. SingleTrackerResolver lets tests and single-provider deployments keep using one adapter without constructing a map. trackerRepo() now computes the per-provider scope: - github: configured Repo, falling back to the parsed origin URL - linear: configured Team key - jira: configured ProjectKey + BaseURL (passed via TrackerRepo.BaseURL) The daemon wires a multiTrackerResolver that owns three lazy adapters, constructed only on first use so projects that don't configure a given provider pay no auth or network cost. GitHub keeps the existing AO_GITHUB_TOKEN + `gh auth token` precedence; Linear reads AO_LINEAR_TOKEN; Jira reads AO_JIRA_EMAIL + AO_JIRA_TOKEN. The `seen` map no longer indexes by bare native ID. With three providers in play, native IDs collide (Linear "ENG-1" vs Jira "ENG-1"), so dedupe runs only on the canonical "<provider>:<native>" form. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2f9f37c commit d3e5fd9

3 files changed

Lines changed: 300 additions & 28 deletions

File tree

backend/internal/daemon/tracker_intake_wiring.go

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package daemon
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"log/slog"
78
"strings"
89
"sync"
910
"time"
1011

1112
trackergithub "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/github"
13+
trackerjira "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/jira"
14+
trackerlinear "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/linear"
1215
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
1316
trackerintake "github.com/aoagents/agent-orchestrator/backend/internal/observe/trackerintake"
1417
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
@@ -17,14 +20,56 @@ import (
1720
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
1821
)
1922

20-
// startTrackerIntake wires the opt-in issue-intake loop. The tracker itself is
21-
// lazy so daemon startup and projects without intake enabled do not pay an auth
22-
// or network cost.
23+
// startTrackerIntake wires the opt-in issue-intake loop. Each provider's
24+
// adapter is constructed lazily on first use so daemon startup and projects
25+
// that don't use a given provider do not pay an auth or network cost.
2326
func startTrackerIntake(ctx context.Context, store *sqlite.Store, sessions *sessionsvc.Service, logger *slog.Logger) <-chan struct{} {
24-
observer := trackerintake.New(newLazyGitHubTracker(logger), store, sessions, trackerintake.Config{Logger: logger})
27+
resolver := newMultiTrackerResolver(logger)
28+
observer := trackerintake.New(resolver, store, sessions, trackerintake.Config{Logger: logger})
2529
return observer.Start(ctx)
2630
}
2731

32+
// multiTrackerResolver routes per project's configured provider to a lazy
33+
// adapter. It implements trackerintake.TrackerResolver.
34+
type multiTrackerResolver struct {
35+
logger *slog.Logger
36+
37+
githubOnce sync.Once
38+
github *lazyGitHubTracker
39+
40+
linearOnce sync.Once
41+
linear *lazyLinearTracker
42+
43+
jiraOnce sync.Once
44+
jira *lazyJiraTracker
45+
}
46+
47+
func newMultiTrackerResolver(logger *slog.Logger) *multiTrackerResolver {
48+
return &multiTrackerResolver{logger: logger}
49+
}
50+
51+
// Tracker returns the lazy adapter for the configured provider, constructing
52+
// it on first request.
53+
func (r *multiTrackerResolver) Tracker(provider domain.TrackerProvider) (ports.Tracker, error) {
54+
switch provider {
55+
case "", domain.TrackerProviderGitHub:
56+
r.githubOnce.Do(func() { r.github = newLazyGitHubTracker(r.logger) })
57+
return r.github, nil
58+
case domain.TrackerProviderLinear:
59+
r.linearOnce.Do(func() { r.linear = newLazyLinearTracker(r.logger) })
60+
return r.linear, nil
61+
case domain.TrackerProviderJira:
62+
r.jiraOnce.Do(func() { r.jira = newLazyJiraTracker(r.logger) })
63+
return r.jira, nil
64+
default:
65+
return nil, fmt.Errorf("tracker intake: unknown provider %q", provider)
66+
}
67+
}
68+
69+
// ---------------------------------------------------------------------------
70+
// GitHub lazy adapter (token sourced from env or gh CLI fallback)
71+
// ---------------------------------------------------------------------------
72+
2873
type lazyGitHubTracker struct {
2974
logger *slog.Logger
3075
tokens *trackerTokenSource
@@ -118,3 +163,118 @@ func (s *trackerTokenSource) Token(ctx context.Context) (string, error) {
118163
s.expiresAt = now.Add(trackerTokenCacheTTL)
119164
return token, nil
120165
}
166+
167+
// ---------------------------------------------------------------------------
168+
// Linear lazy adapter (env-only token)
169+
// ---------------------------------------------------------------------------
170+
171+
type lazyLinearTracker struct {
172+
logger *slog.Logger
173+
mu sync.Mutex
174+
tracker ports.Tracker
175+
}
176+
177+
func newLazyLinearTracker(logger *slog.Logger) *lazyLinearTracker {
178+
return &lazyLinearTracker{logger: logger}
179+
}
180+
181+
func (t *lazyLinearTracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) {
182+
tracker, err := t.resolve()
183+
if err != nil {
184+
return domain.Issue{}, err
185+
}
186+
return tracker.Get(ctx, id)
187+
}
188+
189+
func (t *lazyLinearTracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) {
190+
tracker, err := t.resolve()
191+
if err != nil {
192+
return nil, err
193+
}
194+
return tracker.List(ctx, repo, filter)
195+
}
196+
197+
func (t *lazyLinearTracker) Preflight(ctx context.Context) error {
198+
tracker, err := t.resolve()
199+
if err != nil {
200+
return err
201+
}
202+
return tracker.Preflight(ctx)
203+
}
204+
205+
func (t *lazyLinearTracker) resolve() (ports.Tracker, error) {
206+
t.mu.Lock()
207+
defer t.mu.Unlock()
208+
if t.tracker != nil {
209+
return t.tracker, nil
210+
}
211+
tokens := trackerlinear.EnvTokenSource{EnvVars: []string{"AO_LINEAR_TOKEN"}}
212+
tracker, err := trackerlinear.New(trackerlinear.Options{Token: tokens})
213+
if err != nil {
214+
if errors.Is(err, trackerlinear.ErrNoToken) {
215+
t.logger.Warn("tracker intake disabled: no usable Linear token", "err", err)
216+
}
217+
return nil, err
218+
}
219+
t.tracker = tracker
220+
return tracker, nil
221+
}
222+
223+
// ---------------------------------------------------------------------------
224+
// Jira lazy adapter (env-only email + token)
225+
// ---------------------------------------------------------------------------
226+
227+
type lazyJiraTracker struct {
228+
logger *slog.Logger
229+
mu sync.Mutex
230+
tracker ports.Tracker
231+
}
232+
233+
func newLazyJiraTracker(logger *slog.Logger) *lazyJiraTracker {
234+
return &lazyJiraTracker{logger: logger}
235+
}
236+
237+
func (t *lazyJiraTracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) {
238+
tracker, err := t.resolve()
239+
if err != nil {
240+
return domain.Issue{}, err
241+
}
242+
return tracker.Get(ctx, id)
243+
}
244+
245+
func (t *lazyJiraTracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) {
246+
tracker, err := t.resolve()
247+
if err != nil {
248+
return nil, err
249+
}
250+
return tracker.List(ctx, repo, filter)
251+
}
252+
253+
func (t *lazyJiraTracker) Preflight(ctx context.Context) error {
254+
tracker, err := t.resolve()
255+
if err != nil {
256+
return err
257+
}
258+
return tracker.Preflight(ctx)
259+
}
260+
261+
func (t *lazyJiraTracker) resolve() (ports.Tracker, error) {
262+
t.mu.Lock()
263+
defer t.mu.Unlock()
264+
if t.tracker != nil {
265+
return t.tracker, nil
266+
}
267+
creds := trackerjira.EnvCredentials{
268+
EmailVars: []string{"AO_JIRA_EMAIL"},
269+
TokenVars: []string{"AO_JIRA_TOKEN"},
270+
}
271+
tracker, err := trackerjira.New(trackerjira.Options{Credentials: creds})
272+
if err != nil {
273+
if errors.Is(err, trackerjira.ErrNoCredentials) {
274+
t.logger.Warn("tracker intake disabled: no usable Jira credentials", "err", err)
275+
}
276+
return nil, err
277+
}
278+
t.tracker = tracker
279+
return tracker, nil
280+
}

backend/internal/observe/trackerintake/observer.go

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ type Spawner interface {
4242
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error)
4343
}
4444

45+
// TrackerResolver picks the right tracker adapter for a project's configured
46+
// provider. The daemon wires a concrete resolver that holds a lazy adapter per
47+
// provider; tests use SingleTrackerResolver.
48+
type TrackerResolver interface {
49+
Tracker(provider domain.TrackerProvider) (ports.Tracker, error)
50+
}
51+
52+
// SingleTrackerResolver returns the same tracker for one specific provider and
53+
// refuses every other provider. It exists so tests (and single-provider
54+
// deployments) don't need to construct a map.
55+
type SingleTrackerResolver struct {
56+
Provider domain.TrackerProvider
57+
Adapter ports.Tracker
58+
}
59+
60+
// Tracker returns the wrapped adapter when the requested provider matches, or
61+
// when the resolver was constructed without a provider pin.
62+
func (s SingleTrackerResolver) Tracker(provider domain.TrackerProvider) (ports.Tracker, error) {
63+
if s.Adapter == nil {
64+
return nil, fmt.Errorf("tracker intake: no adapter for provider %q", provider)
65+
}
66+
if s.Provider == "" || provider == "" || provider == s.Provider {
67+
return s.Adapter, nil
68+
}
69+
return nil, fmt.Errorf("tracker intake: no adapter for provider %q", provider)
70+
}
71+
4572
// Config holds optional observer knobs. Zero values use production defaults.
4673
type Config struct {
4774
Tick time.Duration
@@ -52,7 +79,7 @@ type Config struct {
5279

5380
// Observer polls configured projects and starts sessions for eligible issues.
5481
type Observer struct {
55-
tracker ports.Tracker
82+
resolver TrackerResolver
5683
store Store
5784
spawner Spawner
5885
tick time.Duration
@@ -63,8 +90,8 @@ type Observer struct {
6390
}
6491

6592
// New constructs an Observer with safe defaults.
66-
func New(tracker ports.Tracker, store Store, spawner Spawner, cfg Config) *Observer {
67-
o := &Observer{tracker: tracker, store: store, spawner: spawner, tick: cfg.Tick, failureBackoff: cfg.FailureBackoff, clock: cfg.Clock, logger: cfg.Logger, backoffUntil: map[string]time.Time{}}
93+
func New(resolver TrackerResolver, store Store, spawner Spawner, cfg Config) *Observer {
94+
o := &Observer{resolver: resolver, store: store, spawner: spawner, tick: cfg.Tick, failureBackoff: cfg.FailureBackoff, clock: cfg.Clock, logger: cfg.Logger, backoffUntil: map[string]time.Time{}}
6895
if o.tick <= 0 {
6996
o.tick = DefaultTickInterval
7097
}
@@ -94,7 +121,7 @@ func (o *Observer) Poll(ctx context.Context) error {
94121
if err := ctx.Err(); err != nil {
95122
return err
96123
}
97-
if o.tracker == nil || o.store == nil || o.spawner == nil {
124+
if o.resolver == nil || o.store == nil || o.spawner == nil {
98125
return nil
99126
}
100127
now := o.clock().UTC()
@@ -146,10 +173,15 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord
146173
}
147174
repo, ok := trackerRepo(project, cfg)
148175
if !ok {
149-
o.logger.Warn("tracker intake: skipping project without tracker repo", "project", project.ID, "origin", project.RepoOriginURL)
176+
o.logger.Warn("tracker intake: skipping project without tracker scope", "project", project.ID, "provider", cfg.Provider, "origin", project.RepoOriginURL)
150177
return true
151178
}
152-
issues, err := o.tracker.List(ctx, repo, domain.ListFilter{
179+
tracker, err := o.resolver.Tracker(cfg.Provider)
180+
if err != nil {
181+
o.logger.Warn("tracker intake: no adapter for provider", "project", project.ID, "provider", cfg.Provider, "err", err)
182+
return true
183+
}
184+
issues, err := tracker.List(ctx, repo, domain.ListFilter{
153185
State: domain.ListOpen,
154186
Labels: cfg.Labels,
155187
Assignee: cfg.Assignee,
@@ -171,7 +203,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord
171203
continue
172204
}
173205
issueID := CanonicalIssueID(issue.ID)
174-
if issueID == "" || seen[issueID] || seen[domain.IssueID(issue.ID.Native)] {
206+
if issueID == "" || seen[issueID] {
175207
continue
176208
}
177209
if _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{
@@ -185,9 +217,6 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord
185217
continue
186218
}
187219
seen[issueID] = true
188-
if issue.ID.Native != "" {
189-
seen[domain.IssueID(issue.ID.Native)] = true
190-
}
191220
}
192221
return spawnFailed
193222
}
@@ -303,14 +332,32 @@ func trackerRepo(project domain.ProjectRecord, cfg domain.TrackerIntakeConfig) (
303332
if provider == "" {
304333
provider = domain.TrackerProviderGitHub
305334
}
306-
native := strings.TrimSpace(cfg.Repo)
307-
if native == "" {
308-
native = parseGitHubRepoNative(project.RepoOriginURL)
309-
}
310-
if provider != domain.TrackerProviderGitHub || native == "" {
335+
switch provider {
336+
case domain.TrackerProviderGitHub:
337+
native := strings.TrimSpace(cfg.Repo)
338+
if native == "" {
339+
native = parseGitHubRepoNative(project.RepoOriginURL)
340+
}
341+
if native == "" {
342+
return domain.TrackerRepo{}, false
343+
}
344+
return domain.TrackerRepo{Provider: provider, Native: native}, true
345+
case domain.TrackerProviderLinear:
346+
team := strings.TrimSpace(cfg.Team)
347+
if team == "" {
348+
return domain.TrackerRepo{}, false
349+
}
350+
return domain.TrackerRepo{Provider: provider, Native: team}, true
351+
case domain.TrackerProviderJira:
352+
projectKey := strings.TrimSpace(cfg.ProjectKey)
353+
base := strings.TrimSpace(cfg.BaseURL)
354+
if projectKey == "" || base == "" {
355+
return domain.TrackerRepo{}, false
356+
}
357+
return domain.TrackerRepo{Provider: provider, Native: projectKey, BaseURL: base}, true
358+
default:
311359
return domain.TrackerRepo{}, false
312360
}
313-
return domain.TrackerRepo{Provider: provider, Native: native}, true
314361
}
315362

316363
func parseGitHubRepoNative(remote string) string {

0 commit comments

Comments
 (0)