package api import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/thepeterstone/claudomator/internal/config" "github.com/thepeterstone/claudomator/internal/llm" ) // initGitRepo creates a fresh git repo with two commits and returns its path. // Used to verify enrichCIInstructions picks up recent commits. func initGitRepo(t *testing.T) string { t.Helper() dir := t.TempDir() run := func(args ...string) { cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", // Disable signing in case the host has a global pre-commit signer. "GIT_CONFIG_GLOBAL=/dev/null", ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } run("init", "-q") run("config", "commit.gpgsign", "false") run("config", "tag.gpgsign", "false") if err := os.WriteFile(filepath.Join(dir, "README"), []byte("v1\n"), 0644); err != nil { t.Fatal(err) } run("add", "README") run("commit", "-q", "-m", "first commit", "--no-gpg-sign") if err := os.WriteFile(filepath.Join(dir, "README"), []byte("v2\n"), 0644); err != nil { t.Fatal(err) } run("add", "README") run("commit", "-q", "-m", "fix: bump readme", "--no-gpg-sign") return dir } func TestEnrichCIInstructions_NilClient_ReturnsFallback(t *testing.T) { got := enrichCIInstructions(context.Background(), nil, ciTriageContext{}, "FALLBACK") if got != "FALLBACK" { t.Errorf("nil client: want FALLBACK, got %q", got) } } func TestEnrichCIInstructions_LLMFailure_ReturnsFallback(t *testing.T) { // Server that always 500s. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) defer srv.Close() c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} got := enrichCIInstructions(context.Background(), c, ciTriageContext{Repo: "x", Branch: "main"}, "FALLBACK") if got != "FALLBACK" { t.Errorf("llm failure: want FALLBACK, got %q", got) } } func TestEnrichCIInstructions_EmptyLLMBody_ReturnsFallback(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{}}`) })) defer srv.Close() c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} got := enrichCIInstructions(context.Background(), c, ciTriageContext{}, "FALLBACK-2") if got != "FALLBACK-2" { t.Errorf("empty body: want fallback, got %q", got) } } func TestEnrichCIInstructions_LLMSuccess_ReturnsEnriched(t *testing.T) { expected := "1. Look at commit abc123\n2. Re-run build locally\n3. Check unit tests" var capturedPrompt string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Messages []struct { Role string `json:"role"` Content string `json:"content"` } `json:"messages"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } // Capture the user message so we can assert metadata is in the prompt. for _, m := range body.Messages { if m.Role == "user" { capturedPrompt = m.Content } } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"model":"x","choices":[{"message":{"content":%q},"finish_reason":"stop"}],"usage":{}}`, expected) })) defer srv.Close() c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} tctx := ciTriageContext{ Repo: "owner/myrepo", Branch: "main", SHA: "abc123", CheckName: "CI Build", URL: "https://github.com/owner/myrepo/runs/1", } got := enrichCIInstructions(context.Background(), c, tctx, "FALLBACK") if !strings.Contains(got, expected) { t.Errorf("enriched body missing LLM content; got: %s", got) } if !strings.Contains(got, "Repository: owner/myrepo") { t.Errorf("enriched body missing metadata header; got: %s", got) } for _, want := range []string{"owner/myrepo", "main", "abc123", "CI Build"} { if !strings.Contains(capturedPrompt, want) { t.Errorf("prompt missing %q; got: %s", want, capturedPrompt) } } } func TestEnrichCIInstructions_IncludesRecentCommits(t *testing.T) { repo := initGitRepo(t) var capturedPrompt string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Messages []struct { Role string `json:"role"` Content string `json:"content"` } `json:"messages"` } json.NewDecoder(r.Body).Decode(&body) for _, m := range body.Messages { if m.Role == "user" { capturedPrompt = m.Content } } w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":"plan"},"finish_reason":"stop"}],"usage":{}}`) })) defer srv.Close() c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} enrichCIInstructions(context.Background(), c, ciTriageContext{Repo: "x", Branch: "y", ProjectDir: repo}, "FALLBACK") if !strings.Contains(capturedPrompt, "Recent commits") { t.Errorf("expected prompt to include recent commits section; got:\n%s", capturedPrompt) } if !strings.Contains(capturedPrompt, "fix: bump readme") { t.Errorf("expected most recent commit message in prompt; got:\n%s", capturedPrompt) } } // TestWebhook_NoLLM_InstructionsPreserved is the regression guard: when no // LLM is configured, webhook task instructions match the historical template // exactly. func TestWebhook_NoLLM_InstructionsPreserved(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("status: %d", w.Code) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) tk, err := store.GetTask(resp["task_id"]) if err != nil { t.Fatal(err) } for _, want := range []string{ "A CI failure has been detected", "Please investigate the failure by:", "1. Reviewing recent commits on the branch", "4. Fixing the root cause and ensuring the build passes", } { if !strings.Contains(tk.Agent.Instructions, want) { t.Errorf("instructions missing %q (regression: LLM path leaked into no-LLM case)", want) } } } // TestWebhook_WithLLM_InstructionsEnriched verifies the LLM body appears in // the created task's instructions when SetLLM is configured. func TestWebhook_WithLLM_InstructionsEnriched(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":"LLM-GENERATED-PLAN"},"finish_reason":"stop"}],"usage":{}}`) })) defer llmSrv.Close() srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"}) w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("status: %d body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) tk, err := store.GetTask(resp["task_id"]) if err != nil { t.Fatal(err) } if !strings.Contains(tk.Agent.Instructions, "LLM-GENERATED-PLAN") { t.Errorf("instructions missing LLM body; got:\n%s", tk.Agent.Instructions) } if !strings.Contains(tk.Agent.Instructions, "Repository: owner/myrepo") { t.Errorf("instructions missing metadata header; got:\n%s", tk.Agent.Instructions) } }