@@ -3,12 +3,15 @@ package daemon
33import (
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.
2326func 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+
2873type 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+ }
0 commit comments