370 lines
11 KiB
Go
370 lines
11 KiB
Go
// 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 tracker adapter for a project's configured
|
|
// provider.
|
|
type TrackerResolver interface {
|
|
Resolve(provider domain.TrackerProvider) (ports.Tracker, error)
|
|
}
|
|
|
|
// SingleTrackerResolver returns the same tracker for one specific provider and
|
|
// refuses every other provider. It exists so single-provider deployments don't
|
|
// need to construct a map.
|
|
type SingleTrackerResolver struct {
|
|
Provider domain.TrackerProvider
|
|
Adapter ports.Tracker
|
|
}
|
|
|
|
// Resolve returns the wrapped adapter when the requested provider matches, or
|
|
// when the resolver was constructed without a provider pin.
|
|
func (s SingleTrackerResolver) Resolve(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.Resolve(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,
|
|
Assignee: cfg.Assignee,
|
|
})
|
|
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: domain.ProjectID(project.ID),
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if provider != domain.TrackerProviderGitHub {
|
|
return domain.TrackerRepo{}, false
|
|
}
|
|
native := strings.TrimSpace(cfg.Repo)
|
|
if native == "" {
|
|
native = parseGitHubRepoNative(project.RepoOriginURL)
|
|
}
|
|
if native == "" {
|
|
return domain.TrackerRepo{}, false
|
|
}
|
|
return domain.TrackerRepo{Provider: provider, Native: native}, true
|
|
}
|
|
|
|
func parseGitHubRepoNative(remote string) string {
|
|
remote = strings.TrimSpace(remote)
|
|
if remote == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(remote, "git@") {
|
|
if _, rest, ok := strings.Cut(remote, ":"); ok {
|
|
return cleanRepoPath(rest)
|
|
}
|
|
}
|
|
if u, err := url.Parse(remote); err == nil && u.Host != "" {
|
|
host := strings.TrimPrefix(strings.ToLower(u.Host), "www.")
|
|
if host == "github.com" || strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io") {
|
|
return cleanRepoPath(u.Path)
|
|
}
|
|
return ""
|
|
}
|
|
return cleanRepoPath(remote)
|
|
}
|
|
|
|
func cleanRepoPath(path string) string {
|
|
path = strings.Trim(strings.TrimSpace(path), "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
owner := strings.TrimSpace(parts[len(parts)-2])
|
|
repo := strings.TrimSpace(parts[len(parts)-1])
|
|
if owner == "" || repo == "" {
|
|
return ""
|
|
}
|
|
return owner + "/" + repo
|
|
}
|