claw-code/.guardrails/mcp-server/internal/database/failures.go

251 lines
8.4 KiB
Go

package database
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/thearchitectit/guardrail-mcp/internal/models"
)
// FailureStore handles failure registry database operations
type FailureStore struct {
db *DB
}
// NewFailureStore creates a new failure store
func NewFailureStore(db *DB) *FailureStore {
return &FailureStore{db: db}
}
// GetByID retrieves a failure by ID
func (s *FailureStore) GetByID(ctx context.Context, id uuid.UUID) (*models.FailureEntry, error) {
var f models.FailureEntry
err := s.db.QueryRowContext(ctx, `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE id = $1
`, id).Scan(
&f.ID, &f.FailureID, &f.Category, &f.Severity, &f.ErrorMessage,
&f.RootCause, &f.AffectedFiles, &f.RegressionPattern, &f.Status,
&f.ProjectSlug, &f.CreatedAt, &f.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("failure not found: %s", id)
}
return nil, fmt.Errorf("failed to get failure: %w", err)
}
return &f, nil
}
// List retrieves failures with optional filters
// Uses type-safe query building to prevent SQL injection
func (s *FailureStore) List(ctx context.Context, status, category, projectSlug string, limit, offset int) ([]models.FailureEntry, error) {
// Build query safely without string concatenation to prevent SQL injection
type filter struct {
query string
args []interface{}
}
var f filter
// Determine which query to use based on provided filters
switch {
case status != "" && category != "" && projectSlug != "":
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE status = $1 AND category = $2 AND project_slug = $3
ORDER BY created_at DESC LIMIT $4 OFFSET $5
`
f.args = []interface{}{status, category, projectSlug, limit, offset}
case status != "" && category != "":
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE status = $1 AND category = $2
ORDER BY created_at DESC LIMIT $3 OFFSET $4
`
f.args = []interface{}{status, category, limit, offset}
case status != "" && projectSlug != "":
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE status = $1 AND project_slug = $2
ORDER BY created_at DESC LIMIT $3 OFFSET $4
`
f.args = []interface{}{status, projectSlug, limit, offset}
case category != "" && projectSlug != "":
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE category = $1 AND project_slug = $2
ORDER BY created_at DESC LIMIT $3 OFFSET $4
`
f.args = []interface{}{category, projectSlug, limit, offset}
case status != "":
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE status = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3
`
f.args = []interface{}{status, limit, offset}
case category != "":
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE category = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3
`
f.args = []interface{}{category, limit, offset}
case projectSlug != "":
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE project_slug = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3
`
f.args = []interface{}{projectSlug, limit, offset}
default:
f.query = `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
ORDER BY created_at DESC LIMIT $1 OFFSET $2
`
f.args = []interface{}{limit, offset}
}
rows, err := s.db.QueryContext(ctx, f.query, f.args...)
if err != nil {
return nil, fmt.Errorf("failed to list failures: %w", err)
}
defer rows.Close()
var failures []models.FailureEntry
for rows.Next() {
var entry models.FailureEntry
err := rows.Scan(
&entry.ID, &entry.FailureID, &entry.Category, &entry.Severity, &entry.ErrorMessage,
&entry.RootCause, &entry.AffectedFiles, &entry.RegressionPattern, &entry.Status,
&entry.ProjectSlug, &entry.CreatedAt, &entry.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan failure: %w", err)
}
failures = append(failures, entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating failures: %w", err)
}
return failures, nil
}
// Create inserts a new failure within a transaction
func (s *FailureStore) Create(ctx context.Context, f *models.FailureEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
err = tx.QueryRowContext(ctx, `
INSERT INTO failure_registry (failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at
`, f.FailureID, f.Category, f.Severity, f.ErrorMessage, f.RootCause,
f.AffectedFiles, f.RegressionPattern, f.Status, f.ProjectSlug,
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to create failure: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// Update updates an existing failure within a transaction
func (s *FailureStore) Update(ctx context.Context, f *models.FailureEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
UPDATE failure_registry
SET error_message = $1, root_cause = $2, affected_files = $3, regression_pattern = $4, status = $5, updated_at = NOW()
WHERE id = $6
`, f.ErrorMessage, f.RootCause, f.AffectedFiles, f.RegressionPattern, f.Status, f.ID)
if err != nil {
return fmt.Errorf("failed to update failure: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("failure not found: %s", f.ID)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// GetActiveByFiles retrieves active failures that affect given files
func (s *FailureStore) GetActiveByFiles(ctx context.Context, files []string) ([]models.FailureEntry, error) {
if len(files) == 0 {
return []models.FailureEntry{}, nil
}
// Query for failures where affected_files overlaps with input files
query := `
SELECT id, failure_id, category, severity, error_message, root_cause, affected_files, regression_pattern, status, project_slug, created_at, updated_at
FROM failure_registry
WHERE status = 'active'
AND affected_files && $1
ORDER BY severity DESC, created_at DESC
`
rows, err := s.db.QueryContext(ctx, query, files)
if err != nil {
return nil, fmt.Errorf("failed to get active failures: %w", err)
}
defer rows.Close()
var failures []models.FailureEntry
for rows.Next() {
var f models.FailureEntry
err := rows.Scan(
&f.ID, &f.FailureID, &f.Category, &f.Severity, &f.ErrorMessage,
&f.RootCause, &f.AffectedFiles, &f.RegressionPattern, &f.Status,
&f.ProjectSlug, &f.CreatedAt, &f.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan failure: %w", err)
}
failures = append(failures, f)
}
return failures, rows.Err()
}
// Count returns the total count of failures
func (s *FailureStore) Count(ctx context.Context) (int64, error) {
var count int64
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM failure_registry`).Scan(&count)
return count, err
}