summaryrefslogtreecommitdiff
path: root/internal/api/webhook_llm.go
blob: 1cbca171e7fd40788e3895729908cd427377c1cb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
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))
}