agent-orchestrator/backend/internal/observe/trackerintake/observer_test.go

346 lines
13 KiB
Go

package trackerintake
import (
"context"
"errors"
"io"
"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) {
store := &fakeStore{
projects: []domain.ProjectRecord{{
ID: "demo",
RepoOriginURL: "https://github.com/acme/demo.git",
Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{
Enabled: true,
Assignee: "alice",
}},
}},
}
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(context.Background()); 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.Assignee != "alice" || len(got.Labels) != 0 {
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, Assignee: "alice"}},
}},
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,
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) != 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, Assignee: "alice"}}},
},
}
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, Assignee: "alice"}}},
{ID: "good", RepoOriginURL: "https://github.com/acme/good.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}},
}}
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, Assignees: []string{"alice"}},
{ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/good#2"}, Title: "second", State: domain.IssueOpen, Assignees: []string{"alice"}},
},
},
}
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, Assignee: "alice"}},
}}}
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, Assignee: "alice"}},
}}}
tracker := &fakeTracker{issues: []domain.Issue{
{ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "already active", State: domain.IssueInProgress, Assignees: []string{"alice"}},
{ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "ready", State: domain.IssueOpen, 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#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, Assignee: "alice"}},
}}}
tracker := &fakeTracker{issues: []domain.Issue{
{ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "unassigned", State: domain.IssueOpen},
{ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "wrong assignee", State: domain.IssueOpen, 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 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",
Assignee: "alice",
}},
}
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)
}
}
func singleResolver(tracker ports.Tracker) TrackerResolver {
return SingleTrackerResolver{Provider: domain.TrackerProviderGitHub, Adapter: tracker}
}
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(io.Discard, nil))
}