From 6cca695fb83705155f7dc6ba9f4835ec5727cdf3 Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Tue, 7 Jul 2026 21:58:22 +0530 Subject: [PATCH 1/7] feat: bind intake to GitHub user --- .../adapters/tracker/github/tracker.go | 66 ++++++++++--------- .../adapters/tracker/github/tracker_test.go | 31 +++++++++ backend/internal/cli/project.go | 6 +- backend/internal/cli/project_test.go | 15 ++--- backend/internal/daemon/daemon.go | 5 +- .../internal/daemon/tracker_intake_wiring.go | 12 +++- backend/internal/daemon/wiring_test.go | 2 +- backend/internal/domain/projectconfig_test.go | 12 ++-- backend/internal/domain/tracker.go | 19 ++---- backend/internal/httpd/api.go | 5 ++ backend/internal/httpd/apispec/openapi.yaml | 34 +++++++++- .../internal/httpd/apispec/specgen/build.go | 14 ++++ backend/internal/httpd/controllers/dto.go | 5 ++ .../httpd/controllers/tracker_intake.go | 34 ++++++++++ .../httpd/controllers/tracker_intake_test.go | 41 ++++++++++++ .../observe/trackerintake/observer.go | 23 ++----- .../observe/trackerintake/observer_test.go | 51 ++++++-------- backend/internal/ports/tracker.go | 2 + .../internal/service/trackerintake/service.go | 45 +++++++++++++ .../service/trackerintake/service_test.go | 39 +++++++++++ frontend/src/api/schema.ts | 59 ++++++++++++++++- .../CreateProjectAgentSheet.test.tsx | 12 ++-- .../components/CreateProjectAgentSheet.tsx | 8 +-- .../src/renderer/components/IntakeFields.tsx | 32 ++++----- .../components/ProjectSettingsForm.test.tsx | 64 +++++------------- .../components/ProjectSettingsForm.tsx | 10 +-- .../hooks/useTrackerIntakeIdentity.ts | 24 +++++++ frontend/src/renderer/lib/api-client.ts | 1 + 28 files changed, 462 insertions(+), 209 deletions(-) create mode 100644 backend/internal/httpd/controllers/tracker_intake.go create mode 100644 backend/internal/httpd/controllers/tracker_intake_test.go create mode 100644 backend/internal/service/trackerintake/service.go create mode 100644 backend/internal/service/trackerintake/service_test.go create mode 100644 frontend/src/renderer/hooks/useTrackerIntakeIdentity.ts diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index df0524a0fe..f502fe8544 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -12,7 +12,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -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 @@ -104,12 +102,10 @@ type Tracker struct { listCacheMu sync.Mutex listCache map[string]listCacheEntry - // 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 { @@ -393,6 +389,31 @@ func cloneIssues(in []domain.Issue) []domain.Issue { // 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 @@ -401,27 +422,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 } // --------------------------------------------------------------------------- diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go index 23fae915aa..aa3612ec7f 100644 --- a/backend/internal/adapters/tracker/github/tracker_test.go +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -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) { diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index 08e9a74397..f0b541776e 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -90,7 +90,6 @@ type trackerIntakeConfig struct { Enabled bool `json:"enabled,omitempty"` Provider string `json:"provider,omitempty"` Repo string `json:"repo,omitempty"` - Assignee string `json:"assignee,omitempty"` } // projectConfig mirrors the daemon's typed domain.ProjectConfig for the CLI @@ -126,7 +125,6 @@ type projectSetConfigOptions struct { postCreate []string trackerIntake bool trackerRepo string - trackerAssignee string configJSON string clear bool json bool @@ -314,7 +312,6 @@ 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.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") @@ -354,7 +351,6 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { Enabled: opts.trackerIntake, Provider: trackerProviderForFlags(opts), Repo: opts.trackerRepo, - Assignee: opts.trackerAssignee, }, } if reflect.DeepEqual(cfg, projectConfig{}) { @@ -364,7 +360,7 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { } func trackerProviderForFlags(opts projectSetConfigOptions) string { - if opts.trackerIntake || opts.trackerRepo != "" || opts.trackerAssignee != "" { + if opts.trackerIntake || opts.trackerRepo != "" { return "github" } return "" diff --git a/backend/internal/cli/project_test.go b/backend/internal/cli/project_test.go index 2099d5ad59..a8493cd8ba 100644 --- a/backend/internal/cli/project_test.go +++ b/backend/internal/cli/project_test.go @@ -41,7 +41,7 @@ func TestProjectSetConfig_TrackerIntakeFlags(t *testing.T) { _, errOut, err := executeCLI(t, Deps{ ProcessAlive: func(int) bool { return true }, - }, "project", "set-config", "demo", "--tracker-intake", "--tracker-repo", "acme/demo", "--tracker-assignee", "alice") + }, "project", "set-config", "demo", "--tracker-intake", "--tracker-repo", "acme/demo") if err != nil { t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) } @@ -52,7 +52,7 @@ func TestProjectSetConfig_TrackerIntakeFlags(t *testing.T) { if err := json.Unmarshal(capture.body, &got); err != nil { t.Fatalf("decode request: %v\nbody=%s", err, capture.body) } - if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" || got.Config.TrackerIntake.Repo != "acme/demo" || got.Config.TrackerIntake.Assignee != "alice" { + if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" || got.Config.TrackerIntake.Repo != "acme/demo" { t.Fatalf("tracker intake request = %#v", got.Config.TrackerIntake) } } @@ -64,7 +64,7 @@ func TestProjectSetConfig_TrackerIntakeJSON(t *testing.T) { _, errOut, err := executeCLI(t, Deps{ ProcessAlive: func(int) bool { return true }, - }, "project", "set-config", "demo", "--config-json", `{"trackerIntake":{"enabled":true,"provider":"github","assignee":"alice"}}`) + }, "project", "set-config", "demo", "--config-json", `{"trackerIntake":{"enabled":true,"provider":"github"}}`) if err != nil { t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) } @@ -72,21 +72,20 @@ func TestProjectSetConfig_TrackerIntakeJSON(t *testing.T) { if err := json.Unmarshal(capture.body, &got); err != nil { t.Fatalf("decode request: %v\nbody=%s", err, capture.body) } - if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" || got.Config.TrackerIntake.Assignee != "alice" { + if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" { t.Fatalf("tracker intake request = %#v", got.Config.TrackerIntake) } } func TestBuildProjectConfigTrackerIntakeFlags(t *testing.T) { got, err := buildProjectConfig(projectSetConfigOptions{ - trackerIntake: true, - trackerRepo: "acme/demo", - trackerAssignee: "alice", + trackerIntake: true, + trackerRepo: "acme/demo", }) if err != nil { t.Fatal(err) } - if !got.TrackerIntake.Enabled || got.TrackerIntake.Provider != "github" || got.TrackerIntake.Repo != "acme/demo" || got.TrackerIntake.Assignee != "alice" { + if !got.TrackerIntake.Enabled || got.TrackerIntake.Provider != "github" || got.TrackerIntake.Repo != "acme/demo" { t.Fatalf("tracker intake config = %#v", got.TrackerIntake) } } diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 0e31f06399..6be6cf3530 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -26,6 +26,7 @@ import ( importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + trackerintakesvc "github.com/aoagents/agent-orchestrator/backend/internal/service/trackerintake" "github.com/aoagents/agent-orchestrator/backend/internal/skillassets" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" @@ -130,7 +131,8 @@ func Run() error { } return fmt.Errorf("wire session service: %w", err) } - lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log) + githubTracker := newLazyGitHubTracker(log) + lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, githubTracker, log) previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) agentSvc := agentsvc.New() go func() { @@ -151,6 +153,7 @@ func Run() error { Events: cdcPipe.Broadcaster, Activity: lcStack.LCM, Telemetry: telemetrySink, + TrackerIntake: trackerintakesvc.New(githubTracker), }) if err != nil { stop() diff --git a/backend/internal/daemon/tracker_intake_wiring.go b/backend/internal/daemon/tracker_intake_wiring.go index cfdd155766..2dc6566c4c 100644 --- a/backend/internal/daemon/tracker_intake_wiring.go +++ b/backend/internal/daemon/tracker_intake_wiring.go @@ -24,10 +24,10 @@ import ( // stays lazy so daemon readiness is not blocked by credential probing or a gh // CLI call, and no token is resolved until some enabled project is actually // polled. -func startTrackerIntake(ctx context.Context, store *sqlite.Store, sessions *sessionsvc.Service, logger *slog.Logger) <-chan struct{} { +func startTrackerIntake(ctx context.Context, store *sqlite.Store, sessions *sessionsvc.Service, tracker ports.Tracker, logger *slog.Logger) <-chan struct{} { resolver := trackerintake.SingleTrackerResolver{ Provider: domain.TrackerProviderGitHub, - Adapter: newLazyGitHubTracker(logger), + Adapter: tracker, } observer := trackerintake.New(resolver, store, sessions, trackerintake.Config{Logger: logger}) return observer.Start(ctx) @@ -64,6 +64,14 @@ func (t *lazyGitHubTracker) List(ctx context.Context, repo domain.TrackerRepo, f return tracker.List(ctx, repo, filter) } +func (t *lazyGitHubTracker) AuthenticatedUser(ctx context.Context) (domain.TrackerUser, error) { + tracker, err := t.resolve() + if err != nil { + return domain.TrackerUser{}, err + } + return tracker.AuthenticatedUser(ctx) +} + func (t *lazyGitHubTracker) Preflight(ctx context.Context) error { tracker, err := t.resolve() if err != nil { diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 9a3eb20588..0bff891b3a 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -191,7 +191,7 @@ func TestStartTrackerIntake_RunsEvenWithoutEnabledProjects(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) - done := startTrackerIntake(ctx, store, svc, log) + done := startTrackerIntake(ctx, store, svc, newLazyGitHubTracker(log), log) select { case <-done: diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index d100708f64..b417c027d2 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -29,12 +29,10 @@ func TestProjectConfigValidate(t *testing.T) { {"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true}, {"worker-only harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessAider)}}}, true}, {"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true}, - {"tracker intake assignee rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, false}, - {"tracker intake explicit github", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderGitHub, Assignee: "alice"}}, false}, - {"tracker intake no rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true}}, true}, - {"tracker intake unknown provider", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: "linear", Assignee: "alice"}}, true}, - {"tracker intake repo with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Repo: " acme/demo", Assignee: "alice"}}, true}, - {"tracker intake assignee with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: " alice"}}, true}, + {"tracker intake authenticated user", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true}}, false}, + {"tracker intake explicit github", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderGitHub}}, false}, + {"tracker intake unknown provider", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: "linear"}}, true}, + {"tracker intake repo with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Repo: " acme/demo"}}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -80,7 +78,7 @@ func TestProjectConfigWithDefaults(t *testing.T) { t.Fatalf("WithDefaults dropped a set field: %#v", got.AgentConfig) } - got = (ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}).WithDefaults() + got = (ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true}}).WithDefaults() if got.TrackerIntake.Provider != TrackerProviderGitHub { t.Fatalf("TrackerIntake.Provider = %q, want %q", got.TrackerIntake.Provider, TrackerProviderGitHub) } diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index 289d4347f9..78e3895cdd 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -2,7 +2,6 @@ package domain import ( "fmt" - "strings" ) // TrackerProvider identifies an issue-tracker provider implementation. @@ -52,6 +51,11 @@ type TrackerRepo struct { Native string `json:"native"` } +// TrackerUser identifies the account currently authenticated with a tracker. +type TrackerUser struct { + Login string `json:"login"` +} + // ListStateFilter narrows Tracker.List results by the provider's coarse // state (open vs closed). It is intentionally NOT the 5-value normalized // enum — finer filtering (e.g. "only in-review issues") goes through the @@ -79,8 +83,6 @@ type ListFilter struct { } // TrackerIntakeConfig controls issue-driven worker spawning for a project. -// Enabled requires an explicit assignee eligibility rule so turning intake on -// cannot accidentally drain an entire issue backlog. type TrackerIntakeConfig struct { Enabled bool `json:"enabled,omitempty"` // Provider defaults to github when Enabled is true. @@ -88,9 +90,6 @@ type TrackerIntakeConfig struct { // Repo is the GitHub-native repository key ("owner/repo"). When empty, the // intake loop derives it from the project's repo origin URL. GitHub only. Repo string `json:"repo,omitempty"` - // Assignee narrows eligible issues to one assignee. Provider-specific values - // such as "*" are passed through unchanged. - Assignee string `json:"assignee,omitempty"` } // WithDefaults fills the provider only when intake is enabled. Disabled intake @@ -102,7 +101,7 @@ func (c TrackerIntakeConfig) WithDefaults() TrackerIntakeConfig { return c } -// Validate rejects accidental broad intake and unknown providers. +// Validate rejects unknown providers and malformed repository scopes. func (c TrackerIntakeConfig) Validate() error { if !c.Enabled { return nil @@ -114,11 +113,5 @@ func (c TrackerIntakeConfig) Validate() error { if err := validateNoWhitespaceField("trackerIntake.repo", c.Repo); err != nil { return err } - if err := validateNoWhitespaceField("trackerIntake.assignee", c.Assignee); err != nil { - return err - } - if strings.TrimSpace(c.Assignee) == "" { - return fmt.Errorf("trackerIntake: assignee is required when enabled") - } return nil } diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 218e6815b0..f93b77f2f1 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -15,6 +15,7 @@ import ( prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" + trackerintakesvc "github.com/aoagents/agent-orchestrator/backend/internal/service/trackerintake" ) // APIDeps bundles every service the API layer's controllers depend on. @@ -31,6 +32,7 @@ type APIDeps struct { CDC cdc.Source Events cdcSubscriber Telemetry ports.EventSink + TrackerIntake trackerintakesvc.Manager } // API owns one controller per resource and is the single Register call the @@ -44,6 +46,7 @@ type API struct { reviews *controllers.ReviewsController notifications *controllers.NotificationsController imports *controllers.ImportController + trackerIntake *controllers.TrackerIntakeController events *EventsController } @@ -67,6 +70,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API { reviews: &controllers.ReviewsController{Svc: deps.Reviews}, notifications: &controllers.NotificationsController{Svc: deps.Notifications, Stream: deps.NotificationStream}, imports: &controllers.ImportController{Svc: deps.Import}, + trackerIntake: &controllers.TrackerIntakeController{Svc: deps.TrackerIntake}, events: &EventsController{Source: deps.CDC, Live: deps.Events}, } } @@ -92,6 +96,7 @@ func (a *API) Register(root chi.Router) { a.reviews.Register(r) a.notifications.Register(r) a.imports.Register(r) + a.trackerIntake.Register(r) // Sibling REST controllers plug in here. }) // Long-lived streams intentionally bypass the REST timeout middleware. diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index e4554bde88..b385f00a3c 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1529,6 +1529,31 @@ paths: summary: Clean up terminated session workspaces tags: - sessions + /api/v1/tracker-intake/github/user: + get: + operationId: getTrackerIntakeIdentity + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/TrackerIntakeIdentityResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Return the authenticated GitHub account bound to issue intake + tags: + - tracker-intake components: schemas: APIError: @@ -2697,8 +2722,6 @@ components: type: object TrackerIntakeConfig: properties: - assignee: - type: string enabled: type: boolean provider: @@ -2708,6 +2731,13 @@ components: repo: type: string type: object + TrackerIntakeIdentityResponse: + properties: + login: + type: string + required: + - login + type: object TriggerReviewResponse: properties: reviewerHandleId: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 3ce0d5936d..a6cf9ad989 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -140,6 +140,7 @@ var schemaNames = map[string]string{ "ControllersAgentIDParam": "AgentIDParam", "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", + "ControllersTrackerIntakeIdentityResponse": "TrackerIntakeIdentityResponse", "ControllersListSessionsQuery": "ListSessionsQuery", "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", "ControllersListSessionsResponse": "ListSessionsResponse", @@ -292,9 +293,22 @@ func operations() []operation { ops = append(ops, reviewOperations()...) ops = append(ops, notificationOperations()...) ops = append(ops, importOperations()...) + ops = append(ops, trackerIntakeOperations()...) return ops } +func trackerIntakeOperations() []operation { + return []operation{{ + method: http.MethodGet, path: "/api/v1/tracker-intake/github/user", id: "getTrackerIntakeIdentity", tag: "tracker-intake", + summary: "Return the authenticated GitHub account bound to issue intake", + resps: []respUnit{ + {http.StatusOK, controllers.TrackerIntakeIdentityResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }} +} + func agentOperations() []operation { return []operation{ { diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 89cad91923..314f4b0bbc 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -32,6 +32,11 @@ type AgentIDParam struct { Agent string `path:"agent" description:"Agent adapter identifier."` } +// TrackerIntakeIdentityResponse is the GitHub account bound to issue intake. +type TrackerIntakeIdentityResponse struct { + Login string `json:"login"` +} + // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { Projects []projectsvc.Summary `json:"projects"` diff --git a/backend/internal/httpd/controllers/tracker_intake.go b/backend/internal/httpd/controllers/tracker_intake.go new file mode 100644 index 0000000000..7d793ea98b --- /dev/null +++ b/backend/internal/httpd/controllers/tracker_intake.go @@ -0,0 +1,34 @@ +package controllers + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + trackerintakesvc "github.com/aoagents/agent-orchestrator/backend/internal/service/trackerintake" +) + +// TrackerIntakeController owns GitHub issue-intake configuration routes. +type TrackerIntakeController struct { + Svc trackerintakesvc.Manager +} + +// Register mounts tracker intake routes on the supplied router. +func (c *TrackerIntakeController) Register(r chi.Router) { + r.Get("/tracker-intake/github/user", c.identity) +} + +func (c *TrackerIntakeController) identity(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/tracker-intake/github/user") + return + } + user, err := c.Svc.Identity(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, TrackerIntakeIdentityResponse{Login: user.Login}) +} diff --git a/backend/internal/httpd/controllers/tracker_intake_test.go b/backend/internal/httpd/controllers/tracker_intake_test.go new file mode 100644 index 0000000000..a0bf3ecbb5 --- /dev/null +++ b/backend/internal/httpd/controllers/tracker_intake_test.go @@ -0,0 +1,41 @@ +package controllers_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" +) + +func TestGetTrackerIntakeIdentity(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := &fakeTrackerIntakeService{user: domain.TrackerUser{Login: "octocat"}} + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + TrackerIntake: svc, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/tracker-intake/github/user", "") + if status != http.StatusOK { + t.Fatalf("GET identity = %d, body=%s", status, body) + } + if !strings.Contains(string(body), `"login":"octocat"`) { + t.Fatalf("body = %s, want octocat login", body) + } +} + +type fakeTrackerIntakeService struct { + user domain.TrackerUser + err error +} + +func (f *fakeTrackerIntakeService) Identity(context.Context) (domain.TrackerUser, error) { + return f.user, f.err +} diff --git a/backend/internal/observe/trackerintake/observer.go b/backend/internal/observe/trackerintake/observer.go index d3e46964ba..b9ca3ca7a3 100644 --- a/backend/internal/observe/trackerintake/observer.go +++ b/backend/internal/observe/trackerintake/observer.go @@ -180,9 +180,14 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord o.logger.Warn("tracker intake: no adapter for provider", "project", project.ID, "provider", cfg.Provider, "err", err) return true } + user, err := tracker.AuthenticatedUser(ctx) + if err != nil { + o.logger.Error("tracker intake: resolve authenticated user failed", "project", project.ID, "err", err) + return true + } issues, err := tracker.List(ctx, repo, domain.ListFilter{ State: domain.ListOpen, - Assignee: cfg.Assignee, + Assignee: user.Login, }) if err != nil { o.logger.Error("tracker intake: list issues failed", "project", project.ID, "repo", repo.Native, "err", err) @@ -196,7 +201,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord if issue.State != domain.IssueOpen { continue } - if !issueMatchesConfig(issue, cfg) { + if !containsFold(issue.Assignees, user.Login) { continue } issueID := CanonicalIssueID(issue.ID) @@ -218,20 +223,6 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord return spawnFailed } -func issueMatchesConfig(issue domain.Issue, cfg domain.TrackerIntakeConfig) bool { - assignee := strings.TrimSpace(cfg.Assignee) - switch { - case assignee == "": - return true - case assignee == "*": - return len(issue.Assignees) > 0 - case strings.EqualFold(assignee, "none"): - return len(issue.Assignees) == 0 - default: - return containsFold(issue.Assignees, assignee) - } -} - func containsFold(values []string, needle string) bool { for _, value := range values { if strings.EqualFold(strings.TrimSpace(value), needle) { diff --git a/backend/internal/observe/trackerintake/observer_test.go b/backend/internal/observe/trackerintake/observer_test.go index 08ef3d8c25..9b1472400f 100644 --- a/backend/internal/observe/trackerintake/observer_test.go +++ b/backend/internal/observe/trackerintake/observer_test.go @@ -18,10 +18,7 @@ func TestPollSpawnsWorkerForEligibleIssue(t *testing.T) { projects: []domain.ProjectRecord{{ ID: "demo", RepoOriginURL: "https://github.com/acme/demo.git", - Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ - Enabled: true, - Assignee: "alice", - }}, + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}, }}, } tracker := &fakeTracker{issues: []domain.Issue{{ @@ -64,7 +61,7 @@ func TestPollSkipsExistingIssueSessionsAfterRestart(t *testing.T) { projects: []domain.ProjectRecord{{ ID: "demo", RepoOriginURL: "https://github.com/acme/demo.git", - Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}, }}, sessions: []domain.SessionRecord{{ID: "demo-1", ProjectID: "demo", IssueID: "github:acme/demo#12"}}, } @@ -99,8 +96,7 @@ func TestPollSkipsIneligibleAndInvalidProjects(t *testing.T) { store := &fakeStore{ projects: []domain.ProjectRecord{ {ID: "off", RepoOriginURL: "https://github.com/acme/off.git"}, - {ID: "broad", RepoOriginURL: "https://github.com/acme/broad.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}}, - {ID: "missing-origin", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}}, + {ID: "missing-origin", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}}, }, } tracker := &fakeTracker{issues: []domain.Issue{{ @@ -123,8 +119,8 @@ func TestPollSkipsIneligibleAndInvalidProjects(t *testing.T) { func TestPollContinuesAfterTrackerAndSpawnFailures(t *testing.T) { store := &fakeStore{projects: []domain.ProjectRecord{ - {ID: "bad", RepoOriginURL: "https://github.com/acme/bad.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}}, - {ID: "good", RepoOriginURL: "https://github.com/acme/good.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}}, + {ID: "bad", RepoOriginURL: "https://github.com/acme/bad.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}}, + {ID: "good", RepoOriginURL: "https://github.com/acme/good.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}}, }} tracker := &fakeTracker{ failRepos: map[string]error{"acme/bad": errors.New("rate limited")}, @@ -153,7 +149,7 @@ func TestPollBacksOffProjectAfterFailure(t *testing.T) { store := &fakeStore{projects: []domain.ProjectRecord{{ ID: "demo", RepoOriginURL: "https://github.com/acme/demo.git", - Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}, }}} tracker := &fakeTracker{failRepos: map[string]error{"acme/demo": errors.New("rate limited")}} observer := New(singleResolver(tracker), store, &fakeSpawner{}, Config{ @@ -189,7 +185,7 @@ func TestPollSkipsNonOpenIssueStates(t *testing.T) { store := &fakeStore{projects: []domain.ProjectRecord{{ ID: "demo", RepoOriginURL: "https://github.com/acme/demo.git", - Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}, }}} tracker := &fakeTracker{issues: []domain.Issue{ {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "already active", State: domain.IssueInProgress, Assignees: []string{"alice"}}, @@ -209,7 +205,7 @@ func TestPollAppliesLocalEligibilityFilter(t *testing.T) { store := &fakeStore{projects: []domain.ProjectRecord{{ ID: "demo", RepoOriginURL: "https://github.com/acme/demo.git", - Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}, }}} tracker := &fakeTracker{issues: []domain.Issue{ {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "unassigned", State: domain.IssueOpen}, @@ -226,23 +222,6 @@ func TestPollAppliesLocalEligibilityFilter(t *testing.T) { } } -func TestIssueMatchesConfigAssigneeSpecialValues(t *testing.T) { - assigned := domain.Issue{Assignees: []string{"alice"}} - unassigned := domain.Issue{} - if !issueMatchesConfig(assigned, domain.TrackerIntakeConfig{Assignee: "*"}) { - t.Fatal("assigned issue should match assignee=*") - } - if issueMatchesConfig(unassigned, domain.TrackerIntakeConfig{Assignee: "*"}) { - t.Fatal("unassigned issue should not match assignee=*") - } - if !issueMatchesConfig(unassigned, domain.TrackerIntakeConfig{Assignee: "none"}) { - t.Fatal("unassigned issue should match assignee=none") - } - if issueMatchesConfig(assigned, domain.TrackerIntakeConfig{Assignee: "none"}) { - t.Fatal("assigned issue should not match assignee=none") - } -} - func TestBuildIssuePromptCapsLargeIssueBody(t *testing.T) { prompt := BuildIssuePrompt(domain.Issue{ ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#99"}, @@ -269,9 +248,8 @@ func TestTrackerRepoUsesConfiguredRepo(t *testing.T) { ID: "demo", RepoOriginURL: "https://github.com/wrong/repo.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ - Enabled: true, - Repo: "acme/demo", - Assignee: "alice", + Enabled: true, + Repo: "acme/demo", }}, } repo, ok := trackerRepo(project, project.Config.TrackerIntake.WithDefaults()) @@ -302,6 +280,8 @@ func (f *fakeStore) ListAllSessions(context.Context) ([]domain.SessionRecord, er } type fakeTracker struct { + user domain.TrackerUser + userErr error issues []domain.Issue issuesByRepo map[string][]domain.Issue failRepos map[string]error @@ -313,6 +293,13 @@ func (f *fakeTracker) Get(context.Context, domain.TrackerID) (domain.Issue, erro return domain.Issue{}, nil } +func (f *fakeTracker) AuthenticatedUser(context.Context) (domain.TrackerUser, error) { + if f.user.Login == "" { + f.user.Login = "alice" + } + return f.user, f.userErr +} + func (f *fakeTracker) List(_ context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { f.repos = append(f.repos, repo) f.filters = append(f.filters, filter) diff --git a/backend/internal/ports/tracker.go b/backend/internal/ports/tracker.go index 11411d9258..0a78a9943f 100644 --- a/backend/internal/ports/tracker.go +++ b/backend/internal/ports/tracker.go @@ -12,6 +12,7 @@ import ( // to hydrate the agent prompt. // - List returns a filtered slice of issues in a repo, used when the SM // needs to enumerate work (e.g. backlog view, status sweeps). +// - AuthenticatedUser returns the tracker account bound to intake. // - Preflight verifies the configured credential is actually valid against // the provider so daemons fail fast at startup, not at first request. // @@ -21,5 +22,6 @@ import ( type Tracker interface { Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) + AuthenticatedUser(ctx context.Context) (domain.TrackerUser, error) Preflight(ctx context.Context) error } diff --git a/backend/internal/service/trackerintake/service.go b/backend/internal/service/trackerintake/service.go new file mode 100644 index 0000000000..d5f798aa0e --- /dev/null +++ b/backend/internal/service/trackerintake/service.go @@ -0,0 +1,45 @@ +// Package trackerintake exposes GitHub issue-intake configuration data to the +// daemon API while keeping provider access outside the Electron frontend. +package trackerintake + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" +) + +// IdentityReader is the tracker surface needed to resolve the account bound to +// issue intake. +type IdentityReader interface { + AuthenticatedUser(ctx context.Context) (domain.TrackerUser, error) +} + +// Manager is the controller-facing tracker intake contract. +type Manager interface { + Identity(ctx context.Context) (domain.TrackerUser, error) +} + +// Service serves tracker intake configuration data from one GitHub adapter. +type Service struct { + tracker IdentityReader +} + +// New constructs a tracker intake service. +func New(tracker IdentityReader) *Service { + return &Service{tracker: tracker} +} + +// Identity returns the authenticated GitHub login used for assignee filtering. +func (s *Service) Identity(ctx context.Context) (domain.TrackerUser, error) { + if s == nil || s.tracker == nil { + return domain.TrackerUser{}, apierr.Internal("GITHUB_IDENTITY_UNAVAILABLE", "GitHub identity is unavailable") + } + user, err := s.tracker.AuthenticatedUser(ctx) + if err != nil { + return domain.TrackerUser{}, apierr.Internal("GITHUB_IDENTITY_FAILED", "Failed to resolve authenticated GitHub user") + } + return user, nil +} + +var _ Manager = (*Service)(nil) diff --git a/backend/internal/service/trackerintake/service_test.go b/backend/internal/service/trackerintake/service_test.go new file mode 100644 index 0000000000..33e15c9807 --- /dev/null +++ b/backend/internal/service/trackerintake/service_test.go @@ -0,0 +1,39 @@ +package trackerintake + +import ( + "context" + "errors" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" +) + +func TestIdentityReturnsAuthenticatedLogin(t *testing.T) { + svc := New(&fakeTracker{user: domain.TrackerUser{Login: "octocat"}}) + got, err := svc.Identity(context.Background()) + if err != nil { + t.Fatalf("Identity: %v", err) + } + if got.Login != "octocat" { + t.Fatalf("login = %q, want octocat", got.Login) + } +} + +func TestIdentityMapsTrackerFailure(t *testing.T) { + svc := New(&fakeTracker{userErr: errors.New("offline")}) + _, err := svc.Identity(context.Background()) + var apiError *apierr.Error + if !errors.As(err, &apiError) || apiError.Code != "GITHUB_IDENTITY_FAILED" { + t.Fatalf("err = %#v, want GITHUB_IDENTITY_FAILED", err) + } +} + +type fakeTracker struct { + user domain.TrackerUser + userErr error +} + +func (f *fakeTracker) AuthenticatedUser(context.Context) (domain.TrackerUser, error) { + return f.user, f.userErr +} diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 3bd45ec046..84b3b0ec8a 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -539,6 +539,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/tracker-intake/github/user": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return the authenticated GitHub account bound to issue intake */ + get: operations["getTrackerIntakeIdentity"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -987,12 +1004,14 @@ export interface components { verdict: string; }; TrackerIntakeConfig: { - assignee?: string; enabled?: boolean; /** @enum {string} */ provider?: "github"; repo?: string; }; + TrackerIntakeIdentityResponse: { + login: string; + }; TriggerReviewResponse: { reviewerHandleId: string; reviews: components["schemas"]["PRReviewState"][]; @@ -2956,4 +2975,42 @@ export interface operations { }; }; }; + getTrackerIntakeIdentity: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackerIntakeIdentityResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; } diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx index 32e61db71c..16dd7f1ed9 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { agentsQueryKey } from "../hooks/useAgentsQuery"; +import { trackerIntakeIdentityQueryKey } from "../hooks/useTrackerIntakeIdentity"; import { CreateProjectAgentSheet } from "./CreateProjectAgentSheet"; function renderSheet(onSubmit = vi.fn().mockResolvedValue(undefined)) { @@ -21,6 +22,7 @@ function renderSheet(onSubmit = vi.fn().mockResolvedValue(undefined)) { { id: "codex", label: "codex", authStatus: "authorized" }, ], }); + queryClient.setQueryData(trackerIntakeIdentityQueryKey, { login: "octocat" }); render( { }); }); - it("blocks submit when intake is enabled with no assignee, then passes the intake payload once one is set", async () => { + it("binds intake to the authenticated GitHub user", async () => { const onSubmit = renderSheet(); await chooseOption(screen.getByLabelText("Worker agent"), "claude-code"); await chooseOption(screen.getByLabelText("Orchestrator agent"), "codex"); await userEvent.click(screen.getByLabelText("Enable issue intake")); - // Enabled with no eligibility rule → submit stays disabled (compact sheet - // carries no inline guard prose; gating is the disabled button). - expect(screen.getByRole("button", { name: "Create and start" })).toBeDisabled(); - - await userEvent.type(screen.getByLabelText("Assignee"), "octocat"); + expect(screen.getByText("Issues assigned to @octocat")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Create and start" })); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); expect(onSubmit).toHaveBeenCalledWith({ workerAgent: "claude-code", orchestratorAgent: "codex", - trackerIntake: { enabled: true, provider: "github", assignee: "octocat" }, + trackerIntake: { enabled: true, provider: "github" }, }); }); diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 8507fc323a..867049c3e3 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -5,8 +5,8 @@ import { memo, useEffect, useState } from "react"; import type { components } from "../../api/schema"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { AGENT_OPTIONS } from "../lib/agent-options"; -import { buildIntake, type IntakeForm, IntakeFields, intakeNeedsRule } from "./IntakeFields"; import type { ProjectKind } from "../types/workspace"; +import { buildIntake, type IntakeForm, IntakeFields } from "./IntakeFields"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; @@ -21,7 +21,7 @@ export type CreateProjectAgentSelection = { trackerIntake?: TrackerIntakeConfig; }; -const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" }; +const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "" }; type CreateProjectAgentSheetProps = { error?: string | null; @@ -69,9 +69,7 @@ export function CreateProjectAgentSheet({ const [workerAgent, setWorkerAgent] = useState(""); const [orchestratorAgent, setOrchestratorAgent] = useState(""); const [intake, setIntake] = useState(EMPTY_INTAKE); - const intakeIncomplete = intakeNeedsRule(intake); - const canSubmit = - workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating && !isLoadingAgents; + const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !isCreating && !isLoadingAgents; useEffect(() => { if (!open) { diff --git a/frontend/src/renderer/components/IntakeFields.tsx b/frontend/src/renderer/components/IntakeFields.tsx index daf3f9f206..988b95da31 100644 --- a/frontend/src/renderer/components/IntakeFields.tsx +++ b/frontend/src/renderer/components/IntakeFields.tsx @@ -1,5 +1,6 @@ import { Info } from "lucide-react"; import type { components } from "../../api/schema"; +import { useTrackerIntakeIdentity } from "../hooks/useTrackerIntakeIdentity"; import { Label } from "./ui/label"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; @@ -12,7 +13,6 @@ type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; export type IntakeForm = { enabled: boolean; repo: string; - assignee: string; }; // Only "github" is a valid TrackerIntakeConfig["provider"] today (see the @@ -20,13 +20,6 @@ export type IntakeForm = { // grows, IntakeFields gains a provider onChange({ assignee: e.target.value })} - placeholder="type username or * for any" - /> + +
+ {identityQuery.data + ? `Issues assigned to @${identityQuery.data.login}` + : identityQuery.isError + ? "Could not resolve authenticated GitHub user" + : "Resolving authenticated GitHub user…"} +
- {!compact && needsRule && ( -

Enabling intake requires an assignee.

+ {identityQuery.isError && !compact && ( +

Check GitHub authentication and try again.

)} )} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 7098aad03b..22ce83ca49 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -80,6 +80,9 @@ const agentCatalogResponse = { function mockProject(project: Record) { getMock.mockImplementation(async (path: string) => { if (path === "/api/v1/agents") return agentCatalogResponse; + if (path === "/api/v1/tracker-intake/github/user") { + return { data: { login: "octocat" }, error: undefined }; + } return { data: { status: "ok", @@ -271,36 +274,29 @@ describe("ProjectSettingsForm", () => { }); it("saves GitHub tracker intake settings, deriving the repo from the project's git origin", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "git@github.com:acme/project-one.git", - defaultBranch: "main", - config: { - worker: { agent: "codex" }, - orchestrator: { agent: "claude-code" }, - }, - }, + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, }, - error: undefined, }); renderSettings(); await userEvent.click(await screen.findByLabelText("Enable issue intake")); - // Repository is display-only, derived from the project's own git origin — no input to - // fill. Assignee is the only eligibility rule in v1. + // Repository and assignee are both display-only. expect(screen.getByRole("link", { name: "acme/project-one" })).toHaveAttribute( "href", "https://github.com/acme/project-one", ); - await userEvent.type(screen.getByLabelText("Assignee"), "octocat"); + expect(await screen.findByText("Issues assigned to @octocat")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Save changes" })); @@ -309,39 +305,9 @@ describe("ProjectSettingsForm", () => { expect(body.config.trackerIntake).toEqual({ enabled: true, provider: "github", - assignee: "octocat", }); }); - it("blocks save when intake is enabled with no assignee", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "git@github.com:acme/project-one.git", - defaultBranch: "main", - config: { - worker: { agent: "codex" }, - orchestrator: { agent: "claude-code" }, - }, - }, - }, - error: undefined, - }); - - renderSettings(); - - await userEvent.click(await screen.findByLabelText("Enable issue intake")); - await userEvent.click(screen.getByRole("button", { name: "Save changes" })); - - expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2); - expect(putMock).not.toHaveBeenCalled(); - }); - it("restarts when the saved orchestrator agent already differs from the running orchestrator", async () => { getMock.mockResolvedValue({ data: { diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 8bcec66134..e4bf5a3237 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -9,7 +9,7 @@ import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { newestActiveOrchestrator } from "../types/workspace"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { DashboardSubhead } from "./DashboardSubhead"; -import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm, intakeNeedsRule } from "./IntakeFields"; +import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm } from "./IntakeFields"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Label } from "./ui/label"; @@ -86,7 +86,6 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje reviewerHarness: config.reviewers?.[0]?.harness ?? "", intakeEnabled: intake.enabled ?? false, intakeRepo: intake.repo ?? "", - intakeAssignee: intake.assignee ?? "", }); const [savedAt, setSavedAt] = useState(null); const [replacementError, setReplacementError] = useState(null); @@ -107,17 +106,14 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje const intakeForm: IntakeForm = { enabled: form.intakeEnabled, repo: form.intakeRepo, - assignee: form.intakeAssignee, }; const patchIntake = (patch: Partial) => setForm((f) => ({ ...f, intakeEnabled: patch.enabled ?? f.intakeEnabled, intakeRepo: patch.repo ?? f.intakeRepo, - intakeAssignee: patch.assignee ?? f.intakeAssignee, })); const effectiveIntakeRepo = form.intakeRepo.trim() || deriveGitHubRepo(project.repo); - const intakeIncomplete = intakeNeedsRule(intakeForm); const mutation = useMutation({ mutationFn: async () => { @@ -181,10 +177,6 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje setValidationError("Worker and orchestrator agents are required."); return; } - if (intakeIncomplete) { - setValidationError("Enabling intake requires an assignee."); - return; - } setValidationError(null); mutation.mutate(); }} diff --git a/frontend/src/renderer/hooks/useTrackerIntakeIdentity.ts b/frontend/src/renderer/hooks/useTrackerIntakeIdentity.ts new file mode 100644 index 0000000000..bbe9b025b2 --- /dev/null +++ b/frontend/src/renderer/hooks/useTrackerIntakeIdentity.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; + +export type TrackerIntakeIdentity = components["schemas"]["TrackerIntakeIdentityResponse"]; + +export const trackerIntakeIdentityQueryKey = ["tracker-intake", "github", "user"] as const; + +async function fetchTrackerIntakeIdentity(): Promise { + const { data, error } = await apiClient.GET("/api/v1/tracker-intake/github/user"); + if (error) throw new Error(apiErrorMessage(error)); + return data as TrackerIntakeIdentity; +} + +export const trackerIntakeIdentityQueryOptions = { + queryKey: trackerIntakeIdentityQueryKey, + queryFn: fetchTrackerIntakeIdentity, + retry: 1, + staleTime: Number.POSITIVE_INFINITY, +}; + +export function useTrackerIntakeIdentity(enabled: boolean) { + return useQuery({ ...trackerIntakeIdentityQueryOptions, enabled }); +} diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 6afdecde1d..35fbaec734 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -60,6 +60,7 @@ const ROUTE_TEMPLATES = [ "/api/v1/projects", "/api/v1/projects/{id}", "/api/v1/projects/{id}/config", + "/api/v1/tracker-intake/github/user", "/api/v1/prs/{id}/merge", "/api/v1/prs/{id}/resolve-comments", "/api/v1/sessions", From 669b20a87bbb7b2aba1e28c4f300df80a5c59c97 Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Tue, 7 Jul 2026 22:08:13 +0530 Subject: [PATCH 2/7] feat: add GitHub label filters --- .../adapters/tracker/github/tracker.go | 88 ++++++++++++- .../adapters/tracker/github/tracker_test.go | 40 ++++++ backend/internal/cli/project.go | 12 +- backend/internal/cli/project_test.go | 7 +- backend/internal/daemon/daemon.go | 2 +- .../internal/daemon/tracker_intake_wiring.go | 8 ++ backend/internal/domain/projectconfig_test.go | 2 + backend/internal/domain/tracker.go | 15 +++ backend/internal/httpd/apispec/openapi.yaml | 78 +++++++++++ .../internal/httpd/apispec/specgen/build.go | 35 +++-- backend/internal/httpd/controllers/dto.go | 10 ++ .../httpd/controllers/tracker_intake.go | 28 ++++ .../httpd/controllers/tracker_intake_test.go | 30 ++++- .../observe/trackerintake/observer.go | 60 +-------- .../observe/trackerintake/observer_test.go | 14 +- backend/internal/ports/tracker.go | 2 + .../internal/service/trackerintake/service.go | 97 +++++++++++++- .../service/trackerintake/service_test.go | 57 +++++++- .../storage/sqlite/store/store_test.go | 1 + backend/internal/trackerintake/scope.go | 83 ++++++++++++ backend/internal/trackerintake/scope_test.go | 31 +++++ frontend/src/api/schema.ts | 88 +++++++++++++ .../components/CreateProjectAgentSheet.tsx | 2 +- .../src/renderer/components/IntakeFields.tsx | 10 ++ .../renderer/components/LabelPicker.test.tsx | 68 ++++++++++ .../src/renderer/components/LabelPicker.tsx | 123 ++++++++++++++++++ .../components/ProjectSettingsForm.test.tsx | 16 +++ .../components/ProjectSettingsForm.tsx | 5 +- .../renderer/hooks/useTrackerIntakeLabels.ts | 33 +++++ frontend/src/renderer/lib/api-client.ts | 1 + 30 files changed, 952 insertions(+), 94 deletions(-) create mode 100644 backend/internal/trackerintake/scope.go create mode 100644 backend/internal/trackerintake/scope_test.go create mode 100644 frontend/src/renderer/components/LabelPicker.test.tsx create mode 100644 frontend/src/renderer/components/LabelPicker.tsx create mode 100644 frontend/src/renderer/hooks/useTrackerIntakeLabels.ts diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index f502fe8544..1d3024c719 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -101,6 +101,7 @@ type Tracker struct { // eviction is needed here. listCacheMu sync.Mutex listCache map[string]listCacheEntry + labelCache map[string]labelCacheEntry // identityMu serializes the one-time GET /user call and protects the cached // identity so the observer and API can share one adapter safely. @@ -114,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) { @@ -125,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} @@ -168,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 { @@ -385,6 +395,72 @@ 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 // --------------------------------------------------------------------------- diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go index aa3612ec7f..df214bb5ca 100644 --- a/backend/internal/adapters/tracker/github/tracker_test.go +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -573,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{} diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index f0b541776e..2dcd96d174 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -87,9 +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"` + 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 @@ -125,6 +126,7 @@ type projectSetConfigOptions struct { postCreate []string trackerIntake bool trackerRepo string + trackerLabels []string configJSON string clear bool json bool @@ -312,6 +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.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") @@ -351,6 +354,7 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { Enabled: opts.trackerIntake, Provider: trackerProviderForFlags(opts), Repo: opts.trackerRepo, + Labels: opts.trackerLabels, }, } if reflect.DeepEqual(cfg, projectConfig{}) { @@ -360,7 +364,7 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { } func trackerProviderForFlags(opts projectSetConfigOptions) string { - if opts.trackerIntake || opts.trackerRepo != "" { + if opts.trackerIntake || opts.trackerRepo != "" || len(opts.trackerLabels) > 0 { return "github" } return "" diff --git a/backend/internal/cli/project_test.go b/backend/internal/cli/project_test.go index a8493cd8ba..e669ffa0e1 100644 --- a/backend/internal/cli/project_test.go +++ b/backend/internal/cli/project_test.go @@ -41,7 +41,7 @@ func TestProjectSetConfig_TrackerIntakeFlags(t *testing.T) { _, errOut, err := executeCLI(t, Deps{ ProcessAlive: func(int) bool { return true }, - }, "project", "set-config", "demo", "--tracker-intake", "--tracker-repo", "acme/demo") + }, "project", "set-config", "demo", "--tracker-intake", "--tracker-repo", "acme/demo", "--tracker-label", "bug", "--tracker-label", "ready") if err != nil { t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) } @@ -52,7 +52,7 @@ func TestProjectSetConfig_TrackerIntakeFlags(t *testing.T) { if err := json.Unmarshal(capture.body, &got); err != nil { t.Fatalf("decode request: %v\nbody=%s", err, capture.body) } - if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" || got.Config.TrackerIntake.Repo != "acme/demo" { + if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" || got.Config.TrackerIntake.Repo != "acme/demo" || len(got.Config.TrackerIntake.Labels) != 2 { t.Fatalf("tracker intake request = %#v", got.Config.TrackerIntake) } } @@ -81,11 +81,12 @@ func TestBuildProjectConfigTrackerIntakeFlags(t *testing.T) { got, err := buildProjectConfig(projectSetConfigOptions{ trackerIntake: true, trackerRepo: "acme/demo", + trackerLabels: []string{"bug", "ready"}, }) if err != nil { t.Fatal(err) } - if !got.TrackerIntake.Enabled || got.TrackerIntake.Provider != "github" || got.TrackerIntake.Repo != "acme/demo" { + if !got.TrackerIntake.Enabled || got.TrackerIntake.Provider != "github" || got.TrackerIntake.Repo != "acme/demo" || len(got.TrackerIntake.Labels) != 2 { t.Fatalf("tracker intake config = %#v", got.TrackerIntake) } } diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 6be6cf3530..3c407d0f52 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -153,7 +153,7 @@ func Run() error { Events: cdcPipe.Broadcaster, Activity: lcStack.LCM, Telemetry: telemetrySink, - TrackerIntake: trackerintakesvc.New(githubTracker), + TrackerIntake: trackerintakesvc.NewWithDeps(trackerintakesvc.Deps{Tracker: githubTracker, Store: store}), }) if err != nil { stop() diff --git a/backend/internal/daemon/tracker_intake_wiring.go b/backend/internal/daemon/tracker_intake_wiring.go index 2dc6566c4c..547383804f 100644 --- a/backend/internal/daemon/tracker_intake_wiring.go +++ b/backend/internal/daemon/tracker_intake_wiring.go @@ -72,6 +72,14 @@ func (t *lazyGitHubTracker) AuthenticatedUser(ctx context.Context) (domain.Track return tracker.AuthenticatedUser(ctx) } +func (t *lazyGitHubTracker) ListLabels(ctx context.Context, repo domain.TrackerRepo) ([]domain.TrackerLabel, error) { + tracker, err := t.resolve() + if err != nil { + return nil, err + } + return tracker.ListLabels(ctx, repo) +} + func (t *lazyGitHubTracker) Preflight(ctx context.Context) error { tracker, err := t.resolve() if err != nil { diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index b417c027d2..33105f0540 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -31,6 +31,8 @@ func TestProjectConfigValidate(t *testing.T) { {"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true}, {"tracker intake authenticated user", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true}}, false}, {"tracker intake explicit github", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderGitHub}}, false}, + {"tracker intake labels", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"bug", "ready"}}}, false}, + {"tracker intake empty label", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"bug", " "}}}, true}, {"tracker intake unknown provider", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: "linear"}}, true}, {"tracker intake repo with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Repo: " acme/demo"}}, true}, } diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index 78e3895cdd..c4821d33c2 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -2,6 +2,7 @@ package domain import ( "fmt" + "strings" ) // TrackerProvider identifies an issue-tracker provider implementation. @@ -56,6 +57,13 @@ type TrackerUser struct { Login string `json:"login"` } +// TrackerLabel is repository-owned label metadata used by intake filters. +type TrackerLabel struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description,omitempty"` +} + // ListStateFilter narrows Tracker.List results by the provider's coarse // state (open vs closed). It is intentionally NOT the 5-value normalized // enum — finer filtering (e.g. "only in-review issues") goes through the @@ -90,6 +98,8 @@ type TrackerIntakeConfig struct { // Repo is the GitHub-native repository key ("owner/repo"). When empty, the // intake loop derives it from the project's repo origin URL. GitHub only. Repo string `json:"repo,omitempty"` + // Labels narrows intake to issues carrying every selected repository label. + Labels []string `json:"labels,omitempty"` } // WithDefaults fills the provider only when intake is enabled. Disabled intake @@ -113,5 +123,10 @@ func (c TrackerIntakeConfig) Validate() error { if err := validateNoWhitespaceField("trackerIntake.repo", c.Repo); err != nil { return err } + for _, label := range c.Labels { + if strings.TrimSpace(label) == "" { + return fmt.Errorf("trackerIntake.labels: label names must not be empty") + } + } return nil } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index b385f00a3c..3021855046 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -623,6 +623,59 @@ paths: summary: Replace a project's per-project config tags: - projects + /api/v1/projects/{id}/tracker-intake/github/labels: + get: + operationId: listTrackerIntakeLabels + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string + - description: When true, revalidate the repository label catalog immediately. + in: query + name: refresh + schema: + description: When true, revalidate the repository label catalog immediately. + type: + - "null" + - boolean + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/TrackerIntakeLabelsResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: List labels from the project's GitHub repository + tags: + - tracker-intake /api/v1/prs/{id}/merge: post: operationId: mergePR @@ -2724,6 +2777,10 @@ components: properties: enabled: type: boolean + labels: + items: + type: string + type: array provider: enum: - github @@ -2738,6 +2795,27 @@ components: required: - login type: object + TrackerIntakeLabelsResponse: + properties: + labels: + items: + $ref: '#/components/schemas/TrackerLabel' + type: array + required: + - labels + type: object + TrackerLabel: + properties: + color: + type: string + description: + type: string + name: + type: string + required: + - name + - color + type: object TriggerReviewResponse: properties: reviewerHandleId: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index a6cf9ad989..c8eb4cf8c6 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -141,6 +141,9 @@ var schemaNames = map[string]string{ "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", "ControllersTrackerIntakeIdentityResponse": "TrackerIntakeIdentityResponse", + "ControllersTrackerIntakeLabelsQuery": "TrackerIntakeLabelsQuery", + "ControllersTrackerIntakeLabelsResponse": "TrackerIntakeLabelsResponse", + "DomainTrackerLabel": "TrackerLabel", "ControllersListSessionsQuery": "ListSessionsQuery", "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", "ControllersListSessionsResponse": "ListSessionsResponse", @@ -298,15 +301,29 @@ func operations() []operation { } func trackerIntakeOperations() []operation { - return []operation{{ - method: http.MethodGet, path: "/api/v1/tracker-intake/github/user", id: "getTrackerIntakeIdentity", tag: "tracker-intake", - summary: "Return the authenticated GitHub account bound to issue intake", - resps: []respUnit{ - {http.StatusOK, controllers.TrackerIntakeIdentityResponse{}}, - {http.StatusInternalServerError, envelope.APIError{}}, - {http.StatusNotImplemented, envelope.APIError{}}, - }, - }} + return []operation{ + { + method: http.MethodGet, path: "/api/v1/tracker-intake/github/user", id: "getTrackerIntakeIdentity", tag: "tracker-intake", + summary: "Return the authenticated GitHub account bound to issue intake", + resps: []respUnit{ + {http.StatusOK, controllers.TrackerIntakeIdentityResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodGet, path: "/api/v1/projects/{id}/tracker-intake/github/labels", id: "listTrackerIntakeLabels", tag: "tracker-intake", + summary: "List labels from the project's GitHub repository", + pathParams: []any{controllers.ProjectIDParam{}, controllers.TrackerIntakeLabelsQuery{}}, + resps: []respUnit{ + {http.StatusOK, controllers.TrackerIntakeLabelsResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } } func agentOperations() []operation { diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 314f4b0bbc..c359fcdd8d 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -37,6 +37,16 @@ type TrackerIntakeIdentityResponse struct { Login string `json:"login"` } +// TrackerIntakeLabelsQuery controls repository label cache refresh. +type TrackerIntakeLabelsQuery struct { + Refresh *bool `query:"refresh,omitempty" description:"When true, revalidate the repository label catalog immediately."` +} + +// TrackerIntakeLabelsResponse is the complete repository label catalog. +type TrackerIntakeLabelsResponse struct { + Labels []domain.TrackerLabel `json:"labels"` +} + // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { Projects []projectsvc.Summary `json:"projects"` diff --git a/backend/internal/httpd/controllers/tracker_intake.go b/backend/internal/httpd/controllers/tracker_intake.go index 7d793ea98b..4771140f7d 100644 --- a/backend/internal/httpd/controllers/tracker_intake.go +++ b/backend/internal/httpd/controllers/tracker_intake.go @@ -2,9 +2,11 @@ package controllers import ( "net/http" + "strconv" "github.com/go-chi/chi/v5" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" trackerintakesvc "github.com/aoagents/agent-orchestrator/backend/internal/service/trackerintake" @@ -18,6 +20,32 @@ type TrackerIntakeController struct { // Register mounts tracker intake routes on the supplied router. func (c *TrackerIntakeController) Register(r chi.Router) { r.Get("/tracker-intake/github/user", c.identity) + r.Get("/projects/{id}/tracker-intake/github/labels", c.labels) +} + +func (c *TrackerIntakeController) labels(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/projects/{id}/tracker-intake/github/labels") + return + } + refresh := false + if raw := r.URL.Query().Get("refresh"); raw != "" { + parsed, err := strconv.ParseBool(raw) + if err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_REFRESH", "refresh must be true or false", nil) + return + } + refresh = parsed + } + labels, err := c.Svc.Labels(r.Context(), projectID(r), refresh) + if err != nil { + envelope.WriteError(w, r, err) + return + } + if labels == nil { + labels = []domain.TrackerLabel{} + } + envelope.WriteJSON(w, http.StatusOK, TrackerIntakeLabelsResponse{Labels: labels}) } func (c *TrackerIntakeController) identity(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/httpd/controllers/tracker_intake_test.go b/backend/internal/httpd/controllers/tracker_intake_test.go index a0bf3ecbb5..f0f7ae91a0 100644 --- a/backend/internal/httpd/controllers/tracker_intake_test.go +++ b/backend/internal/httpd/controllers/tracker_intake_test.go @@ -31,9 +31,35 @@ func TestGetTrackerIntakeIdentity(t *testing.T) { } } +func TestListTrackerIntakeLabels(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := &fakeTrackerIntakeService{labels: []domain.TrackerLabel{{Name: "bug", Color: "d73a4a", Description: "Something is broken"}}} + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + TrackerIntake: svc, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/projects/demo/tracker-intake/github/labels?refresh=true", "") + if status != http.StatusOK { + t.Fatalf("GET labels = %d, body=%s", status, body) + } + if !strings.Contains(string(body), `"name":"bug"`) || svc.labelsProjectID != "demo" || !svc.refresh { + t.Fatalf("body=%s project=%q refresh=%v", body, svc.labelsProjectID, svc.refresh) + } +} + type fakeTrackerIntakeService struct { - user domain.TrackerUser - err error + user domain.TrackerUser + labels []domain.TrackerLabel + labelsProjectID domain.ProjectID + refresh bool + err error +} + +func (f *fakeTrackerIntakeService) Labels(_ context.Context, projectID domain.ProjectID, refresh bool) ([]domain.TrackerLabel, error) { + f.labelsProjectID = projectID + f.refresh = refresh + return f.labels, f.err } func (f *fakeTrackerIntakeService) Identity(context.Context) (domain.TrackerUser, error) { diff --git a/backend/internal/observe/trackerintake/observer.go b/backend/internal/observe/trackerintake/observer.go index b9ca3ca7a3..5bb8f5dafd 100644 --- a/backend/internal/observe/trackerintake/observer.go +++ b/backend/internal/observe/trackerintake/observer.go @@ -7,13 +7,13 @@ import ( "context" "fmt" "log/slog" - "net/url" "strings" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/observe" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + intakescope "github.com/aoagents/agent-orchestrator/backend/internal/trackerintake" ) const ( @@ -170,7 +170,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord o.logger.Warn("tracker intake: skipping project with invalid config", "project", project.ID, "err", err) return true } - repo, ok := trackerRepo(project, cfg) + repo, ok := intakescope.Repository(project, cfg) if !ok { o.logger.Warn("tracker intake: skipping project without tracker scope", "project", project.ID, "provider", cfg.Provider, "origin", project.RepoOriginURL) return true @@ -187,6 +187,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord } issues, err := tracker.List(ctx, repo, domain.ListFilter{ State: domain.ListOpen, + Labels: cfg.Labels, Assignee: user.Login, }) if err != nil { @@ -201,7 +202,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord if issue.State != domain.IssueOpen { continue } - if !containsFold(issue.Assignees, user.Login) { + if !containsFold(issue.Assignees, user.Login) || !intakescope.MatchesAllLabels(issue.Labels, cfg.Labels) { continue } issueID := CanonicalIssueID(issue.ID) @@ -305,56 +306,3 @@ func truncateUTF8(s string, maxBytes int) string { } return s[:cut] } - -func trackerRepo(project domain.ProjectRecord, cfg domain.TrackerIntakeConfig) (domain.TrackerRepo, bool) { - provider := cfg.Provider - if provider == "" { - provider = domain.TrackerProviderGitHub - } - if provider != domain.TrackerProviderGitHub { - return domain.TrackerRepo{}, false - } - native := strings.TrimSpace(cfg.Repo) - if native == "" { - native = parseGitHubRepoNative(project.RepoOriginURL) - } - if native == "" { - return domain.TrackerRepo{}, false - } - return domain.TrackerRepo{Provider: provider, Native: native}, true -} - -func parseGitHubRepoNative(remote string) string { - remote = strings.TrimSpace(remote) - if remote == "" { - return "" - } - if strings.HasPrefix(remote, "git@") { - if _, rest, ok := strings.Cut(remote, ":"); ok { - return cleanRepoPath(rest) - } - } - if u, err := url.Parse(remote); err == nil && u.Host != "" { - host := strings.TrimPrefix(strings.ToLower(u.Host), "www.") - if host == "github.com" || strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io") { - return cleanRepoPath(u.Path) - } - return "" - } - return cleanRepoPath(remote) -} - -func cleanRepoPath(path string) string { - path = strings.Trim(strings.TrimSpace(path), "/") - path = strings.TrimSuffix(path, ".git") - parts := strings.Split(path, "/") - if len(parts) < 2 { - return "" - } - owner := strings.TrimSpace(parts[len(parts)-2]) - repo := strings.TrimSpace(parts[len(parts)-1]) - if owner == "" || repo == "" { - return "" - } - return owner + "/" + repo -} diff --git a/backend/internal/observe/trackerintake/observer_test.go b/backend/internal/observe/trackerintake/observer_test.go index 9b1472400f..87189cffab 100644 --- a/backend/internal/observe/trackerintake/observer_test.go +++ b/backend/internal/observe/trackerintake/observer_test.go @@ -11,6 +11,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + intakescope "github.com/aoagents/agent-orchestrator/backend/internal/trackerintake" ) func TestPollSpawnsWorkerForEligibleIssue(t *testing.T) { @@ -201,11 +202,11 @@ func TestPollSkipsNonOpenIssueStates(t *testing.T) { } } -func TestPollAppliesLocalEligibilityFilter(t *testing.T) { +func TestPollAppliesAuthenticatedUserAndLabelFilters(t *testing.T) { store := &fakeStore{projects: []domain.ProjectRecord{{ ID: "demo", RepoOriginURL: "https://github.com/acme/demo.git", - Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}, + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, }}} tracker := &fakeTracker{issues: []domain.Issue{ {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "unassigned", State: domain.IssueOpen}, @@ -220,6 +221,9 @@ func TestPollAppliesLocalEligibilityFilter(t *testing.T) { if len(spawner.calls) != 1 || spawner.calls[0].IssueID != "github:acme/demo#3" { t.Fatalf("spawn calls = %+v, want only eligible issue #3", spawner.calls) } + if got := tracker.filters[0].Labels; len(got) != 1 || got[0] != "agent-ready" { + t.Fatalf("tracker labels = %#v", got) + } } func TestBuildIssuePromptCapsLargeIssueBody(t *testing.T) { @@ -252,7 +256,7 @@ func TestTrackerRepoUsesConfiguredRepo(t *testing.T) { Repo: "acme/demo", }}, } - repo, ok := trackerRepo(project, project.Config.TrackerIntake.WithDefaults()) + repo, ok := intakescope.Repository(project, project.Config.TrackerIntake.WithDefaults()) if !ok { t.Fatal("trackerRepo ok = false") } @@ -300,6 +304,10 @@ func (f *fakeTracker) AuthenticatedUser(context.Context) (domain.TrackerUser, er return f.user, f.userErr } +func (f *fakeTracker) ListLabels(context.Context, domain.TrackerRepo) ([]domain.TrackerLabel, error) { + return nil, nil +} + func (f *fakeTracker) List(_ context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { f.repos = append(f.repos, repo) f.filters = append(f.filters, filter) diff --git a/backend/internal/ports/tracker.go b/backend/internal/ports/tracker.go index 0a78a9943f..a8cca00833 100644 --- a/backend/internal/ports/tracker.go +++ b/backend/internal/ports/tracker.go @@ -13,6 +13,7 @@ import ( // - List returns a filtered slice of issues in a repo, used when the SM // needs to enumerate work (e.g. backlog view, status sweeps). // - AuthenticatedUser returns the tracker account bound to intake. +// - ListLabels returns repository-owned label metadata for filter controls. // - Preflight verifies the configured credential is actually valid against // the provider so daemons fail fast at startup, not at first request. // @@ -23,5 +24,6 @@ type Tracker interface { Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) AuthenticatedUser(ctx context.Context) (domain.TrackerUser, error) + ListLabels(ctx context.Context, repo domain.TrackerRepo) ([]domain.TrackerLabel, error) Preflight(ctx context.Context) error } diff --git a/backend/internal/service/trackerintake/service.go b/backend/internal/service/trackerintake/service.go index d5f798aa0e..a782e14625 100644 --- a/backend/internal/service/trackerintake/service.go +++ b/backend/internal/service/trackerintake/service.go @@ -4,30 +4,115 @@ package trackerintake import ( "context" + "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" + intakescope "github.com/aoagents/agent-orchestrator/backend/internal/trackerintake" ) -// IdentityReader is the tracker surface needed to resolve the account bound to -// issue intake. -type IdentityReader interface { +const defaultLabelCacheTTL = 5 * time.Minute + +// Tracker is the provider surface needed by tracker intake configuration. +type Tracker interface { AuthenticatedUser(ctx context.Context) (domain.TrackerUser, error) + ListLabels(ctx context.Context, repo domain.TrackerRepo) ([]domain.TrackerLabel, error) +} + +// ProjectStore resolves the repository attached to a configured project. +type ProjectStore interface { + GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) } // Manager is the controller-facing tracker intake contract. type Manager interface { Identity(ctx context.Context) (domain.TrackerUser, error) + Labels(ctx context.Context, projectID domain.ProjectID, refresh bool) ([]domain.TrackerLabel, error) } // Service serves tracker intake configuration data from one GitHub adapter. type Service struct { - tracker IdentityReader + tracker Tracker + store ProjectStore + clock func() time.Time + cacheTTL time.Duration + cacheMu sync.Mutex + labels map[string]labelCacheEntry +} + +type labelCacheEntry struct { + labels []domain.TrackerLabel + expiresAt time.Time +} + +// Deps contains tracker intake service dependencies. +type Deps struct { + Tracker Tracker + Store ProjectStore + Clock func() time.Time + CacheTTL time.Duration } // New constructs a tracker intake service. -func New(tracker IdentityReader) *Service { - return &Service{tracker: tracker} +func New(tracker Tracker) *Service { + return NewWithDeps(Deps{Tracker: tracker}) +} + +// NewWithDeps constructs a tracker intake service with cache controls. +func NewWithDeps(deps Deps) *Service { + s := &Service{ + tracker: deps.Tracker, + store: deps.Store, + clock: deps.Clock, + cacheTTL: deps.CacheTTL, + labels: map[string]labelCacheEntry{}, + } + if s.clock == nil { + s.clock = time.Now + } + if s.cacheTTL <= 0 { + s.cacheTTL = defaultLabelCacheTTL + } + return s +} + +// Labels returns the repository label catalog, cached for five minutes unless +// refresh explicitly asks GitHub to revalidate it now. +func (s *Service) Labels(ctx context.Context, projectID domain.ProjectID, refresh bool) ([]domain.TrackerLabel, error) { + if s == nil || s.tracker == nil || s.store == nil { + return nil, apierr.Internal("GITHUB_LABELS_UNAVAILABLE", "GitHub labels are unavailable") + } + project, ok, err := s.store.GetProject(ctx, string(projectID)) + if err != nil { + return nil, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project") + } + if !ok || !project.ArchivedAt.IsZero() { + return nil, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") + } + repo, ok := intakescope.Repository(project, project.Config.TrackerIntake.WithDefaults()) + if !ok { + return nil, apierr.Invalid("GITHUB_REPOSITORY_UNAVAILABLE", "Project has no GitHub repository for issue intake", nil) + } + + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + now := s.clock().UTC() + if cached, exists := s.labels[repo.Native]; exists && !refresh && now.Before(cached.expiresAt) { + return cloneLabels(cached.labels), nil + } + labels, err := s.tracker.ListLabels(ctx, repo) + if err != nil { + return nil, apierr.Internal("GITHUB_LABELS_FAILED", "Failed to load GitHub repository labels") + } + s.labels[repo.Native] = labelCacheEntry{labels: cloneLabels(labels), expiresAt: now.Add(s.cacheTTL)} + return cloneLabels(labels), nil +} + +func cloneLabels(labels []domain.TrackerLabel) []domain.TrackerLabel { + out := make([]domain.TrackerLabel, len(labels)) + copy(out, labels) + return out } // Identity returns the authenticated GitHub login used for assignee filtering. diff --git a/backend/internal/service/trackerintake/service_test.go b/backend/internal/service/trackerintake/service_test.go index 33e15c9807..b6d2a128fc 100644 --- a/backend/internal/service/trackerintake/service_test.go +++ b/backend/internal/service/trackerintake/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" @@ -29,9 +30,61 @@ func TestIdentityMapsTrackerFailure(t *testing.T) { } } +func TestLabelsCachesAndRefreshesRepositoryCatalog(t *testing.T) { + now := time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC) + tracker := &fakeTracker{labels: []domain.TrackerLabel{{Name: "bug", Color: "d73a4a"}}} + store := &fakeProjectStore{project: domain.ProjectRecord{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + }} + svc := NewWithDeps(Deps{Tracker: tracker, Store: store, Clock: func() time.Time { return now }}) + + for _, refresh := range []bool{false, false, true} { + labels, err := svc.Labels(context.Background(), "demo", refresh) + if err != nil { + t.Fatalf("Labels(refresh=%v): %v", refresh, err) + } + if len(labels) != 1 || labels[0].Name != "bug" { + t.Fatalf("labels = %#v", labels) + } + } + if tracker.labelCalls != 2 { + t.Fatalf("ListLabels calls = %d, want 2", tracker.labelCalls) + } + + now = now.Add(5*time.Minute + time.Nanosecond) + if _, err := svc.Labels(context.Background(), "demo", false); err != nil { + t.Fatalf("Labels after TTL: %v", err) + } + if tracker.labelCalls != 3 { + t.Fatalf("ListLabels calls after TTL = %d, want 3", tracker.labelCalls) + } +} + type fakeTracker struct { - user domain.TrackerUser - userErr error + user domain.TrackerUser + userErr error + labels []domain.TrackerLabel + labelCalls int +} + +func (f *fakeTracker) ListLabels(context.Context, domain.TrackerRepo) ([]domain.TrackerLabel, error) { + f.labelCalls++ + return append([]domain.TrackerLabel(nil), f.labels...), nil +} + +type fakeProjectStore struct { + project domain.ProjectRecord + found bool + err error +} + +func (f *fakeProjectStore) GetProject(context.Context, string) (domain.ProjectRecord, bool, error) { + found := f.found + if !found && f.project.ID != "" { + found = true + } + return f.project, found, f.err } func (f *fakeTracker) AuthenticatedUser(context.Context) (domain.TrackerUser, error) { diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 3ea986817b..36b7c4766d 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -86,6 +86,7 @@ func TestProjectConfigRoundTrips(t *testing.T) { PostCreate: []string{"echo hi"}, AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits}, Worker: domain.RoleOverride{Harness: domain.HarnessCodex}, + TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Provider: domain.TrackerProviderGitHub, Labels: []string{"bug", "ready"}}, } if err := s.UpsertProject(ctx, domain.ProjectRecord{ ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: cfg, diff --git a/backend/internal/trackerintake/scope.go b/backend/internal/trackerintake/scope.go new file mode 100644 index 0000000000..cdc9a74efd --- /dev/null +++ b/backend/internal/trackerintake/scope.go @@ -0,0 +1,83 @@ +// Package trackerintake contains shared issue-intake matching and repository +// scope rules used by both the background observer and preview service. +package trackerintake + +import ( + "net/url" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Repository resolves the configured GitHub repository, falling back to the +// project's persisted origin URL. +func Repository(project domain.ProjectRecord, cfg domain.TrackerIntakeConfig) (domain.TrackerRepo, bool) { + provider := cfg.Provider + if provider == "" { + provider = domain.TrackerProviderGitHub + } + if provider != domain.TrackerProviderGitHub { + return domain.TrackerRepo{}, false + } + native := strings.TrimSpace(cfg.Repo) + if native == "" { + native = parseGitHubRepoNative(project.RepoOriginURL) + } + if native == "" { + return domain.TrackerRepo{}, false + } + return domain.TrackerRepo{Provider: provider, Native: native}, true +} + +// MatchesAllLabels reports whether every selected label appears on the issue. +// GitHub's issue-list labels query uses the same AND semantics. +func MatchesAllLabels(issueLabels, selected []string) bool { + for _, selectedLabel := range selected { + found := false + for _, issueLabel := range issueLabels { + if strings.EqualFold(strings.TrimSpace(issueLabel), strings.TrimSpace(selectedLabel)) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func parseGitHubRepoNative(remote string) string { + remote = strings.TrimSpace(remote) + if remote == "" { + return "" + } + if strings.HasPrefix(remote, "git@") { + if _, rest, ok := strings.Cut(remote, ":"); ok { + return cleanRepoPath(rest) + } + } + if u, err := url.Parse(remote); err == nil && u.Host != "" { + host := strings.TrimPrefix(strings.ToLower(u.Host), "www.") + if host == "github.com" || strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io") { + return cleanRepoPath(u.Path) + } + return "" + } + return cleanRepoPath(remote) +} + +func cleanRepoPath(path string) string { + path = strings.Trim(strings.TrimSpace(path), "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "" + } + owner := strings.TrimSpace(parts[len(parts)-2]) + repo := strings.TrimSpace(parts[len(parts)-1]) + if owner == "" || repo == "" { + return "" + } + return owner + "/" + repo +} diff --git a/backend/internal/trackerintake/scope_test.go b/backend/internal/trackerintake/scope_test.go new file mode 100644 index 0000000000..f0a6d7561d --- /dev/null +++ b/backend/internal/trackerintake/scope_test.go @@ -0,0 +1,31 @@ +package trackerintake + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestRepository(t *testing.T) { + project := domain.ProjectRecord{RepoOriginURL: "git@github.com:acme/demo.git"} + repo, ok := Repository(project, domain.TrackerIntakeConfig{Enabled: true}) + if !ok || repo.Native != "acme/demo" { + t.Fatalf("Repository = %#v, %v", repo, ok) + } + repo, ok = Repository(project, domain.TrackerIntakeConfig{Enabled: true, Repo: "other/repo"}) + if !ok || repo.Native != "other/repo" { + t.Fatalf("configured Repository = %#v, %v", repo, ok) + } +} + +func TestMatchesAllLabels(t *testing.T) { + if !MatchesAllLabels([]string{"Bug", "ready"}, []string{"bug", "READY"}) { + t.Fatal("expected case-insensitive AND match") + } + if MatchesAllLabels([]string{"bug"}, []string{"bug", "ready"}) { + t.Fatal("expected missing selected label not to match") + } + if !MatchesAllLabels(nil, nil) { + t.Fatal("empty selection should match") + } +} diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 84b3b0ec8a..b0fd2a3e3b 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -246,6 +246,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/projects/{id}/tracker-intake/github/labels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List labels from the project's GitHub repository */ + get: operations["listTrackerIntakeLabels"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/prs/{id}/merge": { parameters: { query?: never; @@ -1005,6 +1022,7 @@ export interface components { }; TrackerIntakeConfig: { enabled?: boolean; + labels?: string[]; /** @enum {string} */ provider?: "github"; repo?: string; @@ -1012,6 +1030,14 @@ export interface components { TrackerIntakeIdentityResponse: { login: string; }; + TrackerIntakeLabelsResponse: { + labels: components["schemas"]["TrackerLabel"][]; + }; + TrackerLabel: { + color: string; + description?: string; + name: string; + }; TriggerReviewResponse: { reviewerHandleId: string; reviews: components["schemas"]["PRReviewState"][]; @@ -1849,6 +1875,68 @@ export interface operations { }; }; }; + listTrackerIntakeLabels: { + parameters: { + query?: { + /** @description When true, revalidate the repository label catalog immediately. */ + refresh?: null | boolean; + }; + header?: never; + path: { + /** @description Project identifier (registry key). */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackerIntakeLabelsResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; mergePR: { parameters: { query?: never; diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 867049c3e3..ebdbdf4732 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -21,7 +21,7 @@ export type CreateProjectAgentSelection = { trackerIntake?: TrackerIntakeConfig; }; -const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "" }; +const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", labels: [] }; type CreateProjectAgentSheetProps = { error?: string | null; diff --git a/frontend/src/renderer/components/IntakeFields.tsx b/frontend/src/renderer/components/IntakeFields.tsx index 988b95da31..f3cec4e88c 100644 --- a/frontend/src/renderer/components/IntakeFields.tsx +++ b/frontend/src/renderer/components/IntakeFields.tsx @@ -1,6 +1,7 @@ import { Info } from "lucide-react"; import type { components } from "../../api/schema"; import { useTrackerIntakeIdentity } from "../hooks/useTrackerIntakeIdentity"; +import { LabelPicker } from "./LabelPicker"; import { Label } from "./ui/label"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; @@ -13,6 +14,7 @@ type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; export type IntakeForm = { enabled: boolean; repo: string; + labels: string[]; }; // Only "github" is a valid TrackerIntakeConfig["provider"] today (see the @@ -28,6 +30,7 @@ export function buildIntake(form: IntakeForm): TrackerIntakeConfig | undefined { enabled: form.enabled || undefined, provider: form.enabled ? "github" : undefined, repo: form.repo.trim() || undefined, + labels: form.labels.length > 0 ? form.labels : undefined, }; return Object.values(next).some((v) => v !== undefined) ? next : undefined; } @@ -73,11 +76,13 @@ export function IntakeFields({ form, onChange, repoPreview, + projectId, compact = false, }: { form: IntakeForm; onChange: (patch: Partial) => void; repoPreview?: { value?: string }; + projectId?: string; // compact drops the descriptive/help prose and folds the explanation into an // info-icon tooltip — used by the create-project sheet, which stays minimal. compact?: boolean; @@ -149,6 +154,11 @@ export function IntakeFields({ {identityQuery.isError && !compact && (

Check GitHub authentication and try again.

)} + {projectId ? ( + + onChange({ labels })} /> + + ) : null} )} diff --git a/frontend/src/renderer/components/LabelPicker.test.tsx b/frontend/src/renderer/components/LabelPicker.test.tsx new file mode 100644 index 0000000000..8b6854cd30 --- /dev/null +++ b/frontend/src/renderer/components/LabelPicker.test.tsx @@ -0,0 +1,68 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { beforeEach, expect, it, vi } from "vitest"; + +const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() })); + +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock }, + apiErrorMessage: () => "Request failed", +})); + +import { LabelPicker } from "./LabelPicker"; + +function TestPicker() { + const [labels, setLabels] = useState([]); + return ; +} + +function renderPicker() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + render( + + + , + ); +} + +beforeEach(() => { + getMock.mockReset(); + getMock.mockResolvedValue({ + data: { + labels: [ + { name: "bug", color: "d73a4a", description: "Something is broken" }, + { name: "ready for agent", color: "4d8dff", description: "Ready for implementation" }, + ], + }, + error: undefined, + }); +}); + +it("searches and multi-selects repository labels without closing", async () => { + renderPicker(); + await userEvent.click(screen.getByRole("button", { name: "Labels" })); + + const search = await screen.findByPlaceholderText("Search labels…"); + await userEvent.type(search, "ready"); + expect(screen.queryByText("bug")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("option", { name: /ready for agent/i })); + expect(search).toBeVisible(); + + await userEvent.clear(search); + await userEvent.click(screen.getByRole("option", { name: /bug/i })); + await userEvent.keyboard("{Escape}"); + expect(screen.getByRole("button", { name: "Labels" })).toHaveTextContent("2 labels selected"); +}); + +it("requests an immediate refresh", async () => { + renderPicker(); + await userEvent.click(screen.getByRole("button", { name: "Labels" })); + await screen.findByText("bug"); + await userEvent.click(screen.getByRole("button", { name: "Refresh labels" })); + expect(getMock).toHaveBeenLastCalledWith( + "/api/v1/projects/{id}/tracker-intake/github/labels", + expect.objectContaining({ params: { path: { id: "demo" }, query: { refresh: true } } }), + ); +}); diff --git a/frontend/src/renderer/components/LabelPicker.tsx b/frontend/src/renderer/components/LabelPicker.tsx new file mode 100644 index 0000000000..eb4449b322 --- /dev/null +++ b/frontend/src/renderer/components/LabelPicker.tsx @@ -0,0 +1,123 @@ +import { Check, ChevronDown, RefreshCw, Search } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Popover as PopoverPrimitive } from "radix-ui"; +import { useTrackerIntakeLabels, type TrackerLabel } from "../hooks/useTrackerIntakeLabels"; + +export function LabelPicker({ + projectId, + value, + onChange, +}: { + projectId: string; + value: string[]; + onChange: (labels: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const labelsQuery = useTrackerIntakeLabels(projectId, open); + const options = useMemo( + () => mergeMissingLabels(labelsQuery.data?.labels ?? [], value), + [labelsQuery.data?.labels, value], + ); + const needle = search.trim().toLocaleLowerCase(); + const filtered = options.filter((label) => { + if (needle === "") return true; + return label.name.toLocaleLowerCase().includes(needle) || (label.description ?? "").toLocaleLowerCase().includes(needle); + }); + const summary = value.length === 0 ? "All labels" : value.length === 1 ? value[0] : `${value.length} labels selected`; + + function toggle(name: string) { + if (value.some((label) => label.toLocaleLowerCase() === name.toLocaleLowerCase())) { + onChange(value.filter((label) => label.toLocaleLowerCase() !== name.toLocaleLowerCase())); + return; + } + onChange([...value, name]); + } + + return ( + + + + + + +
+ + +
+
+ {labelsQuery.isLoading ? Loading labels… : null} + {labelsQuery.isError || labelsQuery.refresh.isError ? Could not load repository labels. : null} + {!labelsQuery.isLoading && !labelsQuery.isError && filtered.length === 0 ? No labels found. : null} + {filtered.map((label) => { + const selected = value.some((item) => item.toLocaleLowerCase() === label.name.toLocaleLowerCase()); + return ( + + ); + })} +
+
+
+
+ ); +} + +function mergeMissingLabels(labels: TrackerLabel[], selected: string[]): TrackerLabel[] { + const names = new Set(labels.map((label) => label.name.toLocaleLowerCase())); + const missing = selected + .filter((name) => !names.has(name.toLocaleLowerCase())) + .map((name) => ({ name, color: "9a9a9a", description: "No longer available in this repository" })); + return [...missing, ...labels]; +} + +function labelColor(color: string): string { + return /^[0-9a-f]{6}$/i.test(color) ? `#${color}` : "#9a9a9a"; +} + +function PickerNote({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 22ce83ca49..ca932f1e67 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -83,6 +83,17 @@ function mockProject(project: Record) { if (path === "/api/v1/tracker-intake/github/user") { return { data: { login: "octocat" }, error: undefined }; } + if (path === "/api/v1/projects/{id}/tracker-intake/github/labels") { + return { + data: { + labels: [ + { name: "bug", color: "d73a4a", description: "Something is broken" }, + { name: "ready", color: "4d8dff", description: "Ready for an agent" }, + ], + }, + error: undefined, + }; + } return { data: { status: "ok", @@ -297,6 +308,10 @@ describe("ProjectSettingsForm", () => { "https://github.com/acme/project-one", ); expect(await screen.findByText("Issues assigned to @octocat")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Labels" })); + await userEvent.click(await screen.findByRole("option", { name: /bug/i })); + await userEvent.click(screen.getByRole("option", { name: /ready/i })); + await userEvent.keyboard("{Escape}"); await userEvent.click(screen.getByRole("button", { name: "Save changes" })); @@ -305,6 +320,7 @@ describe("ProjectSettingsForm", () => { expect(body.config.trackerIntake).toEqual({ enabled: true, provider: "github", + labels: ["bug", "ready"], }); }); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index e4bf5a3237..32338afb1e 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -86,6 +86,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje reviewerHarness: config.reviewers?.[0]?.harness ?? "", intakeEnabled: intake.enabled ?? false, intakeRepo: intake.repo ?? "", + intakeLabels: intake.labels ?? [], }); const [savedAt, setSavedAt] = useState(null); const [replacementError, setReplacementError] = useState(null); @@ -106,12 +107,14 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje const intakeForm: IntakeForm = { enabled: form.intakeEnabled, repo: form.intakeRepo, + labels: form.intakeLabels, }; const patchIntake = (patch: Partial) => setForm((f) => ({ ...f, intakeEnabled: patch.enabled ?? f.intakeEnabled, intakeRepo: patch.repo ?? f.intakeRepo, + intakeLabels: patch.labels ?? f.intakeLabels, })); const effectiveIntakeRepo = form.intakeRepo.trim() || deriveGitHubRepo(project.repo); @@ -334,7 +337,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje Tracker intake - + diff --git a/frontend/src/renderer/hooks/useTrackerIntakeLabels.ts b/frontend/src/renderer/hooks/useTrackerIntakeLabels.ts new file mode 100644 index 0000000000..31f52b2797 --- /dev/null +++ b/frontend/src/renderer/hooks/useTrackerIntakeLabels.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; + +export type TrackerLabel = components["schemas"]["TrackerLabel"]; +type LabelsResponse = components["schemas"]["TrackerIntakeLabelsResponse"]; + +export const trackerIntakeLabelsQueryKey = (projectId: string) => ["tracker-intake", "github", "labels", projectId] as const; + +async function fetchLabels(projectId: string, refresh: boolean): Promise { + const { data, error } = await apiClient.GET("/api/v1/projects/{id}/tracker-intake/github/labels", { + params: { path: { id: projectId }, query: { refresh } }, + }); + if (error) throw new Error(apiErrorMessage(error)); + return data as LabelsResponse; +} + +export function useTrackerIntakeLabels(projectId: string, enabled: boolean) { + const queryClient = useQueryClient(); + const queryKey = trackerIntakeLabelsQueryKey(projectId); + const query = useQuery({ + queryKey, + queryFn: () => fetchLabels(projectId, false), + enabled: enabled && projectId !== "", + staleTime: 5 * 60 * 1000, + retry: 1, + }); + const refresh = useMutation({ + mutationFn: () => fetchLabels(projectId, true), + onSuccess: (data) => queryClient.setQueryData(queryKey, data), + }); + return { ...query, refresh }; +} diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 35fbaec734..6e08961f80 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -60,6 +60,7 @@ const ROUTE_TEMPLATES = [ "/api/v1/projects", "/api/v1/projects/{id}", "/api/v1/projects/{id}/config", + "/api/v1/projects/{id}/tracker-intake/github/labels", "/api/v1/tracker-intake/github/user", "/api/v1/prs/{id}/merge", "/api/v1/prs/{id}/resolve-comments", From cd25d2119f6b67e5d7bbe46692fed4c0842d469e Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Tue, 7 Jul 2026 22:12:20 +0530 Subject: [PATCH 3/7] feat: preview matching GitHub issues --- backend/internal/httpd/apispec/openapi.yaml | 65 ++++++++++++++ .../internal/httpd/apispec/specgen/build.go | 15 ++++ backend/internal/httpd/controllers/dto.go | 10 +++ .../httpd/controllers/tracker_intake.go | 19 ++++ .../httpd/controllers/tracker_intake_test.go | 37 ++++++-- .../observe/trackerintake/observer.go | 11 +-- .../internal/service/trackerintake/service.go | 60 +++++++++++-- .../service/trackerintake/service_test.go | 32 +++++++ backend/internal/trackerintake/scope.go | 15 ++++ backend/internal/trackerintake/scope_test.go | 9 ++ frontend/src/api/schema.ts | 86 +++++++++++++++++++ .../src/renderer/components/IntakeFields.tsx | 10 ++- .../components/MatchingIssuesPreview.tsx | 14 +++ .../components/ProjectSettingsForm.test.tsx | 18 +++- .../renderer/hooks/useTrackerIntakePreview.ts | 31 +++++++ frontend/src/renderer/lib/api-client.ts | 1 + 16 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 frontend/src/renderer/components/MatchingIssuesPreview.tsx create mode 100644 frontend/src/renderer/hooks/useTrackerIntakePreview.ts diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 3021855046..d460b44909 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -676,6 +676,57 @@ paths: summary: List labels from the project's GitHub repository tags: - tracker-intake + /api/v1/projects/{id}/tracker-intake/github/preview: + post: + operationId: previewTrackerIntakeIssues + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TrackerIntakePreviewRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/TrackerIntakePreviewResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Count open GitHub issues matching proposed intake filters + tags: + - tracker-intake /api/v1/prs/{id}/merge: post: operationId: mergePR @@ -2804,6 +2855,20 @@ components: required: - labels type: object + TrackerIntakePreviewRequest: + properties: + labels: + items: + type: string + type: array + type: object + TrackerIntakePreviewResponse: + properties: + count: + type: integer + required: + - count + type: object TrackerLabel: properties: color: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index c8eb4cf8c6..299cb7abb4 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -143,6 +143,8 @@ var schemaNames = map[string]string{ "ControllersTrackerIntakeIdentityResponse": "TrackerIntakeIdentityResponse", "ControllersTrackerIntakeLabelsQuery": "TrackerIntakeLabelsQuery", "ControllersTrackerIntakeLabelsResponse": "TrackerIntakeLabelsResponse", + "ControllersTrackerIntakePreviewRequest": "TrackerIntakePreviewRequest", + "ControllersTrackerIntakePreviewResponse": "TrackerIntakePreviewResponse", "DomainTrackerLabel": "TrackerLabel", "ControllersListSessionsQuery": "ListSessionsQuery", "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", @@ -323,6 +325,19 @@ func trackerIntakeOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, + { + method: http.MethodPost, path: "/api/v1/projects/{id}/tracker-intake/github/preview", id: "previewTrackerIntakeIssues", tag: "tracker-intake", + summary: "Count open GitHub issues matching proposed intake filters", + pathParams: []any{controllers.ProjectIDParam{}}, + reqBody: controllers.TrackerIntakePreviewRequest{}, + resps: []respUnit{ + {http.StatusOK, controllers.TrackerIntakePreviewResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, } } diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index c359fcdd8d..1a3dbefc22 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -47,6 +47,16 @@ type TrackerIntakeLabelsResponse struct { Labels []domain.TrackerLabel `json:"labels"` } +// TrackerIntakePreviewRequest contains unsaved label filters to preview. +type TrackerIntakePreviewRequest struct { + Labels []string `json:"labels,omitempty"` +} + +// TrackerIntakePreviewResponse is the count of matching open issues. +type TrackerIntakePreviewResponse struct { + Count int `json:"count"` +} + // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { Projects []projectsvc.Summary `json:"projects"` diff --git a/backend/internal/httpd/controllers/tracker_intake.go b/backend/internal/httpd/controllers/tracker_intake.go index 4771140f7d..d7deefdc6b 100644 --- a/backend/internal/httpd/controllers/tracker_intake.go +++ b/backend/internal/httpd/controllers/tracker_intake.go @@ -21,6 +21,25 @@ type TrackerIntakeController struct { func (c *TrackerIntakeController) Register(r chi.Router) { r.Get("/tracker-intake/github/user", c.identity) r.Get("/projects/{id}/tracker-intake/github/labels", c.labels) + r.Post("/projects/{id}/tracker-intake/github/preview", c.preview) +} + +func (c *TrackerIntakeController) preview(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/projects/{id}/tracker-intake/github/preview") + return + } + var in TrackerIntakePreviewRequest + if err := decodeJSONStrict(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + preview, err := c.Svc.Preview(r.Context(), projectID(r), in.Labels) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, TrackerIntakePreviewResponse{Count: preview.Count}) } func (c *TrackerIntakeController) labels(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/httpd/controllers/tracker_intake_test.go b/backend/internal/httpd/controllers/tracker_intake_test.go index f0f7ae91a0..03efdc9ff6 100644 --- a/backend/internal/httpd/controllers/tracker_intake_test.go +++ b/backend/internal/httpd/controllers/tracker_intake_test.go @@ -12,6 +12,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + trackerintakesvc "github.com/aoagents/agent-orchestrator/backend/internal/service/trackerintake" ) func TestGetTrackerIntakeIdentity(t *testing.T) { @@ -48,12 +49,38 @@ func TestListTrackerIntakeLabels(t *testing.T) { } } +func TestPreviewTrackerIntakeIssues(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := &fakeTrackerIntakeService{preview: trackerintakesvc.Preview{Count: 7}} + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + TrackerIntake: svc, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/projects/demo/tracker-intake/github/preview", `{"labels":["bug","ready"]}`) + if status != http.StatusOK { + t.Fatalf("POST preview = %d, body=%s", status, body) + } + if !strings.Contains(string(body), `"count":7`) || svc.previewProjectID != "demo" || len(svc.previewLabels) != 2 { + t.Fatalf("body=%s project=%q labels=%#v", body, svc.previewProjectID, svc.previewLabels) + } +} + type fakeTrackerIntakeService struct { - user domain.TrackerUser - labels []domain.TrackerLabel - labelsProjectID domain.ProjectID - refresh bool - err error + user domain.TrackerUser + labels []domain.TrackerLabel + labelsProjectID domain.ProjectID + refresh bool + preview trackerintakesvc.Preview + previewProjectID domain.ProjectID + previewLabels []string + err error +} + +func (f *fakeTrackerIntakeService) Preview(_ context.Context, projectID domain.ProjectID, labels []string) (trackerintakesvc.Preview, error) { + f.previewProjectID = projectID + f.previewLabels = append([]string(nil), labels...) + return f.preview, f.err } func (f *fakeTrackerIntakeService) Labels(_ context.Context, projectID domain.ProjectID, refresh bool) ([]domain.TrackerLabel, error) { diff --git a/backend/internal/observe/trackerintake/observer.go b/backend/internal/observe/trackerintake/observer.go index 5bb8f5dafd..56dd11bde0 100644 --- a/backend/internal/observe/trackerintake/observer.go +++ b/backend/internal/observe/trackerintake/observer.go @@ -202,7 +202,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord if issue.State != domain.IssueOpen { continue } - if !containsFold(issue.Assignees, user.Login) || !intakescope.MatchesAllLabels(issue.Labels, cfg.Labels) { + if !intakescope.MatchesAssignee(issue.Assignees, user.Login) || !intakescope.MatchesAllLabels(issue.Labels, cfg.Labels) { continue } issueID := CanonicalIssueID(issue.ID) @@ -224,15 +224,6 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord return spawnFailed } -func containsFold(values []string, needle string) bool { - for _, value := range values { - if strings.EqualFold(strings.TrimSpace(value), needle) { - return true - } - } - return false -} - func seenIssueIDs(sessions []domain.SessionRecord) map[domain.IssueID]bool { seen := make(map[domain.IssueID]bool, len(sessions)) for _, sess := range sessions { diff --git a/backend/internal/service/trackerintake/service.go b/backend/internal/service/trackerintake/service.go index a782e14625..99f8a76db2 100644 --- a/backend/internal/service/trackerintake/service.go +++ b/backend/internal/service/trackerintake/service.go @@ -17,6 +17,7 @@ const defaultLabelCacheTTL = 5 * time.Minute // Tracker is the provider surface needed by tracker intake configuration. type Tracker interface { AuthenticatedUser(ctx context.Context) (domain.TrackerUser, error) + List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) ListLabels(ctx context.Context, repo domain.TrackerRepo) ([]domain.TrackerLabel, error) } @@ -29,6 +30,12 @@ type ProjectStore interface { type Manager interface { Identity(ctx context.Context) (domain.TrackerUser, error) Labels(ctx context.Context, projectID domain.ProjectID, refresh bool) ([]domain.TrackerLabel, error) + Preview(ctx context.Context, projectID domain.ProjectID, labels []string) (Preview, error) +} + +// Preview is the live count of open issues matching the proposed filters. +type Preview struct { + Count int `json:"count"` } // Service serves tracker intake configuration data from one GitHub adapter. @@ -83,16 +90,9 @@ func (s *Service) Labels(ctx context.Context, projectID domain.ProjectID, refres if s == nil || s.tracker == nil || s.store == nil { return nil, apierr.Internal("GITHUB_LABELS_UNAVAILABLE", "GitHub labels are unavailable") } - project, ok, err := s.store.GetProject(ctx, string(projectID)) + repo, err := s.repository(ctx, projectID) if err != nil { - return nil, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project") - } - if !ok || !project.ArchivedAt.IsZero() { - return nil, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") - } - repo, ok := intakescope.Repository(project, project.Config.TrackerIntake.WithDefaults()) - if !ok { - return nil, apierr.Invalid("GITHUB_REPOSITORY_UNAVAILABLE", "Project has no GitHub repository for issue intake", nil) + return nil, err } s.cacheMu.Lock() @@ -109,6 +109,48 @@ func (s *Service) Labels(ctx context.Context, projectID domain.ProjectID, refres return cloneLabels(labels), nil } +// Preview counts open issues matching the authenticated user and proposed +// label selection without persisting config or spawning sessions. +func (s *Service) Preview(ctx context.Context, projectID domain.ProjectID, labels []string) (Preview, error) { + if s == nil || s.tracker == nil || s.store == nil { + return Preview{}, apierr.Internal("GITHUB_PREVIEW_UNAVAILABLE", "GitHub issue preview is unavailable") + } + repo, err := s.repository(ctx, projectID) + if err != nil { + return Preview{}, err + } + user, err := s.Identity(ctx) + if err != nil { + return Preview{}, err + } + issues, err := s.tracker.List(ctx, repo, domain.ListFilter{State: domain.ListOpen, Labels: labels, Assignee: user.Login}) + if err != nil { + return Preview{}, apierr.Internal("GITHUB_PREVIEW_FAILED", "Failed to preview matching GitHub issues") + } + count := 0 + for _, issue := range issues { + if issue.State == domain.IssueOpen && intakescope.MatchesAssignee(issue.Assignees, user.Login) && intakescope.MatchesAllLabels(issue.Labels, labels) { + count++ + } + } + return Preview{Count: count}, nil +} + +func (s *Service) repository(ctx context.Context, projectID domain.ProjectID) (domain.TrackerRepo, error) { + project, ok, err := s.store.GetProject(ctx, string(projectID)) + if err != nil { + return domain.TrackerRepo{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project") + } + if !ok || !project.ArchivedAt.IsZero() { + return domain.TrackerRepo{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") + } + repo, ok := intakescope.Repository(project, project.Config.TrackerIntake.WithDefaults()) + if !ok { + return domain.TrackerRepo{}, apierr.Invalid("GITHUB_REPOSITORY_UNAVAILABLE", "Project has no GitHub repository for issue intake", nil) + } + return repo, nil +} + func cloneLabels(labels []domain.TrackerLabel) []domain.TrackerLabel { out := make([]domain.TrackerLabel, len(labels)) copy(out, labels) diff --git a/backend/internal/service/trackerintake/service_test.go b/backend/internal/service/trackerintake/service_test.go index b6d2a128fc..459398cb84 100644 --- a/backend/internal/service/trackerintake/service_test.go +++ b/backend/internal/service/trackerintake/service_test.go @@ -61,11 +61,38 @@ func TestLabelsCachesAndRefreshesRepositoryCatalog(t *testing.T) { } } +func TestPreviewCountsOpenIssuesMatchingUserAndLabels(t *testing.T) { + tracker := &fakeTracker{ + user: domain.TrackerUser{Login: "octocat"}, + issues: []domain.Issue{ + {State: domain.IssueOpen, Labels: []string{"bug", "ready"}, Assignees: []string{"OctoCat"}}, + {State: domain.IssueOpen, Labels: []string{"bug"}, Assignees: []string{"octocat"}}, + {State: domain.IssueOpen, Labels: []string{"bug", "ready"}, Assignees: []string{"someone-else"}}, + {State: domain.IssueDone, Labels: []string{"bug", "ready"}, Assignees: []string{"octocat"}}, + }, + } + store := &fakeProjectStore{project: domain.ProjectRecord{ID: "demo", RepoOriginURL: "https://github.com/acme/demo.git"}} + svc := NewWithDeps(Deps{Tracker: tracker, Store: store}) + + preview, err := svc.Preview(context.Background(), "demo", []string{"bug", "ready"}) + if err != nil { + t.Fatalf("Preview: %v", err) + } + if preview.Count != 1 { + t.Fatalf("count = %d, want 1", preview.Count) + } + if tracker.listFilter.Assignee != "octocat" || len(tracker.listFilter.Labels) != 2 || tracker.listFilter.State != domain.ListOpen { + t.Fatalf("filter = %#v", tracker.listFilter) + } +} + type fakeTracker struct { user domain.TrackerUser userErr error labels []domain.TrackerLabel labelCalls int + issues []domain.Issue + listFilter domain.ListFilter } func (f *fakeTracker) ListLabels(context.Context, domain.TrackerRepo) ([]domain.TrackerLabel, error) { @@ -73,6 +100,11 @@ func (f *fakeTracker) ListLabels(context.Context, domain.TrackerRepo) ([]domain. return append([]domain.TrackerLabel(nil), f.labels...), nil } +func (f *fakeTracker) List(_ context.Context, _ domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + f.listFilter = filter + return append([]domain.Issue(nil), f.issues...), nil +} + type fakeProjectStore struct { project domain.ProjectRecord found bool diff --git a/backend/internal/trackerintake/scope.go b/backend/internal/trackerintake/scope.go index cdc9a74efd..59dc06995b 100644 --- a/backend/internal/trackerintake/scope.go +++ b/backend/internal/trackerintake/scope.go @@ -47,6 +47,21 @@ func MatchesAllLabels(issueLabels, selected []string) bool { return true } +// MatchesAssignee reports whether the authenticated login is assigned to the +// issue, using GitHub's case-insensitive login semantics. +func MatchesAssignee(assignees []string, login string) bool { + login = strings.TrimSpace(login) + if login == "" { + return false + } + for _, assignee := range assignees { + if strings.EqualFold(strings.TrimSpace(assignee), login) { + return true + } + } + return false +} + func parseGitHubRepoNative(remote string) string { remote = strings.TrimSpace(remote) if remote == "" { diff --git a/backend/internal/trackerintake/scope_test.go b/backend/internal/trackerintake/scope_test.go index f0a6d7561d..84442078bc 100644 --- a/backend/internal/trackerintake/scope_test.go +++ b/backend/internal/trackerintake/scope_test.go @@ -29,3 +29,12 @@ func TestMatchesAllLabels(t *testing.T) { t.Fatal("empty selection should match") } } + +func TestMatchesAssignee(t *testing.T) { + if !MatchesAssignee([]string{"OctoCat"}, "octocat") { + t.Fatal("expected case-insensitive assignee match") + } + if MatchesAssignee([]string{"someone-else"}, "octocat") { + t.Fatal("unexpected assignee match") + } +} diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b0fd2a3e3b..8f6578fb95 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -263,6 +263,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/projects/{id}/tracker-intake/github/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Count open GitHub issues matching proposed intake filters */ + post: operations["previewTrackerIntakeIssues"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/prs/{id}/merge": { parameters: { query?: never; @@ -1033,6 +1050,12 @@ export interface components { TrackerIntakeLabelsResponse: { labels: components["schemas"]["TrackerLabel"][]; }; + TrackerIntakePreviewRequest: { + labels?: string[]; + }; + TrackerIntakePreviewResponse: { + count: number; + }; TrackerLabel: { color: string; description?: string; @@ -1937,6 +1960,69 @@ export interface operations { }; }; }; + previewTrackerIntakeIssues: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project identifier (registry key). */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TrackerIntakePreviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackerIntakePreviewResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; mergePR: { parameters: { query?: never; diff --git a/frontend/src/renderer/components/IntakeFields.tsx b/frontend/src/renderer/components/IntakeFields.tsx index f3cec4e88c..41de4e1138 100644 --- a/frontend/src/renderer/components/IntakeFields.tsx +++ b/frontend/src/renderer/components/IntakeFields.tsx @@ -2,6 +2,7 @@ import { Info } from "lucide-react"; import type { components } from "../../api/schema"; import { useTrackerIntakeIdentity } from "../hooks/useTrackerIntakeIdentity"; import { LabelPicker } from "./LabelPicker"; +import { MatchingIssuesPreview } from "./MatchingIssuesPreview"; import { Label } from "./ui/label"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; @@ -155,9 +156,12 @@ export function IntakeFields({

Check GitHub authentication and try again.

)} {projectId ? ( - - onChange({ labels })} /> - + <> + + onChange({ labels })} /> + + + ) : null} )} diff --git a/frontend/src/renderer/components/MatchingIssuesPreview.tsx b/frontend/src/renderer/components/MatchingIssuesPreview.tsx new file mode 100644 index 0000000000..f8f64b682d --- /dev/null +++ b/frontend/src/renderer/components/MatchingIssuesPreview.tsx @@ -0,0 +1,14 @@ +import { useTrackerIntakePreview } from "../hooks/useTrackerIntakePreview"; + +export function MatchingIssuesPreview({ projectId, labels }: { projectId: string; labels: string[] }) { + const preview = useTrackerIntakePreview(projectId, labels, true); + const checking = preview.isDebouncing || preview.isFetching; + return ( +
+ Matching open issues + + {checking ? "Checking…" : preview.isError ? "Unavailable" : (preview.data?.count ?? 0)} + +
+ ); +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index ca932f1e67..aa0229979f 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -109,10 +109,15 @@ beforeEach(() => { putMock.mockReset(); postMock.mockReset(); putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); - postMock.mockResolvedValue({ - data: { orchestrator: { id: "proj-1-orch-2" } }, - error: undefined, - response: { status: 200 }, + postMock.mockImplementation(async (path: string) => { + if (path === "/api/v1/projects/{id}/tracker-intake/github/preview") { + return { data: { count: 3 }, error: undefined, response: { status: 200 } }; + } + return { + data: { orchestrator: { id: "proj-1-orch-2" } }, + error: undefined, + response: { status: 200 }, + }; }); }); @@ -312,6 +317,11 @@ describe("ProjectSettingsForm", () => { await userEvent.click(await screen.findByRole("option", { name: /bug/i })); await userEvent.click(screen.getByRole("option", { name: /ready/i })); await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(screen.getByText("Matching open issues").parentElement).toHaveTextContent("3")); + expect(postMock).toHaveBeenCalledWith("/api/v1/projects/{id}/tracker-intake/github/preview", { + params: { path: { id: "proj-1" } }, + body: { labels: ["bug", "ready"] }, + }); await userEvent.click(screen.getByRole("button", { name: "Save changes" })); diff --git a/frontend/src/renderer/hooks/useTrackerIntakePreview.ts b/frontend/src/renderer/hooks/useTrackerIntakePreview.ts new file mode 100644 index 0000000000..1aa699c8d8 --- /dev/null +++ b/frontend/src/renderer/hooks/useTrackerIntakePreview.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import type { components } from "../../api/schema"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; + +type PreviewResponse = components["schemas"]["TrackerIntakePreviewResponse"]; + +export function useTrackerIntakePreview(projectId: string, labels: string[], enabled: boolean) { + const labelsKey = labels.join("\u0000"); + const [debouncedLabels, setDebouncedLabels] = useState(labels); + const debouncedKey = debouncedLabels.join("\u0000"); + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedLabels(labels), 300); + return () => window.clearTimeout(timer); + }, [labelsKey, labels]); + + const query = useQuery({ + queryKey: ["tracker-intake", "github", "preview", projectId, debouncedKey], + queryFn: async (): Promise => { + const { data, error } = await apiClient.POST("/api/v1/projects/{id}/tracker-intake/github/preview", { + params: { path: { id: projectId } }, + body: { labels: debouncedLabels }, + }); + if (error) throw new Error(apiErrorMessage(error)); + return data as PreviewResponse; + }, + enabled: enabled && projectId !== "", + retry: 1, + }); + return { ...query, isDebouncing: labelsKey !== debouncedKey }; +} diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 6e08961f80..8f5b954168 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -61,6 +61,7 @@ const ROUTE_TEMPLATES = [ "/api/v1/projects/{id}", "/api/v1/projects/{id}/config", "/api/v1/projects/{id}/tracker-intake/github/labels", + "/api/v1/projects/{id}/tracker-intake/github/preview", "/api/v1/tracker-intake/github/user", "/api/v1/prs/{id}/merge", "/api/v1/prs/{id}/resolve-comments", From f30719147400e559606880d01439cf7fb0c2a94d Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Tue, 7 Jul 2026 22:18:31 +0530 Subject: [PATCH 4/7] fix: validate GitHub intake filters --- backend/internal/domain/projectconfig_test.go | 1 + backend/internal/domain/tracker.go | 5 ++++- .../internal/service/trackerintake/service.go | 3 +++ .../service/trackerintake/service_test.go | 9 +++++++++ .../src/renderer/components/LabelPicker.test.tsx | 16 ++++++++++++---- frontend/src/renderer/components/LabelPicker.tsx | 2 +- .../components/MatchingIssuesPreview.tsx | 10 +++++----- 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 33105f0540..25c8a91701 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -33,6 +33,7 @@ func TestProjectConfigValidate(t *testing.T) { {"tracker intake explicit github", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderGitHub}}, false}, {"tracker intake labels", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"bug", "ready"}}}, false}, {"tracker intake empty label", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"bug", " "}}}, true}, + {"tracker intake label whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{" bug"}}}, true}, {"tracker intake unknown provider", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: "linear"}}, true}, {"tracker intake repo with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Repo: " acme/demo"}}, true}, } diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index c4821d33c2..0a65ec227b 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -123,10 +123,13 @@ func (c TrackerIntakeConfig) Validate() error { if err := validateNoWhitespaceField("trackerIntake.repo", c.Repo); err != nil { return err } - for _, label := range c.Labels { + for index, label := range c.Labels { if strings.TrimSpace(label) == "" { return fmt.Errorf("trackerIntake.labels: label names must not be empty") } + if err := validateNoWhitespaceField(fmt.Sprintf("trackerIntake.labels[%d]", index), label); err != nil { + return err + } } return nil } diff --git a/backend/internal/service/trackerintake/service.go b/backend/internal/service/trackerintake/service.go index 99f8a76db2..d46946ff66 100644 --- a/backend/internal/service/trackerintake/service.go +++ b/backend/internal/service/trackerintake/service.go @@ -115,6 +115,9 @@ func (s *Service) Preview(ctx context.Context, projectID domain.ProjectID, label if s == nil || s.tracker == nil || s.store == nil { return Preview{}, apierr.Internal("GITHUB_PREVIEW_UNAVAILABLE", "GitHub issue preview is unavailable") } + if err := (domain.TrackerIntakeConfig{Enabled: true, Labels: labels}).Validate(); err != nil { + return Preview{}, apierr.Invalid("INVALID_TRACKER_INTAKE_FILTER", err.Error(), nil) + } repo, err := s.repository(ctx, projectID) if err != nil { return Preview{}, err diff --git a/backend/internal/service/trackerintake/service_test.go b/backend/internal/service/trackerintake/service_test.go index 459398cb84..588b48836e 100644 --- a/backend/internal/service/trackerintake/service_test.go +++ b/backend/internal/service/trackerintake/service_test.go @@ -86,6 +86,15 @@ func TestPreviewCountsOpenIssuesMatchingUserAndLabels(t *testing.T) { } } +func TestPreviewRejectsInvalidLabels(t *testing.T) { + svc := NewWithDeps(Deps{Tracker: &fakeTracker{}, Store: &fakeProjectStore{project: domain.ProjectRecord{ID: "demo"}}}) + _, err := svc.Preview(context.Background(), "demo", []string{" "}) + var apiError *apierr.Error + if !errors.As(err, &apiError) || apiError.Code != "INVALID_TRACKER_INTAKE_FILTER" { + t.Fatalf("err = %#v, want INVALID_TRACKER_INTAKE_FILTER", err) + } +} + type fakeTracker struct { user domain.TrackerUser userErr error diff --git a/frontend/src/renderer/components/LabelPicker.test.tsx b/frontend/src/renderer/components/LabelPicker.test.tsx index 8b6854cd30..c4ed09eea0 100644 --- a/frontend/src/renderer/components/LabelPicker.test.tsx +++ b/frontend/src/renderer/components/LabelPicker.test.tsx @@ -13,16 +13,16 @@ vi.mock("../lib/api-client", () => ({ import { LabelPicker } from "./LabelPicker"; -function TestPicker() { - const [labels, setLabels] = useState([]); +function TestPicker({ initialLabels = [] }: { initialLabels?: string[] }) { + const [labels, setLabels] = useState(initialLabels); return ; } -function renderPicker() { +function renderPicker(initialLabels: string[] = []) { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); render( - + , ); } @@ -66,3 +66,11 @@ it("requests an immediate refresh", async () => { expect.objectContaining({ params: { path: { id: "demo" }, query: { refresh: true } } }), ); }); + +it("does not mark selected labels unavailable while the catalog loads", async () => { + getMock.mockReturnValue(new Promise(() => {})); + renderPicker(["bug"]); + await userEvent.click(screen.getByRole("button", { name: "Labels" })); + expect(screen.getByText("Loading labels…")).toBeInTheDocument(); + expect(screen.queryByText("No longer available in this repository")).not.toBeInTheDocument(); +}); diff --git a/frontend/src/renderer/components/LabelPicker.tsx b/frontend/src/renderer/components/LabelPicker.tsx index eb4449b322..7f2facc57e 100644 --- a/frontend/src/renderer/components/LabelPicker.tsx +++ b/frontend/src/renderer/components/LabelPicker.tsx @@ -16,7 +16,7 @@ export function LabelPicker({ const [search, setSearch] = useState(""); const labelsQuery = useTrackerIntakeLabels(projectId, open); const options = useMemo( - () => mergeMissingLabels(labelsQuery.data?.labels ?? [], value), + () => (labelsQuery.data ? mergeMissingLabels(labelsQuery.data.labels, value) : []), [labelsQuery.data?.labels, value], ); const needle = search.trim().toLocaleLowerCase(); diff --git a/frontend/src/renderer/components/MatchingIssuesPreview.tsx b/frontend/src/renderer/components/MatchingIssuesPreview.tsx index f8f64b682d..dbce834921 100644 --- a/frontend/src/renderer/components/MatchingIssuesPreview.tsx +++ b/frontend/src/renderer/components/MatchingIssuesPreview.tsx @@ -5,10 +5,10 @@ export function MatchingIssuesPreview({ projectId, labels }: { projectId: string const checking = preview.isDebouncing || preview.isFetching; return (
- Matching open issues - - {checking ? "Checking…" : preview.isError ? "Unavailable" : (preview.data?.count ?? 0)} - -
+ Matching open issues + + {checking ? "Checking…" : preview.isError ? "Unavailable" : (preview.data?.count ?? 0)} + + ); } From 5c7b373afac68ee8e831ba6bad181d7386314416 Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Tue, 7 Jul 2026 22:21:52 +0530 Subject: [PATCH 5/7] test: cover GitHub intake API errors --- .../httpd/controllers/tracker_intake_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/internal/httpd/controllers/tracker_intake_test.go b/backend/internal/httpd/controllers/tracker_intake_test.go index 03efdc9ff6..03ec7d33af 100644 --- a/backend/internal/httpd/controllers/tracker_intake_test.go +++ b/backend/internal/httpd/controllers/tracker_intake_test.go @@ -12,6 +12,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" trackerintakesvc "github.com/aoagents/agent-orchestrator/backend/internal/service/trackerintake" ) @@ -66,6 +67,25 @@ func TestPreviewTrackerIntakeIssues(t *testing.T) { } } +func TestTrackerIntakeValidationAndErrorEnvelopes(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := &fakeTrackerIntakeService{} + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + TrackerIntake: svc, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/projects/demo/tracker-intake/github/labels?refresh=sometimes", "") + assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_REFRESH") + + body, status, _ = doRequest(t, srv, http.MethodPost, "/api/v1/projects/demo/tracker-intake/github/preview", `{`) + assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") + + svc.err = apierr.Invalid("INVALID_TRACKER_INTAKE_FILTER", "invalid label filter", nil) + body, status, _ = doRequest(t, srv, http.MethodPost, "/api/v1/projects/demo/tracker-intake/github/preview", `{"labels":[" "]}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_TRACKER_INTAKE_FILTER") +} + type fakeTrackerIntakeService struct { user domain.TrackerUser labels []domain.TrackerLabel From f88715ee2214410e72cd532394b746905205161d Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Wed, 8 Jul 2026 04:31:43 +0530 Subject: [PATCH 6/7] feat: refine GitHub intake layout --- .../CreateProjectAgentSheet.test.tsx | 2 +- .../src/renderer/components/IntakeFields.tsx | 64 +++++++++++-------- .../components/MatchingIssuesPreview.tsx | 14 ++-- .../components/ProjectSettingsForm.test.tsx | 10 ++- 4 files changed, 54 insertions(+), 36 deletions(-) diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx index 16dd7f1ed9..ad93164099 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx @@ -65,7 +65,7 @@ describe("CreateProjectAgentSheet", () => { await chooseOption(screen.getByLabelText("Orchestrator agent"), "codex"); await userEvent.click(screen.getByLabelText("Enable issue intake")); - expect(screen.getByText("Issues assigned to @octocat")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "octocat" })).toHaveAttribute("href", "https://github.com/octocat"); await userEvent.click(screen.getByRole("button", { name: "Create and start" })); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); diff --git a/frontend/src/renderer/components/IntakeFields.tsx b/frontend/src/renderer/components/IntakeFields.tsx index 41de4e1138..86731208e9 100644 --- a/frontend/src/renderer/components/IntakeFields.tsx +++ b/frontend/src/renderer/components/IntakeFields.tsx @@ -89,6 +89,20 @@ export function IntakeFields({ compact?: boolean; }) { const identityQuery = useTrackerIntakeIdentity(form.enabled); + const assignee = identityQuery.data ? ( + + {identityQuery.data.login} + + ) : ( + + {identityQuery.isError ? "Could not resolve authenticated GitHub user" : "Resolving authenticated GitHub user…"} + + ); return (
{!compact && ( @@ -125,33 +139,27 @@ export function IntakeFields({
{form.enabled && ( <> - {repoPreview && ( - - {repoPreview.value ? ( - - {repoPreview.value} - - ) : ( - - Could not detect a GitHub repo from this project's git origin. - - )} - - )} - -
- {identityQuery.data - ? `Issues assigned to @${identityQuery.data.login}` - : identityQuery.isError - ? "Could not resolve authenticated GitHub user" - : "Resolving authenticated GitHub user…"} -
-
+
+ {repoPreview && ( + + {repoPreview.value ? ( + + {repoPreview.value} + + ) : ( + + Could not detect a GitHub repo from this project's git origin. + + )} + + )} + {assignee} +
{identityQuery.isError && !compact && (

Check GitHub authentication and try again.

)} @@ -171,7 +179,7 @@ export function IntakeFields({ function IntakeField({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) { return ( -
+
diff --git a/frontend/src/renderer/components/MatchingIssuesPreview.tsx b/frontend/src/renderer/components/MatchingIssuesPreview.tsx index dbce834921..5220b2dd01 100644 --- a/frontend/src/renderer/components/MatchingIssuesPreview.tsx +++ b/frontend/src/renderer/components/MatchingIssuesPreview.tsx @@ -4,11 +4,17 @@ export function MatchingIssuesPreview({ projectId, labels }: { projectId: string const preview = useTrackerIntakePreview(projectId, labels, true); const checking = preview.isDebouncing || preview.isFetching; return ( -
+
Matching open issues - - {checking ? "Checking…" : preview.isError ? "Unavailable" : (preview.data?.count ?? 0)} - + {checking || preview.isError ? ( + + {checking ? "Checking…" : "Unavailable"} + + ) : ( + + {preview.data?.count ?? 0} + + )}
); } diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index aa0229979f..6d602d70fa 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -307,17 +307,21 @@ describe("ProjectSettingsForm", () => { await userEvent.click(await screen.findByLabelText("Enable issue intake")); - // Repository and assignee are both display-only. - expect(screen.getByRole("link", { name: "acme/project-one" })).toHaveAttribute( + // Repository and assignee are both display-only links in one compact row. + const repositoryLink = screen.getByRole("link", { name: "acme/project-one" }); + expect(repositoryLink).toHaveAttribute( "href", "https://github.com/acme/project-one", ); - expect(await screen.findByText("Issues assigned to @octocat")).toBeInTheDocument(); + const assigneeLink = await screen.findByRole("link", { name: "octocat" }); + expect(assigneeLink).toHaveAttribute("href", "https://github.com/octocat"); + expect(repositoryLink.parentElement?.parentElement).toHaveClass("grid-cols-2"); await userEvent.click(screen.getByRole("button", { name: "Labels" })); await userEvent.click(await screen.findByRole("option", { name: /bug/i })); await userEvent.click(screen.getByRole("option", { name: /ready/i })); await userEvent.keyboard("{Escape}"); await waitFor(() => expect(screen.getByText("Matching open issues").parentElement).toHaveTextContent("3")); + expect(screen.getByText("3")).toHaveClass("rounded-full"); expect(postMock).toHaveBeenCalledWith("/api/v1/projects/{id}/tracker-intake/github/preview", { params: { path: { id: "proj-1" } }, body: { labels: ["bug", "ready"] }, From e06227f92cad805926ad7aa8ec8794fee2b30bc0 Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Wed, 8 Jul 2026 21:47:19 +0530 Subject: [PATCH 7/7] chore: format GitHub intake frontend --- .../src/renderer/components/LabelPicker.tsx | 30 ++++++++++++++----- .../components/ProjectSettingsForm.test.tsx | 5 +--- .../components/ProjectSettingsForm.tsx | 7 ++++- .../renderer/hooks/useTrackerIntakeLabels.ts | 3 +- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/frontend/src/renderer/components/LabelPicker.tsx b/frontend/src/renderer/components/LabelPicker.tsx index 7f2facc57e..8376c0def0 100644 --- a/frontend/src/renderer/components/LabelPicker.tsx +++ b/frontend/src/renderer/components/LabelPicker.tsx @@ -22,7 +22,9 @@ export function LabelPicker({ const needle = search.trim().toLocaleLowerCase(); const filtered = options.filter((label) => { if (needle === "") return true; - return label.name.toLocaleLowerCase().includes(needle) || (label.description ?? "").toLocaleLowerCase().includes(needle); + return ( + label.name.toLocaleLowerCase().includes(needle) || (label.description ?? "").toLocaleLowerCase().includes(needle) + ); }); const summary = value.length === 0 ? "All labels" : value.length === 1 ? value[0] : `${value.length} labels selected`; @@ -70,13 +72,20 @@ export function LabelPicker({ onClick={() => labelsQuery.refresh.mutate()} className="grid size-8 shrink-0 place-items-center rounded-md bg-raised text-muted-foreground transition hover:text-foreground disabled:opacity-50" > -
{labelsQuery.isLoading ? Loading labels… : null} - {labelsQuery.isError || labelsQuery.refresh.isError ? Could not load repository labels. : null} - {!labelsQuery.isLoading && !labelsQuery.isError && filtered.length === 0 ? No labels found. : null} + {labelsQuery.isError || labelsQuery.refresh.isError ? ( + Could not load repository labels. + ) : null} + {!labelsQuery.isLoading && !labelsQuery.isError && filtered.length === 0 ? ( + No labels found. + ) : null} {filtered.map((label) => { const selected = value.some((item) => item.toLocaleLowerCase() === label.name.toLocaleLowerCase()); return ( @@ -88,13 +97,20 @@ export function LabelPicker({ onClick={() => toggle(label.name)} className="flex w-full items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors hover:bg-interactive-hover focus-visible:bg-interactive-hover focus-visible:outline-none" > - + {selected ? - + {label.name} - {label.description ? {label.description} : null} + {label.description ? ( + {label.description} + ) : null} ); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 6d602d70fa..9d0d8ac72f 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -309,10 +309,7 @@ describe("ProjectSettingsForm", () => { // Repository and assignee are both display-only links in one compact row. const repositoryLink = screen.getByRole("link", { name: "acme/project-one" }); - expect(repositoryLink).toHaveAttribute( - "href", - "https://github.com/acme/project-one", - ); + expect(repositoryLink).toHaveAttribute("href", "https://github.com/acme/project-one"); const assigneeLink = await screen.findByRole("link", { name: "octocat" }); expect(assigneeLink).toHaveAttribute("href", "https://github.com/octocat"); expect(repositoryLink.parentElement?.parentElement).toHaveClass("grid-cols-2"); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 32338afb1e..79bdfde5a3 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -337,7 +337,12 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje Tracker intake - + diff --git a/frontend/src/renderer/hooks/useTrackerIntakeLabels.ts b/frontend/src/renderer/hooks/useTrackerIntakeLabels.ts index 31f52b2797..2c027e65da 100644 --- a/frontend/src/renderer/hooks/useTrackerIntakeLabels.ts +++ b/frontend/src/renderer/hooks/useTrackerIntakeLabels.ts @@ -5,7 +5,8 @@ import { apiClient, apiErrorMessage } from "../lib/api-client"; export type TrackerLabel = components["schemas"]["TrackerLabel"]; type LabelsResponse = components["schemas"]["TrackerIntakeLabelsResponse"]; -export const trackerIntakeLabelsQueryKey = (projectId: string) => ["tracker-intake", "github", "labels", projectId] as const; +export const trackerIntakeLabelsQueryKey = (projectId: string) => + ["tracker-intake", "github", "labels", projectId] as const; async function fetchLabels(projectId: string, refresh: boolean): Promise { const { data, error } = await apiClient.GET("/api/v1/projects/{id}/tracker-intake/github/labels", {