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

102 lines
3.1 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// PRFile is one changed file of a pull request.
type PRFile struct {
Filename string `json:"filename"`
Status string `json:"status"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
// Choice is the selector's pick with the probability of every model.
type Choice struct {
Model string
Confidence float64
Probabilities map[string]float64
}
// selectInstructions is the question the selector answers for every pull request.
const selectInstructions = "Pick the model that should review the pull request in `pull_request`. " +
"The review reads the repository, checks the change for bugs, security problems and broken behaviour, " +
"and must be thorough. Prefer a cheaper or faster model when the change is small and low-risk " +
"(documentation, version bumps, simple configuration). Pick a stronger model when the change is large, " +
"spans several components, or touches security, authentication, secrets, network policy, concurrency, " +
"data migrations or anything that is hard to undo."
// TypeSafe picks the review model with a System One Choice question.
type TypeSafe struct {
url string
key string
model string
http *http.Client
}
func (t *TypeSafe) Select(ctx context.Context, slug string, pr PR, files []PRFile, models []Model) (Choice, error) {
criteria := map[string]any{}
for _, m := range models {
if m.Description != "" {
criteria[m.ID()] = m.Description
} else {
criteria[m.ID()] = nil
}
}
state := map[string]any{"pull_request": map[string]any{
"repository": slug, "title": pr.Title, "description": pr.Body,
"additions": pr.Additions, "deletions": pr.Deletions, "changed_files": pr.ChangedFiles,
"files": files,
}}
in := map[string]any{
"state": state,
"model": t.model,
"questions": map[string]any{"model": map[string]any{
"type": "choice", "instructions": selectInstructions, "criteria": criteria,
}},
}
b, err := json.Marshal(in)
if err != nil {
return Choice{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(b))
if err != nil {
return Choice{}, err
}
req.Header.Set("Authorization", "Bearer "+t.key)
req.Header.Set("Content-Type", "application/json")
resp, err := t.http.Do(req)
if err != nil {
return Choice{}, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Choice{}, err
}
if resp.StatusCode != http.StatusOK {
return Choice{}, newAPIError("typesafe", http.MethodPost, "/v1/systemone", resp, data)
}
var out struct {
Answers map[string]struct {
Choice string `json:"choice"`
Confidence float64 `json:"confidence"`
Probabilities map[string]float64 `json:"probabilities"`
} `json:"answers"`
}
if err := json.Unmarshal(data, &out); err != nil {
return Choice{}, err
}
a, ok := out.Answers["model"]
if !ok || a.Choice == "" {
return Choice{}, fmt.Errorf("typesafe answered without a model: %s", snippet(data))
}
return Choice{Model: a.Choice, Confidence: a.Confidence, Probabilities: a.Probabilities}, nil
}