WEBHOOK SERVICE
Gitea Codex Review Bot
This service validates signed Gitea webhook events, queues pull-request review jobs, and posts structured feedback.
Health · Latest job · Latest failure
Webhook: POST /webhook/gitea
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 = `
WEBHOOK SERVICE
This service validates signed Gitea webhook events, queues pull-request review jobs, and posts structured feedback.
Health · Latest job · Latest failure
Webhook: POST /webhook/gitea
Error 404
This service exposes a small set of routes.
Go home