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
@@ -9,7 +9,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "gt prime"
|
"command": "bash ~/.claude/hooks/session-start.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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] {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user