bd sync: 2025-11-24 20:26:24

This commit is contained in:
Steve Yegge
2025-11-24 20:26:24 -08:00
parent c1a10be90a
commit d2f3762969
4 changed files with 631 additions and 2 deletions

View File

@@ -58,7 +58,7 @@
{"id":"bd-m7ge","content_hash":"bb08f2bcbbdd2e392733d92bff2e46a51000337ac019d306dd6a2983916873c4","title":"Add .beads/README.md during 'bd init' for project documentation and promotion","description":"When 'bd init' is run, automatically generate a .beads/README.md file that:\n\n1. Briefly explains what Beads is (AI-native issue tracking that lives in your repo)\n2. Links to the main repository: https://github.com/steveyegge/beads\n3. Provides a quick reference of essential commands:\n - bd create: Create new issues\n - bd list: View all issues\n - bd update: Modify issue status/details\n - bd show: View issue details\n - bd sync: Sync with git remote\n4. Highlights key benefits for AI coding agents and developers\n5. Encourages developers to try it out\n\nThe README should be enthusiastic and compelling to get open source contributors excited about using Beads for their AI-assisted development workflows.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-11-16T22:32:50.478681-08:00","updated_at":"2025-11-16T22:32:58.492868-08:00","source_repo":"."}
{"id":"bd-mexx","content_hash":"8fef16c6c30727dda57cbabc54e315e45cedf6c9cff4f87e768729db288ce2e9","title":"Add test for concurrent deregistration race condition","description":"No test verifies behavior when two concurrent deregistration calls race. Add a test that calls deregister_agent twice concurrently to verify idempotency holds under race conditions.","status":"open","priority":3,"issue_type":"task","created_at":"2025-11-24T17:14:17.901397-08:00","updated_at":"2025-11-24T17:14:17.901397-08:00","source_repo":"."}
{"id":"bd-mnap","content_hash":"c15d3c631656fe6d21291f127fc545af93e712b5f3f94cce028513fb743a4fdb","title":"Investigate performance issues in VS Code Copilot (Windows)","description":"Beads unusable in Windows 11 VS Code Copilot chat with Sonnet 4.5.\nSummary event happens every 3-4 turns, taking 3 minutes.\nCopilot summarizes after ~125k tokens despite model supporting 1M.\nLarge context size of beads might be triggering aggressive summarization.\nNeed workaround or optimization for context size.\n","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-20T18:56:30.124918-05:00","updated_at":"2025-11-20T18:56:30.124918-05:00","source_repo":"."}
{"id":"bd-mq1b","content_hash":"16369efa3bc8d99c33ff5f11066ea39f9a3c152b1cfbd98701136382c2fbc514","title":"Add SearchIssues to Transaction for read-your-writes","description":"Add search capability within transaction for read-your-writes consistency.\n\n## Tasks\n1. Add to Transaction interface:\n - SearchIssues(ctx, query, filter) ([]*types.Issue, error)\n\n2. Implement on sqliteTxStorage:\n - Reuse existing search logic with conn\n - Ensure reads see uncommitted writes within same transaction\n\n## Acceptance Criteria\n- [ ] SearchIssues returns issues created in same transaction\n- [ ] Filter logic works correctly within transaction\n- [ ] Test: create issue then search for it in same transaction","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-24T11:37:21.412233-08:00","updated_at":"2025-11-24T20:19:25.597788-08:00","source_repo":".","dependencies":[{"issue_id":"bd-mq1b","depends_on_id":"bd-6pul","type":"parent-child","created_at":"2025-11-24T11:37:21.413628-08:00","created_by":"daemon"}]}
{"id":"bd-mq1b","content_hash":"c0184edc489d27b99055fc1b91694b6fae8c512cb09055834343e2046a21f7ba","title":"Add SearchIssues to Transaction for read-your-writes","description":"Add search capability within transaction for read-your-writes consistency.\n\n## Tasks\n1. Add to Transaction interface:\n - SearchIssues(ctx, query, filter) ([]*types.Issue, error)\n\n2. Implement on sqliteTxStorage:\n - Reuse existing search logic with conn\n - Ensure reads see uncommitted writes within same transaction\n\n## Acceptance Criteria\n- [ ] SearchIssues returns issues created in same transaction\n- [ ] Filter logic works correctly within transaction\n- [ ] Test: create issue then search for it in same transaction","status":"closed","priority":2,"issue_type":"task","assignee":"claude","created_at":"2025-11-24T11:37:21.412233-08:00","updated_at":"2025-11-24T20:26:12.595322-08:00","closed_at":"2025-11-24T20:26:12.595322-08:00","source_repo":".","dependencies":[{"issue_id":"bd-mq1b","depends_on_id":"bd-6pul","type":"parent-child","created_at":"2025-11-24T11:37:21.413628-08:00","created_by":"daemon"}]}
{"id":"bd-n4gu","content_hash":"0d06c2ec9303bf472c239b1a95e1b857bfb08f630e8016920aa49fff63716947","title":"Build slot cleanup uses synchronous file I/O in async function","description":"In app.py:2996-3020, deregister_agent uses synchronous file I/O (iterdir, glob, read_text, write_text) in an async function. This can cause latency spikes with many build slot files. Recommendation: Use anyio or aiofiles for async file operations, or run in thread pool.","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-24T17:14:02.06723-08:00","updated_at":"2025-11-24T17:14:02.06723-08:00","source_repo":"."}
{"id":"bd-n4td","content_hash":"1a5222748ad9badd0cdfdcfbe831f96c532deeb41909f9729e111dcbaa119d0d","title":"Add warning when staleness check errors","description":"## Problem\n\nWhen ensureDatabaseFresh() calls CheckStaleness() and it errors (corrupted metadata, permission issues, etc.), we silently proceed with potentially stale data.\n\n**Location:** cmd/bd/staleness.go:27-32\n\n**Scenarios:**\n- Corrupted metadata table\n- Database locked by another process \n- Permission issues reading JSONL file\n- Invalid last_import_time format in DB\n\n## Current Code\n\n```go\nisStale, err := autoimport.CheckStaleness(ctx, store, dbPath)\nif err \\!= nil {\n // If we can't determine staleness, allow operation to proceed\n // (better to show potentially stale data than block user)\n return nil\n}\n```\n\n## Fix\n\n```go\nisStale, err := autoimport.CheckStaleness(ctx, store, dbPath)\nif err \\!= nil {\n fmt.Fprintf(os.Stderr, \"Warning: Could not verify database freshness: %v\\n\", err)\n fmt.Fprintf(os.Stderr, \"Proceeding anyway. Data may be stale.\\n\\n\")\n return nil\n}\n```\n\n## Impact\nMedium - users should know when staleness check fails\n\n## Effort\nEasy - 5 minutes","status":"open","priority":2,"issue_type":"bug","created_at":"2025-11-20T20:16:34.889997-05:00","updated_at":"2025-11-20T20:16:34.889997-05:00","source_repo":".","dependencies":[{"issue_id":"bd-n4td","depends_on_id":"bd-2q6d","type":"blocks","created_at":"2025-11-20T20:18:20.154723-05:00","created_by":"stevey"}]}
{"id":"bd-nq41","content_hash":"33f9cfe6a0ef5200dcd5016317b43b1568ff9dc7303537d956bdab02029f6c63","title":"Fix Homebrew warning about Ruby file location","description":"Homebrew warning: Found Ruby file outside steveyegge/beads tap formula directory.\nWarning points to: /opt/homebrew/Library/Taps/steveyegge/homebrew-beads/bd.rb\nIt should likely be inside a Formula/ directory or similar structure expected by Homebrew taps.\n","status":"open","priority":2,"issue_type":"chore","created_at":"2025-11-20T18:56:21.226579-05:00","updated_at":"2025-11-20T18:56:21.226579-05:00","source_repo":"."}

