f19b271642
Rebuild the Gitea Codex review bot from the product contract with a Go HTTP service, durable SQL queue, typed Gitea client, isolated runner, and deployment updates. Co-Authored-By: Claude <noreply@anthropic.com>
218 lines
8.9 KiB
Go
218 lines
8.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea-codex-bot/internal/commands"
|
|
"gitea-codex-bot/internal/config"
|
|
"gitea-codex-bot/internal/domain"
|
|
"gitea-codex-bot/internal/gitea"
|
|
"gitea-codex-bot/internal/review"
|
|
"gitea-codex-bot/internal/store"
|
|
"gitea-codex-bot/internal/webhook"
|
|
)
|
|
|
|
type Server struct {
|
|
settings config.Settings
|
|
store store.Store
|
|
gitea *gitea.Client
|
|
logger *slog.Logger
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
func New(settings config.Settings, st store.Store, client *gitea.Client, logger *slog.Logger) *Server {
|
|
s := &Server{settings: settings, store: st, gitea: client, logger: logger, mux: http.NewServeMux()}
|
|
s.routes()
|
|
return s
|
|
}
|
|
func (s *Server) Handler() http.Handler { return s }
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" || r.URL.Path == "/healthz" || r.URL.Path == "/healthz/latest-job" || r.URL.Path == "/healthz/latest-failure" || r.URL.Path == "/webhook/gitea" {
|
|
s.mux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html") {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = io.WriteString(w, browser404)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"detail": "Not Found"})
|
|
}
|
|
func (s *Server) routes() {
|
|
s.mux.HandleFunc("/", s.root)
|
|
s.mux.HandleFunc("/healthz", s.health)
|
|
s.mux.HandleFunc("/healthz/latest-job", s.latestJob)
|
|
s.mux.HandleFunc("/healthz/latest-failure", s.latestFailure)
|
|
s.mux.HandleFunc("/webhook/gitea", s.webhook)
|
|
}
|
|
func (s *Server) root(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = io.WriteString(w, landingPage)
|
|
}
|
|
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"status": "ok"})
|
|
}
|
|
func (s *Server) latestFailure(w http.ResponseWriter, r *http.Request) {
|
|
job, err := s.store.LatestFailedJob(r.Context())
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if job == nil {
|
|
writeJSON(w, 200, map[string]any{"status": "ok", "has_failed_job": false})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"status": "ok", "has_failed_job": true, "job_id": job.ID, "repo": job.Repo, "pr_number": job.PRNumber, "command": job.Command, "head_sha": job.HeadSHA, "error": limit(job.LastError, 2000), "failed_at": timeString(job.FinishedAt)})
|
|
}
|
|
func (s *Server) latestJob(w http.ResponseWriter, r *http.Request) {
|
|
job, err := s.store.LatestJob(r.Context())
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if job == nil {
|
|
writeJSON(w, 200, map[string]any{"status": "ok", "has_job": false})
|
|
return
|
|
}
|
|
summary := ""
|
|
if len(job.ResultJSON) > 0 {
|
|
var result domain.ReviewResult
|
|
if json.Unmarshal(job.ResultJSON, &result) == nil {
|
|
summary = limit(result.Summary, 2000)
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"status": "ok", "has_job": true, "job_id": job.ID, "repo": job.Repo, "pr_number": job.PRNumber, "command": job.Command, "head_sha": job.HeadSHA, "job_status": job.Status, "error": limit(job.LastError, 2000), "result_summary": summary, "created_at": job.CreatedAt, "started_at": job.StartedAt, "finished_at": job.FinishedAt})
|
|
}
|
|
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.settings.WebhookMaxBytes))
|
|
if err != nil {
|
|
writeJSON(w, 413, map[string]any{"detail": "request body too large"})
|
|
return
|
|
}
|
|
if !webhook.VerifySignature(body, s.settings.GiteaWebhookSecret, r.Header.Get("X-Gitea-Signature")) {
|
|
writeJSON(w, 401, map[string]any{"detail": "invalid signature"})
|
|
return
|
|
}
|
|
eventName := strings.TrimSpace(r.Header.Get("X-Gitea-Event"))
|
|
if eventName != "issue_comment" && eventName != "pull_request_comment" {
|
|
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "event ignored"})
|
|
return
|
|
}
|
|
event, err := webhook.ParseEvent(eventName, r.Header.Get("X-Gitea-Delivery"), body)
|
|
if err != nil {
|
|
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "not a pull request comment"})
|
|
return
|
|
}
|
|
if strings.EqualFold(event.Sender, s.settings.GiteaBotUsername) {
|
|
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "bot comment ignored"})
|
|
return
|
|
}
|
|
if !s.settings.RepoAllowed(event.Repo) {
|
|
s.logger.Info("Webhook ignored: repo not in ALLOWED_REPOS", "repo", event.Repo, "pr", event.PRNumber, "comment_id", event.CommentID)
|
|
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "repo not allowed"})
|
|
return
|
|
}
|
|
cmd, ok := commands.Parse(event.CommentBody, s.settings.Aliases())
|
|
if !ok {
|
|
attempted := commands.DetectPrefixedCommand(event.CommentBody, s.settings.Aliases())
|
|
if attempted != "" {
|
|
message := fmt.Sprintf("⚠️ Command `@codex %s` is not supported. Try `@codex -h`.", attempted)
|
|
if attempted == "fix" {
|
|
message = "⚠️ `@codex fix` is no longer supported on this bot."
|
|
}
|
|
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, message)
|
|
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "unsupported command", "command": attempted})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "no codex command"})
|
|
return
|
|
}
|
|
inserted, err := s.store.InsertWebhookEvent(r.Context(), event)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if !inserted {
|
|
writeJSON(w, 200, map[string]any{"accepted": true, "reason": "duplicate event"})
|
|
return
|
|
}
|
|
if cmd.IsReview() {
|
|
pr, prErr := s.gitea.GetPullRequest(r.Context(), event.Repo, event.PRNumber)
|
|
if prErr == nil {
|
|
event.HeadSHA = pr.HeadSHA
|
|
}
|
|
cfg := review.MissingRepoConfig()
|
|
if prErr == nil {
|
|
if text, configured, cfgErr := s.gitea.GetFileContent(r.Context(), event.Repo, ".codex-review.yml", event.HeadSHA); cfgErr == nil && configured {
|
|
cfg, err = review.ParseRepoConfig(text)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if !cfg.Enabled {
|
|
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, review.FormatDisabledAck())
|
|
writeJSON(w, 200, map[string]any{"accepted": true, "reason": "review disabled by repo config"})
|
|
return
|
|
}
|
|
if cmd.Name != "rerun" {
|
|
remaining, err := s.store.CooldownRemaining(r.Context(), event.Repo, event.PRNumber, time.Duration(s.settings.CooldownSeconds)*time.Second)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if remaining > 0 {
|
|
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, review.FormatCooldownAck(remaining))
|
|
writeJSON(w, 200, map[string]any{"accepted": true, "reason": "cooldown active", "cooldown_seconds_remaining": remaining})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
job, err := s.store.EnqueueJob(r.Context(), event, cmd)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if cmd.IsReview() {
|
|
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, review.FormatQueueAck(event.HeadSHA))
|
|
}
|
|
writeJSON(w, 200, map[string]any{"accepted": true, "job_id": job.ID, "status": "queued"})
|
|
}
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
func writeError(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, context.Canceled) {
|
|
writeJSON(w, 499, map[string]any{"detail": "request canceled"})
|
|
return
|
|
}
|
|
writeJSON(w, 500, map[string]any{"detail": "internal server error"})
|
|
}
|
|
func timeString(value *time.Time) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return value.Format(time.RFC3339Nano)
|
|
}
|
|
func limit(value string, n int) string {
|
|
if len(value) <= n {
|
|
return value
|
|
}
|
|
return value[:n]
|
|
}
|
|
|
|
const landingPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gitea Codex Review Bot</title><style>body{margin:0;background:#020617;color:#e2e8f0;font:16px system-ui,sans-serif}main{max-width:760px;margin:12vh auto;padding:32px}section{border:1px solid #1e293b;border-radius:18px;background:#0f172a;padding:32px;box-shadow:0 20px 60px #0008}h1{color:#fff}a{color:#67e8f9}</style></head><body><main><section><p>WEBHOOK SERVICE</p><h1>Gitea Codex Review Bot</h1><p>This service validates signed Gitea webhook events, queues pull-request review jobs, and posts structured feedback.</p><p><a href="/healthz">Health</a> · <a href="/healthz/latest-job">Latest job</a> · <a href="/healthz/latest-failure">Latest failure</a></p><p>Webhook: <code>POST /webhook/gitea</code></p></section></main></body></html>`
|
|
const browser404 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Not Found</title><style>body{background:#020617;color:#e2e8f0;font:16px system-ui,sans-serif;text-align:center;padding:12vh 20px}section{max-width:600px;margin:auto;border:1px solid #1e293b;border-radius:18px;padding:32px;background:#0f172a}a{color:#67e8f9}</style></head><body><section><p>Error 404</p><h1>Page not found</h1><p>This service exposes a small set of routes.</p><a href="/">Go home</a></section></body></html>`
|