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:
beads/crew/dave
2025-12-31 13:13:49 -08:00
committed by Steve Yegge
parent 7c9b975436
commit b362b36824
42 changed files with 165 additions and 82 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "gt prime" "command": "bash ~/.claude/hooks/session-start.sh"
} }
] ]
} }
+9 -1
View File
@@ -46,6 +46,12 @@ create, update, show, or close operation).`,
noAuto, _ := cmd.Flags().GetBool("no-auto") noAuto, _ := cmd.Flags().GetBool("no-auto")
suggestNext, _ := cmd.Flags().GetBool("suggest-next") suggestNext, _ := cmd.Flags().GetBool("suggest-next")
// Get session ID from flag or environment variable
session, _ := cmd.Flags().GetString("session")
if session == "" {
session = os.Getenv("CLAUDE_SESSION_ID")
}
ctx := rootCtx ctx := rootCtx
// --continue only works with a single issue // --continue only works with a single issue
@@ -101,6 +107,7 @@ create, update, show, or close operation).`,
closeArgs := &rpc.CloseArgs{ closeArgs := &rpc.CloseArgs{
ID: id, ID: id,
Reason: reason, Reason: reason,
Session: session,
SuggestNext: suggestNext, SuggestNext: suggestNext,
} }
resp, err := daemonClient.CloseIssue(closeArgs) resp, err := daemonClient.CloseIssue(closeArgs)
@@ -175,7 +182,7 @@ create, update, show, or close operation).`,
continue continue
} }
if err := store.CloseIssue(ctx, id, reason, actor); err != nil { if err := store.CloseIssue(ctx, id, reason, actor, session); err != nil {
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
continue continue
} }
@@ -253,5 +260,6 @@ func init() {
closeCmd.Flags().Bool("continue", false, "Auto-advance to next step in molecule") closeCmd.Flags().Bool("continue", false, "Auto-advance to next step in molecule")
closeCmd.Flags().Bool("no-auto", false, "With --continue, show next step but don't claim it") closeCmd.Flags().Bool("no-auto", false, "With --continue, show next step but don't claim it")
closeCmd.Flags().Bool("suggest-next", false, "Show newly unblocked issues after closing") closeCmd.Flags().Bool("suggest-next", false, "Show newly unblocked issues after closing")
closeCmd.Flags().String("session", "", "Claude Code session ID (or set CLAUDE_SESSION_ID env var)")
rootCmd.AddCommand(closeCmd) rootCmd.AddCommand(closeCmd)
} }
+2 -2
View File
@@ -118,7 +118,7 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
// NOW THE CRITICAL TEST: Agent A closes the issue and pushes // NOW THE CRITICAL TEST: Agent A closes the issue and pushes
t.Run("DaemonAutoImportsAfterGitPull", func(t *testing.T) { t.Run("DaemonAutoImportsAfterGitPull", func(t *testing.T) {
// Agent A closes the issue // Agent A closes the issue
if err := clone1Store.CloseIssue(ctx, issueID, "Completed", "agent-a"); err != nil { if err := clone1Store.CloseIssue(ctx, issueID, "Completed", "agent-a", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err) t.Fatalf("Failed to close issue: %v", err)
} }
@@ -331,7 +331,7 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
// THE CORRUPTION SCENARIO: // THE CORRUPTION SCENARIO:
// 1. Agent A closes the issue and pushes // 1. Agent A closes the issue and pushes
clone1Store.CloseIssue(ctx, issueID, "Done", "agent-a") clone1Store.CloseIssue(ctx, issueID, "Done", "agent-a", "")
exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath) exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath)
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
runGitCmd(t, clone1Dir, "commit", "-m", "Close issue") runGitCmd(t, clone1Dir, "commit", "-m", "Close issue")
+1 -1
View File
@@ -734,7 +734,7 @@ func TestSyncBranchIntegration_EndToEnd(t *testing.T) {
} }
// Agent B closes the issue // Agent B closes the issue
store2.CloseIssue(ctx, issueID, "Done by Agent B", "agent-b") store2.CloseIssue(ctx, issueID, "Done by Agent B", "agent-b", "")
exportToJSONLWithStore(ctx, store2, clone2JSONLPath) exportToJSONLWithStore(ctx, store2, clone2JSONLPath)
// Agent B commits to sync branch // Agent B commits to sync branch
+1 -1
View File
@@ -283,7 +283,7 @@ func performMerge(targetID string, sourceIDs []string) map[string]interface{} {
for _, sourceID := range sourceIDs { for _, sourceID := range sourceIDs {
// Close the duplicate issue // Close the duplicate issue
reason := fmt.Sprintf("Duplicate of %s", targetID) reason := fmt.Sprintf("Duplicate of %s", targetID)
if err := store.CloseIssue(ctx, sourceID, reason, actor); err != nil { if err := store.CloseIssue(ctx, sourceID, reason, actor, ""); err != nil {
errors = append(errors, fmt.Sprintf("failed to close %s: %v", sourceID, err)) errors = append(errors, fmt.Sprintf("failed to close %s: %v", sourceID, err))
continue continue
} }
+1 -1
View File
@@ -166,7 +166,7 @@ var closeEligibleEpicsCmd = &cobra.Command{
} }
} else { } else {
ctx := rootCtx ctx := rootCtx
err := store.CloseIssue(ctx, epicStatus.Epic.ID, "All children completed", "system") err := store.CloseIssue(ctx, epicStatus.Epic.ID, "All children completed", "system", "")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", epicStatus.Epic.ID, err) fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", epicStatus.Epic.ID, err)
continue continue
+1 -1
View File
@@ -385,7 +385,7 @@ func TestCloseReasonRoundTrip(t *testing.T) {
// Close the issue with a reason // Close the issue with a reason
closeReason := "Completed: all tests passing" closeReason := "Completed: all tests passing"
if err := store.CloseIssue(ctx, issue.ID, closeReason, "test-actor"); err != nil { if err := store.CloseIssue(ctx, issue.ID, closeReason, "test-actor", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err) t.Fatalf("Failed to close issue: %v", err)
} }
+3 -3
View File
@@ -425,7 +425,7 @@ var gateCloseCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil { if err := store.CloseIssue(ctx, gateID, reason, actor, ""); err != nil {
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err) fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -543,7 +543,7 @@ Example:
reason = fmt.Sprintf("Human approval granted: %s (%s)", gate.AwaitID, comment) reason = fmt.Sprintf("Human approval granted: %s (%s)", gate.AwaitID, comment)
} }
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil { if err := store.CloseIssue(ctx, gateID, reason, actor, ""); err != nil {
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err) fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -807,7 +807,7 @@ This command is idempotent and safe to run repeatedly.`,
continue continue
} }
} else if store != nil { } else if store != nil {
if err := store.CloseIssue(ctx, gate.ID, reason, actor); err != nil { if err := store.CloseIssue(ctx, gate.ID, reason, actor, ""); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close gate %s: %v\n", gate.ID, err) fmt.Fprintf(os.Stderr, "Warning: failed to close gate %s: %v\n", gate.ID, err)
continue continue
} }
+1 -1
View File
@@ -71,7 +71,7 @@ func TestGitPullSyncIntegration(t *testing.T) {
issueID := issue.ID issueID := issue.ID
// Close the issue // Close the issue
if err := clone1Store.CloseIssue(ctx, issueID, "Test completed", "test-user"); err != nil { if err := clone1Store.CloseIssue(ctx, issueID, "Test completed", "test-user", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err) t.Fatalf("Failed to close issue: %v", err)
} }
+1 -1
View File
@@ -263,7 +263,7 @@ func TestListQueryCapabilitiesSuite(t *testing.T) {
} }
// Close issue3 to set closed_at timestamp // Close issue3 to set closed_at timestamp
if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing"); err != nil { if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing", ""); err != nil {
t.Fatalf("Failed to close issue3: %v", err) t.Fatalf("Failed to close issue3: %v", err)
} }
+1 -1
View File
@@ -133,7 +133,7 @@ Examples:
// Step 7: Close the source issue (unless --keep-open) // Step 7: Close the source issue (unless --keep-open)
if !keepOpen { if !keepOpen {
closeReason := fmt.Sprintf("Refiled to %s", newIssue.ID) closeReason := fmt.Sprintf("Refiled to %s", newIssue.ID)
if err := result.Store.CloseIssue(ctx, resolvedSourceID, closeReason, actor); err != nil { if err := result.Store.CloseIssue(ctx, resolvedSourceID, closeReason, actor, ""); err != nil {
WarnError("failed to close source issue: %v", err) WarnError("failed to close source issue: %v", err)
} }
// Schedule auto-flush if source was local store // Schedule auto-flush if source was local store
+1 -1
View File
@@ -30,7 +30,7 @@ func (h *reopenTestHelper) createIssue(title string, issueType types.IssueType,
} }
func (h *reopenTestHelper) closeIssue(issueID, reason string) { func (h *reopenTestHelper) closeIssue(issueID, reason string) {
if err := h.s.CloseIssue(h.ctx, issueID, "test-user", reason); err != nil { if err := h.s.CloseIssue(h.ctx, issueID, "test-user", reason, ""); err != nil {
h.t.Fatalf("Failed to close issue: %v", err) h.t.Fatalf("Failed to close issue: %v", err)
} }
} }
+1 -1
View File
@@ -204,7 +204,7 @@ func TestSearchWithDateAndPriorityFilters(t *testing.T) {
} }
// Close issue3 to set closed_at timestamp // Close issue3 to set closed_at timestamp
if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing"); err != nil { if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing", ""); err != nil {
t.Fatalf("Failed to close issue3: %v", err) t.Fatalf("Failed to close issue3: %v", err)
} }
+6
View File
@@ -209,6 +209,9 @@ var showCmd = &cobra.Command{
if issue.CloseReason != "" { if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason) fmt.Printf("Close reason: %s\n", issue.CloseReason)
} }
if issue.ClosedBySession != "" {
fmt.Printf("Closed by session: %s\n", issue.ClosedBySession)
}
fmt.Printf("Priority: P%d\n", issue.Priority) fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType) fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" { if issue.Assignee != "" {
@@ -426,6 +429,9 @@ var showCmd = &cobra.Command{
if issue.CloseReason != "" { if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason) fmt.Printf("Close reason: %s\n", issue.CloseReason)
} }
if issue.ClosedBySession != "" {
fmt.Printf("Closed by session: %s\n", issue.ClosedBySession)
}
fmt.Printf("Priority: P%d\n", issue.Priority) fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType) fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" { if issue.Assignee != "" {
+12
View File
@@ -40,6 +40,17 @@ create, update, show, or close operation).`,
if cmd.Flags().Changed("status") { if cmd.Flags().Changed("status") {
status, _ := cmd.Flags().GetString("status") status, _ := cmd.Flags().GetString("status")
updates["status"] = status updates["status"] = status
// If status is being set to closed, include session if provided
if status == "closed" {
session, _ := cmd.Flags().GetString("session")
if session == "" {
session = os.Getenv("CLAUDE_SESSION_ID")
}
if session != "" {
updates["closed_by_session"] = session
}
}
} }
if cmd.Flags().Changed("priority") { if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority") priorityStr, _ := cmd.Flags().GetString("priority")
@@ -412,5 +423,6 @@ func init() {
updateCmd.Flags().StringSlice("set-labels", nil, "Set labels, replacing all existing (repeatable)") updateCmd.Flags().StringSlice("set-labels", nil, "Set labels, replacing all existing (repeatable)")
updateCmd.Flags().String("parent", "", "New parent issue ID (reparents the issue, use empty string to remove parent)") updateCmd.Flags().String("parent", "", "New parent issue ID (reparents the issue, use empty string to remove parent)")
updateCmd.Flags().Bool("claim", false, "Atomically claim the issue (sets assignee to you, status to in_progress; fails if already claimed)") updateCmd.Flags().Bool("claim", false, "Atomically claim the issue (sets assignee to you, status to in_progress; fails if already claimed)")
updateCmd.Flags().String("session", "", "Claude Code session ID for status=closed (or set CLAUDE_SESSION_ID env var)")
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateCmd)
} }
+1 -1
View File
@@ -65,7 +65,7 @@ func main() {
// Complete // Complete
db.Exec(`UPDATE example_executions SET status='completed', completed_at=? WHERE id=?`, time.Now(), execID) db.Exec(`UPDATE example_executions SET status='completed', completed_at=? WHERE id=?`, time.Now(), execID)
store.CloseIssue(ctx, issue.ID, "Done", "demo-agent") store.CloseIssue(ctx, issue.ID, "Done", "demo-agent", "")
// Show status // Show status
fmt.Println("\nStatus:") fmt.Println("\nStatus:")
+1 -1
View File
@@ -120,7 +120,7 @@ func main() {
// Example 8: Close the issue // Example 8: Close the issue
fmt.Println("\n=== Closing Issue ===") fmt.Println("\n=== Closing Issue ===")
if err := store.CloseIssue(ctx, newIssue.ID, "Completed demo", "library-example"); err != nil { if err := store.CloseIssue(ctx, newIssue.ID, "Completed demo", "library-example", ""); err != nil {
log.Fatalf("Failed to close issue: %v", err) log.Fatalf("Failed to close issue: %v", err)
} }
fmt.Printf("Closed issue %s\n", newIssue.ID) fmt.Printf("Closed issue %s\n", newIssue.ID)
+1 -1
View File
@@ -93,7 +93,7 @@ func TestExampleCompiles(t *testing.T) {
} }
// Close issue (from example code) // Close issue (from example code)
if err := store.CloseIssue(ctx, newIssue.ID, "Test complete", "test"); err != nil { if err := store.CloseIssue(ctx, newIssue.ID, "Test complete", "test", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err) t.Fatalf("Failed to close issue: %v", err)
} }
+1 -1
View File
@@ -67,7 +67,7 @@ func (h *integrationTestHelper) updateIssue(id string, updates map[string]interf
} }
func (h *integrationTestHelper) closeIssue(id string, reason string) { func (h *integrationTestHelper) closeIssue(id string, reason string) {
if err := h.store.CloseIssue(h.ctx, id, reason, "test-actor"); err != nil { if err := h.store.CloseIssue(h.ctx, id, reason, "test-actor", ""); err != nil {
h.t.Fatalf("CloseIssue failed: %v", err) h.t.Fatalf("CloseIssue failed: %v", err)
} }
} }
+1
View File
@@ -160,6 +160,7 @@ type UpdateArgs struct {
type CloseArgs struct { type CloseArgs struct {
ID string `json:"id"` ID string `json:"id"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Session string `json:"session,omitempty"` // Claude Code session ID that closed this issue
SuggestNext bool `json:"suggest_next,omitempty"` // Return newly unblocked issues (GH#679) SuggestNext bool `json:"suggest_next,omitempty"` // Return newly unblocked issues (GH#679)
} }
+2 -2
View File
@@ -737,7 +737,7 @@ func (s *Server) handleClose(req *Request) Response {
oldStatus = string(issue.Status) oldStatus = string(issue.Status)
} }
if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil { if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req), closeArgs.Session); err != nil {
return Response{ return Response{
Success: false, Success: false,
Error: fmt.Sprintf("failed to close issue: %v", err), Error: fmt.Sprintf("failed to close issue: %v", err),
@@ -2069,7 +2069,7 @@ func (s *Server) handleGateClose(req *Request) Response {
oldStatus := string(gate.Status) oldStatus := string(gate.Status)
if err := store.CloseIssue(ctx, gateID, reason, s.reqActor(req)); err != nil { if err := store.CloseIssue(ctx, gateID, reason, s.reqActor(req), ""); err != nil {
return Response{ return Response{
Success: false, Success: false,
Error: fmt.Sprintf("failed to close gate: %v", err), Error: fmt.Sprintf("failed to close gate: %v", err),
+19 -5
View File
@@ -443,6 +443,14 @@ func (m *MemoryStorage) UpdateIssue(ctx context.Context, id string, updates map[
} }
issue.ExternalRef = nil issue.ExternalRef = nil
} }
case "close_reason":
if v, ok := value.(string); ok {
issue.CloseReason = v
}
case "closed_by_session":
if v, ok := value.(string); ok {
issue.ClosedBySession = v
}
} }
} }
@@ -467,11 +475,17 @@ func (m *MemoryStorage) UpdateIssue(ctx context.Context, id string, updates map[
return nil return nil
} }
// CloseIssue closes an issue with a reason // CloseIssue closes an issue with a reason.
func (m *MemoryStorage) 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).
return m.UpdateIssue(ctx, id, map[string]interface{}{ func (m *MemoryStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
"status": string(types.StatusClosed), updates := map[string]interface{}{
}, actor) "status": string(types.StatusClosed),
"close_reason": reason,
}
if session != "" {
updates["closed_by_session"] = session
}
return m.UpdateIssue(ctx, id, updates, actor)
} }
// CreateTombstone converts an existing issue to a tombstone record. // CreateTombstone converts an existing issue to a tombstone record.
@@ -515,7 +515,7 @@ func TestMemoryStorage_GetStaleIssues_FilteringAndLimit(t *testing.T) {
t.Fatalf("CreateIssue %s: %v", iss.ID, err) t.Fatalf("CreateIssue %s: %v", iss.ID, err)
} }
} }
if err := store.CloseIssue(ctx, closed.ID, "done", "actor"); err != nil { if err := store.CloseIssue(ctx, closed.ID, "done", "actor", ""); err != nil {
t.Fatalf("CloseIssue: %v", err) t.Fatalf("CloseIssue: %v", err)
} }
@@ -555,10 +555,10 @@ func TestMemoryStorage_Statistics_EpicsEligibleForClosure_Counting(t *testing.T)
t.Fatalf("CreateIssue %s: %v", iss.ID, err) t.Fatalf("CreateIssue %s: %v", iss.ID, err)
} }
} }
if err := store.CloseIssue(ctx, c1.ID, "done", "actor"); err != nil { if err := store.CloseIssue(ctx, c1.ID, "done", "actor", ""); err != nil {
t.Fatalf("CloseIssue c1: %v", err) t.Fatalf("CloseIssue c1: %v", err)
} }
if err := store.CloseIssue(ctx, c2.ID, "done", "actor"); err != nil { if err := store.CloseIssue(ctx, c2.ID, "done", "actor", ""); err != nil {
t.Fatalf("CloseIssue c2: %v", err) t.Fatalf("CloseIssue c2: %v", err)
} }
// Parent-child deps: child -> epic. // Parent-child deps: child -> epic.
@@ -851,7 +851,7 @@ func TestMemoryStorage_UpdateIssue_CoversMoreFields(t *testing.T) {
} }
// Status closed when already closed should not clear ClosedAt. // Status closed when already closed should not clear ClosedAt.
if err := store.CloseIssue(ctx, iss.ID, "done", "actor"); err != nil { if err := store.CloseIssue(ctx, iss.ID, "done", "actor", ""); err != nil {
t.Fatalf("CloseIssue: %v", err) t.Fatalf("CloseIssue: %v", err)
} }
closedOnce, _ := store.GetIssue(ctx, iss.ID) closedOnce, _ := store.GetIssue(ctx, iss.ID)
@@ -881,7 +881,7 @@ func TestMemoryStorage_CountEpicsEligibleForClosure_CoversBranches(t *testing.T)
t.Fatalf("CreateIssue %s: %v", iss.ID, err) t.Fatalf("CreateIssue %s: %v", iss.ID, err)
} }
} }
if err := store.CloseIssue(ctx, epClosed.ID, "done", "actor"); err != nil { if err := store.CloseIssue(ctx, epClosed.ID, "done", "actor", ""); err != nil {
t.Fatalf("CloseIssue: %v", err) t.Fatalf("CloseIssue: %v", err)
} }
// Child -> ep1 (eligible once child is closed). // Child -> ep1 (eligible once child is closed).
@@ -898,7 +898,7 @@ func TestMemoryStorage_CountEpicsEligibleForClosure_CoversBranches(t *testing.T)
store.mu.Unlock() store.mu.Unlock()
// Close child to make ep1 eligible. // Close child to make ep1 eligible.
if err := store.CloseIssue(ctx, c.ID, "done", "actor"); err != nil { if err := store.CloseIssue(ctx, c.ID, "done", "actor", ""); err != nil {
t.Fatalf("CloseIssue child: %v", err) t.Fatalf("CloseIssue child: %v", err)
} }
+5 -5
View File
@@ -320,7 +320,7 @@ func TestCloseIssue(t *testing.T) {
} }
// Close it // Close it
if err := store.CloseIssue(ctx, issue.ID, "Completed", "test-user"); err != nil { if err := store.CloseIssue(ctx, issue.ID, "Completed", "test-user", ""); err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
@@ -733,7 +733,7 @@ func TestStatistics(t *testing.T) {
} }
// Close the one marked as closed // Close the one marked as closed
if issue.Status == types.StatusClosed { if issue.Status == types.StatusClosed {
if err := store.CloseIssue(ctx, issue.ID, "Done", "test-user"); err != nil { if err := store.CloseIssue(ctx, issue.ID, "Done", "test-user", ""); err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
} }
@@ -786,7 +786,7 @@ func TestStatistics_BlockedAndReadyCounts(t *testing.T) {
} }
// Close the closedBlocker properly // Close the closedBlocker properly
if err := store.CloseIssue(ctx, closedBlocker.ID, "Done", "test"); err != nil { if err := store.CloseIssue(ctx, closedBlocker.ID, "Done", "test", ""); err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
@@ -878,7 +878,7 @@ func TestStatistics_EpicsEligibleForClosure(t *testing.T) {
// Close the children properly // Close the children properly
for _, child := range []*types.Issue{child1, child2} { for _, child := range []*types.Issue{child1, child2} {
if err := store.CloseIssue(ctx, child.ID, "Done", "test"); err != nil { if err := store.CloseIssue(ctx, child.ID, "Done", "test", ""); err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
} }
@@ -926,7 +926,7 @@ func TestStatistics_TombstonesExcludedFromTotal(t *testing.T) {
} }
// Close the closed issue properly // Close the closed issue properly
if err := store.CloseIssue(ctx, issues[1].ID, "Done", "test"); err != nil { if err := store.CloseIssue(ctx, issues[1].ID, "Done", "test", ""); err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
+12 -12
View File
@@ -126,7 +126,7 @@ func TestCacheInvalidationOnStatusChange(t *testing.T) {
} }
// Close the blocker // 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) t.Fatalf("CloseIssue failed: %v", err)
} }
@@ -186,7 +186,7 @@ func TestCacheConsistencyAcrossOperations(t *testing.T) {
} }
// Operation 3: Close blocker1 // Operation 3: Close blocker1
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user") store.CloseIssue(ctx, blocker1.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store) cached = getCachedBlockedIssues(t, store)
if cached[blocked1.ID] || !cached[blocked2.ID] { if cached[blocked1.ID] || !cached[blocked2.ID] {
@@ -322,7 +322,7 @@ func TestDeepHierarchyCacheCorrectness(t *testing.T) {
} }
// Close the blocker and verify all become unblocked // 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) cached = getCachedBlockedIssues(t, store)
if len(cached) != 0 { if len(cached) != 0 {
@@ -359,7 +359,7 @@ func TestMultipleBlockersInCache(t *testing.T) {
} }
// Close one blocker - should still be blocked // 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) cached = getCachedBlockedIssues(t, store)
if !cached[blocked.ID] { if !cached[blocked.ID] {
@@ -367,7 +367,7 @@ func TestMultipleBlockersInCache(t *testing.T) {
} }
// Close the second blocker - should be unblocked // 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) cached = getCachedBlockedIssues(t, store)
if cached[blocked.ID] { 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 // 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) cached = getCachedBlockedIssues(t, store)
if !cached[issueB.ID] { 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") store.UpdateIssue(ctx, issueA.ID, map[string]interface{}{"status": types.StatusOpen}, "test-user")
// Close A with FAILURE - B should now be UNBLOCKED // 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) cached = getCachedBlockedIssues(t, store)
if cached[issueB.ID] { if cached[issueB.ID] {
@@ -451,7 +451,7 @@ func TestConditionalBlocksVariousFailureKeywords(t *testing.T) {
store.AddDependency(ctx, dep, "test-user") store.AddDependency(ctx, dep, "test-user")
// Close A with failure reason // 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) cached := getCachedBlockedIssues(t, store)
if cached[issueB.ID] { 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) // 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) cached = getCachedBlockedIssues(t, store)
if !cached[waiter.ID] { if !cached[waiter.ID] {
@@ -509,7 +509,7 @@ func TestWaitsForAllChildren(t *testing.T) {
} }
// Close second child - waiter should now be unblocked // 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) cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] { 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) // 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) cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] { if cached[waiter.ID] {
@@ -636,7 +636,7 @@ func TestWaitsForDynamicChildrenAdded(t *testing.T) {
} }
// Close the child - waiter should be unblocked again // 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) cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] { if cached[waiter.ID] {
+1 -1
View File
@@ -57,7 +57,7 @@ func (h *epicTestHelper) addParentChildDependency(childID, parentID string) {
} }
func (h *epicTestHelper) closeIssue(id, reason 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) h.t.Fatalf("CloseIssue (%s) failed: %v", id, err)
} }
} }
+1 -1
View File
@@ -311,7 +311,7 @@ func TestEventTypesInHistory(t *testing.T) {
t.Fatalf("AddLabel failed: %v", err) 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 { if err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
+2 -2
View File
@@ -179,7 +179,7 @@ func TestDuplicateOf(t *testing.T) {
} }
// Close the duplicate // 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) t.Fatalf("Failed to close duplicate: %v", err)
} }
@@ -251,7 +251,7 @@ func TestSupersededBy(t *testing.T) {
} }
// Close old version // 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) t.Fatalf("Failed to close old version: %v", err)
} }
+2
View File
@@ -50,6 +50,7 @@ var migrationsList = []Migration{
{"mol_type_column", migrations.MigrateMolTypeColumn}, {"mol_type_column", migrations.MigrateMolTypeColumn},
{"hooked_status_migration", migrations.MigrateHookedStatus}, {"hooked_status_migration", migrations.MigrateHookedStatus},
{"event_fields", migrations.MigrateEventFields}, {"event_fields", migrations.MigrateEventFields},
{"closed_by_session_column", migrations.MigrateClosedBySessionColumn},
} }
// MigrationInfo contains metadata about a migration for inspection // 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)", "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", "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", "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 { 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
}
+7 -4
View File
@@ -664,6 +664,8 @@ var allowedUpdateFields = map[string]bool{
"estimated_minutes": true, "estimated_minutes": true,
"external_ref": true, "external_ref": true,
"closed_at": true, "closed_at": true,
"close_reason": true,
"closed_by_session": true,
// Messaging fields // Messaging fields
"sender": true, "sender": true,
"wisp": true, // Database column is 'ephemeral', mapped in UpdateIssue "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 return nil
} }
// CloseIssue closes an issue with a reason // CloseIssue closes an issue with a reason.
func (s *SQLiteStorage) 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 (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
now := time.Now() now := time.Now()
// Update with special event handling // 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) // 2. events.comment - for audit history (when was it closed, by whom)
// Keep both in sync. If refactoring, consider deriving one from the other. // Keep both in sync. If refactoring, consider deriving one from the other.
result, err := tx.ExecContext(ctx, ` 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 = ? WHERE id = ?
`, types.StatusClosed, now, now, reason, id) `, types.StatusClosed, now, now, reason, session, id)
if err != nil { if err != nil {
return fmt.Errorf("failed to close issue: %w", err) return fmt.Errorf("failed to close issue: %w", err)
} }
+5 -5
View File
@@ -515,7 +515,7 @@ func TestDeepHierarchyBlocking(t *testing.T) {
} }
// Now close the blocker and verify all levels become ready // 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}) ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil { 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.UpdateIssue(ctx, issue3.ID, map[string]interface{}{"status": types.StatusInProgress}, "test-user")
store.CreateIssue(ctx, issue4, "test-user") store.CreateIssue(ctx, issue4, "test-user")
store.CreateIssue(ctx, issue5, "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 // Add dependency: issue3 blocks on issue4
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user") 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 // 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) t.Fatalf("failed to close capability issue: %v", err)
} }
@@ -1246,7 +1246,7 @@ func TestGetBlockedIssuesFiltersExternalDeps(t *testing.T) {
} }
// Close the capability issue // 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) 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 { if err := externalStore.AddLabel(ctx, cap1Issue.ID, "provides:cap1", "test-user"); err != nil {
t.Fatalf("failed to add provides label: %v", err) 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) t.Fatalf("failed to close cap1 issue: %v", err)
} }
+1
View File
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS issues (
created_by TEXT DEFAULT '', created_by TEXT DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME, closed_at DATETIME,
closed_by_session TEXT DEFAULT '',
external_ref TEXT, external_ref TEXT,
compaction_level INTEGER DEFAULT 0, compaction_level INTEGER DEFAULT 0,
compacted_at DATETIME, compacted_at DATETIME,
+1 -1
View File
@@ -180,7 +180,7 @@ func BenchmarkBulkCloseIssues(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
for j, issue := range issues { 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) b.Fatalf("CloseIssue failed: %v", err)
} }
// Re-open for next iteration (except last one) // Re-open for next iteration (except last one)
+6 -6
View File
@@ -640,7 +640,7 @@ func TestCloseIssue(t *testing.T) {
t.Fatalf("CreateIssue failed: %v", err) 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 { if err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
@@ -717,7 +717,7 @@ func TestClosedAtInvariant(t *testing.T) {
} }
// Close the issue // Close the issue
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user") err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
if err != nil { if err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
@@ -812,7 +812,7 @@ func TestSearchIssues(t *testing.T) {
} }
// Close the third issue // Close the third issue
if issue.Title == "Another bug" { 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 { if err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
@@ -977,7 +977,7 @@ func TestGetStatistics(t *testing.T) {
} }
// Close the one that should be closed // Close the one that should be closed
if issue.Title == "Closed task" { 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 { if err != nil {
t.Fatalf("CloseIssue failed: %v", err) t.Fatalf("CloseIssue failed: %v", err)
} }
@@ -1532,7 +1532,7 @@ func TestConvoyReactiveCompletion(t *testing.T) {
} }
// Close first issue - convoy should still be open // 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 { if err != nil {
t.Fatalf("CloseIssue issue1 failed: %v", err) t.Fatalf("CloseIssue issue1 failed: %v", err)
} }
@@ -1546,7 +1546,7 @@ func TestConvoyReactiveCompletion(t *testing.T) {
} }
// Close second issue - convoy should auto-close now // 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 { if err != nil {
t.Fatalf("CloseIssue issue2 failed: %v", err) t.Fatalf("CloseIssue issue2 failed: %v", err)
} }
+1 -1
View File
@@ -105,7 +105,7 @@ func (e *testEnv) AddParentChild(child, parent *types.Issue) {
// Close closes the issue with the given reason. // Close closes the issue with the given reason.
func (e *testEnv) Close(issue *types.Issue, reason string) { func (e *testEnv) Close(issue *types.Issue, reason string) {
e.t.Helper() 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) e.t.Fatalf("CloseIssue(%s) failed: %v", issue.ID, err)
} }
} }
+1 -1
View File
@@ -72,7 +72,7 @@ func TestCreateTombstone(t *testing.T) {
} }
// Close the issue to set closed_at // 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) t.Fatalf("Failed to close issue: %v", err)
} }
+4 -3
View File
@@ -523,13 +523,14 @@ func applyUpdatesToIssue(issue *types.Issue, updates map[string]interface{}) {
// CloseIssue closes an issue within the transaction. // CloseIssue closes an issue within the transaction.
// NOTE: close_reason is stored in both issues table and events table - see SQLiteStorage.CloseIssue. // 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() now := time.Now()
result, err := t.conn.ExecContext(ctx, ` 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 = ? WHERE id = ?
`, types.StatusClosed, now, now, reason, id) `, types.StatusClosed, now, now, reason, session, id)
if err != nil { if err != nil {
return fmt.Errorf("failed to close issue: %w", err) return fmt.Errorf("failed to close issue: %w", err)
} }
+1 -1
View File
@@ -254,7 +254,7 @@ func TestTransactionCloseIssue(t *testing.T) {
// Close in transaction // Close in transaction
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { 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 { if err != nil {
+2 -2
View File
@@ -50,7 +50,7 @@ type Transaction interface {
CreateIssue(ctx context.Context, issue *types.Issue, actor string) error CreateIssue(ctx context.Context, issue *types.Issue, actor string) error
CreateIssues(ctx context.Context, issues []*types.Issue, actor string) error CreateIssues(ctx context.Context, issues []*types.Issue, actor string) error
UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
CloseIssue(ctx context.Context, id string, reason string, actor string) error CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error
DeleteIssue(ctx context.Context, id string) error DeleteIssue(ctx context.Context, id string) error
GetIssue(ctx context.Context, id string) (*types.Issue, error) // For read-your-writes within transaction GetIssue(ctx context.Context, id string) (*types.Issue, error) // For read-your-writes within transaction
SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) // For read-your-writes within transaction SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) // For read-your-writes within transaction
@@ -83,7 +83,7 @@ type Storage interface {
GetIssue(ctx context.Context, id string) (*types.Issue, error) GetIssue(ctx context.Context, id string) (*types.Issue, error)
GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error) GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error)
UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
CloseIssue(ctx context.Context, id string, reason string, actor string) error CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error
DeleteIssue(ctx context.Context, id string) error DeleteIssue(ctx context.Context, id string) error
SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error)
+2 -2
View File
@@ -35,7 +35,7 @@ func (m *mockStorage) GetIssueByExternalRef(ctx context.Context, externalRef str
func (m *mockStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error { func (m *mockStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
return nil return nil
} }
func (m *mockStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error { func (m *mockStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
return nil return nil
} }
func (m *mockStorage) DeleteIssue(ctx context.Context, id string) error { func (m *mockStorage) DeleteIssue(ctx context.Context, id string) error {
@@ -213,7 +213,7 @@ func (m *mockTransaction) CreateIssues(ctx context.Context, issues []*types.Issu
func (m *mockTransaction) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error { func (m *mockTransaction) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
return nil return nil
} }
func (m *mockTransaction) CloseIssue(ctx context.Context, id string, reason string, actor string) error { func (m *mockTransaction) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
return nil return nil
} }
func (m *mockTransaction) DeleteIssue(ctx context.Context, id string) error { func (m *mockTransaction) DeleteIssue(ctx context.Context, id string) error {
+3 -2
View File
@@ -36,8 +36,9 @@ type Issue struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"` // Who created this issue (GH#748) CreatedBy string `json:"created_by,omitempty"` // Who created this issue (GH#748)
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"` ClosedAt *time.Time `json:"closed_at,omitempty"`
CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing
ClosedBySession string `json:"closed_by_session,omitempty"` // Claude Code session that closed this issue
// ===== External Integration ===== // ===== External Integration =====
ExternalRef *string `json:"external_ref,omitempty"` // e.g., "gh-9", "jira-ABC" ExternalRef *string `json:"external_ref,omitempty"` // e.g., "gh-9", "jira-ABC"