Files
homelab/review-bot/backend.go
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

103 lines
3.0 KiB
Go

package main
import (
"context"
_ "embed"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
//go:embed prompt.md
var reviewPrompt string
// A Backend runs the review agent of one provider. The agent reads the repository through a
// read-only Gitea connection of that provider and answers with the report.
type Backend interface {
// Prepare makes the backend ready to review with the given models of it. It is retried until
// it succeeds.
Prepare(ctx context.Context, models []string) error
// Ask runs one review conversation with model and returns the agent's answer.
Ask(ctx context.Context, model, task string) (Answer, error)
}
type Answer struct {
Text string
ToolCalls int
Violations []string
}
// apiError is a non-2xx answer of a provider API. Whatever limit headers come back are kept so
// they can be logged and used.
type apiError struct {
Provider string
Method, Path string
Status int
Body string
RetryAfter time.Duration
HasRetryAfter bool
Limits map[string]string
}
func (e *apiError) Error() string {
return fmt.Sprintf("%s %s %s: HTTP %d: %s", e.Provider, e.Method, e.Path, e.Status, e.Body)
}
// newAPIError reads the status, the Retry-After and the limit headers of a failed response.
func newAPIError(provider, method, path string, resp *http.Response, body []byte) *apiError {
e := &apiError{Provider: provider, Method: method, Path: path, Status: resp.StatusCode, Body: snippet(body),
Limits: limitHeaders(resp.Header)}
if secs, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil && secs >= 0 {
e.RetryAfter, e.HasRetryAfter = time.Duration(secs)*time.Second, true
}
return e
}
// limitHeaders collects the Retry-After and rate limit headers of a response.
func limitHeaders(h http.Header) map[string]string {
out := map[string]string{}
for k, v := range h {
l := strings.ToLower(k)
if l == "retry-after" || strings.Contains(l, "ratelimit") || strings.Contains(l, "rate-limit") {
out[k] = strings.Join(v, ",")
}
}
return out
}
// streamError is a response stream that broke off or reported an error before the answer was
// complete.
type streamError struct {
Provider string
Err error
}
func (e *streamError) Error() string { return fmt.Sprintf("%s stream: %v", e.Provider, e.Err) }
func (e *streamError) Unwrap() error { return e.Err }
// retryable says whether err is worth asking again after: a rate limit (429), a gateway or
// overload failure (502, 503, 504, 529), or a response stream that broke off.
func retryable(err error) bool {
if api := asAPIError(err); api != nil {
switch api.Status {
case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable,
http.StatusGatewayTimeout, 529:
return true
}
return false
}
var s *streamError
return errors.As(err, &s) && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled)
}
func asAPIError(err error) *apiError {
var api *apiError
if errors.As(err, &api) {
return api
}
return nil
}