Files
claude-botandClaude Opus 5.5 16d957dff1
review-bot queued
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
feat(review-bot): stream Mistral, add an Anthropic backend and a TypeSafe model selector
Mistral cut every POST /conversations off with HTTP 502 exactly ten minutes
after it started (about 30 times in the last 7 days), and every retry restarted
the whole review, so a review needing more than ten minutes could never finish.

- Mistral: the conversation now streams (stream=true, SSE). A stream that
  breaks off after the conversation started is looked up in the stored history
  (store=true, GET /conversations/{id}/history) before it is asked again. A
  broken stream is retryable like 429/502/503/504; a
  conversation.response.error event is not. One agent per model, named
  <AGENT_NAME>-<model>.
- Anthropic: new backend on the Messages API (Go SDK, streaming, adaptive
  thinking, effort high). Anthropic connects to the Gitea MCP server
  (gitea-mcp.renner.uno/mcp) with a read-only token; the toolset is in
  allowlist mode with only the read tools enabled. Paused turns are resumed,
  refusals use the server-side fallback on Opus/Sonnet 5.5.
- Models: MODELS lists backend:model = description, the first is the default.
  A backend whose keys are missing is switched off with a warning instead of
  stopping the bot.
- Selector: SELECTOR=typesafe asks TypeSafe (Jev, Choice) which model reviews
  each PR, from the title, description, size and changed files. A failure or
  an unusable choice falls back to the default model.
- Secrets: the Anthropic and TypeSafe keys reuse the wiki keys
  (apps/ai/wiki-anthropic, apps/ai/wiki-typesafe), like the Mistral key does.
  The MCP token is a second review-bot PAT scoped read:repository,read:issue,
  read:user, minted by a new rotate-mint-mcp step into
  infrastructure/review-bot#mcp_token (seeded with a placeholder). Each
  optional key has its own ExternalSecret and optional env ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:08:22 +02:00

204 lines
6.1 KiB
Go

