feat. rebuild service in Go
ci / test (pull_request) Successful in 1m54s
ci / publish (pull_request) Has been skipped

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:
Space-Banane
2026-07-12 21:51:01 +02:00
parent fdd3819ff8
commit f19b271642
74 changed files with 2623 additions and 4819 deletions
+135
View File
@@ -0,0 +1,135 @@
package commands
import (
"errors"
"strings"
"unicode"
"gitea-codex-bot/internal/domain"
)
var helpAliases = map[string]bool{"-h": true, "--help": true, "help": true}
var supported = map[string]bool{"review": true, "rerun": true, "explain": true, "ignore": true}
func DetectPrefixedCommand(body string, aliases map[string]bool) string {
parts, ok := prefixParts(body, aliases)
if !ok || len(parts) == 0 {
return ""
}
return strings.ToLower(parts[0])
}
func Parse(body string, aliases map[string]bool) (domain.ParsedCommand, bool) {
stripped := strings.TrimSpace(body)
parts, ok := prefixParts(stripped, aliases)
if !ok || len(parts) == 0 {
return domain.ParsedCommand{}, false
}
name := strings.ToLower(parts[0])
rawArgs := append([]string(nil), parts[1:]...)
if helpAliases[name] {
return domain.ParsedCommand{Name: "help", Alias: aliasOf(stripped), Raw: stripped, Arguments: rawArgs}, true
}
if !supported[name] {
return domain.ParsedCommand{}, false
}
cmd := domain.ParsedCommand{Name: name, Alias: aliasOf(stripped), Raw: stripped, Mode: "summary", Arguments: rawArgs}
if name == "review" {
for _, token := range rawArgs {
switch strings.ToLower(token) {
case "--full":
cmd.Full = true
cmd.Mode = "full"
cmd.ModeExplicit = true
case "security", "performance", "tests":
if !cmd.ModeExplicit || cmd.Mode == "full" {
cmd.Mode = strings.ToLower(token)
cmd.ModeExplicit = true
}
}
}
}
return cmd, true
}
func prefixParts(body string, aliases map[string]bool) ([]string, bool) {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "@") {
return nil, false
}
space := strings.IndexFunc(trimmed, unicode.IsSpace)
if space <= 1 {
return nil, false
}
alias := strings.ToLower(strings.TrimPrefix(trimmed[1:space], "@"))
if !aliases[alias] {
return nil, false
}
remainder := strings.TrimSpace(trimmed[space:])
tokens, err := lex(remainder)
if err != nil {
return nil, false
}
return tokens, true
}
func aliasOf(body string) string {
end := strings.IndexFunc(body[1:], unicode.IsSpace)
if end < 0 {
return body
}
return body[:end+1]
}
func lex(input string) ([]string, error) {
var out []string
var b strings.Builder
quoted := rune(0)
escaped := false
started := false
flush := func() {
if started {
out = append(out, b.String())
b.Reset()
started = false
}
}
for _, r := range input {
if escaped {
b.WriteRune(r)
started = true
escaped = false
continue
}
if r == '\\' {
escaped = true
started = true
continue
}
if quoted != 0 {
if r == quoted {
quoted = 0
} else {
b.WriteRune(r)
}
started = true
continue
}
if r == '\'' || r == '"' {
quoted = r
started = true
continue
}
if unicode.IsSpace(r) {
flush()
continue
}
b.WriteRune(r)
started = true
}
if escaped || quoted != 0 {
return nil, errors.New("unterminated command quote")
}
flush()
return out, nil
}
+42
View File
@@ -0,0 +1,42 @@
package commands
import "testing"
func TestParseSupportedCommandsAndAliases(t *testing.T) {
aliases := map[string]bool{"codex": true, "codex-bot": true}
cases := []struct{ body, name, mode string }{
{"@codex review", "review", "summary"},
{"@codex review security --full", "review", "full"},
{"@codex review tests", "review", "tests"},
{"@codex-bot explain", "explain", "summary"},
{"@codex --help", "help", ""},
}
for _, tc := range cases {
got, ok := Parse(tc.body, aliases)
if !ok || got.Name != tc.name || got.Mode != tc.mode {
t.Fatalf("Parse(%q) = %#v, %v", tc.body, got, ok)
}
}
}
func TestParsePreservesRawAndQuotedArguments(t *testing.T) {
got, ok := Parse("@codex review \"focus auth\" --full\nsecond line", map[string]bool{"codex": true})
if !ok || got.Raw != "@codex review \"focus auth\" --full\nsecond line" {
t.Fatalf("raw command was not preserved: %#v", got)
}
if len(got.Arguments) != 4 || got.Arguments[0] != "focus auth" || got.Arguments[1] != "--full" || got.Arguments[2] != "second" || got.Arguments[3] != "line" {
t.Fatalf("arguments were not lexed: %#v", got.Arguments)
}
}
func TestUnsupportedAndInlineCommands(t *testing.T) {
if _, ok := Parse("@codex fix", map[string]bool{"codex": true}); ok {
t.Fatal("fix must remain unsupported")
}
if DetectPrefixedCommand("Please run @codex review", map[string]bool{"codex": true}) != "" {
t.Fatal("inline mention must not trigger")
}
if DetectPrefixedCommand("@codex deploy now", map[string]bool{"codex": true}) != "deploy" {
t.Fatal("unsupported prefix was not detected")
}
}
+151
View File
@@ -0,0 +1,151 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
type Settings struct {
GiteaBaseURL string
GiteaToken string
GiteaBotUsername string
GiteaBotMentions string
GiteaWebhookSecret string
OpenAIAPIKey string
OpenAIProjectID string
OpenAIOrgID string
OpenAIReviewModel string
CodexAuthMode string
CodexAuthJSONPath string
AllowedRepos []string
CooldownSeconds int
WebhookMode string
DatabaseURL string
DBHost string
DBPort int
DBName string
DBUser string
DBPassword string
Workdir string
MaxDiffBytes int
MaxReviewMinutes int
Concurrency int
RunnerImage string
AllowUntrustedForks bool
WebhookMaxBytes int64
}
func Load() (Settings, error) {
s := Settings{
GiteaBaseURL: strings.TrimRight(os.Getenv("GITEA_BASE_URL"), "/"), GiteaToken: os.Getenv("GITEA_TOKEN"),
GiteaBotUsername: os.Getenv("GITEA_BOT_USERNAME"), GiteaBotMentions: os.Getenv("GITEA_BOT_MENTIONS"), GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"),
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), OpenAIProjectID: os.Getenv("OPENAI_PROJECT_ID"), OpenAIOrgID: os.Getenv("OPENAI_ORG_ID"),
OpenAIReviewModel: envString("OPENAI_REVIEW_MODEL", "gpt-5.3-codex"), CodexAuthMode: envString("CODEX_AUTH_MODE", "api_key"), CodexAuthJSONPath: envString("CODEX_AUTH_JSON_PATH", "~/.codex/auth.json"),
AllowedRepos: splitCSV(os.Getenv("ALLOWED_REPOS")), WebhookMode: envString("WEBHOOK_MODE", "repo"), DatabaseURL: os.Getenv("DATABASE_URL"),
DBHost: os.Getenv("DB_HOST"), DBName: os.Getenv("DB_NAME"), DBUser: os.Getenv("DB_USER"), DBPassword: os.Getenv("DB_PASSWORD"),
Workdir: envString("WORKDIR", "/var/lib/gitea-codex/worktrees"), RunnerImage: envString("REVIEW_RUNNER_IMAGE", "node:22-bookworm-slim"),
}
var err error
if s.DBPort, err = envInt("DB_PORT", 3306); err != nil {
return Settings{}, err
}
if s.CooldownSeconds, err = envInt("COOLDOWN_SECONDS", 60); err != nil {
return Settings{}, err
}
if s.MaxDiffBytes, err = envInt("MAX_DIFF_BYTES", 200000); err != nil {
return Settings{}, err
}
if s.MaxReviewMinutes, err = envInt("MAX_REVIEW_MINUTES", 10); err != nil {
return Settings{}, err
}
if s.Concurrency, err = envInt("CONCURRENCY", 1); err != nil {
return Settings{}, err
}
maxBytes, err := envInt("WEBHOOK_MAX_BYTES", 2*1024*1024)
if err != nil {
return Settings{}, err
}
s.WebhookMaxBytes = int64(maxBytes)
s.AllowUntrustedForks, err = envBool("ALLOW_UNTRUSTED_FORKS", false)
if err != nil {
return Settings{}, err
}
if err := s.Validate(); err != nil {
return Settings{}, err
}
return s, nil
}
func (s Settings) Validate() error {
for name, value := range map[string]string{"GITEA_BASE_URL": s.GiteaBaseURL, "GITEA_TOKEN": s.GiteaToken, "GITEA_BOT_USERNAME": s.GiteaBotUsername, "GITEA_WEBHOOK_SECRET": s.GiteaWebhookSecret} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
}
if len(s.AllowedRepos) == 0 {
return errors.New("ALLOWED_REPOS is required")
}
if s.CodexAuthMode != "api_key" && s.CodexAuthMode != "chatgpt" {
return errors.New("CODEX_AUTH_MODE must be api_key or chatgpt")
}
if s.CodexAuthMode == "api_key" && strings.TrimSpace(s.OpenAIAPIKey) == "" {
return errors.New("OPENAI_API_KEY is required")
}
if s.CooldownSeconds < 0 || s.MaxDiffBytes <= 0 || s.MaxReviewMinutes <= 0 || s.Concurrency <= 0 || s.WebhookMaxBytes <= 0 {
return errors.New("numeric configuration values are invalid")
}
return nil
}
func (s Settings) RepoAllowed(repo string) bool {
for _, allowed := range s.AllowedRepos {
if allowed == repo {
return true
}
}
return false
}
func (s Settings) Aliases() map[string]bool {
out := map[string]bool{"codex": true, strings.ToLower(strings.TrimPrefix(strings.TrimSpace(s.GiteaBotUsername), "@")): true}
for _, x := range splitCSV(s.GiteaBotMentions) {
out[strings.ToLower(strings.TrimPrefix(x, "@"))] = true
}
return out
}
func envString(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
func envInt(name string, fallback int) (int, error) {
v := envString(name, strconv.Itoa(fallback))
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %w", name, err)
}
return n, nil
}
func envBool(name string, fallback bool) (bool, error) {
v := os.Getenv(name)
if v == "" {
return fallback, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("%s: %w", name, err)
}
return b, nil
}
func splitCSV(value string) []string {
var out []string
for _, item := range strings.Split(value, ",") {
if v := strings.TrimSpace(item); v != "" {
out = append(out, v)
}
}
return out
}
+180
View File
@@ -0,0 +1,180 @@
package domain
import (
"context"
"errors"
"strings"
"time"
)
type JobStatus string
const (
JobQueued JobStatus = "queued"
JobRunning JobStatus = "running"
JobSucceeded JobStatus = "succeeded"
JobFailed JobStatus = "failed"
JobSkipped JobStatus = "skipped"
)
type RunStatus string
const (
RunRunning RunStatus = "running"
RunSucceeded RunStatus = "succeeded"
RunFailed RunStatus = "failed"
RunSkipped RunStatus = "skipped"
)
type ParsedCommand struct {
Name string
Alias string
Raw string
Mode string
ModeExplicit bool
Full bool
Arguments []string
}
func (c ParsedCommand) IsReview() bool { return c.Name == "review" || c.Name == "rerun" }
type RepoReviewConfig struct {
Configured bool
Enabled bool
DefaultMode string
MaxDiffBytes int
IncludeTests bool
Focus []string
Ignore []string
}
func DefaultRepoReviewConfig() RepoReviewConfig {
return RepoReviewConfig{Configured: true, Enabled: true, DefaultMode: "full", MaxDiffBytes: 200000, Focus: []string{"correctness", "security", "maintainability"}}
}
type PullRequestContext struct {
Repo string
PRNumber int
BaseRef string
BaseSHA string
HeadRef string
HeadSHA string
CloneURL string
BaseCloneURL string
HeadCloneURL string
HTMLURL string
IsFork bool
}
type Finding struct {
Severity string `json:"severity"`
File string `json:"file"`
LineStart int `json:"line_start"`
LineEnd int `json:"line_end"`
Title string `json:"title"`
Body string `json:"body"`
Suggestion *string `json:"suggestion"`
}
type Usage struct {
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
}
type ReviewMeta struct {
Source string `json:"source,omitempty"`
Model string `json:"model,omitempty"`
Usage Usage `json:"usage,omitempty"`
}
type ReviewResult struct {
Verdict string `json:"verdict"`
Confidence float64 `json:"confidence"`
Summary string `json:"summary"`
MarkdownComment string `json:"markdown_comment"`
Findings []Finding `json:"findings"`
Meta *ReviewMeta `json:"_meta,omitempty"`
}
func (r ReviewResult) Validate() error {
if r.Verdict != "correct" && r.Verdict != "has_issues" {
return errors.New("invalid verdict")
}
if r.Confidence < 0 || r.Confidence > 1 {
return errors.New("confidence must be between 0 and 1")
}
if len(r.Summary) > 20000 || len(r.MarkdownComment) > 50000 {
return errors.New("review text is too long")
}
if len(r.Findings) > 200 {
return errors.New("too many findings")
}
for i, f := range r.Findings {
if f.Severity != "low" && f.Severity != "medium" && f.Severity != "high" && f.Severity != "critical" {
return errors.New("invalid finding severity")
}
if f.File == "" || len(f.File) > 1000 || f.LineStart < 1 || f.LineEnd < f.LineStart {
return errors.New("invalid finding location")
}
if len(f.Title) > 2000 || len(f.Body) > 20000 {
return errors.New("finding text is too long")
}
if f.Suggestion != nil && len(*f.Suggestion) > 20000 {
return errors.New("suggestion is too long")
}
_ = i
}
return nil
}
type WebhookEvent struct {
EventName string
DeliveryID string
Repo string
PRNumber int
HeadSHA string
CommentID int64
CommentBody string
Sender string
PayloadSHA256 string
}
type Job struct {
ID int64
Repo string
PRNumber int
HeadSHA string
TriggerCommentID int64
TriggerCommentBody string
Command string
CommandArgs string
RequestedBy string
Status JobStatus
LastError string
ResultJSON []byte
CreatedAt time.Time
UpdatedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
}
type ReviewRun struct {
ID int64
JobID int64
Status RunStatus
ContainerID string
ResultJSON []byte
Error string
StartedAt time.Time
FinishedAt *time.Time
}
func (j Job) AttemptNumber() int { return 1 }
// ReviewRunner isolates all process/container execution from orchestration.
type ReviewRunner interface {
Run(context.Context, PullRequestContext, ParsedCommand, RepoReviewConfig) (ReviewResult, error)
}
func NormalizeRepo(repo string) string { return strings.TrimSpace(repo) }
+196
View File
@@ -0,0 +1,196 @@
package gitea
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
)
type Client struct {
baseURL, token string
httpClient *http.Client
}
func NewClient(settings config.Settings) *Client {
return &Client{baseURL: strings.TrimRight(settings.GiteaBaseURL, "/"), token: settings.GiteaToken, httpClient: &http.Client{Timeout: 20 * time.Second}}
}
func (c *Client) request(ctx context.Context, method, path string, body any) ([]byte, int, error) {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, 0, err
}
reader = strings.NewReader(string(data))
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, resp.StatusCode, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return data, resp.StatusCode, fmt.Errorf("gitea returned HTTP %d", resp.StatusCode)
}
return data, resp.StatusCode, nil
}
func splitRepo(repo string) (string, string, error) {
owner, name, ok := strings.Cut(repo, "/")
if !ok || owner == "" || name == "" {
return "", "", fmt.Errorf("invalid repository %q", repo)
}
return url.PathEscape(owner), url.PathEscape(name), nil
}
func (c *Client) GetPullRequest(ctx context.Context, repo string, number int) (domain.PullRequestContext, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return domain.PullRequestContext{}, err
}
data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, name, number), nil)
if err != nil {
return domain.PullRequestContext{}, err
}
var p struct {
HTMLURL string `json:"html_url"`
Base struct {
Ref, SHA string
Repo struct {
CloneURL string `json:"clone_url"`
FullName string `json:"full_name"`
} `json:"repo"`
} `json:"base"`
Head struct {
Ref, SHA string
Repo struct {
CloneURL string `json:"clone_url"`
FullName string `json:"full_name"`
} `json:"repo"`
} `json:"head"`
}
if err := json.Unmarshal(data, &p); err != nil {
return domain.PullRequestContext{}, err
}
if p.Base.SHA == "" || p.Head.SHA == "" || p.Base.Repo.CloneURL == "" || p.Head.Repo.CloneURL == "" {
return domain.PullRequestContext{}, fmt.Errorf("gitea pull request response missing required fields")
}
return domain.PullRequestContext{Repo: repo, PRNumber: number, BaseRef: p.Base.Ref, BaseSHA: p.Base.SHA, HeadRef: p.Head.Ref, HeadSHA: p.Head.SHA, CloneURL: p.Head.Repo.CloneURL, BaseCloneURL: p.Base.Repo.CloneURL, HeadCloneURL: p.Head.Repo.CloneURL, HTMLURL: p.HTMLURL, IsFork: p.Base.Repo.FullName != p.Head.Repo.FullName}, nil
}
func (c *Client) GetFileContent(ctx context.Context, repo, path, ref string) (string, bool, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return "", false, err
}
data, status, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?ref=%s", owner, name, url.PathEscape(path), url.QueryEscape(ref)), nil)
if err != nil && status == http.StatusNotFound {
return "", false, nil
}
if err != nil {
return "", false, err
}
var p struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := json.Unmarshal(data, &p); err != nil {
return "", false, err
}
if p.Encoding != "base64" || p.Content == "" {
return "", false, nil
}
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(p.Content, "\n", ""))
if err != nil {
return "", false, err
}
return string(decoded), true, nil
}
func (c *Client) PostIssueComment(ctx context.Context, repo string, number int, body string) (int64, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return 0, err
}
data, _, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, name, number), map[string]string{"body": body})
if err != nil {
return 0, err
}
var p struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(data, &p); err != nil {
return 0, err
}
return p.ID, nil
}
func (c *Client) EditIssueComment(ctx context.Context, repo string, commentID int64, body string) (int64, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return 0, err
}
data, _, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", owner, name, commentID), map[string]string{"body": body})
if err != nil {
return 0, err
}
var p struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(data, &p); err != nil {
return 0, err
}
return p.ID, nil
}
func (c *Client) GetIssueComments(ctx context.Context, repo string, number int) ([]map[string]any, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, name, number), nil)
if err != nil {
return nil, err
}
var p []map[string]any
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return p, nil
}
func (c *Client) GetIssueComment(ctx context.Context, repo string, commentID int64) (map[string]any, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", owner, name, commentID), nil)
if err != nil {
return nil, err
}
var p map[string]any
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return p, nil
}
var _ = strconv.Itoa
+217
View File
@@ -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>`
+65
View File
@@ -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)) }
+179
View File
@@ -0,0 +1,179 @@
package review
import (
"encoding/json"
"fmt"
"strings"
"gopkg.in/yaml.v3"
"gitea-codex-bot/internal/domain"
)
type rawConfig struct {
Enabled *bool `yaml:"enabled"`
Review struct {
DefaultMode string `yaml:"default_mode"`
MaxDiffBytes int `yaml:"max_diff_bytes"`
IncludeTests bool `yaml:"include_tests"`
Focus []string `yaml:"focus"`
} `yaml:"review"`
Ignore []string `yaml:"ignore"`
}
func ParseRepoConfig(text string) (domain.RepoReviewConfig, error) {
cfg := domain.DefaultRepoReviewConfig()
cfg.Configured = true
var raw rawConfig
if err := yaml.Unmarshal([]byte(text), &raw); err != nil {
return domain.RepoReviewConfig{}, err
}
if raw.Enabled != nil {
cfg.Enabled = *raw.Enabled
}
if raw.Review.DefaultMode != "" {
cfg.DefaultMode = strings.ToLower(strings.TrimSpace(raw.Review.DefaultMode))
}
if raw.Review.MaxDiffBytes > 0 {
cfg.MaxDiffBytes = raw.Review.MaxDiffBytes
}
cfg.IncludeTests = raw.Review.IncludeTests
if raw.Review.Focus != nil {
cfg.Focus = boundedStrings(raw.Review.Focus, 32, 200)
}
cfg.Ignore = boundedStrings(raw.Ignore, 128, 500)
return cfg, nil
}
func MissingRepoConfig() domain.RepoReviewConfig {
cfg := domain.DefaultRepoReviewConfig()
cfg.Configured = false
return cfg
}
func boundedStrings(input []string, maxItems, maxLen int) []string {
out := make([]string, 0, min(len(input), maxItems))
for _, item := range input {
item = strings.TrimSpace(item)
if item != "" && len(item) <= maxLen {
out = append(out, item)
}
if len(out) == maxItems {
break
}
}
return out
}
func ResolveMode(cmd *domain.ParsedCommand, cfg domain.RepoReviewConfig) {
if cmd.Name == "review" && !cmd.ModeExplicit {
if cfg.DefaultMode == "full" || cfg.DefaultMode == "summary" || cfg.DefaultMode == "security" || cfg.DefaultMode == "performance" || cfg.DefaultMode == "tests" {
cmd.Mode = cfg.DefaultMode
} else {
cmd.Mode = "summary"
}
}
}
func BuildPrompt(cmd domain.ParsedCommand, cfg domain.RepoReviewConfig, pr domain.PullRequestContext) string {
raw := strings.TrimSpace(cmd.Raw)
intent := raw
if at := strings.IndexAny(raw, " \t\r\n"); at >= 0 {
intent = strings.TrimSpace(raw[at:])
}
if at := strings.IndexAny(intent, " \t\r\n"); at >= 0 {
intent = strings.TrimSpace(intent[at:])
}
if intent == "" {
intent = "review this pull request and report introduced issues."
}
focus := strings.Join(cfg.Focus, ", ")
if focus == "" {
focus = "correctness, security, maintainability"
}
ignore := strings.Join(cfg.Ignore, ", ")
if ignore == "" {
ignore = "(none)"
}
tests := "Do not run tests, benchmarks, or other executables. Review changes statically unless explicitly asked."
if cmd.Mode == "tests" || cfg.IncludeTests {
tests = "Tests may be executed for this run because tests mode/include_tests is explicitly enabled."
}
return fmt.Sprintf("review: %s\nReview only issues introduced by this PR.\nCompare exactly these commits: base `%s` ... head `%s`.\nUse local git data from this checkout; do not review unrelated history.\nRequested mode: %s.\nFocus areas: %s.\nIgnore patterns: %s.\nInclude tests setting: %t.\n%s\nFull review requested: %t.\nReturn strict JSON matching the provided output schema.", intent, pr.BaseSHA, pr.HeadSHA, cmd.Mode, focus, ignore, cfg.IncludeTests, tests, cmd.Full)
}
func ValidateResult(result domain.ReviewResult) error { return result.Validate() }
func DecodeResult(data []byte) (domain.ReviewResult, error) {
var result domain.ReviewResult
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(&result); err != nil {
return domain.ReviewResult{}, err
}
if err := result.Validate(); err != nil {
return domain.ReviewResult{}, err
}
return result, nil
}
func FormatQueueAck(sha string) string {
return fmt.Sprintf("👀 Codex review queued for commit `%s`.", first(sha, 7))
}
func FormatCooldownAck(seconds int) string {
return fmt.Sprintf("⏳ Cooldown active. Please wait %ds before requesting another review on this PR.", seconds)
}
func FormatDisabledAck() string {
return "🚫 Review is disabled by `.codex-review.yml` for this repository."
}
func FormatUnsupportedAck(name string) string {
return fmt.Sprintf("⚠️ Command `@codex %s` is not enabled on this repository.", name)
}
func FormatResultComment(sha string, result domain.ReviewResult, configured bool) string {
marker := fmt.Sprintf("<!-- codex-review:head_sha=%s -->", sha)
body := strings.TrimSpace(result.MarkdownComment)
if body == "" {
body = fmt.Sprintf("## Codex Review\n\nVerdict: `%s`\nConfidence: `%.2f`\n\n%s", result.Verdict, result.Confidence, result.Summary)
if len(result.Findings) == 0 {
body += "\n\nNo blocking issues found."
} else {
body += "\n\nFindings:"
for i, f := range result.Findings {
suggestion := "n/a"
if f.Suggestion != nil && *f.Suggestion != "" {
suggestion = *f.Suggestion
}
body += fmt.Sprintf("\n\n%d. `%s:%d-%d` (%s)\n %s\n %s\n Suggestion: %s", i+1, f.File, f.LineStart, f.LineEnd, f.Severity, f.Title, f.Body, suggestion)
}
}
} else if len(result.Findings) > 0 {
body += "\n\n---\n\n### Structured Findings\n\n"
for i, f := range result.Findings {
body += fmt.Sprintf("%d. `%s:%d-%d` (%s)\n %s\n %s\n\n", i+1, f.File, f.LineStart, f.LineEnd, f.Severity, f.Title, f.Body)
}
}
if result.Meta != nil && result.Meta.Model != "" {
body += fmt.Sprintf("\n_Note: model `%s`, input `%d`, output `%d`, total `%d` tokens used._", result.Meta.Model, result.Meta.Usage.InputTokens, result.Meta.Usage.OutputTokens, result.Meta.Usage.TotalTokens)
}
if !configured {
body += "\n\n> ️.codex-review.yml is not configured"
}
if strings.HasPrefix(body, "<!-- codex-review:head_sha=") {
lines := strings.SplitN(body, "\n", 2)
if len(lines) == 2 {
body = marker + "\n" + lines[1]
}
} else {
body = marker + "\n" + body
}
return body
}
func FailureComment(sha, errText string) string {
return fmt.Sprintf("⚠️ Codex review run failed after queueing.\n\n- Commit: `%s`\n- Error: `%s`\n\nPlease rerun `@codex rerun` after checking worker logs.", first(sha, 7), first(strings.Join(strings.Fields(errText), " "), 500))
}
func first(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+45
View File
@@ -0,0 +1,45 @@
package review
import (
"testing"
"gitea-codex-bot/internal/domain"
)
func TestParseConfigAndResolveMode(t *testing.T) {
cfg, err := ParseRepoConfig("enabled: true\nreview:\n default_mode: security\n include_tests: false\n focus: [correctness, security]\nignore: [generated/]\n")
if err != nil {
t.Fatal(err)
}
if !cfg.Configured || cfg.DefaultMode != "security" || len(cfg.Focus) != 2 {
t.Fatalf("unexpected config: %#v", cfg)
}
cmd := domain.ParsedCommand{Name: "review", Raw: "@codex review", Mode: "summary"}
ResolveMode(&cmd, cfg)
if cmd.Mode != "security" {
t.Fatalf("mode was not resolved: %q", cmd.Mode)
}
}
func TestResultValidationAndFormatting(t *testing.T) {
suggestion := "Use a checked conversion."
result := domain.ReviewResult{Verdict: "has_issues", Confidence: .9, Summary: "Found one issue", Findings: []domain.Finding{{Severity: "high", File: "internal/x.go", LineStart: 4, LineEnd: 5, Title: "Unsafe conversion", Body: "The conversion can overflow.", Suggestion: &suggestion}}}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
body := FormatResultComment("abcdef123", result, false)
if len(body) == 0 || body[:len("<!-- codex-review:head_sha=abcdef123 -->")] != "<!-- codex-review:head_sha=abcdef123 -->" {
t.Fatalf("missing SHA marker: %s", body)
}
if !contains(body, "not configured") || !contains(body, "Unsafe conversion") {
t.Fatalf("missing formatted details: %s", body)
}
}
func contains(s, needle string) bool {
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
return true
}
}
return false
}
+150
View File
@@ -0,0 +1,150 @@
package runner
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
"gitea-codex-bot/internal/review"
)
const startMarker = "__CODEX_REVIEW_RESULT_BEGIN__"
const endMarker = "__CODEX_REVIEW_RESULT_END__"
const maxRunnerOutput = 4 << 20
type DockerRunner struct{ settings config.Settings }
func NewDockerRunner(settings config.Settings) *DockerRunner {
return &DockerRunner{settings: settings}
}
func (r *DockerRunner) Run(parent context.Context, pr domain.PullRequestContext, cmd domain.ParsedCommand, cfg domain.RepoReviewConfig) (domain.ReviewResult, error) {
ctx, cancel := context.WithTimeout(parent, time.Duration(r.settings.MaxReviewMinutes)*time.Minute)
defer cancel()
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
begin, end := startMarker+"_"+nonce, endMarker+"_"+nonce
prompt := review.BuildPrompt(cmd, cfg, pr)
script := r.script(pr, prompt, begin, end)
name := "codex-review-" + nonce
args := []string{"run", "--rm", "-i", "--name", name, "--cap-drop=ALL", "--security-opt", "no-new-privileges", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=512m", "--tmpfs", "/work:rw,nosuid,size=1g", "-e", "CODEX_DISABLE_TELEMETRY=1"}
if r.settings.CodexAuthMode == "chatgpt" {
args = append(args, "-e", "CODEX_AUTH_JSON_B64")
} else {
args = append(args, "-e", "OPENAI_API_KEY")
}
args = append(args, "-e", "GITEA_TOKEN", "-e", "GITEA_GIT_USERNAME", r.settings.RunnerImage, "bash", "-lc", script)
cmdExec := exec.CommandContext(ctx, "docker", args...)
cmdExec.Env = append(os.Environ(), "OPENAI_API_KEY="+r.settings.OpenAIAPIKey, "GITEA_TOKEN="+r.settings.GiteaToken, "GITEA_GIT_USERNAME="+r.settings.GiteaBotUsername)
if r.settings.CodexAuthMode == "chatgpt" {
data, err := readAuthJSON(r.settings.CodexAuthJSONPath)
if err != nil {
return domain.ReviewResult{}, err
}
cmdExec.Env = append(cmdExec.Env, "CODEX_AUTH_JSON_B64="+base64.StdEncoding.EncodeToString(data))
}
var output limitedBuffer
output.limit = maxRunnerOutput
cmdExec.Stdout = &output
cmdExec.Stderr = &output
if err := cmdExec.Run(); err != nil {
_ = exec.CommandContext(context.Background(), "docker", "rm", "-f", name).Run()
if ctx.Err() != nil {
return domain.ReviewResult{}, fmt.Errorf("review runner timeout: %w", ctx.Err())
}
return domain.ReviewResult{}, fmt.Errorf("review runner failed: %w", err)
}
text := output.String()
start := strings.Index(text, begin)
endPos := strings.LastIndex(text, end)
if start < 0 || endPos <= start {
return domain.ReviewResult{}, fmt.Errorf("review runner returned no result artifact")
}
artifact := strings.TrimSpace(text[start+len(begin) : endPos])
var result domain.ReviewResult
if err := json.Unmarshal([]byte(artifact), &result); err != nil {
return domain.ReviewResult{}, err
}
if err := review.ValidateResult(result); err != nil {
return domain.ReviewResult{}, err
}
return result, nil
}
func readAuthJSON(rawPath string) ([]byte, error) {
path := os.ExpandEnv(rawPath)
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return nil, err
}
if !json.Valid(data) {
return nil, fmt.Errorf("CODEX_AUTH_JSON_PATH is not valid JSON")
}
return data, nil
}
func (r *DockerRunner) script(pr domain.PullRequestContext, prompt, begin, end string) string {
auth := base64.StdEncoding.EncodeToString([]byte(r.settings.GiteaBotUsername + ":" + r.settings.GiteaToken))
schema := `{"type":"object","additionalProperties":false,"required":["verdict","confidence","summary","findings","markdown_comment"],"properties":{"verdict":{"type":"string","enum":["correct","has_issues"]},"confidence":{"type":"number"},"summary":{"type":"string"},"markdown_comment":{"type":"string"},"findings":{"type":"array"}}}`
quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" }
baseRemote := "origin"
remoteSetup := ""
if pr.BaseCloneURL != "" && pr.BaseCloneURL != pr.CloneURL {
baseRemote = "upstream"
remoteSetup = "git remote add upstream " + quote(pr.BaseCloneURL)
}
authSetup := ""
if r.settings.CodexAuthMode == "chatgpt" {
authSetup = "mkdir -p /root/.codex; printf '%s' \"$CODEX_AUTH_JSON_B64\" | base64 -d > /root/.codex/auth.json; chmod 600 /root/.codex/auth.json"
}
fetchHead := "git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags origin " + quote(pr.HeadRef) + " || git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags origin " + quote(pr.HeadSHA)
fetchBase := "git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags " + baseRemote + " " + quote(pr.BaseRef) + " || git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags " + baseRemote + " " + quote(pr.BaseSHA)
steps := []string{"set -eu", "printf '%s' " + quote(schema) + " > /tmp/schema.json"}
if authSetup != "" {
steps = append(steps, authSetup)
}
steps = append(steps, "git -c http.extraHeader="+quote("Authorization: Basic "+auth)+" clone --no-tags --depth 80 "+quote(pr.CloneURL)+" /work/repo", "cd /work/repo")
if remoteSetup != "" {
steps = append(steps, remoteSetup)
}
steps = append(steps, fetchHead, fetchBase, "git checkout --detach "+quote(pr.HeadSHA), "test \"$(git rev-parse HEAD)\" = "+quote(pr.HeadSHA), "unset GITEA_TOKEN", "codex exec --sandbox danger-full-access --json --output-schema /tmp/schema.json -o /tmp/result.json -m "+quote(r.settings.OpenAIReviewModel)+" "+quote(prompt), "test -s /tmp/result.json", "printf '%s\\n' "+quote(begin), "cat /tmp/result.json", "printf '%s\\n' "+quote(end))
return strings.Join(steps, "; ")
}
type limitedBuffer struct {
buffer bytes.Buffer
limit int
truncated bool
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remaining := b.limit - b.buffer.Len()
if remaining <= 0 {
b.truncated = true
return len(p), nil
}
if len(p) > remaining {
_, _ = b.buffer.Write(p[:remaining])
b.truncated = true
return len(p), nil
}
return b.buffer.Write(p)
}
func (b *limitedBuffer) String() string { return b.buffer.String() }
var _ domain.ReviewRunner = (*DockerRunner)(nil)
+47
View File
@@ -0,0 +1,47 @@
package runner
import (
"strings"
"testing"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
)
func samplePR() domain.PullRequestContext {
return domain.PullRequestContext{Repo: "acme/repo", PRNumber: 1, BaseRef: "main", BaseSHA: strings.Repeat("b", 40), HeadRef: "feature", HeadSHA: strings.Repeat("a", 40), CloneURL: "https://gitea.test/acme/repo.git", BaseCloneURL: "https://gitea.test/acme/repo.git", HTMLURL: "https://gitea.test/pulls/1"}
}
func TestScriptChecksExactHeadAndBase(t *testing.T) {
r := NewDockerRunner(config.Settings{GiteaBotUsername: "bot", GiteaToken: "token", OpenAIReviewModel: "model", CodexAuthMode: "api_key"})
script := r.script(samplePR(), "review prompt", "BEGIN_nonce", "END_nonce")
for _, fragment := range []string{"git checkout --detach", "git rev-parse HEAD", "fetch --no-tags origin 'feature'", "fetch --no-tags origin '" + strings.Repeat("b", 40) + "'", "BEGIN_nonce", "END_nonce", "--output-schema", "-o /tmp/result.json"} {
if !strings.Contains(script, fragment) {
t.Fatalf("script missing %q: %s", fragment, script)
}
}
if strings.Contains(script, "; ;") {
t.Fatalf("script contains an empty shell command: %s", script)
}
if strings.Contains(script, "|| true") {
t.Fatalf("runner must fail when Codex fails")
}
}
func TestForkScriptUsesUpstreamBaseRemote(t *testing.T) {
pr := samplePR()
pr.BaseCloneURL = "https://gitea.test/base/repo.git"
r := NewDockerRunner(config.Settings{GiteaBotUsername: "bot", GiteaToken: "token", OpenAIReviewModel: "model", CodexAuthMode: "api_key"})
script := r.script(pr, "prompt", "BEGIN", "END")
if !strings.Contains(script, "git remote add upstream") || !strings.Contains(script, "fetch --no-tags upstream") {
t.Fatalf("fork base remote was not configured: %s", script)
}
}
func TestChatGPTScriptWritesAuthFile(t *testing.T) {
r := NewDockerRunner(config.Settings{GiteaBotUsername: "bot", GiteaToken: "token", OpenAIReviewModel: "model", CodexAuthMode: "chatgpt"})
script := r.script(samplePR(), "prompt", "BEGIN", "END")
if !strings.Contains(script, "CODEX_AUTH_JSON_B64") || !strings.Contains(script, "chmod 600 /root/.codex/auth.json") {
t.Fatalf("chatgpt auth setup missing: %s", script)
}
}
+342
View File
@@ -0,0 +1,342 @@
package sqlstore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
"gitea-codex-bot/internal/store"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
)
type Store struct {
db *sql.DB
dialect string
}
func Open(settings config.Settings) (*Store, error) {
dsn := settings.DatabaseURL
dialect := "mysql"
if dsn == "" {
dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4", settings.DBUser, settings.DBPassword, settings.DBHost, settings.DBPort, settings.DBName)
}
if strings.HasPrefix(dsn, "mysql://") {
dsn = strings.TrimPrefix(dsn, "mysql://")
}
if strings.HasPrefix(dsn, "sqlite://") {
dialect = "sqlite"
dsn = strings.TrimPrefix(dsn, "sqlite://")
} else if strings.HasPrefix(dsn, "sqlite:") {
dialect = "sqlite"
dsn = strings.TrimPrefix(dsn, "sqlite:")
} else if strings.HasPrefix(dsn, "file:") || dsn == ":memory:" || filepath.Ext(dsn) == ".db" {
dialect = "sqlite"
}
db, err := sql.Open(map[string]string{"sqlite": "sqlite", "mysql": "mysql"}[dialect], dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(8)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db, dialect: dialect}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Migrate(ctx context.Context) error {
stmts := s.schema()
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migration: %w", err)
}
}
if _, err := s.db.ExecContext(ctx, s.alterAddTriggerCommentBody()); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate") && !strings.Contains(strings.ToLower(err.Error()), "exists") {
return fmt.Errorf("migration trigger_comment_body: %w", err)
}
return nil
}
func (s *Store) schema() []string {
if s.dialect == "sqlite" {
return []string{
`CREATE TABLE IF NOT EXISTS webhook_events (id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_id TEXT NULL UNIQUE, event_name TEXT NOT NULL, repo TEXT NOT NULL, comment_id INTEGER NULL, payload_sha256 TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(repo, comment_id))`,
`CREATE TABLE IF NOT EXISTS review_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, head_sha TEXT NOT NULL, trigger_comment_id INTEGER NOT NULL, command TEXT NOT NULL, command_args TEXT NULL, trigger_comment_body TEXT NULL, requested_by TEXT NOT NULL, status TEXT NOT NULL, last_error TEXT NULL, result_json TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at TIMESTAMP NULL, finished_at TIMESTAMP NULL, UNIQUE(repo, trigger_comment_id))`,
`CREATE INDEX IF NOT EXISTS ix_review_jobs_lookup ON review_jobs(repo, pr_number, head_sha, status, created_at)`,
`CREATE TABLE IF NOT EXISTS review_runs (id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL, status TEXT NOT NULL, runner_container_id TEXT NULL, result_json TEXT NULL, error_message TEXT NULL, started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at TIMESTAMP NULL, FOREIGN KEY(job_id) REFERENCES review_jobs(id) ON DELETE CASCADE)`,
`CREATE INDEX IF NOT EXISTS ix_review_runs_job_status ON review_runs(job_id, status)`,
`CREATE TABLE IF NOT EXISTS bot_comments (id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, head_sha TEXT NOT NULL, gitea_comment_id INTEGER NOT NULL, marker TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(repo, pr_number, marker))`,
`CREATE INDEX IF NOT EXISTS ix_bot_comments_repo_pr ON bot_comments(repo, pr_number)`,
}
}
return []string{
`CREATE TABLE IF NOT EXISTS webhook_events (id BIGINT AUTO_INCREMENT PRIMARY KEY, delivery_id VARCHAR(255) NULL UNIQUE, event_name VARCHAR(128) NOT NULL, repo VARCHAR(255) NOT NULL, comment_id BIGINT NULL, payload_sha256 VARCHAR(64) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE KEY uq_webhook_events_repo_comment (repo, comment_id)) ENGINE=InnoDB`,
`CREATE TABLE IF NOT EXISTS review_jobs (id BIGINT AUTO_INCREMENT PRIMARY KEY, repo VARCHAR(255) NOT NULL, pr_number INT NOT NULL, head_sha VARCHAR(64) NOT NULL, trigger_comment_id BIGINT NOT NULL, command VARCHAR(64) NOT NULL, command_args TEXT NULL, trigger_comment_body TEXT NULL, requested_by VARCHAR(255) NOT NULL, status VARCHAR(32) NOT NULL, last_error TEXT NULL, result_json JSON NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), started_at DATETIME(6) NULL, finished_at DATETIME(6) NULL, UNIQUE KEY uq_review_jobs_repo_trigger_comment (repo, trigger_comment_id), KEY ix_review_jobs_lookup (repo, pr_number, head_sha, status, created_at)) ENGINE=InnoDB`,
`CREATE TABLE IF NOT EXISTS review_runs (id BIGINT AUTO_INCREMENT PRIMARY KEY, job_id BIGINT NOT NULL, status VARCHAR(32) NOT NULL, runner_container_id VARCHAR(128) NULL, result_json JSON NULL, error_message TEXT NULL, started_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), finished_at DATETIME(6) NULL, CONSTRAINT fk_review_runs_job FOREIGN KEY(job_id) REFERENCES review_jobs(id) ON DELETE CASCADE, KEY ix_review_runs_job_status (job_id, status)) ENGINE=InnoDB`,
`CREATE TABLE IF NOT EXISTS bot_comments (id BIGINT AUTO_INCREMENT PRIMARY KEY, repo VARCHAR(255) NOT NULL, pr_number INT NOT NULL, head_sha VARCHAR(64) NOT NULL, gitea_comment_id BIGINT NOT NULL, marker VARCHAR(255) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE KEY uq_bot_comments_marker (repo, pr_number, marker), KEY ix_bot_comments_repo_pr (repo, pr_number)) ENGINE=InnoDB`,
}
}
func (s *Store) alterAddTriggerCommentBody() string {
if s.dialect == "sqlite" {
return `ALTER TABLE review_jobs ADD COLUMN trigger_comment_body TEXT`
}
return `ALTER TABLE review_jobs ADD COLUMN trigger_comment_body TEXT NULL`
}
func (s *Store) InsertWebhookEvent(ctx context.Context, event domain.WebhookEvent) (bool, error) {
_, err := s.db.ExecContext(ctx, `INSERT INTO webhook_events(delivery_id,event_name,repo,comment_id,payload_sha256) VALUES(?,?,?,?,?)`, nullable(event.DeliveryID), event.EventName, event.Repo, event.CommentID, event.PayloadSHA256)
if err != nil {
if isConstraint(err) {
return false, nil
}
return false, err
}
return true, nil
}
func (s *Store) CooldownRemaining(ctx context.Context, repo string, pr int, duration time.Duration) (int, error) {
cutoff := time.Now().UTC().Add(-duration)
var created time.Time
err := s.db.QueryRowContext(ctx, `SELECT created_at FROM review_jobs WHERE repo=? AND pr_number=? AND created_at>=? ORDER BY created_at DESC LIMIT 1`, repo, pr, cutoff).Scan(&created)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
remaining := int((duration - time.Since(created)).Seconds())
if remaining < 0 {
return 0, nil
}
return remaining, nil
}
func (s *Store) EnqueueJob(ctx context.Context, event domain.WebhookEvent, command domain.ParsedCommand) (domain.Job, error) {
args, _ := json.Marshal(command.Arguments)
result, err := s.db.ExecContext(ctx, `INSERT INTO review_jobs(repo,pr_number,head_sha,trigger_comment_id,command,command_args,trigger_comment_body,requested_by,status) VALUES(?,?,?,?,?,?,?,?,?)`, event.Repo, event.PRNumber, event.HeadSHA, event.CommentID, command.Name, string(args), event.CommentBody, event.Sender, domain.JobQueued)
if err != nil {
return domain.Job{}, err
}
id, err := result.LastInsertId()
if err != nil {
return domain.Job{}, err
}
return s.getJob(ctx, id)
}
func (s *Store) ClaimNextJob(ctx context.Context, now time.Time, lease time.Duration, maxRetries int) (*domain.Job, *domain.ReviewRun, error) {
if err := s.recoverStale(ctx, now, lease, maxRetries); err != nil {
return nil, nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, nil, err
}
defer tx.Rollback()
query := `SELECT id FROM review_jobs WHERE status=? ORDER BY created_at ASC,id ASC LIMIT 1`
if s.dialect == "mysql" {
query += ` FOR UPDATE SKIP LOCKED`
}
var id int64
if err := tx.QueryRowContext(ctx, query, domain.JobQueued).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, nil
}
return nil, nil, err
}
started := now.UTC()
if _, err := tx.ExecContext(ctx, `UPDATE review_jobs SET status=?, started_at=?, finished_at=NULL, updated_at=? WHERE id=?`, domain.JobRunning, started, started, id); err != nil {
return nil, nil, err
}
runRes, err := tx.ExecContext(ctx, `INSERT INTO review_runs(job_id,status,started_at) VALUES(?,?,?)`, id, domain.RunRunning, started)
if err != nil {
return nil, nil, err
}
runID, err := runRes.LastInsertId()
if err != nil {
return nil, nil, err
}
if err := tx.Commit(); err != nil {
return nil, nil, err
}
job, err := s.getJob(ctx, id)
if err != nil {
return nil, nil, err
}
return &job, &domain.ReviewRun{ID: runID, JobID: id, Status: domain.RunRunning, StartedAt: started}, nil
}
func (s *Store) recoverStale(ctx context.Context, now time.Time, lease time.Duration, maxRetries int) error {
rows, err := s.db.QueryContext(ctx, `SELECT id,started_at FROM review_jobs WHERE status=? AND started_at IS NOT NULL AND started_at<=?`, domain.JobRunning, now.Add(-lease))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var started time.Time
if err := rows.Scan(&id, &started); err != nil {
return err
}
var attempts int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM review_runs WHERE job_id=?`, id).Scan(&attempts); err != nil {
return err
}
message := fmt.Sprintf("Job lease timed out after %ds on attempt %d. Recovered by queue watchdog.", int(lease.Seconds()), attempts)
_, _ = s.db.ExecContext(ctx, `UPDATE review_runs SET status=?,finished_at=?,error_message=? WHERE id=(SELECT id FROM (SELECT id FROM review_runs WHERE job_id=? ORDER BY id DESC LIMIT 1) AS latest) AND status=?`, domain.RunFailed, now, message, id, domain.RunRunning)
if attempts-1 < maxRetries {
_, err = s.db.ExecContext(ctx, `UPDATE review_jobs SET status=?,started_at=NULL,finished_at=NULL,last_error=?,updated_at=? WHERE id=?`, domain.JobQueued, message, now, id)
} else {
_, err = s.db.ExecContext(ctx, `UPDATE review_jobs SET status=?,finished_at=?,last_error=?,updated_at=? WHERE id=?`, domain.JobFailed, now, message, now, id)
}
if err != nil {
return err
}
}
return rows.Err()
}
func (s *Store) FinishJob(ctx context.Context, jobID, runID int64, success, skipped bool, result *domain.ReviewResult, runErr error) error {
now := time.Now().UTC()
status, runStatus := domain.JobFailed, domain.RunFailed
if skipped {
status, runStatus = domain.JobSkipped, domain.RunSkipped
} else if success {
status, runStatus = domain.JobSucceeded, domain.RunSucceeded
} else {
var attempts int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM review_runs WHERE job_id=?`, jobID).Scan(&attempts); err != nil {
return err
}
if attempts <= 3 {
status = domain.JobQueued
}
}
var resultJSON []byte
if result != nil {
resultJSON, _ = json.Marshal(result)
}
errText := ""
if runErr != nil {
errText = runErr.Error()
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if status == domain.JobQueued {
_, err = tx.ExecContext(ctx, `UPDATE review_jobs SET status=?,started_at=NULL,finished_at=NULL,last_error=?,result_json=?,updated_at=? WHERE id=?`, status, nullable(errText), nullableBytes(resultJSON), now, jobID)
} else {
_, err = tx.ExecContext(ctx, `UPDATE review_jobs SET status=?,finished_at=?,last_error=?,result_json=?,updated_at=? WHERE id=?`, status, now, nullable(errText), nullableBytes(resultJSON), now, jobID)
}
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE review_runs SET status=?,finished_at=?,error_message=?,result_json=? WHERE id=?`, runStatus, now, nullable(errText), nullableBytes(resultJSON), runID)
if err != nil {
return err
}
return tx.Commit()
}
func (s *Store) LatestFailedJob(ctx context.Context) (*domain.Job, error) {
return s.latest(ctx, `WHERE status=?`, domain.JobFailed)
}
func (s *Store) LatestJob(ctx context.Context) (*domain.Job, error) { return s.latest(ctx, ``, nil) }
func (s *Store) latest(ctx context.Context, where string, arg any) (*domain.Job, error) {
query := `SELECT id,repo,pr_number,head_sha,trigger_comment_id,COALESCE(trigger_comment_body,''),command,COALESCE(command_args,''),requested_by,status,COALESCE(last_error,''),COALESCE(result_json,''),created_at,updated_at,started_at,finished_at FROM review_jobs ` + where + ` ORDER BY created_at DESC,id DESC LIMIT 1`
var row *sql.Row
if arg == nil {
row = s.db.QueryRowContext(ctx, query)
} else {
row = s.db.QueryRowContext(ctx, query, arg)
}
job, err := scanJob(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &job, err
}
func (s *Store) LatestSuccessfulReview(ctx context.Context, repo string, pr int) (*domain.Job, error) {
return s.latestWith(ctx, `WHERE repo=? AND pr_number=? AND command IN ('review','rerun') AND status=?`, repo, pr, domain.JobSucceeded)
}
func (s *Store) latestWith(ctx context.Context, where string, args ...any) (*domain.Job, error) {
query := `SELECT id,repo,pr_number,head_sha,trigger_comment_id,COALESCE(trigger_comment_body,''),command,COALESCE(command_args,''),requested_by,status,COALESCE(last_error,''),COALESCE(result_json,''),created_at,updated_at,started_at,finished_at FROM review_jobs ` + where + ` ORDER BY id DESC LIMIT 1`
job, err := scanJob(s.db.QueryRowContext(ctx, query, args...))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &job, err
}
func (s *Store) PendingCount(ctx context.Context, repo string, pr int) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM review_jobs WHERE repo=? AND pr_number=? AND status IN (?,?)`, repo, pr, domain.JobQueued, domain.JobRunning).Scan(&n)
return n, err
}
func (s *Store) UpsertBotComment(ctx context.Context, repo string, pr int, marker string, commentID int64, sha string) error {
if s.dialect == "sqlite" {
_, err := s.db.ExecContext(ctx, `INSERT INTO bot_comments(repo,pr_number,head_sha,gitea_comment_id,marker) VALUES(?,?,?,?,?) ON CONFLICT(repo,pr_number,marker) DO UPDATE SET head_sha=excluded.head_sha,gitea_comment_id=excluded.gitea_comment_id,updated_at=CURRENT_TIMESTAMP`, repo, pr, sha, commentID, marker)
return err
}
_, err := s.db.ExecContext(ctx, `INSERT INTO bot_comments(repo,pr_number,head_sha,gitea_comment_id,marker) VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE head_sha=VALUES(head_sha),gitea_comment_id=VALUES(gitea_comment_id),updated_at=CURRENT_TIMESTAMP(6)`, repo, pr, sha, commentID, marker)
return err
}
func (s *Store) BotCommentID(ctx context.Context, repo string, pr int, marker string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, `SELECT gitea_comment_id FROM bot_comments WHERE repo=? AND pr_number=? AND marker=?`, repo, pr, marker).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return id, err
}
func (s *Store) getJob(ctx context.Context, id int64) (domain.Job, error) {
return scanJob(s.db.QueryRowContext(ctx, `SELECT id,repo,pr_number,head_sha,trigger_comment_id,COALESCE(trigger_comment_body,''),command,COALESCE(command_args,''),requested_by,status,COALESCE(last_error,''),COALESCE(result_json,''),created_at,updated_at,started_at,finished_at FROM review_jobs WHERE id=?`, id))
}
func scanJob(scanner interface{ Scan(...any) error }) (domain.Job, error) {
var j domain.Job
var status string
var result, body, args, last sql.NullString
var started, finished sql.NullTime
if err := scanner.Scan(&j.ID, &j.Repo, &j.PRNumber, &j.HeadSHA, &j.TriggerCommentID, &body, &j.Command, &args, &j.RequestedBy, &status, &last, &result, &j.CreatedAt, &j.UpdatedAt, &started, &finished); err != nil {
return domain.Job{}, err
}
j.TriggerCommentBody = body.String
j.CommandArgs = args.String
j.LastError = last.String
j.ResultJSON = []byte(result.String)
j.Status = domain.JobStatus(status)
if started.Valid {
j.StartedAt = &started.Time
}
if finished.Valid {
j.FinishedAt = &finished.Time
}
return j, nil
}
func isConstraint(err error) bool {
text := strings.ToLower(err.Error())
return strings.Contains(text, "unique") || strings.Contains(text, "duplicate") || strings.Contains(text, "constraint")
}
func nullable(v string) any {
if v == "" {
return nil
}
return v
}
func nullableBytes(v []byte) any {
if len(v) == 0 {
return nil
}
return string(v)
}
var _ store.Store = (*Store)(nil)
+52
View File
@@ -0,0 +1,52 @@
package sqlstore
import (
"context"
"testing"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
)
func TestSQLiteMigrationsAndJobLifecycle(t *testing.T) {
settings := config.Settings{DatabaseURL: "sqlite://" + t.TempDir() + "/test.db"}
st, err := Open(settings)
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
event := domain.WebhookEvent{EventName: "issue_comment", DeliveryID: "d1", Repo: "acme/repo", PRNumber: 9, HeadSHA: "abc123", CommentID: 11, CommentBody: "@codex review", Sender: "alice", PayloadSHA256: "digest"}
inserted, err := st.InsertWebhookEvent(ctx, event)
if err != nil || !inserted {
t.Fatalf("insert event: %v %v", inserted, err)
}
duplicate, err := st.InsertWebhookEvent(ctx, event)
if err != nil || duplicate {
t.Fatalf("duplicate event result: %v %v", duplicate, err)
}
job, err := st.EnqueueJob(ctx, event, domain.ParsedCommand{Name: "review", Raw: event.CommentBody, Mode: "summary"})
if err != nil {
t.Fatal(err)
}
claimed, run, err := st.ClaimNextJob(ctx, time.Now().UTC(), 5*time.Minute, 2)
if err != nil || claimed == nil || run == nil {
t.Fatalf("claim: %#v %#v %v", claimed, run, err)
}
result := domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: "ok", Findings: []domain.Finding{}}
if err := st.FinishJob(ctx, job.ID, run.ID, true, false, &result, nil); err != nil {
t.Fatal(err)
}
latest, err := st.LatestJob(ctx)
if err != nil || latest == nil || latest.Status != domain.JobSucceeded {
t.Fatalf("latest: %#v %v", latest, err)
}
remaining, err := st.CooldownRemaining(ctx, event.Repo, event.PRNumber, time.Minute)
if err != nil || remaining <= 0 {
t.Fatalf("cooldown: %d %v", remaining, err)
}
}
+24
View File
@@ -0,0 +1,24 @@
package store
import (
"context"
"time"
"gitea-codex-bot/internal/domain"
)
type Store interface {
Migrate(context.Context) error
InsertWebhookEvent(context.Context, domain.WebhookEvent) (bool, error)
CooldownRemaining(context.Context, string, int, time.Duration) (int, error)
EnqueueJob(context.Context, domain.WebhookEvent, domain.ParsedCommand) (domain.Job, error)
ClaimNextJob(context.Context, time.Time, time.Duration, int) (*domain.Job, *domain.ReviewRun, error)
FinishJob(context.Context, int64, int64, bool, bool, *domain.ReviewResult, error) error
LatestFailedJob(context.Context) (*domain.Job, error)
LatestJob(context.Context) (*domain.Job, error)
LatestSuccessfulReview(context.Context, string, int) (*domain.Job, error)
PendingCount(context.Context, string, int) (int, error)
UpsertBotComment(context.Context, string, int, string, int64, string) error
BotCommentID(context.Context, string, int, string) (int64, error)
Close() error
}
+127
View File
@@ -0,0 +1,127 @@
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"gitea-codex-bot/internal/domain"
)
func VerifySignature(body []byte, secret, supplied string) bool {
supplied = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(supplied), "sha256="))
if supplied == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(supplied)))
}
func Digest(body []byte) string { sum := sha256.Sum256(body); return hex.EncodeToString(sum[:]) }
func ParseEvent(eventName, delivery string, body []byte) (domain.WebhookEvent, error) {
if eventName != "issue_comment" && eventName != "pull_request_comment" {
return domain.WebhookEvent{}, errors.New("event ignored")
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return domain.WebhookEvent{}, errors.New("invalid JSON payload")
}
repo := nestedString(raw, "repository", "full_name")
commentID := nestedInt64(raw, "comment", "id")
sender := nestedString(raw, "sender", "username")
commentBody := nestedString(raw, "comment", "body")
if repo == "" || commentID <= 0 {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
prNumber, headSHA := 0, ""
if eventName == "issue_comment" {
if _, ok := raw["pull_request"]; !ok || raw["pull_request"] == nil {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
issue, ok := raw["issue"].(map[string]any)
if !ok || !truthy(issue["pull_request"]) {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
prNumber = number(issue["number"])
headSHA = nestedString(raw, "pull_request", "head", "sha")
} else {
pr, ok := raw["pull_request"].(map[string]any)
if !ok || pr == nil {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
prNumber = number(pr["number"])
headSHA = nestedString(raw, "pull_request", "head", "sha")
}
if prNumber <= 0 {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
if headSHA == "" {
headSHA = "unknown"
}
return domain.WebhookEvent{EventName: eventName, DeliveryID: delivery, Repo: repo, PRNumber: prNumber, HeadSHA: headSHA, CommentID: commentID, CommentBody: strings.TrimSpace(commentBody), Sender: sender, PayloadSHA256: Digest(body)}, nil
}
func nestedString(raw map[string]any, path ...string) string {
var current any = raw
for _, key := range path {
obj, ok := current.(map[string]any)
if !ok {
return ""
}
current = obj[key]
}
if value, ok := current.(string); ok {
return value
}
return ""
}
func nestedInt64(raw map[string]any, path ...string) int64 {
var current any = raw
for _, key := range path {
obj, ok := current.(map[string]any)
if !ok {
return 0
}
current = obj[key]
}
return int64(number(current))
}
func number(value any) int {
switch v := value.(type) {
case float64:
return int(v)
case json.Number:
n, _ := strconv.Atoi(string(v))
return n
case int:
return v
case int64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
}
return 0
}
func truthy(value any) bool {
switch v := value.(type) {
case bool:
return v
case map[string]any:
return len(v) > 0
case string:
return strings.TrimSpace(v) != ""
default:
return value != nil
}
}
var _ = fmt.Sprintf
+39
View File
@@ -0,0 +1,39 @@
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"testing"
)
func TestVerifySignatureUsesRawBodyAndPrefix(t *testing.T) {
body := []byte(`{"ok":true}`)
mac := hmac.New(sha256.New, []byte("secret"))
_, _ = mac.Write(body)
signature := hex.EncodeToString(mac.Sum(nil))
if !VerifySignature(body, "secret", "sha256="+signature) {
t.Fatal("valid signature was rejected")
}
if VerifySignature([]byte(`{"ok":false}`), "secret", signature) {
t.Fatal("changed body was accepted")
}
if VerifySignature(body, "wrong", signature) {
t.Fatal("wrong secret was accepted")
}
}
func TestParseEvents(t *testing.T) {
body := []byte(`{"repository":{"full_name":"acme/repo"},"sender":{"username":"alice"},"comment":{"id":11,"body":"@codex review"},"issue":{"number":9,"pull_request":{"url":"x"}},"pull_request":{"head":{"sha":"abc"}}}`)
event, err := ParseEvent("issue_comment", "delivery-1", body)
if err != nil {
t.Fatal(err)
}
if event.Repo != "acme/repo" || event.PRNumber != 9 || event.HeadSHA != "abc" || event.CommentID != 11 {
t.Fatalf("unexpected event: %#v", event)
}
bad := []byte(`{"repository":{"full_name":"acme/repo"},"comment":{"id":1},"issue":{"number":9}}`)
if _, err := ParseEvent("issue_comment", "", bad); err == nil {
t.Fatal("non-PR issue comment was accepted")
}
}
+210
View File
@@ -0,0 +1,210 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"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"
)
type Worker struct {
settings config.Settings
store store.Store
gitea *gitea.Client
runner domain.ReviewRunner
logger *slog.Logger
}
func New(settings config.Settings, st store.Store, client *gitea.Client, runner domain.ReviewRunner, logger *slog.Logger) *Worker {
return &Worker{settings: settings, store: st, gitea: client, runner: runner, logger: logger}
}
func (w *Worker) Run(ctx context.Context) error {
var wg sync.WaitGroup
for i := 0; i < w.settings.Concurrency; i++ {
wg.Add(1)
go func() { defer wg.Done(); w.loop(ctx) }()
}
wg.Wait()
return nil
}
func (w *Worker) loop(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
job, run, err := w.store.ClaimNextJob(ctx, time.Now().UTC(), 5*time.Minute, 2)
if err != nil {
w.logger.Error("claim job", "error", err)
sleep(ctx, time.Second)
continue
}
if job == nil {
sleep(ctx, time.Second)
continue
}
if err := w.process(ctx, *job, *run); err != nil {
w.logger.Error("process job", "job_id", job.ID, "error", err)
}
}
}
func (w *Worker) process(ctx context.Context, job domain.Job, run domain.ReviewRun) error {
cmd := commandFromJob(job, w.settings.Aliases())
if cmd.Name == "help" || cmd.Name == "ignore" || cmd.Name == "explain" {
return w.processNonReview(ctx, job, run, cmd)
}
pr, err := w.gitea.GetPullRequest(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
if pr.IsFork && !w.settings.AllowUntrustedForks {
message := "Skipped review for fork PR because `ALLOW_UNTRUSTED_FORKS=false`."
_, postErr := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, message)
if postErr != nil {
return w.fail(ctx, job, run, postErr)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: message, Findings: []domain.Finding{}}, nil)
}
cfg := review.MissingRepoConfig()
text, configured, cfgErr := w.gitea.GetFileContent(ctx, job.Repo, ".codex-review.yml", pr.HeadSHA)
if cfgErr != nil {
return w.fail(ctx, job, run, cfgErr)
}
if configured {
cfg, err = review.ParseRepoConfig(text)
if err != nil {
return w.fail(ctx, job, run, err)
}
}
if !cfg.Enabled {
_, postErr := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, review.FormatDisabledAck())
if postErr != nil {
return w.fail(ctx, job, run, postErr)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: review.FormatDisabledAck(), Findings: []domain.Finding{}}, nil)
}
review.ResolveMode(&cmd, cfg)
result, err := w.runner.Run(ctx, pr, cmd, cfg)
if err != nil {
return w.fail(ctx, job, run, err)
}
body := review.FormatResultComment(pr.HeadSHA, result, cfg.Configured)
commentID, err := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, body)
if err != nil {
return w.fail(ctx, job, run, err)
}
if err := w.store.UpsertBotComment(ctx, job.Repo, job.PRNumber, "codex-review", commentID, pr.HeadSHA); err != nil {
return w.fail(ctx, job, run, err)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, false, &result, nil)
}
func (w *Worker) processNonReview(ctx context.Context, job domain.Job, run domain.ReviewRun, cmd domain.ParsedCommand) error {
switch cmd.Name {
case "ignore":
result := domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: "Ignore command acknowledged. No review run executed.", Findings: []domain.Finding{}}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &result, nil)
case "explain":
latest, err := w.store.LatestSuccessfulReview(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
message := "## Codex Explain\n\nNo previous result found for this command."
if latest != nil && len(latest.ResultJSON) > 0 {
var result domain.ReviewResult
if json.Unmarshal(latest.ResultJSON, &result) == nil {
message = "## Codex Explain\n\n" + result.Summary
}
}
if _, err := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, message); err != nil {
return w.fail(ctx, job, run, err)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: message, Findings: []domain.Finding{}}, nil)
case "help":
comments, err := w.gitea.GetIssueComments(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
pending, err := w.store.PendingCount(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
message := helpComment(comments, w.settings.GiteaBotUsername, pending)
if _, err := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, message); err != nil {
return w.fail(ctx, job, run, err)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: "Help/status summary posted.", Findings: []domain.Finding{}}, nil)
}
return w.fail(ctx, job, run, fmt.Errorf("unsupported worker command %q", cmd.Name))
}
func (w *Worker) fail(ctx context.Context, job domain.Job, run domain.ReviewRun, err error) error {
errorText := strings.TrimSpace(err.Error())
if errorText == "" {
errorText = "review failed"
}
if _, postErr := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, review.FailureComment(job.HeadSHA, errorText)); postErr != nil {
w.logger.Error("post failure comment", "job_id", job.ID, "error", postErr)
}
return w.store.FinishJob(ctx, job.ID, run.ID, false, false, nil, fmt.Errorf("%s", errorText))
}
func commandFromJob(job domain.Job, aliases map[string]bool) domain.ParsedCommand {
if parsed, ok := commands.Parse(job.TriggerCommentBody, aliases); ok {
return parsed
}
args := strings.Fields(job.CommandArgs)
return domain.ParsedCommand{Name: job.Command, Raw: job.TriggerCommentBody, Arguments: args, Mode: "summary", Full: contains(args, "--full")}
}
func contains(items []string, needle string) bool {
for _, item := range items {
if item == needle {
return true
}
}
return false
}
func helpComment(comments []map[string]any, bot string, pending int) string {
bot = strings.ToLower(strings.TrimSpace(bot))
human, bots := 0, 0
lines := []string{"## Codex Help", "", "Supported commands:", "- `@codex review [security|performance|tests] [--full]`", "- `@codex rerun`", "- `@codex explain`", "- `@codex ignore`", "- `@codex -h` / `@codex --help` / `@codex help`", "", "Status note:", fmt.Sprintf("- Pending jobs on this PR: `%d`", pending), "", fmt.Sprintf("Discussion summary (%d comments):", len(comments))}
for _, c := range comments {
user := "unknown"
if obj, ok := c["user"].(map[string]any); ok {
if v, ok := obj["username"].(string); ok && v != "" {
user = v
} else if v, ok := obj["login"].(string); ok {
user = v
}
}
if strings.ToLower(user) == bot {
bots++
} else {
human++
}
body, _ := c["body"].(string)
body = strings.Join(strings.Fields(body), " ")
if body != "" {
if len(body) > 180 {
body = body[:180] + "..."
}
lines = append(lines, fmt.Sprintf("- @%s: %s", user, body))
}
}
lines[11] = fmt.Sprintf("Discussion summary (%d comments, human `%d`, bot `%d`):", len(comments), human, bots)
return strings.Join(lines, "\n")
}
func sleep(ctx context.Context, duration time.Duration) {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}