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>
103 lines
3.0 KiB
Go
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
|
|
}
|