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))
}
|