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>
102 lines
3.1 KiB
Go
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
|
|
}
|