This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
gitea-codex/internal/httpapi/httpapi.go
T
Space-Banane f19b271642
ci / test (pull_request) Successful in 1m54s
ci / publish (pull_request) Has been skipped
feat. rebuild service in Go
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>
2026-07-12 21:51:01 +02:00

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>`