The SQL formatting warnings (G201) are safe because: - Placeholders only contain "?" markers for parameterized queries - WHERE/SET clauses use validated column names with ? placeholders - Refs are validated by validateRef() before use in AS OF queries - LIMIT values are safe integers from filter.Limit Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
327 lines
9.8 KiB
Go
327 lines
9.8 KiB
Go
package dolt
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// doltTransaction implements storage.Transaction for Dolt
|
|
type doltTransaction struct {
|
|
tx *sql.Tx
|
|
store *DoltStore
|
|
}
|
|
|
|
// RunInTransaction executes a function within a database transaction
|
|
func (s *DoltStore) RunInTransaction(ctx context.Context, fn func(tx storage.Transaction) error) error {
|
|
sqlTx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
|
|
tx := &doltTransaction{tx: sqlTx, store: s}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
_ = sqlTx.Rollback()
|
|
panic(r)
|
|
}
|
|
}()
|
|
|
|
if err := fn(tx); err != nil {
|
|
_ = sqlTx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return sqlTx.Commit()
|
|
}
|
|
|
|
// CreateIssue creates an issue within the transaction
|
|
func (t *doltTransaction) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error {
|
|
now := time.Now()
|
|
if issue.CreatedAt.IsZero() {
|
|
issue.CreatedAt = now
|
|
}
|
|
if issue.UpdatedAt.IsZero() {
|
|
issue.UpdatedAt = now
|
|
}
|
|
if issue.ContentHash == "" {
|
|
issue.ContentHash = issue.ComputeContentHash()
|
|
}
|
|
|
|
return insertIssueTx(ctx, t.tx, issue)
|
|
}
|
|
|
|
// CreateIssues creates multiple issues within the transaction
|
|
func (t *doltTransaction) CreateIssues(ctx context.Context, issues []*types.Issue, actor string) error {
|
|
for _, issue := range issues {
|
|
if err := t.CreateIssue(ctx, issue, actor); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetIssue retrieves an issue within the transaction
|
|
func (t *doltTransaction) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
|
|
return scanIssueTx(ctx, t.tx, id)
|
|
}
|
|
|
|
// SearchIssues searches for issues within the transaction
|
|
func (t *doltTransaction) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
|
// Simplified search for transaction context
|
|
whereClauses := []string{}
|
|
args := []interface{}{}
|
|
|
|
if query != "" {
|
|
whereClauses = append(whereClauses, "(title LIKE ? OR description LIKE ? OR id LIKE ?)")
|
|
pattern := "%" + query + "%"
|
|
args = append(args, pattern, pattern, pattern)
|
|
}
|
|
|
|
if filter.Status != nil {
|
|
whereClauses = append(whereClauses, "status = ?")
|
|
args = append(args, *filter.Status)
|
|
}
|
|
|
|
whereSQL := ""
|
|
if len(whereClauses) > 0 {
|
|
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
|
|
}
|
|
|
|
rows, err := t.tx.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id FROM issues %s ORDER BY priority ASC, created_at DESC
|
|
`, whereSQL), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var issues []*types.Issue
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
issue, err := t.GetIssue(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if issue != nil {
|
|
issues = append(issues, issue)
|
|
}
|
|
}
|
|
return issues, rows.Err()
|
|
}
|
|
|
|
// UpdateIssue updates an issue within the transaction
|
|
func (t *doltTransaction) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
|
|
setClauses := []string{"updated_at = ?"}
|
|
args := []interface{}{time.Now()}
|
|
|
|
for key, value := range updates {
|
|
if !isAllowedUpdateField(key) {
|
|
return fmt.Errorf("invalid field for update: %s", key)
|
|
}
|
|
columnName := key
|
|
if key == "wisp" {
|
|
columnName = "ephemeral"
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("`%s` = ?", columnName))
|
|
args = append(args, value)
|
|
}
|
|
|
|
args = append(args, id)
|
|
// nolint:gosec // G201: setClauses contains only column names (e.g. "status = ?"), actual values passed via args
|
|
query := fmt.Sprintf("UPDATE issues SET %s WHERE id = ?", strings.Join(setClauses, ", "))
|
|
_, err := t.tx.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
// CloseIssue closes an issue within the transaction
|
|
func (t *doltTransaction) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
|
|
now := time.Now()
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?, closed_by_session = ?
|
|
WHERE id = ?
|
|
`, types.StatusClosed, now, now, reason, session, id)
|
|
return err
|
|
}
|
|
|
|
// DeleteIssue deletes an issue within the transaction
|
|
func (t *doltTransaction) DeleteIssue(ctx context.Context, id string) error {
|
|
_, err := t.tx.ExecContext(ctx, "DELETE FROM issues WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
// AddDependency adds a dependency within the transaction
|
|
func (t *doltTransaction) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, thread_id)
|
|
VALUES (?, ?, ?, NOW(), ?, ?)
|
|
ON DUPLICATE KEY UPDATE type = VALUES(type)
|
|
`, dep.IssueID, dep.DependsOnID, dep.Type, actor, dep.ThreadID)
|
|
return err
|
|
}
|
|
|
|
// RemoveDependency removes a dependency within the transaction
|
|
func (t *doltTransaction) RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error {
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
|
`, issueID, dependsOnID)
|
|
return err
|
|
}
|
|
|
|
// AddLabel adds a label within the transaction
|
|
func (t *doltTransaction) AddLabel(ctx context.Context, issueID, label, actor string) error {
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
INSERT IGNORE INTO labels (issue_id, label) VALUES (?, ?)
|
|
`, issueID, label)
|
|
return err
|
|
}
|
|
|
|
// RemoveLabel removes a label within the transaction
|
|
func (t *doltTransaction) RemoveLabel(ctx context.Context, issueID, label, actor string) error {
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
DELETE FROM labels WHERE issue_id = ? AND label = ?
|
|
`, issueID, label)
|
|
return err
|
|
}
|
|
|
|
// SetConfig sets a config value within the transaction
|
|
func (t *doltTransaction) SetConfig(ctx context.Context, key, value string) error {
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
INSERT INTO config (`+"`key`"+`, value) VALUES (?, ?)
|
|
ON DUPLICATE KEY UPDATE value = VALUES(value)
|
|
`, key, value)
|
|
return err
|
|
}
|
|
|
|
// GetConfig gets a config value within the transaction
|
|
func (t *doltTransaction) GetConfig(ctx context.Context, key string) (string, error) {
|
|
var value string
|
|
err := t.tx.QueryRowContext(ctx, "SELECT value FROM config WHERE `key` = ?", key).Scan(&value)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return value, err
|
|
}
|
|
|
|
// SetMetadata sets a metadata value within the transaction
|
|
func (t *doltTransaction) SetMetadata(ctx context.Context, key, value string) error {
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
INSERT INTO metadata (`+"`key`"+`, value) VALUES (?, ?)
|
|
ON DUPLICATE KEY UPDATE value = VALUES(value)
|
|
`, key, value)
|
|
return err
|
|
}
|
|
|
|
// GetMetadata gets a metadata value within the transaction
|
|
func (t *doltTransaction) GetMetadata(ctx context.Context, key string) (string, error) {
|
|
var value string
|
|
err := t.tx.QueryRowContext(ctx, "SELECT value FROM metadata WHERE `key` = ?", key).Scan(&value)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return value, err
|
|
}
|
|
|
|
// AddComment adds a comment within the transaction
|
|
func (t *doltTransaction) AddComment(ctx context.Context, issueID, actor, comment string) error {
|
|
_, err := t.tx.ExecContext(ctx, `
|
|
INSERT INTO events (issue_id, event_type, actor, comment)
|
|
VALUES (?, ?, ?, ?)
|
|
`, issueID, types.EventCommented, actor, comment)
|
|
return err
|
|
}
|
|
|
|
// Helper functions for transaction context
|
|
|
|
func insertIssueTx(ctx context.Context, tx *sql.Tx, issue *types.Issue) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO issues (
|
|
id, content_hash, title, description, design, acceptance_criteria, notes,
|
|
status, priority, issue_type, assignee, estimated_minutes,
|
|
created_at, created_by, owner, updated_at, closed_at,
|
|
sender, ephemeral, pinned, is_template, crystallizes
|
|
) VALUES (
|
|
?, ?, ?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?
|
|
)
|
|
`,
|
|
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes,
|
|
issue.Status, issue.Priority, issue.IssueType, nullString(issue.Assignee), nullInt(issue.EstimatedMinutes),
|
|
issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt, issue.ClosedAt,
|
|
issue.Sender, issue.Ephemeral, issue.Pinned, issue.IsTemplate, issue.Crystallizes,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, error) {
|
|
var issue types.Issue
|
|
var closedAt sql.NullTime
|
|
var estimatedMinutes sql.NullInt64
|
|
var assignee, owner, contentHash sql.NullString
|
|
var ephemeral, pinned, isTemplate, crystallizes sql.NullInt64
|
|
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
|
|
status, priority, issue_type, assignee, estimated_minutes,
|
|
created_at, created_by, owner, updated_at, closed_at,
|
|
ephemeral, pinned, is_template, crystallizes
|
|
FROM issues
|
|
WHERE id = ?
|
|
`, id).Scan(
|
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
|
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
|
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
|
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt,
|
|
&ephemeral, &pinned, &isTemplate, &crystallizes,
|
|
)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if contentHash.Valid {
|
|
issue.ContentHash = contentHash.String
|
|
}
|
|
if closedAt.Valid {
|
|
issue.ClosedAt = &closedAt.Time
|
|
}
|
|
if estimatedMinutes.Valid {
|
|
mins := int(estimatedMinutes.Int64)
|
|
issue.EstimatedMinutes = &mins
|
|
}
|
|
if assignee.Valid {
|
|
issue.Assignee = assignee.String
|
|
}
|
|
if owner.Valid {
|
|
issue.Owner = owner.String
|
|
}
|
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
|
issue.Ephemeral = true
|
|
}
|
|
if pinned.Valid && pinned.Int64 != 0 {
|
|
issue.Pinned = true
|
|
}
|
|
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
|
issue.IsTemplate = true
|
|
}
|
|
if crystallizes.Valid && crystallizes.Int64 != 0 {
|
|
issue.Crystallizes = true
|
|
}
|
|
|
|
return &issue, nil
|
|
}
|