feat: add session_id field to issue close/update mutations (bd-tksk)
Adds closed_by_session tracking for entity CV building per Gas Town decision 009-session-events-architecture.md. Changes: - Add ClosedBySession field to Issue struct - Add closed_by_session column to issues table (migration 034) - Add --session flag to bd close command - Support CLAUDE_SESSION_ID env var as fallback - Add --session flag to bd update for status=closed - Display closed_by_session in bd show output - Update Storage interface to include session parameter in CloseIssue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
This commit is contained in:
committed by
Steve Yegge
parent
7c9b975436
commit
b362b36824
@@ -126,7 +126,7 @@ func TestCacheInvalidationOnStatusChange(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the blocker
|
||||
if err := store.CloseIssue(ctx, blocker.ID, "Done", "test-user"); err != nil {
|
||||
if err := store.CloseIssue(ctx, blocker.ID, "Done", "test-user", ""); err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestCacheConsistencyAcrossOperations(t *testing.T) {
|
||||
}
|
||||
|
||||
// Operation 3: Close blocker1
|
||||
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if cached[blocked1.ID] || !cached[blocked2.ID] {
|
||||
@@ -322,7 +322,7 @@ func TestDeepHierarchyCacheCorrectness(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the blocker and verify all become unblocked
|
||||
store.CloseIssue(ctx, blocker.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, blocker.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if len(cached) != 0 {
|
||||
@@ -359,7 +359,7 @@ func TestMultipleBlockersInCache(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close one blocker - should still be blocked
|
||||
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if !cached[blocked.ID] {
|
||||
@@ -367,7 +367,7 @@ func TestMultipleBlockersInCache(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the second blocker - should be unblocked
|
||||
store.CloseIssue(ctx, blocker2.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, blocker2.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if cached[blocked.ID] {
|
||||
@@ -400,7 +400,7 @@ func TestConditionalBlocksCache(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close A with SUCCESS (no failure keywords) - B should STILL be blocked
|
||||
store.CloseIssue(ctx, issueA.ID, "Completed successfully", "test-user")
|
||||
store.CloseIssue(ctx, issueA.ID, "Completed successfully", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if !cached[issueB.ID] {
|
||||
@@ -411,7 +411,7 @@ func TestConditionalBlocksCache(t *testing.T) {
|
||||
store.UpdateIssue(ctx, issueA.ID, map[string]interface{}{"status": types.StatusOpen}, "test-user")
|
||||
|
||||
// Close A with FAILURE - B should now be UNBLOCKED
|
||||
store.CloseIssue(ctx, issueA.ID, "Task failed due to timeout", "test-user")
|
||||
store.CloseIssue(ctx, issueA.ID, "Task failed due to timeout", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if cached[issueB.ID] {
|
||||
@@ -451,7 +451,7 @@ func TestConditionalBlocksVariousFailureKeywords(t *testing.T) {
|
||||
store.AddDependency(ctx, dep, "test-user")
|
||||
|
||||
// Close A with failure reason
|
||||
store.CloseIssue(ctx, issueA.ID, "Closed: "+reason, "test-user")
|
||||
store.CloseIssue(ctx, issueA.ID, "Closed: "+reason, "test-user", "")
|
||||
|
||||
cached := getCachedBlockedIssues(t, store)
|
||||
if cached[issueB.ID] {
|
||||
@@ -501,7 +501,7 @@ func TestWaitsForAllChildren(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close first child - waiter should still be blocked (second child still open)
|
||||
store.CloseIssue(ctx, child1.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, child1.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if !cached[waiter.ID] {
|
||||
@@ -509,7 +509,7 @@ func TestWaitsForAllChildren(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close second child - waiter should now be unblocked
|
||||
store.CloseIssue(ctx, child2.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, child2.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if cached[waiter.ID] {
|
||||
@@ -557,7 +557,7 @@ func TestWaitsForAnyChildren(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close first child - waiter should now be unblocked (any-children gate satisfied)
|
||||
store.CloseIssue(ctx, child1.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, child1.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if cached[waiter.ID] {
|
||||
@@ -636,7 +636,7 @@ func TestWaitsForDynamicChildrenAdded(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the child - waiter should be unblocked again
|
||||
store.CloseIssue(ctx, child.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, child.ID, "Done", "test-user", "")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if cached[waiter.ID] {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (h *epicTestHelper) addParentChildDependency(childID, parentID string) {
|
||||
}
|
||||
|
||||
func (h *epicTestHelper) closeIssue(id, reason string) {
|
||||
if err := h.store.CloseIssue(h.ctx, id, reason, "test-user"); err != nil {
|
||||
if err := h.store.CloseIssue(h.ctx, id, reason, "test-user", ""); err != nil {
|
||||
h.t.Fatalf("CloseIssue (%s) failed: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func TestEventTypesInHistory(t *testing.T) {
|
||||
t.Fatalf("AddLabel failed: %v", err)
|
||||
}
|
||||
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ func TestDuplicateOf(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the duplicate
|
||||
if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test"); err != nil {
|
||||
if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test", ""); err != nil {
|
||||
t.Fatalf("Failed to close duplicate: %v", err)
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ func TestSupersededBy(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close old version
|
||||
if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test"); err != nil {
|
||||
if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test", ""); err != nil {
|
||||
t.Fatalf("Failed to close old version: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ var migrationsList = []Migration{
|
||||
{"mol_type_column", migrations.MigrateMolTypeColumn},
|
||||
{"hooked_status_migration", migrations.MigrateHookedStatus},
|
||||
{"event_fields", migrations.MigrateEventFields},
|
||||
{"closed_by_session_column", migrations.MigrateClosedBySessionColumn},
|
||||
}
|
||||
|
||||
// MigrationInfo contains metadata about a migration for inspection
|
||||
@@ -107,6 +108,7 @@ func getMigrationDescription(name string) string {
|
||||
"mol_type_column": "Adds mol_type column for molecule type classification (swarm/patrol/work)",
|
||||
"hooked_status_migration": "Migrates blocked hooked issues to in_progress status",
|
||||
"event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads",
|
||||
"closed_by_session_column": "Adds closed_by_session column for tracking which Claude Code session closed an issue",
|
||||
}
|
||||
|
||||
if desc, ok := descriptions[name]; ok {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MigrateClosedBySessionColumn adds the closed_by_session column to the issues table.
|
||||
// This tracks which Claude Code session closed the issue, enabling work attribution
|
||||
// for entity CV building. See Gas Town decision 009-session-events-architecture.md.
|
||||
func MigrateClosedBySessionColumn(db *sql.DB) error {
|
||||
// Check if column already exists
|
||||
var columnExists bool
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM pragma_table_info('issues')
|
||||
WHERE name = 'closed_by_session'
|
||||
`).Scan(&columnExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check closed_by_session column: %w", err)
|
||||
}
|
||||
|
||||
if columnExists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add the closed_by_session column
|
||||
_, err = db.Exec(`ALTER TABLE issues ADD COLUMN closed_by_session TEXT DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add closed_by_session column: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -664,6 +664,8 @@ var allowedUpdateFields = map[string]bool{
|
||||
"estimated_minutes": true,
|
||||
"external_ref": true,
|
||||
"closed_at": true,
|
||||
"close_reason": true,
|
||||
"closed_by_session": true,
|
||||
// Messaging fields
|
||||
"sender": true,
|
||||
"wisp": true, // Database column is 'ephemeral', mapped in UpdateIssue
|
||||
@@ -1070,8 +1072,9 @@ func (s *SQLiteStorage) ResetCounter(ctx context.Context, prefix string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseIssue closes an issue with a reason
|
||||
func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
|
||||
// CloseIssue closes an issue with a reason.
|
||||
// The session parameter tracks which Claude Code session closed the issue (can be empty).
|
||||
func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
|
||||
now := time.Now()
|
||||
|
||||
// Update with special event handling
|
||||
@@ -1086,9 +1089,9 @@ func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string
|
||||
// 2. events.comment - for audit history (when was it closed, by whom)
|
||||
// Keep both in sync. If refactoring, consider deriving one from the other.
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
|
||||
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?, closed_by_session = ?
|
||||
WHERE id = ?
|
||||
`, types.StatusClosed, now, now, reason, id)
|
||||
`, types.StatusClosed, now, now, reason, session, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close issue: %w", err)
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ func TestDeepHierarchyBlocking(t *testing.T) {
|
||||
}
|
||||
|
||||
// Now close the blocker and verify all levels become ready
|
||||
store.CloseIssue(ctx, blocker.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, blocker.ID, "Done", "test-user", "")
|
||||
|
||||
ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
||||
if err != nil {
|
||||
@@ -564,7 +564,7 @@ func TestGetReadyWorkIncludesInProgress(t *testing.T) {
|
||||
store.UpdateIssue(ctx, issue3.ID, map[string]interface{}{"status": types.StatusInProgress}, "test-user")
|
||||
store.CreateIssue(ctx, issue4, "test-user")
|
||||
store.CreateIssue(ctx, issue5, "test-user")
|
||||
store.CloseIssue(ctx, issue5.ID, "Done", "test-user")
|
||||
store.CloseIssue(ctx, issue5.ID, "Done", "test-user", "")
|
||||
|
||||
// Add dependency: issue3 blocks on issue4
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user")
|
||||
@@ -1018,7 +1018,7 @@ func TestGetReadyWorkExternalDeps(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the capability issue
|
||||
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user"); err != nil {
|
||||
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil {
|
||||
t.Fatalf("failed to close capability issue: %v", err)
|
||||
}
|
||||
|
||||
@@ -1246,7 +1246,7 @@ func TestGetBlockedIssuesFiltersExternalDeps(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the capability issue
|
||||
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user"); err != nil {
|
||||
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil {
|
||||
t.Fatalf("failed to close capability issue: %v", err)
|
||||
}
|
||||
|
||||
@@ -1371,7 +1371,7 @@ func TestGetBlockedIssuesPartialExternalDeps(t *testing.T) {
|
||||
if err := externalStore.AddLabel(ctx, cap1Issue.ID, "provides:cap1", "test-user"); err != nil {
|
||||
t.Fatalf("failed to add provides label: %v", err)
|
||||
}
|
||||
if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user"); err != nil {
|
||||
if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user", ""); err != nil {
|
||||
t.Fatalf("failed to close cap1 issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS issues (
|
||||
created_by TEXT DEFAULT '',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at DATETIME,
|
||||
closed_by_session TEXT DEFAULT '',
|
||||
external_ref TEXT,
|
||||
compaction_level INTEGER DEFAULT 0,
|
||||
compacted_at DATETIME,
|
||||
|
||||
@@ -180,7 +180,7 @@ func BenchmarkBulkCloseIssues(b *testing.B) {
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j, issue := range issues {
|
||||
if err := store.CloseIssue(ctx, issue.ID, "Bulk closed", "bench"); err != nil {
|
||||
if err := store.CloseIssue(ctx, issue.ID, "Bulk closed", "bench", ""); err != nil {
|
||||
b.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
// Re-open for next iteration (except last one)
|
||||
|
||||
@@ -640,7 +640,7 @@ func TestCloseIssue(t *testing.T) {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
@@ -717,7 +717,7 @@ func TestClosedAtInvariant(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the issue
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
@@ -812,7 +812,7 @@ func TestSearchIssues(t *testing.T) {
|
||||
}
|
||||
// Close the third issue
|
||||
if issue.Title == "Another bug" {
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
@@ -977,7 +977,7 @@ func TestGetStatistics(t *testing.T) {
|
||||
}
|
||||
// Close the one that should be closed
|
||||
if issue.Title == "Closed task" {
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
@@ -1532,7 +1532,7 @@ func TestConvoyReactiveCompletion(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close first issue - convoy should still be open
|
||||
err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user")
|
||||
err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue issue1 failed: %v", err)
|
||||
}
|
||||
@@ -1546,7 +1546,7 @@ func TestConvoyReactiveCompletion(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close second issue - convoy should auto-close now
|
||||
err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user")
|
||||
err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue issue2 failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func (e *testEnv) AddParentChild(child, parent *types.Issue) {
|
||||
// Close closes the issue with the given reason.
|
||||
func (e *testEnv) Close(issue *types.Issue, reason string) {
|
||||
e.t.Helper()
|
||||
if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user"); err != nil {
|
||||
if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user", ""); err != nil {
|
||||
e.t.Fatalf("CloseIssue(%s) failed: %v", issue.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestCreateTombstone(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close the issue to set closed_at
|
||||
if err := store.CloseIssue(ctx, "bd-closed-1", "closing for test", "tester"); err != nil {
|
||||
if err := store.CloseIssue(ctx, "bd-closed-1", "closing for test", "tester", ""); err != nil {
|
||||
t.Fatalf("Failed to close issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -523,13 +523,14 @@ func applyUpdatesToIssue(issue *types.Issue, updates map[string]interface{}) {
|
||||
|
||||
// CloseIssue closes an issue within the transaction.
|
||||
// NOTE: close_reason is stored in both issues table and events table - see SQLiteStorage.CloseIssue.
|
||||
func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
|
||||
// The session parameter tracks which Claude Code session closed the issue (can be empty).
|
||||
func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
|
||||
now := time.Now()
|
||||
|
||||
result, err := t.conn.ExecContext(ctx, `
|
||||
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
|
||||
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?, closed_by_session = ?
|
||||
WHERE id = ?
|
||||
`, types.StatusClosed, now, now, reason, id)
|
||||
`, types.StatusClosed, now, now, reason, session, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close issue: %w", err)
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ func TestTransactionCloseIssue(t *testing.T) {
|
||||
|
||||
// Close in transaction
|
||||
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||
return tx.CloseIssue(ctx, issue.ID, "Done", "test-actor")
|
||||
return tx.CloseIssue(ctx, issue.ID, "Done", "test-actor", "")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user