From c95aefed94b8ef38f2a86a97ce1a0a71f4114555 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 17:13:04 +0000 Subject: [PATCH] refactor: extract agentci + jobrunner to core/go-agent Both packages now live in forge.lthn.ai/core/go-agent as a unified agent orchestration repo. go-scm retains collect/, forge/, git/, gitea/. Co-Authored-By: Virgil --- CLAUDE.md | 19 +- agentci/clotho.go | 87 -- agentci/clotho_test.go | 194 ----- agentci/config.go | 144 --- agentci/config_test.go | 329 ------- agentci/security.go | 57 -- agentci/security_test.go | 116 --- go.sum | 3 +- jobrunner/coverage_boost_test.go | 389 --------- jobrunner/forgejo/signals.go | 114 --- jobrunner/forgejo/signals_test.go | 205 ----- jobrunner/forgejo/source.go | 173 ---- jobrunner/forgejo/source_extra_test.go | 320 ------- jobrunner/forgejo/source_phase3_test.go | 672 -------------- .../forgejo/source_supplementary_test.go | 409 --------- jobrunner/forgejo/source_test.go | 177 ---- jobrunner/handlers/completion.go | 87 -- jobrunner/handlers/completion_test.go | 291 ------- jobrunner/handlers/coverage_boost_test.go | 704 --------------- jobrunner/handlers/dispatch.go | 293 ------- jobrunner/handlers/dispatch_test.go | 327 ------- jobrunner/handlers/enable_auto_merge.go | 58 -- jobrunner/handlers/enable_auto_merge_test.go | 105 --- jobrunner/handlers/handlers_extra_test.go | 583 ------------- jobrunner/handlers/integration_test.go | 824 ------------------ jobrunner/handlers/publish_draft.go | 55 -- jobrunner/handlers/publish_draft_test.go | 84 -- jobrunner/handlers/resolve_threads.go | 79 -- jobrunner/handlers/resolve_threads_test.go | 91 -- jobrunner/handlers/send_fix_command.go | 74 -- jobrunner/handlers/send_fix_command_test.go | 87 -- jobrunner/handlers/testhelper_test.go | 35 - jobrunner/handlers/tick_parent.go | 100 --- jobrunner/handlers/tick_parent_test.go | 98 --- jobrunner/journal.go | 170 ---- jobrunner/journal_replay_test.go | 540 ------------ jobrunner/journal_test.go | 263 ------ jobrunner/poller.go | 195 ----- jobrunner/poller_test.go | 307 ------- jobrunner/types.go | 72 -- jobrunner/types_test.go | 98 --- 41 files changed, 6 insertions(+), 9022 deletions(-) delete mode 100644 agentci/clotho.go delete mode 100644 agentci/clotho_test.go delete mode 100644 agentci/config.go delete mode 100644 agentci/config_test.go delete mode 100644 agentci/security.go delete mode 100644 agentci/security_test.go delete mode 100644 jobrunner/coverage_boost_test.go delete mode 100644 jobrunner/forgejo/signals.go delete mode 100644 jobrunner/forgejo/signals_test.go delete mode 100644 jobrunner/forgejo/source.go delete mode 100644 jobrunner/forgejo/source_extra_test.go delete mode 100644 jobrunner/forgejo/source_phase3_test.go delete mode 100644 jobrunner/forgejo/source_supplementary_test.go delete mode 100644 jobrunner/forgejo/source_test.go delete mode 100644 jobrunner/handlers/completion.go delete mode 100644 jobrunner/handlers/completion_test.go delete mode 100644 jobrunner/handlers/coverage_boost_test.go delete mode 100644 jobrunner/handlers/dispatch.go delete mode 100644 jobrunner/handlers/dispatch_test.go delete mode 100644 jobrunner/handlers/enable_auto_merge.go delete mode 100644 jobrunner/handlers/enable_auto_merge_test.go delete mode 100644 jobrunner/handlers/handlers_extra_test.go delete mode 100644 jobrunner/handlers/integration_test.go delete mode 100644 jobrunner/handlers/publish_draft.go delete mode 100644 jobrunner/handlers/publish_draft_test.go delete mode 100644 jobrunner/handlers/resolve_threads.go delete mode 100644 jobrunner/handlers/resolve_threads_test.go delete mode 100644 jobrunner/handlers/send_fix_command.go delete mode 100644 jobrunner/handlers/send_fix_command_test.go delete mode 100644 jobrunner/handlers/testhelper_test.go delete mode 100644 jobrunner/handlers/tick_parent.go delete mode 100644 jobrunner/handlers/tick_parent_test.go delete mode 100644 jobrunner/journal.go delete mode 100644 jobrunner/journal_replay_test.go delete mode 100644 jobrunner/journal_test.go delete mode 100644 jobrunner/poller.go delete mode 100644 jobrunner/poller_test.go delete mode 100644 jobrunner/types.go delete mode 100644 jobrunner/types_test.go diff --git a/CLAUDE.md b/CLAUDE.md index cd0a79b..5680fab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ You are a dedicated domain expert for `forge.lthn.ai/core/go-scm`. Virgil orches ## What This Package Does -SCM integration, AgentCI dispatch, and data collection for the Lethean ecosystem. See `docs/architecture.md` for the full package breakdown. +SCM integration and data collection for the Lethean ecosystem. Four packages: `forge/` (Forgejo API client), `git/` (multi-repo git ops), `gitea/` (Gitea API client), `collect/` (data collection). + +**Extracted to other repos:** `agentci/` + `jobrunner/` → `core/go-agent`, `git/` → `core/go-git`. ## Commands @@ -35,26 +37,13 @@ type RepoStatus struct { Modified, Untracked, Staged, Ahead, Behind int Error error } - -// jobrunner/types.go -type PipelineSignal struct { - EpicNumber, ChildNumber, PRNumber int - RepoOwner, RepoName string - PRState, Mergeable, CheckStatus string - NeedsCoding bool - IssueTitle, IssueBody string -} - -// agentci/clotho.go -type RunMode string // "standard" or "dual" -type Spinner struct { Config ClothoConfig; Agents map[string]AgentConfig } ``` ## Coding Standards - **UK English**: colour, organisation, centre - **Tests**: testify assert/require, table-driven preferred, `_Good`/`_Bad`/`_Ugly` naming -- **Conventional commits**: `feat(forge):`, `fix(jobrunner):`, `test(collect):` +- **Conventional commits**: `feat(forge):`, `fix(git):`, `test(collect):` - **Co-Author**: `Co-Authored-By: Virgil ` - **Licence**: EUPL-1.2 - **Imports**: stdlib → forge.lthn.ai → third-party, each group separated by blank line diff --git a/agentci/clotho.go b/agentci/clotho.go deleted file mode 100644 index 2bec8ee..0000000 --- a/agentci/clotho.go +++ /dev/null @@ -1,87 +0,0 @@ -package agentci - -import ( - "context" - "strings" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// RunMode determines the execution strategy for a dispatched task. -type RunMode string - -const ( - ModeStandard RunMode = "standard" - ModeDual RunMode = "dual" // The Clotho Protocol — dual-run verification -) - -// Spinner is the Clotho orchestrator that determines the fate of each task. -type Spinner struct { - Config ClothoConfig - Agents map[string]AgentConfig -} - -// NewSpinner creates a new Clotho orchestrator. -func NewSpinner(cfg ClothoConfig, agents map[string]AgentConfig) *Spinner { - return &Spinner{ - Config: cfg, - Agents: agents, - } -} - -// DeterminePlan decides if a signal requires dual-run verification based on -// the global strategy, agent configuration, and repository criticality. -func (s *Spinner) DeterminePlan(signal *jobrunner.PipelineSignal, agentName string) RunMode { - if s.Config.Strategy != "clotho-verified" { - return ModeStandard - } - - agent, ok := s.Agents[agentName] - if !ok { - return ModeStandard - } - if agent.DualRun { - return ModeDual - } - - // Protect critical repos with dual-run (Axiom 1). - if signal.RepoName == "core" || strings.Contains(signal.RepoName, "security") { - return ModeDual - } - - return ModeStandard -} - -// GetVerifierModel returns the model for the secondary "signed" verification run. -func (s *Spinner) GetVerifierModel(agentName string) string { - agent, ok := s.Agents[agentName] - if !ok || agent.VerifyModel == "" { - return "gemini-1.5-pro" - } - return agent.VerifyModel -} - -// FindByForgejoUser resolves a Forgejo username to the agent config key and config. -// This decouples agent naming (mythological roles) from Forgejo identity. -func (s *Spinner) FindByForgejoUser(forgejoUser string) (string, AgentConfig, bool) { - if forgejoUser == "" { - return "", AgentConfig{}, false - } - // Direct match on config key first. - if agent, ok := s.Agents[forgejoUser]; ok { - return forgejoUser, agent, true - } - // Search by ForgejoUser field. - for name, agent := range s.Agents { - if agent.ForgejoUser != "" && agent.ForgejoUser == forgejoUser { - return name, agent, true - } - } - return "", AgentConfig{}, false -} - -// Weave compares primary and verifier outputs. Returns true if they converge. -// This is a placeholder for future semantic diff logic. -func (s *Spinner) Weave(ctx context.Context, primaryOutput, signedOutput []byte) (bool, error) { - return string(primaryOutput) == string(signedOutput), nil -} diff --git a/agentci/clotho_test.go b/agentci/clotho_test.go deleted file mode 100644 index 6ee0fac..0000000 --- a/agentci/clotho_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package agentci - -import ( - "context" - "testing" - - "forge.lthn.ai/core/go-scm/jobrunner" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestSpinner() *Spinner { - return NewSpinner( - ClothoConfig{ - Strategy: "clotho-verified", - ValidationThreshold: 0.85, - }, - map[string]AgentConfig{ - "claude-agent": { - Host: "claude@10.0.0.1", - Model: "opus", - Runner: "claude", - Active: true, - DualRun: false, - ForgejoUser: "claude-forge", - }, - "gemini-agent": { - Host: "localhost", - Model: "gemini-2.0-flash", - VerifyModel: "gemini-1.5-pro", - Runner: "gemini", - Active: true, - DualRun: true, - ForgejoUser: "gemini-forge", - }, - }, - ) -} - -func TestNewSpinner_Good(t *testing.T) { - spinner := newTestSpinner() - assert.NotNil(t, spinner) - assert.Equal(t, "clotho-verified", spinner.Config.Strategy) - assert.Len(t, spinner.Agents, 2) -} - -func TestDeterminePlan_Good_Standard(t *testing.T) { - spinner := newTestSpinner() - - signal := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-php", - } - - mode := spinner.DeterminePlan(signal, "claude-agent") - assert.Equal(t, ModeStandard, mode) -} - -func TestDeterminePlan_Good_DualRunByAgent(t *testing.T) { - spinner := newTestSpinner() - - signal := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "some-repo", - } - - mode := spinner.DeterminePlan(signal, "gemini-agent") - assert.Equal(t, ModeDual, mode) -} - -func TestDeterminePlan_Good_DualRunByCriticalRepo(t *testing.T) { - spinner := newTestSpinner() - - tests := []struct { - name string - repoName string - expected RunMode - }{ - {name: "core repo", repoName: "core", expected: ModeDual}, - {name: "security repo", repoName: "auth-security", expected: ModeDual}, - {name: "security-audit", repoName: "security-audit", expected: ModeDual}, - {name: "regular repo", repoName: "docs", expected: ModeStandard}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - signal := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: tt.repoName, - } - mode := spinner.DeterminePlan(signal, "claude-agent") - assert.Equal(t, tt.expected, mode) - }) - } -} - -func TestDeterminePlan_Good_NonVerifiedStrategy(t *testing.T) { - spinner := NewSpinner( - ClothoConfig{Strategy: "direct"}, - map[string]AgentConfig{ - "agent": {Host: "localhost", DualRun: true, Active: true}, - }, - ) - - signal := &jobrunner.PipelineSignal{RepoName: "core"} - mode := spinner.DeterminePlan(signal, "agent") - assert.Equal(t, ModeStandard, mode, "non-verified strategy should always return standard") -} - -func TestDeterminePlan_Good_UnknownAgent(t *testing.T) { - spinner := newTestSpinner() - - signal := &jobrunner.PipelineSignal{RepoName: "some-repo"} - mode := spinner.DeterminePlan(signal, "nonexistent-agent") - assert.Equal(t, ModeStandard, mode, "unknown agent should return standard") -} - -func TestGetVerifierModel_Good(t *testing.T) { - spinner := newTestSpinner() - - model := spinner.GetVerifierModel("gemini-agent") - assert.Equal(t, "gemini-1.5-pro", model) -} - -func TestGetVerifierModel_Good_Default(t *testing.T) { - spinner := newTestSpinner() - - // claude-agent has no VerifyModel set. - model := spinner.GetVerifierModel("claude-agent") - assert.Equal(t, "gemini-1.5-pro", model, "should fall back to default") -} - -func TestGetVerifierModel_Good_UnknownAgent(t *testing.T) { - spinner := newTestSpinner() - - model := spinner.GetVerifierModel("unknown") - assert.Equal(t, "gemini-1.5-pro", model, "should fall back to default") -} - -func TestFindByForgejoUser_Good_DirectMatch(t *testing.T) { - spinner := newTestSpinner() - - // Direct match on config key. - name, agent, found := spinner.FindByForgejoUser("claude-agent") - assert.True(t, found) - assert.Equal(t, "claude-agent", name) - assert.Equal(t, "opus", agent.Model) -} - -func TestFindByForgejoUser_Good_ByField(t *testing.T) { - spinner := newTestSpinner() - - // Match by ForgejoUser field. - name, agent, found := spinner.FindByForgejoUser("claude-forge") - assert.True(t, found) - assert.Equal(t, "claude-agent", name) - assert.Equal(t, "opus", agent.Model) -} - -func TestFindByForgejoUser_Bad_NotFound(t *testing.T) { - spinner := newTestSpinner() - - _, _, found := spinner.FindByForgejoUser("nonexistent") - assert.False(t, found) -} - -func TestFindByForgejoUser_Bad_Empty(t *testing.T) { - spinner := newTestSpinner() - - _, _, found := spinner.FindByForgejoUser("") - assert.False(t, found) -} - -func TestWeave_Good_Matching(t *testing.T) { - spinner := newTestSpinner() - - converge, err := spinner.Weave(context.Background(), []byte("output"), []byte("output")) - require.NoError(t, err) - assert.True(t, converge) -} - -func TestWeave_Good_Diverging(t *testing.T) { - spinner := newTestSpinner() - - converge, err := spinner.Weave(context.Background(), []byte("primary"), []byte("different")) - require.NoError(t, err) - assert.False(t, converge) -} - -func TestRunModeConstants(t *testing.T) { - assert.Equal(t, RunMode("standard"), ModeStandard) - assert.Equal(t, RunMode("dual"), ModeDual) -} diff --git a/agentci/config.go b/agentci/config.go deleted file mode 100644 index 333faa3..0000000 --- a/agentci/config.go +++ /dev/null @@ -1,144 +0,0 @@ -// Package agentci provides configuration, security, and orchestration for AgentCI dispatch targets. -package agentci - -import ( - "fmt" - - "forge.lthn.ai/core/go/pkg/config" -) - -// AgentConfig represents a single agent machine in the config file. -type AgentConfig struct { - Host string `yaml:"host" mapstructure:"host"` - QueueDir string `yaml:"queue_dir" mapstructure:"queue_dir"` - ForgejoUser string `yaml:"forgejo_user" mapstructure:"forgejo_user"` - Model string `yaml:"model" mapstructure:"model"` // primary AI model - Runner string `yaml:"runner" mapstructure:"runner"` // runner binary: claude, codex, gemini - VerifyModel string `yaml:"verify_model" mapstructure:"verify_model"` // secondary model for dual-run - SecurityLevel string `yaml:"security_level" mapstructure:"security_level"` // low, high - Roles []string `yaml:"roles" mapstructure:"roles"` - DualRun bool `yaml:"dual_run" mapstructure:"dual_run"` - Active bool `yaml:"active" mapstructure:"active"` -} - -// ClothoConfig controls the orchestration strategy. -type ClothoConfig struct { - Strategy string `yaml:"strategy" mapstructure:"strategy"` // direct, clotho-verified - ValidationThreshold float64 `yaml:"validation_threshold" mapstructure:"validation_threshold"` // divergence limit (0.0-1.0) - SigningKeyPath string `yaml:"signing_key_path" mapstructure:"signing_key_path"` -} - -// LoadAgents reads agent targets from config and returns a map of AgentConfig. -// Returns an empty map (not an error) if no agents are configured. -func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) { - var agents map[string]AgentConfig - if err := cfg.Get("agentci.agents", &agents); err != nil { - return map[string]AgentConfig{}, nil - } - - // Validate and apply defaults. - for name, ac := range agents { - if !ac.Active { - continue - } - if ac.Host == "" { - return nil, fmt.Errorf("agentci.LoadAgents: agent %q: host is required", name) - } - if ac.QueueDir == "" { - ac.QueueDir = "/home/claude/ai-work/queue" - } - if ac.Model == "" { - ac.Model = "sonnet" - } - if ac.Runner == "" { - ac.Runner = "claude" - } - agents[name] = ac - } - - return agents, nil -} - -// LoadActiveAgents returns only active agents. -func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) { - all, err := LoadAgents(cfg) - if err != nil { - return nil, err - } - active := make(map[string]AgentConfig) - for name, ac := range all { - if ac.Active { - active[name] = ac - } - } - return active, nil -} - -// LoadClothoConfig loads the Clotho orchestrator settings. -// Returns sensible defaults if no config is present. -func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) { - var cc ClothoConfig - if err := cfg.Get("agentci.clotho", &cc); err != nil { - return ClothoConfig{ - Strategy: "direct", - ValidationThreshold: 0.85, - }, nil - } - if cc.Strategy == "" { - cc.Strategy = "direct" - } - if cc.ValidationThreshold == 0 { - cc.ValidationThreshold = 0.85 - } - return cc, nil -} - -// SaveAgent writes an agent config entry to the config file. -func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error { - key := fmt.Sprintf("agentci.agents.%s", name) - data := map[string]any{ - "host": ac.Host, - "queue_dir": ac.QueueDir, - "forgejo_user": ac.ForgejoUser, - "active": ac.Active, - "dual_run": ac.DualRun, - } - if ac.Model != "" { - data["model"] = ac.Model - } - if ac.Runner != "" { - data["runner"] = ac.Runner - } - if ac.VerifyModel != "" { - data["verify_model"] = ac.VerifyModel - } - if ac.SecurityLevel != "" { - data["security_level"] = ac.SecurityLevel - } - if len(ac.Roles) > 0 { - data["roles"] = ac.Roles - } - return cfg.Set(key, data) -} - -// RemoveAgent removes an agent from the config file. -func RemoveAgent(cfg *config.Config, name string) error { - var agents map[string]AgentConfig - if err := cfg.Get("agentci.agents", &agents); err != nil { - return fmt.Errorf("agentci.RemoveAgent: no agents configured") - } - if _, ok := agents[name]; !ok { - return fmt.Errorf("agentci.RemoveAgent: agent %q not found", name) - } - delete(agents, name) - return cfg.Set("agentci.agents", agents) -} - -// ListAgents returns all configured agents (active and inactive). -func ListAgents(cfg *config.Config) (map[string]AgentConfig, error) { - var agents map[string]AgentConfig - if err := cfg.Get("agentci.agents", &agents); err != nil { - return map[string]AgentConfig{}, nil - } - return agents, nil -} diff --git a/agentci/config_test.go b/agentci/config_test.go deleted file mode 100644 index 7ee40ca..0000000 --- a/agentci/config_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package agentci - -import ( - "testing" - - "forge.lthn.ai/core/go/pkg/config" - "forge.lthn.ai/core/go/pkg/io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestConfig(t *testing.T, yaml string) *config.Config { - t.Helper() - m := io.NewMockMedium() - if yaml != "" { - m.Files["/tmp/test/config.yaml"] = yaml - } - cfg, err := config.New(config.WithMedium(m), config.WithPath("/tmp/test/config.yaml")) - require.NoError(t, err) - return cfg -} - -func TestLoadAgents_Good(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - darbs-claude: - host: claude@192.168.0.201 - queue_dir: /home/claude/ai-work/queue - forgejo_user: darbs-claude - model: sonnet - runner: claude - active: true -`) - agents, err := LoadAgents(cfg) - require.NoError(t, err) - require.Len(t, agents, 1) - - agent := agents["darbs-claude"] - assert.Equal(t, "claude@192.168.0.201", agent.Host) - assert.Equal(t, "/home/claude/ai-work/queue", agent.QueueDir) - assert.Equal(t, "sonnet", agent.Model) - assert.Equal(t, "claude", agent.Runner) -} - -func TestLoadAgents_Good_MultipleAgents(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - darbs-claude: - host: claude@192.168.0.201 - queue_dir: /home/claude/ai-work/queue - active: true - local-codex: - host: localhost - queue_dir: /home/claude/ai-work/queue - runner: codex - active: true -`) - agents, err := LoadAgents(cfg) - require.NoError(t, err) - assert.Len(t, agents, 2) - assert.Contains(t, agents, "darbs-claude") - assert.Contains(t, agents, "local-codex") -} - -func TestLoadAgents_Good_SkipsInactive(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - active-agent: - host: claude@10.0.0.1 - active: true - offline-agent: - host: claude@10.0.0.2 - active: false -`) - agents, err := LoadAgents(cfg) - require.NoError(t, err) - // Both are returned, but only active-agent has defaults applied. - assert.Len(t, agents, 2) - assert.Contains(t, agents, "active-agent") -} - -func TestLoadActiveAgents_Good(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - active-agent: - host: claude@10.0.0.1 - active: true - offline-agent: - host: claude@10.0.0.2 - active: false -`) - active, err := LoadActiveAgents(cfg) - require.NoError(t, err) - assert.Len(t, active, 1) - assert.Contains(t, active, "active-agent") -} - -func TestLoadAgents_Good_Defaults(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - minimal: - host: claude@10.0.0.1 - active: true -`) - agents, err := LoadAgents(cfg) - require.NoError(t, err) - require.Len(t, agents, 1) - - agent := agents["minimal"] - assert.Equal(t, "/home/claude/ai-work/queue", agent.QueueDir) - assert.Equal(t, "sonnet", agent.Model) - assert.Equal(t, "claude", agent.Runner) -} - -func TestLoadAgents_Good_NoConfig(t *testing.T) { - cfg := newTestConfig(t, "") - agents, err := LoadAgents(cfg) - require.NoError(t, err) - assert.Empty(t, agents) -} - -func TestLoadAgents_Bad_MissingHost(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - broken: - queue_dir: /tmp - active: true -`) - _, err := LoadAgents(cfg) - assert.Error(t, err) - assert.Contains(t, err.Error(), "host is required") -} - -func TestLoadAgents_Good_WithDualRun(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - gemini-agent: - host: localhost - runner: gemini - model: gemini-2.0-flash - verify_model: gemini-1.5-pro - dual_run: true - active: true -`) - agents, err := LoadAgents(cfg) - require.NoError(t, err) - - agent := agents["gemini-agent"] - assert.Equal(t, "gemini", agent.Runner) - assert.Equal(t, "gemini-2.0-flash", agent.Model) - assert.Equal(t, "gemini-1.5-pro", agent.VerifyModel) - assert.True(t, agent.DualRun) -} - -func TestLoadClothoConfig_Good(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - clotho: - strategy: clotho-verified - validation_threshold: 0.9 - signing_key_path: /etc/core/keys/clotho.pub -`) - cc, err := LoadClothoConfig(cfg) - require.NoError(t, err) - assert.Equal(t, "clotho-verified", cc.Strategy) - assert.Equal(t, 0.9, cc.ValidationThreshold) - assert.Equal(t, "/etc/core/keys/clotho.pub", cc.SigningKeyPath) -} - -func TestLoadClothoConfig_Good_Defaults(t *testing.T) { - cfg := newTestConfig(t, "") - cc, err := LoadClothoConfig(cfg) - require.NoError(t, err) - assert.Equal(t, "direct", cc.Strategy) - assert.Equal(t, 0.85, cc.ValidationThreshold) -} - -func TestSaveAgent_Good(t *testing.T) { - cfg := newTestConfig(t, "") - - err := SaveAgent(cfg, "new-agent", AgentConfig{ - Host: "claude@10.0.0.5", - QueueDir: "/home/claude/ai-work/queue", - ForgejoUser: "new-agent", - Model: "haiku", - Runner: "claude", - Active: true, - }) - require.NoError(t, err) - - agents, err := ListAgents(cfg) - require.NoError(t, err) - require.Contains(t, agents, "new-agent") - assert.Equal(t, "claude@10.0.0.5", agents["new-agent"].Host) - assert.Equal(t, "haiku", agents["new-agent"].Model) -} - -func TestSaveAgent_Good_WithDualRun(t *testing.T) { - cfg := newTestConfig(t, "") - - err := SaveAgent(cfg, "verified-agent", AgentConfig{ - Host: "claude@10.0.0.5", - Model: "gemini-2.0-flash", - VerifyModel: "gemini-1.5-pro", - DualRun: true, - Active: true, - }) - require.NoError(t, err) - - agents, err := ListAgents(cfg) - require.NoError(t, err) - require.Contains(t, agents, "verified-agent") - assert.True(t, agents["verified-agent"].DualRun) -} - -func TestSaveAgent_Good_OmitsEmptyOptionals(t *testing.T) { - cfg := newTestConfig(t, "") - - err := SaveAgent(cfg, "minimal", AgentConfig{ - Host: "claude@10.0.0.1", - Active: true, - }) - require.NoError(t, err) - - agents, err := ListAgents(cfg) - require.NoError(t, err) - assert.Contains(t, agents, "minimal") -} - -func TestRemoveAgent_Good(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - to-remove: - host: claude@10.0.0.1 - active: true - to-keep: - host: claude@10.0.0.2 - active: true -`) - err := RemoveAgent(cfg, "to-remove") - require.NoError(t, err) - - agents, err := ListAgents(cfg) - require.NoError(t, err) - assert.NotContains(t, agents, "to-remove") - assert.Contains(t, agents, "to-keep") -} - -func TestRemoveAgent_Bad_NotFound(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - existing: - host: claude@10.0.0.1 - active: true -`) - err := RemoveAgent(cfg, "nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -func TestRemoveAgent_Bad_NoAgents(t *testing.T) { - cfg := newTestConfig(t, "") - err := RemoveAgent(cfg, "anything") - assert.Error(t, err) - assert.Contains(t, err.Error(), "no agents configured") -} - -func TestListAgents_Good(t *testing.T) { - cfg := newTestConfig(t, ` -agentci: - agents: - agent-a: - host: claude@10.0.0.1 - active: true - agent-b: - host: claude@10.0.0.2 - active: false -`) - agents, err := ListAgents(cfg) - require.NoError(t, err) - assert.Len(t, agents, 2) - assert.True(t, agents["agent-a"].Active) - assert.False(t, agents["agent-b"].Active) -} - -func TestListAgents_Good_Empty(t *testing.T) { - cfg := newTestConfig(t, "") - agents, err := ListAgents(cfg) - require.NoError(t, err) - assert.Empty(t, agents) -} - -func TestRoundTrip_SaveThenLoad(t *testing.T) { - cfg := newTestConfig(t, "") - - err := SaveAgent(cfg, "alpha", AgentConfig{ - Host: "claude@alpha", - QueueDir: "/home/claude/work/queue", - ForgejoUser: "alpha-bot", - Model: "opus", - Runner: "claude", - Active: true, - }) - require.NoError(t, err) - - err = SaveAgent(cfg, "beta", AgentConfig{ - Host: "claude@beta", - ForgejoUser: "beta-bot", - Runner: "codex", - Active: true, - }) - require.NoError(t, err) - - agents, err := LoadActiveAgents(cfg) - require.NoError(t, err) - assert.Len(t, agents, 2) - assert.Equal(t, "claude@alpha", agents["alpha"].Host) - assert.Equal(t, "opus", agents["alpha"].Model) - assert.Equal(t, "codex", agents["beta"].Runner) -} diff --git a/agentci/security.go b/agentci/security.go deleted file mode 100644 index 52b433f..0000000 --- a/agentci/security.go +++ /dev/null @@ -1,57 +0,0 @@ -package agentci - -import ( - "context" - "fmt" - "os/exec" - "path/filepath" - "regexp" - "strings" -) - -var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`) - -// SanitizePath ensures a filename or directory name is safe and prevents path traversal. -// Returns filepath.Base of the input after validation. -func SanitizePath(input string) (string, error) { - base := filepath.Base(input) - if !safeNameRegex.MatchString(base) { - return "", fmt.Errorf("agentci.SanitizePath: invalid characters in path element: %s", input) - } - if base == "." || base == ".." || base == "/" { - return "", fmt.Errorf("agentci.SanitizePath: invalid path element: %s", base) - } - return base, nil -} - -// EscapeShellArg wraps a string in single quotes for safe remote shell insertion. -// Prefer exec.Command arguments over constructing shell strings where possible. -func EscapeShellArg(arg string) string { - return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" -} - -// SecureSSHCommand creates an SSH exec.Cmd with strict host key checking and batch mode. -// Deprecated: Use SecureSSHCommandContext for context-aware cancellation. -func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd { - return SecureSSHCommandContext(context.Background(), host, remoteCmd) -} - -// SecureSSHCommandContext creates an SSH exec.Cmd with context support for cancellation, -// strict host key checking, and batch mode. -func SecureSSHCommandContext(ctx context.Context, host string, remoteCmd string) *exec.Cmd { - return exec.CommandContext(ctx, "ssh", - "-o", "StrictHostKeyChecking=yes", - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=10", - host, - remoteCmd, - ) -} - -// MaskToken returns a masked version of a token for safe logging. -func MaskToken(token string) string { - if len(token) < 8 { - return "*****" - } - return token[:4] + "****" + token[len(token)-4:] -} diff --git a/agentci/security_test.go b/agentci/security_test.go deleted file mode 100644 index 1e18ff1..0000000 --- a/agentci/security_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package agentci - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSanitizePath_Good(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - {name: "simple name", input: "myfile.txt", expected: "myfile.txt"}, - {name: "with hyphen", input: "my-file", expected: "my-file"}, - {name: "with underscore", input: "my_file", expected: "my_file"}, - {name: "with dots", input: "file.tar.gz", expected: "file.tar.gz"}, - {name: "strips directory", input: "/path/to/file.txt", expected: "file.txt"}, - {name: "alphanumeric", input: "abc123", expected: "abc123"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := SanitizePath(tt.input) - require.NoError(t, err) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestSanitizePath_Good_StripsDirTraversal(t *testing.T) { - // filepath.Base("../secret") returns "secret" which is safe. - result, err := SanitizePath("../secret") - require.NoError(t, err) - assert.Equal(t, "secret", result, "directory traversal component stripped by filepath.Base") -} - -func TestSanitizePath_Bad(t *testing.T) { - tests := []struct { - name string - input string - }{ - {name: "spaces", input: "my file"}, - {name: "special chars", input: "file;rm -rf"}, - {name: "pipe", input: "file|cmd"}, - {name: "backtick", input: "file`cmd`"}, - {name: "dollar", input: "file$var"}, - {name: "single dot", input: "."}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := SanitizePath(tt.input) - assert.Error(t, err) - }) - } -} - -func TestEscapeShellArg_Good(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - {name: "simple string", input: "hello", expected: "'hello'"}, - {name: "with spaces", input: "hello world", expected: "'hello world'"}, - {name: "empty string", input: "", expected: "''"}, - {name: "with single quote", input: "it's", expected: "'it'\\''s'"}, - {name: "multiple single quotes", input: "a'b'c", expected: "'a'\\''b'\\''c'"}, - {name: "with special chars", input: "$(rm -rf /)", expected: "'$(rm -rf /)'"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := EscapeShellArg(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestSecureSSHCommand_Good(t *testing.T) { - cmd := SecureSSHCommand("claude@10.0.0.1", "ls -la /tmp") - - assert.Equal(t, "ssh", cmd.Path[len(cmd.Path)-3:]) - args := cmd.Args - assert.Contains(t, args, "-o") - assert.Contains(t, args, "StrictHostKeyChecking=yes") - assert.Contains(t, args, "BatchMode=yes") - assert.Contains(t, args, "ConnectTimeout=10") - assert.Contains(t, args, "claude@10.0.0.1") - assert.Contains(t, args, "ls -la /tmp") -} - -func TestMaskToken_Good(t *testing.T) { - tests := []struct { - name string - token string - expected string - }{ - {name: "normal token", token: "abcdefghijkl", expected: "abcd****ijkl"}, - {name: "exactly 8 chars", token: "12345678", expected: "1234****5678"}, - {name: "short token", token: "abc", expected: "*****"}, - {name: "empty token", token: "", expected: "*****"}, - {name: "7 chars", token: "1234567", expected: "*****"}, - {name: "long token", token: "ghp_1234567890abcdef", expected: "ghp_****cdef"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := MaskToken(tt.token) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/go.sum b/go.sum index 5c132a1..f9a1e72 100644 --- a/go.sum +++ b/go.sum @@ -68,7 +68,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jobrunner/coverage_boost_test.go b/jobrunner/coverage_boost_test.go deleted file mode 100644 index a1ea6dd..0000000 --- a/jobrunner/coverage_boost_test.go +++ /dev/null @@ -1,389 +0,0 @@ -package jobrunner - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// --- Journal: NewJournal error path --- - -func TestNewJournal_Bad_EmptyBaseDir(t *testing.T) { - _, err := NewJournal("") - require.Error(t, err) - assert.Contains(t, err.Error(), "base directory is required") -} - -func TestNewJournal_Good(t *testing.T) { - dir := t.TempDir() - j, err := NewJournal(dir) - require.NoError(t, err) - assert.NotNil(t, j) -} - -// --- Journal: sanitizePathComponent additional cases --- - -func TestSanitizePathComponent_Good_ValidNames(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"host-uk", "host-uk"}, - {"core", "core"}, - {"my_repo", "my_repo"}, - {"repo.v2", "repo.v2"}, - {"A123", "A123"}, - } - - for _, tc := range tests { - got, err := sanitizePathComponent(tc.input) - require.NoError(t, err, "input: %q", tc.input) - assert.Equal(t, tc.want, got) - } -} - -func TestSanitizePathComponent_Bad_Invalid(t *testing.T) { - tests := []struct { - name string - input string - }{ - {"empty", ""}, - {"spaces", " "}, - {"dotdot", ".."}, - {"dot", "."}, - {"slash", "foo/bar"}, - {"backslash", `foo\bar`}, - {"special", "org$bad"}, - {"leading-dot", ".hidden"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, err := sanitizePathComponent(tc.input) - assert.Error(t, err, "input: %q", tc.input) - }) - } -} - -// --- Journal: Append with readonly directory --- - -func TestJournal_Append_Bad_ReadonlyDir(t *testing.T) { - // Create a dir that we then make readonly (only works as non-root). - dir := t.TempDir() - readonlyDir := filepath.Join(dir, "readonly") - require.NoError(t, os.MkdirAll(readonlyDir, 0o755)) - require.NoError(t, os.Chmod(readonlyDir, 0o444)) - t.Cleanup(func() { _ = os.Chmod(readonlyDir, 0o755) }) - - j, err := NewJournal(readonlyDir) - require.NoError(t, err) - - signal := &PipelineSignal{ - RepoOwner: "test-owner", - RepoName: "test-repo", - } - result := &ActionResult{ - Action: "test", - Timestamp: time.Now(), - } - - err = j.Append(signal, result) - // Should fail because MkdirAll cannot create subdirectories in readonly dir. - assert.Error(t, err) -} - -// --- Poller: error-returning source --- - -type errorSource struct { - name string -} - -func (e *errorSource) Name() string { return e.name } -func (e *errorSource) Poll(_ context.Context) ([]*PipelineSignal, error) { - return nil, fmt.Errorf("poll error") -} -func (e *errorSource) Report(_ context.Context, _ *ActionResult) error { return nil } - -func TestPoller_RunOnce_Good_SourceError(t *testing.T) { - src := &errorSource{name: "broken-source"} - handler := &mockHandler{name: "test"} - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - }) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) // Poll errors are logged, not returned - - handler.mu.Lock() - defer handler.mu.Unlock() - assert.Empty(t, handler.executed, "handler should not be called when poll fails") -} - -// --- Poller: error-returning handler --- - -type errorHandler struct { - name string -} - -func (e *errorHandler) Name() string { return e.name } -func (e *errorHandler) Match(_ *PipelineSignal) bool { return true } -func (e *errorHandler) Execute(_ context.Context, _ *PipelineSignal) (*ActionResult, error) { - return nil, fmt.Errorf("handler error") -} - -func TestPoller_RunOnce_Good_HandlerError(t *testing.T) { - sig := &PipelineSignal{ - EpicNumber: 1, - ChildNumber: 1, - PRNumber: 1, - RepoOwner: "test", - RepoName: "repo", - } - - src := &mockSource{ - name: "test-source", - signals: []*PipelineSignal{sig}, - } - - handler := &errorHandler{name: "broken-handler"} - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - }) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) // Handler errors are logged, not returned - - // Source should not have received a report (handler errored out). - src.mu.Lock() - defer src.mu.Unlock() - assert.Empty(t, src.reports) -} - -// --- Poller: with Journal integration --- - -func TestPoller_RunOnce_Good_WithJournal(t *testing.T) { - dir := t.TempDir() - journal, err := NewJournal(dir) - require.NoError(t, err) - - sig := &PipelineSignal{ - EpicNumber: 10, - ChildNumber: 3, - PRNumber: 55, - RepoOwner: "host-uk", - RepoName: "core", - PRState: "OPEN", - CheckStatus: "SUCCESS", - Mergeable: "MERGEABLE", - } - - src := &mockSource{ - name: "test-source", - signals: []*PipelineSignal{sig}, - } - - handler := &mockHandler{ - name: "test-handler", - matchFn: func(s *PipelineSignal) bool { - return true - }, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - Journal: journal, - }) - - err = p.RunOnce(context.Background()) - require.NoError(t, err) - - handler.mu.Lock() - require.Len(t, handler.executed, 1) - handler.mu.Unlock() - - // Verify the journal file was written. - date := time.Now().UTC().Format("2006-01-02") - journalPath := filepath.Join(dir, "host-uk", "core", date+".jsonl") - _, statErr := os.Stat(journalPath) - assert.NoError(t, statErr, "journal file should exist at %s", journalPath) -} - -// --- Poller: error-returning Report --- - -type reportErrorSource struct { - name string - signals []*PipelineSignal - mu sync.Mutex -} - -func (r *reportErrorSource) Name() string { return r.name } -func (r *reportErrorSource) Poll(_ context.Context) ([]*PipelineSignal, error) { - r.mu.Lock() - defer r.mu.Unlock() - return r.signals, nil -} -func (r *reportErrorSource) Report(_ context.Context, _ *ActionResult) error { - return fmt.Errorf("report error") -} - -func TestPoller_RunOnce_Good_ReportError(t *testing.T) { - sig := &PipelineSignal{ - EpicNumber: 1, - ChildNumber: 1, - PRNumber: 1, - RepoOwner: "test", - RepoName: "repo", - } - - src := &reportErrorSource{ - name: "report-fail-source", - signals: []*PipelineSignal{sig}, - } - - handler := &mockHandler{ - name: "test-handler", - matchFn: func(s *PipelineSignal) bool { return true }, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - }) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) // Report errors are logged, not returned - - handler.mu.Lock() - defer handler.mu.Unlock() - assert.Len(t, handler.executed, 1, "handler should still execute even though report fails") -} - -// --- Poller: multiple sources and handlers --- - -func TestPoller_RunOnce_Good_MultipleSources(t *testing.T) { - sig1 := &PipelineSignal{ - EpicNumber: 1, ChildNumber: 1, PRNumber: 1, - RepoOwner: "org1", RepoName: "repo1", - } - sig2 := &PipelineSignal{ - EpicNumber: 2, ChildNumber: 2, PRNumber: 2, - RepoOwner: "org2", RepoName: "repo2", - } - - src1 := &mockSource{name: "source-1", signals: []*PipelineSignal{sig1}} - src2 := &mockSource{name: "source-2", signals: []*PipelineSignal{sig2}} - - handler := &mockHandler{ - name: "catch-all", - matchFn: func(s *PipelineSignal) bool { return true }, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src1, src2}, - Handlers: []JobHandler{handler}, - }) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) - - handler.mu.Lock() - defer handler.mu.Unlock() - assert.Len(t, handler.executed, 2) -} - -// --- Poller: Run with immediate cancellation --- - -func TestPoller_Run_Good_ImmediateCancel(t *testing.T) { - src := &mockSource{name: "source", signals: nil} - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - PollInterval: 1 * time.Hour, // long interval - }) - - ctx, cancel := context.WithCancel(context.Background()) - // Cancel after the first RunOnce completes. - go func() { - time.Sleep(50 * time.Millisecond) - cancel() - }() - - err := p.Run(ctx) - assert.ErrorIs(t, err, context.Canceled) - assert.Equal(t, 1, p.Cycle()) // One cycle from the initial RunOnce -} - -// --- Journal: Append with journal error logging --- - -func TestPoller_RunOnce_Good_JournalAppendError(t *testing.T) { - // Use a directory that will cause journal writes to fail. - dir := t.TempDir() - journal, err := NewJournal(dir) - require.NoError(t, err) - - // Make the journal directory read-only to trigger append errors. - require.NoError(t, os.Chmod(dir, 0o444)) - t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) - - sig := &PipelineSignal{ - EpicNumber: 1, - ChildNumber: 1, - PRNumber: 1, - RepoOwner: "test", - RepoName: "repo", - } - - src := &mockSource{ - name: "test-source", - signals: []*PipelineSignal{sig}, - } - - handler := &mockHandler{ - name: "test-handler", - matchFn: func(s *PipelineSignal) bool { return true }, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - Journal: journal, - }) - - err = p.RunOnce(context.Background()) - // Journal errors are logged, not returned. - require.NoError(t, err) - - handler.mu.Lock() - defer handler.mu.Unlock() - assert.Len(t, handler.executed, 1, "handler should still execute even when journal fails") -} - -// --- Poller: Cycle counter increments --- - -func TestPoller_Cycle_Good_Increments(t *testing.T) { - src := &mockSource{name: "source", signals: nil} - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - }) - - assert.Equal(t, 0, p.Cycle()) - - _ = p.RunOnce(context.Background()) - assert.Equal(t, 1, p.Cycle()) - - _ = p.RunOnce(context.Background()) - assert.Equal(t, 2, p.Cycle()) -} diff --git a/jobrunner/forgejo/signals.go b/jobrunner/forgejo/signals.go deleted file mode 100644 index 9f1e1ee..0000000 --- a/jobrunner/forgejo/signals.go +++ /dev/null @@ -1,114 +0,0 @@ -package forgejo - -import ( - "regexp" - "strconv" - - forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// epicChildRe matches checklist items: - [ ] #42 or - [x] #42 -var epicChildRe = regexp.MustCompile(`- \[([ x])\] #(\d+)`) - -// parseEpicChildren extracts child issue numbers from an epic body's checklist. -func parseEpicChildren(body string) (unchecked []int, checked []int) { - matches := epicChildRe.FindAllStringSubmatch(body, -1) - for _, m := range matches { - num, err := strconv.Atoi(m[2]) - if err != nil { - continue - } - if m[1] == "x" { - checked = append(checked, num) - } else { - unchecked = append(unchecked, num) - } - } - return unchecked, checked -} - -// linkedPRRe matches "#N" references in PR bodies. -var linkedPRRe = regexp.MustCompile(`#(\d+)`) - -// findLinkedPR finds the first PR whose body references the given issue number. -func findLinkedPR(prs []*forgejosdk.PullRequest, issueNumber int) *forgejosdk.PullRequest { - target := strconv.Itoa(issueNumber) - for _, pr := range prs { - matches := linkedPRRe.FindAllStringSubmatch(pr.Body, -1) - for _, m := range matches { - if m[1] == target { - return pr - } - } - } - return nil -} - -// mapPRState maps Forgejo's PR state and merged flag to a canonical string. -func mapPRState(pr *forgejosdk.PullRequest) string { - if pr.HasMerged { - return "MERGED" - } - switch pr.State { - case forgejosdk.StateOpen: - return "OPEN" - case forgejosdk.StateClosed: - return "CLOSED" - default: - return "CLOSED" - } -} - -// mapMergeable maps Forgejo's boolean Mergeable field to a canonical string. -func mapMergeable(pr *forgejosdk.PullRequest) string { - if pr.HasMerged { - return "UNKNOWN" - } - if pr.Mergeable { - return "MERGEABLE" - } - return "CONFLICTING" -} - -// mapCombinedStatus maps a Forgejo CombinedStatus to SUCCESS/FAILURE/PENDING. -func mapCombinedStatus(cs *forgejosdk.CombinedStatus) string { - if cs == nil || cs.TotalCount == 0 { - return "PENDING" - } - switch cs.State { - case forgejosdk.StatusSuccess: - return "SUCCESS" - case forgejosdk.StatusFailure, forgejosdk.StatusError: - return "FAILURE" - default: - return "PENDING" - } -} - -// buildSignal creates a PipelineSignal from Forgejo API data. -func buildSignal( - owner, repo string, - epicNumber, childNumber int, - pr *forgejosdk.PullRequest, - checkStatus string, -) *jobrunner.PipelineSignal { - sig := &jobrunner.PipelineSignal{ - EpicNumber: epicNumber, - ChildNumber: childNumber, - PRNumber: int(pr.Index), - RepoOwner: owner, - RepoName: repo, - PRState: mapPRState(pr), - IsDraft: false, // SDK v2.2.0 doesn't expose Draft; treat as non-draft - Mergeable: mapMergeable(pr), - CheckStatus: checkStatus, - } - - if pr.Head != nil { - sig.LastCommitSHA = pr.Head.Sha - } - - return sig -} diff --git a/jobrunner/forgejo/signals_test.go b/jobrunner/forgejo/signals_test.go deleted file mode 100644 index 4b72535..0000000 --- a/jobrunner/forgejo/signals_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package forgejo - -import ( - "testing" - - forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "github.com/stretchr/testify/assert" -) - -func TestMapPRState_Good_Open(t *testing.T) { - pr := &forgejosdk.PullRequest{State: forgejosdk.StateOpen, HasMerged: false} - assert.Equal(t, "OPEN", mapPRState(pr)) -} - -func TestMapPRState_Good_Merged(t *testing.T) { - pr := &forgejosdk.PullRequest{State: forgejosdk.StateClosed, HasMerged: true} - assert.Equal(t, "MERGED", mapPRState(pr)) -} - -func TestMapPRState_Good_Closed(t *testing.T) { - pr := &forgejosdk.PullRequest{State: forgejosdk.StateClosed, HasMerged: false} - assert.Equal(t, "CLOSED", mapPRState(pr)) -} - -func TestMapPRState_Good_UnknownState(t *testing.T) { - // Any unknown state should default to CLOSED. - pr := &forgejosdk.PullRequest{State: "weird", HasMerged: false} - assert.Equal(t, "CLOSED", mapPRState(pr)) -} - -func TestMapMergeable_Good_Mergeable(t *testing.T) { - pr := &forgejosdk.PullRequest{Mergeable: true, HasMerged: false} - assert.Equal(t, "MERGEABLE", mapMergeable(pr)) -} - -func TestMapMergeable_Good_Conflicting(t *testing.T) { - pr := &forgejosdk.PullRequest{Mergeable: false, HasMerged: false} - assert.Equal(t, "CONFLICTING", mapMergeable(pr)) -} - -func TestMapMergeable_Good_Merged(t *testing.T) { - pr := &forgejosdk.PullRequest{HasMerged: true} - assert.Equal(t, "UNKNOWN", mapMergeable(pr)) -} - -func TestMapCombinedStatus_Good_Success(t *testing.T) { - cs := &forgejosdk.CombinedStatus{ - State: forgejosdk.StatusSuccess, - TotalCount: 1, - } - assert.Equal(t, "SUCCESS", mapCombinedStatus(cs)) -} - -func TestMapCombinedStatus_Good_Failure(t *testing.T) { - cs := &forgejosdk.CombinedStatus{ - State: forgejosdk.StatusFailure, - TotalCount: 1, - } - assert.Equal(t, "FAILURE", mapCombinedStatus(cs)) -} - -func TestMapCombinedStatus_Good_Error(t *testing.T) { - cs := &forgejosdk.CombinedStatus{ - State: forgejosdk.StatusError, - TotalCount: 1, - } - assert.Equal(t, "FAILURE", mapCombinedStatus(cs)) -} - -func TestMapCombinedStatus_Good_Pending(t *testing.T) { - cs := &forgejosdk.CombinedStatus{ - State: forgejosdk.StatusPending, - TotalCount: 1, - } - assert.Equal(t, "PENDING", mapCombinedStatus(cs)) -} - -func TestMapCombinedStatus_Good_Nil(t *testing.T) { - assert.Equal(t, "PENDING", mapCombinedStatus(nil)) -} - -func TestMapCombinedStatus_Good_ZeroCount(t *testing.T) { - cs := &forgejosdk.CombinedStatus{ - State: forgejosdk.StatusSuccess, - TotalCount: 0, - } - assert.Equal(t, "PENDING", mapCombinedStatus(cs)) -} - -func TestParseEpicChildren_Good_Mixed(t *testing.T) { - body := "## Sprint\n- [x] #1\n- [ ] #2\n- [x] #3\n- [ ] #4\nSome text\n" - unchecked, checked := parseEpicChildren(body) - assert.Equal(t, []int{2, 4}, unchecked) - assert.Equal(t, []int{1, 3}, checked) -} - -func TestParseEpicChildren_Good_NoCheckboxes(t *testing.T) { - body := "This is just a normal issue with no checkboxes." - unchecked, checked := parseEpicChildren(body) - assert.Nil(t, unchecked) - assert.Nil(t, checked) -} - -func TestParseEpicChildren_Good_AllChecked(t *testing.T) { - body := "- [x] #10\n- [x] #20\n" - unchecked, checked := parseEpicChildren(body) - assert.Nil(t, unchecked) - assert.Equal(t, []int{10, 20}, checked) -} - -func TestParseEpicChildren_Good_AllUnchecked(t *testing.T) { - body := "- [ ] #5\n- [ ] #6\n" - unchecked, checked := parseEpicChildren(body) - assert.Equal(t, []int{5, 6}, unchecked) - assert.Nil(t, checked) -} - -func TestFindLinkedPR_Good(t *testing.T) { - prs := []*forgejosdk.PullRequest{ - {Index: 10, Body: "Fixes #5"}, - {Index: 11, Body: "Resolves #7"}, - {Index: 12, Body: "Nothing here"}, - } - - pr := findLinkedPR(prs, 7) - assert.NotNil(t, pr) - assert.Equal(t, int64(11), pr.Index) -} - -func TestFindLinkedPR_Good_NotFound(t *testing.T) { - prs := []*forgejosdk.PullRequest{ - {Index: 10, Body: "Fixes #5"}, - } - pr := findLinkedPR(prs, 99) - assert.Nil(t, pr) -} - -func TestFindLinkedPR_Good_Nil(t *testing.T) { - pr := findLinkedPR(nil, 1) - assert.Nil(t, pr) -} - -func TestBuildSignal_Good(t *testing.T) { - pr := &forgejosdk.PullRequest{ - Index: 42, - State: forgejosdk.StateOpen, - Mergeable: true, - Head: &forgejosdk.PRBranchInfo{Sha: "deadbeef"}, - } - - sig := buildSignal("org", "repo", 10, 5, pr, "SUCCESS") - - assert.Equal(t, 10, sig.EpicNumber) - assert.Equal(t, 5, sig.ChildNumber) - assert.Equal(t, 42, sig.PRNumber) - assert.Equal(t, "org", sig.RepoOwner) - assert.Equal(t, "repo", sig.RepoName) - assert.Equal(t, "OPEN", sig.PRState) - assert.Equal(t, "MERGEABLE", sig.Mergeable) - assert.Equal(t, "SUCCESS", sig.CheckStatus) - assert.Equal(t, "deadbeef", sig.LastCommitSHA) - assert.False(t, sig.IsDraft) -} - -func TestBuildSignal_Good_NilHead(t *testing.T) { - pr := &forgejosdk.PullRequest{ - Index: 1, - State: forgejosdk.StateClosed, - HasMerged: true, - } - - sig := buildSignal("org", "repo", 1, 2, pr, "PENDING") - assert.Equal(t, "", sig.LastCommitSHA) - assert.Equal(t, "MERGED", sig.PRState) -} - -func TestSplitRepo_Good(t *testing.T) { - tests := []struct { - input string - owner string - repo string - err bool - }{ - {"host-uk/core", "host-uk", "core", false}, - {"a/b", "a", "b", false}, - {"org/repo-name", "org", "repo-name", false}, - {"invalid", "", "", true}, - {"", "", "", true}, - {"/repo", "", "", true}, - {"owner/", "", "", true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - owner, repo, err := splitRepo(tt.input) - if tt.err { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.owner, owner) - assert.Equal(t, tt.repo, repo) - } - }) - } -} diff --git a/jobrunner/forgejo/source.go b/jobrunner/forgejo/source.go deleted file mode 100644 index 92b2ba2..0000000 --- a/jobrunner/forgejo/source.go +++ /dev/null @@ -1,173 +0,0 @@ -package forgejo - -import ( - "context" - "fmt" - "strings" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" - "forge.lthn.ai/core/go/pkg/log" -) - -// Config configures a ForgejoSource. -type Config struct { - Repos []string // "owner/repo" format -} - -// ForgejoSource polls a Forgejo instance for pipeline signals from epic issues. -type ForgejoSource struct { - repos []string - forge *forge.Client -} - -// New creates a ForgejoSource using the given forge client. -func New(cfg Config, client *forge.Client) *ForgejoSource { - return &ForgejoSource{ - repos: cfg.Repos, - forge: client, - } -} - -// Name returns the source identifier. -func (s *ForgejoSource) Name() string { - return "forgejo" -} - -// Poll fetches epics and their linked PRs from all configured repositories, -// returning a PipelineSignal for each unchecked child that has a linked PR. -func (s *ForgejoSource) Poll(ctx context.Context) ([]*jobrunner.PipelineSignal, error) { - var signals []*jobrunner.PipelineSignal - - for _, repoFull := range s.repos { - owner, repo, err := splitRepo(repoFull) - if err != nil { - log.Error("invalid repo format", "repo", repoFull, "err", err) - continue - } - - repoSignals, err := s.pollRepo(ctx, owner, repo) - if err != nil { - log.Error("poll repo failed", "repo", repoFull, "err", err) - continue - } - - signals = append(signals, repoSignals...) - } - - return signals, nil -} - -// Report posts the action result as a comment on the epic issue. -func (s *ForgejoSource) Report(ctx context.Context, result *jobrunner.ActionResult) error { - if result == nil { - return nil - } - - status := "succeeded" - if !result.Success { - status = "failed" - } - - body := fmt.Sprintf("**jobrunner** `%s` %s for #%d (PR #%d)", result.Action, status, result.ChildNumber, result.PRNumber) - if result.Error != "" { - body += fmt.Sprintf("\n\n```\n%s\n```", result.Error) - } - - return s.forge.CreateIssueComment(result.RepoOwner, result.RepoName, int64(result.EpicNumber), body) -} - -// pollRepo fetches epics and PRs for a single repository. -func (s *ForgejoSource) pollRepo(_ context.Context, owner, repo string) ([]*jobrunner.PipelineSignal, error) { - // Fetch epic issues (label=epic, state=open). - issues, err := s.forge.ListIssues(owner, repo, forge.ListIssuesOpts{State: "open"}) - if err != nil { - return nil, log.E("forgejo.pollRepo", "fetch issues", err) - } - - // Filter to epics only. - var epics []epicInfo - for _, issue := range issues { - for _, label := range issue.Labels { - if label.Name == "epic" { - epics = append(epics, epicInfo{ - Number: int(issue.Index), - Body: issue.Body, - }) - break - } - } - } - - if len(epics) == 0 { - return nil, nil - } - - // Fetch all open PRs (and also merged/closed to catch MERGED state). - prs, err := s.forge.ListPullRequests(owner, repo, "all") - if err != nil { - return nil, log.E("forgejo.pollRepo", "fetch PRs", err) - } - - var signals []*jobrunner.PipelineSignal - - for _, epic := range epics { - unchecked, _ := parseEpicChildren(epic.Body) - for _, childNum := range unchecked { - pr := findLinkedPR(prs, childNum) - - if pr == nil { - // No PR yet — check if the child issue is assigned (needs coding). - childIssue, err := s.forge.GetIssue(owner, repo, int64(childNum)) - if err != nil { - log.Error("fetch child issue failed", "repo", owner+"/"+repo, "issue", childNum, "err", err) - continue - } - if len(childIssue.Assignees) > 0 && childIssue.Assignees[0].UserName != "" { - sig := &jobrunner.PipelineSignal{ - EpicNumber: epic.Number, - ChildNumber: childNum, - RepoOwner: owner, - RepoName: repo, - NeedsCoding: true, - Assignee: childIssue.Assignees[0].UserName, - IssueTitle: childIssue.Title, - IssueBody: childIssue.Body, - } - signals = append(signals, sig) - } - continue - } - - // Get combined commit status for the PR's head SHA. - checkStatus := "PENDING" - if pr.Head != nil && pr.Head.Sha != "" { - cs, err := s.forge.GetCombinedStatus(owner, repo, pr.Head.Sha) - if err != nil { - log.Error("fetch combined status failed", "repo", owner+"/"+repo, "sha", pr.Head.Sha, "err", err) - } else { - checkStatus = mapCombinedStatus(cs) - } - } - - sig := buildSignal(owner, repo, epic.Number, childNum, pr, checkStatus) - signals = append(signals, sig) - } - } - - return signals, nil -} - -type epicInfo struct { - Number int - Body string -} - -// splitRepo parses "owner/repo" into its components. -func splitRepo(full string) (string, string, error) { - parts := strings.SplitN(full, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", log.E("forgejo.splitRepo", fmt.Sprintf("expected owner/repo format, got %q", full), nil) - } - return parts[0], parts[1], nil -} diff --git a/jobrunner/forgejo/source_extra_test.go b/jobrunner/forgejo/source_extra_test.go deleted file mode 100644 index 7548e76..0000000 --- a/jobrunner/forgejo/source_extra_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package forgejo - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -func TestForgejoSource_Poll_Good_InvalidRepo(t *testing.T) { - // Invalid repo format should be logged and skipped, not error. - s := New(Config{Repos: []string{"invalid-no-slash"}}, nil) - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - assert.Empty(t, signals) -} - -func TestForgejoSource_Poll_Good_MultipleRepos(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - // Return one epic per repo. - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #2\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 10, - "body": "Fixes #2", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "abc", "ref": "fix", "label": "fix"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "state": "success", - "total_count": 1, - "statuses": []any{}, - }) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org-a/repo-1", "org-b/repo-2"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - assert.Len(t, signals, 2) -} - -func TestForgejoSource_Poll_Good_NeedsCoding(t *testing.T) { - // When a child issue has no linked PR but is assigned, NeedsCoding should be true. - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues/5"): - // Child issue with assignee. - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 5, - "title": "Implement feature", - "body": "Please implement this.", - "state": "open", - "assignees": []map[string]any{{"login": "darbs-claude", "username": "darbs-claude"}}, - }) - - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #5\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - // No PRs linked. - _ = json.NewEncoder(w).Encode([]any{}) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"test-org/test-repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - sig := signals[0] - assert.True(t, sig.NeedsCoding) - assert.Equal(t, "darbs-claude", sig.Assignee) - assert.Equal(t, "Implement feature", sig.IssueTitle) - assert.Equal(t, "Please implement this.", sig.IssueBody) - assert.Equal(t, 5, sig.ChildNumber) -} - -func TestForgejoSource_Poll_Good_MergedPR(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #3\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 20, - "body": "Fixes #3", - "state": "closed", - "mergeable": false, - "merged": true, - "head": map[string]string{"sha": "merged123", "ref": "fix", "label": "fix"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "state": "success", - "total_count": 1, - "statuses": []any{}, - }) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - assert.Equal(t, "MERGED", signals[0].PRState) - assert.Equal(t, "UNKNOWN", signals[0].Mergeable) -} - -func TestForgejoSource_Poll_Good_NoHeadSHA(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #3\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 20, - "body": "Fixes #3", - "state": "open", - "mergeable": true, - "merged": false, - // No head field. - }, - } - _ = json.NewEncoder(w).Encode(prs) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - // Without head SHA, check status stays PENDING. - assert.Equal(t, "PENDING", signals[0].CheckStatus) -} - -func TestForgejoSource_Report_Good_Nil(t *testing.T) { - s := New(Config{}, nil) - err := s.Report(context.Background(), nil) - assert.NoError(t, err) -} - -func TestForgejoSource_Report_Good_Failed(t *testing.T) { - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - capturedBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{}, client) - - result := &jobrunner.ActionResult{ - Action: "dispatch", - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 1, - ChildNumber: 2, - PRNumber: 3, - Success: false, - Error: "transfer failed", - } - - err := s.Report(context.Background(), result) - require.NoError(t, err) - assert.Contains(t, capturedBody, "failed") - assert.Contains(t, capturedBody, "transfer failed") -} - -func TestForgejoSource_Poll_Good_APIErrors(t *testing.T) { - // When the issues API fails, poll should continue with other repos. - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - assert.Empty(t, signals) -} - -func TestForgejoSource_Poll_Good_EmptyRepos(t *testing.T) { - s := New(Config{Repos: []string{}}, nil) - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - assert.Empty(t, signals) -} - -func TestForgejoSource_Poll_Good_NonEpicIssues(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - // Issues without the "epic" label. - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #2\n", - "labels": []map[string]string{{"name": "bug"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - assert.Empty(t, signals, "non-epic issues should not generate signals") -} diff --git a/jobrunner/forgejo/source_phase3_test.go b/jobrunner/forgejo/source_phase3_test.go deleted file mode 100644 index 1b2970f..0000000 --- a/jobrunner/forgejo/source_phase3_test.go +++ /dev/null @@ -1,672 +0,0 @@ -package forgejo - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// --- Signal parsing and filtering tests --- - -func TestParseEpicChildren_Good_EmptyBody(t *testing.T) { - unchecked, checked := parseEpicChildren("") - assert.Nil(t, unchecked) - assert.Nil(t, checked) -} - -func TestParseEpicChildren_Good_MixedContent(t *testing.T) { - // Checkboxes mixed with regular markdown content. - body := `## Epic: Refactor Auth - -Some description of the epic. - -### Tasks -- [x] #10 — Migrate session store -- [ ] #11 — Update OAuth flow -- [x] #12 — Fix token refresh -- [ ] #13 — Add 2FA support - -### Notes -This is a note, not a checkbox. -- Regular list item -- Another item -` - unchecked, checked := parseEpicChildren(body) - assert.Equal(t, []int{11, 13}, unchecked) - assert.Equal(t, []int{10, 12}, checked) -} - -func TestParseEpicChildren_Good_LargeIssueNumbers(t *testing.T) { - body := "- [ ] #9999\n- [x] #10000\n" - unchecked, checked := parseEpicChildren(body) - assert.Equal(t, []int{9999}, unchecked) - assert.Equal(t, []int{10000}, checked) -} - -func TestParseEpicChildren_Good_ConsecutiveCheckboxes(t *testing.T) { - body := "- [ ] #1\n- [ ] #2\n- [ ] #3\n- [ ] #4\n- [ ] #5\n" - unchecked, checked := parseEpicChildren(body) - assert.Equal(t, []int{1, 2, 3, 4, 5}, unchecked) - assert.Nil(t, checked) -} - -// --- findLinkedPR tests --- - -func TestFindLinkedPR_Good_MultipleReferencesInBody(t *testing.T) { - prs := []*forgejosdk.PullRequest{ - {Index: 10, Body: "Fixes #5 and relates to #7"}, - {Index: 11, Body: "Closes #8"}, - } - - // Should find PR #10 because it references #7. - pr := findLinkedPR(prs, 7) - assert.NotNil(t, pr) - assert.Equal(t, int64(10), pr.Index) - - // Should find PR #10 because it references #5. - pr = findLinkedPR(prs, 5) - assert.NotNil(t, pr) - assert.Equal(t, int64(10), pr.Index) -} - -func TestFindLinkedPR_Good_EmptyBodyPR(t *testing.T) { - prs := []*forgejosdk.PullRequest{ - {Index: 10, Body: ""}, - {Index: 11, Body: "Fixes #7"}, - } - - pr := findLinkedPR(prs, 7) - assert.NotNil(t, pr) - assert.Equal(t, int64(11), pr.Index) -} - -func TestFindLinkedPR_Good_FirstMatchWins(t *testing.T) { - // Both PRs reference #7, first one should win. - prs := []*forgejosdk.PullRequest{ - {Index: 10, Body: "Fixes #7"}, - {Index: 11, Body: "Also fixes #7"}, - } - - pr := findLinkedPR(prs, 7) - assert.NotNil(t, pr) - assert.Equal(t, int64(10), pr.Index) -} - -func TestFindLinkedPR_Good_EmptySlice(t *testing.T) { - prs := []*forgejosdk.PullRequest{} - pr := findLinkedPR(prs, 1) - assert.Nil(t, pr) -} - -// --- mapPRState edge case --- - -func TestMapPRState_Good_MergedOverridesState(t *testing.T) { - // HasMerged=true should return MERGED regardless of State. - pr := &forgejosdk.PullRequest{State: forgejosdk.StateOpen, HasMerged: true} - assert.Equal(t, "MERGED", mapPRState(pr)) -} - -// --- mapCombinedStatus edge cases --- - -func TestMapCombinedStatus_Good_WarningState(t *testing.T) { - // Unknown/warning state should default to PENDING. - cs := &forgejosdk.CombinedStatus{ - State: forgejosdk.StatusWarning, - TotalCount: 1, - } - assert.Equal(t, "PENDING", mapCombinedStatus(cs)) -} - -// --- buildSignal edge cases --- - -func TestBuildSignal_Good_ClosedPR(t *testing.T) { - pr := &forgejosdk.PullRequest{ - Index: 5, - State: forgejosdk.StateClosed, - Mergeable: false, - HasMerged: false, - Head: &forgejosdk.PRBranchInfo{Sha: "abc"}, - } - - sig := buildSignal("org", "repo", 1, 2, pr, "FAILURE") - assert.Equal(t, "CLOSED", sig.PRState) - assert.Equal(t, "CONFLICTING", sig.Mergeable) - assert.Equal(t, "FAILURE", sig.CheckStatus) - assert.Equal(t, "abc", sig.LastCommitSHA) -} - -func TestBuildSignal_Good_MergedPR(t *testing.T) { - pr := &forgejosdk.PullRequest{ - Index: 99, - State: forgejosdk.StateClosed, - Mergeable: false, - HasMerged: true, - Head: &forgejosdk.PRBranchInfo{Sha: "merged123"}, - } - - sig := buildSignal("owner", "repo", 10, 5, pr, "SUCCESS") - assert.Equal(t, "MERGED", sig.PRState) - assert.Equal(t, "UNKNOWN", sig.Mergeable) - assert.Equal(t, 99, sig.PRNumber) - assert.Equal(t, "merged123", sig.LastCommitSHA) -} - -// --- splitRepo edge cases --- - -func TestSplitRepo_Bad_OnlySlash(t *testing.T) { - _, _, err := splitRepo("/") - assert.Error(t, err) -} - -func TestSplitRepo_Bad_MultipleSlashes(t *testing.T) { - // Should take only the first part as owner, rest as repo. - owner, repo, err := splitRepo("a/b/c") - require.NoError(t, err) - assert.Equal(t, "a", owner) - assert.Equal(t, "b/c", repo) -} - -// --- Poll with combined status failure --- - -func TestForgejoSource_Poll_Good_CombinedStatusFailure(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #2\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 10, - "body": "Fixes #2", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "fail123", "ref": "feature", "label": "feature"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - status := map[string]any{ - "state": "failure", - "total_count": 2, - "statuses": []map[string]any{{"status": "failure", "context": "ci"}}, - } - _ = json.NewEncoder(w).Encode(status) - - default: - w.WriteHeader(http.StatusNotFound) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - assert.Equal(t, "FAILURE", signals[0].CheckStatus) - assert.Equal(t, "OPEN", signals[0].PRState) - assert.Equal(t, "MERGEABLE", signals[0].Mergeable) -} - -// --- Poll with combined status error --- - -func TestForgejoSource_Poll_Good_CombinedStatusError(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #3\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 20, - "body": "Fixes #3", - "state": "open", - "mergeable": false, - "merged": false, - "head": map[string]string{"sha": "err123", "ref": "fix", "label": "fix"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - // Combined status endpoint returns 500 — should fall back to PENDING. - case strings.Contains(path, "/status"): - w.WriteHeader(http.StatusInternalServerError) - - default: - w.WriteHeader(http.StatusNotFound) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - // Combined status API error -> falls back to PENDING. - assert.Equal(t, "PENDING", signals[0].CheckStatus) - assert.Equal(t, "CONFLICTING", signals[0].Mergeable) -} - -// --- Poll with child that has no assignee (NeedsCoding path, no assignee) --- - -func TestForgejoSource_Poll_Good_ChildNoAssignee(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues/5"): - // Child issue with no assignee. - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 5, - "title": "Unassigned task", - "body": "No one is working on this.", - "state": "open", - "assignees": []map[string]any{}, - }) - - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #5\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - _ = json.NewEncoder(w).Encode([]any{}) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - // No signal should be emitted when child has no assignee and no PR. - assert.Empty(t, signals) -} - -// --- Poll with child issue fetch failure --- - -func TestForgejoSource_Poll_Good_ChildFetchFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues/5"): - // Child issue fetch fails. - w.WriteHeader(http.StatusInternalServerError) - - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #5\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - _ = json.NewEncoder(w).Encode([]any{}) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - // Child fetch error should be logged and skipped, not returned as error. - assert.Empty(t, signals) -} - -// --- Poll with multiple epics --- - -func TestForgejoSource_Poll_Good_MultipleEpics(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #3\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - { - "number": 2, - "body": "- [ ] #4\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 10, - "body": "Fixes #3", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "aaa", "ref": "f1", "label": "f1"}, - }, - { - "number": 11, - "body": "Fixes #4", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "bbb", "ref": "f2", "label": "f2"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "state": "success", - "total_count": 1, - "statuses": []any{}, - }) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 2) - assert.Equal(t, 1, signals[0].EpicNumber) - assert.Equal(t, 3, signals[0].ChildNumber) - assert.Equal(t, 10, signals[0].PRNumber) - - assert.Equal(t, 2, signals[1].EpicNumber) - assert.Equal(t, 4, signals[1].ChildNumber) - assert.Equal(t, 11, signals[1].PRNumber) -} - -// --- Report with nil result --- - -func TestForgejoSource_Report_Good_NilResult(t *testing.T) { - s := New(Config{}, nil) - err := s.Report(context.Background(), nil) - assert.NoError(t, err) -} - -// --- Report constructs correct comment body --- - -func TestForgejoSource_Report_Good_SuccessFormat(t *testing.T) { - var capturedPath string - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path - w.Header().Set("Content-Type", "application/json") - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - capturedBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{}, client) - - result := &jobrunner.ActionResult{ - Action: "tick_parent", - RepoOwner: "core", - RepoName: "go-scm", - EpicNumber: 5, - ChildNumber: 10, - PRNumber: 20, - Success: true, - } - - err := s.Report(context.Background(), result) - require.NoError(t, err) - - // Comment should be on the epic issue. - assert.Contains(t, capturedPath, "/issues/5/comments") - assert.Contains(t, capturedBody, "tick_parent") - assert.Contains(t, capturedBody, "succeeded") - assert.Contains(t, capturedBody, "#10") - assert.Contains(t, capturedBody, "PR #20") -} - -func TestForgejoSource_Report_Good_FailureWithError(t *testing.T) { - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - capturedBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{}, client) - - result := &jobrunner.ActionResult{ - Action: "enable_auto_merge", - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 1, - ChildNumber: 2, - PRNumber: 3, - Success: false, - Error: "merge conflict detected", - } - - err := s.Report(context.Background(), result) - require.NoError(t, err) - - assert.Contains(t, capturedBody, "failed") - assert.Contains(t, capturedBody, "merge conflict detected") -} - -// --- Poll filters only epic-labelled issues --- - -func TestForgejoSource_Poll_Good_MixedLabels(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #2\n", - "labels": []map[string]string{{"name": "epic"}, {"name": "priority-high"}}, - "state": "open", - }, - { - "number": 3, - "body": "- [ ] #4\n", - "labels": []map[string]string{{"name": "bug"}}, - "state": "open", - }, - { - "number": 5, - "body": "- [ ] #6\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 10, - "body": "Fixes #2", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "sha1", "ref": "f1", "label": "f1"}, - }, - { - "number": 11, - "body": "Fixes #4", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "sha2", "ref": "f2", "label": "f2"}, - }, - { - "number": 12, - "body": "Fixes #6", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "sha3", "ref": "f3", "label": "f3"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "state": "success", - "total_count": 1, - "statuses": []any{}, - }) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - // Only issues #1 and #5 have the "epic" label. - require.Len(t, signals, 2) - assert.Equal(t, 1, signals[0].EpicNumber) - assert.Equal(t, 2, signals[0].ChildNumber) - assert.Equal(t, 5, signals[1].EpicNumber) - assert.Equal(t, 6, signals[1].ChildNumber) -} - -// --- Poll with PRs error after issues succeed --- - -func TestForgejoSource_Poll_Good_PRsAPIError(t *testing.T) { - callCount := 0 - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - callCount++ - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, - "body": "- [ ] #2\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - w.WriteHeader(http.StatusInternalServerError) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client, err := forge.New(srv.URL, "test-token") - require.NoError(t, err) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - // PR API failure -> repo is skipped, no signals. - assert.Empty(t, signals) -} - -// --- New creates source correctly --- - -func TestForgejoSource_New_Good(t *testing.T) { - s := New(Config{Repos: []string{"a/b", "c/d"}}, nil) - assert.Equal(t, "forgejo", s.Name()) - assert.Equal(t, []string{"a/b", "c/d"}, s.repos) -} diff --git a/jobrunner/forgejo/source_supplementary_test.go b/jobrunner/forgejo/source_supplementary_test.go deleted file mode 100644 index cb4355a..0000000 --- a/jobrunner/forgejo/source_supplementary_test.go +++ /dev/null @@ -1,409 +0,0 @@ -package forgejo - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// --------------------------------------------------------------------------- -// Supplementary Forgejo signal source tests — extends Phase 3 coverage -// --------------------------------------------------------------------------- - -func TestForgejoSource_Poll_Good_MultipleEpicsMultipleChildren(t *testing.T) { - // Two epics, each with multiple unchecked children that have linked PRs. - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 10, - "body": "## Sprint\n- [ ] #11\n- [ ] #12\n- [x] #13\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - { - "number": 20, - "body": "## Sprint 2\n- [ ] #21\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 30, "body": "Fixes #11", "state": "open", - "mergeable": true, "merged": false, - "head": map[string]string{"sha": "aaa111", "ref": "fix-11", "label": "fix-11"}, - }, - { - "number": 31, "body": "Fixes #12", "state": "open", - "mergeable": false, "merged": false, - "head": map[string]string{"sha": "bbb222", "ref": "fix-12", "label": "fix-12"}, - }, - { - "number": 32, "body": "Resolves #21", "state": "open", - "mergeable": true, "merged": false, - "head": map[string]string{"sha": "ccc333", "ref": "fix-21", "label": "fix-21"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "state": "success", "total_count": 1, "statuses": []any{}, - }) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - // Epic 10 has #11 and #12 unchecked; epic 20 has #21 unchecked. Total 3 signals. - require.Len(t, signals, 3, "expected three signals from two epics") - - childNumbers := map[int]bool{} - for _, sig := range signals { - childNumbers[sig.ChildNumber] = true - } - assert.True(t, childNumbers[11]) - assert.True(t, childNumbers[12]) - assert.True(t, childNumbers[21]) -} - -func TestForgejoSource_Poll_Good_CombinedStatusFetchErrorFallsToPending(t *testing.T) { - // When combined status fetch fails, check status should default to PENDING. - var statusFetched atomic.Bool - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, "body": "- [ ] #2\n", - "labels": []map[string]string{{"name": "epic"}}, "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 10, "body": "Fixes #2", "state": "open", - "mergeable": true, "merged": false, - "head": map[string]string{"sha": "sha123", "ref": "fix", "label": "fix"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - statusFetched.Store(true) - w.WriteHeader(http.StatusInternalServerError) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - assert.True(t, statusFetched.Load(), "status endpoint should have been called") - assert.Equal(t, "PENDING", signals[0].CheckStatus, "failed status fetch should default to PENDING") -} - -func TestForgejoSource_Poll_Good_MixedReposFirstFailsSecondSucceeds(t *testing.T) { - // First repo fails (issues endpoint 500), second repo succeeds. - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/repos/bad-org/bad-repo/issues"): - w.WriteHeader(http.StatusInternalServerError) - - case strings.Contains(path, "/repos/good-org/good-repo/issues"): - issues := []map[string]any{ - { - "number": 1, "body": "- [ ] #2\n", - "labels": []map[string]string{{"name": "epic"}}, "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/repos/good-org/good-repo/pulls"): - prs := []map[string]any{ - { - "number": 10, "body": "Fixes #2", "state": "open", - "mergeable": true, "merged": false, - "head": map[string]string{"sha": "abc", "ref": "fix", "label": "fix"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "state": "success", "total_count": 1, "statuses": []any{}, - }) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"bad-org/bad-repo", "good-org/good-repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - require.Len(t, signals, 1, "only the good repo should produce signals") - assert.Equal(t, "good-org", signals[0].RepoOwner) - assert.Equal(t, "good-repo", signals[0].RepoName) -} - -func TestForgejoSource_Report_Good_CommentBodyTable(t *testing.T) { - tests := []struct { - name string - result *jobrunner.ActionResult - wantContains []string - }{ - { - name: "successful action", - result: &jobrunner.ActionResult{ - Action: "enable_auto_merge", RepoOwner: "org", RepoName: "repo", - EpicNumber: 10, ChildNumber: 11, PRNumber: 20, Success: true, - }, - wantContains: []string{"enable_auto_merge", "succeeded", "#11", "PR #20"}, - }, - { - name: "failed action with error", - result: &jobrunner.ActionResult{ - Action: "tick_parent", RepoOwner: "org", RepoName: "repo", - EpicNumber: 10, ChildNumber: 11, PRNumber: 20, - Success: false, Error: "rate limit exceeded", - }, - wantContains: []string{"tick_parent", "failed", "#11", "PR #20", "rate limit exceeded"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - capturedBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{}, client) - - err := s.Report(context.Background(), tt.result) - require.NoError(t, err) - - for _, want := range tt.wantContains { - assert.Contains(t, capturedBody, want) - } - }) - } -} - -func TestForgejoSource_Report_Good_PostsToCorrectEpicIssue(t *testing.T) { - var capturedPath string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost { - capturedPath = r.URL.Path - } - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{}, client) - - result := &jobrunner.ActionResult{ - Action: "merge", RepoOwner: "test-org", RepoName: "test-repo", - EpicNumber: 42, ChildNumber: 7, PRNumber: 99, Success: true, - } - - err := s.Report(context.Background(), result) - require.NoError(t, err) - - expected := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", result.RepoOwner, result.RepoName, result.EpicNumber) - assert.Equal(t, expected, capturedPath, "comment should be posted on the epic issue") -} - -func TestForgejoSource_Poll_Good_SignalFieldCompleteness(t *testing.T) { - // Verify that all expected signal fields are populated correctly. - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 100, "body": "## Work\n- [ ] #101\n- [x] #102\n", - "labels": []map[string]string{{"name": "epic"}}, "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 200, "body": "Closes #101", "state": "open", - "mergeable": true, "merged": false, - "head": map[string]string{"sha": "deadbeef", "ref": "feature", "label": "feature"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - case strings.Contains(path, "/status"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "state": "success", "total_count": 2, - "statuses": []map[string]any{{"status": "success"}, {"status": "success"}}, - }) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"acme/widgets"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - sig := signals[0] - - assert.Equal(t, 100, sig.EpicNumber) - assert.Equal(t, 101, sig.ChildNumber) - assert.Equal(t, 200, sig.PRNumber) - assert.Equal(t, "acme", sig.RepoOwner) - assert.Equal(t, "widgets", sig.RepoName) - assert.Equal(t, "OPEN", sig.PRState) - assert.Equal(t, "MERGEABLE", sig.Mergeable) - assert.Equal(t, "SUCCESS", sig.CheckStatus) - assert.Equal(t, "deadbeef", sig.LastCommitSHA) - assert.False(t, sig.NeedsCoding) - assert.Equal(t, "acme/widgets", sig.RepoFullName()) -} - -func TestForgejoSource_Poll_Good_AllChildrenCheckedNoSignals(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, "body": "- [x] #2\n- [x] #3\n", - "labels": []map[string]string{{"name": "epic"}}, "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - _ = json.NewEncoder(w).Encode([]any{}) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - assert.Empty(t, signals, "all children checked means no work to do") -} - -func TestForgejoSource_Poll_Good_NeedsCodingSignalFields(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.Contains(path, "/issues/7"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 7, "title": "Implement authentication", - "body": "Add OAuth2 support.", "state": "open", - "assignees": []map[string]any{{"login": "agent-bot", "username": "agent-bot"}}, - }) - - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 1, "body": "- [ ] #7\n", - "labels": []map[string]string{{"name": "epic"}}, "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - case strings.Contains(path, "/pulls"): - _ = json.NewEncoder(w).Encode([]any{}) - - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"org/repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - sig := signals[0] - assert.True(t, sig.NeedsCoding) - assert.Equal(t, "agent-bot", sig.Assignee) - assert.Equal(t, "Implement authentication", sig.IssueTitle) - assert.Contains(t, sig.IssueBody, "OAuth2 support") - assert.Equal(t, 0, sig.PRNumber, "PRNumber should be zero for NeedsCoding signals") -} diff --git a/jobrunner/forgejo/source_test.go b/jobrunner/forgejo/source_test.go deleted file mode 100644 index 5721201..0000000 --- a/jobrunner/forgejo/source_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package forgejo - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// withVersion wraps an HTTP handler to serve the Forgejo /api/v1/version -// endpoint that the SDK calls during NewClient initialization. -func withVersion(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "/version") { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"version":"9.0.0"}`)) - return - } - next.ServeHTTP(w, r) - }) -} - -func newTestClient(t *testing.T, url string) *forge.Client { - t.Helper() - client, err := forge.New(url, "test-token") - require.NoError(t, err) - return client -} - -func TestForgejoSource_Name(t *testing.T) { - s := New(Config{}, nil) - assert.Equal(t, "forgejo", s.Name()) -} - -func TestForgejoSource_Poll_Good(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Content-Type", "application/json") - - switch { - // List issues — return one epic - case strings.Contains(path, "/issues"): - issues := []map[string]any{ - { - "number": 10, - "body": "## Tasks\n- [ ] #11\n- [x] #12\n", - "labels": []map[string]string{{"name": "epic"}}, - "state": "open", - }, - } - _ = json.NewEncoder(w).Encode(issues) - - // List PRs — return one open PR linked to #11 - case strings.Contains(path, "/pulls"): - prs := []map[string]any{ - { - "number": 20, - "body": "Fixes #11", - "state": "open", - "mergeable": true, - "merged": false, - "head": map[string]string{"sha": "abc123", "ref": "feature", "label": "feature"}, - }, - } - _ = json.NewEncoder(w).Encode(prs) - - // Combined status - case strings.Contains(path, "/status"): - status := map[string]any{ - "state": "success", - "total_count": 1, - "statuses": []map[string]any{{"status": "success", "context": "ci"}}, - } - _ = json.NewEncoder(w).Encode(status) - - default: - w.WriteHeader(http.StatusNotFound) - } - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"test-org/test-repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - - require.Len(t, signals, 1) - sig := signals[0] - assert.Equal(t, 10, sig.EpicNumber) - assert.Equal(t, 11, sig.ChildNumber) - assert.Equal(t, 20, sig.PRNumber) - assert.Equal(t, "OPEN", sig.PRState) - assert.Equal(t, "MERGEABLE", sig.Mergeable) - assert.Equal(t, "SUCCESS", sig.CheckStatus) - assert.Equal(t, "test-org", sig.RepoOwner) - assert.Equal(t, "test-repo", sig.RepoName) - assert.Equal(t, "abc123", sig.LastCommitSHA) -} - -func TestForgejoSource_Poll_NoEpics(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]any{}) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{Repos: []string{"test-org/test-repo"}}, client) - - signals, err := s.Poll(context.Background()) - require.NoError(t, err) - assert.Empty(t, signals) -} - -func TestForgejoSource_Report_Good(t *testing.T) { - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - capturedBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - }))) - defer srv.Close() - - client := newTestClient(t, srv.URL) - s := New(Config{}, client) - - result := &jobrunner.ActionResult{ - Action: "enable_auto_merge", - RepoOwner: "test-org", - RepoName: "test-repo", - EpicNumber: 10, - ChildNumber: 11, - PRNumber: 20, - Success: true, - } - - err := s.Report(context.Background(), result) - require.NoError(t, err) - assert.Contains(t, capturedBody, "enable_auto_merge") - assert.Contains(t, capturedBody, "succeeded") -} - -func TestParseEpicChildren(t *testing.T) { - body := "## Tasks\n- [x] #1\n- [ ] #7\n- [ ] #8\n- [x] #3\n" - unchecked, checked := parseEpicChildren(body) - assert.Equal(t, []int{7, 8}, unchecked) - assert.Equal(t, []int{1, 3}, checked) -} - -func TestFindLinkedPR(t *testing.T) { - assert.Nil(t, findLinkedPR(nil, 7)) -} - -func TestSplitRepo(t *testing.T) { - owner, repo, err := splitRepo("host-uk/core") - require.NoError(t, err) - assert.Equal(t, "host-uk", owner) - assert.Equal(t, "core", repo) - - _, _, err = splitRepo("invalid") - assert.Error(t, err) - - _, _, err = splitRepo("") - assert.Error(t, err) -} diff --git a/jobrunner/handlers/completion.go b/jobrunner/handlers/completion.go deleted file mode 100644 index 0355bda..0000000 --- a/jobrunner/handlers/completion.go +++ /dev/null @@ -1,87 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "time" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -const ( - ColorAgentComplete = "#0e8a16" // Green -) - -// CompletionHandler manages issue state when an agent finishes work. -type CompletionHandler struct { - forge *forge.Client -} - -// NewCompletionHandler creates a handler for agent completion events. -func NewCompletionHandler(client *forge.Client) *CompletionHandler { - return &CompletionHandler{ - forge: client, - } -} - -// Name returns the handler identifier. -func (h *CompletionHandler) Name() string { - return "completion" -} - -// Match returns true if the signal indicates an agent has finished a task. -func (h *CompletionHandler) Match(signal *jobrunner.PipelineSignal) bool { - return signal.Type == "agent_completion" -} - -// Execute updates the issue labels based on the completion status. -func (h *CompletionHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) { - start := time.Now() - - // Remove in-progress label. - if inProgressLabel, err := h.forge.GetLabelByName(signal.RepoOwner, signal.RepoName, LabelInProgress); err == nil { - _ = h.forge.RemoveIssueLabel(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), inProgressLabel.ID) - } - - if signal.Success { - completeLabel, err := h.forge.EnsureLabel(signal.RepoOwner, signal.RepoName, LabelAgentComplete, ColorAgentComplete) - if err != nil { - return nil, fmt.Errorf("ensure label %s: %w", LabelAgentComplete, err) - } - - if err := h.forge.AddIssueLabels(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), []int64{completeLabel.ID}); err != nil { - return nil, fmt.Errorf("add completed label: %w", err) - } - - if signal.Message != "" { - _ = h.forge.CreateIssueComment(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), signal.Message) - } - } else { - failedLabel, err := h.forge.EnsureLabel(signal.RepoOwner, signal.RepoName, LabelAgentFailed, ColorAgentFailed) - if err != nil { - return nil, fmt.Errorf("ensure label %s: %w", LabelAgentFailed, err) - } - - if err := h.forge.AddIssueLabels(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), []int64{failedLabel.ID}); err != nil { - return nil, fmt.Errorf("add failed label: %w", err) - } - - msg := "Agent reported failure." - if signal.Error != "" { - msg += fmt.Sprintf("\n\nError: %s", signal.Error) - } - _ = h.forge.CreateIssueComment(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), msg) - } - - return &jobrunner.ActionResult{ - Action: "completion", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - EpicNumber: signal.EpicNumber, - ChildNumber: signal.ChildNumber, - Success: true, - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil -} diff --git a/jobrunner/handlers/completion_test.go b/jobrunner/handlers/completion_test.go deleted file mode 100644 index 6c52b61..0000000 --- a/jobrunner/handlers/completion_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -func TestCompletion_Name_Good(t *testing.T) { - h := NewCompletionHandler(nil) - assert.Equal(t, "completion", h.Name()) -} - -func TestCompletion_Match_Good_AgentCompletion(t *testing.T) { - h := NewCompletionHandler(nil) - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - } - assert.True(t, h.Match(sig)) -} - -func TestCompletion_Match_Bad_WrongType(t *testing.T) { - h := NewCompletionHandler(nil) - sig := &jobrunner.PipelineSignal{ - Type: "pr_update", - } - assert.False(t, h.Match(sig)) -} - -func TestCompletion_Match_Bad_EmptyType(t *testing.T) { - h := NewCompletionHandler(nil) - sig := &jobrunner.PipelineSignal{} - assert.False(t, h.Match(sig)) -} - -func TestCompletion_Execute_Good_Success(t *testing.T) { - var labelRemoved bool - var labelAdded bool - var commentPosted bool - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - // GetLabelByName (in-progress) — GET labels to find in-progress. - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/test-org/test-repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - }) - - // RemoveIssueLabel (in-progress). - case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/repos/test-org/test-repo/issues/5/labels/1": - labelRemoved = true - w.WriteHeader(http.StatusNoContent) - - // EnsureLabel (agent-completed) — POST to create. - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/test-org/test-repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 2, "name": "agent-completed", "color": "#0e8a16"}) - - // AddIssueLabels. - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/test-org/test-repo/issues/5/labels": - labelAdded = true - _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 2, "name": "agent-completed"}}) - - // CreateIssueComment. - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/test-org/test-repo/issues/5/comments": - commentPosted = true - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - commentBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "body": body["body"]}) - - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "test-org", - RepoName: "test-repo", - ChildNumber: 5, - EpicNumber: 3, - Success: true, - Message: "Task completed successfully", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "completion", result.Action) - assert.Equal(t, "test-org", result.RepoOwner) - assert.Equal(t, "test-repo", result.RepoName) - assert.Equal(t, 3, result.EpicNumber) - assert.Equal(t, 5, result.ChildNumber) - assert.True(t, labelRemoved, "in-progress label should be removed") - assert.True(t, labelAdded, "agent-completed label should be added") - assert.True(t, commentPosted, "comment should be posted") - assert.Contains(t, commentBody, "Task completed successfully") -} - -func TestCompletion_Execute_Good_Failure(t *testing.T) { - var labelAdded bool - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/test-org/test-repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{}) - - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/test-org/test-repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 3, "name": "agent-failed", "color": "#c0392b"}) - - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/test-org/test-repo/issues/5/labels": - labelAdded = true - _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 3, "name": "agent-failed"}}) - - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/test-org/test-repo/issues/5/comments": - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - commentBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "body": body["body"]}) - - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "test-org", - RepoName: "test-repo", - ChildNumber: 5, - EpicNumber: 3, - Success: false, - Error: "tests failed", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.True(t, result.Success) // The handler itself succeeded - assert.Equal(t, "completion", result.Action) - assert.True(t, labelAdded, "agent-failed label should be added") - assert.Contains(t, commentBody, "Agent reported failure") - assert.Contains(t, commentBody, "tests failed") -} - -func TestCompletion_Execute_Good_FailureNoError(t *testing.T) { - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 3, "name": "agent-failed", "color": "#c0392b"}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/issues/1/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/issues/1/comments": - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - commentBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 1, - Success: false, - Error: "", // No error message. - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) - assert.Contains(t, commentBody, "Agent reported failure") - assert.NotContains(t, commentBody, "Error:") // No error detail. -} - -func TestCompletion_Execute_Good_SuccessNoMessage(t *testing.T) { - var commentPosted bool - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 2, "name": "agent-completed", "color": "#0e8a16"}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/issues/1/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/issues/1/comments": - commentPosted = true - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 1, - Success: true, - Message: "", // No message. - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) - assert.False(t, commentPosted, "no comment should be posted when message is empty") -} - -func TestCompletion_Execute_Bad_EnsureLabelFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - // Return empty so EnsureLabel tries to create. - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - // Label creation fails. - w.WriteHeader(http.StatusInternalServerError) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 1, - Success: true, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ensure label") -} diff --git a/jobrunner/handlers/coverage_boost_test.go b/jobrunner/handlers/coverage_boost_test.go deleted file mode 100644 index 09b38e3..0000000 --- a/jobrunner/handlers/coverage_boost_test.go +++ /dev/null @@ -1,704 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/agentci" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// --- Dispatch: Execute with invalid repo name --- - -func TestDispatch_Execute_Bad_InvalidRepoNameSpecialChars(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "valid-org", - RepoName: "repo$bad!", - ChildNumber: 1, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid repo name") -} - -// --- Dispatch: Execute when EnsureLabel fails --- - -func TestDispatch_Execute_Bad_EnsureLabelCreationFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusInternalServerError) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 1, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ensure label") -} - -// dispatchMockServer creates a standard mock server for dispatch tests. -// It handles all the Forgejo API calls needed for a full dispatch flow. -func dispatchMockServer(t *testing.T) *httptest.Server { - t.Helper() - return httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - // GetLabelByName / list labels - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - {"id": 2, "name": "agent-ready", "color": "#00ff00"}, - }) - - // CreateLabel (shouldn't normally be needed since we return it above) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "in-progress", "color": "#1d76db"}) - - // GetIssue (returns issue with no label to trigger the full dispatch flow) - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - w.WriteHeader(http.StatusNotFound) // Issue not found => full dispatch flow - - // AssignIssue - case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - _ = json.NewEncoder(w).Encode(map[string]any{"id": 5, "number": 5}) - - // AddIssueLabels - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "name": "in-progress"}}) - - // RemoveIssueLabel - case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/labels/"): - w.WriteHeader(http.StatusNoContent) - - // CreateIssueComment - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/comments"): - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "body": "dispatched"}) - - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) -} - -// --- Dispatch: Execute when GetIssue returns 404 (full dispatch path) --- - -func TestDispatch_Execute_Good_GetIssueNotFound(t *testing.T) { - srv := dispatchMockServer(t) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/nonexistent-queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 5, - EpicNumber: 3, - IssueTitle: "Test issue", - IssueBody: "Test body", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.Equal(t, "dispatch", result.Action) -} - -// --- Completion: Execute when AddIssueLabels fails for success case --- - -func TestCompletion_Execute_Bad_AddCompleteLabelFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/repo/labels"): - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 2, "name": "agent-completed", "color": "#0e8a16"}) - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/labels"): - w.WriteHeader(http.StatusInternalServerError) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 5, - Success: true, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "add completed label") -} - -// --- Completion: Execute when AddIssueLabels fails for failure case --- - -func TestCompletion_Execute_Bad_AddFailLabelFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/repo/labels"): - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 3, "name": "agent-failed", "color": "#c0392b"}) - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/labels"): - w.WriteHeader(http.StatusInternalServerError) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 5, - Success: false, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "add failed label") -} - -// --- Completion: Execute with EnsureLabel failure on failure path --- - -func TestCompletion_Execute_Bad_FailedPathEnsureLabelFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{}) - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/labels"): - w.WriteHeader(http.StatusInternalServerError) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - sig := &jobrunner.PipelineSignal{ - Type: "agent_completion", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 1, - Success: false, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ensure label") -} - -// --- EnableAutoMerge: additional edge case --- - -func TestEnableAutoMerge_Match_Bad_PendingChecks(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - IsDraft: false, - Mergeable: "MERGEABLE", - CheckStatus: "PENDING", - } - assert.False(t, h.Match(sig)) -} - -func TestEnableAutoMerge_Execute_Bad_InternalServerError(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewEnableAutoMergeHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.False(t, result.Success) - assert.Contains(t, result.Error, "merge failed") -} - -// --- PublishDraft: Match with MERGED state --- - -func TestPublishDraft_Match_Bad_MergedState(t *testing.T) { - h := NewPublishDraftHandler(nil) - sig := &jobrunner.PipelineSignal{ - IsDraft: true, - PRState: "MERGED", - CheckStatus: "SUCCESS", - } - assert.False(t, h.Match(sig)) -} - -// --- SendFixCommand: Execute merge conflict message --- - -func TestSendFixCommand_Execute_Good_MergeConflictMessage(t *testing.T) { - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost { - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - capturedBody = body["body"] - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewSendFixCommandHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - Mergeable: "CONFLICTING", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) - assert.Contains(t, capturedBody, "fix the merge conflict") -} - -// --- DismissReviews: Execute with stale review that gets dismissed --- - -func TestDismissReviews_Execute_Good_StaleReviewDismissed(t *testing.T) { - var dismissCalled bool - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/reviews") { - reviews := []map[string]any{ - { - "id": 1, "state": "REQUEST_CHANGES", "dismissed": false, "stale": true, - "body": "fix it", "commit_id": "abc123", - }, - } - _ = json.NewEncoder(w).Encode(reviews) - return - } - - if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/dismissals") { - dismissCalled = true - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "state": "DISMISSED"}) - return - } - - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewDismissReviewsHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - PRState: "OPEN", - ThreadsTotal: 1, - ThreadsResolved: 0, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) - assert.True(t, dismissCalled) -} - -// --- TickParent: Execute ticks and closes --- - -func TestTickParent_Execute_Good_TicksCheckboxAndCloses(t *testing.T) { - epicBody := "## Tasks\n- [ ] #7\n- [ ] #8\n" - var editedBody string - var closedIssue bool - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/42"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": epicBody, - "title": "Epic", - }) - case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/issues/42"): - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - if b, ok := body["body"].(string); ok { - editedBody = b - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": editedBody, - "title": "Epic", - }) - case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/issues/7"): - closedIssue = true - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 7, - "state": "closed", - }) - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewTickParentHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - PRState: "MERGED", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) - assert.Contains(t, editedBody, "- [x] #7") - assert.True(t, closedIssue) -} - -// --- Dispatch: DualRun mode --- - -func TestDispatch_Execute_Good_DualRunModeDispatch(t *testing.T) { - srv := dispatchMockServer(t) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - spinner := agentci.NewSpinner( - agentci.ClothoConfig{Strategy: "clotho-verified"}, - map[string]agentci.AgentConfig{ - "darbs-claude": { - Host: "localhost", - QueueDir: "/tmp/nonexistent-queue", - Active: true, - Model: "sonnet", - DualRun: true, - }, - }, - ) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 5, - EpicNumber: 3, - IssueTitle: "Test issue", - IssueBody: "Test body", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.Equal(t, "dispatch", result.Action) -} - -// --- TickParent: ChildNumber not found in epic body --- - -func TestTickParent_Execute_Good_ChildNotInBody(t *testing.T) { - epicBody := "## Tasks\n- [ ] #99\n" - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/42") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": epicBody, - "title": "Epic", - }) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewTickParentHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 42, - ChildNumber: 50, - PRNumber: 100, - PRState: "MERGED", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) -} - -// --- Dispatch: AssignIssue fails (warn, continue) --- - -func TestDispatch_Execute_Good_AssignIssueFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - {"id": 2, "name": "agent-ready", "color": "#00ff00"}, - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "in-progress"}) - // GetIssue returns issue with NO special labels - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 5, "number": 5, "title": "Test Issue", - "labels": []map[string]any{}, - }) - // AssignIssue FAILS - case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"message":"assign failed"}`)) - // AddIssueLabels succeeds - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "name": "in-progress"}}) - case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/labels/"): - w.WriteHeader(http.StatusNoContent) - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/comments"): - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "body": "dispatched"}) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/nonexistent-queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 1, - ChildNumber: 5, - PRNumber: 10, - RepoOwner: "org", - RepoName: "repo", - Assignee: "darbs-claude", - IssueTitle: "Test Issue", - IssueBody: "Test body", - } - - // Should not return error because AssignIssue failure is only a warning. - result, err := h.Execute(context.Background(), signal) - // secureTransfer will fail because SSH isn't available, but we exercised the assign-error path. - _ = result - _ = err -} - -// --- Dispatch: AddIssueLabels fails --- - -func TestDispatch_Execute_Bad_AddIssueLabelsError(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "in-progress"}) - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 5, "number": 5, "title": "Test Issue", - "labels": []map[string]any{}, - }) - case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - _ = json.NewEncoder(w).Encode(map[string]any{"id": 5, "number": 5}) - // AddIssueLabels FAILS - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/labels"): - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"message":"label add failed"}`)) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/nonexistent-queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 1, - ChildNumber: 5, - PRNumber: 10, - RepoOwner: "org", - RepoName: "repo", - Assignee: "darbs-claude", - IssueTitle: "Test Issue", - IssueBody: "Test body", - } - - _, err := h.Execute(context.Background(), signal) - assert.Error(t, err) - assert.Contains(t, err.Error(), "add in-progress label") -} - -// --- Dispatch: GetIssue returns issue with existing labels not matching --- - -func TestDispatch_Execute_Good_IssueFoundNoSpecialLabels(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - {"id": 2, "name": "agent-ready", "color": "#00ff00"}, - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "in-progress"}) - // GetIssue returns issue with unrelated labels - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 5, "number": 5, "title": "Test Issue", - "labels": []map[string]any{ - {"id": 10, "name": "enhancement"}, - }, - }) - case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - _ = json.NewEncoder(w).Encode(map[string]any{"id": 5, "number": 5}) - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "name": "in-progress"}}) - case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/labels/"): - w.WriteHeader(http.StatusNoContent) - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/issues/5/comments"): - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "body": "dispatched"}) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/nonexistent-queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 1, - ChildNumber: 5, - PRNumber: 10, - RepoOwner: "org", - RepoName: "repo", - Assignee: "darbs-claude", - IssueTitle: "Test Issue", - IssueBody: "Test body", - } - - // Execute will proceed past label check and try SSH (which fails). - result, err := h.Execute(context.Background(), signal) - // Should either succeed (if somehow SSH works) or fail at secureTransfer. - _ = result - _ = err -} diff --git a/jobrunner/handlers/dispatch.go b/jobrunner/handlers/dispatch.go deleted file mode 100644 index e16ae95..0000000 --- a/jobrunner/handlers/dispatch.go +++ /dev/null @@ -1,293 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "path/filepath" - "time" - - "forge.lthn.ai/core/go-scm/agentci" - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" - "forge.lthn.ai/core/go/pkg/log" -) - -const ( - LabelAgentReady = "agent-ready" - LabelInProgress = "in-progress" - LabelAgentFailed = "agent-failed" - LabelAgentComplete = "agent-completed" - - ColorInProgress = "#1d76db" // Blue - ColorAgentFailed = "#c0392b" // Red -) - -// DispatchTicket is the JSON payload written to the agent's queue. -// The ForgeToken is transferred separately via a .env file with 0600 permissions. -type DispatchTicket struct { - ID string `json:"id"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` - IssueNumber int `json:"issue_number"` - IssueTitle string `json:"issue_title"` - IssueBody string `json:"issue_body"` - TargetBranch string `json:"target_branch"` - EpicNumber int `json:"epic_number"` - ForgeURL string `json:"forge_url"` - ForgeUser string `json:"forgejo_user"` - Model string `json:"model,omitempty"` - Runner string `json:"runner,omitempty"` - VerifyModel string `json:"verify_model,omitempty"` - DualRun bool `json:"dual_run"` - CreatedAt string `json:"created_at"` -} - -// DispatchHandler dispatches coding work to remote agent machines via SSH. -type DispatchHandler struct { - forge *forge.Client - forgeURL string - token string - spinner *agentci.Spinner -} - -// NewDispatchHandler creates a handler that dispatches tickets to agent machines. -func NewDispatchHandler(client *forge.Client, forgeURL, token string, spinner *agentci.Spinner) *DispatchHandler { - return &DispatchHandler{ - forge: client, - forgeURL: forgeURL, - token: token, - spinner: spinner, - } -} - -// Name returns the handler identifier. -func (h *DispatchHandler) Name() string { - return "dispatch" -} - -// Match returns true for signals where a child issue needs coding (no PR yet) -// and the assignee is a known agent (by config key or Forgejo username). -func (h *DispatchHandler) Match(signal *jobrunner.PipelineSignal) bool { - if !signal.NeedsCoding { - return false - } - _, _, ok := h.spinner.FindByForgejoUser(signal.Assignee) - return ok -} - -// Execute creates a ticket JSON and transfers it securely to the agent's queue directory. -func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) { - start := time.Now() - - agentName, agent, ok := h.spinner.FindByForgejoUser(signal.Assignee) - if !ok { - return nil, fmt.Errorf("handlers.Dispatch.Execute: unknown agent: %s", signal.Assignee) - } - - // Sanitize inputs to prevent path traversal. - safeOwner, err := agentci.SanitizePath(signal.RepoOwner) - if err != nil { - return nil, fmt.Errorf("invalid repo owner: %w", err) - } - safeRepo, err := agentci.SanitizePath(signal.RepoName) - if err != nil { - return nil, fmt.Errorf("invalid repo name: %w", err) - } - - // Ensure in-progress label exists on repo. - inProgressLabel, err := h.forge.EnsureLabel(safeOwner, safeRepo, LabelInProgress, ColorInProgress) - if err != nil { - return nil, fmt.Errorf("ensure label %s: %w", LabelInProgress, err) - } - - // Check if already in progress to prevent double-dispatch. - issue, err := h.forge.GetIssue(safeOwner, safeRepo, int64(signal.ChildNumber)) - if err == nil { - for _, l := range issue.Labels { - if l.Name == LabelInProgress || l.Name == LabelAgentComplete { - log.Info("issue already processed, skipping", "issue", signal.ChildNumber, "label", l.Name) - return &jobrunner.ActionResult{ - Action: "dispatch", - Success: true, - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil - } - } - } - - // Assign agent and add in-progress label. - if err := h.forge.AssignIssue(safeOwner, safeRepo, int64(signal.ChildNumber), []string{signal.Assignee}); err != nil { - log.Warn("failed to assign agent, continuing", "err", err) - } - - if err := h.forge.AddIssueLabels(safeOwner, safeRepo, int64(signal.ChildNumber), []int64{inProgressLabel.ID}); err != nil { - return nil, fmt.Errorf("add in-progress label: %w", err) - } - - // Remove agent-ready label if present. - if readyLabel, err := h.forge.GetLabelByName(safeOwner, safeRepo, LabelAgentReady); err == nil { - _ = h.forge.RemoveIssueLabel(safeOwner, safeRepo, int64(signal.ChildNumber), readyLabel.ID) - } - - // Clotho planning — determine execution mode. - runMode := h.spinner.DeterminePlan(signal, agentName) - verifyModel := "" - if runMode == agentci.ModeDual { - verifyModel = h.spinner.GetVerifierModel(agentName) - } - - // Build ticket — resolve target branch from repo default. - targetBranch := "main" - if repo, err := h.forge.GetRepo(safeOwner, safeRepo); err == nil && repo.DefaultBranch != "" { - targetBranch = repo.DefaultBranch - } - ticketID := fmt.Sprintf("%s-%s-%d-%d", safeOwner, safeRepo, signal.ChildNumber, time.Now().Unix()) - - ticket := DispatchTicket{ - ID: ticketID, - RepoOwner: safeOwner, - RepoName: safeRepo, - IssueNumber: signal.ChildNumber, - IssueTitle: signal.IssueTitle, - IssueBody: signal.IssueBody, - TargetBranch: targetBranch, - EpicNumber: signal.EpicNumber, - ForgeURL: h.forgeURL, - ForgeUser: signal.Assignee, - Model: agent.Model, - Runner: agent.Runner, - VerifyModel: verifyModel, - DualRun: runMode == agentci.ModeDual, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } - - ticketJSON, err := json.MarshalIndent(ticket, "", " ") - if err != nil { - h.failDispatch(signal, "Failed to marshal ticket JSON") - return nil, fmt.Errorf("marshal ticket: %w", err) - } - - // Check if ticket already exists on agent (dedup). - ticketName := fmt.Sprintf("ticket-%s-%s-%d.json", safeOwner, safeRepo, signal.ChildNumber) - if h.ticketExists(ctx, agent, ticketName) { - log.Info("ticket already queued, skipping", "ticket", ticketName, "agent", signal.Assignee) - return &jobrunner.ActionResult{ - Action: "dispatch", - RepoOwner: safeOwner, - RepoName: safeRepo, - EpicNumber: signal.EpicNumber, - ChildNumber: signal.ChildNumber, - Success: true, - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil - } - - // Transfer ticket JSON. - remoteTicketPath := filepath.Join(agent.QueueDir, ticketName) - if err := h.secureTransfer(ctx, agent, remoteTicketPath, ticketJSON, 0644); err != nil { - h.failDispatch(signal, fmt.Sprintf("Ticket transfer failed: %v", err)) - return &jobrunner.ActionResult{ - Action: "dispatch", - RepoOwner: safeOwner, - RepoName: safeRepo, - EpicNumber: signal.EpicNumber, - ChildNumber: signal.ChildNumber, - Success: false, - Error: fmt.Sprintf("transfer ticket: %v", err), - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil - } - - // Transfer token via separate .env file with 0600 permissions. - envContent := fmt.Sprintf("FORGE_TOKEN=%s\n", h.token) - remoteEnvPath := filepath.Join(agent.QueueDir, fmt.Sprintf(".env.%s", ticketID)) - if err := h.secureTransfer(ctx, agent, remoteEnvPath, []byte(envContent), 0600); err != nil { - // Clean up the ticket if env transfer fails. - _ = h.runRemote(ctx, agent, fmt.Sprintf("rm -f %s", agentci.EscapeShellArg(remoteTicketPath))) - h.failDispatch(signal, fmt.Sprintf("Token transfer failed: %v", err)) - return &jobrunner.ActionResult{ - Action: "dispatch", - RepoOwner: safeOwner, - RepoName: safeRepo, - EpicNumber: signal.EpicNumber, - ChildNumber: signal.ChildNumber, - Success: false, - Error: fmt.Sprintf("transfer token: %v", err), - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil - } - - // Comment on issue. - modeStr := "Standard" - if runMode == agentci.ModeDual { - modeStr = "Clotho Verified (Dual Run)" - } - comment := fmt.Sprintf("Dispatched to **%s** agent queue.\nMode: **%s**", signal.Assignee, modeStr) - _ = h.forge.CreateIssueComment(safeOwner, safeRepo, int64(signal.ChildNumber), comment) - - return &jobrunner.ActionResult{ - Action: "dispatch", - RepoOwner: safeOwner, - RepoName: safeRepo, - EpicNumber: signal.EpicNumber, - ChildNumber: signal.ChildNumber, - Success: true, - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil -} - -// failDispatch handles cleanup when dispatch fails (adds failed label, removes in-progress). -func (h *DispatchHandler) failDispatch(signal *jobrunner.PipelineSignal, reason string) { - if failedLabel, err := h.forge.EnsureLabel(signal.RepoOwner, signal.RepoName, LabelAgentFailed, ColorAgentFailed); err == nil { - _ = h.forge.AddIssueLabels(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), []int64{failedLabel.ID}) - } - - if inProgressLabel, err := h.forge.GetLabelByName(signal.RepoOwner, signal.RepoName, LabelInProgress); err == nil { - _ = h.forge.RemoveIssueLabel(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), inProgressLabel.ID) - } - - _ = h.forge.CreateIssueComment(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), fmt.Sprintf("Agent dispatch failed: %s", reason)) -} - -// secureTransfer writes data to a remote path via SSH stdin, preventing command injection. -func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.AgentConfig, remotePath string, data []byte, mode int) error { - safeRemotePath := agentci.EscapeShellArg(remotePath) - remoteCmd := fmt.Sprintf("cat > %s && chmod %o %s", safeRemotePath, mode, safeRemotePath) - - cmd := agentci.SecureSSHCommandContext(ctx, agent.Host, remoteCmd) - cmd.Stdin = bytes.NewReader(data) - - output, err := cmd.CombinedOutput() - if err != nil { - return log.E("dispatch.transfer", fmt.Sprintf("ssh to %s failed: %s", agent.Host, string(output)), err) - } - return nil -} - -// runRemote executes a command on the agent via SSH. -func (h *DispatchHandler) runRemote(ctx context.Context, agent agentci.AgentConfig, cmdStr string) error { - cmd := agentci.SecureSSHCommandContext(ctx, agent.Host, cmdStr) - return cmd.Run() -} - -// ticketExists checks if a ticket file already exists in queue, active, or done. -func (h *DispatchHandler) ticketExists(ctx context.Context, agent agentci.AgentConfig, ticketName string) bool { - safeTicket, err := agentci.SanitizePath(ticketName) - if err != nil { - return false - } - qDir := agent.QueueDir - checkCmd := fmt.Sprintf( - "test -f %s/%s || test -f %s/../active/%s || test -f %s/../done/%s", - qDir, safeTicket, qDir, safeTicket, qDir, safeTicket, - ) - cmd := agentci.SecureSSHCommandContext(ctx, agent.Host, checkCmd) - return cmd.Run() == nil -} diff --git a/jobrunner/handlers/dispatch_test.go b/jobrunner/handlers/dispatch_test.go deleted file mode 100644 index 8456d38..0000000 --- a/jobrunner/handlers/dispatch_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "forge.lthn.ai/core/go-scm/agentci" - "forge.lthn.ai/core/go-scm/jobrunner" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// newTestSpinner creates a Spinner with the given agents for testing. -func newTestSpinner(agents map[string]agentci.AgentConfig) *agentci.Spinner { - return agentci.NewSpinner(agentci.ClothoConfig{Strategy: "direct"}, agents) -} - -// --- Match tests --- - -func TestDispatch_Match_Good_NeedsCoding(t *testing.T) { - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "claude@192.168.0.201", QueueDir: "~/ai-work/queue", Active: true}, - }) - h := NewDispatchHandler(nil, "", "", spinner) - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - } - assert.True(t, h.Match(sig)) -} - -func TestDispatch_Match_Good_MultipleAgents(t *testing.T) { - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "claude@192.168.0.201", QueueDir: "~/ai-work/queue", Active: true}, - "local-codex": {Host: "localhost", QueueDir: "~/ai-work/queue", Active: true}, - }) - h := NewDispatchHandler(nil, "", "", spinner) - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "local-codex", - } - assert.True(t, h.Match(sig)) -} - -func TestDispatch_Match_Bad_HasPR(t *testing.T) { - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "claude@192.168.0.201", QueueDir: "~/ai-work/queue", Active: true}, - }) - h := NewDispatchHandler(nil, "", "", spinner) - sig := &jobrunner.PipelineSignal{ - NeedsCoding: false, - PRNumber: 7, - Assignee: "darbs-claude", - } - assert.False(t, h.Match(sig)) -} - -func TestDispatch_Match_Bad_UnknownAgent(t *testing.T) { - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "claude@192.168.0.201", QueueDir: "~/ai-work/queue", Active: true}, - }) - h := NewDispatchHandler(nil, "", "", spinner) - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "unknown-user", - } - assert.False(t, h.Match(sig)) -} - -func TestDispatch_Match_Bad_NotAssigned(t *testing.T) { - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "claude@192.168.0.201", QueueDir: "~/ai-work/queue", Active: true}, - }) - h := NewDispatchHandler(nil, "", "", spinner) - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "", - } - assert.False(t, h.Match(sig)) -} - -func TestDispatch_Match_Bad_EmptyAgentMap(t *testing.T) { - spinner := newTestSpinner(map[string]agentci.AgentConfig{}) - h := NewDispatchHandler(nil, "", "", spinner) - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - } - assert.False(t, h.Match(sig)) -} - -// --- Name test --- - -func TestDispatch_Name_Good(t *testing.T) { - spinner := newTestSpinner(nil) - h := NewDispatchHandler(nil, "", "", spinner) - assert.Equal(t, "dispatch", h.Name()) -} - -// --- Execute tests --- - -func TestDispatch_Execute_Bad_UnknownAgent(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "claude@192.168.0.201", QueueDir: "~/ai-work/queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "nonexistent-agent", - RepoOwner: "host-uk", - RepoName: "core", - ChildNumber: 1, - } - - _, err := h.Execute(context.Background(), sig) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown agent") -} - -func TestDispatch_TicketJSON_Good(t *testing.T) { - ticket := DispatchTicket{ - ID: "host-uk-core-5-1234567890", - RepoOwner: "host-uk", - RepoName: "core", - IssueNumber: 5, - IssueTitle: "Fix the thing", - IssueBody: "Please fix this bug", - TargetBranch: "new", - EpicNumber: 3, - ForgeURL: "https://forge.lthn.ai", - ForgeUser: "darbs-claude", - Model: "sonnet", - Runner: "claude", - DualRun: false, - CreatedAt: "2026-02-09T12:00:00Z", - } - - data, err := json.MarshalIndent(ticket, "", " ") - require.NoError(t, err) - - var decoded map[string]any - err = json.Unmarshal(data, &decoded) - require.NoError(t, err) - - assert.Equal(t, "host-uk-core-5-1234567890", decoded["id"]) - assert.Equal(t, "host-uk", decoded["repo_owner"]) - assert.Equal(t, "core", decoded["repo_name"]) - assert.Equal(t, float64(5), decoded["issue_number"]) - assert.Equal(t, "Fix the thing", decoded["issue_title"]) - assert.Equal(t, "Please fix this bug", decoded["issue_body"]) - assert.Equal(t, "new", decoded["target_branch"]) - assert.Equal(t, float64(3), decoded["epic_number"]) - assert.Equal(t, "https://forge.lthn.ai", decoded["forge_url"]) - assert.Equal(t, "darbs-claude", decoded["forgejo_user"]) - assert.Equal(t, "sonnet", decoded["model"]) - assert.Equal(t, "claude", decoded["runner"]) - // Token should NOT be present in the ticket. - _, hasToken := decoded["forge_token"] - assert.False(t, hasToken, "forge_token must not be in ticket JSON") -} - -func TestDispatch_TicketJSON_Good_DualRun(t *testing.T) { - ticket := DispatchTicket{ - ID: "test-dual", - RepoOwner: "host-uk", - RepoName: "core", - IssueNumber: 1, - ForgeURL: "https://forge.lthn.ai", - Model: "gemini-2.0-flash", - VerifyModel: "gemini-1.5-pro", - DualRun: true, - } - - data, err := json.Marshal(ticket) - require.NoError(t, err) - - var roundtrip DispatchTicket - err = json.Unmarshal(data, &roundtrip) - require.NoError(t, err) - assert.True(t, roundtrip.DualRun) - assert.Equal(t, "gemini-1.5-pro", roundtrip.VerifyModel) -} - -func TestDispatch_TicketJSON_Good_OmitsEmptyModelRunner(t *testing.T) { - ticket := DispatchTicket{ - ID: "test-1", - RepoOwner: "host-uk", - RepoName: "core", - IssueNumber: 1, - TargetBranch: "new", - ForgeURL: "https://forge.lthn.ai", - } - - data, err := json.MarshalIndent(ticket, "", " ") - require.NoError(t, err) - - var decoded map[string]any - err = json.Unmarshal(data, &decoded) - require.NoError(t, err) - - _, hasModel := decoded["model"] - _, hasRunner := decoded["runner"] - assert.False(t, hasModel, "model should be omitted when empty") - assert.False(t, hasRunner, "runner should be omitted when empty") -} - -func TestDispatch_TicketJSON_Good_ModelRunnerVariants(t *testing.T) { - tests := []struct { - name string - model string - runner string - }{ - {"claude-sonnet", "sonnet", "claude"}, - {"claude-opus", "opus", "claude"}, - {"codex-default", "", "codex"}, - {"gemini-default", "", "gemini"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ticket := DispatchTicket{ - ID: "test-" + tt.name, - RepoOwner: "host-uk", - RepoName: "core", - IssueNumber: 1, - TargetBranch: "new", - ForgeURL: "https://forge.lthn.ai", - Model: tt.model, - Runner: tt.runner, - } - - data, err := json.Marshal(ticket) - require.NoError(t, err) - - var roundtrip DispatchTicket - err = json.Unmarshal(data, &roundtrip) - require.NoError(t, err) - assert.Equal(t, tt.model, roundtrip.Model) - assert.Equal(t, tt.runner, roundtrip.Runner) - }) - } -} - -func TestDispatch_Execute_Good_PostsComment(t *testing.T) { - var commentPosted bool - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/host-uk/core/labels": - json.NewEncoder(w).Encode([]any{}) - return - - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/host-uk/core/labels": - json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "in-progress", "color": "#1d76db"}) - return - - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/host-uk/core/issues/5": - json.NewEncoder(w).Encode(map[string]any{"id": 5, "number": 5, "labels": []any{}, "title": "Test"}) - return - - case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/host-uk/core/issues/5": - json.NewEncoder(w).Encode(map[string]any{"id": 5, "number": 5}) - return - - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/host-uk/core/issues/5/labels": - json.NewEncoder(w).Encode([]any{map[string]any{"id": 1, "name": "in-progress"}}) - return - - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/host-uk/core/issues/5/comments": - commentPosted = true - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - commentBody = body["body"] - json.NewEncoder(w).Encode(map[string]any{"id": 1, "body": body["body"]}) - return - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]any{}) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/nonexistent-queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "host-uk", - RepoName: "core", - ChildNumber: 5, - EpicNumber: 3, - IssueTitle: "Test issue", - IssueBody: "Test body", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.Equal(t, "dispatch", result.Action) - assert.Equal(t, "host-uk", result.RepoOwner) - assert.Equal(t, "core", result.RepoName) - assert.Equal(t, 3, result.EpicNumber) - assert.Equal(t, 5, result.ChildNumber) - - if result.Success { - assert.True(t, commentPosted) - assert.Contains(t, commentBody, "darbs-claude") - } -} diff --git a/jobrunner/handlers/enable_auto_merge.go b/jobrunner/handlers/enable_auto_merge.go deleted file mode 100644 index dc919e7..0000000 --- a/jobrunner/handlers/enable_auto_merge.go +++ /dev/null @@ -1,58 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "time" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// EnableAutoMergeHandler merges a PR that is ready using squash strategy. -type EnableAutoMergeHandler struct { - forge *forge.Client -} - -// NewEnableAutoMergeHandler creates a handler that merges ready PRs. -func NewEnableAutoMergeHandler(f *forge.Client) *EnableAutoMergeHandler { - return &EnableAutoMergeHandler{forge: f} -} - -// Name returns the handler identifier. -func (h *EnableAutoMergeHandler) Name() string { - return "enable_auto_merge" -} - -// Match returns true when the PR is open, not a draft, mergeable, checks -// are passing, and there are no unresolved review threads. -func (h *EnableAutoMergeHandler) Match(signal *jobrunner.PipelineSignal) bool { - return signal.PRState == "OPEN" && - !signal.IsDraft && - signal.Mergeable == "MERGEABLE" && - signal.CheckStatus == "SUCCESS" && - !signal.HasUnresolvedThreads() -} - -// Execute merges the pull request with squash strategy. -func (h *EnableAutoMergeHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) { - start := time.Now() - - err := h.forge.MergePullRequest(signal.RepoOwner, signal.RepoName, int64(signal.PRNumber), "squash") - - result := &jobrunner.ActionResult{ - Action: "enable_auto_merge", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - PRNumber: signal.PRNumber, - Success: err == nil, - Timestamp: time.Now(), - Duration: time.Since(start), - } - - if err != nil { - result.Error = fmt.Sprintf("merge failed: %v", err) - } - - return result, nil -} diff --git a/jobrunner/handlers/enable_auto_merge_test.go b/jobrunner/handlers/enable_auto_merge_test.go deleted file mode 100644 index a85130e..0000000 --- a/jobrunner/handlers/enable_auto_merge_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -func TestEnableAutoMerge_Match_Good(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - IsDraft: false, - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - ThreadsTotal: 0, - ThreadsResolved: 0, - } - assert.True(t, h.Match(sig)) -} - -func TestEnableAutoMerge_Match_Bad_Draft(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - IsDraft: true, - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - ThreadsTotal: 0, - ThreadsResolved: 0, - } - assert.False(t, h.Match(sig)) -} - -func TestEnableAutoMerge_Match_Bad_UnresolvedThreads(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - IsDraft: false, - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - ThreadsTotal: 5, - ThreadsResolved: 3, - } - assert.False(t, h.Match(sig)) -} - -func TestEnableAutoMerge_Execute_Good(t *testing.T) { - var capturedPath string - var capturedMethod string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedMethod = r.Method - capturedPath = r.URL.Path - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - h := NewEnableAutoMergeHandler(client) - sig := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-php", - PRNumber: 55, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "enable_auto_merge", result.Action) - assert.Equal(t, http.MethodPost, capturedMethod) - assert.Equal(t, "/api/v1/repos/host-uk/core-php/pulls/55/merge", capturedPath) -} - -func TestEnableAutoMerge_Execute_Bad_MergeFailed(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusConflict) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "merge conflict"}) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - h := NewEnableAutoMergeHandler(client) - sig := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-php", - PRNumber: 55, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.False(t, result.Success) - assert.Contains(t, result.Error, "merge failed") -} diff --git a/jobrunner/handlers/handlers_extra_test.go b/jobrunner/handlers/handlers_extra_test.go deleted file mode 100644 index be9cd25..0000000 --- a/jobrunner/handlers/handlers_extra_test.go +++ /dev/null @@ -1,583 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/agentci" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// --- Name tests for all handlers --- - -func TestEnableAutoMerge_Name_Good(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - assert.Equal(t, "enable_auto_merge", h.Name()) -} - -func TestPublishDraft_Name_Good(t *testing.T) { - h := NewPublishDraftHandler(nil) - assert.Equal(t, "publish_draft", h.Name()) -} - -func TestDismissReviews_Name_Good(t *testing.T) { - h := NewDismissReviewsHandler(nil) - assert.Equal(t, "dismiss_reviews", h.Name()) -} - -func TestSendFixCommand_Name_Good(t *testing.T) { - h := NewSendFixCommandHandler(nil) - assert.Equal(t, "send_fix_command", h.Name()) -} - -func TestTickParent_Name_Good(t *testing.T) { - h := NewTickParentHandler(nil) - assert.Equal(t, "tick_parent", h.Name()) -} - -// --- Additional Match tests --- - -func TestEnableAutoMerge_Match_Bad_Closed(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "CLOSED", - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - } - assert.False(t, h.Match(sig)) -} - -func TestEnableAutoMerge_Match_Bad_ChecksFailing(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - Mergeable: "MERGEABLE", - CheckStatus: "FAILURE", - } - assert.False(t, h.Match(sig)) -} - -func TestEnableAutoMerge_Match_Bad_Conflicting(t *testing.T) { - h := NewEnableAutoMergeHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - Mergeable: "CONFLICTING", - CheckStatus: "SUCCESS", - } - assert.False(t, h.Match(sig)) -} - -func TestPublishDraft_Match_Bad_Closed(t *testing.T) { - h := NewPublishDraftHandler(nil) - sig := &jobrunner.PipelineSignal{ - IsDraft: true, - PRState: "CLOSED", - CheckStatus: "SUCCESS", - } - assert.False(t, h.Match(sig)) -} - -func TestDismissReviews_Match_Bad_Closed(t *testing.T) { - h := NewDismissReviewsHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "CLOSED", - ThreadsTotal: 3, - ThreadsResolved: 1, - } - assert.False(t, h.Match(sig)) -} - -func TestDismissReviews_Match_Bad_NoThreads(t *testing.T) { - h := NewDismissReviewsHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - ThreadsTotal: 0, - ThreadsResolved: 0, - } - assert.False(t, h.Match(sig)) -} - -func TestSendFixCommand_Match_Bad_Closed(t *testing.T) { - h := NewSendFixCommandHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "CLOSED", - Mergeable: "CONFLICTING", - } - assert.False(t, h.Match(sig)) -} - -func TestSendFixCommand_Match_Bad_NoIssues(t *testing.T) { - h := NewSendFixCommandHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - } - assert.False(t, h.Match(sig)) -} - -func TestSendFixCommand_Match_Good_ThreadsFailure(t *testing.T) { - h := NewSendFixCommandHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - Mergeable: "MERGEABLE", - CheckStatus: "FAILURE", - ThreadsTotal: 2, - ThreadsResolved: 0, - } - assert.True(t, h.Match(sig)) -} - -func TestTickParent_Match_Bad_Closed(t *testing.T) { - h := NewTickParentHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "CLOSED", - } - assert.False(t, h.Match(sig)) -} - -// --- Additional Execute tests --- - -func TestPublishDraft_Execute_Bad_ServerError(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewPublishDraftHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.False(t, result.Success) - assert.Contains(t, result.Error, "publish draft failed") -} - -func TestSendFixCommand_Execute_Good_Reviews(t *testing.T) { - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost { - b, _ := io.ReadAll(r.Body) - capturedBody = string(b) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":1}`)) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewSendFixCommandHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 5, - PRState: "OPEN", - Mergeable: "MERGEABLE", - CheckStatus: "FAILURE", - ThreadsTotal: 2, - ThreadsResolved: 0, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) - assert.Contains(t, capturedBody, "fix the code reviews") -} - -func TestSendFixCommand_Execute_Bad_CommentFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewSendFixCommandHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - Mergeable: "CONFLICTING", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.False(t, result.Success) - assert.Contains(t, result.Error, "post comment failed") -} - -func TestTickParent_Execute_Good_AlreadyTicked(t *testing.T) { - epicBody := "## Tasks\n- [x] #7\n- [ ] #8\n" - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/42") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": epicBody, - "title": "Epic", - }) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewTickParentHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - PRState: "MERGED", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) - assert.Equal(t, "tick_parent", result.Action) -} - -func TestTickParent_Execute_Bad_FetchEpicFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewTickParentHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 999, - ChildNumber: 1, - PRState: "MERGED", - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "fetch epic") -} - -func TestTickParent_Execute_Bad_EditEpicFails(t *testing.T) { - epicBody := "## Tasks\n- [ ] #7\n" - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/42"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": epicBody, - "title": "Epic", - }) - case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/issues/42"): - w.WriteHeader(http.StatusInternalServerError) - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewTickParentHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - PRState: "MERGED", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.False(t, result.Success) - assert.Contains(t, result.Error, "edit epic failed") -} - -func TestTickParent_Execute_Bad_CloseChildFails(t *testing.T) { - epicBody := "## Tasks\n- [ ] #7\n" - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/42"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": epicBody, - "title": "Epic", - }) - case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/issues/42"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": strings.Replace(epicBody, "- [ ] #7", "- [x] #7", 1), - "title": "Epic", - }) - case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/issues/7"): - w.WriteHeader(http.StatusInternalServerError) - default: - w.WriteHeader(http.StatusOK) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewTickParentHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - PRState: "MERGED", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.False(t, result.Success) - assert.Contains(t, result.Error, "close child issue failed") -} - -func TestDismissReviews_Execute_Bad_ListFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewDismissReviewsHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "list reviews") -} - -func TestDismissReviews_Execute_Good_NothingToDismiss(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodGet { - // All reviews are either approved or already dismissed. - reviews := []map[string]any{ - { - "id": 1, "state": "APPROVED", "dismissed": false, "stale": false, - "body": "lgtm", "commit_id": "abc123", - }, - { - "id": 2, "state": "REQUEST_CHANGES", "dismissed": true, "stale": true, - "body": "already dismissed", "commit_id": "abc123", - }, - { - "id": 3, "state": "REQUEST_CHANGES", "dismissed": false, "stale": false, - "body": "not stale", "commit_id": "abc123", - }, - } - _ = json.NewEncoder(w).Encode(reviews) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewDismissReviewsHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success, "nothing to dismiss should be success") -} - -func TestDismissReviews_Execute_Bad_DismissFails(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodGet { - reviews := []map[string]any{ - { - "id": 1, "state": "REQUEST_CHANGES", "dismissed": false, "stale": true, - "body": "fix it", "commit_id": "abc123", - }, - } - _ = json.NewEncoder(w).Encode(reviews) - return - } - - // Dismiss fails. - w.WriteHeader(http.StatusForbidden) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewDismissReviewsHandler(client) - - sig := &jobrunner.PipelineSignal{ - RepoOwner: "org", - RepoName: "repo", - PRNumber: 1, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.False(t, result.Success) - assert.Contains(t, result.Error, "failed to dismiss") -} - -// --- Dispatch Execute edge cases --- - -func TestDispatch_Execute_Good_AlreadyInProgress(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "in-progress"}) - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - // Issue already has in-progress label. - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 5, - "number": 5, - "labels": []map[string]any{{"name": "in-progress", "id": 1}}, - "title": "Test", - }) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 5, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success, "already in-progress should be a no-op success") -} - -func TestDispatch_Execute_Good_AlreadyCompleted(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 2, "name": "agent-completed", "color": "#0e8a16"}, - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/org/repo/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "in-progress"}) - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/org/repo/issues/5": - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 5, - "number": 5, - "labels": []map[string]any{{"name": "agent-completed", "id": 2}}, - "title": "Done", - }) - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "org", - RepoName: "repo", - ChildNumber: 5, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - assert.True(t, result.Success) -} - -func TestDispatch_Execute_Bad_InvalidRepoOwner(t *testing.T) { - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - spinner := newTestSpinner(map[string]agentci.AgentConfig{ - "darbs-claude": {Host: "localhost", QueueDir: "/tmp/queue", Active: true}, - }) - h := NewDispatchHandler(client, srv.URL, "test-token", spinner) - - sig := &jobrunner.PipelineSignal{ - NeedsCoding: true, - Assignee: "darbs-claude", - RepoOwner: "org$bad", - RepoName: "repo", - ChildNumber: 1, - } - - _, err := h.Execute(context.Background(), sig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid repo owner") -} diff --git a/jobrunner/handlers/integration_test.go b/jobrunner/handlers/integration_test.go deleted file mode 100644 index 65a73bc..0000000 --- a/jobrunner/handlers/integration_test.go +++ /dev/null @@ -1,824 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// --- Integration: full signal -> handler -> result flow --- -// These tests exercise the complete pipeline: a signal is created, -// matched by a handler, executed against a mock Forgejo server, -// and the result is verified. - -// mockForgejoServer creates a comprehensive mock Forgejo API server -// for integration testing. It supports issues, PRs, labels, comments, -// and tracks all API calls made. -type apiCall struct { - Method string - Path string - Body string -} - -type forgejoMock struct { - epicBody string - calls []apiCall - srv *httptest.Server - closedChild bool - editedBody string - comments []string -} - -func newForgejoMock(t *testing.T, epicBody string) *forgejoMock { - t.Helper() - m := &forgejoMock{ - epicBody: epicBody, - } - - m.srv = httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bodyBytes, _ := io.ReadAll(r.Body) - m.calls = append(m.calls, apiCall{ - Method: r.Method, - Path: r.URL.Path, - Body: string(bodyBytes), - }) - - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - - switch { - // GET epic issue. - case r.Method == http.MethodGet && strings.Contains(path, "/issues/") && !strings.Contains(path, "/comments"): - issueNum := path[strings.LastIndex(path, "/")+1:] - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": json.Number(issueNum), - "body": m.epicBody, - "title": "Epic: Phase 3", - "state": "open", - "labels": []map[string]any{{"name": "epic", "id": 1}}, - }) - - // PATCH epic issue (edit body or close child). - case r.Method == http.MethodPatch && strings.Contains(path, "/issues/"): - var body map[string]any - _ = json.Unmarshal(bodyBytes, &body) - - if bodyStr, ok := body["body"].(string); ok { - m.editedBody = bodyStr - } - if state, ok := body["state"].(string); ok && state == "closed" { - m.closedChild = true - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 1, - "body": m.editedBody, - "state": "open", - }) - - // POST comment. - case r.Method == http.MethodPost && strings.Contains(path, "/comments"): - var body map[string]string - _ = json.Unmarshal(bodyBytes, &body) - m.comments = append(m.comments, body["body"]) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "body": body["body"]}) - - // GET labels. - case r.Method == http.MethodGet && strings.Contains(path, "/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "epic", "color": "#ff0000"}, - {"id": 2, "name": "in-progress", "color": "#1d76db"}, - }) - - // POST labels. - case r.Method == http.MethodPost && strings.HasSuffix(path, "/labels"): - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "name": "new-label"}) - - // POST issue labels. - case r.Method == http.MethodPost && strings.Contains(path, "/issues/") && strings.Contains(path, "/labels"): - _ = json.NewEncoder(w).Encode([]map[string]any{}) - - // DELETE issue label. - case r.Method == http.MethodDelete && strings.Contains(path, "/labels/"): - w.WriteHeader(http.StatusNoContent) - - // POST merge PR. - case r.Method == http.MethodPost && strings.Contains(path, "/merge"): - w.WriteHeader(http.StatusOK) - - // PATCH PR (publish draft). - case r.Method == http.MethodPatch && strings.Contains(path, "/pulls/"): - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{}`)) - - // GET reviews. - case r.Method == http.MethodGet && strings.Contains(path, "/reviews"): - _ = json.NewEncoder(w).Encode([]map[string]any{}) - - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - - return m -} - -func (m *forgejoMock) close() { - m.srv.Close() -} - -func (m *forgejoMock) client(t *testing.T) *forge.Client { - t.Helper() - c, err := forge.New(m.srv.URL, "test-token") - require.NoError(t, err) - return c -} - -// --- TickParent integration: signal -> execute -> verify epic updated --- - -func TestIntegration_TickParent_Good_FullFlow(t *testing.T) { - epicBody := "## Tasks\n- [x] #1\n- [ ] #7\n- [ ] #8\n- [x] #3\n" - - mock := newForgejoMock(t, epicBody) - defer mock.close() - - h := NewTickParentHandler(mock.client(t)) - - // Create signal representing a merged PR for child #7. - signal := &jobrunner.PipelineSignal{ - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - RepoOwner: "host-uk", - RepoName: "core-php", - PRState: "MERGED", - CheckStatus: "SUCCESS", - Mergeable: "UNKNOWN", - } - - // Verify the handler matches. - assert.True(t, h.Match(signal)) - - // Execute. - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - // Verify result. - assert.True(t, result.Success) - assert.Equal(t, "tick_parent", result.Action) - assert.Equal(t, "host-uk", result.RepoOwner) - assert.Equal(t, "core-php", result.RepoName) - assert.Equal(t, 99, result.PRNumber) - - // Verify the epic body was updated: #7 should now be checked. - assert.Contains(t, mock.editedBody, "- [x] #7") - // #8 should still be unchecked. - assert.Contains(t, mock.editedBody, "- [ ] #8") - // #1 and #3 should remain checked. - assert.Contains(t, mock.editedBody, "- [x] #1") - assert.Contains(t, mock.editedBody, "- [x] #3") - - // Verify the child issue was closed. - assert.True(t, mock.closedChild) -} - -// --- TickParent integration: epic progress tracking --- - -func TestIntegration_TickParent_Good_TrackEpicProgress(t *testing.T) { - // Start with 4 tasks, 1 checked. - epicBody := "## Tasks\n- [x] #1\n- [ ] #2\n- [ ] #3\n- [ ] #4\n" - - mock := newForgejoMock(t, epicBody) - defer mock.close() - - h := NewTickParentHandler(mock.client(t)) - - // Tick child #2. - signal := &jobrunner.PipelineSignal{ - EpicNumber: 10, - ChildNumber: 2, - PRNumber: 20, - RepoOwner: "org", - RepoName: "repo", - PRState: "MERGED", - } - - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - assert.True(t, result.Success) - - // Verify #2 is now checked. - assert.Contains(t, mock.editedBody, "- [x] #2") - // #3 and #4 should still be unchecked. - assert.Contains(t, mock.editedBody, "- [ ] #3") - assert.Contains(t, mock.editedBody, "- [ ] #4") - - // Count progress: 2 out of 4 now checked. - checked := strings.Count(mock.editedBody, "- [x]") - unchecked := strings.Count(mock.editedBody, "- [ ]") - assert.Equal(t, 2, checked) - assert.Equal(t, 2, unchecked) -} - -// --- EnableAutoMerge integration: full flow --- - -func TestIntegration_EnableAutoMerge_Good_FullFlow(t *testing.T) { - var mergeMethod string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/merge") { - bodyBytes, _ := io.ReadAll(r.Body) - var body map[string]any - _ = json.Unmarshal(bodyBytes, &body) - if do, ok := body["Do"].(string); ok { - mergeMethod = do - } - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewEnableAutoMergeHandler(client) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 1, - ChildNumber: 5, - PRNumber: 42, - RepoOwner: "host-uk", - RepoName: "core-tenant", - PRState: "OPEN", - IsDraft: false, - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - } - - // Verify match. - assert.True(t, h.Match(signal)) - - // Execute. - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "enable_auto_merge", result.Action) - assert.Equal(t, "host-uk", result.RepoOwner) - assert.Equal(t, "core-tenant", result.RepoName) - assert.Equal(t, 42, result.PRNumber) - assert.Equal(t, "squash", mergeMethod) -} - -// --- PublishDraft integration: full flow --- - -func TestIntegration_PublishDraft_Good_FullFlow(t *testing.T) { - var patchedDraft bool - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/pulls/") { - bodyBytes, _ := io.ReadAll(r.Body) - if strings.Contains(string(bodyBytes), `"draft":false`) { - patchedDraft = true - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{}`)) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewPublishDraftHandler(client) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 3, - ChildNumber: 8, - PRNumber: 15, - RepoOwner: "org", - RepoName: "repo", - PRState: "OPEN", - IsDraft: true, - CheckStatus: "SUCCESS", - Mergeable: "MERGEABLE", - } - - // Verify match. - assert.True(t, h.Match(signal)) - - // Execute. - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "publish_draft", result.Action) - assert.True(t, patchedDraft) -} - -// --- SendFixCommand integration: conflict message --- - -func TestIntegration_SendFixCommand_Good_ConflictFlow(t *testing.T) { - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/comments") { - bodyBytes, _ := io.ReadAll(r.Body) - var body map[string]string - _ = json.Unmarshal(bodyBytes, &body) - commentBody = body["body"] - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewSendFixCommandHandler(client) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 1, - ChildNumber: 3, - PRNumber: 10, - RepoOwner: "org", - RepoName: "repo", - PRState: "OPEN", - Mergeable: "CONFLICTING", - CheckStatus: "SUCCESS", - } - - assert.True(t, h.Match(signal)) - - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "send_fix_command", result.Action) - assert.Contains(t, commentBody, "fix the merge conflict") -} - -// --- SendFixCommand integration: code review message --- - -func TestIntegration_SendFixCommand_Good_ReviewFlow(t *testing.T) { - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/comments") { - bodyBytes, _ := io.ReadAll(r.Body) - var body map[string]string - _ = json.Unmarshal(bodyBytes, &body) - commentBody = body["body"] - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - return - } - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewSendFixCommandHandler(client) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 1, - ChildNumber: 3, - PRNumber: 10, - RepoOwner: "org", - RepoName: "repo", - PRState: "OPEN", - Mergeable: "MERGEABLE", - CheckStatus: "FAILURE", - ThreadsTotal: 3, - ThreadsResolved: 1, - } - - assert.True(t, h.Match(signal)) - - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Contains(t, commentBody, "fix the code reviews") -} - -// --- Completion integration: success flow --- - -func TestIntegration_Completion_Good_SuccessFlow(t *testing.T) { - var labelAdded bool - var labelRemoved bool - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - // GetLabelByName — GET repo labels. - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/core/go-scm/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - }) - - // RemoveIssueLabel. - case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/labels/"): - labelRemoved = true - w.WriteHeader(http.StatusNoContent) - - // EnsureLabel — POST to create repo label. - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/core/go-scm/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 2, "name": "agent-completed", "color": "#0e8a16"}) - - // AddIssueLabels — POST to issue labels. - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/core/go-scm/issues/12/labels": - labelAdded = true - _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 2, "name": "agent-completed"}}) - - // CreateIssueComment. - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/comments"): - bodyBytes, _ := io.ReadAll(r.Body) - var body map[string]string - _ = json.Unmarshal(bodyBytes, &body) - commentBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - signal := &jobrunner.PipelineSignal{ - Type: "agent_completion", - EpicNumber: 5, - ChildNumber: 12, - RepoOwner: "core", - RepoName: "go-scm", - Success: true, - Message: "PR created and tests passing", - } - - assert.True(t, h.Match(signal)) - - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "completion", result.Action) - assert.Equal(t, "core", result.RepoOwner) - assert.Equal(t, "go-scm", result.RepoName) - assert.Equal(t, 5, result.EpicNumber) - assert.Equal(t, 12, result.ChildNumber) - assert.True(t, labelRemoved, "in-progress label should be removed") - assert.True(t, labelAdded, "agent-completed label should be added") - assert.Contains(t, commentBody, "PR created and tests passing") -} - -// --- Full pipeline integration: signal -> match -> execute -> journal --- - -func TestIntegration_FullPipeline_Good_TickParentWithJournal(t *testing.T) { - epicBody := "## Tasks\n- [ ] #7\n- [ ] #8\n" - - mock := newForgejoMock(t, epicBody) - defer mock.close() - - dir := t.TempDir() - journal, err := jobrunner.NewJournal(dir) - require.NoError(t, err) - - client := mock.client(t) - h := NewTickParentHandler(client) - - signal := &jobrunner.PipelineSignal{ - EpicNumber: 10, - ChildNumber: 7, - PRNumber: 55, - RepoOwner: "host-uk", - RepoName: "core-tenant", - PRState: "MERGED", - CheckStatus: "SUCCESS", - Mergeable: "UNKNOWN", - } - - // Verify match. - assert.True(t, h.Match(signal)) - - // Execute. - start := time.Now() - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - assert.True(t, result.Success) - - // Write to journal (simulating what the poller does). - result.EpicNumber = signal.EpicNumber - result.ChildNumber = signal.ChildNumber - result.Cycle = 1 - result.Duration = time.Since(start) - - err = journal.Append(signal, result) - require.NoError(t, err) - - // Verify the journal file exists and contains the entry. - date := time.Now().UTC().Format("2006-01-02") - journalPath := filepath.Join(dir, "host-uk", "core-tenant", date+".jsonl") - - _, statErr := os.Stat(journalPath) - require.NoError(t, statErr) - - f, err := os.Open(journalPath) - require.NoError(t, err) - defer func() { _ = f.Close() }() - - var entry jobrunner.JournalEntry - err = json.NewDecoder(f).Decode(&entry) - require.NoError(t, err) - - assert.Equal(t, "tick_parent", entry.Action) - assert.Equal(t, "host-uk/core-tenant", entry.Repo) - assert.Equal(t, 10, entry.Epic) - assert.Equal(t, 7, entry.Child) - assert.Equal(t, 55, entry.PR) - assert.Equal(t, 1, entry.Cycle) - assert.True(t, entry.Result.Success) - assert.Equal(t, "MERGED", entry.Signals.PRState) - - // Verify the epic was properly updated. - assert.Contains(t, mock.editedBody, "- [x] #7") - assert.Contains(t, mock.editedBody, "- [ ] #8") - assert.True(t, mock.closedChild) -} - -// --- Handler matching priority: first match wins --- - -func TestIntegration_HandlerPriority_Good_FirstMatchWins(t *testing.T) { - // Test that when multiple handlers could match, the first one wins. - // This exercises the poller's findHandler logic. - - // Signal with OPEN, not draft, MERGEABLE, SUCCESS, no threads: - // This matches enable_auto_merge. - signal := &jobrunner.PipelineSignal{ - PRState: "OPEN", - IsDraft: false, - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - ThreadsTotal: 0, - ThreadsResolved: 0, - } - - autoMerge := NewEnableAutoMergeHandler(nil) - publishDraft := NewPublishDraftHandler(nil) - fixCommand := NewSendFixCommandHandler(nil) - - // enable_auto_merge should match. - assert.True(t, autoMerge.Match(signal)) - // publish_draft should NOT match (not a draft). - assert.False(t, publishDraft.Match(signal)) - // send_fix_command should NOT match (mergeable and passing). - assert.False(t, fixCommand.Match(signal)) -} - -// --- Handler matching: draft PR path --- - -func TestIntegration_HandlerPriority_Good_DraftPRPath(t *testing.T) { - signal := &jobrunner.PipelineSignal{ - PRState: "OPEN", - IsDraft: true, - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - ThreadsTotal: 0, - ThreadsResolved: 0, - } - - autoMerge := NewEnableAutoMergeHandler(nil) - publishDraft := NewPublishDraftHandler(nil) - fixCommand := NewSendFixCommandHandler(nil) - - // enable_auto_merge should NOT match (is draft). - assert.False(t, autoMerge.Match(signal)) - // publish_draft should match (draft + open + success). - assert.True(t, publishDraft.Match(signal)) - // send_fix_command should NOT match. - assert.False(t, fixCommand.Match(signal)) -} - -// --- Handler matching: merged PR only matches tick_parent --- - -func TestIntegration_HandlerPriority_Good_MergedPRPath(t *testing.T) { - signal := &jobrunner.PipelineSignal{ - PRState: "MERGED", - IsDraft: false, - Mergeable: "UNKNOWN", - CheckStatus: "SUCCESS", - ThreadsTotal: 0, - ThreadsResolved: 0, - } - - autoMerge := NewEnableAutoMergeHandler(nil) - publishDraft := NewPublishDraftHandler(nil) - fixCommand := NewSendFixCommandHandler(nil) - tickParent := NewTickParentHandler(nil) - - assert.False(t, autoMerge.Match(signal)) - assert.False(t, publishDraft.Match(signal)) - assert.False(t, fixCommand.Match(signal)) - assert.True(t, tickParent.Match(signal)) -} - -// --- Handler matching: conflicting PR matches send_fix_command --- - -func TestIntegration_HandlerPriority_Good_ConflictingPRPath(t *testing.T) { - signal := &jobrunner.PipelineSignal{ - PRState: "OPEN", - IsDraft: false, - Mergeable: "CONFLICTING", - CheckStatus: "SUCCESS", - ThreadsTotal: 0, - ThreadsResolved: 0, - } - - autoMerge := NewEnableAutoMergeHandler(nil) - fixCommand := NewSendFixCommandHandler(nil) - - // enable_auto_merge should NOT match (conflicting). - assert.False(t, autoMerge.Match(signal)) - // send_fix_command should match (conflicting). - assert.True(t, fixCommand.Match(signal)) -} - -// --- Completion integration: failure flow --- - -func TestIntegration_Completion_Good_FailureFlow(t *testing.T) { - var commentBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - // GetLabelByName — GET repo labels. - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/core/go-scm/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": 1, "name": "in-progress", "color": "#1d76db"}, - }) - - // RemoveIssueLabel. - case r.Method == http.MethodDelete: - w.WriteHeader(http.StatusNoContent) - - // EnsureLabel — POST to create repo label. - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/core/go-scm/labels": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 3, "name": "agent-failed", "color": "#c0392b"}) - - // AddIssueLabels — POST to issue labels. - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/core/go-scm/issues/12/labels": - _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 3, "name": "agent-failed"}}) - - // CreateIssueComment. - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/comments"): - bodyBytes, _ := io.ReadAll(r.Body) - var body map[string]string - _ = json.Unmarshal(bodyBytes, &body) - commentBody = body["body"] - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - h := NewCompletionHandler(client) - - signal := &jobrunner.PipelineSignal{ - Type: "agent_completion", - EpicNumber: 5, - ChildNumber: 12, - RepoOwner: "core", - RepoName: "go-scm", - Success: false, - Error: "tests failed: 3 assertions", - } - - result, err := h.Execute(context.Background(), signal) - require.NoError(t, err) - - assert.True(t, result.Success) // The handler itself succeeded. - assert.Contains(t, commentBody, "Agent reported failure") - assert.Contains(t, commentBody, "tests failed: 3 assertions") -} - -// --- Multiple handlers execute in sequence for different signals --- - -func TestIntegration_MultipleHandlers_Good_DifferentSignals(t *testing.T) { - var commentBodies []string - var mergedPRs []int64 - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/merge"): - // Extract PR number from path. - parts := strings.Split(r.URL.Path, "/") - for i, p := range parts { - if p == "pulls" && i+1 < len(parts) { - var prNum int64 - _ = json.Unmarshal([]byte(parts[i+1]), &prNum) - mergedPRs = append(mergedPRs, prNum) - } - } - w.WriteHeader(http.StatusOK) - - case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/comments"): - bodyBytes, _ := io.ReadAll(r.Body) - var body map[string]string - _ = json.Unmarshal(bodyBytes, &body) - commentBodies = append(commentBodies, body["body"]) - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) - - case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": "## Tasks\n- [ ] #7\n- [ ] #8\n", - "title": "Epic", - }) - - case r.Method == http.MethodPatch: - _ = json.NewEncoder(w).Encode(map[string]any{"number": 1, "body": "", "state": "open"}) - - default: - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{}) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - autoMergeHandler := NewEnableAutoMergeHandler(client) - fixCommandHandler := NewSendFixCommandHandler(client) - - // Signal 1: should trigger auto merge. - sig1 := &jobrunner.PipelineSignal{ - PRState: "OPEN", IsDraft: false, Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", PRNumber: 10, - RepoOwner: "org", RepoName: "repo", - } - - // Signal 2: should trigger fix command. - sig2 := &jobrunner.PipelineSignal{ - PRState: "OPEN", Mergeable: "CONFLICTING", - CheckStatus: "SUCCESS", PRNumber: 20, - RepoOwner: "org", RepoName: "repo", - } - - assert.True(t, autoMergeHandler.Match(sig1)) - assert.False(t, autoMergeHandler.Match(sig2)) - - assert.False(t, fixCommandHandler.Match(sig1)) - assert.True(t, fixCommandHandler.Match(sig2)) - - // Execute both. - result1, err := autoMergeHandler.Execute(context.Background(), sig1) - require.NoError(t, err) - assert.True(t, result1.Success) - - result2, err := fixCommandHandler.Execute(context.Background(), sig2) - require.NoError(t, err) - assert.True(t, result2.Success) - - // Verify correct comment was posted for the conflicting PR. - require.Len(t, commentBodies, 1) - assert.Contains(t, commentBodies[0], "fix the merge conflict") -} diff --git a/jobrunner/handlers/publish_draft.go b/jobrunner/handlers/publish_draft.go deleted file mode 100644 index 3604e9a..0000000 --- a/jobrunner/handlers/publish_draft.go +++ /dev/null @@ -1,55 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "time" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// PublishDraftHandler marks a draft PR as ready for review once its checks pass. -type PublishDraftHandler struct { - forge *forge.Client -} - -// NewPublishDraftHandler creates a handler that publishes draft PRs. -func NewPublishDraftHandler(f *forge.Client) *PublishDraftHandler { - return &PublishDraftHandler{forge: f} -} - -// Name returns the handler identifier. -func (h *PublishDraftHandler) Name() string { - return "publish_draft" -} - -// Match returns true when the PR is a draft, open, and all checks have passed. -func (h *PublishDraftHandler) Match(signal *jobrunner.PipelineSignal) bool { - return signal.IsDraft && - signal.PRState == "OPEN" && - signal.CheckStatus == "SUCCESS" -} - -// Execute marks the PR as no longer a draft. -func (h *PublishDraftHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) { - start := time.Now() - - err := h.forge.SetPRDraft(signal.RepoOwner, signal.RepoName, int64(signal.PRNumber), false) - - result := &jobrunner.ActionResult{ - Action: "publish_draft", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - PRNumber: signal.PRNumber, - Success: err == nil, - Timestamp: time.Now(), - Duration: time.Since(start), - } - - if err != nil { - result.Error = fmt.Sprintf("publish draft failed: %v", err) - } - - return result, nil -} diff --git a/jobrunner/handlers/publish_draft_test.go b/jobrunner/handlers/publish_draft_test.go deleted file mode 100644 index dd76e81..0000000 --- a/jobrunner/handlers/publish_draft_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package handlers - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -func TestPublishDraft_Match_Good(t *testing.T) { - h := NewPublishDraftHandler(nil) - sig := &jobrunner.PipelineSignal{ - IsDraft: true, - PRState: "OPEN", - CheckStatus: "SUCCESS", - } - assert.True(t, h.Match(sig)) -} - -func TestPublishDraft_Match_Bad_NotDraft(t *testing.T) { - h := NewPublishDraftHandler(nil) - sig := &jobrunner.PipelineSignal{ - IsDraft: false, - PRState: "OPEN", - CheckStatus: "SUCCESS", - } - assert.False(t, h.Match(sig)) -} - -func TestPublishDraft_Match_Bad_ChecksFailing(t *testing.T) { - h := NewPublishDraftHandler(nil) - sig := &jobrunner.PipelineSignal{ - IsDraft: true, - PRState: "OPEN", - CheckStatus: "FAILURE", - } - assert.False(t, h.Match(sig)) -} - -func TestPublishDraft_Execute_Good(t *testing.T) { - var capturedMethod string - var capturedPath string - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedMethod = r.Method - capturedPath = r.URL.Path - b, _ := io.ReadAll(r.Body) - capturedBody = string(b) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{}`)) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - h := NewPublishDraftHandler(client) - sig := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-php", - PRNumber: 42, - IsDraft: true, - PRState: "OPEN", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.Equal(t, http.MethodPatch, capturedMethod) - assert.Equal(t, "/api/v1/repos/host-uk/core-php/pulls/42", capturedPath) - assert.Contains(t, capturedBody, `"draft":false`) - - assert.True(t, result.Success) - assert.Equal(t, "publish_draft", result.Action) - assert.Equal(t, "host-uk", result.RepoOwner) - assert.Equal(t, "core-php", result.RepoName) - assert.Equal(t, 42, result.PRNumber) -} diff --git a/jobrunner/handlers/resolve_threads.go b/jobrunner/handlers/resolve_threads.go deleted file mode 100644 index acb8477..0000000 --- a/jobrunner/handlers/resolve_threads.go +++ /dev/null @@ -1,79 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "time" - - forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// DismissReviewsHandler dismisses stale "request changes" reviews on a PR. -// This replaces the GitHub-only ResolveThreadsHandler because Forgejo does -// not have a thread resolution API. -type DismissReviewsHandler struct { - forge *forge.Client -} - -// NewDismissReviewsHandler creates a handler that dismisses stale reviews. -func NewDismissReviewsHandler(f *forge.Client) *DismissReviewsHandler { - return &DismissReviewsHandler{forge: f} -} - -// Name returns the handler identifier. -func (h *DismissReviewsHandler) Name() string { - return "dismiss_reviews" -} - -// Match returns true when the PR is open and has unresolved review threads. -func (h *DismissReviewsHandler) Match(signal *jobrunner.PipelineSignal) bool { - return signal.PRState == "OPEN" && signal.HasUnresolvedThreads() -} - -// Execute dismisses stale "request changes" reviews on the PR. -func (h *DismissReviewsHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) { - start := time.Now() - - reviews, err := h.forge.ListPRReviews(signal.RepoOwner, signal.RepoName, int64(signal.PRNumber)) - if err != nil { - return nil, fmt.Errorf("dismiss_reviews: list reviews: %w", err) - } - - var dismissErrors []string - dismissed := 0 - for _, review := range reviews { - if review.State != forgejosdk.ReviewStateRequestChanges || review.Dismissed || !review.Stale { - continue - } - - if err := h.forge.DismissReview( - signal.RepoOwner, signal.RepoName, - int64(signal.PRNumber), review.ID, - "Automatically dismissed: review is stale after new commits", - ); err != nil { - dismissErrors = append(dismissErrors, err.Error()) - } else { - dismissed++ - } - } - - result := &jobrunner.ActionResult{ - Action: "dismiss_reviews", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - PRNumber: signal.PRNumber, - Success: len(dismissErrors) == 0, - Timestamp: time.Now(), - Duration: time.Since(start), - } - - if len(dismissErrors) > 0 { - result.Error = fmt.Sprintf("failed to dismiss %d review(s): %s", - len(dismissErrors), dismissErrors[0]) - } - - return result, nil -} diff --git a/jobrunner/handlers/resolve_threads_test.go b/jobrunner/handlers/resolve_threads_test.go deleted file mode 100644 index 4b09208..0000000 --- a/jobrunner/handlers/resolve_threads_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -func TestDismissReviews_Match_Good(t *testing.T) { - h := NewDismissReviewsHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - ThreadsTotal: 4, - ThreadsResolved: 2, - } - assert.True(t, h.Match(sig)) -} - -func TestDismissReviews_Match_Bad_AllResolved(t *testing.T) { - h := NewDismissReviewsHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - ThreadsTotal: 3, - ThreadsResolved: 3, - } - assert.False(t, h.Match(sig)) -} - -func TestDismissReviews_Execute_Good(t *testing.T) { - callCount := 0 - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - w.Header().Set("Content-Type", "application/json") - - // ListPullReviews (GET) - if r.Method == http.MethodGet { - reviews := []map[string]any{ - { - "id": 1, "state": "REQUEST_CHANGES", "dismissed": false, "stale": true, - "body": "fix this", "commit_id": "abc123", - }, - { - "id": 2, "state": "APPROVED", "dismissed": false, "stale": false, - "body": "looks good", "commit_id": "abc123", - }, - { - "id": 3, "state": "REQUEST_CHANGES", "dismissed": false, "stale": true, - "body": "needs work", "commit_id": "abc123", - }, - } - _ = json.NewEncoder(w).Encode(reviews) - return - } - - // DismissPullReview (POST to dismissals endpoint) - w.WriteHeader(http.StatusOK) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - h := NewDismissReviewsHandler(client) - sig := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-admin", - PRNumber: 33, - PRState: "OPEN", - ThreadsTotal: 3, - ThreadsResolved: 1, - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "dismiss_reviews", result.Action) - assert.Equal(t, "host-uk", result.RepoOwner) - assert.Equal(t, "core-admin", result.RepoName) - assert.Equal(t, 33, result.PRNumber) - - // 1 list + 2 dismiss (reviews #1 and #3 are stale REQUEST_CHANGES) - assert.Equal(t, 3, callCount) -} diff --git a/jobrunner/handlers/send_fix_command.go b/jobrunner/handlers/send_fix_command.go deleted file mode 100644 index bfb7202..0000000 --- a/jobrunner/handlers/send_fix_command.go +++ /dev/null @@ -1,74 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "time" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// SendFixCommandHandler posts a comment on a PR asking for conflict or -// review fixes. -type SendFixCommandHandler struct { - forge *forge.Client -} - -// NewSendFixCommandHandler creates a handler that posts fix commands. -func NewSendFixCommandHandler(f *forge.Client) *SendFixCommandHandler { - return &SendFixCommandHandler{forge: f} -} - -// Name returns the handler identifier. -func (h *SendFixCommandHandler) Name() string { - return "send_fix_command" -} - -// Match returns true when the PR is open and either has merge conflicts or -// has unresolved threads with failing checks. -func (h *SendFixCommandHandler) Match(signal *jobrunner.PipelineSignal) bool { - if signal.PRState != "OPEN" { - return false - } - if signal.Mergeable == "CONFLICTING" { - return true - } - if signal.HasUnresolvedThreads() && signal.CheckStatus == "FAILURE" { - return true - } - return false -} - -// Execute posts a comment on the PR asking for a fix. -func (h *SendFixCommandHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) { - start := time.Now() - - var message string - if signal.Mergeable == "CONFLICTING" { - message = "Can you fix the merge conflict?" - } else { - message = "Can you fix the code reviews?" - } - - err := h.forge.CreateIssueComment( - signal.RepoOwner, signal.RepoName, - int64(signal.PRNumber), message, - ) - - result := &jobrunner.ActionResult{ - Action: "send_fix_command", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - PRNumber: signal.PRNumber, - Success: err == nil, - Timestamp: time.Now(), - Duration: time.Since(start), - } - - if err != nil { - result.Error = fmt.Sprintf("post comment failed: %v", err) - } - - return result, nil -} diff --git a/jobrunner/handlers/send_fix_command_test.go b/jobrunner/handlers/send_fix_command_test.go deleted file mode 100644 index 871c739..0000000 --- a/jobrunner/handlers/send_fix_command_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package handlers - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -func TestSendFixCommand_Match_Good_Conflicting(t *testing.T) { - h := NewSendFixCommandHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - Mergeable: "CONFLICTING", - } - assert.True(t, h.Match(sig)) -} - -func TestSendFixCommand_Match_Good_UnresolvedThreads(t *testing.T) { - h := NewSendFixCommandHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - Mergeable: "MERGEABLE", - CheckStatus: "FAILURE", - ThreadsTotal: 3, - ThreadsResolved: 1, - } - assert.True(t, h.Match(sig)) -} - -func TestSendFixCommand_Match_Bad_Clean(t *testing.T) { - h := NewSendFixCommandHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - ThreadsTotal: 2, - ThreadsResolved: 2, - } - assert.False(t, h.Match(sig)) -} - -func TestSendFixCommand_Execute_Good_Conflict(t *testing.T) { - var capturedMethod string - var capturedPath string - var capturedBody string - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedMethod = r.Method - capturedPath = r.URL.Path - b, _ := io.ReadAll(r.Body) - capturedBody = string(b) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":1}`)) - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - h := NewSendFixCommandHandler(client) - sig := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-tenant", - PRNumber: 17, - PRState: "OPEN", - Mergeable: "CONFLICTING", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.Equal(t, http.MethodPost, capturedMethod) - assert.Equal(t, "/api/v1/repos/host-uk/core-tenant/issues/17/comments", capturedPath) - assert.Contains(t, capturedBody, "fix the merge conflict") - - assert.True(t, result.Success) - assert.Equal(t, "send_fix_command", result.Action) - assert.Equal(t, "host-uk", result.RepoOwner) - assert.Equal(t, "core-tenant", result.RepoName) - assert.Equal(t, 17, result.PRNumber) -} diff --git a/jobrunner/handlers/testhelper_test.go b/jobrunner/handlers/testhelper_test.go deleted file mode 100644 index 277591c..0000000 --- a/jobrunner/handlers/testhelper_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package handlers - -import ( - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/forge" -) - -// forgejoVersionResponse is the JSON response for /api/v1/version. -const forgejoVersionResponse = `{"version":"9.0.0"}` - -// withVersion wraps an HTTP handler to also serve the Forgejo version endpoint -// that the SDK calls during NewClient initialization. -func withVersion(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "/version") { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(forgejoVersionResponse)) - return - } - next.ServeHTTP(w, r) - }) -} - -// newTestForgeClient creates a forge.Client pointing at the given test server URL. -func newTestForgeClient(t *testing.T, url string) *forge.Client { - t.Helper() - client, err := forge.New(url, "test-token") - require.NoError(t, err) - return client -} diff --git a/jobrunner/handlers/tick_parent.go b/jobrunner/handlers/tick_parent.go deleted file mode 100644 index fa7db10..0000000 --- a/jobrunner/handlers/tick_parent.go +++ /dev/null @@ -1,100 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "strings" - "time" - - forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - - "forge.lthn.ai/core/go-scm/forge" - "forge.lthn.ai/core/go-scm/jobrunner" -) - -// TickParentHandler ticks a child checkbox in the parent epic issue body -// after the child's PR has been merged. -type TickParentHandler struct { - forge *forge.Client -} - -// NewTickParentHandler creates a handler that ticks parent epic checkboxes. -func NewTickParentHandler(f *forge.Client) *TickParentHandler { - return &TickParentHandler{forge: f} -} - -// Name returns the handler identifier. -func (h *TickParentHandler) Name() string { - return "tick_parent" -} - -// Match returns true when the child PR has been merged. -func (h *TickParentHandler) Match(signal *jobrunner.PipelineSignal) bool { - return signal.PRState == "MERGED" -} - -// Execute fetches the epic body, replaces the unchecked checkbox for the -// child issue with a checked one, updates the epic, and closes the child issue. -func (h *TickParentHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) { - start := time.Now() - - // Fetch the epic issue body. - epic, err := h.forge.GetIssue(signal.RepoOwner, signal.RepoName, int64(signal.EpicNumber)) - if err != nil { - return nil, fmt.Errorf("tick_parent: fetch epic: %w", err) - } - - oldBody := epic.Body - unchecked := fmt.Sprintf("- [ ] #%d", signal.ChildNumber) - checked := fmt.Sprintf("- [x] #%d", signal.ChildNumber) - - if !strings.Contains(oldBody, unchecked) { - // Already ticked or not found -- nothing to do. - return &jobrunner.ActionResult{ - Action: "tick_parent", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - PRNumber: signal.PRNumber, - Success: true, - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil - } - - newBody := strings.Replace(oldBody, unchecked, checked, 1) - - // Update the epic body. - _, err = h.forge.EditIssue(signal.RepoOwner, signal.RepoName, int64(signal.EpicNumber), forgejosdk.EditIssueOption{ - Body: &newBody, - }) - if err != nil { - return &jobrunner.ActionResult{ - Action: "tick_parent", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - PRNumber: signal.PRNumber, - Error: fmt.Sprintf("edit epic failed: %v", err), - Timestamp: time.Now(), - Duration: time.Since(start), - }, nil - } - - // Close the child issue. - err = h.forge.CloseIssue(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber)) - - result := &jobrunner.ActionResult{ - Action: "tick_parent", - RepoOwner: signal.RepoOwner, - RepoName: signal.RepoName, - PRNumber: signal.PRNumber, - Success: err == nil, - Timestamp: time.Now(), - Duration: time.Since(start), - } - - if err != nil { - result.Error = fmt.Sprintf("close child issue failed: %v", err) - } - - return result, nil -} diff --git a/jobrunner/handlers/tick_parent_test.go b/jobrunner/handlers/tick_parent_test.go deleted file mode 100644 index 4a8fd78..0000000 --- a/jobrunner/handlers/tick_parent_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go-scm/jobrunner" -) - -func TestTickParent_Match_Good(t *testing.T) { - h := NewTickParentHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "MERGED", - } - assert.True(t, h.Match(sig)) -} - -func TestTickParent_Match_Bad_Open(t *testing.T) { - h := NewTickParentHandler(nil) - sig := &jobrunner.PipelineSignal{ - PRState: "OPEN", - } - assert.False(t, h.Match(sig)) -} - -func TestTickParent_Execute_Good(t *testing.T) { - epicBody := "## Tasks\n- [x] #1\n- [ ] #7\n- [ ] #8\n" - var editBody string - var closeCalled bool - - srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - method := r.Method - w.Header().Set("Content-Type", "application/json") - - switch { - // GET issue (fetch epic) - case method == http.MethodGet && strings.Contains(path, "/issues/42"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": epicBody, - "title": "Epic", - }) - - // PATCH issue (edit epic body) - case method == http.MethodPatch && strings.Contains(path, "/issues/42"): - b, _ := io.ReadAll(r.Body) - editBody = string(b) - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 42, - "body": editBody, - "title": "Epic", - }) - - // PATCH issue (close child — state: closed) - case method == http.MethodPatch && strings.Contains(path, "/issues/7"): - closeCalled = true - _ = json.NewEncoder(w).Encode(map[string]any{ - "number": 7, - "state": "closed", - }) - - default: - w.WriteHeader(http.StatusNotFound) - } - }))) - defer srv.Close() - - client := newTestForgeClient(t, srv.URL) - - h := NewTickParentHandler(client) - sig := &jobrunner.PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-php", - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - PRState: "MERGED", - } - - result, err := h.Execute(context.Background(), sig) - require.NoError(t, err) - - assert.True(t, result.Success) - assert.Equal(t, "tick_parent", result.Action) - - // Verify the edit body contains the checked checkbox. - assert.Contains(t, editBody, "- [x] #7") - assert.True(t, closeCalled, "expected child issue to be closed") -} diff --git a/jobrunner/journal.go b/jobrunner/journal.go deleted file mode 100644 index 99eaf94..0000000 --- a/jobrunner/journal.go +++ /dev/null @@ -1,170 +0,0 @@ -package jobrunner - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "sync" -) - -// validPathComponent matches safe repo owner/name characters (alphanumeric, hyphen, underscore, dot). -var validPathComponent = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) - -// JournalEntry is a single line in the JSONL audit log. -type JournalEntry struct { - Timestamp string `json:"ts"` - Epic int `json:"epic"` - Child int `json:"child"` - PR int `json:"pr"` - Repo string `json:"repo"` - Action string `json:"action"` - Signals SignalSnapshot `json:"signals"` - Result ResultSnapshot `json:"result"` - Cycle int `json:"cycle"` -} - -// SignalSnapshot captures the structural state of a PR at the time of action. -type SignalSnapshot struct { - PRState string `json:"pr_state"` - IsDraft bool `json:"is_draft"` - CheckStatus string `json:"check_status"` - Mergeable string `json:"mergeable"` - ThreadsTotal int `json:"threads_total"` - ThreadsResolved int `json:"threads_resolved"` -} - -// ResultSnapshot captures the outcome of an action. -type ResultSnapshot struct { - Success bool `json:"success"` - Error string `json:"error,omitempty"` - DurationMs int64 `json:"duration_ms"` -} - -// Journal writes ActionResult entries to date-partitioned JSONL files. -type Journal struct { - baseDir string - mu sync.Mutex -} - -// NewJournal creates a new Journal rooted at baseDir. -func NewJournal(baseDir string) (*Journal, error) { - if baseDir == "" { - return nil, fmt.Errorf("journal.NewJournal: base directory is required") - } - return &Journal{baseDir: baseDir}, nil -} - -// sanitizePathComponent validates a single path component (owner or repo name) -// to prevent path traversal attacks. It rejects "..", empty strings, paths -// containing separators, and any value outside the safe character set. -func sanitizePathComponent(name string) (string, error) { - // Reject empty or whitespace-only values. - if name == "" || strings.TrimSpace(name) == "" { - return "", fmt.Errorf("journal.sanitizePathComponent: invalid path component: %q", name) - } - - // Reject inputs containing path separators (directory traversal attempt). - if strings.ContainsAny(name, `/\`) { - return "", fmt.Errorf("journal.sanitizePathComponent: path component contains directory separator: %q", name) - } - - // Use filepath.Clean to normalize (e.g., collapse redundant dots). - clean := filepath.Clean(name) - - // Reject traversal components. - if clean == "." || clean == ".." { - return "", fmt.Errorf("journal.sanitizePathComponent: invalid path component: %q", name) - } - - // Validate against the safe character set. - if !validPathComponent.MatchString(clean) { - return "", fmt.Errorf("journal.sanitizePathComponent: path component contains invalid characters: %q", name) - } - - return clean, nil -} - -// Append writes a journal entry for the given signal and result. -func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error { - if signal == nil { - return fmt.Errorf("journal.Append: signal is required") - } - if result == nil { - return fmt.Errorf("journal.Append: result is required") - } - - entry := JournalEntry{ - Timestamp: result.Timestamp.UTC().Format("2006-01-02T15:04:05Z"), - Epic: signal.EpicNumber, - Child: signal.ChildNumber, - PR: signal.PRNumber, - Repo: signal.RepoFullName(), - Action: result.Action, - Signals: SignalSnapshot{ - PRState: signal.PRState, - IsDraft: signal.IsDraft, - CheckStatus: signal.CheckStatus, - Mergeable: signal.Mergeable, - ThreadsTotal: signal.ThreadsTotal, - ThreadsResolved: signal.ThreadsResolved, - }, - Result: ResultSnapshot{ - Success: result.Success, - Error: result.Error, - DurationMs: result.Duration.Milliseconds(), - }, - Cycle: result.Cycle, - } - - data, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("journal.Append: marshal entry: %w", err) - } - data = append(data, '\n') - - // Sanitize path components to prevent path traversal (CVE: issue #46). - owner, err := sanitizePathComponent(signal.RepoOwner) - if err != nil { - return fmt.Errorf("journal.Append: invalid repo owner: %w", err) - } - repo, err := sanitizePathComponent(signal.RepoName) - if err != nil { - return fmt.Errorf("journal.Append: invalid repo name: %w", err) - } - - date := result.Timestamp.UTC().Format("2006-01-02") - dir := filepath.Join(j.baseDir, owner, repo) - - // Resolve to absolute path and verify it stays within baseDir. - absBase, err := filepath.Abs(j.baseDir) - if err != nil { - return fmt.Errorf("journal.Append: resolve base directory: %w", err) - } - absDir, err := filepath.Abs(dir) - if err != nil { - return fmt.Errorf("journal.Append: resolve journal directory: %w", err) - } - if !strings.HasPrefix(absDir, absBase+string(filepath.Separator)) { - return fmt.Errorf("journal.Append: path %q escapes base directory %q", absDir, absBase) - } - - j.mu.Lock() - defer j.mu.Unlock() - - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("journal.Append: create directory: %w", err) - } - - path := filepath.Join(dir, date+".jsonl") - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return fmt.Errorf("journal.Append: open file: %w", err) - } - defer func() { _ = f.Close() }() - - _, err = f.Write(data) - return err -} diff --git a/jobrunner/journal_replay_test.go b/jobrunner/journal_replay_test.go deleted file mode 100644 index a9a73d3..0000000 --- a/jobrunner/journal_replay_test.go +++ /dev/null @@ -1,540 +0,0 @@ -package jobrunner - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// readJournalEntries reads all JSONL entries from a given file path. -func readJournalEntries(t *testing.T, path string) []JournalEntry { - t.Helper() - f, err := os.Open(path) - require.NoError(t, err) - defer func() { _ = f.Close() }() - - var entries []JournalEntry - scanner := bufio.NewScanner(f) - for scanner.Scan() { - var entry JournalEntry - err := json.Unmarshal(scanner.Bytes(), &entry) - require.NoError(t, err) - entries = append(entries, entry) - } - require.NoError(t, scanner.Err()) - return entries -} - -// readAllJournalFiles reads all .jsonl files recursively under a base directory. -func readAllJournalFiles(t *testing.T, baseDir string) []JournalEntry { - t.Helper() - var all []JournalEntry - err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if filepath.Ext(path) == ".jsonl" { - entries := readJournalEntries(t, path) - all = append(all, entries...) - } - return nil - }) - require.NoError(t, err) - return all -} - -// --- Journal replay: write multiple entries, read back, verify round-trip --- - -func TestJournal_Replay_Good_WriteAndReadBack(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - baseTime := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC) - - // Write 5 entries with different actions, times, and repos. - entries := []struct { - signal *PipelineSignal - result *ActionResult - }{ - { - signal: &PipelineSignal{ - EpicNumber: 1, ChildNumber: 2, PRNumber: 10, - RepoOwner: "org-a", RepoName: "repo-1", - PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE", - }, - result: &ActionResult{ - Action: "enable_auto_merge", - RepoOwner: "org-a", RepoName: "repo-1", - Success: true, Timestamp: baseTime, Duration: 100 * time.Millisecond, Cycle: 1, - }, - }, - { - signal: &PipelineSignal{ - EpicNumber: 1, ChildNumber: 3, PRNumber: 11, - RepoOwner: "org-a", RepoName: "repo-1", - PRState: "OPEN", CheckStatus: "FAILURE", Mergeable: "CONFLICTING", - }, - result: &ActionResult{ - Action: "send_fix_command", - RepoOwner: "org-a", RepoName: "repo-1", - Success: true, Timestamp: baseTime.Add(5 * time.Minute), Duration: 50 * time.Millisecond, Cycle: 1, - }, - }, - { - signal: &PipelineSignal{ - EpicNumber: 5, ChildNumber: 10, PRNumber: 20, - RepoOwner: "org-b", RepoName: "repo-2", - PRState: "MERGED", CheckStatus: "SUCCESS", Mergeable: "UNKNOWN", - }, - result: &ActionResult{ - Action: "tick_parent", - RepoOwner: "org-b", RepoName: "repo-2", - Success: true, Timestamp: baseTime.Add(10 * time.Minute), Duration: 200 * time.Millisecond, Cycle: 2, - }, - }, - { - signal: &PipelineSignal{ - EpicNumber: 5, ChildNumber: 11, PRNumber: 21, - RepoOwner: "org-b", RepoName: "repo-2", - PRState: "OPEN", CheckStatus: "PENDING", Mergeable: "MERGEABLE", - IsDraft: true, - }, - result: &ActionResult{ - Action: "publish_draft", - RepoOwner: "org-b", RepoName: "repo-2", - Success: false, Error: "API error", Timestamp: baseTime.Add(15 * time.Minute), - Duration: 300 * time.Millisecond, Cycle: 2, - }, - }, - { - signal: &PipelineSignal{ - EpicNumber: 1, ChildNumber: 4, PRNumber: 12, - RepoOwner: "org-a", RepoName: "repo-1", - PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE", - ThreadsTotal: 3, ThreadsResolved: 1, - }, - result: &ActionResult{ - Action: "dismiss_reviews", - RepoOwner: "org-a", RepoName: "repo-1", - Success: true, Timestamp: baseTime.Add(20 * time.Minute), Duration: 150 * time.Millisecond, Cycle: 3, - }, - }, - } - - for _, e := range entries { - err := j.Append(e.signal, e.result) - require.NoError(t, err) - } - - // Read back all entries. - all := readAllJournalFiles(t, dir) - require.Len(t, all, 5) - - // Build a map by action for flexible lookup (filepath.Walk order is by path, not insertion). - byAction := make(map[string][]JournalEntry) - for _, e := range all { - byAction[e.Action] = append(byAction[e.Action], e) - } - - // Verify enable_auto_merge entry (org-a/repo-1). - require.Len(t, byAction["enable_auto_merge"], 1) - eam := byAction["enable_auto_merge"][0] - assert.Equal(t, "org-a/repo-1", eam.Repo) - assert.Equal(t, 1, eam.Epic) - assert.Equal(t, 2, eam.Child) - assert.Equal(t, 10, eam.PR) - assert.Equal(t, 1, eam.Cycle) - assert.True(t, eam.Result.Success) - assert.Equal(t, int64(100), eam.Result.DurationMs) - - // Verify publish_draft (failed entry has error). - require.Len(t, byAction["publish_draft"], 1) - pd := byAction["publish_draft"][0] - assert.Equal(t, "publish_draft", pd.Action) - assert.False(t, pd.Result.Success) - assert.Equal(t, "API error", pd.Result.Error) - - // Verify signal snapshot preserves state. - assert.True(t, pd.Signals.IsDraft) - assert.Equal(t, "PENDING", pd.Signals.CheckStatus) - - // Verify dismiss_reviews has thread counts preserved. - require.Len(t, byAction["dismiss_reviews"], 1) - dr := byAction["dismiss_reviews"][0] - assert.Equal(t, 3, dr.Signals.ThreadsTotal) - assert.Equal(t, 1, dr.Signals.ThreadsResolved) -} - -// --- Journal replay: filter by action --- - -func TestJournal_Replay_Good_FilterByAction(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts := time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC) - - actions := []string{"enable_auto_merge", "tick_parent", "send_fix_command", "tick_parent", "publish_draft"} - for i, action := range actions { - signal := &PipelineSignal{ - EpicNumber: 1, ChildNumber: i + 1, PRNumber: 10 + i, - RepoOwner: "org", RepoName: "repo", - PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE", - } - result := &ActionResult{ - Action: action, - RepoOwner: "org", RepoName: "repo", - Success: true, - Timestamp: ts.Add(time.Duration(i) * time.Minute), - Duration: 100 * time.Millisecond, - Cycle: i + 1, - } - require.NoError(t, j.Append(signal, result)) - } - - all := readAllJournalFiles(t, dir) - require.Len(t, all, 5) - - // Filter by action=tick_parent. - var tickParentEntries []JournalEntry - for _, e := range all { - if e.Action == "tick_parent" { - tickParentEntries = append(tickParentEntries, e) - } - } - - assert.Len(t, tickParentEntries, 2) - assert.Equal(t, 2, tickParentEntries[0].Child) - assert.Equal(t, 4, tickParentEntries[1].Child) -} - -// --- Journal replay: filter by repo --- - -func TestJournal_Replay_Good_FilterByRepo(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts := time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC) - - repos := []struct { - owner string - name string - }{ - {"host-uk", "core-php"}, - {"host-uk", "core-tenant"}, - {"host-uk", "core-php"}, - {"lethean", "go-scm"}, - {"host-uk", "core-tenant"}, - } - - for i, r := range repos { - signal := &PipelineSignal{ - EpicNumber: 1, ChildNumber: i + 1, PRNumber: 10 + i, - RepoOwner: r.owner, RepoName: r.name, - PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE", - } - result := &ActionResult{ - Action: "tick_parent", - RepoOwner: r.owner, RepoName: r.name, - Success: true, - Timestamp: ts.Add(time.Duration(i) * time.Minute), - Duration: 50 * time.Millisecond, - Cycle: i + 1, - } - require.NoError(t, j.Append(signal, result)) - } - - // Read entries for host-uk/core-php. - phpPath := filepath.Join(dir, "host-uk", "core-php", "2026-02-10.jsonl") - phpEntries := readJournalEntries(t, phpPath) - assert.Len(t, phpEntries, 2) - for _, e := range phpEntries { - assert.Equal(t, "host-uk/core-php", e.Repo) - } - - // Read entries for host-uk/core-tenant. - tenantPath := filepath.Join(dir, "host-uk", "core-tenant", "2026-02-10.jsonl") - tenantEntries := readJournalEntries(t, tenantPath) - assert.Len(t, tenantEntries, 2) - for _, e := range tenantEntries { - assert.Equal(t, "host-uk/core-tenant", e.Repo) - } - - // Read entries for lethean/go-scm. - scmPath := filepath.Join(dir, "lethean", "go-scm", "2026-02-10.jsonl") - scmEntries := readJournalEntries(t, scmPath) - assert.Len(t, scmEntries, 1) - assert.Equal(t, "lethean/go-scm", scmEntries[0].Repo) -} - -// --- Journal replay: filter by time range (date partitioning) --- - -func TestJournal_Replay_Good_FilterByTimeRange(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - // Write entries across three different days. - dates := []time.Time{ - time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC), - time.Date(2026, 2, 9, 10, 0, 0, 0, time.UTC), - time.Date(2026, 2, 9, 14, 0, 0, 0, time.UTC), - time.Date(2026, 2, 10, 8, 0, 0, 0, time.UTC), - time.Date(2026, 2, 10, 16, 0, 0, 0, time.UTC), - } - - for i, ts := range dates { - signal := &PipelineSignal{ - EpicNumber: 1, ChildNumber: i + 1, PRNumber: 10 + i, - RepoOwner: "org", RepoName: "repo", - PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE", - } - result := &ActionResult{ - Action: "merge", - RepoOwner: "org", RepoName: "repo", - Success: true, - Timestamp: ts, - Duration: 100 * time.Millisecond, - Cycle: i + 1, - } - require.NoError(t, j.Append(signal, result)) - } - - // Verify each date file has the correct number of entries. - day8Path := filepath.Join(dir, "org", "repo", "2026-02-08.jsonl") - day8Entries := readJournalEntries(t, day8Path) - assert.Len(t, day8Entries, 1) - assert.Equal(t, "2026-02-08T09:00:00Z", day8Entries[0].Timestamp) - - day9Path := filepath.Join(dir, "org", "repo", "2026-02-09.jsonl") - day9Entries := readJournalEntries(t, day9Path) - assert.Len(t, day9Entries, 2) - assert.Equal(t, "2026-02-09T10:00:00Z", day9Entries[0].Timestamp) - assert.Equal(t, "2026-02-09T14:00:00Z", day9Entries[1].Timestamp) - - day10Path := filepath.Join(dir, "org", "repo", "2026-02-10.jsonl") - day10Entries := readJournalEntries(t, day10Path) - assert.Len(t, day10Entries, 2) - - // Simulate a time range query: get entries for Feb 9 only. - // In a real system, you'd list files matching the date range. - // Here we verify the date partitioning is correct. - rangeStart := time.Date(2026, 2, 9, 0, 0, 0, 0, time.UTC) - rangeEnd := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC) // exclusive - - var filtered []JournalEntry - all := readAllJournalFiles(t, dir) - for _, e := range all { - ts, err := time.Parse("2006-01-02T15:04:05Z", e.Timestamp) - require.NoError(t, err) - if !ts.Before(rangeStart) && ts.Before(rangeEnd) { - filtered = append(filtered, e) - } - } - - assert.Len(t, filtered, 2) - assert.Equal(t, 2, filtered[0].Child) - assert.Equal(t, 3, filtered[1].Child) -} - -// --- Journal replay: combined filter (action + repo + time) --- - -func TestJournal_Replay_Good_CombinedFilter(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts1 := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC) - ts2 := time.Date(2026, 2, 10, 11, 0, 0, 0, time.UTC) - ts3 := time.Date(2026, 2, 11, 9, 0, 0, 0, time.UTC) - - testData := []struct { - owner string - name string - action string - ts time.Time - }{ - {"org", "repo-a", "tick_parent", ts1}, - {"org", "repo-a", "enable_auto_merge", ts1}, - {"org", "repo-b", "tick_parent", ts2}, - {"org", "repo-a", "tick_parent", ts3}, - {"org", "repo-b", "send_fix_command", ts3}, - } - - for i, td := range testData { - signal := &PipelineSignal{ - EpicNumber: 1, ChildNumber: i + 1, PRNumber: 100 + i, - RepoOwner: td.owner, RepoName: td.name, - PRState: "MERGED", CheckStatus: "SUCCESS", Mergeable: "UNKNOWN", - } - result := &ActionResult{ - Action: td.action, - RepoOwner: td.owner, RepoName: td.name, - Success: true, - Timestamp: td.ts, - Duration: 50 * time.Millisecond, - Cycle: i + 1, - } - require.NoError(t, j.Append(signal, result)) - } - - // Filter: action=tick_parent AND repo=org/repo-a. - repoAPath := filepath.Join(dir, "org", "repo-a") - var repoAEntries []JournalEntry - err = filepath.Walk(repoAPath, func(path string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - if filepath.Ext(path) == ".jsonl" { - entries := readJournalEntries(t, path) - repoAEntries = append(repoAEntries, entries...) - } - return nil - }) - require.NoError(t, err) - - var tickParentRepoA []JournalEntry - for _, e := range repoAEntries { - if e.Action == "tick_parent" && e.Repo == "org/repo-a" { - tickParentRepoA = append(tickParentRepoA, e) - } - } - - assert.Len(t, tickParentRepoA, 2) - assert.Equal(t, 1, tickParentRepoA[0].Child) - assert.Equal(t, 4, tickParentRepoA[1].Child) -} - -// --- Journal replay: empty journal returns no entries --- - -func TestJournal_Replay_Good_EmptyJournal(t *testing.T) { - dir := t.TempDir() - - all := readAllJournalFiles(t, dir) - assert.Empty(t, all) -} - -// --- Journal replay: single entry round-trip preserves all fields --- - -func TestJournal_Replay_Good_FullFieldRoundTrip(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts := time.Date(2026, 2, 15, 14, 30, 45, 0, time.UTC) - - signal := &PipelineSignal{ - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - RepoOwner: "host-uk", - RepoName: "core-admin", - PRState: "OPEN", - IsDraft: true, - Mergeable: "CONFLICTING", - CheckStatus: "FAILURE", - ThreadsTotal: 5, - ThreadsResolved: 2, - } - - result := &ActionResult{ - Action: "send_fix_command", - RepoOwner: "host-uk", - RepoName: "core-admin", - Success: false, - Error: "comment API returned 503", - Timestamp: ts, - Duration: 1500 * time.Millisecond, - Cycle: 7, - } - - require.NoError(t, j.Append(signal, result)) - - path := filepath.Join(dir, "host-uk", "core-admin", "2026-02-15.jsonl") - entries := readJournalEntries(t, path) - require.Len(t, entries, 1) - - e := entries[0] - assert.Equal(t, "2026-02-15T14:30:45Z", e.Timestamp) - assert.Equal(t, 42, e.Epic) - assert.Equal(t, 7, e.Child) - assert.Equal(t, 99, e.PR) - assert.Equal(t, "host-uk/core-admin", e.Repo) - assert.Equal(t, "send_fix_command", e.Action) - assert.Equal(t, 7, e.Cycle) - - // Signal snapshot. - assert.Equal(t, "OPEN", e.Signals.PRState) - assert.True(t, e.Signals.IsDraft) - assert.Equal(t, "CONFLICTING", e.Signals.Mergeable) - assert.Equal(t, "FAILURE", e.Signals.CheckStatus) - assert.Equal(t, 5, e.Signals.ThreadsTotal) - assert.Equal(t, 2, e.Signals.ThreadsResolved) - - // Result snapshot. - assert.False(t, e.Result.Success) - assert.Equal(t, "comment API returned 503", e.Result.Error) - assert.Equal(t, int64(1500), e.Result.DurationMs) -} - -// --- Journal replay: concurrent writes produce valid JSONL --- - -func TestJournal_Replay_Good_ConcurrentWrites(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts := time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC) - - // Write 20 entries concurrently. - done := make(chan struct{}, 20) - for i := 0; i < 20; i++ { - go func(idx int) { - signal := &PipelineSignal{ - EpicNumber: 1, ChildNumber: idx, PRNumber: idx, - RepoOwner: "org", RepoName: "repo", - PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE", - } - result := &ActionResult{ - Action: "test", - RepoOwner: "org", RepoName: "repo", - Success: true, - Timestamp: ts, - Duration: 10 * time.Millisecond, - Cycle: idx, - } - _ = j.Append(signal, result) - done <- struct{}{} - }(i) - } - - for i := 0; i < 20; i++ { - <-done - } - - // All entries should be parseable and present. - path := filepath.Join(dir, "org", "repo", "2026-02-10.jsonl") - entries := readJournalEntries(t, path) - assert.Len(t, entries, 20) - - // Each entry should have valid JSON (no corruption from concurrent writes). - for _, e := range entries { - assert.NotEmpty(t, e.Action) - assert.Equal(t, "org/repo", e.Repo) - } -} diff --git a/jobrunner/journal_test.go b/jobrunner/journal_test.go deleted file mode 100644 index a17a88b..0000000 --- a/jobrunner/journal_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package jobrunner - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestJournal_Append_Good(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts := time.Date(2026, 2, 5, 14, 30, 0, 0, time.UTC) - - signal := &PipelineSignal{ - EpicNumber: 10, - ChildNumber: 3, - PRNumber: 55, - RepoOwner: "host-uk", - RepoName: "core-tenant", - PRState: "OPEN", - IsDraft: false, - Mergeable: "MERGEABLE", - CheckStatus: "SUCCESS", - ThreadsTotal: 2, - ThreadsResolved: 1, - LastCommitSHA: "abc123", - LastCommitAt: ts, - LastReviewAt: ts, - } - - result := &ActionResult{ - Action: "merge", - RepoOwner: "host-uk", - RepoName: "core-tenant", - EpicNumber: 10, - ChildNumber: 3, - PRNumber: 55, - Success: true, - Timestamp: ts, - Duration: 1200 * time.Millisecond, - Cycle: 1, - } - - err = j.Append(signal, result) - require.NoError(t, err) - - // Read the file back. - expectedPath := filepath.Join(dir, "host-uk", "core-tenant", "2026-02-05.jsonl") - f, err := os.Open(expectedPath) - require.NoError(t, err) - defer func() { _ = f.Close() }() - - scanner := bufio.NewScanner(f) - require.True(t, scanner.Scan(), "expected at least one line in JSONL file") - - var entry JournalEntry - err = json.Unmarshal(scanner.Bytes(), &entry) - require.NoError(t, err) - - assert.Equal(t, "2026-02-05T14:30:00Z", entry.Timestamp) - assert.Equal(t, 10, entry.Epic) - assert.Equal(t, 3, entry.Child) - assert.Equal(t, 55, entry.PR) - assert.Equal(t, "host-uk/core-tenant", entry.Repo) - assert.Equal(t, "merge", entry.Action) - assert.Equal(t, 1, entry.Cycle) - - // Verify signal snapshot. - assert.Equal(t, "OPEN", entry.Signals.PRState) - assert.Equal(t, false, entry.Signals.IsDraft) - assert.Equal(t, "SUCCESS", entry.Signals.CheckStatus) - assert.Equal(t, "MERGEABLE", entry.Signals.Mergeable) - assert.Equal(t, 2, entry.Signals.ThreadsTotal) - assert.Equal(t, 1, entry.Signals.ThreadsResolved) - - // Verify result snapshot. - assert.Equal(t, true, entry.Result.Success) - assert.Equal(t, "", entry.Result.Error) - assert.Equal(t, int64(1200), entry.Result.DurationMs) - - // Append a second entry and verify two lines exist. - result2 := &ActionResult{ - Action: "comment", - RepoOwner: "host-uk", - RepoName: "core-tenant", - Success: false, - Error: "rate limited", - Timestamp: ts, - Duration: 50 * time.Millisecond, - Cycle: 2, - } - err = j.Append(signal, result2) - require.NoError(t, err) - - data, err := os.ReadFile(expectedPath) - require.NoError(t, err) - - lines := 0 - sc := bufio.NewScanner(strings.NewReader(string(data))) - for sc.Scan() { - lines++ - } - assert.Equal(t, 2, lines, "expected two JSONL lines after two appends") -} - -func TestJournal_Append_Bad_PathTraversal(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts := time.Now() - - tests := []struct { - name string - repoOwner string - repoName string - wantErr string - }{ - { - name: "dotdot owner", - repoOwner: "..", - repoName: "core", - wantErr: "invalid repo owner", - }, - { - name: "dotdot repo", - repoOwner: "host-uk", - repoName: "../../etc/cron.d", - wantErr: "invalid repo name", - }, - { - name: "slash in owner", - repoOwner: "../etc", - repoName: "core", - wantErr: "invalid repo owner", - }, - { - name: "absolute path in repo", - repoOwner: "host-uk", - repoName: "/etc/passwd", - wantErr: "invalid repo name", - }, - { - name: "empty owner", - repoOwner: "", - repoName: "core", - wantErr: "invalid repo owner", - }, - { - name: "empty repo", - repoOwner: "host-uk", - repoName: "", - wantErr: "invalid repo name", - }, - { - name: "dot only owner", - repoOwner: ".", - repoName: "core", - wantErr: "invalid repo owner", - }, - { - name: "spaces only owner", - repoOwner: " ", - repoName: "core", - wantErr: "invalid repo owner", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - signal := &PipelineSignal{ - RepoOwner: tc.repoOwner, - RepoName: tc.repoName, - } - result := &ActionResult{ - Action: "merge", - Timestamp: ts, - } - - err := j.Append(signal, result) - require.Error(t, err) - assert.Contains(t, err.Error(), tc.wantErr) - }) - } -} - -func TestJournal_Append_Good_ValidNames(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - ts := time.Date(2026, 2, 5, 14, 30, 0, 0, time.UTC) - - // Verify valid names with dots, hyphens, underscores all work. - validNames := []struct { - owner string - repo string - }{ - {"host-uk", "core"}, - {"my_org", "my_repo"}, - {"org.name", "repo.v2"}, - {"a", "b"}, - {"Org-123", "Repo_456.go"}, - } - - for _, vn := range validNames { - signal := &PipelineSignal{ - RepoOwner: vn.owner, - RepoName: vn.repo, - } - result := &ActionResult{ - Action: "test", - Timestamp: ts, - } - - err := j.Append(signal, result) - assert.NoError(t, err, "expected valid name pair %s/%s to succeed", vn.owner, vn.repo) - } -} - -func TestJournal_Append_Bad_NilSignal(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - result := &ActionResult{ - Action: "merge", - Timestamp: time.Now(), - } - - err = j.Append(nil, result) - require.Error(t, err) - assert.Contains(t, err.Error(), "signal is required") -} - -func TestJournal_Append_Bad_NilResult(t *testing.T) { - dir := t.TempDir() - - j, err := NewJournal(dir) - require.NoError(t, err) - - signal := &PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-php", - } - - err = j.Append(signal, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "result is required") -} diff --git a/jobrunner/poller.go b/jobrunner/poller.go deleted file mode 100644 index be6b213..0000000 --- a/jobrunner/poller.go +++ /dev/null @@ -1,195 +0,0 @@ -package jobrunner - -import ( - "context" - "sync" - "time" - - "forge.lthn.ai/core/go/pkg/log" -) - -// PollerConfig configures a Poller. -type PollerConfig struct { - Sources []JobSource - Handlers []JobHandler - Journal *Journal - PollInterval time.Duration - DryRun bool -} - -// Poller discovers signals from sources and dispatches them to handlers. -type Poller struct { - mu sync.RWMutex - sources []JobSource - handlers []JobHandler - journal *Journal - interval time.Duration - dryRun bool - cycle int -} - -// NewPoller creates a Poller from the given config. -func NewPoller(cfg PollerConfig) *Poller { - interval := cfg.PollInterval - if interval <= 0 { - interval = 60 * time.Second - } - - return &Poller{ - sources: cfg.Sources, - handlers: cfg.Handlers, - journal: cfg.Journal, - interval: interval, - dryRun: cfg.DryRun, - } -} - -// Cycle returns the number of completed poll-dispatch cycles. -func (p *Poller) Cycle() int { - p.mu.RLock() - defer p.mu.RUnlock() - return p.cycle -} - -// DryRun returns whether dry-run mode is enabled. -func (p *Poller) DryRun() bool { - p.mu.RLock() - defer p.mu.RUnlock() - return p.dryRun -} - -// SetDryRun enables or disables dry-run mode. -func (p *Poller) SetDryRun(v bool) { - p.mu.Lock() - p.dryRun = v - p.mu.Unlock() -} - -// AddSource appends a source to the poller. -func (p *Poller) AddSource(s JobSource) { - p.mu.Lock() - p.sources = append(p.sources, s) - p.mu.Unlock() -} - -// AddHandler appends a handler to the poller. -func (p *Poller) AddHandler(h JobHandler) { - p.mu.Lock() - p.handlers = append(p.handlers, h) - p.mu.Unlock() -} - -// Run starts a blocking poll-dispatch loop. It runs one cycle immediately, -// then repeats on each tick of the configured interval until the context -// is cancelled. -func (p *Poller) Run(ctx context.Context) error { - if err := p.RunOnce(ctx); err != nil { - return err - } - - ticker := time.NewTicker(p.interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - if err := p.RunOnce(ctx); err != nil { - return err - } - } - } -} - -// RunOnce performs a single poll-dispatch cycle: iterate sources, poll each, -// find the first matching handler for each signal, and execute it. -func (p *Poller) RunOnce(ctx context.Context) error { - p.mu.Lock() - p.cycle++ - cycle := p.cycle - dryRun := p.dryRun - sources := make([]JobSource, len(p.sources)) - copy(sources, p.sources) - handlers := make([]JobHandler, len(p.handlers)) - copy(handlers, p.handlers) - p.mu.Unlock() - - log.Info("poller cycle starting", "cycle", cycle, "sources", len(sources), "handlers", len(handlers)) - - for _, src := range sources { - signals, err := src.Poll(ctx) - if err != nil { - log.Error("poll failed", "source", src.Name(), "err", err) - continue - } - - log.Info("polled source", "source", src.Name(), "signals", len(signals)) - - for _, sig := range signals { - handler := p.findHandler(handlers, sig) - if handler == nil { - log.Debug("no matching handler", "epic", sig.EpicNumber, "child", sig.ChildNumber) - continue - } - - if dryRun { - log.Info("dry-run: would execute", - "handler", handler.Name(), - "epic", sig.EpicNumber, - "child", sig.ChildNumber, - "pr", sig.PRNumber, - ) - continue - } - - start := time.Now() - result, err := handler.Execute(ctx, sig) - elapsed := time.Since(start) - - if err != nil { - log.Error("handler execution failed", - "handler", handler.Name(), - "epic", sig.EpicNumber, - "child", sig.ChildNumber, - "err", err, - ) - continue - } - - result.Cycle = cycle - result.EpicNumber = sig.EpicNumber - result.ChildNumber = sig.ChildNumber - result.Duration = elapsed - - if p.journal != nil { - if jErr := p.journal.Append(sig, result); jErr != nil { - log.Error("journal append failed", "err", jErr) - } - } - - if rErr := src.Report(ctx, result); rErr != nil { - log.Error("source report failed", "source", src.Name(), "err", rErr) - } - - log.Info("handler executed", - "handler", handler.Name(), - "action", result.Action, - "success", result.Success, - "duration", elapsed, - ) - } - } - - return nil -} - -// findHandler returns the first handler that matches the signal, or nil. -func (p *Poller) findHandler(handlers []JobHandler, sig *PipelineSignal) JobHandler { - for _, h := range handlers { - if h.Match(sig) { - return h - } - } - return nil -} diff --git a/jobrunner/poller_test.go b/jobrunner/poller_test.go deleted file mode 100644 index 1d3a908..0000000 --- a/jobrunner/poller_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package jobrunner - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// --- Mock source --- - -type mockSource struct { - name string - signals []*PipelineSignal - reports []*ActionResult - mu sync.Mutex -} - -func (m *mockSource) Name() string { return m.name } - -func (m *mockSource) Poll(_ context.Context) ([]*PipelineSignal, error) { - m.mu.Lock() - defer m.mu.Unlock() - return m.signals, nil -} - -func (m *mockSource) Report(_ context.Context, result *ActionResult) error { - m.mu.Lock() - defer m.mu.Unlock() - m.reports = append(m.reports, result) - return nil -} - -// --- Mock handler --- - -type mockHandler struct { - name string - matchFn func(*PipelineSignal) bool - executed []*PipelineSignal - mu sync.Mutex -} - -func (m *mockHandler) Name() string { return m.name } - -func (m *mockHandler) Match(sig *PipelineSignal) bool { - if m.matchFn != nil { - return m.matchFn(sig) - } - return true -} - -func (m *mockHandler) Execute(_ context.Context, sig *PipelineSignal) (*ActionResult, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.executed = append(m.executed, sig) - return &ActionResult{ - Action: m.name, - RepoOwner: sig.RepoOwner, - RepoName: sig.RepoName, - PRNumber: sig.PRNumber, - Success: true, - Timestamp: time.Now(), - }, nil -} - -func TestPoller_RunOnce_Good(t *testing.T) { - sig := &PipelineSignal{ - EpicNumber: 1, - ChildNumber: 2, - PRNumber: 10, - RepoOwner: "host-uk", - RepoName: "core-php", - PRState: "OPEN", - CheckStatus: "SUCCESS", - Mergeable: "MERGEABLE", - } - - src := &mockSource{ - name: "test-source", - signals: []*PipelineSignal{sig}, - } - - handler := &mockHandler{ - name: "test-handler", - matchFn: func(s *PipelineSignal) bool { - return s.PRNumber == 10 - }, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - }) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) - - // Handler should have been called with our signal. - handler.mu.Lock() - defer handler.mu.Unlock() - require.Len(t, handler.executed, 1) - assert.Equal(t, 10, handler.executed[0].PRNumber) - - // Source should have received a report. - src.mu.Lock() - defer src.mu.Unlock() - require.Len(t, src.reports, 1) - assert.Equal(t, "test-handler", src.reports[0].Action) - assert.True(t, src.reports[0].Success) - assert.Equal(t, 1, src.reports[0].Cycle) - assert.Equal(t, 1, src.reports[0].EpicNumber) - assert.Equal(t, 2, src.reports[0].ChildNumber) - - // Cycle counter should have incremented. - assert.Equal(t, 1, p.Cycle()) -} - -func TestPoller_RunOnce_Good_NoSignals(t *testing.T) { - src := &mockSource{ - name: "empty-source", - signals: nil, - } - - handler := &mockHandler{ - name: "unused-handler", - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - }) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) - - // Handler should not have been called. - handler.mu.Lock() - defer handler.mu.Unlock() - assert.Empty(t, handler.executed) - - // Source should not have received reports. - src.mu.Lock() - defer src.mu.Unlock() - assert.Empty(t, src.reports) - - assert.Equal(t, 1, p.Cycle()) -} - -func TestPoller_RunOnce_Good_NoMatchingHandler(t *testing.T) { - sig := &PipelineSignal{ - EpicNumber: 5, - ChildNumber: 8, - PRNumber: 42, - RepoOwner: "host-uk", - RepoName: "core-tenant", - PRState: "OPEN", - } - - src := &mockSource{ - name: "test-source", - signals: []*PipelineSignal{sig}, - } - - handler := &mockHandler{ - name: "picky-handler", - matchFn: func(s *PipelineSignal) bool { - return false // never matches - }, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - }) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) - - // Handler should not have been called. - handler.mu.Lock() - defer handler.mu.Unlock() - assert.Empty(t, handler.executed) - - // Source should not have received reports (no action taken). - src.mu.Lock() - defer src.mu.Unlock() - assert.Empty(t, src.reports) -} - -func TestPoller_RunOnce_Good_DryRun(t *testing.T) { - sig := &PipelineSignal{ - EpicNumber: 1, - ChildNumber: 3, - PRNumber: 20, - RepoOwner: "host-uk", - RepoName: "core-admin", - PRState: "OPEN", - CheckStatus: "SUCCESS", - Mergeable: "MERGEABLE", - } - - src := &mockSource{ - name: "test-source", - signals: []*PipelineSignal{sig}, - } - - handler := &mockHandler{ - name: "merge-handler", - matchFn: func(s *PipelineSignal) bool { - return true - }, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - Handlers: []JobHandler{handler}, - DryRun: true, - }) - - assert.True(t, p.DryRun()) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) - - // Handler should NOT have been called in dry-run mode. - handler.mu.Lock() - defer handler.mu.Unlock() - assert.Empty(t, handler.executed) - - // Source should not have received reports. - src.mu.Lock() - defer src.mu.Unlock() - assert.Empty(t, src.reports) -} - -func TestPoller_SetDryRun_Good(t *testing.T) { - p := NewPoller(PollerConfig{}) - - assert.False(t, p.DryRun()) - p.SetDryRun(true) - assert.True(t, p.DryRun()) - p.SetDryRun(false) - assert.False(t, p.DryRun()) -} - -func TestPoller_AddSourceAndHandler_Good(t *testing.T) { - p := NewPoller(PollerConfig{}) - - sig := &PipelineSignal{ - EpicNumber: 1, - ChildNumber: 1, - PRNumber: 5, - RepoOwner: "host-uk", - RepoName: "core-php", - PRState: "OPEN", - } - - src := &mockSource{ - name: "added-source", - signals: []*PipelineSignal{sig}, - } - - handler := &mockHandler{ - name: "added-handler", - matchFn: func(s *PipelineSignal) bool { return true }, - } - - p.AddSource(src) - p.AddHandler(handler) - - err := p.RunOnce(context.Background()) - require.NoError(t, err) - - handler.mu.Lock() - defer handler.mu.Unlock() - require.Len(t, handler.executed, 1) - assert.Equal(t, 5, handler.executed[0].PRNumber) -} - -func TestPoller_Run_Good(t *testing.T) { - src := &mockSource{ - name: "tick-source", - signals: nil, - } - - p := NewPoller(PollerConfig{ - Sources: []JobSource{src}, - PollInterval: 50 * time.Millisecond, - }) - - ctx, cancel := context.WithTimeout(context.Background(), 180*time.Millisecond) - defer cancel() - - err := p.Run(ctx) - assert.ErrorIs(t, err, context.DeadlineExceeded) - - // Should have completed at least 2 cycles (one immediate + at least one tick). - assert.GreaterOrEqual(t, p.Cycle(), 2) -} - -func TestPoller_DefaultInterval_Good(t *testing.T) { - p := NewPoller(PollerConfig{}) - assert.Equal(t, 60*time.Second, p.interval) -} diff --git a/jobrunner/types.go b/jobrunner/types.go deleted file mode 100644 index ce51caf..0000000 --- a/jobrunner/types.go +++ /dev/null @@ -1,72 +0,0 @@ -package jobrunner - -import ( - "context" - "time" -) - -// PipelineSignal is the structural snapshot of a child issue/PR. -// Carries structural state plus issue title/body for dispatch prompts. -type PipelineSignal struct { - EpicNumber int - ChildNumber int - PRNumber int - RepoOwner string - RepoName string - PRState string // OPEN, MERGED, CLOSED - IsDraft bool - Mergeable string // MERGEABLE, CONFLICTING, UNKNOWN - CheckStatus string // SUCCESS, FAILURE, PENDING - ThreadsTotal int - ThreadsResolved int - LastCommitSHA string - LastCommitAt time.Time - LastReviewAt time.Time - NeedsCoding bool // true if child has no PR (work not started) - Assignee string // issue assignee username (for dispatch) - IssueTitle string // child issue title (for dispatch prompt) - IssueBody string // child issue body (for dispatch prompt) - Type string // signal type (e.g., "agent_completion") - Success bool // agent completion success flag - Error string // agent error message - Message string // agent completion message -} - -// RepoFullName returns "owner/repo". -func (s *PipelineSignal) RepoFullName() string { - return s.RepoOwner + "/" + s.RepoName -} - -// HasUnresolvedThreads returns true if there are unresolved review threads. -func (s *PipelineSignal) HasUnresolvedThreads() bool { - return s.ThreadsTotal > s.ThreadsResolved -} - -// ActionResult carries the outcome of a handler execution. -type ActionResult struct { - Action string `json:"action"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` - EpicNumber int `json:"epic"` - ChildNumber int `json:"child"` - PRNumber int `json:"pr"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` - Timestamp time.Time `json:"ts"` - Duration time.Duration `json:"duration_ms"` - Cycle int `json:"cycle"` -} - -// JobSource discovers actionable work from an external system. -type JobSource interface { - Name() string - Poll(ctx context.Context) ([]*PipelineSignal, error) - Report(ctx context.Context, result *ActionResult) error -} - -// JobHandler processes a single pipeline signal. -type JobHandler interface { - Name() string - Match(signal *PipelineSignal) bool - Execute(ctx context.Context, signal *PipelineSignal) (*ActionResult, error) -} diff --git a/jobrunner/types_test.go b/jobrunner/types_test.go deleted file mode 100644 index c81a840..0000000 --- a/jobrunner/types_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package jobrunner - -import ( - "encoding/json" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPipelineSignal_RepoFullName_Good(t *testing.T) { - sig := &PipelineSignal{ - RepoOwner: "host-uk", - RepoName: "core-php", - } - assert.Equal(t, "host-uk/core-php", sig.RepoFullName()) -} - -func TestPipelineSignal_HasUnresolvedThreads_Good(t *testing.T) { - sig := &PipelineSignal{ - ThreadsTotal: 5, - ThreadsResolved: 3, - } - assert.True(t, sig.HasUnresolvedThreads()) -} - -func TestPipelineSignal_HasUnresolvedThreads_Bad_AllResolved(t *testing.T) { - sig := &PipelineSignal{ - ThreadsTotal: 4, - ThreadsResolved: 4, - } - assert.False(t, sig.HasUnresolvedThreads()) - - // Also verify zero threads is not unresolved. - sigZero := &PipelineSignal{ - ThreadsTotal: 0, - ThreadsResolved: 0, - } - assert.False(t, sigZero.HasUnresolvedThreads()) -} - -func TestActionResult_JSON_Good(t *testing.T) { - ts := time.Date(2026, 2, 5, 12, 0, 0, 0, time.UTC) - result := &ActionResult{ - Action: "merge", - RepoOwner: "host-uk", - RepoName: "core-tenant", - EpicNumber: 42, - ChildNumber: 7, - PRNumber: 99, - Success: true, - Timestamp: ts, - Duration: 1500 * time.Millisecond, - Cycle: 3, - } - - data, err := json.Marshal(result) - require.NoError(t, err) - - var decoded map[string]any - err = json.Unmarshal(data, &decoded) - require.NoError(t, err) - - assert.Equal(t, "merge", decoded["action"]) - assert.Equal(t, "host-uk", decoded["repo_owner"]) - assert.Equal(t, "core-tenant", decoded["repo_name"]) - assert.Equal(t, float64(42), decoded["epic"]) - assert.Equal(t, float64(7), decoded["child"]) - assert.Equal(t, float64(99), decoded["pr"]) - assert.Equal(t, true, decoded["success"]) - assert.Equal(t, float64(3), decoded["cycle"]) - - // Error field should be omitted when empty. - _, hasError := decoded["error"] - assert.False(t, hasError, "error field should be omitted when empty") - - // Verify round-trip with error field present. - resultWithErr := &ActionResult{ - Action: "merge", - RepoOwner: "host-uk", - RepoName: "core-tenant", - Success: false, - Error: "checks failing", - Timestamp: ts, - Duration: 200 * time.Millisecond, - Cycle: 1, - } - data2, err := json.Marshal(resultWithErr) - require.NoError(t, err) - - var decoded2 map[string]any - err = json.Unmarshal(data2, &decoded2) - require.NoError(t, err) - - assert.Equal(t, "checks failing", decoded2["error"]) - assert.Equal(t, false, decoded2["success"]) -}