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

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