Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ FROM alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2
ARG GOSEC_VERSION
ARG SEMGREP_VERSION

RUN apk add --no-cache git ca-certificates curl wget python3 python3-dev py3-pip alpine-sdk clamav
RUN apk add --no-cache git ca-certificates curl wget python3 python3-dev py3-pip alpine-sdk clamav nodejs npm
RUN update-ca-certificates
RUN freshclam

Expand Down
16 changes: 3 additions & 13 deletions pkg/analysis/passes/codediff/codediff.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ func run(pass *analysis.Pass) (any, error) {
return nil, nil
}

geminiKey := os.Getenv("GEMINI_API_KEY")
if geminiKey == "" {
if err := llmClient.CanUseLLM(); err != nil {
return nil, nil
}

Expand Down Expand Up @@ -303,19 +302,10 @@ func runLLMAnalysis(
return nil, err
}

// clean up files from repositoryPath
cleanFiles := []string{"replies.json", ".nvmrc", "GEMINI.md"}
for _, file := range cleanFiles {
filePath := filepath.Join(repositoryPath, file)
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err != nil {
logme.Debugln("Failed to remove file:", err)
}
}
}
llmclient.CleanUpPromptFiles(repositoryPath)

// Call the LLM
if err := llmClient.CallLLM(prompt, repositoryPath); err != nil {
if err := llmClient.CallLLM(prompt, repositoryPath, nil); err != nil {
logme.Debugln("Failed to call LLM:", err)
return nil, err
}
Expand Down
157 changes: 139 additions & 18 deletions pkg/llmclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,65 @@ package llmclient

import (
"context"
_ "embed"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/grafana/plugin-validator/pkg/logme"
)

//go:embed settings.json
var embeddedSettings []byte

var ErrAPIKeyNotSet = errors.New("GEMINI_API_KEY not set")

// filesToClean are files that should be removed from the working directory
// before calling the LLM to avoid influencing its behavior.
var filesToClean = []string{
"GEMINI.md", "gemini.md",
"CLAUDE.md", "claude.md",
"AGENTS.md", "agents.md",
"COPILOT.md", "copilot.md",
"replies.json",
"output.json",
}

// CleanUpPromptFiles removes agent config files and known output files
// from the given directory to avoid influencing the LLM.
func CleanUpPromptFiles(dir string) {
for _, file := range filesToClean {
p := filepath.Join(dir, file)
if _, err := os.Stat(p); err == nil {
if err := os.Remove(p); err != nil {
logme.DebugFln("Failed to remove %s: %v", p, err)
}
}
}
}

type CallLLMOptions struct {
Model string // e.g. "gemini-2.5-flash", empty = CLI default
}

type LLMClient interface {
CallLLM(prompt, repositoryPath string) error
CanUseLLM() error
CallLLM(prompt, repositoryPath string, opts *CallLLMOptions) error
}

func (g *GeminiClient) CanUseLLM() error {
if os.Getenv("GEMINI_API_KEY") == "" {
return ErrAPIKeyNotSet
}
_, err := getGeminiBinaryPath()
if err != nil {
return err
}
return nil
}

type GeminiClient struct{}
Expand All @@ -21,40 +69,113 @@ func NewGeminiClient() *GeminiClient {
return &GeminiClient{}
}

func (g *GeminiClient) CallLLM(prompt, repositoryPath string) error {
_, err := exec.LookPath("npx")
var cachedGeminiBinPath string
var cachedSettingsPath string

// getSettingsPath writes the embedded settings.json to a temp file once and returns its path.
func getSettingsPath() (string, error) {
if cachedSettingsPath != "" {
return cachedSettingsPath, nil
}

dir, err := os.MkdirTemp("", "gemini-settings-*")
if err != nil {
return errors.New("npx is not available in PATH")
return "", fmt.Errorf("failed to create temp settings dir: %w", err)
}

p := filepath.Join(dir, "settings.json")
if err := os.WriteFile(p, embeddedSettings, 0644); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("failed to write settings file: %w", err)
}

cachedSettingsPath = p
logme.DebugFln("Gemini settings written to %s", cachedSettingsPath)
return cachedSettingsPath, nil
}

// getGeminiBinaryPath returns the path to the gemini binary.
// It first checks PATH, then falls back to a local npm install.
// The result is cached after the first successful resolution.
func getGeminiBinaryPath() (string, error) {
if cachedGeminiBinPath != "" {
return cachedGeminiBinPath, nil
}

if p, err := exec.LookPath("gemini"); err == nil {
cachedGeminiBinPath = p
return p, nil
}

if _, err := exec.LookPath("npm"); err != nil {
return "", fmt.Errorf("neither gemini nor npm available in PATH")
}

dir, err := os.MkdirTemp("", "gemini-cli-*")
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %w", err)
}

logme.DebugFln("Installing gemini CLI locally to %s", dir)
install := exec.Command("npm", "install", "@google/gemini-cli")
install.Dir = dir
if out, err := install.CombinedOutput(); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("npm install failed: %s: %w", string(out), err)
}

