Fix comments display: move outside dependents block, merge main

This commit is contained in:
Steve Yegge
2025-12-27 00:11:35 -08:00
47 changed files with 597 additions and 285 deletions
+43
View File
@@ -67,6 +67,49 @@ func ExtractPrefix(id string) string {
return id[:idx+1] // Include the hyphen
}
// ExtractProjectFromPath extracts the project name from a route path.
// For "beads/mayor/rig", returns "beads".
// For "gastown/crew/max", returns "gastown".
func ExtractProjectFromPath(path string) string {
// Get the first component of the path
parts := strings.Split(path, "/")
if len(parts) > 0 && parts[0] != "" {
return parts[0]
}
return ""
}
// ResolveToExternalRef attempts to convert a foreign issue ID to an external reference
// using routes.jsonl for prefix-based routing.
//
// If the ID's prefix matches a route, returns "external:<project>:<id>".
// Otherwise, returns empty string (no route found).
//
// Example: If routes.jsonl has {"prefix": "bd-", "path": "beads/mayor/rig"}
// then ResolveToExternalRef("bd-abc", beadsDir) returns "external:beads:bd-abc"
func ResolveToExternalRef(id, beadsDir string) string {
routes, err := LoadRoutes(beadsDir)
if err != nil || len(routes) == 0 {
return ""
}
prefix := ExtractPrefix(id)
if prefix == "" {
return ""
}
for _, route := range routes {
if route.Prefix == prefix {
project := ExtractProjectFromPath(route.Path)
if project != "" {
return fmt.Sprintf("external:%s:%s", project, id)
}
}
}
return ""
}
// ResolveBeadsDirForID determines which beads directory contains the given issue ID.
// It first checks the local beads directory, then consults routes.jsonl for prefix-based routing.
//
+54
View File
@@ -88,3 +88,57 @@ func TestDetectUserRole_Fallback(t *testing.T) {
t.Errorf("DetectUserRole() = %v, want %v (fallback)", role, Contributor)
}
}
func TestExtractPrefix(t *testing.T) {
tests := []struct {
id string
want string
}{
{"gt-abc123", "gt-"},
{"bd-xyz", "bd-"},
{"hq-1234", "hq-"},
{"abc123", ""}, // No hyphen
{"", ""}, // Empty string
{"-abc", "-"}, // Starts with hyphen
}
for _, tt := range tests {
t.Run(tt.id, func(t *testing.T) {
got := ExtractPrefix(tt.id)
if got != tt.want {
t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.id, got, tt.want)
}
})
}
}
func TestExtractProjectFromPath(t *testing.T) {
tests := []struct {
path string
want string
}{
{"beads/mayor/rig", "beads"},
{"gastown/crew/max", "gastown"},
{"simple", "simple"},
{"", ""},
{"/absolute/path", ""}, // Starts with /, first component is empty
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := ExtractProjectFromPath(tt.path)
if got != tt.want {
t.Errorf("ExtractProjectFromPath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestResolveToExternalRef(t *testing.T) {
// This test is limited since it requires a routes.jsonl file
// Just test that it returns empty string for nonexistent directory
got := ResolveToExternalRef("bd-abc", "/nonexistent/path")
if got != "" {
t.Errorf("ResolveToExternalRef() = %q, want empty string for nonexistent path", got)
}
}
+7 -7
View File
@@ -89,11 +89,11 @@ type CreateArgs struct {
WaitsFor string `json:"waits_for,omitempty"` // Spawner issue ID to wait for
WaitsForGate string `json:"waits_for_gate,omitempty"` // Gate type: all-children or any-children
// Messaging fields (bd-kwro)
Sender string `json:"sender,omitempty"` // Who sent this (for messages)
Wisp bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed
Sender string `json:"sender,omitempty"` // Who sent this (for messages)
Ephemeral bool `json:"ephemeral,omitempty"` // If true, not exported to JSONL; bulk-deleted when closed
RepliesTo string `json:"replies_to,omitempty"` // Issue ID for conversation threading
// ID generation (bd-hobo)
IDPrefix string `json:"id_prefix,omitempty"` // Override prefix for ID generation (mol, wisp, etc.)
IDPrefix string `json:"id_prefix,omitempty"` // Override prefix for ID generation (mol, eph, etc.)
CreatedBy string `json:"created_by,omitempty"` // Who created the issue
}
@@ -115,8 +115,8 @@ type UpdateArgs struct {
RemoveLabels []string `json:"remove_labels,omitempty"`
SetLabels []string `json:"set_labels,omitempty"`
// Messaging fields (bd-kwro)
Sender *string `json:"sender,omitempty"` // Who sent this (for messages)
Wisp *bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed
Sender *string `json:"sender,omitempty"` // Who sent this (for messages)
Ephemeral *bool `json:"ephemeral,omitempty"` // If true, not exported to JSONL; bulk-deleted when closed
RepliesTo *string `json:"replies_to,omitempty"` // Issue ID for conversation threading
// Graph link fields (bd-fu83)
RelatesTo *string `json:"relates_to,omitempty"` // JSON array of related issue IDs
@@ -193,8 +193,8 @@ type ListArgs struct {
// Parent filtering (bd-yqhh)
ParentID string `json:"parent_id,omitempty"`
// Wisp filtering (bd-bkul)
Wisp *bool `json:"wisp,omitempty"`
// Ephemeral filtering (bd-bkul)
Ephemeral *bool `json:"ephemeral,omitempty"`
}
// CountArgs represents arguments for the count operation
+7 -7
View File
@@ -81,8 +81,8 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} {
if a.Sender != nil {
u["sender"] = *a.Sender
}
if a.Wisp != nil {
u["wisp"] = *a.Wisp
if a.Ephemeral != nil {
u["ephemeral"] = *a.Ephemeral
}
if a.RepliesTo != nil {
u["replies_to"] = *a.RepliesTo
@@ -176,8 +176,8 @@ func (s *Server) handleCreate(req *Request) Response {
EstimatedMinutes: createArgs.EstimatedMinutes,
Status: types.StatusOpen,
// Messaging fields (bd-kwro)
Sender: createArgs.Sender,
Wisp: createArgs.Wisp,
Sender: createArgs.Sender,
Ephemeral: createArgs.Ephemeral,
// NOTE: RepliesTo now handled via replies-to dependency (Decision 004)
// ID generation (bd-hobo)
IDPrefix: createArgs.IDPrefix,
@@ -844,8 +844,8 @@ func (s *Server) handleList(req *Request) Response {
filter.ParentID = &listArgs.ParentID
}
// Wisp filtering (bd-bkul)
filter.Wisp = listArgs.Wisp
// Ephemeral filtering (bd-bkul)
filter.Ephemeral = listArgs.Ephemeral
// Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000
@@ -1480,7 +1480,7 @@ func (s *Server) handleGateCreate(req *Request) Response {
Status: types.StatusOpen,
Priority: 1, // Gates are typically high priority
Assignee: "deacon/",
Wisp: true, // Gates are wisps (ephemeral)
Ephemeral: true, // Gates are wisps (ephemeral)
AwaitType: args.AwaitType,
AwaitID: args.AwaitID,
Timeout: args.Timeout,
+2 -2
View File
@@ -885,7 +885,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
issue.Sender = sender.String
}
if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true
issue.Ephemeral = true
}
// Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 {
@@ -1006,7 +1006,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
issue.Sender = sender.String
}
if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true
issue.Ephemeral = true
}
// Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 {
+11 -11
View File
@@ -295,7 +295,7 @@ func TestRepliesTo(t *testing.T) {
IssueType: types.TypeMessage,
Sender: "alice",
Assignee: "bob",
Wisp: true,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -307,7 +307,7 @@ func TestRepliesTo(t *testing.T) {
IssueType: types.TypeMessage,
Sender: "bob",
Assignee: "alice",
Wisp: true,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -363,7 +363,7 @@ func TestRepliesTo_Chain(t *testing.T) {
IssueType: types.TypeMessage,
Sender: "user",
Assignee: "inbox",
Wisp: true,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -415,7 +415,7 @@ func TestWispField(t *testing.T) {
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Wisp: true,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -426,7 +426,7 @@ func TestWispField(t *testing.T) {
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
Wisp: false,
Ephemeral: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -443,7 +443,7 @@ func TestWispField(t *testing.T) {
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if !savedWisp.Wisp {
if !savedWisp.Ephemeral {
t.Error("Wisp issue should have Wisp=true")
}
@@ -451,7 +451,7 @@ func TestWispField(t *testing.T) {
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if savedPermanent.Wisp {
if savedPermanent.Ephemeral {
t.Error("Permanent issue should have Wisp=false")
}
}
@@ -468,7 +468,7 @@ func TestWispFilter(t *testing.T) {
Status: types.StatusClosed, // Closed for cleanup test
Priority: 2,
IssueType: types.TypeMessage,
Wisp: true,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -483,7 +483,7 @@ func TestWispFilter(t *testing.T) {
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Wisp: false,
Ephemeral: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -497,7 +497,7 @@ func TestWispFilter(t *testing.T) {
closedStatus := types.StatusClosed
wispFilter := types.IssueFilter{
Status: &closedStatus,
Wisp: &wispTrue,
Ephemeral: &wispTrue,
}
wispIssues, err := store.SearchIssues(ctx, "", wispFilter)
@@ -512,7 +512,7 @@ func TestWispFilter(t *testing.T) {
wispFalse := false
nonWispFilter := types.IssueFilter{
Status: &closedStatus,
Wisp: &wispFalse,
Ephemeral: &wispFalse,
}
permanentIssues, err := store.SearchIssues(ctx, "", nonWispFilter)
+2 -2
View File
@@ -28,7 +28,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
}
wisp := 0
if issue.Wisp {
if issue.Ephemeral {
wisp = 1
}
pinned := 0
@@ -94,7 +94,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
}
wisp := 0
if issue.Wisp {
if issue.Ephemeral {
wisp = 1
}
pinned := 0
@@ -3,6 +3,7 @@ package migrations
import (
"database/sql"
"fmt"
"strings"
)
// MigrateTombstoneClosedAt updates the closed_at constraint to allow tombstones
@@ -22,8 +23,20 @@ func MigrateTombstoneClosedAt(db *sql.DB) error {
// SQLite doesn't support ALTER TABLE to modify CHECK constraints
// We must recreate the table with the new constraint
// Idempotency check: see if the new CHECK constraint already exists
// The new constraint contains "status = 'tombstone'" which the old one didn't
var tableSql string
err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='issues'`).Scan(&tableSql)
if err != nil {
return fmt.Errorf("failed to get issues table schema: %w", err)
}
// If the schema already has the tombstone clause, migration is already applied
if strings.Contains(tableSql, "status = 'tombstone'") || strings.Contains(tableSql, `status = "tombstone"`) {
return nil
}
// Step 0: Drop views that depend on the issues table
_, err := db.Exec(`DROP VIEW IF EXISTS ready_issues`)
_, err = db.Exec(`DROP VIEW IF EXISTS ready_issues`)
if err != nil {
return fmt.Errorf("failed to drop ready_issues view: %w", err)
}
@@ -48,6 +61,7 @@ func MigrateTombstoneClosedAt(db *sql.DB) error {
assignee TEXT,
estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
external_ref TEXT,
@@ -81,20 +95,73 @@ func MigrateTombstoneClosedAt(db *sql.DB) error {
}
// Step 2: Copy data from old table to new table
// List all columns explicitly to handle cases where old table has fewer columns
// Note: created_by is added in migration 029, so don't reference it here
_, err = db.Exec(`
INSERT INTO issues_new
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref,
source_repo, compaction_level, compacted_at, compacted_at_commit, original_size,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, close_reason, pinned, is_template,
await_type, await_id, timeout_ns, waiters
FROM issues
`)
// We need to check if created_by column exists in the old table
// If not, we insert a default empty string for it
var hasCreatedBy bool
rows, err := db.Query(`PRAGMA table_info(issues)`)
if err != nil {
return fmt.Errorf("failed to get table info: %w", err)
}
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt interface{}
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close()
return fmt.Errorf("failed to scan table info: %w", err)
}
if name == "created_by" {
hasCreatedBy = true
break
}
}
rows.Close()
var insertSQL string
if hasCreatedBy {
// Old table has created_by, copy all columns directly
insertSQL = `
INSERT INTO issues_new (
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
created_by, 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, pinned,
is_template, await_type, await_id, timeout_ns, waiters
)
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
created_by, 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, pinned,
is_template, await_type, await_id, timeout_ns, waiters
FROM issues
`
} else {
// Old table doesn't have created_by, use empty string default
insertSQL = `
INSERT INTO issues_new (
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
created_by, 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, pinned,
is_template, await_type, await_id, timeout_ns, waiters
)
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
'', updated_at, closed_at, external_ref, source_repo, compaction_level,
compacted_at, compacted_at_commit, original_size, deleted_at, deleted_by,
delete_reason, original_type, sender, ephemeral, close_reason, pinned,
is_template, await_type, await_id, timeout_ns, waiters
FROM issues
`
}
_, err = db.Exec(insertSQL)
if err != nil {
return fmt.Errorf("failed to copy issues data: %w", err)
}
+1 -1
View File
@@ -282,7 +282,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
err := tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE id = ?`, issue.ID).Scan(&existingID)
wisp := 0
if issue.Wisp {
if issue.Ephemeral {
wisp = 1
}
pinned := 0
+1 -1
View File
@@ -54,7 +54,7 @@ func (s *SQLiteStorage) ExportToMultiRepo(ctx context.Context) (map[string]int,
// Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL.
filtered := make([]*types.Issue, 0, len(allIssues))
for _, issue := range allIssues {
if !issue.Wisp {
if !issue.Ephemeral {
filtered = append(filtered, issue)
}
}
+1 -1
View File
@@ -909,7 +909,7 @@ func TestUpsertPreservesGateFields(t *testing.T) {
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeGate,
Wisp: true,
Ephemeral: true,
AwaitType: "gh:run",
AwaitID: "123456789",
Timeout: 30 * 60 * 1000000000, // 30 minutes in nanoseconds
+4 -4
View File
@@ -349,7 +349,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
issue.Sender = sender.String
}
if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true
issue.Ephemeral = true
}
// Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 {
@@ -562,7 +562,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
issue.Sender = sender.String
}
if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true
issue.Ephemeral = true
}
// Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 {
@@ -1652,8 +1652,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
}
// Wisp filtering (bd-kwro.9)
if filter.Wisp != nil {
if *filter.Wisp {
if filter.Ephemeral != nil {
if *filter.Ephemeral {
whereClauses = append(whereClauses, "ephemeral = 1") // SQL column is still 'ephemeral'
} else {
whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)")
+3 -2
View File
@@ -17,7 +17,8 @@ import (
// Excludes pinned issues which are persistent anchors, not actionable work (bd-92u)
func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
whereClauses := []string{
"i.pinned = 0", // Exclude pinned issues (bd-92u)
"i.pinned = 0", // Exclude pinned issues (bd-92u)
"(i.ephemeral = 0 OR i.ephemeral IS NULL)", // Exclude wisps (hq-t15s)
}
args := []interface{}{}
@@ -399,7 +400,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
issue.Sender = sender.String
}
if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Wisp = true
issue.Ephemeral = true
}
// Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 {
+1
View File
@@ -230,6 +230,7 @@ WITH RECURSIVE
SELECT i.*
FROM issues i
WHERE i.status = 'open'
AND (i.ephemeral = 0 OR i.ephemeral IS NULL)
AND NOT EXISTS (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
);
+3 -3
View File
@@ -1089,8 +1089,8 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
}
// Wisp filtering (bd-kwro.9)
if filter.Wisp != nil {
if *filter.Wisp {
if filter.Ephemeral != nil {
if *filter.Ephemeral {
whereClauses = append(whereClauses, "ephemeral = 1") // SQL column is still 'ephemeral'
} else {
whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)")
@@ -1244,7 +1244,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
issue.Sender = sender.String
}
if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true
issue.Ephemeral = true
}
// Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 {
+4 -4
View File
@@ -44,8 +44,8 @@ type Issue struct {
OriginalType string `json:"original_type,omitempty"` // Issue type before deletion (for tombstones)
// Messaging fields (bd-kwro): inter-agent communication support
Sender string `json:"sender,omitempty"` // Who sent this (for messages)
Wisp bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed
Sender string `json:"sender,omitempty"` // Who sent this (for messages)
Ephemeral bool `json:"ephemeral,omitempty"` // If true, not exported to JSONL; bulk-deleted when closed
// NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table
// per Decision 004 (Edge Schema Consolidation). Use dependency API instead.
@@ -598,8 +598,8 @@ type IssueFilter struct {
// Tombstone filtering (bd-1bu)
IncludeTombstones bool // If false (default), exclude tombstones from results
// Wisp filtering (bd-kwro.9)
Wisp *bool // Filter by wisp flag (nil = any, true = only wisps, false = only non-wisps)
// Ephemeral filtering (bd-kwro.9)
Ephemeral *bool // Filter by ephemeral flag (nil = any, true = only ephemeral, false = only persistent)
// Pinned filtering (bd-7h5)
Pinned *bool // Filter by pinned flag (nil = any, true = only pinned, false = only non-pinned)