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>
204 lines
6.1 KiB
Go
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...)
|
|
}
|