bin := filepath.Join(dir, "node_modules", ".bin", "gemini")
if _, err := os.Stat(bin); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("gemini binary not found after install")
}

cachedGeminiBinPath = bin
logme.DebugFln("Gemini CLI installed at %s", bin)
return bin, nil
}

func (g *GeminiClient) CallLLM(prompt, repositoryPath string, opts *CallLLMOptions) error {
if err := g.CanUseLLM(); err != nil {
return err
}

geminiBin, err := getGeminiBinaryPath()
if err != nil {
return fmt.Errorf("failed to get gemini CLI: %w", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

cmd := exec.CommandContext(
ctx,
"npx",
"-y",
"https://github.com/google-gemini/gemini-cli",
"-y",
)
settingsPath, err := getSettingsPath()
if err != nil {
return fmt.Errorf("failed to prepare settings: %w", err)
}

args := []string{}

if opts != nil && opts.Model != "" {
args = append(args, "-m", opts.Model)
}

cmd := exec.CommandContext(ctx, geminiBin, args...)
cmd.Dir = repositoryPath
cmd.Stdin = strings.NewReader(prompt)
// we only want the output in debug mode
cmd.Env = append(os.Environ(), "GEMINI_CLI_SYSTEM_SETTINGS_PATH="+settingsPath)

if os.Getenv("DEBUG") != "" {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}

logme.Debugln("Running gemini CLI analysis in directory:", repositoryPath)
logme.DebugFln("Running: GEMINI_CLI_SYSTEM_SETTINGS_PATH=%s %s %s", settingsPath, geminiBin, strings.Join(args, " "))

if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
logme.Debugln("Gemini CLI timed out after 5 minutes")
} else {
logme.Debugln("Gemini CLI failed:", err)
return fmt.Errorf("gemini CLI timed out after 5 minutes")
}
return fmt.Errorf("gemini CLI failed: %w", err)
}

return nil
}

6 changes: 5 additions & 1 deletion pkg/llmclient/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ func (m *MockLLMClient) WithResponses(responses []MockResponse) *MockLLMClient {
return m
}

func (m *MockLLMClient) CallLLM(prompt, repositoryPath string) error {
func (m *MockLLMClient) CanUseLLM() error {
return nil
}

func (m *MockLLMClient) CallLLM(prompt, repositoryPath string, opts *CallLLMOptions) error {
logme.Debugln("Mock LLM client called with repository:", repositoryPath)

repliesPath := filepath.Join(repositoryPath, "replies.json")
Expand Down
45 changes: 45 additions & 0 deletions pkg/llmclient/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"skills": {
"enabled": false
},
"tools": {
"approvalMode": "auto_edit",
"allowed": [
"ShellTool(ls)",
"ShellTool(cat)",
"ShellTool(git status)",
"ShellTool(git diff)",
"ShellTool(git log)",
"ShellTool(git show)",
"ShellTool(git blame)",
"ShellTool(git branch)",
"ShellTool(git tag)",
"ShellTool(git remote)",
"ShellTool(git rev-parse)",
"ShellTool(git ls-files)",
"ShellTool(git shortlog)",
"ShellTool(git checkout)",
"ShellTool(git reset)",
"ShellTool(git clean)",
"ShellTool(grep)",
"ShellTool(rg)",
"ShellTool(head)",
"ShellTool(tail)",
"ShellTool(wc)",
"ShellTool(diff)",
"ShellTool(tree)",
"ShellTool(file)",
"ShellTool(stat)",
"ShellTool(sort)",
"ShellTool(uniq)",
"ShellTool(jq)"
],
"exclude": [
"web_fetch",
"google_web_search",
"ShellTool(curl)",
"ShellTool(wget)",
"ShellTool(ssh)"
]
}
}
10 changes: 2 additions & 8 deletions pkg/repotool/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ func FindReleaseByVersion(
CommitSHA: commitSHA,
Source: "github_release",
CreatedAt: createdAt,
URL: release.HTMLURL,
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", repo.Owner, repo.Repo, commitSHA),
}, nil
}
}
Expand All @@ -226,18 +226,12 @@ func FindReleaseByVersion(
searchVersion := strings.TrimPrefix(version, "v")

if strings.EqualFold(tagVersion, searchVersion) {
tagURL := fmt.Sprintf(
"https://github.com/%s/%s/tree/%s",
repo.Owner,
repo.Repo,
tag.Name,
)
return &VersionInfo{
Version: tag.Name,
CommitSHA: tag.Commit.SHA,
Source: "github_tag",
CreatedAt: time.Time{}, // Tags don't have creation time in the API
URL: tagURL,
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", repo.Owner, repo.Repo, tag.Commit.SHA),
}, nil
}
}
Expand Down
Loading
Loading