View File

@@ -884,3 +884,266 @@ func (t *sqliteTxStorage) AddComment(ctx context.Context, issueID, actor, commen
return nil
}
// SearchIssues finds issues matching query and filters within the transaction.
// This enables read-your-writes semantics for searching within a transaction.
func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
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.TitleSearch != "" {
whereClauses = append(whereClauses, "title LIKE ?")
pattern := "%" + filter.TitleSearch + "%"
args = append(args, pattern)
}
// Pattern matching
if filter.TitleContains != "" {
whereClauses = append(whereClauses, "title LIKE ?")
args = append(args, "%"+filter.TitleContains+"%")
}
if filter.DescriptionContains != "" {
whereClauses = append(whereClauses, "description LIKE ?")
args = append(args, "%"+filter.DescriptionContains+"%")
}
if filter.NotesContains != "" {
whereClauses = append(whereClauses, "notes LIKE ?")
args = append(args, "%"+filter.NotesContains+"%")
}
if filter.Status != nil {
whereClauses = append(whereClauses, "status = ?")
args = append(args, *filter.Status)
}
if filter.Priority != nil {
whereClauses = append(whereClauses, "priority = ?")
args = append(args, *filter.Priority)
}
// Priority ranges
if filter.PriorityMin != nil {
whereClauses = append(whereClauses, "priority >= ?")
args = append(args, *filter.PriorityMin)
}
if filter.PriorityMax != nil {
whereClauses = append(whereClauses, "priority <= ?")
args = append(args, *filter.PriorityMax)
}
if filter.IssueType != nil {
whereClauses = append(whereClauses, "issue_type = ?")
args = append(args, *filter.IssueType)
}
if filter.Assignee != nil {
whereClauses = append(whereClauses, "assignee = ?")
args = append(args, *filter.Assignee)
}
// Date ranges
if filter.CreatedAfter != nil {
whereClauses = append(whereClauses, "created_at > ?")
args = append(args, filter.CreatedAfter.Format(time.RFC3339))
}
if filter.CreatedBefore != nil {
whereClauses = append(whereClauses, "created_at < ?")
args = append(args, filter.CreatedBefore.Format(time.RFC3339))
}
if filter.UpdatedAfter != nil {
whereClauses = append(whereClauses, "updated_at > ?")
args = append(args, filter.UpdatedAfter.Format(time.RFC3339))
}
if filter.UpdatedBefore != nil {
whereClauses = append(whereClauses, "updated_at < ?")
args = append(args, filter.UpdatedBefore.Format(time.RFC3339))
}
if filter.ClosedAfter != nil {
whereClauses = append(whereClauses, "closed_at > ?")
args = append(args, filter.ClosedAfter.Format(time.RFC3339))
}
if filter.ClosedBefore != nil {
whereClauses = append(whereClauses, "closed_at < ?")
args = append(args, filter.ClosedBefore.Format(time.RFC3339))
}
// Empty/null checks
if filter.EmptyDescription {
whereClauses = append(whereClauses, "(description IS NULL OR description = '')")
}
if filter.NoAssignee {
whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')")
}
if filter.NoLabels {
whereClauses = append(whereClauses, "id NOT IN (SELECT DISTINCT issue_id FROM labels)")
}
// Label filtering: issue must have ALL specified labels
if len(filter.Labels) > 0 {
for _, label := range filter.Labels {
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM labels WHERE label = ?)")
args = append(args, label)
}
}
// Label filtering (OR): issue must have AT LEAST ONE of these labels
if len(filter.LabelsAny) > 0 {
placeholders := make([]string, len(filter.LabelsAny))
for i, label := range filter.LabelsAny {
placeholders[i] = "?"
args = append(args, label)
}
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM labels WHERE label IN (%s))", strings.Join(placeholders, ", ")))
}
// ID filtering: match specific issue IDs
if len(filter.IDs) > 0 {
placeholders := make([]string, len(filter.IDs))
for i, id := range filter.IDs {
placeholders[i] = "?"
args = append(args, id)
}
whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", ")))
}
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
}
limitSQL := ""
if filter.Limit > 0 {
limitSQL = " LIMIT ?"
args = append(args, filter.Limit)
}
// #nosec G201 - safe SQL with controlled formatting
querySQL := fmt.Sprintf(`
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo
FROM issues
%s
ORDER BY priority ASC, created_at DESC
%s
`, whereSQL, limitSQL)
rows, err := t.conn.QueryContext(ctx, querySQL, args...)
if err != nil {
return nil, fmt.Errorf("failed to search issues: %w", err)
}
defer func() { _ = rows.Close() }()
return t.scanIssues(ctx, rows)
}
// scanIssues scans issue rows and fetches labels using the transaction connection.
func (t *sqliteTxStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*types.Issue, error) {
var issues []*types.Issue
var issueIDs []string
// First pass: scan all issues
for rows.Next() {
var issue types.Issue
var contentHash sql.NullString
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var externalRef sql.NullString
var sourceRepo sql.NullString
err := rows.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.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
)
if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", 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 externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
if sourceRepo.Valid {
issue.SourceRepo = sourceRepo.String
}
issues = append(issues, &issue)
issueIDs = append(issueIDs, issue.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
// Second pass: batch-load labels for all issues using transaction connection
labelsMap, err := t.getLabelsForIssues(ctx, issueIDs)
if err != nil {
return nil, fmt.Errorf("failed to get labels: %w", err)
}
// Attach labels to issues
for _, issue := range issues {
issue.Labels = labelsMap[issue.ID]
}
return issues, nil
}
// getLabelsForIssues retrieves labels for multiple issues using the transaction connection.
func (t *sqliteTxStorage) getLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) {
result := make(map[string][]string)
if len(issueIDs) == 0 {
return result, nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(issueIDs))
args := make([]interface{}, len(issueIDs))
for i, id := range issueIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`
SELECT issue_id, label FROM labels
WHERE issue_id IN (%s)
ORDER BY issue_id, label
`, strings.Join(placeholders, ", "))
rows, err := t.conn.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to get labels: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var issueID, label string
if err := rows.Scan(&issueID, &label); err != nil {
return nil, err
}
result[issueID] = append(result[issueID], label)
}
return result, rows.Err()
}

