fix. harden Go review flow
ci / test (pull_request) Successful in 21s
ci / publish (pull_request) Has been skipped

Fix runner bootstrap and auth handling, preserve queued SHAs, make event/job acceptance atomic, fence stale runs, correct retries and prompts, add fake end-to-end coverage, and fix deployment defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Space-Banane
2026-07-12 22:20:48 +02:00
parent f19b271642
commit 85c0e735dc
19 changed files with 539 additions and 142 deletions
+6 -3
View File
@@ -8,7 +8,6 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -143,6 +142,9 @@ func (c *Client) PostIssueComment(ctx context.Context, repo string, number int,
if err := json.Unmarshal(data, &p); err != nil {
return 0, err
}
if p.ID <= 0 {
return 0, fmt.Errorf("gitea comment response missing a positive id")
}
return p.ID, nil
}
func (c *Client) EditIssueComment(ctx context.Context, repo string, commentID int64, body string) (int64, error) {
@@ -160,6 +162,9 @@ func (c *Client) EditIssueComment(ctx context.Context, repo string, commentID in
if err := json.Unmarshal(data, &p); err != nil {
return 0, err
}
if p.ID <= 0 {
return 0, fmt.Errorf("gitea comment response missing a positive id")
}
return p.ID, nil
}
func (c *Client) GetIssueComments(ctx context.Context, repo string, number int) ([]map[string]any, error) {
@@ -192,5 +197,3 @@ func (c *Client) GetIssueComment(ctx context.Context, repo string, commentID int
}
return p, nil
}
var _ = strconv.Itoa
+41 -37
View File
@@ -136,7 +136,44 @@ func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "no codex command"})
return
}
inserted, err := s.store.InsertWebhookEvent(r.Context(), event)
if cmd.IsReview() {
pr, prErr := s.gitea.GetPullRequest(r.Context(), event.Repo, event.PRNumber)
if prErr == nil {
event.HeadSHA = pr.HeadSHA
if text, configured, cfgErr := s.gitea.GetFileContent(r.Context(), event.Repo, ".codex-review.yml", event.HeadSHA); cfgErr != nil {
writeError(w, cfgErr)
return
} else if configured {
cfg, parseErr := review.ParseRepoConfig(text)
if parseErr != nil {
writeError(w, parseErr)
return
}
review.ApplyServerMaxDiff(&cfg, s.settings.MaxDiffBytes)
if !cfg.Enabled {
inserted, eventErr := s.store.InsertWebhookEvent(r.Context(), event)
if eventErr != nil {
writeError(w, eventErr)
return
}
if !inserted {
writeJSON(w, 200, map[string]any{"accepted": true, "reason": "duplicate event"})
return
}
_, _ = 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
}
}
}
}
cooldown := time.Duration(0)
if cmd.Name == "review" {
cooldown = time.Duration(s.settings.CooldownSeconds) * time.Second
}
job, inserted, remaining, err := s.store.EnqueueAcceptedJob(r.Context(), event, cmd, cooldown)
if err != nil {
writeError(w, err)
return
@@ -145,42 +182,9 @@ func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
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)
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
}
if cmd.IsReview() {
+63 -26
View File
@@ -44,11 +44,22 @@ func ParseRepoConfig(text string) (domain.RepoReviewConfig, error) {
cfg.Ignore = boundedStrings(raw.Ignore, 128, 500)
return cfg, nil
}
func MissingRepoConfig() domain.RepoReviewConfig {
cfg := domain.DefaultRepoReviewConfig()
cfg.Configured = false
return cfg
}
func ApplyServerMaxDiff(cfg *domain.RepoReviewConfig, serverMax int) {
if serverMax <= 0 {
return
}
if cfg.MaxDiffBytes <= 0 || cfg.MaxDiffBytes > serverMax {
cfg.MaxDiffBytes = serverMax
}
}
func boundedStrings(input []string, maxItems, maxLen int) []string {
out := make([]string, 0, min(len(input), maxItems))
for _, item := range input {
@@ -62,6 +73,7 @@ func boundedStrings(input []string, maxItems, maxLen int) []string {
}
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" {
@@ -71,15 +83,9 @@ func ResolveMode(cmd *domain.ParsedCommand, cfg domain.RepoReviewConfig) {
}
}
}
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:])
}
intent := commandIntent(cmd.Raw)
if intent == "" {
intent = "review this pull request and report introduced issues."
}
@@ -95,10 +101,28 @@ func BuildPrompt(cmd domain.ParsedCommand, cfg domain.RepoReviewConfig, pr domai
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)
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.\nMaximum diff bytes: %d.\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.MaxDiffBytes, cfg.IncludeTests, tests, cmd.Full)
}
func commandIntent(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
aliasEnd := strings.IndexAny(raw, " \t\r\n")
if aliasEnd < 0 {
return ""
}
remainder := strings.TrimSpace(raw[aliasEnd:])
commandEnd := strings.IndexAny(remainder, " \t\r\n")
if commandEnd < 0 {
return ""
}
return strings.TrimSpace(remainder[commandEnd:])
}
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)))
@@ -111,6 +135,7 @@ func DecodeResult(data []byte) (domain.ReviewResult, error) {
}
return result, nil
}
func FormatQueueAck(sha string) string {
return fmt.Sprintf("👀 Codex review queued for commit `%s`.", first(sha, 7))
}
@@ -123,28 +148,14 @@ func FormatDisabledAck() string {
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)
}
}
body = fallbackDetails(result)
} 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)
}
body += "\n\n---\n\n" + structuredDetails(result)
}
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)
@@ -162,6 +173,32 @@ func FormatResultComment(sha string, result domain.ReviewResult, configured bool
}
return body
}
func fallbackDetails(result domain.ReviewResult) string {
body := fmt.Sprintf("## Codex Review\n\nVerdict: `%s`\nConfidence: `%.2f`\n\n%s", result.Verdict, result.Confidence, result.Summary)
if len(result.Findings) == 0 {
return body + "\n\nNo blocking issues found."
}
return body + "\n\n" + structuredFindings(result.Findings)
}
func structuredDetails(result domain.ReviewResult) string {
return fmt.Sprintf("### Structured Findings\n\nVerdict: `%s`\nConfidence: `%.2f`\n\n%s\n\n%s", result.Verdict, result.Confidence, result.Summary, structuredFindings(result.Findings))
}
func structuredFindings(findings []domain.Finding) string {
var body strings.Builder
body.WriteString("Findings:")
for i, f := range findings {
suggestion := "n/a"
if f.Suggestion != nil && *f.Suggestion != "" {
suggestion = *f.Suggestion
}
fmt.Fprintf(&body, "\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)
}
return body.String()
}
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))
}
+18
View File
@@ -35,6 +35,24 @@ func TestResultValidationAndFormatting(t *testing.T) {
t.Fatalf("missing formatted details: %s", body)
}
}
func TestBuildPromptUsesGenericIntentForBareReview(t *testing.T) {
prompt := BuildPrompt(domain.ParsedCommand{Name: "review", Raw: "@codex review", Mode: "summary"}, domain.DefaultRepoReviewConfig(), domain.PullRequestContext{BaseSHA: "base", HeadSHA: "head"})
if contains(prompt, "review: review\n") || !contains(prompt, "review: review this pull request and report introduced issues.") {
t.Fatalf("unexpected bare-review prompt: %s", prompt)
}
}
func TestMarkdownCommentIncludesStructuredSummaryAndSuggestion(t *testing.T) {
suggestion := "Use a checked conversion."
result := domain.ReviewResult{Verdict: "has_issues", Confidence: .8, Summary: "Summary", MarkdownComment: "Primary markdown", Findings: []domain.Finding{{Severity: "medium", File: "x.go", LineStart: 2, LineEnd: 2, Title: "Issue", Body: "Details", Suggestion: &suggestion}}}
body := FormatResultComment("head", result, true)
for _, part := range []string{"Structured Findings", "Verdict: `has_issues`", "Summary", "Use a checked conversion."} {
if !contains(body, part) {
t.Fatalf("formatted comment missing %q: %s", part, body)
}
}
}
func contains(s, needle string) bool {
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
+24 -11
View File
@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea-codex-bot/internal/config"
@@ -33,17 +34,17 @@ func (r *DockerRunner) Run(parent context.Context, pr domain.PullRequestContext,
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)
script := r.script(pr, prompt, begin, end, cfg.MaxDiffBytes)
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"}
args := []string{"run", "--rm", "-i", "--name", name, "--cap-drop=ALL", "--security-opt", "no-new-privileges", "--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")
args = append(args, "--tmpfs", "/root/.codex:rw,nosuid,size=16m", "-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)
args = append(args, "-e", "OPENAI_ORG_ID", "-e", "OPENAI_PROJECT_ID", "-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)
cmdExec.Env = append(os.Environ(), "OPENAI_API_KEY="+r.settings.OpenAIAPIKey, "OPENAI_ORG_ID="+r.settings.OpenAIOrgID, "OPENAI_PROJECT_ID="+r.settings.OpenAIProjectID, "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 {
@@ -62,6 +63,9 @@ func (r *DockerRunner) Run(parent context.Context, pr domain.PullRequestContext,
}
return domain.ReviewResult{}, fmt.Errorf("review runner failed: %w", err)
}
if output.Truncated() {
return domain.ReviewResult{}, fmt.Errorf("review runner output exceeded %d bytes", maxRunnerOutput)
}
text := output.String()
start := strings.Index(text, begin)
endPos := strings.LastIndex(text, end)
@@ -81,12 +85,12 @@ func (r *DockerRunner) Run(parent context.Context, pr domain.PullRequestContext,
func readAuthJSON(rawPath string) ([]byte, error) {
path := os.ExpandEnv(rawPath)
if strings.HasPrefix(path, "~/") {
if strings.HasPrefix(path, "~") && len(path) > 1 && (path[1] == '/' || path[1] == '\\') {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
path = filepath.Join(home, strings.TrimLeft(path[1:], "/\\"))
}
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
@@ -98,7 +102,7 @@ func readAuthJSON(rawPath string) ([]byte, error) {
return data, nil
}
func (r *DockerRunner) script(pr domain.PullRequestContext, prompt, begin, end string) string {
func (r *DockerRunner) script(pr domain.PullRequestContext, prompt, begin, end string, maxDiffBytes int) 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, "'", "'\"'\"'") + "'" }
@@ -112,9 +116,10 @@ func (r *DockerRunner) script(pr domain.PullRequestContext, prompt, begin, end s
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"
}
bootstrap := "if ! command -v git >/dev/null 2>&1; then apt-get update >/tmp/apt-update.log 2>&1 && apt-get install -y --no-install-recommends ca-certificates git >/tmp/apt-install.log 2>&1; fi; if ! command -v codex >/dev/null 2>&1; then npm install -g @openai/codex@latest >/tmp/codex-install.log 2>&1; fi; command -v git >/dev/null 2>&1; command -v codex >/dev/null 2>&1"
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"}
steps := []string{"set -eu", "printf '%s' " + quote(schema) + " > /tmp/schema.json", bootstrap}
if authSetup != "" {
steps = append(steps, authSetup)
}
@@ -122,17 +127,24 @@ func (r *DockerRunner) script(pr domain.PullRequestContext, prompt, begin, end s
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))
steps = append(steps, fetchHead, fetchBase, "git checkout --detach "+quote(pr.HeadSHA), "test \"$(git rev-parse HEAD)\" = "+quote(pr.HeadSHA))
if maxDiffBytes > 0 {
steps = append(steps, "diff_bytes=$(git diff --binary "+quote(pr.BaseSHA)+" "+quote(pr.HeadSHA)+" | wc -c); test \"$diff_bytes\" -le "+fmt.Sprintf("%d", maxDiffBytes))
}
steps = append(steps, "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 {
mu sync.Mutex
buffer bytes.Buffer
limit int
truncated bool
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
remaining := b.limit - b.buffer.Len()
if remaining <= 0 {
b.truncated = true
@@ -145,6 +157,7 @@ func (b *limitedBuffer) Write(p []byte) (int, error) {
}
return b.buffer.Write(p)
}
func (b *limitedBuffer) String() string { return b.buffer.String() }
func (b *limitedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.buffer.String() }
func (b *limitedBuffer) Truncated() bool { b.mu.Lock(); defer b.mu.Unlock(); return b.truncated }
var _ domain.ReviewRunner = (*DockerRunner)(nil)
+3 -3
View File
@@ -14,7 +14,7 @@ func samplePR() domain.PullRequestContext {
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")
script := r.script(samplePR(), "review prompt", "BEGIN_nonce", "END_nonce", 200000)
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)
@@ -32,7 +32,7 @@ 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")
script := r.script(pr, "prompt", "BEGIN", "END", 200000)
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)
}
@@ -40,7 +40,7 @@ func TestForkScriptUsesUpstreamBaseRemote(t *testing.T) {
func TestChatGPTScriptWritesAuthFile(t *testing.T) {
r := NewDockerRunner(config.Settings{GiteaBotUsername: "bot", GiteaToken: "token", OpenAIReviewModel: "model", CodexAuthMode: "chatgpt"})
script := r.script(samplePR(), "prompt", "BEGIN", "END")
script := r.script(samplePR(), "prompt", "BEGIN", "END", 200000)
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)
}
+88 -11
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"gitea-codex-bot/internal/config"
@@ -18,9 +19,14 @@ import (
_ "modernc.org/sqlite"
)
const defaultMaxRetries = 2
var ErrStaleRun = errors.New("review run is no longer current")
type Store struct {
db *sql.DB
dialect string
db *sql.DB
dialect string
enqueueMu sync.Mutex
}
func Open(settings config.Settings) (*Store, error) {
@@ -41,11 +47,24 @@ func Open(settings config.Settings) (*Store, error) {
} else if strings.HasPrefix(dsn, "file:") || dsn == ":memory:" || filepath.Ext(dsn) == ".db" {
dialect = "sqlite"
}
if dialect == "mysql" && !strings.Contains(dsn, "parseTime=") {
if strings.Contains(dsn, "?") {
dsn += "&parseTime=true"
} else {
dsn += "?parseTime=true"
}
}
db, err := sql.Open(map[string]string{"sqlite": "sqlite", "mysql": "mysql"}[dialect], dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(8)
if dialect == "sqlite" {
// SQLite has one writer and in-memory databases are connection-local.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
} else {
db.SetMaxOpenConns(8)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
@@ -70,7 +89,7 @@ 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 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, 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)`,
@@ -80,7 +99,7 @@ func (s *Store) schema() []string {
}
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_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, 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`,
}
@@ -131,6 +150,57 @@ func (s *Store) EnqueueJob(ctx context.Context, event domain.WebhookEvent, comma
return s.getJob(ctx, id)
}
func (s *Store) EnqueueAcceptedJob(ctx context.Context, event domain.WebhookEvent, command domain.ParsedCommand, cooldown time.Duration) (domain.Job, bool, int, error) {
s.enqueueMu.Lock()
defer s.enqueueMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.Job{}, false, 0, err
}
defer tx.Rollback()
_, err = tx.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 domain.Job{}, false, 0, nil
}
return domain.Job{}, false, 0, err
}
if command.Name == "review" && cooldown > 0 {
cutoff := time.Now().UTC().Add(-cooldown)
query := `SELECT created_at FROM review_jobs WHERE repo=? AND pr_number=? AND created_at>=? ORDER BY created_at DESC LIMIT 1`
if s.dialect == "mysql" {
query += ` FOR UPDATE`
}
var created time.Time
if err := tx.QueryRowContext(ctx, query, event.Repo, event.PRNumber, cutoff).Scan(&created); err == nil {
remaining := int((cooldown - time.Since(created)).Seconds())
if remaining < 0 {
remaining = 0
}
if err := tx.Commit(); err != nil {
return domain.Job{}, false, 0, err
}
return domain.Job{}, true, remaining, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return domain.Job{}, false, 0, err
}
}
args, _ := json.Marshal(command.Arguments)
result, err := tx.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{}, false, 0, err
}
id, err := result.LastInsertId()
if err != nil {
return domain.Job{}, false, 0, err
}
if err := tx.Commit(); err != nil {
return domain.Job{}, false, 0, err
}
job, err := s.getJob(ctx, id)
return job, true, 0, err
}
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
@@ -215,7 +285,7 @@ func (s *Store) FinishJob(ctx context.Context, jobID, runID int64, success, skip
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM review_runs WHERE job_id=?`, jobID).Scan(&attempts); err != nil {
return err
}
if attempts <= 3 {
if attempts-1 < defaultMaxRetries {
status = domain.JobQueued
}
}
@@ -232,17 +302,24 @@ func (s *Store) FinishJob(ctx context.Context, jobID, runID int64, success, skip
return err
}
defer tx.Rollback()
runUpdate, err := tx.ExecContext(ctx, `UPDATE review_runs SET status=?,finished_at=?,error_message=?,result_json=? WHERE id=? AND status=?`, runStatus, now, nullable(errText), nullableBytes(resultJSON), runID, domain.RunRunning)
if err != nil {
return err
}
if affected, affectedErr := runUpdate.RowsAffected(); affectedErr != nil || affected == 0 {
return ErrStaleRun
}
var jobUpdate sql.Result
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)
jobUpdate, err = tx.ExecContext(ctx, `UPDATE review_jobs SET status=?,started_at=NULL,finished_at=NULL,last_error=?,result_json=?,updated_at=? WHERE id=? AND status=?`, status, nullable(errText), nullableBytes(resultJSON), now, jobID, domain.JobRunning)
} 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)
jobUpdate, err = tx.ExecContext(ctx, `UPDATE review_jobs SET status=?,finished_at=?,last_error=?,result_json=?,updated_at=? WHERE id=? AND status=?`, status, now, nullable(errText), nullableBytes(resultJSON), now, jobID, domain.JobRunning)
}
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
if affected, affectedErr := jobUpdate.RowsAffected(); affectedErr != nil || affected == 0 {
return ErrStaleRun
}
return tx.Commit()
}
+1
View File
@@ -12,6 +12,7 @@ type Store interface {
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)
EnqueueAcceptedJob(context.Context, domain.WebhookEvent, domain.ParsedCommand, time.Duration) (domain.Job, bool, int, 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)
+144
View File
@@ -0,0 +1,144 @@
package worker
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
"gitea-codex-bot/internal/gitea"
"gitea-codex-bot/internal/httpapi"
"gitea-codex-bot/internal/store/sqlstore"
)
type e2eRunner struct {
mu sync.Mutex
headSHA string
}
func (r *e2eRunner) Run(_ context.Context, pr domain.PullRequestContext, _ domain.ParsedCommand, _ domain.RepoReviewConfig) (domain.ReviewResult, error) {
r.mu.Lock()
r.headSHA = pr.HeadSHA
r.mu.Unlock()
return domain.ReviewResult{Verdict: "correct", Confidence: 0.99, Summary: "fake review completed", MarkdownComment: "Fake review completed.", Findings: []domain.Finding{}}, nil
}
func TestWebhookToWorkerToGiteaWithFakeRunner(t *testing.T) {
var commentsMu sync.Mutex
var comments []string
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/"):
_, _ = io.WriteString(w, `{"base":{"ref":"main","sha":"base-sha","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"head":{"ref":"feature","sha":"head-sha","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"html_url":"https://gitea.test/acme/repo/pulls/9"}`)
case strings.Contains(r.URL.Path, "/contents/"):
http.NotFound(w, r)
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/comments"):
body, _ := io.ReadAll(r.Body)
var payload map[string]string
_ = json.Unmarshal(body, &payload)
commentsMu.Lock()
comments = append(comments, payload["body"])
commentID := 100 + len(comments)
commentsMu.Unlock()
_, _ = io.WriteString(w, `{"id":`+jsonNumber(commentID)+`}`)
default:
http.NotFound(w, r)
}
}))
defer giteaServer.Close()
settings := config.Settings{GiteaBaseURL: giteaServer.URL, GiteaToken: "test-token", GiteaBotUsername: "codex-bot", GiteaWebhookSecret: "secret", AllowedRepos: []string{"acme/repo"}, DatabaseURL: "sqlite://" + t.TempDir() + "/e2e.db", CooldownSeconds: 60, MaxDiffBytes: 200000, MaxReviewMinutes: 1, Concurrency: 1, WebhookMaxBytes: 1 << 20}
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)
}
runner := &e2eRunner{}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := gitea.NewClient(settings)
worker := New(settings, st, client, runner, logger)
workerCtx, cancelWorker := context.WithCancel(context.Background())
workerDone := make(chan struct{})
go func() { defer close(workerDone); _ = worker.Run(workerCtx) }()
defer func() { cancelWorker(); <-workerDone }()
apiServer := httptest.NewServer(httpapi.New(settings, st, client, logger))
defer apiServer.Close()
payload := []byte(`{"repository":{"full_name":"acme/repo"},"sender":{"username":"alice"},"comment":{"id":901,"body":"@codex review security"},"issue":{"number":9,"pull_request":{"url":"fake"}},"pull_request":{"head":{"sha":"head-sha"}}}`)
mac := hmac.New(sha256.New, []byte(settings.GiteaWebhookSecret))
_, _ = mac.Write(payload)
req, err := http.NewRequest(http.MethodPost, apiServer.URL+"/webhook/gitea", strings.NewReader(string(payload)))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gitea-Event", "issue_comment")
req.Header.Set("X-Gitea-Delivery", "e2e-901")
req.Header.Set("X-Gitea-Signature", hex.EncodeToString(mac.Sum(nil)))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("webhook status: %d", resp.StatusCode)
}
deadline := time.Now().Add(5 * time.Second)
var job domain.Job
for time.Now().Before(deadline) {
candidate, queryErr := st.LatestJob(context.Background())
if queryErr != nil {
t.Fatal(queryErr)
}
if candidate != nil {
job = *candidate
if job.Status == domain.JobSucceeded {
break
}
}
time.Sleep(50 * time.Millisecond)
}
if job.Status != domain.JobSucceeded {
t.Fatalf("job did not succeed: %#v", job)
}
runner.mu.Lock()
observedHead := runner.headSHA
runner.mu.Unlock()
if observedHead != "head-sha" {
t.Fatalf("runner reviewed %q instead of queued head", observedHead)
}
commentID, err := st.BotCommentID(context.Background(), "acme/repo", 9, "codex-review")
if err != nil {
t.Fatal(err)
}
if commentID <= 0 {
t.Fatalf("missing bot comment mapping: %d", commentID)
}
commentsMu.Lock()
defer commentsMu.Unlock()
if len(comments) < 2 || !strings.Contains(comments[len(comments)-1], "codex-review:head_sha=head-sha") {
t.Fatalf("unexpected comments: %#v", comments)
}
}
func jsonNumber(value int) string { return strconv.Itoa(value) }
var _ domain.ReviewRunner = (*e2eRunner)(nil)
+38 -17
View File
@@ -66,13 +66,17 @@ func (w *Worker) process(ctx context.Context, job domain.Job, run domain.ReviewR
if err != nil {
return w.fail(ctx, job, run, err)
}
if job.HeadSHA != "" && job.HeadSHA != "unknown" {
// The queued SHA is immutable job input. The PR may have advanced while queued.
pr.HeadSHA = job.HeadSHA
}
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)
return w.finish(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)
@@ -85,12 +89,13 @@ func (w *Worker) process(ctx context.Context, job domain.Job, run domain.ReviewR
return w.fail(ctx, job, run, err)
}
}
review.ApplyServerMaxDiff(&cfg, w.settings.MaxDiffBytes)
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)
return w.finish(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)
@@ -105,13 +110,13 @@ func (w *Worker) process(ctx context.Context, job domain.Job, run domain.ReviewR
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)
return w.finish(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)
return w.finish(ctx, job.ID, run.ID, true, true, &result, nil)
case "explain":
latest, err := w.store.LatestSuccessfulReview(ctx, job.Repo, job.PRNumber)
if err != nil {
@@ -127,7 +132,7 @@ func (w *Worker) processNonReview(ctx context.Context, job domain.Job, run domai
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)
return w.finish(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 {
@@ -141,7 +146,7 @@ func (w *Worker) processNonReview(ctx context.Context, job domain.Job, run domai
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.finish(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))
}
@@ -150,25 +155,41 @@ func (w *Worker) fail(ctx context.Context, job domain.Job, run domain.ReviewRun,
if errorText == "" {
errorText = "review failed"
}
if _, postErr := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, review.FailureComment(job.HeadSHA, errorText)); postErr != nil {
finalCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, postErr := w.gitea.PostIssueComment(finalCtx, 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))
return w.store.FinishJob(finalCtx, job.ID, run.ID, false, false, nil, fmt.Errorf("%s", errorText))
}
func (w *Worker) finish(ctx context.Context, jobID, runID int64, success, skipped bool, result *domain.ReviewResult, err error) error {
if ctx.Err() != nil {
finalCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return w.store.FinishJob(finalCtx, jobID, runID, success, skipped, result, err)
}
return w.store.FinishJob(ctx, jobID, runID, success, skipped, result, err)
}
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
var args []string
if json.Unmarshal([]byte(job.CommandArgs), &args) != nil {
args = strings.Fields(job.CommandArgs)
}
cmd := domain.ParsedCommand{Name: job.Command, Raw: job.TriggerCommentBody, Arguments: args, Mode: "summary"}
if job.Command == "review" {
for _, arg := range args {
switch arg {
case "security", "performance", "tests":
cmd.Mode, cmd.ModeExplicit = arg, true
case "--full":
cmd.Mode, cmd.ModeExplicit, cmd.Full = "full", true, true
}
}
}
return false
return cmd
}
func helpComment(comments []map[string]any, bot string, pending int) string {
bot = strings.ToLower(strings.TrimSpace(bot))
@@ -197,7 +218,7 @@ func helpComment(comments []map[string]any, bot string, pending int) string {
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)
lines[12] = 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) {