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>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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>`
|
||||
@@ -0,0 +1,65 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea-codex-bot/internal/config"
|
||||
"gitea-codex-bot/internal/gitea"
|
||||
"gitea-codex-bot/internal/store/sqlstore"
|
||||
)
|
||||
|
||||
func TestWebhookQueuesSignedReview(t *testing.T) {
|
||||
giteaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/pulls/"):
|
||||
_, _ = w.Write([]byte(`{"base":{"ref":"main","sha":"base","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"head":{"ref":"feature","sha":"head","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"html_url":"https://gitea.test"}`))
|
||||
case r.Method == http.MethodPost:
|
||||
_, _ = w.Write([]byte(`{"id":100}`))
|
||||
case strings.Contains(r.URL.Path, "contents"):
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer giteaServer.Close()
|
||||
settings := config.Settings{GiteaBaseURL: giteaServer.URL, GiteaToken: "token", GiteaBotUsername: "codex-bot", GiteaWebhookSecret: "secret", AllowedRepos: []string{"acme/repo"}, DatabaseURL: "sqlite://" + t.TempDir() + "/test.db", WebhookMaxBytes: 1 << 20, CooldownSeconds: 60}
|
||||
st, err := sqlstore.Open(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := New(settings, st, gitea.NewClient(settings), nilLogger())
|
||||
payload := []byte(`{"repository":{"full_name":"acme/repo"},"sender":{"username":"alice"},"comment":{"id":11,"body":"@codex review security"},"issue":{"number":9,"pull_request":{"url":"x"}},"pull_request":{"head":{"sha":"head"}}}`)
|
||||
mac := hmac.New(sha256.New, []byte("secret"))
|
||||
_, _ = mac.Write(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/gitea", strings.NewReader(string(payload)))
|
||||
req.Header.Set("X-Gitea-Event", "issue_comment")
|
||||
req.Header.Set("X-Gitea-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||
rec := httptest.NewRecorder()
|
||||
server.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response["status"] != "queued" {
|
||||
t.Fatalf("response %#v", response)
|
||||
}
|
||||
}
|
||||
func nilLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
Reference in New Issue
Block a user