5252143a33
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 19s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Failing after 6s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Failing after 10s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Failing after 10s
- #19639: Use empty default for --gitea-url alias to remove ordering dependency - #19640: Upgrade slog.Warn to slog.Error for missing ReviewSuperseder (signals bug) - #19641: Remove orphaned comment fragment from buildSupersededBody relocation - #19642: Rename ProviderGithub → ProviderGitHub per Go acronym convention - #19643: Log resolution failures at debug level in SupersedeReviews
806 lines
28 KiB
Go
806 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/budget"
|
|
"gitea.weiker.me/rodin/review-bot/gitea"
|
|
"gitea.weiker.me/rodin/review-bot/github"
|
|
"gitea.weiker.me/rodin/review-bot/llm"
|
|
"gitea.weiker.me/rodin/review-bot/review"
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
var version = "dev"
|
|
|
|
// setupLogger configures the global slog default logger based on format and verbosity.
|
|
func setupLogger(format, verbosity string) {
|
|
var level slog.Level
|
|
switch strings.ToLower(verbosity) {
|
|
case "debug":
|
|
level = slog.LevelDebug
|
|
case "info":
|
|
level = slog.LevelInfo
|
|
case "warn":
|
|
level = slog.LevelWarn
|
|
case "error":
|
|
level = slog.LevelError
|
|
default:
|
|
level = slog.LevelInfo
|
|
}
|
|
|
|
opts := &slog.HandlerOptions{Level: level}
|
|
|
|
var handler slog.Handler
|
|
switch strings.ToLower(format) {
|
|
case "json":
|
|
handler = slog.NewJSONHandler(os.Stderr, opts)
|
|
default:
|
|
handler = slog.NewTextHandler(os.Stderr, opts)
|
|
}
|
|
|
|
slog.SetDefault(slog.New(handler))
|
|
}
|
|
|
|
func main() {
|
|
versionFlag := flag.Bool("version", false, "Print version and exit")
|
|
// Logging flags
|
|
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
|
verbosity := flag.String("verbosity", envOrDefault("LOG_VERBOSITY", "info"), "Log verbosity: debug, info, warn, error")
|
|
// VCS flags
|
|
provider := flag.String("provider", envOrDefault("VCS_PROVIDER", "gitea"), "VCS provider: gitea or github")
|
|
baseURL := flag.String("base-url", envOrDefault("VCS_BASE_URL", ""), "VCS API base URL (for github provider; defaults to https://api.github.com)")
|
|
vcsURL := flag.String("vcs-url", envOrDefault("VCS_URL", envOrDefault("GITEA_URL", envOrDefault("GITHUB_SERVER_URL", ""))), "VCS instance URL (Gitea) [deprecated alias: --gitea-url]")
|
|
// Keep --gitea-url as backward-compatible alias (flag package doesn't support aliases natively, handle below)
|
|
repo := flag.String("repo", envOrDefault("VCS_REPO", envOrDefault("GITEA_REPO", envOrDefault("GITHUB_REPOSITORY", ""))), "Repository (owner/name)")
|
|
prNum := flag.String("pr", envOrDefault("PR_NUMBER", ""), "Pull request number")
|
|
reviewerName := flag.String("reviewer-name", envOrDefault("REVIEWER_NAME", ""), "Reviewer display name")
|
|
reviewerToken := flag.String("reviewer-token", envOrDefault("REVIEWER_TOKEN", ""), "VCS token for posting review")
|
|
llmBaseURL := flag.String("llm-base-url", envOrDefault("LLM_BASE_URL", ""), "LLM API base URL")
|
|
llmAPIKey := flag.String("llm-api-key", envOrDefault("LLM_API_KEY", ""), "LLM API key")
|
|
llmModel := flag.String("llm-model", envOrDefault("LLM_MODEL", ""), "LLM model name")
|
|
conventionsFile := flag.String("conventions-file", envOrDefault("CONVENTIONS_FILE", ""), "Conventions file path in repo (e.g. CLAUDE.md)")
|
|
systemPromptFile := flag.String("system-prompt-file", envOrDefault("SYSTEM_PROMPT_FILE", ""), "Local file with additional system prompt instructions")
|
|
patternsRepo := flag.String("patterns-repo", envOrDefault("PATTERNS_REPO", ""), "Repo with language patterns (e.g. rodin/elixir-patterns)")
|
|
patternsFiles := flag.String("patterns-files", envOrDefault("PATTERNS_FILES", "README.md"), "Comma-separated file paths to fetch from patterns repo")
|
|
dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting")
|
|
llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)")
|
|
llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)")
|
|
llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai, anthropic, or aicore")
|
|
personaName := flag.String("persona", envOrDefault("PERSONA", ""), "Built-in persona name (security, architect, docs)")
|
|
personaFile := flag.String("persona-file", envOrDefault("PERSONA_FILE", ""), "Path to persona JSON file")
|
|
// AI Core specific flags (only used when provider=aicore)
|
|
aicoreClientID := flag.String("aicore-client-id", envOrDefault("AICORE_CLIENT_ID", ""), "SAP AI Core client ID (for provider=aicore)")
|
|
aicoreClientSecret := flag.String("aicore-client-secret", envOrDefault("AICORE_CLIENT_SECRET", ""), "SAP AI Core client secret (for provider=aicore)")
|
|
aicoreAuthURL := flag.String("aicore-auth-url", envOrDefault("AICORE_AUTH_URL", ""), "SAP AI Core auth URL (for provider=aicore)")
|
|
aicoreAPIURL := flag.String("aicore-api-url", envOrDefault("AICORE_API_URL", ""), "SAP AI Core API URL (for provider=aicore)")
|
|
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
|
|
|
|
// Backward-compatible alias: --gitea-url shares vcsURL's pointer (last flag wins).
|
|
// Shares vcsURL pointer; empty default avoids ordering dependency with vcsURL declaration.
|
|
flag.StringVar(vcsURL, "gitea-url", "", "Deprecated: use --vcs-url instead")
|
|
|
|
flag.Parse()
|
|
|
|
if *versionFlag {
|
|
fmt.Printf("review-bot %s\n", version)
|
|
os.Exit(0)
|
|
}
|
|
|
|
// Initialize structured logger
|
|
setupLogger(*logFormat, *verbosity)
|
|
|
|
slog.Info("review-bot starting", "version", version)
|
|
|
|
// Validate VCS provider
|
|
vcsProvider := vcs.VCSProvider(*provider)
|
|
if !vcsProvider.Valid() {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid --provider %q (valid: gitea, github)\n", *provider)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate required fields
|
|
isAICore := llm.Provider(*llmProvider) == llm.ProviderAICore
|
|
if *repo == "" || *prNum == "" || *reviewerToken == "" || *llmModel == "" {
|
|
fmt.Fprintf(os.Stderr, "Error: missing required flags or environment variables\n\n")
|
|
fmt.Fprintf(os.Stderr, "Required: --repo, --pr, --reviewer-token, --llm-model\n")
|
|
os.Exit(1)
|
|
}
|
|
// --vcs-url is required only for gitea provider
|
|
if vcsProvider == vcs.ProviderGitea && *vcsURL == "" {
|
|
fmt.Fprintf(os.Stderr, "Error: --vcs-url (or --gitea-url) is required for provider=gitea\n")
|
|
os.Exit(1)
|
|
}
|
|
if !isAICore && (*llmBaseURL == "" || *llmAPIKey == "") {
|
|
fmt.Fprintf(os.Stderr, "Error: --llm-base-url and --llm-api-key are required for provider=%s\n", *llmProvider)
|
|
os.Exit(1)
|
|
}
|
|
if isAICore && (*aicoreClientID == "" || *aicoreClientSecret == "" || *aicoreAuthURL == "" || *aicoreAPIURL == "") {
|
|
fmt.Fprintf(os.Stderr, "Error: AI Core credentials required for provider=aicore\n\n")
|
|
fmt.Fprintf(os.Stderr, "Required: --aicore-client-id, --aicore-client-secret, --aicore-auth-url, --aicore-api-url\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate persona flags are mutually exclusive
|
|
if *personaName != "" && *personaFile != "" {
|
|
slog.Error("--persona and --persona-file are mutually exclusive")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate reviewer-name: only safe characters allowed in sentinel
|
|
if err := validateReviewerName(*reviewerName); err != nil {
|
|
slog.Error("invalid reviewer name", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Parse repo owner/name
|
|
parts := strings.SplitN(*repo, "/", 2)
|
|
if len(parts) != 2 {
|
|
slog.Error("invalid repo format", "repo", *repo, "expected", "owner/name")
|
|
os.Exit(1)
|
|
}
|
|
owner, repoName := parts[0], parts[1]
|
|
|
|
// Parse PR number
|
|
prNumber, err := strconv.Atoi(*prNum)
|
|
if err != nil {
|
|
slog.Error("invalid PR number", "pr", *prNum, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Initialize VCS client
|
|
var client vcs.Client
|
|
switch vcsProvider {
|
|
case vcs.ProviderGitea:
|
|
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
|
|
client = gitea.NewAdapter(giteaClient)
|
|
case vcs.ProviderGitHub:
|
|
client = github.NewClient(*reviewerToken, *baseURL)
|
|
default:
|
|
panic("unreachable: provider validation should have caught " + vcsProvider.String())
|
|
}
|
|
slog.Info("VCS client initialized", "provider", vcsProvider)
|
|
|
|
// Initialize LLM client
|
|
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
|
|
if *llmTemp < 0 || *llmTemp > 2 {
|
|
slog.Error("invalid LLM temperature", "temperature", *llmTemp, "range", "0-2")
|
|
os.Exit(1)
|
|
}
|
|
if *llmTemp > 0 {
|
|
llmClient.WithTemperature(*llmTemp)
|
|
}
|
|
switch llm.Provider(*llmProvider) {
|
|
case llm.ProviderOpenAI, llm.ProviderAnthropic:
|
|
llmClient.WithProvider(llm.Provider(*llmProvider))
|
|
case llm.ProviderAICore:
|
|
llmClient.WithAICore(llm.AICoreConfig{
|
|
ClientID: *aicoreClientID,
|
|
ClientSecret: *aicoreClientSecret,
|
|
AuthURL: *aicoreAuthURL,
|
|
APIURL: *aicoreAPIURL,
|
|
ResourceGroup: *aicoreResourceGroup,
|
|
})
|
|
slog.Info("using SAP AI Core provider", "resource_group", *aicoreResourceGroup)
|
|
default:
|
|
slog.Error("invalid LLM provider", "provider", *llmProvider, "valid", "openai, anthropic, aicore")
|
|
os.Exit(1)
|
|
}
|
|
if *llmTimeout > 0 {
|
|
llmClient.WithTimeout(time.Duration(*llmTimeout) * time.Second)
|
|
}
|
|
|
|
// Create a top-level context. Timeout derived from LLM timeout + 1 min for other ops.
|
|
overallTimeout := time.Duration(*llmTimeout)*time.Second + time.Minute
|
|
ctx, cancel := context.WithTimeout(context.Background(), overallTimeout)
|
|
defer cancel()
|
|
|
|
// Load persona if specified
|
|
var persona *review.Persona
|
|
if *personaName != "" {
|
|
// Try loading from repo first, then fall back to built-in
|
|
repoPersonas, err := review.LoadRepoPersonas(ctx, client, owner, repoName)
|
|
if err != nil {
|
|
slog.Warn("could not load repo personas", "repo", owner+"/"+repoName, "error", err)
|
|
}
|
|
if p, ok := repoPersonas[*personaName]; ok {
|
|
persona = p
|
|
slog.Info("loaded repo persona", "persona", persona.Name, "display", persona.DisplayName, "repo", owner+"/"+repoName)
|
|
} else {
|
|
// Fall back to built-in
|
|
persona, err = review.LoadBuiltinPersona(*personaName)
|
|
if err != nil {
|
|
slog.Error("failed to load persona", "persona", *personaName, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName)
|
|
}
|
|
} else if *personaFile != "" {
|
|
resolvedPath, err := validateWorkspacePath(*personaFile, "persona-file")
|
|
if err != nil {
|
|
slog.Error("invalid persona-file path", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
persona, err = review.LoadPersona(resolvedPath)
|
|
if err != nil {
|
|
slog.Error("failed to load persona file", "file", *personaFile, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("loaded persona from file", "file", *personaFile, "persona", persona.Name)
|
|
}
|
|
|
|
slog.Info("reviewing pull request", "pr", prNumber, "repo", fmt.Sprintf("%s/%s", owner, repoName))
|
|
|
|
// Step 1: Fetch PR metadata
|
|
pr, err := client.GetPullRequest(ctx, owner, repoName, prNumber)
|
|
if err != nil {
|
|
slog.Error("failed to fetch PR", "pr", prNumber, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("fetched PR metadata", "pr", prNumber, "title", pr.Title)
|
|
|
|
// Step 2: Fetch diff
|
|
diff, err := client.GetPullRequestDiff(ctx, owner, repoName, prNumber)
|
|
if err != nil {
|
|
slog.Error("failed to fetch diff", "pr", prNumber, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("fetched diff", "bytes", len(diff))
|
|
|
|
// Step 3: Fetch full file content for modified files
|
|
fileContext := ""
|
|
files, err := client.GetPullRequestFiles(ctx, owner, repoName, prNumber)
|
|
if err != nil {
|
|
slog.Warn("could not fetch PR files list", "pr", prNumber, "error", err)
|
|
} else {
|
|
fileContext = fetchFileContext(ctx, client, owner, repoName, pr.Head.Ref, files)
|
|
slog.Debug("fetched file context", "files", len(files))
|
|
}
|
|
|
|
// Step 4: Check CI status
|
|
ciPassed := true
|
|
ciDetails := ""
|
|
if pr.Head.SHA != "" {
|
|
statuses, err := client.GetCommitStatuses(ctx, owner, repoName, pr.Head.SHA)
|
|
if err != nil {
|
|
slog.Warn("could not fetch CI status", "sha", pr.Head.SHA, "error", err)
|
|
} else {
|
|
ciPassed, ciDetails = evaluateCIStatus(statuses)
|
|
slog.Info("CI status checked", "passed", ciPassed)
|
|
}
|
|
}
|
|
|
|
// Step 5: Load conventions file if specified
|
|
conventions := ""
|
|
if *conventionsFile != "" {
|
|
content, err := client.GetFileContent(ctx, owner, repoName, *conventionsFile, "")
|
|
if err != nil {
|
|
slog.Warn("could not load conventions file", "file", *conventionsFile, "error", err)
|
|
} else {
|
|
conventions = content
|
|
slog.Debug("loaded conventions file", "file", *conventionsFile, "bytes", len(conventions))
|
|
}
|
|
}
|
|
|
|
// Step 6: Load patterns from external repo if specified
|
|
patterns := ""
|
|
if *patternsRepo != "" {
|
|
patterns = fetchPatterns(ctx, client, *patternsRepo, *patternsFiles)
|
|
slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns))
|
|
}
|
|
|
|
// Step 6b: Load additional system prompt if specified
|
|
additionalPrompt := ""
|
|
if *systemPromptFile != "" {
|
|
resolvedPath, err := validateWorkspacePath(*systemPromptFile, "system-prompt-file")
|
|
if err != nil {
|
|
slog.Error("invalid system-prompt-file path", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
data, err := os.ReadFile(resolvedPath)
|
|
if err != nil {
|
|
slog.Error("failed to read system prompt file", "path", *systemPromptFile, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
additionalPrompt = string(data)
|
|
slog.Debug("loaded system prompt file", "file", *systemPromptFile, "bytes", len(additionalPrompt))
|
|
}
|
|
|
|
// Step 7: Budget-aware prompt assembly
|
|
var systemBase string
|
|
if persona != nil {
|
|
systemBase = review.BuildPersonaSystemPrompt(persona)
|
|
slog.Debug("using persona system prompt", "persona", persona.Name)
|
|
} else {
|
|
systemBase = review.BuildSystemBase()
|
|
}
|
|
if additionalPrompt != "" {
|
|
systemBase += "\n\n## Additional Review Instructions\n\n" + additionalPrompt
|
|
}
|
|
sections := budget.Sections{
|
|
SystemBase: systemBase,
|
|
Patterns: patterns,
|
|
Conventions: conventions,
|
|
FileContext: fileContext,
|
|
Diff: diff,
|
|
UserMeta: review.BuildUserMeta(pr.Title, pr.Body, ciPassed, ciDetails),
|
|
}
|
|
budgetResult := budget.Fit(*llmModel, sections)
|
|
slog.Info("token budget calculated", "tokens", budgetResult.EstTokens, "limit", budget.LimitForModel(*llmModel), "model", *llmModel)
|
|
if len(budgetResult.Trimmed) > 0 {
|
|
slog.Warn("context trimmed to fit budget", "trimmed", budgetResult.Trimmed)
|
|
}
|
|
|
|
// Step 8: Call LLM (with retry on parse failure)
|
|
slog.Info("sending request to LLM", "model", *llmModel)
|
|
messages := []llm.Message{
|
|
{Role: "system", Content: budgetResult.SystemPrompt},
|
|
{Role: "user", Content: budgetResult.UserPrompt},
|
|
}
|
|
|
|
var response string
|
|
var result *review.ReviewResult
|
|
for attempt := 1; attempt <= 2; attempt++ {
|
|
if attempt > 1 {
|
|
slog.Warn("retrying LLM request after parse failure", "attempt", attempt)
|
|
time.Sleep(time.Second)
|
|
}
|
|
|
|
response, err = llmClient.Complete(ctx, messages)
|
|
if err != nil {
|
|
slog.Error("LLM request failed", "model", *llmModel, "error", err, "attempt", attempt)
|
|
if attempt == 2 {
|
|
os.Exit(1)
|
|
}
|
|
continue
|
|
}
|
|
slog.Info("LLM response received", "bytes", len(response), "attempt", attempt)
|
|
|
|
// Step 9: Parse response
|
|
result, err = review.ParseResponse(response)
|
|
if err != nil {
|
|
slog.Error("failed to parse LLM response", "error", err, "attempt", attempt)
|
|
if attempt == 2 {
|
|
os.Exit(1)
|
|
}
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
slog.Info("review parsed", "verdict", result.Verdict, "findings", len(result.Findings))
|
|
|
|
// Step 10: Format and post review
|
|
var reviewBody string
|
|
if persona != nil && persona.DisplayName != "" {
|
|
reviewBody = review.FormatMarkdownWithDisplay(result, persona.DisplayName, *reviewerName)
|
|
} else {
|
|
reviewBody = review.FormatMarkdown(result, *reviewerName)
|
|
}
|
|
|
|
// Add commit footer so readers know which commit was evaluated
|
|
if pr.Head.SHA != "" {
|
|
shortSHA := pr.Head.SHA
|
|
if len(shortSHA) > 8 {
|
|
shortSHA = shortSHA[:8]
|
|
}
|
|
reviewBody += fmt.Sprintf("\n\n---\n*Evaluated against %s*", shortSHA)
|
|
}
|
|
|
|
// Map verdict to canonical review event
|
|
event := verdictToEvent(result.Verdict)
|
|
|
|
if *dryRun {
|
|
fmt.Println("--- DRY RUN ---")
|
|
fmt.Printf("Event: %s\n\n", event)
|
|
fmt.Println(reviewBody)
|
|
return
|
|
}
|
|
|
|
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
|
|
|
|
// Stale check: verify HEAD hasn't moved since we started
|
|
evaluatedSHA := pr.Head.SHA
|
|
var currentSHA string
|
|
currentPR, err := client.GetPullRequest(ctx, owner, repoName, prNumber)
|
|
if err != nil {
|
|
slog.Warn("could not re-fetch PR for stale check", "pr", prNumber, "error", err)
|
|
} else {
|
|
currentSHA = currentPR.Head.SHA
|
|
}
|
|
if shouldSkipStaleReview(evaluatedSHA, currentSHA) {
|
|
slog.Warn("HEAD moved during review -- skipping stale review",
|
|
"evaluated", evaluatedSHA,
|
|
"current", currentSHA,
|
|
"pr", prNumber)
|
|
return
|
|
}
|
|
|
|
// Build line→position map for inline comments
|
|
lineToPosition := vcs.BuildLineToPositionMap(diff)
|
|
var inlineComments []vcs.ReviewComment
|
|
for _, f := range result.Findings {
|
|
if f.File == "" || f.Line <= 0 {
|
|
continue
|
|
}
|
|
pos, ok := lineToPosition[f.File][f.Line]
|
|
if !ok {
|
|
slog.Warn("line not in diff, skipping comment", "file", f.File, "line", f.Line)
|
|
continue
|
|
}
|
|
inlineComments = append(inlineComments, vcs.ReviewComment{
|
|
Path: f.File,
|
|
Position: pos,
|
|
CommitID: pr.Head.SHA,
|
|
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
|
|
})
|
|
}
|
|
if len(inlineComments) > 0 {
|
|
slog.Debug("attaching inline comments", "count", len(inlineComments))
|
|
}
|
|
|
|
// --- Review update strategy ---
|
|
// 1. POST new review first (gets non-stale approval badge on HEAD)
|
|
// 2. Then supersede old review with link to the new one
|
|
var oldReviews []vcs.Review
|
|
if *reviewerName != "" {
|
|
existingReviews, err := client.ListReviews(ctx, owner, repoName, prNumber)
|
|
if err != nil {
|
|
slog.Warn("could not list existing reviews", "pr", prNumber, "error", err)
|
|
} else {
|
|
if hasSharedToken(existingReviews, sentinel) {
|
|
slog.Warn("shared token mode: skipping supersede to avoid clobbering sibling review")
|
|
} else {
|
|
oldReviews = findAllOwnReviews(existingReviews, sentinel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Self-request as reviewer (Gitea-specific; ensures we appear in required-reviewer checks)
|
|
if selfReq, ok := client.(vcs.ReviewerSelfRequester); ok {
|
|
authUser, err := client.GetAuthenticatedUser(ctx)
|
|
if err != nil {
|
|
slog.Warn("could not determine authenticated user for reviewer self-request", "error", err)
|
|
} else if authUser != "" {
|
|
if err := selfReq.RequestReviewerSelf(ctx, owner, repoName, prNumber, authUser); err != nil {
|
|
slog.Warn("could not self-request as reviewer", "user", authUser, "error", err)
|
|
} else {
|
|
slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber)
|
|
}
|
|
}
|
|
} else {
|
|
slog.Debug("RequestReviewer not supported for provider, skipping")
|
|
}
|
|
|
|
// POST new review
|
|
slog.Info("posting review", "event", event, "pr", prNumber)
|
|
reviewReq := vcs.ReviewRequest{
|
|
Body: reviewBody,
|
|
Event: event,
|
|
Comments: inlineComments,
|
|
}
|
|
posted, err := client.PostReview(ctx, owner, repoName, prNumber, reviewReq)
|
|
if err != nil {
|
|
slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
|
|
|
|
// Supersede all old reviews via optional interface
|
|
if len(oldReviews) > 0 {
|
|
if superseder, ok := client.(vcs.ReviewSuperseder); ok {
|
|
if err := superseder.SupersedeReviews(ctx, owner, repoName, prNumber, oldReviews, posted.ID, *vcsURL, sentinel); err != nil {
|
|
slog.Error("failed to supersede old reviews", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
slog.Error("provider does not support review superseding", "provider", vcsProvider)
|
|
}
|
|
}
|
|
}
|
|
|
|
// verdictToEvent maps a verdict string from the LLM response to a canonical vcs.ReviewEvent.
|
|
func verdictToEvent(verdict string) vcs.ReviewEvent {
|
|
switch verdict {
|
|
case "APPROVE":
|
|
return vcs.ReviewEventApprove
|
|
case "REQUEST_CHANGES":
|
|
return vcs.ReviewEventRequestChanges
|
|
default:
|
|
return vcs.ReviewEventComment
|
|
}
|
|
}
|
|
|
|
// fetchFileContext fetches the full content of modified files from the PR branch.
|
|
func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref string, files []vcs.ChangedFile) string {
|
|
var sb strings.Builder
|
|
for _, f := range files {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
if f.Status == "removed" {
|
|
continue // Skip deleted files
|
|
}
|
|
content, err := client.GetFileContentAtRef(ctx, owner, repo, f.Filename, ref)
|
|
if err != nil {
|
|
slog.Warn("could not fetch file content", "file", f.Filename, "error", err)
|
|
continue
|
|
}
|
|
sb.WriteString(fmt.Sprintf("--- %s ---\n", f.Filename))
|
|
sb.WriteString("```\n")
|
|
sb.WriteString(content)
|
|
sb.WriteString("\n```\n\n")
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// fetchPatterns fetches pattern files from one or more external repos.
|
|
// patternsRepo is comma-separated list of owner/name repos.
|
|
// patternsFiles is comma-separated list of file paths or directories.
|
|
// If a path ends with / or is a directory, all files within it are fetched recursively.
|
|
// Empty entries in patternsFiles are skipped (no implicit repo-root fetch).
|
|
func fetchPatterns(ctx context.Context, client vcs.FileReader, patternsRepo, patternsFiles string) string {
|
|
var sb strings.Builder
|
|
|
|
repos := strings.Split(patternsRepo, ",")
|
|
paths := strings.Split(patternsFiles, ",")
|
|
|
|
for _, repoRef := range repos {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
repoRef = strings.TrimSpace(repoRef)
|
|
if repoRef == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(repoRef, "/", 2)
|
|
if len(parts) != 2 {
|
|
slog.Warn("invalid patterns-repo format", "repo", repoRef, "expected", "owner/name")
|
|
continue
|
|
}
|
|
owner, repo := parts[0], parts[1]
|
|
|
|
var repoLoadedFiles []string
|
|
var repoSkippedFiles []string
|
|
|
|
for _, path := range paths {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
|
|
files, err := vcs.GetAllFilesInPath(ctx, client, owner, repo, path)
|
|
if err != nil {
|
|
slog.Warn("could not fetch patterns", "path", path, "repo", repoRef, "error", err)
|
|
continue
|
|
}
|
|
|
|
for filePath, content := range files {
|
|
// Only include markdown and text files as patterns
|
|
if !isPatternFile(filePath) {
|
|
repoSkippedFiles = append(repoSkippedFiles, filePath)
|
|
continue
|
|
}
|
|
repoLoadedFiles = append(repoLoadedFiles, filePath)
|
|
sb.WriteString(fmt.Sprintf("### %s/%s\n\n%s\n\n", repoRef, filePath, content))
|
|
}
|
|
}
|
|
|
|
if len(repoLoadedFiles) > 0 {
|
|
slog.Info("loaded pattern files", "repo", repoRef, "count", len(repoLoadedFiles), "files", repoLoadedFiles)
|
|
} else {
|
|
slog.Warn("no pattern files loaded", "repo", repoRef, "paths", paths)
|
|
}
|
|
if len(repoSkippedFiles) > 0 {
|
|
slog.Debug("skipped non-pattern files", "repo", repoRef, "count", len(repoSkippedFiles), "files", repoSkippedFiles)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// isPatternFile returns true if the file should be included as pattern content.
|
|
func isPatternFile(path string) bool {
|
|
lower := strings.ToLower(path)
|
|
return strings.HasSuffix(lower, ".md") ||
|
|
strings.HasSuffix(lower, ".txt") ||
|
|
strings.HasSuffix(lower, ".yml") ||
|
|
strings.HasSuffix(lower, ".yaml")
|
|
}
|
|
|
|
// evaluateCIStatus checks if all CI statuses indicate success.
|
|
// Returns passed=true if no checks have failed (pending checks are not treated as failures).
|
|
func evaluateCIStatus(statuses []vcs.CommitStatus) (passed bool, details string) {
|
|
if len(statuses) == 0 {
|
|
return true, "no CI statuses found"
|
|
}
|
|
|
|
var failed []string
|
|
var pending int
|
|
for _, s := range statuses {
|
|
switch s.Status {
|
|
case "success":
|
|
// good
|
|
case "pending":
|
|
pending++
|
|
case "failure", "error":
|
|
failed = append(failed, fmt.Sprintf("%s: %s", s.Context, s.Description))
|
|
}
|
|
}
|
|
|
|
if len(failed) > 0 {
|
|
return false, strings.Join(failed, "; ")
|
|
}
|
|
if pending > 0 {
|
|
return true, fmt.Sprintf("no failures (%d pending)", pending)
|
|
}
|
|
return true, "all checks passed"
|
|
}
|
|
|
|
func envOrDefault(key, defaultVal string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
func envOrDefaultFloat(key string, defaultVal float64) float64 {
|
|
if v := os.Getenv(key); v != "" {
|
|
f, err := strconv.ParseFloat(v, 64)
|
|
if err == nil {
|
|
return f
|
|
}
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
func envOrDefaultInt(key string, defaultVal int) int {
|
|
if v := os.Getenv(key); v != "" {
|
|
i, err := strconv.Atoi(v)
|
|
if err == nil {
|
|
return i
|
|
}
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// validateReviewerName checks that the name contains only safe characters
|
|
// for embedding in an HTML comment sentinel ([a-zA-Z0-9_-]).
|
|
func validateReviewerName(name string) error {
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
for _, ch := range name {
|
|
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
|
|
return fmt.Errorf("reviewer-name must contain only [a-zA-Z0-9_-] (got %q)", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateWorkspacePath ensures a file path is within the workspace and resolves
|
|
// symlinks to prevent traversal attacks. Returns the resolved absolute path or
|
|
// an error if the path is outside the workspace.
|
|
func validateWorkspacePath(path, pathName string) (string, error) {
|
|
workspace := os.Getenv("GITHUB_WORKSPACE")
|
|
if workspace == "" {
|
|
workspace, _ = os.Getwd()
|
|
}
|
|
absWorkspace, err := filepath.Abs(workspace)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
|
|
}
|
|
|
|
// Join and clean the path
|
|
fullPath := filepath.Join(absWorkspace, path)
|
|
fullPath = filepath.Clean(fullPath)
|
|
|
|
// Check path is within workspace using filepath.Rel (more robust than HasPrefix)
|
|
rel, err := filepath.Rel(absWorkspace, fullPath)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
return "", fmt.Errorf("%s resolves outside workspace: path=%s workspace=%s", pathName, fullPath, absWorkspace)
|
|
}
|
|
|
|
// Resolve symlinks and re-validate to prevent symlink traversal
|
|
resolvedPath, err := filepath.EvalSymlinks(fullPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve %s: %w", pathName, err)
|
|
}
|
|
|
|
relResolved, err := filepath.Rel(absWorkspace, resolvedPath)
|
|
if err != nil || strings.HasPrefix(relResolved, "..") {
|
|
return "", fmt.Errorf("%s symlink resolves outside workspace: resolved=%s workspace=%s", pathName, resolvedPath, absWorkspace)
|
|
}
|
|
|
|
return resolvedPath, nil
|
|
}
|
|
|
|
// hasSharedToken detects if another review-bot role posted under the same
|
|
// VCS user. This indicates misconfiguration where two roles share a token
|
|
// instead of having separate accounts. Returns true if shared token
|
|
// detected (caller should skip update-in-place logic to avoid clobbering).
|
|
func hasSharedToken(reviews []vcs.Review, ownSentinel string) bool {
|
|
ownLogin := ""
|
|
for _, r := range reviews {
|
|
if strings.Contains(r.Body, ownSentinel) {
|
|
ownLogin = r.User.Login
|
|
break
|
|
}
|
|
}
|
|
if ownLogin == "" {
|
|
return false
|
|
}
|
|
for _, r := range reviews {
|
|
if r.User.Login == ownLogin && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
|
|
slog.Warn("shared token detected -- another review-bot role is using the same VCS user",
|
|
"sibling_role", extractSentinelName(r.Body), "user", ownLogin)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractSentinelName pulls the reviewer name from a sentinel comment.
|
|
func extractSentinelName(body string) string {
|
|
const prefix = "<!-- review-bot:"
|
|
const suffix = " -->"
|
|
idx := strings.Index(body, prefix)
|
|
if idx < 0 {
|
|
return "unknown"
|
|
}
|
|
rest := body[idx+len(prefix):]
|
|
end := strings.Index(rest, suffix)
|
|
if end < 0 {
|
|
return "unknown"
|
|
}
|
|
name := rest[:end]
|
|
// Sanitize: strip control characters to prevent log injection.
|
|
name = strings.Map(func(r rune) rune {
|
|
if r < 0x20 || r == 0x7f {
|
|
return -1
|
|
}
|
|
return r
|
|
}, name)
|
|
if len(name) > 64 {
|
|
name = name[:64]
|
|
}
|
|
if name == "" {
|
|
return "unknown"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// findAllOwnReviews returns all non-superseded reviews matching the sentinel.
|
|
func findAllOwnReviews(reviews []vcs.Review, sentinel string) []vcs.Review {
|
|
var result []vcs.Review
|
|
for i := range reviews {
|
|
if !strings.Contains(reviews[i].Body, sentinel) {
|
|
continue
|
|
}
|
|
if strings.Contains(reviews[i].Body, "~~Original review~~") {
|
|
continue
|
|
}
|
|
result = append(result, reviews[i])
|
|
}
|
|
return result
|
|
}
|
|
|
|
// shouldSkipStaleReview reports whether to skip posting because HEAD moved.
|
|
// Returns true (skip) if evaluatedSHA differs from currentSHA.
|
|
// Returns false (don't skip) if:
|
|
// - SHAs match (no movement)
|
|
// - currentSHA is empty (re-fetch failed; prefer posting stale over failing)
|
|
func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
|
|
if currentSHA == "" {
|
|
// Re-fetch failed; better to post potentially stale than fail
|
|
return false
|
|
}
|
|
return evaluatedSHA != currentSHA
|
|
}
|