Phase 4: Remove deprecated edge fields from Issue struct (Decision 004)

This is the final phase of the Edge Schema Consolidation. It removes
the deprecated edge fields (RepliesTo, RelatesTo, DuplicateOf, SupersededBy)
from the Issue struct and all related code.

Changes:
- Remove edge fields from types.Issue struct
- Remove edge field scanning from queries.go and transaction.go
- Update graph_links_test.go to use dependency API exclusively
- Update relate.go to use AddDependency/RemoveDependency
- Update show.go with helper functions for thread traversal via deps
- Update mail_test.go to verify thread links via dependencies
- Add migration 022 to drop columns from issues table
- Fix cycle detection to allow bidirectional relates-to links
- Fix migration 022 to disable foreign keys before table recreation

All edge relationships now use the dependencies table exclusively.
The old Issue fields are fully removed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-18 02:48:13 -08:00
parent 3ec517cc1b
commit 7c8b69f5b3
18 changed files with 768 additions and 607 deletions

View File

@@ -70,60 +70,68 @@ func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency
return s.withTx(ctx, func(tx *sql.Tx) error {
// Cycle Detection and Prevention
//
// We prevent cycles across ALL dependency types (blocks, related, parent-child, discovered-from)
// to maintain a directed acyclic graph (DAG). This is critical for:
//
// 1. Ready Work Calculation: Cycles can hide issues from the ready list by making them
// appear blocked when they're actually part of a circular dependency.
//
// 2. Dependency Traversal: Operations like dep tree and blocking propagation rely on
// DAG structure. Cycles would require special handling and could cause confusion.
//
// 3. Semantic Clarity: Circular dependencies are conceptually problematic - if A depends
// on B and B depends on A (directly or through other issues), which should be done first?
//
// Implementation: We use a recursive CTE to traverse from DependsOnID to see if we can
// reach IssueID. If yes, adding "IssueID depends on DependsOnID" would complete a cycle.
// We check ALL dependency types because cross-type cycles (e.g., A blocks B, B parent-child A)
// are just as problematic as single-type cycles.
//
// The traversal is depth-limited to maxDependencyDepth (100) to prevent infinite loops
// and excessive query cost. We check before inserting to avoid unnecessary write on failure.
var cycleExists bool
err = tx.QueryRowContext(ctx, `
WITH RECURSIVE paths AS (
SELECT
issue_id,
depends_on_id,
1 as depth
FROM dependencies
WHERE issue_id = ?
//
// We prevent cycles across most dependency types to maintain a directed acyclic graph (DAG).
// This is critical for:
//
// 1. Ready Work Calculation: Cycles can hide issues from the ready list by making them
// appear blocked when they're actually part of a circular dependency.
//
// 2. Dependency Traversal: Operations like dep tree and blocking propagation rely on
// DAG structure. Cycles would require special handling and could cause confusion.
//
// 3. Semantic Clarity: Circular dependencies are conceptually problematic - if A depends
// on B and B depends on A (directly or through other issues), which should be done first?
//
// EXCEPTION: relates-to links are inherently bidirectional ("see also" relationships).
// When A relates-to B, we also create B relates-to A. This is not a cycle in the
// problematic sense - it's a symmetric relationship that doesn't affect work ordering.
//
// Implementation: We use a recursive CTE to traverse from DependsOnID to see if we can
// reach IssueID. If yes, adding "IssueID depends on DependsOnID" would complete a cycle.
// We check ALL dependency types because cross-type cycles (e.g., A blocks B, B parent-child A)
// are just as problematic as single-type cycles.
//
// The traversal is depth-limited to maxDependencyDepth (100) to prevent infinite loops
// and excessive query cost. We check before inserting to avoid unnecessary write on failure.
UNION ALL
// Skip cycle detection for relates-to (inherently bidirectional)
if dep.Type != types.DepRelatesTo {
var cycleExists bool
err = tx.QueryRowContext(ctx, `
WITH RECURSIVE paths AS (
SELECT
issue_id,
depends_on_id,
1 as depth
FROM dependencies
WHERE issue_id = ?
SELECT
d.issue_id,
d.depends_on_id,
p.depth + 1
FROM dependencies d
JOIN paths p ON d.issue_id = p.depends_on_id
WHERE p.depth < ?
)
SELECT EXISTS(
SELECT 1 FROM paths
WHERE depends_on_id = ?
)
`, dep.DependsOnID, maxDependencyDepth, dep.IssueID).Scan(&cycleExists)
UNION ALL
if err != nil {
return fmt.Errorf("failed to check for cycles: %w", err)
}
SELECT
d.issue_id,
d.depends_on_id,
p.depth + 1
FROM dependencies d
JOIN paths p ON d.issue_id = p.depends_on_id
WHERE p.depth < ?
)
SELECT EXISTS(
SELECT 1 FROM paths
WHERE depends_on_id = ?
)
`, dep.DependsOnID, maxDependencyDepth, dep.IssueID).Scan(&cycleExists)
if cycleExists {
return fmt.Errorf("cannot add dependency: would create a cycle (%s → %s → ... → %s)",
dep.IssueID, dep.DependsOnID, dep.IssueID)
}
if err != nil {
return fmt.Errorf("failed to check for cycles: %w", err)
}
if cycleExists {
return fmt.Errorf("cannot add dependency: would create a cycle (%s → %s → ... → %s)",
dep.IssueID, dep.DependsOnID, dep.IssueID)
}
}
// Insert dependency (including metadata and thread_id for edge consolidation - Decision 004)
_, err = tx.ExecContext(ctx, `
@@ -225,7 +233,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by,
i.sender, i.ephemeral,
d.type
FROM issues i
JOIN dependencies d ON i.id = d.depends_on_id
@@ -247,7 +255,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by,
i.sender, i.ephemeral,
d.type
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
@@ -706,10 +714,6 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
// Messaging fields (bd-kwro)
var sender sql.NullString
var ephemeral sql.NullInt64
var repliesTo sql.NullString
var relatesTo sql.NullString
var duplicateOf sql.NullString
var supersededBy sql.NullString
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
@@ -717,7 +721,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy,
&sender, &ephemeral,
)
if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", err)
@@ -762,18 +766,6 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Ephemeral = true
}
if repliesTo.Valid {
issue.RepliesTo = repliesTo.String
}
if relatesTo.Valid && relatesTo.String != "" {
issue.RelatesTo = parseJSONStringArray(relatesTo.String)
}
if duplicateOf.Valid {
issue.DuplicateOf = duplicateOf.String
}
if supersededBy.Valid {
issue.SupersededBy = supersededBy.String
}
issues = append(issues, &issue)
issueIDs = append(issueIDs, issue.ID)
@@ -813,10 +805,6 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
// Messaging fields (bd-kwro)
var sender sql.NullString
var ephemeral sql.NullInt64
var repliesTo sql.NullString
var relatesTo sql.NullString
var duplicateOf sql.NullString
var supersededBy sql.NullString
var depType types.DependencyType
err := rows.Scan(
@@ -825,7 +813,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy,
&sender, &ephemeral,
&depType,
)
if err != nil {
@@ -868,18 +856,6 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Ephemeral = true
}
if repliesTo.Valid {
issue.RepliesTo = repliesTo.String
}
if relatesTo.Valid && relatesTo.String != "" {
issue.RelatesTo = parseJSONStringArray(relatesTo.String)
}
if duplicateOf.Valid {
issue.DuplicateOf = duplicateOf.String
}
if supersededBy.Valid {
issue.SupersededBy = supersededBy.String
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)

View File

@@ -2,13 +2,14 @@ package sqlite
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
// TestRelatesTo verifies relates-to dependencies work via the dependency API.
// Per Decision 004, relates-to links are now stored in the dependencies table.
func TestRelatesTo(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
@@ -39,36 +40,51 @@ func TestRelatesTo(t *testing.T) {
t.Fatalf("Failed to create issue2: %v", err)
}
// Add relates_to link (bidirectional)
relatesTo1, _ := json.Marshal([]string{issue2.ID})
if err := store.UpdateIssue(ctx, issue1.ID, map[string]interface{}{
"relates_to": string(relatesTo1),
}, "test"); err != nil {
t.Fatalf("Failed to update issue1 relates_to: %v", err)
// Add relates-to dependency (bidirectional)
dep1 := &types.Dependency{
IssueID: issue1.ID,
DependsOnID: issue2.ID,
Type: types.DepRelatesTo,
}
if err := store.AddDependency(ctx, dep1, "test"); err != nil {
t.Fatalf("Failed to add relates-to dep1: %v", err)
}
dep2 := &types.Dependency{
IssueID: issue2.ID,
DependsOnID: issue1.ID,
Type: types.DepRelatesTo,
}
if err := store.AddDependency(ctx, dep2, "test"); err != nil {
t.Fatalf("Failed to add relates-to dep2: %v", err)
}
relatesTo2, _ := json.Marshal([]string{issue1.ID})
if err := store.UpdateIssue(ctx, issue2.ID, map[string]interface{}{
"relates_to": string(relatesTo2),
}, "test"); err != nil {
t.Fatalf("Failed to update issue2 relates_to: %v", err)
}
// Verify links
updated1, err := store.GetIssue(ctx, issue1.ID)
// Verify links via GetDependenciesWithMetadata
deps1, err := store.GetDependenciesWithMetadata(ctx, issue1.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
if len(updated1.RelatesTo) != 1 || updated1.RelatesTo[0] != issue2.ID {
t.Errorf("issue1.RelatesTo = %v, want [%s]", updated1.RelatesTo, issue2.ID)
found1 := false
for _, d := range deps1 {
if d.ID == issue2.ID && d.DependencyType == types.DepRelatesTo {
found1 = true
}
}
if !found1 {
t.Errorf("issue1 should have relates-to link to issue2")
}
updated2, err := store.GetIssue(ctx, issue2.ID)
deps2, err := store.GetDependenciesWithMetadata(ctx, issue2.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
if len(updated2.RelatesTo) != 1 || updated2.RelatesTo[0] != issue1.ID {
t.Errorf("issue2.RelatesTo = %v, want [%s]", updated2.RelatesTo, issue1.ID)
found2 := false
for _, d := range deps2 {
if d.ID == issue1.ID && d.DependencyType == types.DepRelatesTo {
found2 = true
}
}
if !found2 {
t.Errorf("issue2 should have relates-to link to issue1")
}
}
@@ -94,23 +110,34 @@ func TestRelatesTo_MultipleLinks(t *testing.T) {
}
// Link issue0 to both issue1 and issue2
relatesTo, _ := json.Marshal([]string{issues[1].ID, issues[2].ID})
if err := store.UpdateIssue(ctx, issues[0].ID, map[string]interface{}{
"relates_to": string(relatesTo),
}, "test"); err != nil {
t.Fatalf("Failed to update relates_to: %v", err)
for _, targetIssue := range []*types.Issue{issues[1], issues[2]} {
dep := &types.Dependency{
IssueID: issues[0].ID,
DependsOnID: targetIssue.ID,
Type: types.DepRelatesTo,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add relates-to: %v", err)
}
}
// Verify
updated, err := store.GetIssue(ctx, issues[0].ID)
deps, err := store.GetDependenciesWithMetadata(ctx, issues[0].ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
if len(updated.RelatesTo) != 2 {
t.Errorf("RelatesTo has %d links, want 2", len(updated.RelatesTo))
relatesCount := 0
for _, d := range deps {
if d.DependencyType == types.DepRelatesTo {
relatesCount++
}
}
if relatesCount != 2 {
t.Errorf("RelatesTo has %d links, want 2", relatesCount)
}
}
// TestDuplicateOf verifies duplicates dependencies work via the dependency API.
func TestDuplicateOf(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
@@ -141,27 +168,47 @@ func TestDuplicateOf(t *testing.T) {
t.Fatalf("Failed to create duplicate: %v", err)
}
// Mark as duplicate and close
if err := store.UpdateIssue(ctx, duplicate.ID, map[string]interface{}{
"duplicate_of": canonical.ID,
"status": string(types.StatusClosed),
}, "test"); err != nil {
t.Fatalf("Failed to mark as duplicate: %v", err)
// Add duplicates dependency
dep := &types.Dependency{
IssueID: duplicate.ID,
DependsOnID: canonical.ID,
Type: types.DepDuplicates,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add duplicates dep: %v", err)
}
// Verify
// Close the duplicate
if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test"); err != nil {
t.Fatalf("Failed to close duplicate: %v", err)
}
// Verify dependency
deps, err := store.GetDependenciesWithMetadata(ctx, duplicate.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
found := false
for _, d := range deps {
if d.ID == canonical.ID && d.DependencyType == types.DepDuplicates {
found = true
}
}
if !found {
t.Errorf("duplicate should have duplicates link to canonical")
}
// Verify closed status
updated, err := store.GetIssue(ctx, duplicate.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if updated.DuplicateOf != canonical.ID {
t.Errorf("DuplicateOf = %q, want %q", updated.DuplicateOf, canonical.ID)
}
if updated.Status != types.StatusClosed {
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
}
}
// TestSupersededBy verifies supersedes dependencies work via the dependency API.
func TestSupersededBy(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
@@ -192,27 +239,48 @@ func TestSupersededBy(t *testing.T) {
t.Fatalf("Failed to create new version: %v", err)
}
// Mark old as superseded
if err := store.UpdateIssue(ctx, oldVersion.ID, map[string]interface{}{
"superseded_by": newVersion.ID,
"status": string(types.StatusClosed),
}, "test"); err != nil {
t.Fatalf("Failed to mark as superseded: %v", err)
// Add supersedes dependency (newVersion supersedes oldVersion)
// Stored as: oldVersion depends on newVersion with type supersedes
dep := &types.Dependency{
IssueID: oldVersion.ID,
DependsOnID: newVersion.ID,
Type: types.DepSupersedes,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add supersedes dep: %v", err)
}
// Verify
// Close old version
if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test"); err != nil {
t.Fatalf("Failed to close old version: %v", err)
}
// Verify dependency
deps, err := store.GetDependenciesWithMetadata(ctx, oldVersion.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
found := false
for _, d := range deps {
if d.ID == newVersion.ID && d.DependencyType == types.DepSupersedes {
found = true
}
}
if !found {
t.Errorf("oldVersion should have supersedes link to newVersion")
}
// Verify closed status
updated, err := store.GetIssue(ctx, oldVersion.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if updated.SupersededBy != newVersion.ID {
t.Errorf("SupersededBy = %q, want %q", updated.SupersededBy, newVersion.ID)
}
if updated.Status != types.StatusClosed {
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
}
}
// TestRepliesTo verifies replies-to dependencies work via the dependency API.
func TestRepliesTo(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
@@ -247,20 +315,34 @@ func TestRepliesTo(t *testing.T) {
if err := store.CreateIssue(ctx, original, "test"); err != nil {
t.Fatalf("Failed to create original: %v", err)
}
// Set replies_to before creation
reply.RepliesTo = original.ID
if err := store.CreateIssue(ctx, reply, "test"); err != nil {
t.Fatalf("Failed to create reply: %v", err)
}
// Verify thread link
savedReply, err := store.GetIssue(ctx, reply.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
// Add replies-to dependency
dep := &types.Dependency{
IssueID: reply.ID,
DependsOnID: original.ID,
Type: types.DepRepliesTo,
ThreadID: original.ID, // Thread root is the original message
}
if savedReply.RepliesTo != original.ID {
t.Errorf("RepliesTo = %q, want %q", savedReply.RepliesTo, original.ID)
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add replies-to dep: %v", err)
}
// Verify thread link
deps, err := store.GetDependenciesWithMetadata(ctx, reply.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
found := false
for _, d := range deps {
if d.ID == original.ID && d.DependencyType == types.DepRepliesTo {
found = true
}
}
if !found {
t.Errorf("reply should have replies-to link to original")
}
}
@@ -282,24 +364,42 @@ func TestRepliesTo_Chain(t *testing.T) {
Sender: "user",
Assignee: "inbox",
Ephemeral: true,
RepliesTo: prevID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, messages[i], "test"); err != nil {
t.Fatalf("Failed to create message %d: %v", i, err)
}
// Add replies-to dependency for subsequent messages
if prevID != "" {
dep := &types.Dependency{
IssueID: messages[i].ID,
DependsOnID: prevID,
Type: types.DepRepliesTo,
ThreadID: messages[0].ID, // Thread root is the first message
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add replies-to dep for message %d: %v", i, err)
}
}
prevID = messages[i].ID
}
// Verify chain
for i := 1; i < len(messages); i++ {
saved, err := store.GetIssue(ctx, messages[i].ID)
// Verify chain by checking dependents
for i := 0; i < len(messages)-1; i++ {
dependents, err := store.GetDependentsWithMetadata(ctx, messages[i].ID)
if err != nil {
t.Fatalf("GetIssue failed for message %d: %v", i, err)
t.Fatalf("GetDependentsWithMetadata failed for message %d: %v", i, err)
}
if saved.RepliesTo != messages[i-1].ID {
t.Errorf("Message %d: RepliesTo = %q, want %q", i, saved.RepliesTo, messages[i-1].ID)
found := false
for _, d := range dependents {
if d.ID == messages[i+1].ID && d.DependencyType == types.DepRepliesTo {
found = true
}
}
if !found {
t.Errorf("Message %d should have reply from message %d", i, i+1)
}
}
}

View File

@@ -15,8 +15,6 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
sourceRepo = "." // Default to primary repo
}
// Format relates_to as JSON for storage
relatesTo := formatJSONStringArray(issue.RelatesTo)
ephemeral := 0
if issue.Ephemeral {
ephemeral = 1
@@ -28,8 +26,8 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender, ephemeral
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
@@ -37,7 +35,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy,
issue.Sender, ephemeral,
)
if err != nil {
return fmt.Errorf("failed to insert issue: %w", err)
@@ -53,8 +51,8 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender, ephemeral
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
@@ -67,8 +65,6 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
sourceRepo = "." // Default to primary repo
}
// Format relates_to as JSON for storage
relatesTo := formatJSONStringArray(issue.RelatesTo)
ephemeral := 0
if issue.Ephemeral {
ephemeral = 1
@@ -81,7 +77,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy,
issue.Sender, ephemeral,
)
if err != nil {
return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)

View File

@@ -159,7 +159,7 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by
i.sender, i.ephemeral
FROM issues i
JOIN labels l ON i.id = l.issue_id
WHERE l.label = ?

View File

@@ -38,6 +38,7 @@ var migrationsList = []Migration{
{"messaging_fields", migrations.MigrateMessagingFields},
{"edge_consolidation", migrations.MigrateEdgeConsolidation},
{"migrate_edge_fields", migrations.MigrateEdgeFields},
{"drop_edge_columns", migrations.MigrateDropEdgeColumns},
}
// MigrationInfo contains metadata about a migration for inspection
@@ -83,6 +84,7 @@ func getMigrationDescription(name string) string {
"messaging_fields": "Adds messaging fields (sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by) for inter-agent communication (bd-kwro)",
"edge_consolidation": "Adds metadata and thread_id columns to dependencies table for edge schema consolidation (Decision 004)",
"migrate_edge_fields": "Migrates existing issue fields (replies_to, relates_to, duplicate_of, superseded_by) to dependency edges (Decision 004 Phase 3)",
"drop_edge_columns": "Drops deprecated edge columns (replies_to, relates_to, duplicate_of, superseded_by) from issues table (Decision 004 Phase 4)",
}
if desc, ok := descriptions[name]; ok {

View File

@@ -0,0 +1,236 @@
package migrations
import (
"database/sql"
"fmt"
)
// MigrateDropEdgeColumns removes the deprecated edge fields from the issues table.
// This is Phase 4 of the Edge Schema Consolidation (Decision 004).
//
// Removes columns:
// - replies_to (now: replies-to dependency)
// - relates_to (now: relates-to dependencies)
// - duplicate_of (now: duplicates dependency)
// - superseded_by (now: supersedes dependency)
//
// Prerequisites:
// - Migration 021 (migrate_edge_fields) must have already run to convert data
// - All code must be updated to use the dependencies API
//
// SQLite doesn't support DROP COLUMN directly in older versions, so we
// recreate the table without the deprecated columns.
func MigrateDropEdgeColumns(db *sql.DB) error {
// Check if any of the columns still exist
var hasRepliesTo, hasRelatesTo, hasDuplicateOf, hasSupersededBy bool
checkCol := func(name string) (bool, error) {
var exists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = ?
`, name).Scan(&exists)
return exists, err
}
var err error
hasRepliesTo, err = checkCol("replies_to")
if err != nil {
return fmt.Errorf("failed to check replies_to column: %w", err)
}
hasRelatesTo, err = checkCol("relates_to")
if err != nil {
return fmt.Errorf("failed to check relates_to column: %w", err)
}
hasDuplicateOf, err = checkCol("duplicate_of")
if err != nil {
return fmt.Errorf("failed to check duplicate_of column: %w", err)
}
hasSupersededBy, err = checkCol("superseded_by")
if err != nil {
return fmt.Errorf("failed to check superseded_by column: %w", err)
}
// If none of the columns exist, migration already ran
if !hasRepliesTo && !hasRelatesTo && !hasDuplicateOf && !hasSupersededBy {
return nil
}
// SQLite 3.35.0+ supports DROP COLUMN, but we use table recreation for compatibility
// This is idempotent - we recreate the table without the deprecated columns
// CRITICAL: Disable foreign keys to prevent CASCADE deletes when we drop the issues table
// The dependencies table has FOREIGN KEY (depends_on_id) REFERENCES issues(id) ON DELETE CASCADE
// Without disabling foreign keys, dropping the issues table would delete all dependencies!
_, err = db.Exec(`PRAGMA foreign_keys = OFF`)
if err != nil {
return fmt.Errorf("failed to disable foreign keys: %w", err)
}
// Re-enable foreign keys at the end (deferred to ensure it runs)
defer db.Exec(`PRAGMA foreign_keys = ON`)
// Drop views that depend on the issues table BEFORE starting transaction
// This is necessary because SQLite validates views during table operations
_, err = db.Exec(`DROP VIEW IF EXISTS ready_issues`)
if err != nil {
return fmt.Errorf("failed to drop ready_issues view: %w", err)
}
_, err = db.Exec(`DROP VIEW IF EXISTS blocked_issues`)
if err != nil {
return fmt.Errorf("failed to drop blocked_issues view: %w", err)
}
// Start a transaction for atomicity
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Create new table without the deprecated columns
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS issues_new (
id TEXT PRIMARY KEY,
content_hash TEXT,
title TEXT NOT NULL CHECK(length(title) <= 500),
description TEXT NOT NULL DEFAULT '',
design TEXT NOT NULL DEFAULT '',
acceptance_criteria TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 2 CHECK(priority >= 0 AND priority <= 4),
issue_type TEXT NOT NULL DEFAULT 'task',
assignee TEXT,
estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
external_ref TEXT,
source_repo TEXT DEFAULT '',
compaction_level INTEGER DEFAULT 0,
compacted_at DATETIME,
compacted_at_commit TEXT,
original_size INTEGER,
deleted_at DATETIME,
deleted_by TEXT DEFAULT '',
delete_reason TEXT DEFAULT '',
original_type TEXT DEFAULT '',
sender TEXT DEFAULT '',
ephemeral INTEGER DEFAULT 0,
close_reason TEXT DEFAULT '',
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
)
`)
if err != nil {
return fmt.Errorf("failed to create new issues table: %w", err)
}
// Copy data from old table to new table (excluding deprecated columns)
_, err = tx.Exec(`
INSERT INTO issues_new (
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, compaction_level,
compacted_at, compacted_at_commit, original_size, deleted_at,
deleted_by, delete_reason, original_type, sender, ephemeral, close_reason
)
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, COALESCE(source_repo, ''), compaction_level,
compacted_at, compacted_at_commit, original_size, deleted_at,
deleted_by, delete_reason, original_type, sender, ephemeral,
COALESCE(close_reason, '')
FROM issues
`)
if err != nil {
return fmt.Errorf("failed to copy issues data: %w", err)
}
// Drop old table
_, err = tx.Exec(`DROP TABLE issues`)
if err != nil {
return fmt.Errorf("failed to drop old issues table: %w", err)
}
// Rename new table to issues
_, err = tx.Exec(`ALTER TABLE issues_new RENAME TO issues`)
if err != nil {
return fmt.Errorf("failed to rename new issues table: %w", err)
}
// Recreate indexes
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status)`,
`CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority)`,
`CREATE INDEX IF NOT EXISTS idx_issues_assignee ON issues(assignee)`,
`CREATE INDEX IF NOT EXISTS idx_issues_created_at ON issues(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_issues_external_ref ON issues(external_ref) WHERE external_ref IS NOT NULL`,
}
for _, idx := range indexes {
_, err = tx.Exec(idx)
if err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
}
// Commit transaction
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration: %w", err)
}
// Recreate views that we dropped earlier (after commit, outside transaction)
// ready_issues view
_, err = db.Exec(`
CREATE VIEW IF NOT EXISTS ready_issues AS
WITH RECURSIVE
blocked_directly AS (
SELECT DISTINCT d.issue_id
FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
),
blocked_transitively AS (
SELECT issue_id, 0 as depth
FROM blocked_directly
UNION ALL
SELECT d.issue_id, bt.depth + 1
FROM blocked_transitively bt
JOIN dependencies d ON d.depends_on_id = bt.issue_id
WHERE d.type = 'parent-child'
AND bt.depth < 50
)
SELECT i.*
FROM issues i
WHERE i.status = 'open'
AND NOT EXISTS (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
)
`)
if err != nil {
return fmt.Errorf("failed to recreate ready_issues view: %w", err)
}
// blocked_issues view
_, err = db.Exec(`
CREATE VIEW IF NOT EXISTS blocked_issues AS
SELECT
i.*,
COUNT(d.depends_on_id) as blocked_by_count
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE i.status IN ('open', 'in_progress', 'blocked')
AND d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
GROUP BY i.id
`)
if err != nil {
return fmt.Errorf("failed to recreate blocked_issues view: %w", err)
}
return nil
}

