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

@@ -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