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>
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
// review-bot reviews pull requests with an agent that reads the repository: a Mistral agent with
|
|
// the Gitea connector, or Claude with the Gitea MCP server; TypeSafe can pick the model per pull
|
|
// request. Gitea's pull_request webhook queues the review at once; a periodic sweep over the
|
|
// configured repositories catches deliveries that were lost. The result is posted as the review
|
|
// bot. Nothing is stored: the state is the marker in the bot's own review.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
func main() {
|
|
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
cfg, err := loadConfig(os.Getenv, os.ReadFile)
|
|
if err != nil {
|
|
log.Error("invalid configuration", "err", err)
|
|
os.Exit(2)
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
if err := run(ctx, cfg, log); err != nil {
|
|
log.Error("stopped", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(ctx context.Context, cfg Config, log *slog.Logger) error {
|
|
metrics := newMetrics()
|
|
gitea := &Gitea{base: cfg.GiteaURL, token: cfg.ReviewToken, http: &http.Client{Timeout: time.Minute}}
|
|
status := newTracker(gitea, log, metrics)
|
|
go status.Run(ctx)
|
|
for _, w := range cfg.Warnings {
|
|
log.Warn("configuration", "warning", w)
|
|
}
|
|
reviewer := &Reviewer{
|
|
cfg: cfg,
|
|
gitea: gitea,
|
|
// The conversations stream; the review timeout bounds them, not the client.
|
|
backends: map[string]Backend{
|
|
"mistral": &Mistral{base: cfg.MistralURL, key: cfg.MistralKey, agentPrefix: cfg.AgentName,
|
|
connectorID: cfg.ConnectorID, http: &http.Client{}, recoverTries: 3, recoverWait: 30 * time.Second},
|
|
"anthropic": newAnthropic(cfg),
|
|
},
|
|
log: log,
|
|
metrics: metrics,
|
|
status: status,
|
|
}
|
|
if cfg.Selector == "typesafe" {
|
|
reviewer.selector = &TypeSafe{url: cfg.TypeSafeURL, key: cfg.TypeSafeKey, model: cfg.TypeSafeModel,
|
|
http: &http.Client{Timeout: time.Minute}}
|
|
}
|
|
queue := newQueue(reviewer, cfg.QueueSize, log, metrics)
|
|
queue.Start(ctx, cfg.Workers)
|
|
|
|
for _, b := range backends {
|
|
if models := modelsOf(cfg.Models, b); len(models) > 0 {
|
|
go prepare(ctx, b, models, reviewer, log)
|
|
}
|
|
}
|
|
go sweepLoop(ctx, cfg, reviewer, queue)
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.Listen,
|
|
Handler: (&Server{cfg: cfg, queue: queue, reviewer: reviewer, metrics: metrics, log: log}).routes(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
}
|
|
failed := make(chan error, 1)
|
|
go func() { failed <- srv.ListenAndServe() }()
|
|
log.Info("listening", "addr", cfg.Listen, "repos", cfg.Repos)
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
case err := <-failed:
|
|
if !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
}
|
|
log.Info("shutting down")
|
|
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdown)
|
|
queue.Stop(cfg.ShutdownWait)
|
|
return nil
|
|
}
|
|
|
|
// prepare readies one backend for its models, and retries until it works.
|
|
func prepare(ctx context.Context, backend string, models []string, r *Reviewer, log *slog.Logger) {
|
|
for wait := 10 * time.Second; ; wait = min(wait*2, 5*time.Minute) {
|
|
attempt, cancel := context.WithTimeout(ctx, time.Minute)
|
|
err := r.backends[backend].Prepare(attempt, models)
|
|
cancel()
|
|
if err == nil {
|
|
r.setReady(backend, true)
|
|
log.Info("backend ready", "backend", backend, "models", models)
|
|
return
|
|
}
|
|
log.Error("cannot prepare the backend", "backend", backend, "err", err, "retry_in", wait.String())
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(wait):
|
|
}
|
|
}
|
|
}
|
|
|
|
func sweepLoop(ctx context.Context, cfg Config, r *Reviewer, q *Queue) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(time.Minute):
|
|
}
|
|
for {
|
|
r.Sweep(ctx, q)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(cfg.Sweep):
|
|
}
|
|
}
|
|
}
|