package api import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/thepeterstone/claudomator/internal/llm" ) // ciTriagePromptTimeout caps the LLM enrichment call so a slow local model // can't stall webhook handling. On timeout the original template is used. const ciTriagePromptTimeout = 10 * time.Second // ciTriageContext holds everything we know at webhook time, plus best-effort // project-side signals (recent git log, CLAUDE.md content) when project_dir // is available. type ciTriageContext struct { Repo string Branch string SHA string CheckName string URL string ProjectDir string RecentCommits string // multi-line, may be "" ProjectDoc string // first ~4 KB of CLAUDE.md, may be "" } // enrichCIInstructions asks the local LLM to produce a tighter, project-aware // investigation plan than the hardcoded template. On any error (no client, // timeout, parse failure) it returns fallback unchanged so the webhook flow // is never worse off for trying. func enrichCIInstructions(parent context.Context, c *llm.Client, ctx ciTriageContext, fallback string) string { if c == nil { return fallback } // Pull project-side signals best-effort. Errors are silently swallowed — // the LLM still gets the metadata it does have. if ctx.ProjectDir != "" { ctx.RecentCommits = readRecentCommits(ctx.ProjectDir, 5) ctx.ProjectDoc = readProjectDoc(ctx.ProjectDir) } cctx, cancel := context.WithTimeout(parent, ciTriagePromptTimeout) defer cancel() prompt := buildCITriagePrompt(ctx) resp, err := c.Chat(cctx, llm.ChatRequest{ Messages: []llm.Message{ {Role: "system", Content: "You produce concise, actionable CI failure investigation plans. Respond with plain text only — no markdown fences, no JSON, no preamble."}, {Role: "user", Content: prompt}, }, }) if err != nil { return fallback } body := strings.TrimSpace(resp.Content) if body == "" { return fallback } // Always preserve the metadata header from the fallback so investigators // can see repo/branch/SHA/URL even if the LLM body is terse. return ciInstructionsHeader(ctx) + "\n\n" + body } func buildCITriagePrompt(ctx ciTriageContext) string { var sb strings.Builder fmt.Fprintf(&sb, "CI just failed.\n\nRepository: %s\nBranch: %s\nCommit SHA: %s\nCheck/Workflow: %s\nRun URL: %s\n", ctx.Repo, ctx.Branch, ctx.SHA, ctx.CheckName, ctx.URL) if ctx.RecentCommits != "" { fmt.Fprintf(&sb, "\nRecent commits on this branch (newest first):\n%s\n", ctx.RecentCommits) } if ctx.ProjectDoc != "" { fmt.Fprintf(&sb, "\nProject context (CLAUDE.md, truncated):\n%s\n", ctx.ProjectDoc) } sb.WriteString("\nProduce 6–12 lines of investigation steps. Name suspect commits or files when you can; otherwise give concrete starting actions (which logs to read, which tests to re-run locally). End with an explicit 'Acceptance Criteria' section listing what 'fixed' looks like.") return sb.String() } func ciInstructionsHeader(ctx ciTriageContext) string { return fmt.Sprintf( "A CI failure has been detected and requires investigation.\n\n"+ "Repository: %s\n"+ "Branch: %s\n"+ "Commit SHA: %s\n"+ "Check/Workflow: %s\n"+ "Run URL: %s", ctx.Repo, ctx.Branch, ctx.SHA, ctx.CheckName, ctx.URL, ) } // readRecentCommits returns the last n commits as a `git log --oneline`-style // string, or "" on any error. func readRecentCommits(projectDir string, n int) string { if projectDir == "" { return "" } cctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() cmd := exec.CommandContext(cctx, "git", "-C", projectDir, "log", "--oneline", fmt.Sprintf("-n%d", n)) out, err := cmd.Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } // readProjectDoc returns CLAUDE.md content (capped at 4KB) or "". func readProjectDoc(projectDir string) string { if projectDir == "" { return "" } data, err := os.ReadFile(filepath.Join(projectDir, "CLAUDE.md")) if err != nil { return "" } const cap = 4096 if len(data) > cap { data = data[:cap] } return strings.TrimSpace(string(data)) }