diff --git a/backend/internal/adapters/tracker/jira/auth.go b/backend/internal/adapters/tracker/jira/auth.go new file mode 100644 index 0000000000..178395446e --- /dev/null +++ b/backend/internal/adapters/tracker/jira/auth.go @@ -0,0 +1,62 @@ +package jira + +import ( + "context" + "errors" + "os" + "strings" +) + +// CredentialSource yields Jira Cloud Basic-auth credentials (email + API +// token). Jira's REST API does not accept bearer tokens for cloud accounts; +// every request is `Authorization: Basic base64(email:token)`. +type CredentialSource interface { + Credentials(ctx context.Context) (email, token string, err error) +} + +// ErrNoCredentials is returned when no email/token pair can be obtained. +var ErrNoCredentials = errors.New("jira tracker: no credentials configured") + +// StaticCredentials are literal values, typically used in tests. +type StaticCredentials struct { + Email string + Token string +} + +// Credentials returns the literal pair, or ErrNoCredentials if either is blank. +func (s StaticCredentials) Credentials(context.Context) (string, string, error) { + email := strings.TrimSpace(s.Email) + token := strings.TrimSpace(s.Token) + if email == "" || token == "" { + return "", "", ErrNoCredentials + } + return email, token, nil +} + +// EnvCredentials reads Jira email/token pairs from env vars. Email and Token +// each accept a list of var names to support overlay scopes (project-specific +// vars beat the global default). +type EnvCredentials struct { + EmailVars []string + TokenVars []string +} + +// Credentials returns the first non-empty value from each var list, with +// AO_JIRA_EMAIL / AO_JIRA_TOKEN as defaults. +func (s EnvCredentials) Credentials(context.Context) (string, string, error) { + email := firstNonEmpty(s.EmailVars, "AO_JIRA_EMAIL") + token := firstNonEmpty(s.TokenVars, "AO_JIRA_TOKEN") + if email == "" || token == "" { + return "", "", ErrNoCredentials + } + return email, token, nil +} + +func firstNonEmpty(names []string, fallback string) string { + for _, name := range names { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v + } + } + return strings.TrimSpace(os.Getenv(fallback)) +} diff --git a/backend/internal/adapters/tracker/jira/tracker.go b/backend/internal/adapters/tracker/jira/tracker.go new file mode 100644 index 0000000000..f7f4970557 --- /dev/null +++ b/backend/internal/adapters/tracker/jira/tracker.go @@ -0,0 +1,420 @@ +// Package jira implements ports.Tracker against Jira Cloud's REST API. The +// project-config scope is the project key plus the Jira site base URL; intake +// constructs a JQL query and maps Jira statusCategory onto NormalizedIssueState. +package jira + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultUserAgent = "ao-agent-orchestrator/tracker-jira" + defaultListLimit = 30 + maxListLimit = 100 + + searchPath = "/rest/api/3/search" + issuePath = "/rest/api/3/issue/" +) + +// Sentinels for callers. +var ( + ErrNotFound = errors.New("jira tracker: issue not found") + ErrAuthFailed = errors.New("jira tracker: authentication failed") + ErrWrongProvider = errors.New("jira tracker: id is not a jira tracker id") + ErrBadID = errors.New("jira tracker: malformed native id") + ErrNoBaseURL = errors.New("jira tracker: no base URL") +) + +// Options mirrors the other tracker adapters. Credentials are required; the +// site BaseURL travels per-call via domain.TrackerRepo.BaseURL so a single +// adapter instance can serve multiple Jira tenants under one account. +type Options struct { + Credentials CredentialSource + HTTPClient *http.Client + UserAgent string +} + +// Tracker implements ports.Tracker against Jira Cloud REST. +type Tracker struct { + http *http.Client + creds CredentialSource + userAgent string +} + +// New constructs a Tracker; it fails fast when no credentials can be obtained. +func New(opts Options) (*Tracker, error) { + if opts.Credentials == nil { + return nil, ErrNoCredentials + } + if _, _, err := opts.Credentials.Credentials(context.Background()); err != nil { + return nil, err + } + t := &Tracker{ + http: opts.HTTPClient, + creds: opts.Credentials, + userAgent: opts.UserAgent, + } + if t.http == nil { + t.http = &http.Client{Timeout: 30 * time.Second} + } + if t.userAgent == "" { + t.userAgent = defaultUserAgent + } + return t, nil +} + +var _ ports.Tracker = (*Tracker)(nil) + +// --------------------------------------------------------------------------- +// REST DTOs +// --------------------------------------------------------------------------- + +type jrIssue struct { + Key string `json:"key"` + Fields jrFields `json:"fields"` +} + +type jrFields struct { + Summary string `json:"summary"` + Description any `json:"description"` // Atlassian Document Format or string + Status jrStatus `json:"status"` + Labels []string `json:"labels"` + Assignee *jrUser `json:"assignee"` + IssueType *jrIssueType `json:"issuetype"` +} + +type jrStatus struct { + Name string `json:"name"` + StatusCategory jrStatusCategory `json:"statusCategory"` +} + +type jrStatusCategory struct { + Key string `json:"key"` + Name string `json:"name"` +} + +type jrUser struct { + AccountID string `json:"accountId"` + DisplayName string `json:"displayName"` + EmailAddress string `json:"emailAddress"` +} + +type jrIssueType struct { + Name string `json:"name"` +} + +type jrSearchResp struct { + Issues []jrIssue `json:"issues"` +} + +// --------------------------------------------------------------------------- +// Get +// --------------------------------------------------------------------------- + +// Get fetches one issue by Jira key (e.g. "ENG-7"). The native id MUST embed +// the Jira site so we know which tenant to query: the canonical form is +// "/browse/" or, more conveniently for callers, a TrackerRepo +// with BaseURL set — but Get only sees a TrackerID. We split the BaseURL out +// of TrackerID.Native using the same browse URL convention as the URL field +// we emit; callers wanting a different shape must pre-resolve through List. +func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + if id.Provider != domain.TrackerProviderJira { + return domain.Issue{}, fmt.Errorf("%w: provider=%q", ErrWrongProvider, id.Provider) + } + baseURL, key, err := splitJiraNative(id.Native) + if err != nil { + return domain.Issue{}, err + } + var raw jrIssue + if err := t.get(ctx, baseURL+issuePath+url.PathEscape(key), &raw); err != nil { + return domain.Issue{}, err + } + return issueFromJira(baseURL, raw), nil +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +// List queries Jira's REST search for one project. repo.BaseURL is required; +// repo.Native is the Jira project key. +func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + if repo.Provider != domain.TrackerProviderJira { + return nil, fmt.Errorf("%w: provider=%q", ErrWrongProvider, repo.Provider) + } + baseURL := normalizeBaseURL(repo.BaseURL) + if baseURL == "" { + return nil, ErrNoBaseURL + } + projectKey := strings.TrimSpace(repo.Native) + if projectKey == "" { + return nil, fmt.Errorf("%w: empty project key", ErrBadID) + } + + jql := buildJQL(projectKey, filter) + q := url.Values{} + q.Set("jql", jql) + q.Set("fields", "summary,description,status,labels,assignee,issuetype") + limit := filter.Limit + if limit <= 0 { + limit = defaultListLimit + } + if limit > maxListLimit { + limit = maxListLimit + } + q.Set("maxResults", strconv.Itoa(limit)) + + var resp jrSearchResp + if err := t.get(ctx, baseURL+searchPath+"?"+q.Encode(), &resp); err != nil { + return nil, err + } + out := make([]domain.Issue, 0, len(resp.Issues)) + for _, raw := range resp.Issues { + out = append(out, issueFromJira(baseURL, raw)) + } + return out, nil +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +// Preflight cannot run without a base URL. The observer skips Preflight for +// Jira and lets the first List surface auth errors. +func (t *Tracker) Preflight(_ context.Context) error { + // No-op: Jira has no global identity endpoint without a tenant base URL. + return nil +} + +// --------------------------------------------------------------------------- +// Mapping +// --------------------------------------------------------------------------- + +func issueFromJira(baseURL string, raw jrIssue) domain.Issue { + labels := make([]string, 0, len(raw.Fields.Labels)) + for _, l := range raw.Fields.Labels { + if name := strings.TrimSpace(l); name != "" { + labels = append(labels, name) + } + } + var assignees []string + if u := raw.Fields.Assignee; u != nil { + if pick := strings.TrimSpace(u.DisplayName); pick != "" { + assignees = append(assignees, pick) + } else if pick := strings.TrimSpace(u.EmailAddress); pick != "" { + assignees = append(assignees, pick) + } else if pick := strings.TrimSpace(u.AccountID); pick != "" { + assignees = append(assignees, pick) + } + } + out := domain.Issue{ + ID: domain.TrackerID{ + Provider: domain.TrackerProviderJira, + Native: baseURL + "/browse/" + raw.Key, + }, + Title: raw.Fields.Summary, + Body: flattenJiraDescription(raw.Fields.Description), + State: mapStateFromJira(raw.Fields.Status), + URL: baseURL + "/browse/" + raw.Key, + Labels: labels, + Assignees: assignees, + } + if len(out.Labels) == 0 { + out.Labels = nil + } + if len(out.Assignees) == 0 { + out.Assignees = nil + } + return out +} + +// flattenJiraDescription accepts either a plain string or an Atlassian +// Document Format object. ADF is rendered as a best-effort plaintext join of +// its leaf text nodes — enough for the intake prompt without dragging in a +// full ADF dependency. +func flattenJiraDescription(desc any) string { + switch v := desc.(type) { + case nil: + return "" + case string: + return v + case map[string]any: + var b strings.Builder + flattenADF(v, &b) + return strings.TrimSpace(b.String()) + default: + return "" + } +} + +func flattenADF(node map[string]any, b *strings.Builder) { + if t, ok := node["text"].(string); ok { + b.WriteString(t) + } + if content, ok := node["content"].([]any); ok { + for _, child := range content { + if cm, ok := child.(map[string]any); ok { + flattenADF(cm, b) + } + } + // Insert paragraph breaks between block nodes for readability. + if typ, _ := node["type"].(string); typ == "paragraph" || typ == "heading" { + b.WriteString("\n") + } + } +} + +func mapStateFromJira(status jrStatus) domain.NormalizedIssueState { + switch strings.ToLower(strings.TrimSpace(status.StatusCategory.Key)) { + case "done": + // Jira distinguishes Done vs Cancelled only via status name; treat the + // explicit "Cancelled"/"Won't Do" names as cancelled. + name := strings.ToLower(strings.TrimSpace(status.Name)) + if strings.Contains(name, "cancel") || strings.Contains(name, "won't") || strings.Contains(name, "wont") { + return domain.IssueCancelled + } + return domain.IssueDone + case "indeterminate": + return domain.IssueInProgress + default: + return domain.IssueOpen + } +} + +// buildJQL composes the JQL search expression. Quoted scalars are escaped +// by doubling embedded quotes so a `"` in a label or assignee can't break out. +func buildJQL(projectKey string, filter domain.ListFilter) string { + parts := []string{fmt.Sprintf("project = %s", jqlQuote(projectKey))} + switch filter.State { + case domain.ListOpen: + parts = append(parts, "statusCategory != Done") + case domain.ListClosed: + parts = append(parts, "statusCategory = Done") + } + if len(filter.Labels) > 0 { + quoted := make([]string, 0, len(filter.Labels)) + for _, l := range filter.Labels { + quoted = append(quoted, jqlQuote(l)) + } + parts = append(parts, "labels in ("+strings.Join(quoted, ",")+")") + } + if a := strings.TrimSpace(filter.Assignee); a != "" && a != "*" { + parts = append(parts, fmt.Sprintf("assignee = %s", jqlQuote(a))) + } + parts = append(parts, "ORDER BY created DESC") + return strings.Join(parts, " AND ") +} + +func jqlQuote(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` +} + +// --------------------------------------------------------------------------- +// HTTP plumbing +// --------------------------------------------------------------------------- + +func (t *Tracker) get(ctx context.Context, fullURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return fmt.Errorf("jira tracker: build request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", t.userAgent) + email, token, err := t.creds.Credentials(ctx) + if err != nil { + return err + } + req.Header.Set("Authorization", basicAuth(email, token)) + + resp, err := t.http.Do(req) + if err != nil { + return fmt.Errorf("jira tracker: GET %s: %w", fullURL, err) + } + defer func() { _ = resp.Body.Close() }() + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("jira tracker: read response: %w", readErr) + } + switch resp.StatusCode { + case http.StatusOK: + if out == nil { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("jira tracker: decode response: %w", err) + } + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("%w: %s", ErrAuthFailed, strings.TrimSpace(string(body))) + case http.StatusNotFound: + return fmt.Errorf("%w: %s", ErrNotFound, strings.TrimSpace(string(body))) + default: + return fmt.Errorf("jira tracker: %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } +} + +func basicAuth(email, token string) string { + creds := email + ":" + token + return "Basic " + base64.StdEncoding.EncodeToString([]byte(creds)) +} + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +// normalizeBaseURL accepts "acme.atlassian.net" or "https://acme.atlassian.net" +// and returns a canonical "https://acme.atlassian.net" with no trailing slash. +// Empty input returns empty. +func normalizeBaseURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + raw = strings.TrimRight(raw, "/") + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + return raw + } + return "https://" + raw +} + +// splitJiraNative parses Get's id form. Accepts: +// - "://host/browse/KEY-NN" (the form Get's URL field uses) +// - "https://host|KEY-NN" (compact form, pipe-separated) +// +// Both forms make the per-call base URL explicit so Get works without a +// separate TrackerRepo. The second form is for callers that want a stable key +// without URL-encoding ambiguity; observer code uses the first form. +func splitJiraNative(native string) (string, string, error) { + native = strings.TrimSpace(native) + if i := strings.LastIndex(native, "|"); i >= 0 { + base := normalizeBaseURL(native[:i]) + key := strings.TrimSpace(native[i+1:]) + if base == "" || key == "" { + return "", "", fmt.Errorf("%w: pipe-form missing base or key", ErrBadID) + } + return base, key, nil + } + idx := strings.Index(native, "/browse/") + if idx < 0 { + return "", "", fmt.Errorf("%w: expected /browse/ form", ErrBadID) + } + base := normalizeBaseURL(native[:idx]) + key := strings.TrimSpace(native[idx+len("/browse/"):]) + if base == "" || key == "" { + return "", "", fmt.Errorf("%w: missing base or key", ErrBadID) + } + return base, key, nil +} diff --git a/backend/internal/adapters/tracker/jira/tracker_test.go b/backend/internal/adapters/tracker/jira/tracker_test.go new file mode 100644 index 0000000000..fc49d44d4e --- /dev/null +++ b/backend/internal/adapters/tracker/jira/tracker_test.go @@ -0,0 +1,182 @@ +package jira + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestNew_RequiresCredentials(t *testing.T) { + if _, err := New(Options{}); !errors.Is(err, ErrNoCredentials) { + t.Fatalf("New without creds: %v, want ErrNoCredentials", err) + } + if _, err := New(Options{Credentials: StaticCredentials{Email: "", Token: "x"}}); !errors.Is(err, ErrNoCredentials) { + t.Fatalf("New with missing email: %v, want ErrNoCredentials", err) + } +} + +func TestList_BuildsJQLAndDecodes(t *testing.T) { + var capturedJQL string + var capturedAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != searchPath { + t.Errorf("path = %q, want %q", r.URL.Path, searchPath) + } + capturedJQL = r.URL.Query().Get("jql") + capturedAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"issues":[{ + "key":"ENG-1", + "fields":{ + "summary":"first", + "description":"plain body", + "status":{"name":"To Do","statusCategory":{"key":"new","name":"To Do"}}, + "labels":["agent-ready","bug"], + "assignee":{"accountId":"abc","displayName":"Alice","emailAddress":"alice@example.com"} + } + }]}`)) + })) + defer srv.Close() + + tracker, err := New(Options{Credentials: StaticCredentials{Email: "me@example.com", Token: "tok"}, HTTPClient: srv.Client()}) + if err != nil { + t.Fatalf("New: %v", err) + } + issues, err := tracker.List(context.Background(), domain.TrackerRepo{ + Provider: domain.TrackerProviderJira, + Native: "ENG", + BaseURL: srv.URL, + }, domain.ListFilter{ + State: domain.ListOpen, + Labels: []string{"agent-ready", `quote"trap`}, + Assignee: "Alice", + Limit: 5, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(issues) != 1 { + t.Fatalf("got %d issues", len(issues)) + } + got := issues[0] + if got.ID.Provider != domain.TrackerProviderJira { + t.Fatalf("provider = %q", got.ID.Provider) + } + if !strings.HasSuffix(got.URL, "/browse/ENG-1") { + t.Fatalf("url = %q", got.URL) + } + if got.State != domain.IssueOpen { + t.Fatalf("state = %q, want open", got.State) + } + if len(got.Labels) != 2 || got.Labels[0] != "agent-ready" { + t.Fatalf("labels = %v", got.Labels) + } + if len(got.Assignees) != 1 || got.Assignees[0] != "Alice" { + t.Fatalf("assignees = %v", got.Assignees) + } + if !strings.Contains(capturedJQL, `project = "ENG"`) { + t.Fatalf("jql missing project: %q", capturedJQL) + } + if !strings.Contains(capturedJQL, "statusCategory != Done") { + t.Fatalf("jql missing state filter: %q", capturedJQL) + } + if !strings.Contains(capturedJQL, `labels in ("agent-ready","quote\"trap")`) { + t.Fatalf("jql labels malformed: %q", capturedJQL) + } + if !strings.HasPrefix(capturedAuth, "Basic ") { + t.Fatalf("auth header = %q", capturedAuth) + } +} + +func TestList_RequiresBaseURL(t *testing.T) { + tracker, _ := New(Options{Credentials: StaticCredentials{Email: "e", Token: "t"}}) + _, err := tracker.List(context.Background(), domain.TrackerRepo{Provider: domain.TrackerProviderJira, Native: "ENG"}, domain.ListFilter{}) + if !errors.Is(err, ErrNoBaseURL) { + t.Fatalf("expected ErrNoBaseURL, got %v", err) + } +} + +func TestList_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"nope"}`)) + })) + defer srv.Close() + tracker, _ := New(Options{Credentials: StaticCredentials{Email: "e", Token: "t"}, HTTPClient: srv.Client()}) + _, err := tracker.List(context.Background(), domain.TrackerRepo{Provider: domain.TrackerProviderJira, Native: "ENG", BaseURL: srv.URL}, domain.ListFilter{}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("expected ErrAuthFailed, got %v", err) + } +} + +func TestGet_BrowseURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, issuePath) { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"key":"ENG-7","fields":{ + "summary":"hi", + "description":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"}]}]}, + "status":{"name":"In Progress","statusCategory":{"key":"indeterminate","name":"In Progress"}}, + "labels":[], + "assignee":null + }}`)) + })) + defer srv.Close() + tracker, _ := New(Options{Credentials: StaticCredentials{Email: "e", Token: "t"}, HTTPClient: srv.Client()}) + got, err := tracker.Get(context.Background(), domain.TrackerID{ + Provider: domain.TrackerProviderJira, + Native: srv.URL + "/browse/ENG-7", + }) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.State != domain.IssueInProgress { + t.Fatalf("state = %q, want in_progress", got.State) + } + if !strings.Contains(got.Body, "line one") { + t.Fatalf("ADF flatten failed: %q", got.Body) + } +} + +func TestMapStateFromJira_Cancelled(t *testing.T) { + if got := mapStateFromJira(jrStatus{Name: "Cancelled", StatusCategory: jrStatusCategory{Key: "done"}}); got != domain.IssueCancelled { + t.Fatalf("got %q", got) + } + if got := mapStateFromJira(jrStatus{Name: "Won't Do", StatusCategory: jrStatusCategory{Key: "done"}}); got != domain.IssueCancelled { + t.Fatalf("got %q", got) + } + if got := mapStateFromJira(jrStatus{Name: "Done", StatusCategory: jrStatusCategory{Key: "done"}}); got != domain.IssueDone { + t.Fatalf("got %q", got) + } +} + +func TestNormalizeBaseURL(t *testing.T) { + cases := map[string]string{ + "acme.atlassian.net": "https://acme.atlassian.net", + "https://acme.atlassian.net": "https://acme.atlassian.net", + "https://acme.atlassian.net/": "https://acme.atlassian.net", + "": "", + } + for in, want := range cases { + if got := normalizeBaseURL(in); got != want { + t.Errorf("normalizeBaseURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestWrongProvider(t *testing.T) { + tracker, _ := New(Options{Credentials: StaticCredentials{Email: "e", Token: "t"}}) + _, err := tracker.List(context.Background(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "x"}, domain.ListFilter{}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("expected ErrWrongProvider, got %v", err) + } + _, err = tracker.Get(context.Background(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "x"}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("expected ErrWrongProvider, got %v", err) + } +} diff --git a/backend/internal/adapters/tracker/linear/auth.go b/backend/internal/adapters/tracker/linear/auth.go new file mode 100644 index 0000000000..65e9509c18 --- /dev/null +++ b/backend/internal/adapters/tracker/linear/auth.go @@ -0,0 +1,50 @@ +package linear + +import ( + "context" + "errors" + "os" + "strings" +) + +// TokenSource yields a Linear API token on demand. Mirrors the shape used by +// the GitHub adapter so wiring code can swap them out behind the same surface. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// ErrNoToken is returned when no token source could yield a non-empty token. +var ErrNoToken = errors.New("linear tracker: no token configured") + +// StaticTokenSource is a literal token, typically used in tests. +type StaticTokenSource string + +// Token returns the literal token, or ErrNoToken if it is blank. +func (s StaticTokenSource) Token(context.Context) (string, error) { + t := strings.TrimSpace(string(s)) + if t == "" { + return "", ErrNoToken + } + return t, nil +} + +// EnvTokenSource reads the first non-empty value from the listed env vars, +// falling back to LINEAR_API_KEY. The order matters: AO_LINEAR_TOKEN wins so +// users can scope a token to AO without disturbing other Linear tooling. +type EnvTokenSource struct { + EnvVars []string +} + +// Token returns the first non-empty configured env var (falling back to +// LINEAR_API_KEY), or ErrNoToken if none is set. +func (s EnvTokenSource) Token(context.Context) (string, error) { + for _, name := range s.EnvVars { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v, nil + } + } + if v := strings.TrimSpace(os.Getenv("LINEAR_API_KEY")); v != "" { + return v, nil + } + return "", ErrNoToken +} diff --git a/backend/internal/adapters/tracker/linear/tracker.go b/backend/internal/adapters/tracker/linear/tracker.go new file mode 100644 index 0000000000..aea56c08c2 --- /dev/null +++ b/backend/internal/adapters/tracker/linear/tracker.go @@ -0,0 +1,376 @@ +// Package linear implements ports.Tracker against Linear's GraphQL API. The +// project-config scope is the team key (e.g. "ENG"); intake reads issues for +// that team and maps Linear's workflow-state types onto NormalizedIssueState. +package linear + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultBaseURL = "https://api.linear.app/graphql" + defaultUserAgent = "ao-agent-orchestrator/tracker-linear" + defaultListLimit = 30 + maxListLimit = 100 +) + +// Sentinels mirror the GitHub adapter so observer code can treat them the same. +var ( + ErrNotFound = errors.New("linear tracker: issue not found") + ErrAuthFailed = errors.New("linear tracker: authentication failed") + ErrWrongProvider = errors.New("linear tracker: id is not a linear tracker id") + ErrBadID = errors.New("linear tracker: malformed native id") +) + +// Options mirrors github.Options. Token is required; HTTPClient/BaseURL/UserAgent +// are test injection points. +type Options struct { + Token TokenSource + HTTPClient *http.Client + BaseURL string + UserAgent string +} + +// Tracker implements ports.Tracker against Linear's GraphQL API. +type Tracker struct { + http *http.Client + tokens TokenSource + baseURL string + userAgent string +} + +// New constructs a Tracker. It fails fast when no token can be obtained so the +// daemon crashes at startup rather than at first list. +func New(opts Options) (*Tracker, error) { + if opts.Token == nil { + return nil, ErrNoToken + } + if _, err := opts.Token.Token(context.Background()); err != nil { + return nil, err + } + t := &Tracker{ + http: opts.HTTPClient, + tokens: opts.Token, + baseURL: opts.BaseURL, + userAgent: opts.UserAgent, + } + if t.http == nil { + t.http = &http.Client{Timeout: 30 * time.Second} + } + if t.baseURL == "" { + t.baseURL = defaultBaseURL + } + if t.userAgent == "" { + t.userAgent = defaultUserAgent + } + return t, nil +} + +var _ ports.Tracker = (*Tracker)(nil) + +// --------------------------------------------------------------------------- +// GraphQL DTOs +// --------------------------------------------------------------------------- + +type lnIssue struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + State lnState `json:"state"` + Labels lnLabels `json:"labels"` + Assignee *lnUser `json:"assignee"` + Assignees lnUsers `json:"assignees"` // tolerate array form for forward compatibility +} + +type lnState struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type lnLabels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` +} + +type lnUser struct { + DisplayName string `json:"displayName"` + Name string `json:"name"` +} + +type lnUsers struct { + Nodes []lnUser `json:"nodes"` +} + +type lnGraphQLResp struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors,omitempty"` +} + +// --------------------------------------------------------------------------- +// Get +// --------------------------------------------------------------------------- + +const getIssueQuery = `query GetIssue($id: String!) { + issue(id: $id) { + identifier + title + description + url + state { type name } + labels { nodes { name } } + assignee { displayName name } + } +}` + +// Get fetches one issue by its Linear identifier (e.g. "ENG-123"). +func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + if id.Provider != domain.TrackerProviderLinear { + return domain.Issue{}, fmt.Errorf("%w: provider=%q", ErrWrongProvider, id.Provider) + } + native := strings.TrimSpace(id.Native) + if native == "" { + return domain.Issue{}, fmt.Errorf("%w: empty native id", ErrBadID) + } + var resp struct { + Issue *lnIssue `json:"issue"` + } + if err := t.query(ctx, getIssueQuery, map[string]any{"id": native}, &resp); err != nil { + return domain.Issue{}, err + } + if resp.Issue == nil { + return domain.Issue{}, fmt.Errorf("%w: %s", ErrNotFound, native) + } + return issueFromLN(*resp.Issue), nil +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +const listIssuesQuery = `query ListIssues($filter: IssueFilter!, $first: Int!) { + issues(filter: $filter, first: $first) { + nodes { + identifier + title + description + url + state { type name } + labels { nodes { name } } + assignee { displayName name } + } + } +}` + +// List returns issues for a Linear team key. The IssueFilter is composed from +// the team key, state coarse filter, labels, and assignee — all optional. +func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + if repo.Provider != domain.TrackerProviderLinear { + return nil, fmt.Errorf("%w: provider=%q", ErrWrongProvider, repo.Provider) + } + team := strings.TrimSpace(repo.Native) + if team == "" { + return nil, fmt.Errorf("%w: empty team key", ErrBadID) + } + + issueFilter := map[string]any{ + "team": map[string]any{"key": map[string]any{"eq": team}}, + } + if states := listStateTypes(filter.State); len(states) > 0 { + issueFilter["state"] = map[string]any{"type": map[string]any{"in": states}} + } + if len(filter.Labels) > 0 { + issueFilter["labels"] = map[string]any{"name": map[string]any{"in": filter.Labels}} + } + if a := strings.TrimSpace(filter.Assignee); a != "" && a != "*" { + issueFilter["assignee"] = map[string]any{"displayName": map[string]any{"eq": a}} + } + + limit := filter.Limit + if limit <= 0 { + limit = defaultListLimit + } + if limit > maxListLimit { + limit = maxListLimit + } + + var resp struct { + Issues struct { + Nodes []lnIssue `json:"nodes"` + } `json:"issues"` + } + if err := t.query(ctx, listIssuesQuery, map[string]any{"filter": issueFilter, "first": limit}, &resp); err != nil { + return nil, err + } + out := make([]domain.Issue, 0, len(resp.Issues.Nodes)) + for _, raw := range resp.Issues.Nodes { + out = append(out, issueFromLN(raw)) + } + return out, nil +} + +// listStateTypes maps the coarse open/closed/all filter onto Linear's set of +// workflow state types. +func listStateTypes(state domain.ListStateFilter) []string { + switch state { + case domain.ListOpen: + return []string{"backlog", "unstarted", "triage", "started"} + case domain.ListClosed: + return []string{"completed", "cancelled"} + default: + return nil + } +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +const preflightQuery = `query Viewer { viewer { id } }` + +// Preflight verifies the token by issuing a viewer query. +func (t *Tracker) Preflight(ctx context.Context) error { + var resp struct { + Viewer struct { + ID string `json:"id"` + } `json:"viewer"` + } + return t.query(ctx, preflightQuery, nil, &resp) +} + +// --------------------------------------------------------------------------- +// Mapping +// --------------------------------------------------------------------------- + +func issueFromLN(raw lnIssue) domain.Issue { + labels := make([]string, 0, len(raw.Labels.Nodes)) + for _, l := range raw.Labels.Nodes { + if name := strings.TrimSpace(l.Name); name != "" { + labels = append(labels, name) + } + } + var assignees []string + if raw.Assignee != nil { + if name := pickDisplay(*raw.Assignee); name != "" { + assignees = append(assignees, name) + } + } + for _, u := range raw.Assignees.Nodes { + if name := pickDisplay(u); name != "" { + assignees = append(assignees, name) + } + } + out := domain.Issue{ + ID: domain.TrackerID{ + Provider: domain.TrackerProviderLinear, + Native: strings.TrimSpace(raw.Identifier), + }, + Title: raw.Title, + Body: raw.Description, + State: mapStateFromLinear(raw.State.Type), + URL: raw.URL, + Labels: labels, + Assignees: assignees, + } + if len(out.Labels) == 0 { + out.Labels = nil + } + if len(out.Assignees) == 0 { + out.Assignees = nil + } + return out +} + +func pickDisplay(u lnUser) string { + if name := strings.TrimSpace(u.DisplayName); name != "" { + return name + } + return strings.TrimSpace(u.Name) +} + +// mapStateFromLinear projects Linear's workflow-state type onto the normalized +// cross-provider state vocabulary. +func mapStateFromLinear(stateType string) domain.NormalizedIssueState { + switch strings.ToLower(strings.TrimSpace(stateType)) { + case "started": + return domain.IssueInProgress + case "completed": + return domain.IssueDone + case "cancelled", "canceled": + return domain.IssueCancelled + default: + return domain.IssueOpen + } +} + +// --------------------------------------------------------------------------- +// HTTP plumbing +// --------------------------------------------------------------------------- + +func (t *Tracker) query(ctx context.Context, query string, variables map[string]any, out any) error { + payload := map[string]any{"query": query} + if variables != nil { + payload["variables"] = variables + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("linear tracker: encode request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.baseURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("linear tracker: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", t.userAgent) + tok, err := t.tokens.Token(ctx) + if err != nil { + return err + } + // Linear personal API keys go as the raw header value; OAuth tokens carry + // the "Bearer " prefix already. Either way the token is passed through opaque. + req.Header.Set("Authorization", tok) + + resp, err := t.http.Do(req) + if err != nil { + return fmt.Errorf("linear tracker: POST: %w", err) + } + defer func() { _ = resp.Body.Close() }() + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("linear tracker: read response: %w", readErr) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("%w: %s", ErrAuthFailed, strings.TrimSpace(string(respBody))) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("linear tracker: %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + var gql lnGraphQLResp + if err := json.Unmarshal(respBody, &gql); err != nil { + return fmt.Errorf("linear tracker: decode response: %w", err) + } + if len(gql.Errors) > 0 { + return fmt.Errorf("linear tracker: graphql error: %s", gql.Errors[0].Message) + } + if out != nil && len(gql.Data) > 0 { + if err := json.Unmarshal(gql.Data, out); err != nil { + return fmt.Errorf("linear tracker: decode data: %w", err) + } + } + return nil +} diff --git a/backend/internal/adapters/tracker/linear/tracker_test.go b/backend/internal/adapters/tracker/linear/tracker_test.go new file mode 100644 index 0000000000..eb2fde7101 --- /dev/null +++ b/backend/internal/adapters/tracker/linear/tracker_test.go @@ -0,0 +1,194 @@ +package linear + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestNew_RequiresToken(t *testing.T) { + if _, err := New(Options{}); !errors.Is(err, ErrNoToken) { + t.Fatalf("New without token: %v, want ErrNoToken", err) + } + if _, err := New(Options{Token: StaticTokenSource(" ")}); !errors.Is(err, ErrNoToken) { + t.Fatalf("New with blank token: %v, want ErrNoToken", err) + } +} + +func TestList_BuildsFilter(t *testing.T) { + var captured struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + if got := r.Header.Get("Authorization"); got != "lin_api_test" { + t.Errorf("Authorization = %q, want %q", got, "lin_api_test") + } + _, _ = w.Write([]byte(`{"data":{"issues":{"nodes":[ + {"identifier":"ENG-1","title":"first","description":"body","url":"https://linear.app/x/issue/ENG-1", + "state":{"type":"backlog","name":"Backlog"}, + "labels":{"nodes":[{"name":"agent-ready"}]}, + "assignee":{"displayName":"Alice","name":"alice"}, + "assignees":{"nodes":[]}} + ]}}}`)) + })) + defer srv.Close() + + tracker, err := New(Options{Token: StaticTokenSource("lin_api_test"), BaseURL: srv.URL, HTTPClient: srv.Client()}) + if err != nil { + t.Fatalf("New: %v", err) + } + issues, err := tracker.List(context.Background(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: "ENG"}, domain.ListFilter{ + State: domain.ListOpen, + Labels: []string{"agent-ready"}, + Assignee: "Alice", + Limit: 5, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if got, want := len(issues), 1; got != want { + t.Fatalf("len(issues) = %d, want %d", got, want) + } + got := issues[0] + if got.ID.Provider != domain.TrackerProviderLinear || got.ID.Native != "ENG-1" { + t.Fatalf("issue ID = %+v", got.ID) + } + if got.State != domain.IssueOpen { + t.Fatalf("state = %q, want open", got.State) + } + if got.URL != "https://linear.app/x/issue/ENG-1" { + t.Fatalf("url = %q", got.URL) + } + if len(got.Labels) != 1 || got.Labels[0] != "agent-ready" { + t.Fatalf("labels = %v", got.Labels) + } + if len(got.Assignees) != 1 || got.Assignees[0] != "Alice" { + t.Fatalf("assignees = %v", got.Assignees) + } + + // Verify the GraphQL filter we built. + filter, ok := captured.Variables["filter"].(map[string]any) + if !ok { + t.Fatalf("filter not present in variables: %+v", captured.Variables) + } + team, ok := filter["team"].(map[string]any) + if !ok { + t.Fatalf("team filter missing: %+v", filter) + } + if !strings.Contains(asJSON(t, team), "ENG") { + t.Fatalf("team filter lacks ENG: %s", asJSON(t, team)) + } + if _, ok := filter["state"]; !ok { + t.Fatalf("state filter missing") + } + if _, ok := filter["labels"]; !ok { + t.Fatalf("labels filter missing") + } + if _, ok := filter["assignee"]; !ok { + t.Fatalf("assignee filter missing") + } + if first, _ := captured.Variables["first"].(float64); first != 5 { + t.Fatalf("first = %v, want 5", captured.Variables["first"]) + } +} + +func TestList_AssigneeWildcardOmitsFilter(t *testing.T) { + var captured struct { + Variables map[string]any `json:"variables"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + _, _ = w.Write([]byte(`{"data":{"issues":{"nodes":[]}}}`)) + })) + defer srv.Close() + + tracker, _ := New(Options{Token: StaticTokenSource("t"), BaseURL: srv.URL, HTTPClient: srv.Client()}) + _, err := tracker.List(context.Background(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: "ENG"}, domain.ListFilter{ + Assignee: "*", + }) + if err != nil { + t.Fatalf("List: %v", err) + } + filter, _ := captured.Variables["filter"].(map[string]any) + if _, has := filter["assignee"]; has { + t.Fatalf("assignee filter should be omitted for wildcard, got: %v", filter["assignee"]) + } +} + +func TestGet_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"issue":{ + "identifier":"ENG-7","title":"hello","description":"body","url":"https://linear.app/x/issue/ENG-7", + "state":{"type":"started","name":"In Progress"}, + "labels":{"nodes":[]}, + "assignee":null, + "assignees":{"nodes":[]} + }}}`)) + })) + defer srv.Close() + tracker, _ := New(Options{Token: StaticTokenSource("t"), BaseURL: srv.URL, HTTPClient: srv.Client()}) + got, err := tracker.Get(context.Background(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "ENG-7"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.State != domain.IssueInProgress { + t.Fatalf("state = %q, want in_progress", got.State) + } +} + +func TestGet_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"issue":null}}`)) + })) + defer srv.Close() + tracker, _ := New(Options{Token: StaticTokenSource("t"), BaseURL: srv.URL, HTTPClient: srv.Client()}) + _, err := tracker.Get(context.Background(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "ENG-404"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestQuery_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + })) + defer srv.Close() + tracker, _ := New(Options{Token: StaticTokenSource("t"), BaseURL: srv.URL, HTTPClient: srv.Client()}) + _, err := tracker.List(context.Background(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: "ENG"}, domain.ListFilter{}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("expected ErrAuthFailed, got %v", err) + } +} + +func TestWrongProvider(t *testing.T) { + tracker, _ := New(Options{Token: StaticTokenSource("t"), BaseURL: "http://invalid"}) + _, err := tracker.List(context.Background(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "acme/x"}, domain.ListFilter{}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("expected ErrWrongProvider, got %v", err) + } + _, err = tracker.Get(context.Background(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/x#1"}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("expected ErrWrongProvider for Get, got %v", err) + } +} + +func asJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index ff74dec8e5..54bd2f483d 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -85,18 +85,29 @@ type roleOverride struct { AgentConfig agentConfig `json:"agentConfig,omitempty"` } +// trackerIntakeConfig mirrors domain.TrackerIntakeConfig. +type trackerIntakeConfig struct { + Enabled bool `json:"enabled,omitempty"` + Provider string `json:"provider,omitempty"` + Repo string `json:"repo,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignee string `json:"assignee,omitempty"` + Limit int `json:"limit,omitempty"` +} + // projectConfig mirrors the daemon's typed domain.ProjectConfig for the CLI // client. The CLI sets common fields via flags and the whole object via // --config-json. type projectConfig struct { - DefaultBranch string `json:"defaultBranch,omitempty"` - SessionPrefix string `json:"sessionPrefix,omitempty"` - Env map[string]string `json:"env,omitempty"` - Symlinks []string `json:"symlinks,omitempty"` - PostCreate []string `json:"postCreate,omitempty"` - AgentConfig agentConfig `json:"agentConfig,omitempty"` - Worker roleOverride `json:"worker,omitempty"` - Orchestrator roleOverride `json:"orchestrator,omitempty"` + DefaultBranch string `json:"defaultBranch,omitempty"` + SessionPrefix string `json:"sessionPrefix,omitempty"` + Env map[string]string `json:"env,omitempty"` + Symlinks []string `json:"symlinks,omitempty"` + PostCreate []string `json:"postCreate,omitempty"` + AgentConfig agentConfig `json:"agentConfig,omitempty"` + Worker roleOverride `json:"worker,omitempty"` + Orchestrator roleOverride `json:"orchestrator,omitempty"` + TrackerIntake trackerIntakeConfig `json:"trackerIntake,omitempty"` } // setConfigRequest mirrors the daemon's SetConfigInput body for @@ -259,7 +270,7 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { Use: "set-config ", Short: "Set the per-project config", Long: "Replace a project's per-project config (branch, session prefix, env, " + - "symlinks, post-create, agent model/permissions, role overrides). The config " + + "symlinks, post-create, agent model/permissions, role overrides, tracker intake). The config " + "is resolved when a session spawns.\n\n" + "Set fields via flags, pass the whole object with --config-json, or --clear " + "to remove all config.", diff --git a/backend/internal/cli/project_test.go b/backend/internal/cli/project_test.go index eea7c924a4..23ca82e12c 100644 --- a/backend/internal/cli/project_test.go +++ b/backend/internal/cli/project_test.go @@ -12,6 +12,7 @@ import ( type projectCapture struct { method string path string + body []byte } func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, *projectCapture) { @@ -20,6 +21,7 @@ func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capture.method = r.Method capture.path = r.URL.Path + capture.body, _ = io.ReadAll(r.Body) if !strings.HasPrefix(r.URL.Path, "/api/v1/projects") { http.NotFound(w, r) return @@ -32,6 +34,29 @@ func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, return srv, capture } +func TestProjectSetConfig_TrackerIntakeJSON(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"project":{"id":"demo","path":"/repo/demo"}}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "set-config", "demo", "--config-json", `{"trackerIntake":{"enabled":true,"labels":["agent-ready"],"limit":5}}`) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodPut || capture.path != "/api/v1/projects/demo/config" { + t.Fatalf("request = %s %s, want PUT /api/v1/projects/demo/config", capture.method, capture.path) + } + var got setConfigRequest + 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.Limit != 5 || len(got.Config.TrackerIntake.Labels) != 1 || got.Config.TrackerIntake.Labels[0] != "agent-ready" { + t.Fatalf("tracker intake request = %#v", got.Config.TrackerIntake) + } +} + func TestProjectList_Success(t *testing.T) { cfg := setConfigEnv(t) srv, capture := projectServer(t, http.StatusOK, `{"projects":[{"id":"zeta","name":"Zeta","sessionPrefix":"zeta"},{"id":"alpha","name":"Alpha","sessionPrefix":"alpha","resolveError":"config missing"}]}`) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 8918388157..e0d0835f11 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -151,6 +151,7 @@ func Run() error { if reconcileErr := sessMgr.Reconcile(ctx); reconcileErr != nil { log.Error("reconcile sessions on boot failed", "err", reconcileErr) } + lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log) // ponytail: 5s tolerates a brief frontend restart; tune if dev hot-reload trips it. const supervisorGrace = 5 * time.Second diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index c5391fe197..5113d9dcb6 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -34,9 +34,10 @@ type lifecycleStack struct { // LCM is the Lifecycle Manager (the canonical write path). It is exposed so // startSession can share the same reducer the reaper drives, rather than // standing up a second store+LCM pair that would diverge under writes. - LCM *lifecycle.Manager - reaperDone <-chan struct{} - scmDone <-chan struct{} + LCM *lifecycle.Manager + reaperDone <-chan struct{} + scmDone <-chan struct{} + trackerDone <-chan struct{} } // startLifecycle constructs the Lifecycle Manager over the store and starts the @@ -56,6 +57,9 @@ func (l *lifecycleStack) Stop() { if l.scmDone != nil { <-l.scmDone } + if l.trackerDone != nil { + <-l.trackerDone + } } // sessionLifecycle is the narrow surface of sessionmanager.Manager used for diff --git a/backend/internal/daemon/tracker_intake_wiring.go b/backend/internal/daemon/tracker_intake_wiring.go new file mode 100644 index 0000000000..01948225f4 --- /dev/null +++ b/backend/internal/daemon/tracker_intake_wiring.go @@ -0,0 +1,280 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + trackergithub "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/github" + trackerjira "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/jira" + trackerlinear "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/linear" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + trackerintake "github.com/aoagents/agent-orchestrator/backend/internal/observe/trackerintake" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// startTrackerIntake wires the opt-in issue-intake loop. Each provider's +// adapter is constructed lazily on first use so daemon startup and projects +// that don't use a given provider do not pay an auth or network cost. +func startTrackerIntake(ctx context.Context, store *sqlite.Store, sessions *sessionsvc.Service, logger *slog.Logger) <-chan struct{} { + resolver := newMultiTrackerResolver(logger) + observer := trackerintake.New(resolver, store, sessions, trackerintake.Config{Logger: logger}) + return observer.Start(ctx) +} + +// multiTrackerResolver routes per project's configured provider to a lazy +// adapter. It implements trackerintake.TrackerResolver. +type multiTrackerResolver struct { + logger *slog.Logger + + githubOnce sync.Once + github *lazyGitHubTracker + + linearOnce sync.Once + linear *lazyLinearTracker + + jiraOnce sync.Once + jira *lazyJiraTracker +} + +func newMultiTrackerResolver(logger *slog.Logger) *multiTrackerResolver { + return &multiTrackerResolver{logger: logger} +} + +// Tracker returns the lazy adapter for the configured provider, constructing +// it on first request. +func (r *multiTrackerResolver) Tracker(provider domain.TrackerProvider) (ports.Tracker, error) { + switch provider { + case "", domain.TrackerProviderGitHub: + r.githubOnce.Do(func() { r.github = newLazyGitHubTracker(r.logger) }) + return r.github, nil + case domain.TrackerProviderLinear: + r.linearOnce.Do(func() { r.linear = newLazyLinearTracker(r.logger) }) + return r.linear, nil + case domain.TrackerProviderJira: + r.jiraOnce.Do(func() { r.jira = newLazyJiraTracker(r.logger) }) + return r.jira, nil + default: + return nil, fmt.Errorf("tracker intake: unknown provider %q", provider) + } +} + +// --------------------------------------------------------------------------- +// GitHub lazy adapter (token sourced from env or gh CLI fallback) +// --------------------------------------------------------------------------- + +type lazyGitHubTracker struct { + logger *slog.Logger + tokens *trackerTokenSource + mu sync.Mutex + tracker ports.Tracker +} + +func newLazyGitHubTracker(logger *slog.Logger) *lazyGitHubTracker { + return &lazyGitHubTracker{logger: logger, tokens: &trackerTokenSource{}} +} + +func (t *lazyGitHubTracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return domain.Issue{}, err + } + return tracker.Get(ctx, id) +} + +func (t *lazyGitHubTracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return nil, err + } + return tracker.List(ctx, repo, filter) +} + +func (t *lazyGitHubTracker) Preflight(ctx context.Context) error { + tracker, err := t.resolve() + if err != nil { + return err + } + return tracker.Preflight(ctx) +} + +func (t *lazyGitHubTracker) resolve() (ports.Tracker, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.tracker != nil { + return t.tracker, nil + } + tracker, err := trackergithub.New(trackergithub.Options{Token: t.tokens}) + if err != nil { + if errors.Is(err, trackergithub.ErrNoToken) { + t.logger.Warn("tracker intake disabled: no usable GitHub token", "err", err) + } + return nil, err + } + t.tracker = tracker + return tracker, nil +} + +const ( + trackerTokenCacheTTL = 5 * time.Minute + trackerTokenCommandTimeout = 5 * time.Second +) + +// trackerTokenSource mirrors the SCM credential precedence while returning the +// tracker adapter's own ErrNoToken sentinel. +type trackerTokenSource struct { + mu sync.Mutex + token string + expiresAt time.Time +} + +func (s *trackerTokenSource) Token(ctx context.Context) (string, error) { + env := trackergithub.EnvTokenSource{EnvVars: []string{"AO_GITHUB_TOKEN"}} + if tok, err := env.Token(ctx); err == nil { + return tok, nil + } else if !errors.Is(err, trackergithub.ErrNoToken) { + return "", err + } + + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + if s.token != "" && now.Before(s.expiresAt) { + return s.token, nil + } + cmdCtx, cancel := context.WithTimeout(ctx, trackerTokenCommandTimeout) + defer cancel() + out, err := aoprocess.CommandContext(cmdCtx, "gh", "auth", "token").Output() + if err != nil { + return "", err + } + token := strings.TrimSpace(string(out)) + if token == "" { + return "", trackergithub.ErrNoToken + } + s.token = token + s.expiresAt = now.Add(trackerTokenCacheTTL) + return token, nil +} + +// --------------------------------------------------------------------------- +// Linear lazy adapter (env-only token) +// --------------------------------------------------------------------------- + +type lazyLinearTracker struct { + logger *slog.Logger + mu sync.Mutex + tracker ports.Tracker +} + +func newLazyLinearTracker(logger *slog.Logger) *lazyLinearTracker { + return &lazyLinearTracker{logger: logger} +} + +func (t *lazyLinearTracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return domain.Issue{}, err + } + return tracker.Get(ctx, id) +} + +func (t *lazyLinearTracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return nil, err + } + return tracker.List(ctx, repo, filter) +} + +func (t *lazyLinearTracker) Preflight(ctx context.Context) error { + tracker, err := t.resolve() + if err != nil { + return err + } + return tracker.Preflight(ctx) +} + +func (t *lazyLinearTracker) resolve() (ports.Tracker, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.tracker != nil { + return t.tracker, nil + } + tokens := trackerlinear.EnvTokenSource{EnvVars: []string{"AO_LINEAR_TOKEN"}} + tracker, err := trackerlinear.New(trackerlinear.Options{Token: tokens}) + if err != nil { + if errors.Is(err, trackerlinear.ErrNoToken) { + t.logger.Warn("tracker intake disabled: no usable Linear token", "err", err) + } + return nil, err + } + t.tracker = tracker + return tracker, nil +} + +// --------------------------------------------------------------------------- +// Jira lazy adapter (env-only email + token) +// --------------------------------------------------------------------------- + +type lazyJiraTracker struct { + logger *slog.Logger + mu sync.Mutex + tracker ports.Tracker +} + +func newLazyJiraTracker(logger *slog.Logger) *lazyJiraTracker { + return &lazyJiraTracker{logger: logger} +} + +func (t *lazyJiraTracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return domain.Issue{}, err + } + return tracker.Get(ctx, id) +} + +func (t *lazyJiraTracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return nil, err + } + return tracker.List(ctx, repo, filter) +} + +func (t *lazyJiraTracker) Preflight(ctx context.Context) error { + tracker, err := t.resolve() + if err != nil { + return err + } + return tracker.Preflight(ctx) +} + +func (t *lazyJiraTracker) resolve() (ports.Tracker, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.tracker != nil { + return t.tracker, nil + } + creds := trackerjira.EnvCredentials{ + EmailVars: []string{"AO_JIRA_EMAIL"}, + TokenVars: []string{"AO_JIRA_TOKEN"}, + } + tracker, err := trackerjira.New(trackerjira.Options{Credentials: creds}) + if err != nil { + if errors.Is(err, trackerjira.ErrNoCredentials) { + t.logger.Warn("tracker intake disabled: no usable Jira credentials", "err", err) + } + return nil, err + } + t.tracker = tracker + return tracker, nil +} diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 840ad34b94..a02ed006e4 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -14,9 +14,8 @@ import ( // // Only fields with a live consumer are modeled: DefaultBranch, Env, Symlinks, // PostCreate, AgentConfig, and the role overrides are consumed at spawn; -// SessionPrefix feeds the display prefix. Settings whose consumers do not yet -// exist (tracker/SCM per-project config, prompt rules) are intentionally absent -// and land in focused follow-up PRs alongside the code that reads them. +// SessionPrefix feeds the display prefix. TrackerIntake feeds the background +// issue-intake loop. type ProjectConfig struct { // DefaultBranch is the base branch new session worktrees are created from. DefaultBranch string `json:"defaultBranch,omitempty"` @@ -41,6 +40,43 @@ type ProjectConfig struct { // triggered. It is configured independently of the Worker override; an empty // list falls back to the worker's own harness (see ResolveReviewerHarness). Reviewers []ReviewerConfig `json:"reviewers,omitempty"` + + // TrackerIntake controls issue-driven worker spawning. It is opt-in and + // read-only toward the tracker in v1: matching issues spawn sessions, but the + // tracker is not commented on or transitioned. + TrackerIntake TrackerIntakeConfig `json:"trackerIntake,omitempty"` +} + +// TrackerIntakeConfig controls the first issue-intake slice for a project. +// Enabled requires at least one explicit eligibility rule so turning intake on +// cannot accidentally drain an entire issue backlog. +// +// Scope fields are provider-specific: only the field set that matches Provider +// is used; the others must be empty. Validate enforces this so a stale field +// from a prior provider does not silently survive a provider switch. +type TrackerIntakeConfig struct { + Enabled bool `json:"enabled,omitempty"` + // Provider defaults to github when Enabled is true. + Provider TrackerProvider `json:"provider,omitempty" enum:"github,linear,jira"` + // 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"` + // Team is the Linear team key (e.g. "ENG"). Linear only. + Team string `json:"team,omitempty"` + // BaseURL is the Jira Cloud site URL (e.g. "acme.atlassian.net" or a full + // https URL). Jira only. + BaseURL string `json:"baseURL,omitempty"` + // ProjectKey is the Jira project key (e.g. "ENG"). Jira only. + ProjectKey string `json:"projectKey,omitempty"` + // Labels narrows eligible issues. All labels are forwarded to the provider's + // list filter; providers decide whether the match is all-of or provider-native. + Labels []string `json:"labels,omitempty"` + // Assignee narrows eligible issues to one assignee. Provider-specific values + // such as "*" are passed through unchanged. + Assignee string `json:"assignee,omitempty"` + // Limit caps the number of issues fetched per poll. Zero lets the adapter use + // its default. + Limit int `json:"limit,omitempty"` } // ReviewerConfig names one reviewer agent by harness. The harness is drawn from @@ -92,6 +128,7 @@ func (c ProjectConfig) WithDefaults() ProjectConfig { if c.DefaultBranch == "" { c.DefaultBranch = def.DefaultBranch } + c.TrackerIntake = c.TrackerIntake.WithDefaults() return c } @@ -128,6 +165,116 @@ func (c ProjectConfig) Validate() error { return fmt.Errorf("reviewers[%d].harness: unknown harness %q", i, rv.Harness) } } + if err := c.TrackerIntake.Validate(); err != nil { + return err + } + return nil +} + +// WithDefaults fills the provider only when intake is enabled. Disabled intake +// leaves the zero value untouched so empty project configs still store as NULL. +func (c TrackerIntakeConfig) WithDefaults() TrackerIntakeConfig { + if c.Enabled && c.Provider == "" { + c.Provider = TrackerProviderGitHub + } + return c +} + +// Validate rejects accidental broad intake, unknown providers, and +// cross-provider field bleed (e.g. a Linear "team" left set after switching to +// GitHub). +func (c TrackerIntakeConfig) Validate() error { + if !c.Enabled { + return nil + } + c = c.WithDefaults() + switch c.Provider { + case TrackerProviderGitHub: + if err := validateNoWhitespaceField("trackerIntake.repo", c.Repo); err != nil { + return err + } + if err := mustBeEmpty("trackerIntake.team", c.Team, "linear"); err != nil { + return err + } + if err := mustBeEmpty("trackerIntake.baseURL", c.BaseURL, "jira"); err != nil { + return err + } + if err := mustBeEmpty("trackerIntake.projectKey", c.ProjectKey, "jira"); err != nil { + return err + } + case TrackerProviderLinear: + team := strings.TrimSpace(c.Team) + if team == "" || team != c.Team { + return fmt.Errorf("trackerIntake.team: must be a non-empty Linear team key without surrounding whitespace") + } + if err := mustBeEmpty("trackerIntake.repo", c.Repo, "github"); err != nil { + return err + } + if err := mustBeEmpty("trackerIntake.baseURL", c.BaseURL, "jira"); err != nil { + return err + } + if err := mustBeEmpty("trackerIntake.projectKey", c.ProjectKey, "jira"); err != nil { + return err + } + case TrackerProviderJira: + base := strings.TrimSpace(c.BaseURL) + if base == "" || base != c.BaseURL { + return fmt.Errorf("trackerIntake.baseURL: must be a non-empty Jira site URL without surrounding whitespace") + } + if strings.HasSuffix(c.BaseURL, "/") { + return fmt.Errorf("trackerIntake.baseURL: must not have a trailing slash") + } + projectKey := strings.TrimSpace(c.ProjectKey) + if projectKey == "" || projectKey != c.ProjectKey { + return fmt.Errorf("trackerIntake.projectKey: must be a non-empty Jira project key without surrounding whitespace") + } + if err := mustBeEmpty("trackerIntake.repo", c.Repo, "github"); err != nil { + return err + } + if err := mustBeEmpty("trackerIntake.team", c.Team, "linear"); err != nil { + return err + } + default: + return fmt.Errorf("trackerIntake.provider: unknown provider %q", c.Provider) + } + hasLabel := false + for i, label := range c.Labels { + trimmed := strings.TrimSpace(label) + if trimmed == "" { + return fmt.Errorf("trackerIntake.labels[%d]: must not be empty", i) + } + if trimmed != label { + return fmt.Errorf("trackerIntake.labels[%d]: must not contain surrounding whitespace", i) + } + hasLabel = true + } + assignee := strings.TrimSpace(c.Assignee) + if assignee != c.Assignee { + return fmt.Errorf("trackerIntake.assignee: must not contain surrounding whitespace") + } + if !hasLabel && assignee == "" { + return fmt.Errorf("trackerIntake: enabled intake requires at least one label or assignee rule") + } + if c.Limit < 0 { + return fmt.Errorf("trackerIntake.limit: must be non-negative") + } + return nil +} + +func mustBeEmpty(field, value, owningProvider string) error { + if strings.TrimSpace(value) != "" { + return fmt.Errorf("%s: only valid for provider %q", field, owningProvider) + } + return nil +} + +func validateNoWhitespaceField(field, value string) error { + if strings.TrimSpace(value) != value { + return fmt.Errorf("%s: must not contain surrounding whitespace", field) + } + if strings.ContainsAny(value, " \t\r\n") { + return fmt.Errorf("%s: must not contain whitespace", field) + } return nil } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 58d9c3bada..799e76806b 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -27,6 +27,32 @@ func TestProjectConfigValidate(t *testing.T) { {"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true}, {"worker harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessCodex)}}}, true}, {"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true}, + {"tracker intake label rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, false}, + {"tracker intake assignee rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, false}, + {"tracker intake no rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true}}, true}, + {"tracker intake unknown provider", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: "unknown", Labels: []string{"agent-ready"}}}, true}, + {"tracker intake empty label", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{""}}}, true}, + {"tracker intake label with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{" agent-ready"}}}, true}, + {"tracker intake repo with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Repo: " acme/demo", Labels: []string{"agent-ready"}}}, true}, + {"tracker intake assignee with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: " alice"}}, true}, + {"tracker intake negative limit", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}, Limit: -1}}, true}, + + // Linear provider — team scope required and cross-provider fields rejected. + {"linear intake ok", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderLinear, Team: "ENG", Labels: []string{"ready"}}}, false}, + {"linear intake missing team", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderLinear, Labels: []string{"ready"}}}, true}, + {"linear intake team whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderLinear, Team: " ENG", Labels: []string{"ready"}}}, true}, + {"linear intake leaks repo", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderLinear, Team: "ENG", Repo: "acme/demo", Labels: []string{"ready"}}}, true}, + {"linear intake leaks jira fields", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderLinear, Team: "ENG", BaseURL: "acme.atlassian.net", Labels: []string{"ready"}}}, true}, + {"linear intake no rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderLinear, Team: "ENG"}}, true}, + + // Jira provider — base URL + project key required and cross-provider fields rejected. + {"jira intake ok", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderJira, BaseURL: "https://acme.atlassian.net", ProjectKey: "ENG", Labels: []string{"ready"}}}, false}, + {"jira intake host ok", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderJira, BaseURL: "acme.atlassian.net", ProjectKey: "ENG", Labels: []string{"ready"}}}, false}, + {"jira intake missing base URL", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderJira, ProjectKey: "ENG", Labels: []string{"ready"}}}, true}, + {"jira intake trailing slash", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderJira, BaseURL: "acme.atlassian.net/", ProjectKey: "ENG", Labels: []string{"ready"}}}, true}, + {"jira intake missing project key", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderJira, BaseURL: "acme.atlassian.net", Labels: []string{"ready"}}}, true}, + {"jira intake leaks repo", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderJira, BaseURL: "acme.atlassian.net", ProjectKey: "ENG", Repo: "acme/demo", Labels: []string{"ready"}}}, true}, + {"jira intake leaks linear team", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderJira, BaseURL: "acme.atlassian.net", ProjectKey: "ENG", Team: "ENG", Labels: []string{"ready"}}}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -71,6 +97,16 @@ func TestProjectConfigWithDefaults(t *testing.T) { if got.AgentConfig.Model != "m" { t.Fatalf("WithDefaults dropped a set field: %#v", got.AgentConfig) } + + got = (ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}).WithDefaults() + if got.TrackerIntake.Provider != TrackerProviderGitHub { + t.Fatalf("TrackerIntake.Provider = %q, want %q", got.TrackerIntake.Provider, TrackerProviderGitHub) + } + + got = (ProjectConfig{}).WithDefaults() + if got.TrackerIntake.Provider != "" { + t.Fatalf("disabled TrackerIntake.Provider = %q, want empty", got.TrackerIntake.Provider) + } } func TestResolveReviewerHarness(t *testing.T) { diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index fde1631b36..9746df994a 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -3,8 +3,13 @@ package domain // TrackerProvider identifies an issue-tracker provider implementation. type TrackerProvider string -// TrackerProviderGitHub is the only supported issue-tracker provider. -const TrackerProviderGitHub TrackerProvider = "github" +// Supported issue-tracker providers. Each value maps 1:1 to a backend adapter +// under backend/internal/adapters/tracker/. +const ( + TrackerProviderGitHub TrackerProvider = "github" + TrackerProviderLinear TrackerProvider = "linear" + TrackerProviderJira TrackerProvider = "jira" +) // TrackerID identifies one issue. Native is the provider's own canonical form // ("owner/repo#123" for GitHub) and is parsed by the adapter. @@ -39,12 +44,21 @@ type Issue struct { Assignees []string `json:"assignees,omitempty"` } -// TrackerRepo identifies a repository for cross-issue queries like Tracker.List. -// Native is the provider's canonical owner/project form, e.g. "owner/repo" for -// GitHub. +// TrackerRepo identifies a repository (or its provider analogue) for +// cross-issue queries like Tracker.List. Native is the provider's canonical +// scope key: +// - GitHub: "owner/repo" +// - Linear: team key, e.g. "ENG" +// - Jira: project key, e.g. "ENG" +// +// BaseURL is provider-specific metadata. Only Jira uses it (the Cloud site URL, +// e.g. "https://acme.atlassian.net") because Jira issues are addressed against +// a tenant's own site rather than a global API host. GitHub and Linear leave it +// empty. type TrackerRepo struct { Provider TrackerProvider `json:"provider"` Native string `json:"native"` + BaseURL string `json:"baseURL,omitempty"` } // ListStateFilter narrows Tracker.List results by the provider's coarse diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 738a9dcef6..b50bfe2e54 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1938,6 +1938,8 @@ components: items: type: string type: array + trackerIntake: + $ref: '#/components/schemas/TrackerIntakeConfig' worker: $ref: '#/components/schemas/RoleOverride' type: object @@ -2543,6 +2545,33 @@ components: - runId - verdict type: object + TrackerIntakeConfig: + properties: + assignee: + type: string + baseURL: + type: string + enabled: + type: boolean + labels: + items: + type: string + type: array + limit: + type: integer + projectKey: + type: string + provider: + enum: + - github + - linear + - jira + type: string + repo: + type: string + team: + type: string + type: object TriggerReviewResponse: properties: reviewerHandleId: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 21570e3582..5643286846 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -124,13 +124,14 @@ var schemaNames = map[string]string{ // httpd/envelope "EnvelopeAPIError": "APIError", // domain - "DomainProjectID": "ProjectID", - "DomainSessionID": "SessionID", - "DomainIssueID": "IssueID", - "DomainSession": "Session", - "DomainProjectConfig": "ProjectConfig", - "DomainAgentConfig": "AgentConfig", - "DomainRoleOverride": "RoleOverride", + "DomainProjectID": "ProjectID", + "DomainSessionID": "SessionID", + "DomainIssueID": "IssueID", + "DomainSession": "Session", + "DomainProjectConfig": "ProjectConfig", + "DomainTrackerIntakeConfig": "TrackerIntakeConfig", + "DomainAgentConfig": "AgentConfig", + "DomainRoleOverride": "RoleOverride", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", diff --git a/backend/internal/observe/trackerintake/observer.go b/backend/internal/observe/trackerintake/observer.go new file mode 100644 index 0000000000..a2d954e364 --- /dev/null +++ b/backend/internal/observe/trackerintake/observer.go @@ -0,0 +1,396 @@ +// Package trackerintake implements the opt-in issue-intake observer. It polls a +// project's configured tracker for eligible issues and starts one worker session +// per issue, leaving PR/lifecycle handling to the existing observers. +package trackerintake + +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" +) + +const ( + // DefaultTickInterval is intentionally slower than runtime liveness checks: + // intake is a backlog sweep, not an interactive status surface. + DefaultTickInterval = time.Minute + // DefaultFailureBackoff suppresses repeated polls for a project after an + // intake failure. The observer retries automatically after this window. + DefaultFailureBackoff = 5 * time.Minute + // maxIntakePromptLen mirrors the session HTTP prompt limit. Intake uses the + // session service directly, so it must enforce the same boundary itself. + maxIntakePromptLen = 4096 + + intakePromptTruncationNotice = "\n\n[Issue content truncated to fit the session prompt limit. Open the linked issue for the full details.]\n" + intakePromptFooter = "\nImplement the requested change in this repository, run the relevant checks, and open or update a pull request when ready." +) + +// Store is the durable read surface the observer needs. +type Store interface { + ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) +} + +// Spawner is the session creation surface used by intake. +type Spawner interface { + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) +} + +// TrackerResolver picks the right tracker adapter for a project's configured +// provider. The daemon wires a concrete resolver that holds a lazy adapter per +// provider; tests use SingleTrackerResolver. +type TrackerResolver interface { + Tracker(provider domain.TrackerProvider) (ports.Tracker, error) +} + +// SingleTrackerResolver returns the same tracker for one specific provider and +// refuses every other provider. It exists so tests (and single-provider +// deployments) don't need to construct a map. +type SingleTrackerResolver struct { + Provider domain.TrackerProvider + Adapter ports.Tracker +} + +// Tracker returns the wrapped adapter when the requested provider matches, or +// when the resolver was constructed without a provider pin. +func (s SingleTrackerResolver) Tracker(provider domain.TrackerProvider) (ports.Tracker, error) { + if s.Adapter == nil { + return nil, fmt.Errorf("tracker intake: no adapter for provider %q", provider) + } + if s.Provider == "" || provider == "" || provider == s.Provider { + return s.Adapter, nil + } + return nil, fmt.Errorf("tracker intake: no adapter for provider %q", provider) +} + +// Config holds optional observer knobs. Zero values use production defaults. +type Config struct { + Tick time.Duration + FailureBackoff time.Duration + Clock func() time.Time + Logger *slog.Logger +} + +// Observer polls configured projects and starts sessions for eligible issues. +type Observer struct { + resolver TrackerResolver + store Store + spawner Spawner + tick time.Duration + failureBackoff time.Duration + clock func() time.Time + logger *slog.Logger + backoffUntil map[string]time.Time +} + +// New constructs an Observer with safe defaults. +func New(resolver TrackerResolver, store Store, spawner Spawner, cfg Config) *Observer { + o := &Observer{resolver: resolver, store: store, spawner: spawner, tick: cfg.Tick, failureBackoff: cfg.FailureBackoff, clock: cfg.Clock, logger: cfg.Logger, backoffUntil: map[string]time.Time{}} + if o.tick <= 0 { + o.tick = DefaultTickInterval + } + if o.failureBackoff <= 0 { + o.failureBackoff = DefaultFailureBackoff + } + if o.clock == nil { + o.clock = time.Now + } + if o.logger == nil { + o.logger = slog.Default() + } + return o +} + +// Start launches the observer loop. The first poll runs immediately inside the +// goroutine, keeping daemon startup non-blocking. +func (o *Observer) Start(ctx context.Context) <-chan struct{} { + return observe.StartPollLoop(ctx, o.tick, o.Poll, o.logger, "tracker intake") +} + +// Poll runs one synchronous intake pass. Store discovery failures are returned +// because they prevent the pass from knowing the current world; provider and +// spawn failures are logged and skipped so one bad issue/project does not block +// the rest of the daemon. +func (o *Observer) Poll(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if o.resolver == nil || o.store == nil || o.spawner == nil { + return nil + } + now := o.clock().UTC() + projects, err := o.store.ListProjects(ctx) + if err != nil { + return err + } + enabledProjects := make([]domain.ProjectRecord, 0, len(projects)) + for _, project := range projects { + if project.Config.TrackerIntake.Enabled { + enabledProjects = append(enabledProjects, project) + } + } + if len(enabledProjects) == 0 { + return nil + } + sessions, err := o.store.ListAllSessions(ctx) + if err != nil { + return err + } + seen := seenIssueIDs(sessions) + for _, project := range enabledProjects { + if err := ctx.Err(); err != nil { + return err + } + if until, ok := o.backoffUntil[project.ID]; ok && now.Before(until) { + o.logger.Debug("tracker intake: project in failure backoff", "project", project.ID, "until", until) + continue + } + if failed := o.pollProject(ctx, project, seen); failed { + o.backoffUntil[project.ID] = now.Add(o.failureBackoff) + } else { + delete(o.backoffUntil, project.ID) + } + } + return nil +} + +// pollProject returns failed=true for conditions that should be retried after a +// backoff window rather than logged on every poll. +func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord, seen map[domain.IssueID]bool) (failed bool) { + cfg := project.Config.TrackerIntake.WithDefaults() + if !cfg.Enabled { + return false + } + if err := cfg.Validate(); err != nil { + o.logger.Warn("tracker intake: skipping project with invalid config", "project", project.ID, "err", err) + return true + } + repo, ok := trackerRepo(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 + } + tracker, err := o.resolver.Tracker(cfg.Provider) + if err != nil { + o.logger.Warn("tracker intake: no adapter for provider", "project", project.ID, "provider", cfg.Provider, "err", err) + return true + } + issues, err := tracker.List(ctx, repo, domain.ListFilter{ + State: domain.ListOpen, + Labels: cfg.Labels, + Assignee: cfg.Assignee, + Limit: cfg.Limit, + }) + if err != nil { + o.logger.Error("tracker intake: list issues failed", "project", project.ID, "repo", repo.Native, "err", err) + return true + } + var spawnFailed bool + for _, issue := range issues { + if ctx.Err() != nil { + return true + } + if issue.State != domain.IssueOpen { + continue + } + if !issueMatchesConfig(issue, cfg) { + continue + } + issueID := CanonicalIssueID(issue.ID) + if issueID == "" || seen[issueID] { + continue + } + if _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{ + ProjectID: projectID(project), + IssueID: issueID, + Kind: domain.KindWorker, + Prompt: BuildIssuePrompt(issue), + }); err != nil { + o.logger.Error("tracker intake: spawn issue session failed", "project", project.ID, "issue", issueID, "err", err) + spawnFailed = true + continue + } + seen[issueID] = true + } + return spawnFailed +} + +func issueMatchesConfig(issue domain.Issue, cfg domain.TrackerIntakeConfig) bool { + for _, required := range cfg.Labels { + if !containsFold(issue.Labels, strings.TrimSpace(required)) { + return false + } + } + 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) { + 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 { + if sess.IssueID != "" { + seen[sess.IssueID] = true + } + } + return seen +} + +func projectID(project domain.ProjectRecord) domain.ProjectID { + return domain.ProjectID(project.ID) +} + +// CanonicalIssueID stores tracker issue ids in sessions.issue_id with the +// provider included, so future providers cannot collide on native ids. +func CanonicalIssueID(id domain.TrackerID) domain.IssueID { + provider := id.Provider + if provider == "" { + provider = domain.TrackerProviderGitHub + } + native := strings.TrimSpace(id.Native) + if native == "" { + return "" + } + return domain.IssueID(string(provider) + ":" + native) +} + +// BuildIssuePrompt turns normalized issue facts into the worker's initial task. +func BuildIssuePrompt(issue domain.Issue) string { + var b strings.Builder + fmt.Fprintf(&b, "Work on tracker issue %s.\n\n", CanonicalIssueID(issue.ID)) + if issue.Title != "" { + fmt.Fprintf(&b, "Title: %s\n", issue.Title) + } + if issue.URL != "" { + fmt.Fprintf(&b, "URL: %s\n", issue.URL) + } + if len(issue.Labels) > 0 { + fmt.Fprintf(&b, "Labels: %s\n", strings.Join(issue.Labels, ", ")) + } + if len(issue.Assignees) > 0 { + fmt.Fprintf(&b, "Assignees: %s\n", strings.Join(issue.Assignees, ", ")) + } + body := strings.TrimSpace(issue.Body) + if body != "" { + fmt.Fprintf(&b, "\nBody:\n%s\n", body) + } + b.WriteString(intakePromptFooter) + return capIntakePrompt(b.String()) +} + +func capIntakePrompt(prompt string) string { + if len(prompt) <= maxIntakePromptLen { + return prompt + } + prefix := strings.TrimSuffix(prompt, intakePromptFooter) + prefixBudget := maxIntakePromptLen - len(intakePromptTruncationNotice) - len(intakePromptFooter) + if prefixBudget <= 0 { + return truncateUTF8(prompt, maxIntakePromptLen) + } + return truncateUTF8(prefix, prefixBudget) + intakePromptTruncationNotice + intakePromptFooter +} + +func truncateUTF8(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := 0 + for i := range s { + if i > maxBytes { + break + } + cut = i + } + return s[:cut] +} + +func trackerRepo(project domain.ProjectRecord, cfg domain.TrackerIntakeConfig) (domain.TrackerRepo, bool) { + provider := cfg.Provider + if provider == "" { + provider = domain.TrackerProviderGitHub + } + switch provider { + case domain.TrackerProviderGitHub: + 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 + case domain.TrackerProviderLinear: + team := strings.TrimSpace(cfg.Team) + if team == "" { + return domain.TrackerRepo{}, false + } + return domain.TrackerRepo{Provider: provider, Native: team}, true + case domain.TrackerProviderJira: + projectKey := strings.TrimSpace(cfg.ProjectKey) + base := strings.TrimSpace(cfg.BaseURL) + if projectKey == "" || base == "" { + return domain.TrackerRepo{}, false + } + return domain.TrackerRepo{Provider: provider, Native: projectKey, BaseURL: base}, true + default: + return domain.TrackerRepo{}, false + } +} + +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 new file mode 100644 index 0000000000..d96ee00b5f --- /dev/null +++ b/backend/internal/observe/trackerintake/observer_test.go @@ -0,0 +1,418 @@ +package trackerintake + +import ( + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestPollSpawnsWorkerForEligibleIssue(t *testing.T) { + ctx := context.Background() + store := &fakeStore{ + projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ + Enabled: true, + Labels: []string{"agent-ready"}, + Limit: 10, + }}, + }}, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#12"}, + Title: "Fix login", + Body: "The login form submits twice.", + State: domain.IssueOpen, + URL: "https://github.com/acme/demo/issues/12", + Labels: []string{"agent-ready"}, + Assignees: []string{"alice"}, + }}} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(ctx); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 { + t.Fatalf("spawn calls = %d, want 1", len(spawner.calls)) + } + call := spawner.calls[0] + if call.ProjectID != "demo" || call.Kind != domain.KindWorker { + t.Fatalf("spawn config = %+v", call) + } + if call.IssueID != "github:acme/demo#12" { + t.Fatalf("IssueID = %q, want canonical github id", call.IssueID) + } + if !strings.Contains(call.Prompt, "Fix login") || !strings.Contains(call.Prompt, "The login form submits twice.") { + t.Fatalf("prompt missing issue context:\n%s", call.Prompt) + } + if len(tracker.filters) != 1 { + t.Fatalf("tracker filters = %d, want 1", len(tracker.filters)) + } + if got := tracker.filters[0]; got.State != domain.ListOpen || got.Labels[0] != "agent-ready" || got.Limit != 10 { + t.Fatalf("tracker filter = %+v", got) + } +} + +func TestPollSkipsExistingIssueSessionsAfterRestart(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, Labels: []string{"agent-ready"}}}, + }}, + sessions: []domain.SessionRecord{{ID: "demo-1", ProjectID: "demo", IssueID: "github:acme/demo#12"}}, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#12"}, + Title: "Already running", + State: domain.IssueOpen, + }}} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 0 { + t.Fatalf("spawn calls = %d, want 0", len(spawner.calls)) + } +} + +func TestPollSkipsSessionScanWhenIntakeDisabled(t *testing.T) { + store := &fakeStore{ + projects: []domain.ProjectRecord{{ID: "demo"}}, + sessionsErr: errors.New("session scan should not run"), + } + + if err := New(singleResolver(&fakeTracker{}), store, &fakeSpawner{}, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v, want nil", err) + } +} + +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, Labels: []string{"agent-ready"}}}}, + }, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/off#1"}, + Title: "ignored", + State: domain.IssueOpen, + }}} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(tracker.repos) != 0 { + t.Fatalf("tracker was called for invalid/off projects: %+v", tracker.repos) + } + if len(spawner.calls) != 0 { + t.Fatalf("spawn calls = %d, want 0", len(spawner.calls)) + } +} + +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, Labels: []string{"agent-ready"}}}, + }, + { + ID: "good", + RepoOriginURL: "https://github.com/acme/good.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, + }, + }} + tracker := &fakeTracker{ + failRepos: map[string]error{"acme/bad": errors.New("rate limited")}, + issuesByRepo: map[string][]domain.Issue{ + "acme/good": { + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/good#1"}, Title: "first", State: domain.IssueOpen, Labels: []string{"agent-ready"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/good#2"}, Title: "second", State: domain.IssueOpen, Labels: []string{"agent-ready"}}, + }, + }, + } + spawner := &fakeSpawner{failIssue: domain.IssueID("github:acme/good#1")} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 2 { + t.Fatalf("spawn attempts = %d, want 2", len(spawner.calls)) + } + if spawner.calls[1].IssueID != "github:acme/good#2" { + t.Fatalf("second spawn issue = %q", spawner.calls[1].IssueID) + } +} + +func TestPollBacksOffProjectAfterFailure(t *testing.T) { + now := time.Date(2026, 6, 27, 10, 0, 0, 0, time.UTC) + store := &fakeStore{projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, + }}} + tracker := &fakeTracker{failRepos: map[string]error{"acme/demo": errors.New("rate limited")}} + observer := New(singleResolver(tracker), store, &fakeSpawner{}, Config{ + Clock: func() time.Time { return now }, + FailureBackoff: time.Minute, + Logger: discardLogger(), + }) + + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("first Poll() error = %v", err) + } + if len(tracker.repos) != 1 { + t.Fatalf("tracker calls after first poll = %d, want 1", len(tracker.repos)) + } + + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("second Poll() error = %v", err) + } + if len(tracker.repos) != 1 { + t.Fatalf("tracker calls during backoff = %d, want still 1", len(tracker.repos)) + } + + now = now.Add(time.Minute + time.Nanosecond) + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("third Poll() error = %v", err) + } + if len(tracker.repos) != 2 { + t.Fatalf("tracker calls after backoff = %d, want 2", len(tracker.repos)) + } +} + +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, Labels: []string{"agent-ready"}}}, + }}} + tracker := &fakeTracker{issues: []domain.Issue{ + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "already active", State: domain.IssueInProgress, Labels: []string{"agent-ready"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "ready", State: domain.IssueOpen, Labels: []string{"agent-ready"}}, + }} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 || spawner.calls[0].IssueID != "github:acme/demo#2" { + t.Fatalf("spawn calls = %+v, want only open issue #2", spawner.calls) + } +} + +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, Labels: []string{"agent-ready"}, Assignee: "alice"}}, + }}} + tracker := &fakeTracker{issues: []domain.Issue{ + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "missing label", State: domain.IssueOpen, Assignees: []string{"alice"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "wrong assignee", State: domain.IssueOpen, Labels: []string{"agent-ready"}, Assignees: []string{"bob"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#3"}, Title: "eligible", State: domain.IssueOpen, Labels: []string{"Agent-Ready"}, Assignees: []string{"Alice"}}, + }} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + 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) + } +} + +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"}, + Title: "Large issue", + URL: "https://github.com/acme/demo/issues/99", + Body: strings.Repeat("body ", 2000), + }) + if len(prompt) > maxIntakePromptLen { + t.Fatalf("prompt length = %d, want <= %d", len(prompt), maxIntakePromptLen) + } + if !strings.Contains(prompt, "Issue content truncated") { + t.Fatalf("prompt missing truncation notice:\n%s", prompt) + } + if !strings.Contains(prompt, "https://github.com/acme/demo/issues/99") { + t.Fatalf("prompt missing issue URL:\n%s", prompt) + } + if !strings.HasSuffix(prompt, intakePromptFooter) { + t.Fatalf("prompt missing footer:\n%s", prompt) + } +} + +func singleResolver(tracker ports.Tracker) TrackerResolver { + return SingleTrackerResolver{Adapter: tracker} +} + +func TestPollRoutesPerProvider(t *testing.T) { + store := &fakeStore{projects: []domain.ProjectRecord{ + { + ID: "gh", + RepoOriginURL: "https://github.com/acme/gh.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Provider: domain.TrackerProviderGitHub, Labels: []string{"ready"}}}, + }, + { + ID: "ln", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Provider: domain.TrackerProviderLinear, Team: "ENG", Labels: []string{"ready"}}}, + }, + { + ID: "jr", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Provider: domain.TrackerProviderJira, BaseURL: "https://acme.atlassian.net", ProjectKey: "ENG", Labels: []string{"ready"}}}, + }, + }} + gh := &fakeTracker{issues: []domain.Issue{{ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/gh#1"}, Title: "g", State: domain.IssueOpen, Labels: []string{"ready"}}}} + ln := &fakeTracker{issues: []domain.Issue{{ID: domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "ENG-1"}, Title: "l", State: domain.IssueOpen, Labels: []string{"ready"}}}} + jr := &fakeTracker{issues: []domain.Issue{{ID: domain.TrackerID{Provider: domain.TrackerProviderJira, Native: "ENG-1"}, Title: "j", State: domain.IssueOpen, Labels: []string{"ready"}}}} + resolver := mapResolver{ + domain.TrackerProviderGitHub: gh, + domain.TrackerProviderLinear: ln, + domain.TrackerProviderJira: jr, + } + spawner := &fakeSpawner{} + + if err := New(resolver, store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 3 { + t.Fatalf("spawn calls = %d, want 3", len(spawner.calls)) + } + if got := gh.repos[0]; got.Native != "acme/gh" { + t.Fatalf("github repo = %+v", got) + } + if got := ln.repos[0]; got.Native != "ENG" { + t.Fatalf("linear repo = %+v", got) + } + if got := jr.repos[0]; got.Native != "ENG" || got.BaseURL != "https://acme.atlassian.net" { + t.Fatalf("jira repo = %+v", got) + } + want := map[string]bool{"github:acme/gh#1": false, "linear:ENG-1": false, "jira:ENG-1": false} + for _, call := range spawner.calls { + want[string(call.IssueID)] = true + } + for id, got := range want { + if !got { + t.Fatalf("spawn missing issue %q (calls=%+v)", id, spawner.calls) + } + } +} + +type mapResolver map[domain.TrackerProvider]ports.Tracker + +func (m mapResolver) Tracker(p domain.TrackerProvider) (ports.Tracker, error) { + if t, ok := m[p]; ok { + return t, nil + } + return nil, errors.New("no tracker for provider " + string(p)) +} + +func TestTrackerRepoUsesConfiguredRepo(t *testing.T) { + project := domain.ProjectRecord{ + ID: "demo", + RepoOriginURL: "https://github.com/wrong/repo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ + Enabled: true, + Repo: "acme/demo", + Labels: []string{"agent-ready"}, + }}, + } + repo, ok := trackerRepo(project, project.Config.TrackerIntake.WithDefaults()) + if !ok { + t.Fatal("trackerRepo ok = false") + } + if repo.Native != "acme/demo" { + t.Fatalf("repo.Native = %q, want acme/demo", repo.Native) + } +} + +type fakeStore struct { + projects []domain.ProjectRecord + sessions []domain.SessionRecord + sessionsErr error +} + +func (f *fakeStore) ListProjects(context.Context) ([]domain.ProjectRecord, error) { + return append([]domain.ProjectRecord(nil), f.projects...), nil +} + +func (f *fakeStore) ListAllSessions(context.Context) ([]domain.SessionRecord, error) { + return append([]domain.SessionRecord(nil), f.sessions...), f.sessionsErr +} + +type fakeTracker struct { + issues []domain.Issue + issuesByRepo map[string][]domain.Issue + failRepos map[string]error + repos []domain.TrackerRepo + filters []domain.ListFilter +} + +func (f *fakeTracker) Get(context.Context, domain.TrackerID) (domain.Issue, error) { + return domain.Issue{}, 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) + if err := f.failRepos[repo.Native]; err != nil { + return nil, err + } + if f.issuesByRepo != nil { + return append([]domain.Issue(nil), f.issuesByRepo[repo.Native]...), nil + } + return append([]domain.Issue(nil), f.issues...), nil +} + +func (f *fakeTracker) Preflight(context.Context) error { return nil } + +type fakeSpawner struct { + calls []ports.SpawnConfig + failIssue domain.IssueID +} + +func (f *fakeSpawner) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + f.calls = append(f.calls, cfg) + if cfg.IssueID == f.failIssue { + return domain.Session{}, errors.New("spawn failed") + } + return domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind}}, nil +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(testDiscard{}, nil)) +} + +type testDiscard struct{} + +func (testDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 3ea986817b..853f0de0c5 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, Repo: "acme/cfg", Labels: []string{"agent-ready"}, Assignee: "alice", Limit: 10}, } if err := s.UpsertProject(ctx, domain.ProjectRecord{ ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: cfg, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8a3b89818b..7837f49174 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -679,6 +679,7 @@ export interface components { reviewers?: components["schemas"]["DomainReviewerConfig"][]; sessionPrefix?: string; symlinks?: string[]; + trackerIntake?: components["schemas"]["TrackerIntakeConfig"]; worker?: components["schemas"]["RoleOverride"]; }; ProjectGetResponse: { @@ -910,6 +911,18 @@ export interface components { /** @description Review verdict: approved or changes_requested. */ verdict: string; }; + TrackerIntakeConfig: { + assignee?: string; + baseURL?: string; + enabled?: boolean; + labels?: string[]; + limit?: number; + projectKey?: string; + /** @enum {string} */ + provider?: "github" | "linear" | "jira"; + repo?: string; + team?: string; + }; TriggerReviewResponse: { reviewerHandleId: string; reviews: components["schemas"]["PRReviewState"][];