View File

@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
@@ -1298,3 +1299,367 @@ type testError struct {
func (e *testError) Error() string {
return e.msg
}
// TestTransactionSearchIssuesBasic tests basic search within a transaction.
func TestTransactionSearchIssuesBasic(t *testing.T) {
ctx := context.Background()
store, cleanup := setupTestDB(t)
defer cleanup()
// Create some issues first
closedAt := time.Now()
issues := []*types.Issue{
{Title: "Alpha task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
{Title: "Beta task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask},
{Title: "Gamma feature", Status: types.StatusClosed, Priority: 3, IssueType: types.TypeFeature, ClosedAt: &closedAt},
}
for _, issue := range issues {
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
}
// Search within transaction
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Search by query
results, err := tx.SearchIssues(ctx, "task", types.IssueFilter{})
if err != nil {
return err
}
if len(results) != 2 {
t.Errorf("expected 2 issues matching 'task', got %d", len(results))
}
// Search by status
closedStatus := types.StatusClosed
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{Status: &closedStatus})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("expected 1 closed issue, got %d", len(results))
}
return nil
})
if err != nil {
t.Fatalf("RunInTransaction failed: %v", err)
}
}
// TestTransactionSearchIssuesReadYourWrites is the KEY test: create an issue and search
// for it within the same transaction (read-your-writes consistency).
func TestTransactionSearchIssuesReadYourWrites(t *testing.T) {
ctx := context.Background()
store, cleanup := setupTestDB(t)
defer cleanup()
// Create an existing issue outside the transaction
existingIssue := &types.Issue{
Title: "Existing Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, existingIssue, "test-actor"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
var newIssueID string
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Create a new issue within the transaction
newIssue := &types.Issue{
Title: "Unique Searchable Title XYZ123",
Description: "This has special content ABC789",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeFeature,
}
if err := tx.CreateIssue(ctx, newIssue, "test-actor"); err != nil {
return err
}
newIssueID = newIssue.ID
// CRITICAL: Search for the just-created issue by title
results, err := tx.SearchIssues(ctx, "XYZ123", types.IssueFilter{})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("read-your-writes FAILED: expected 1 issue with title 'XYZ123', got %d", len(results))
return nil
}
if results[0].ID != newIssueID {
t.Errorf("read-your-writes FAILED: found wrong issue, expected %s, got %s", newIssueID, results[0].ID)
}
// Search for it by description
results, err = tx.SearchIssues(ctx, "ABC789", types.IssueFilter{})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("read-your-writes FAILED: expected 1 issue with description 'ABC789', got %d", len(results))
}
// Search by type filter
featureType := types.TypeFeature
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IssueType: &featureType})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("read-your-writes FAILED: expected 1 feature type issue, got %d", len(results))
}
return nil
})
if err != nil {
t.Fatalf("RunInTransaction failed: %v", err)
}
// Verify the issue was committed
issue, err := store.GetIssue(ctx, newIssueID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if issue == nil {
t.Error("expected issue to be committed, but it wasn't found")
}
}
// TestTransactionSearchIssuesWithFilters tests various filter options within transaction.
func TestTransactionSearchIssuesWithFilters(t *testing.T) {
ctx := context.Background()
store, cleanup := setupTestDB(t)
defer cleanup()
closedAt := time.Now()
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Create issues with different attributes
issues := []*types.Issue{
{Title: "P1 Bug", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, Assignee: "alice"},
{Title: "P2 Task", Status: types.StatusInProgress, Priority: 2, IssueType: types.TypeTask, Assignee: "bob"},
{Title: "P3 Feature", Status: types.StatusClosed, Priority: 3, IssueType: types.TypeFeature, Assignee: "alice", ClosedAt: &closedAt},
}
for _, issue := range issues {
if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil {
return err
}
}
// Filter by assignee
assignee := "alice"
results, err := tx.SearchIssues(ctx, "", types.IssueFilter{Assignee: &assignee})
if err != nil {
return err
}
if len(results) != 2 {
t.Errorf("expected 2 issues assigned to alice, got %d", len(results))
}
// Filter by priority
priority := 1
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{Priority: &priority})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("expected 1 P1 issue, got %d", len(results))
}
// Filter by type
bugType := types.TypeBug
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IssueType: &bugType})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("expected 1 bug, got %d", len(results))
}
// Combined filter: status + assignee
inProgressStatus := types.StatusInProgress
bobAssignee := "bob"
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{
Status: &inProgressStatus,
Assignee: &bobAssignee,
})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("expected 1 in_progress issue assigned to bob, got %d", len(results))
}
return nil
})
if err != nil {
t.Fatalf("RunInTransaction failed: %v", err)
}
}
// TestTransactionSearchIssuesWithLabels tests label filtering within transaction.
func TestTransactionSearchIssuesWithLabels(t *testing.T) {
ctx := context.Background()
store, cleanup := setupTestDB(t)
defer cleanup()
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Create issues
issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
if err := tx.CreateIssue(ctx, issue1, "test-actor"); err != nil {
return err
}
if err := tx.CreateIssue(ctx, issue2, "test-actor"); err != nil {
return err
}
// Add labels
if err := tx.AddLabel(ctx, issue1.ID, "frontend", "test-actor"); err != nil {
return err
}
if err := tx.AddLabel(ctx, issue1.ID, "urgent", "test-actor"); err != nil {
return err
}
if err := tx.AddLabel(ctx, issue2.ID, "backend", "test-actor"); err != nil {
return err
}
// Search by label (must have ALL labels)
results, err := tx.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{"frontend"}})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("expected 1 issue with 'frontend' label, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue1.ID {
t.Errorf("expected issue1, got %s", results[0].ID)
}
// Verify labels are attached to the issue
if len(results) > 0 && len(results[0].Labels) != 2 {
t.Errorf("expected 2 labels on issue, got %d", len(results[0].Labels))
}
// Search by multiple labels (AND)
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{"frontend", "urgent"}})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("expected 1 issue with both 'frontend' and 'urgent' labels, got %d", len(results))
}
// Search by any label (OR)
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{LabelsAny: []string{"frontend", "backend"}})
if err != nil {
return err
}
if len(results) != 2 {
t.Errorf("expected 2 issues with either 'frontend' or 'backend' label, got %d", len(results))
}
return nil
})
if err != nil {
t.Fatalf("RunInTransaction failed: %v", err)
}
}
// TestTransactionSearchIssuesRollback verifies uncommitted issues aren't visible outside transaction.
func TestTransactionSearchIssuesRollback(t *testing.T) {
ctx := context.Background()
store, cleanup := setupTestDB(t)
defer cleanup()
// Try to create an issue but rollback (by returning an error)
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
issue := &types.Issue{
Title: "RollbackTestIssue999",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil {
return err
}
// Verify it's visible within the transaction
results, err := tx.SearchIssues(ctx, "RollbackTestIssue999", types.IssueFilter{})
if err != nil {
return err
}
if len(results) != 1 {
t.Errorf("expected issue to be visible within transaction, got %d results", len(results))
}
// Return error to trigger rollback
return &testError{msg: "intentional rollback"}
})
if err == nil {
t.Fatal("expected error from rollback, got nil")
}
// Verify the issue is NOT visible outside the transaction (it was rolled back)
results, err := store.SearchIssues(ctx, "RollbackTestIssue999", types.IssueFilter{})
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 issues after rollback, got %d - rollback didn't work!", len(results))
}
}
// TestTransactionSearchIssuesLimit tests the limit filter within transaction.
func TestTransactionSearchIssuesLimit(t *testing.T) {
ctx := context.Background()
store, cleanup := setupTestDB(t)
defer cleanup()
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Create several issues
for i := 0; i < 10; i++ {
issue := &types.Issue{
Title: "Limit Test Issue",
Status: types.StatusOpen,
Priority: i % 5,
IssueType: types.TypeTask,
}
if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil {
return err
}
}
// Search with limit
results, err := tx.SearchIssues(ctx, "", types.IssueFilter{Limit: 3})
if err != nil {
return err
}
if len(results) != 3 {
t.Errorf("expected 3 issues with limit, got %d", len(results))
}
// Search without limit should return all
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
return err
}
if len(results) != 10 {
t.Errorf("expected 10 issues without limit, got %d", len(results))
}
return nil
})
if err != nil {
t.Fatalf("RunInTransaction failed: %v", err)
}
}

View File

@@ -52,7 +52,8 @@ type Transaction interface {
UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
CloseIssue(ctx context.Context, id string, reason string, actor string) error
DeleteIssue(ctx context.Context, id string) error
GetIssue(ctx context.Context, id string) (*types.Issue, error) // For read-your-writes within transaction
GetIssue(ctx context.Context, id string) (*types.Issue, error) // For read-your-writes within transaction
SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) // For read-your-writes within transaction
// Dependency operations
AddDependency(ctx context.Context, dep *types.Dependency, actor string) error