summaryrefslogtreecommitdiff
path: root/internal/api/webhook_llm.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-02 07:54:51 +0000
committerClaude <noreply@anthropic.com>2026-05-02 07:54:51 +0000
commit6c5762848f4f3114a6ece9ce0bc70a84fca040ce (patch)
treec118fe596c66b23dbf23d7aee5d6d6f823d0903a /internal/api/webhook_llm.go
parentae833b2765c7c8086bf8e1ea8e8ec8ee9b73e656 (diff)
feat(api): enrich CI failure task instructions via local LLM
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
Diffstat (limited to 'internal/api/webhook_llm.go')
-rw-r--r--internal/api/webhook_llm.go127
1 files changed, 127 insertions, 0 deletions
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))
+}