fix. harden Go review flow
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user