From 6c5762848f4f3114a6ece9ce0bc70a84fca040ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 07:54:51 +0000 Subject: feat(api): enrich CI failure task instructions via local LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of "local OSS models as agents" plan. When the webhook handler creates a task for a failed CI run AND a local LLM is configured on the server, the hardcoded 4-step investigation template is replaced with a project-aware investigation plan generated by the LLM. Scope adjustment from the original sketch: the original plan said "summarize fetched workflow logs", but fetching logs requires GitHub API auth that isn't wired. Narrowed to project-context triage — recent git log + CLAUDE.md content + webhook metadata, fed to the LLM with a system prompt asking for 6-12 lines of concrete next steps. Deferred GitHub log fetching to post-epic cleanup. Implementation: - New internal/api/webhook_llm.go holds enrichCIInstructions and its helpers (readRecentCommits via `git log`, readProjectDoc). - enrichCIInstructions is truly additive: any failure mode (no client, HTTP error, empty body, 10s timeout) returns the original fallback template unchanged. Existing webhook tests pass byte-for-byte. - Always preserves a metadata header (repo/branch/SHA/check/URL) ahead of the LLM body so investigators don't lose context if the LLM is terse. - Reuses s.llm (set via Server.SetLLM in Phase 2) — no new config knob, no per-feature gating. Asymmetric opt-out (yes-elaborate, no-CI-triage) deferred until there's actual demand. Tests: - enrichCIInstructions: nil client, LLM 500, empty body all return fallback unchanged. - enrichCIInstructions: success path produces enriched body with metadata header preserved; user prompt contains repo/branch/SHA. - enrichCIInstructions: real git repo (init + 2 commits) → recent commits appear in user prompt. - Webhook handler regression guard: no-LLM path produces the exact legacy template substrings. - Webhook handler with LLM stubbed: task instructions contain LLM body + metadata header. Plan: docs/plans/local-oss-runner.md. https://claude.ai/code/session_017Edeq947TpSm1vQTxMhi1J --- internal/api/webhook_llm.go | 127 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 internal/api/webhook_llm.go (limited to 'internal/api/webhook_llm.go') diff --git a/internal/api/webhook_llm.go b/internal/api/webhook_llm.go new file mode 100644 index 0000000..1cbca17 --- /dev/null +++ b/internal/api/webhook_llm.go @@ -0,0 +1,127 @@ +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)) +} -- cgit v1.2.3