View File

@@ -257,8 +257,6 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
var existingID string
err := tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE id = ?`, issue.ID).Scan(&existingID)
// Format relates_to as JSON for storage
relatesTo := formatJSONStringArray(issue.RelatesTo)
ephemeral := 0
if issue.Ephemeral {
ephemeral = 1
@@ -272,8 +270,8 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender, ephemeral
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
@@ -281,7 +279,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy,
issue.Sender, ephemeral,
)
if err != nil {
return fmt.Errorf("failed to insert issue: %w", err)
@@ -305,7 +303,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
issue_type = ?, assignee = ?, estimated_minutes = ?,
updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?,
deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?,
sender = ?, ephemeral = ?, replies_to = ?, relates_to = ?, duplicate_of = ?, superseded_by = ?
sender = ?, ephemeral = ?
WHERE id = ?
`,
issue.ContentHash, issue.Title, issue.Description, issue.Design,
@@ -313,7 +311,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
issue.IssueType, issue.Assignee, issue.EstimatedMinutes,
issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy,
issue.Sender, ephemeral,
issue.ID,
)
if err != nil {

View File

@@ -11,149 +11,10 @@ import (
"github.com/steveyegge/beads/internal/types"
)
// createGraphEdgesFromIssueFields creates dependency edges for issue messaging/graph fields.
// This implements Phase 2 of Edge Schema Consolidation (Decision 004) - dual-write mode.
// When issue fields like RepliesTo, RelatesTo, etc. are set, we also create corresponding
// dependency edges. This ensures both the field and the dependency table stay in sync.
//
// For replies-to edges, we also compute and store the thread_id for efficient thread queries:
// - If parent has a thread_id, inherit it
// - If parent has no thread_id, use the parent's issue ID as the thread root
func createGraphEdgesFromIssueFields(ctx context.Context, conn *sql.Conn, issue *types.Issue, actor string) error {
now := time.Now()
// Helper to insert a dependency edge (no cycle check needed for new issues)
insertEdge := func(toID string, edgeType types.DependencyType, metadata, threadID string) error {
_, err := conn.ExecContext(ctx, `
INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, issue.ID, toID, edgeType, now, actor, metadata, threadID)
return err
}
// RepliesTo -> replies-to dependency with thread_id
if issue.RepliesTo != "" {
// Compute thread_id: check if parent has a thread_id, otherwise use parent's ID
var parentThreadID string
err := conn.QueryRowContext(ctx, `
SELECT COALESCE(
(SELECT thread_id FROM dependencies WHERE issue_id = ? AND type = 'replies-to' AND thread_id != '' LIMIT 1),
?
)
`, issue.RepliesTo, issue.RepliesTo).Scan(&parentThreadID)
if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("failed to get parent thread_id: %w", err)
}
if parentThreadID == "" {
parentThreadID = issue.RepliesTo // Use parent's ID as thread root
}
if err := insertEdge(issue.RepliesTo, types.DepRepliesTo, "{}", parentThreadID); err != nil {
return fmt.Errorf("failed to create replies-to edge: %w", err)
}
}
// RelatesTo -> relates-to dependencies
for _, relatedID := range issue.RelatesTo {
if relatedID != "" {
if err := insertEdge(relatedID, types.DepRelatesTo, "{}", ""); err != nil {
return fmt.Errorf("failed to create relates-to edge for %s: %w", relatedID, err)
}
}
}
// DuplicateOf -> duplicates dependency
if issue.DuplicateOf != "" {
if err := insertEdge(issue.DuplicateOf, types.DepDuplicates, "{}", ""); err != nil {
return fmt.Errorf("failed to create duplicates edge: %w", err)
}
}
// SupersededBy -> supersedes dependency (reversed: this issue is superseded BY another)
// So we create: this issue depends on the superseding issue
if issue.SupersededBy != "" {
if err := insertEdge(issue.SupersededBy, types.DepSupersedes, "{}", ""); err != nil {
return fmt.Errorf("failed to create supersedes edge: %w", err)
}
}
return nil
}
// createGraphEdgesFromUpdates creates dependency edges when graph fields are updated.
// This implements Phase 2 of Edge Schema Consolidation (Decision 004) for UpdateIssue.
func createGraphEdgesFromUpdates(ctx context.Context, tx *sql.Tx, issueID string, updates map[string]interface{}, actor string) error {
now := time.Now()
// Helper to insert a dependency edge
insertEdge := func(toID string, edgeType types.DependencyType, metadata, threadID string) error {
_, err := tx.ExecContext(ctx, `
INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, issueID, toID, edgeType, now, actor, metadata, threadID)
return err
}
// RepliesTo -> replies-to dependency with thread_id
if repliesTo, ok := updates["replies_to"]; ok {
if replyID, isString := repliesTo.(string); isString && replyID != "" {
// Compute thread_id
var parentThreadID string
err := tx.QueryRowContext(ctx, `
SELECT COALESCE(
(SELECT thread_id FROM dependencies WHERE issue_id = ? AND type = 'replies-to' AND thread_id != '' LIMIT 1),
?
)
`, replyID, replyID).Scan(&parentThreadID)
if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("failed to get parent thread_id: %w", err)
}
if parentThreadID == "" {
parentThreadID = replyID
}
if err := insertEdge(replyID, types.DepRepliesTo, "{}", parentThreadID); err != nil {
return fmt.Errorf("failed to create replies-to edge: %w", err)
}
}
}
// RelatesTo -> relates-to dependencies (JSON string array)
if relatesTo, ok := updates["relates_to"]; ok {
if relatesStr, isString := relatesTo.(string); isString && relatesStr != "" && relatesStr != "[]" {
var relatedIDs []string
if err := json.Unmarshal([]byte(relatesStr), &relatedIDs); err == nil {
for _, relatedID := range relatedIDs {
if relatedID != "" {
if err := insertEdge(relatedID, types.DepRelatesTo, "{}", ""); err != nil {
return fmt.Errorf("failed to create relates-to edge for %s: %w", relatedID, err)
}
}
}
}
}
}
// DuplicateOf -> duplicates dependency
if duplicateOf, ok := updates["duplicate_of"]; ok {
if dupID, isString := duplicateOf.(string); isString && dupID != "" {
if err := insertEdge(dupID, types.DepDuplicates, "{}", ""); err != nil {
return fmt.Errorf("failed to create duplicates edge: %w", err)
}
}
}
// SupersededBy -> supersedes dependency
if supersededBy, ok := updates["superseded_by"]; ok {
if supID, isString := supersededBy.(string); isString && supID != "" {
if err := insertEdge(supID, types.DepSupersedes, "{}", ""); err != nil {
return fmt.Errorf("failed to create supersedes edge: %w", err)
}
}
}
return nil
}
// NOTE: createGraphEdgesFromIssueFields and createGraphEdgesFromUpdates removed
// per Decision 004 Phase 4 - Edge Schema Consolidation.
// Graph edges (replies-to, relates-to, duplicates, supersedes) are now managed
// exclusively through the dependency API. Use AddDependency() instead.
// parseNullableTimeString parses a nullable time string from database TEXT columns.
// The ncruces/go-sqlite3 driver only auto-converts TEXT→time.Time for columns declared
@@ -342,10 +203,8 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
return wrapDBError("record creation event", err)
}
// Create graph edges for messaging/graph fields (Phase 2: dual-write - Decision 004)
if err := createGraphEdgesFromIssueFields(ctx, conn, issue, actor); err != nil {
return wrapDBError("create graph edges from issue fields", err)
}
// NOTE: Graph edges (replies-to, relates-to, duplicates, supersedes) are now
// managed via AddDependency() per Decision 004 Phase 4.
// Mark issue as dirty for incremental export
if err := markDirty(ctx, conn, issue.ID); err != nil {
@@ -384,10 +243,6 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
// Messaging fields (bd-kwro)
var sender sql.NullString
var ephemeral sql.NullInt64
var repliesTo sql.NullString
var relatesTo sql.NullString
var duplicateOf sql.NullString
var supersededBy sql.NullString
var contentHash sql.NullString
var compactedAtCommit sql.NullString
@@ -397,7 +252,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
sender, ephemeral
FROM issues
WHERE id = ?
`, id).Scan(
@@ -407,7 +262,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy,
&sender, &ephemeral,
)
if err == sql.ErrNoRows {
@@ -465,18 +320,6 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Ephemeral = true
}
if repliesTo.Valid {
issue.RepliesTo = repliesTo.String
}
if relatesTo.Valid && relatesTo.String != "" {
issue.RelatesTo = parseJSONStringArray(relatesTo.String)
}
if duplicateOf.Valid {
issue.DuplicateOf = duplicateOf.String
}
if supersededBy.Valid {
issue.SupersededBy = supersededBy.String
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
@@ -583,10 +426,6 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
// Messaging fields (bd-kwro)
var sender sql.NullString
var ephemeral sql.NullInt64
var repliesTo sql.NullString
var relatesTo sql.NullString
var duplicateOf sql.NullString
var supersededBy sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
@@ -594,7 +433,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
sender, ephemeral
FROM issues
WHERE external_ref = ?
`, externalRef).Scan(
@@ -604,7 +443,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy,
&sender, &ephemeral,
)
if err == sql.ErrNoRows {
@@ -662,18 +501,6 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Ephemeral = true
}
if repliesTo.Valid {
issue.RepliesTo = repliesTo.String
}
if relatesTo.Valid && relatesTo.String != "" {
issue.RelatesTo = parseJSONStringArray(relatesTo.String)
}
if duplicateOf.Valid {
issue.DuplicateOf = duplicateOf.String
}
if supersededBy.Valid {
issue.SupersededBy = supersededBy.String
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
@@ -700,12 +527,10 @@ var allowedUpdateFields = map[string]bool{
"external_ref": true,
"closed_at": true,
// Messaging fields (bd-kwro)
"sender": true,
"ephemeral": true,
"replies_to": true,
"relates_to": true,
"duplicate_of": true,
"superseded_by": true,
"sender": true,
"ephemeral": true,
// NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
// Use AddDependency() to create graph edges instead
}
// validatePriority validates a priority value
@@ -923,10 +748,7 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[
return fmt.Errorf("failed to record event: %w", err)
}
// Create graph edges for messaging/graph fields (Phase 2: dual-write - Decision 004)
if err := createGraphEdgesFromUpdates(ctx, tx, id, updates, actor); err != nil {
return fmt.Errorf("failed to create graph edges from updates: %w", err)
}
// NOTE: Graph edges now managed via AddDependency() per Decision 004 Phase 4.
// Mark issue as dirty for incremental export
_, err = tx.ExecContext(ctx, `
@@ -1732,7 +1554,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
sender, ephemeral
FROM issues
%s
ORDER BY priority ASC, created_at DESC

View File

@@ -101,7 +101,7 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by
i.sender, i.ephemeral
FROM issues i
WHERE %s
AND NOT EXISTS (
@@ -130,7 +130,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
created_at, updated_at, closed_at, external_ref, source_repo,
compaction_level, compacted_at, compacted_at_commit, original_size, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
sender, ephemeral
FROM issues
WHERE status != 'closed'
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
@@ -179,10 +179,6 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
// Messaging fields (bd-kwro)
var sender sql.NullString
var ephemeral sql.NullInt64
var repliesTo sql.NullString
var relatesTo sql.NullString
var duplicateOf sql.NullString
var supersededBy sql.NullString
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
@@ -191,7 +187,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy,
&sender, &ephemeral,
)
if err != nil {
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
@@ -248,18 +244,6 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Ephemeral = true
}
if repliesTo.Valid {
issue.RepliesTo = repliesTo.String
}
if relatesTo.Valid && relatesTo.String != "" {
issue.RelatesTo = parseJSONStringArray(relatesTo.String)
}
if duplicateOf.Valid {
issue.DuplicateOf = duplicateOf.String
}
if supersededBy.Valid {
issue.SupersededBy = supersededBy.String
}
issues = append(issues, &issue)
}

View File

@@ -30,10 +30,8 @@ CREATE TABLE IF NOT EXISTS issues (
-- Messaging fields (bd-kwro)
sender TEXT DEFAULT '',
ephemeral INTEGER DEFAULT 0,
replies_to TEXT DEFAULT '',
relates_to TEXT DEFAULT '',
duplicate_of TEXT DEFAULT '',
superseded_by TEXT DEFAULT '',
-- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
-- These relationships are now stored in the dependencies table
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
);

View File

@@ -306,7 +306,7 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
sender, ephemeral
FROM issues
WHERE id = ?
`, id)
@@ -1095,7 +1095,7 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by
sender, ephemeral
FROM issues
%s
ORDER BY priority ASC, created_at DESC
@@ -1138,10 +1138,6 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
// Messaging fields (bd-kwro)
var sender sql.NullString
var ephemeral sql.NullInt64
var repliesTo sql.NullString
var relatesTo sql.NullString
var duplicateOf sql.NullString
var supersededBy sql.NullString
err := row.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
@@ -1150,7 +1146,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy,
&sender, &ephemeral,
)
if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", err)
@@ -1204,18 +1200,6 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Ephemeral = true
}
if repliesTo.Valid {
issue.RepliesTo = repliesTo.String
}
if relatesTo.Valid && relatesTo.String != "" {
issue.RelatesTo = parseJSONStringArray(relatesTo.String)
}
if duplicateOf.Valid {
issue.DuplicateOf = duplicateOf.String
}
if supersededBy.Valid {
issue.SupersededBy = supersededBy.String
}
return &issue, nil
}