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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
236
internal/storage/sqlite/migrations/022_drop_edge_columns.go
Normal file
236
internal/storage/sqlite/migrations/022_drop_edge_columns.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user