package main
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// defaultMCPTools are the read tools of the Gitea MCP server that the Anthropic backend enables.
var defaultMCPTools = []string{
"get_commit", "get_dir_contents", "get_file_contents", "get_repository_tree", "get_tag", "get_release",
"get_latest_release", "issue_read", "label_read", "list_branches", "list_commits", "list_issues",
"list_pull_requests", "list_releases", "list_tags", "milestone_read", "pull_request_read", "search_issues",
"search_repos",
}
// Config is read from the environment. A secret comes from NAME, or from the file named by
// NAME_FILE when that is set (a mounted Kubernetes Secret).
type Config struct {
Listen string
GiteaURL string
BotLogin string
ReviewToken string
WebhookSecret string
// Models the bot reviews with; the first is the default. A backend whose credentials are
// missing drops its models.
Models []Model
MistralURL string
MistralKey string
AgentName string // prefix of the Mistral agent names, one agent per model
ConnectorID string
AnthropicURL string
AnthropicKey string
AnthropicEffort string
MCPURL string
MCPToken string // read-only Gitea token Anthropic uses on the MCP server
MCPTools []string
// Selector "typesafe" picks the model per pull request; empty uses the default model.
Selector string
TypeSafeURL string
TypeSafeKey string
TypeSafeModel string
Repos []string
Workers int
QueueSize int
Sweep time.Duration
ReviewTimeout time.Duration
ShutdownWait time.Duration
// A review that runs into a rate limit or a gateway failure is asked again after a wait.
RateLimitRetries int
RateLimitWait time.Duration
// Warnings are configuration problems the bot works around.
Warnings []string
}
func loadConfig(getenv func(string) string, readFile func(string) ([]byte, error)) (Config, error) {
var errs []error
text := func(name, def string) string {
if v := strings.TrimSpace(getenv(name)); v != "" {
return v
}
return def
}
optional := func(name string) string {
if path := strings.TrimSpace(getenv(name + "_FILE")); path != "" {
b, err := readFile(path)
if err != nil {
errs = append(errs, fmt.Errorf("%s_FILE: %w", name, err))
return ""
}
return strings.TrimSpace(string(b))
}
return strings.TrimSpace(getenv(name))
}
secret := func(name string) string {
v := optional(name)
if v == "" && strings.TrimSpace(getenv(name+"_FILE")) == "" {
errs = append(errs, fmt.Errorf("%s is not set", name))
}
return v
}
number := func(name string, def int) int {
v := text(name, "")
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
errs = append(errs, fmt.Errorf("%s must be a positive number, got %q", name, v))
return def
}
return n
}
duration := func(name string, def time.Duration) time.Duration {
v := text(name, "")
if v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
errs = append(errs, fmt.Errorf("%s must be a positive duration, got %q", name, v))
return def
}
return d
}
list := func(name string, def []string) []string {
v := text(name, "")
if v == "" {
return def
}
return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' })
}
c := Config{
Listen: text("LISTEN", ":8080"),
GiteaURL: strings.TrimRight(text("GITEA_URL", "https://gitea.renner.uno"), "/"),
BotLogin: text("BOT_LOGIN", "review-bot"),
ReviewToken: secret("REVIEW_TOKEN"),
WebhookSecret: secret("WEBHOOK_SECRET"),
MistralURL: strings.TrimRight(text("MISTRAL_URL", "https://api.mistral.ai/v1"), "/"),
MistralKey: optional("MISTRAL_API_KEY"),
AgentName: text("AGENT_NAME", "pr-review"),
ConnectorID: text("CONNECTOR_ID", ""),
AnthropicURL: strings.TrimRight(text("ANTHROPIC_URL", ""), "/"),
AnthropicKey: optional("ANTHROPIC_API_KEY"),
AnthropicEffort: text("ANTHROPIC_EFFORT", "high"),
MCPURL: text("MCP_URL", "https://gitea-mcp.renner.uno/mcp"),
MCPToken: optional("MCP_TOKEN"),
MCPTools: list("MCP_TOOLS", defaultMCPTools),
Selector: text("SELECTOR", ""),
TypeSafeURL: text("TYPESAFE_URL", "https://api.typesafe.ai/v1/systemone"),
TypeSafeKey: optional("TYPESAFE_API_KEY"),
TypeSafeModel: text("TYPESAFE_MODEL", "jev-latest"),
Workers: number("WORKERS", 1),
QueueSize: number("QUEUE_SIZE", 32),
Sweep: duration("SWEEP_INTERVAL", 30*time.Minute),
ReviewTimeout: duration("REVIEW_TIMEOUT", 25*time.Minute),
ShutdownWait: duration("SHUTDOWN_WAIT", 9*time.Minute),
RateLimitRetries: number("RATE_LIMIT_RETRIES", 3),
RateLimitWait: duration("RATE_LIMIT_WAIT", time.Minute),
}
models, err := parseModels(text("MODELS", "mistral:mistral-large-4-0"))
if err != nil {
errs = append(errs, err)
}
missing := map[string]string{}
if c.MistralKey == "" {
missing["mistral"] = "MISTRAL_API_KEY is not set"
} else if c.ConnectorID == "" {
missing["mistral"] = "CONNECTOR_ID is not set"
}
if c.AnthropicKey == "" {
missing["anthropic"] = "ANTHROPIC_API_KEY is not set"
} else if c.MCPToken == "" {
missing["anthropic"] = "MCP_TOKEN is not set"
}
for _, m := range models {
if why, ok := missing[m.Backend]; ok {
c.Warnings = append(c.Warnings, fmt.Sprintf("%s is off: %s", m.ID(), why))
continue
}
c.Models = append(c.Models, m)
}
if err == nil && len(c.Models) == 0 {
errs = append(errs, errors.New("no model of MODELS is usable: "+strings.Join(c.Warnings, "; ")))
}
switch c.Selector {
case "":
case "typesafe":
if c.TypeSafeKey == "" {
c.Warnings = append(c.Warnings, "the typesafe selector is off: TYPESAFE_API_KEY is not set")
c.Selector = ""
}
default:
errs = append(errs, fmt.Errorf("SELECTOR must be empty or typesafe, got %q", c.Selector))
}
for _, r := range list("REPOS", nil) {
if strings.Count(r, "/") != 1 {
errs = append(errs, fmt.Errorf("REPOS entry %q is not owner/repo", r))
continue
}
c.Repos = append(c.Repos, r)
}
if len(c.Repos) == 0 {
errs = append(errs, errors.New("REPOS is empty"))
}
return c, errors.Join(errs...)
}