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,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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
@@ -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)) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user