diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 8e4ba536..a3023ea5 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -123,6 +123,9 @@ var showCmd = &cobra.Command{ fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) + if issue.CloseReason != "" { + fmt.Printf("Close reason: %s\n", issue.CloseReason) + } fmt.Printf("Priority: P%d\n", issue.Priority) fmt.Printf("Type: %s\n", issue.IssueType) if issue.Assignee != "" { @@ -264,6 +267,9 @@ var showCmd = &cobra.Command{ fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) + if issue.CloseReason != "" { + fmt.Printf("Close reason: %s\n", issue.CloseReason) + } fmt.Printf("Priority: P%d\n", issue.Priority) fmt.Printf("Type: %s\n", issue.IssueType) if issue.Assignee != "" { diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index ca450fc4..98598016 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -736,6 +736,19 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type } } + // Third pass: batch-load close reasons for closed issues + closeReasonsMap, err := s.GetCloseReasonsForIssues(ctx, issueIDs) + if err != nil { + return nil, fmt.Errorf("failed to batch get close reasons: %w", err) + } + + // Assign close reasons to issues + for _, issue := range issues { + if reason, ok := closeReasonsMap[issue.ID]; ok { + issue.CloseReason = reason + } + } + return issues, nil } diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 3a2c36e4..07642de7 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -212,9 +212,93 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, } issue.Labels = labels + // Fetch close reason if issue is closed + if issue.Status == types.StatusClosed { + closeReason, err := s.GetCloseReason(ctx, issue.ID) + if err != nil { + return nil, fmt.Errorf("failed to get close reason: %w", err) + } + issue.CloseReason = closeReason + } + return &issue, nil } +// GetCloseReason retrieves the close reason from the most recent closed event for an issue +func (s *SQLiteStorage) GetCloseReason(ctx context.Context, issueID string) (string, error) { + var comment sql.NullString + err := s.db.QueryRowContext(ctx, ` + SELECT comment FROM events + WHERE issue_id = ? AND event_type = ? + ORDER BY created_at DESC + LIMIT 1 + `, issueID, types.EventClosed).Scan(&comment) + + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to get close reason: %w", err) + } + if comment.Valid { + return comment.String, nil + } + return "", nil +} + +// GetCloseReasonsForIssues retrieves close reasons for multiple issues in a single query +func (s *SQLiteStorage) GetCloseReasonsForIssues(ctx context.Context, issueIDs []string) (map[string]string, error) { + result := make(map[string]string) + if len(issueIDs) == 0 { + return result, nil + } + + // Build placeholders for IN clause + placeholders := make([]string, len(issueIDs)) + args := make([]interface{}, len(issueIDs)+1) + args[0] = types.EventClosed + for i, id := range issueIDs { + placeholders[i] = "?" + args[i+1] = id + } + + // Use a subquery to get the most recent closed event for each issue + // #nosec G201 - safe SQL with controlled formatting + query := fmt.Sprintf(` + SELECT e.issue_id, e.comment + FROM events e + INNER JOIN ( + SELECT issue_id, MAX(created_at) as max_created_at + FROM events + WHERE event_type = ? AND issue_id IN (%s) + GROUP BY issue_id + ) latest ON e.issue_id = latest.issue_id AND e.created_at = latest.max_created_at + WHERE e.event_type = ? + `, strings.Join(placeholders, ", ")) + + // Append event_type again for the outer WHERE clause + args = append(args, types.EventClosed) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to get close reasons: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var issueID string + var comment sql.NullString + if err := rows.Scan(&issueID, &comment); err != nil { + return nil, fmt.Errorf("failed to scan close reason: %w", err) + } + if comment.Valid && comment.String != "" { + result[issueID] = comment.String + } + } + + return result, nil +} + // GetIssueByExternalRef retrieves an issue by external reference func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error) { var issue types.Issue @@ -282,6 +366,15 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s } issue.Labels = labels + // Fetch close reason if issue is closed + if issue.Status == types.StatusClosed { + closeReason, err := s.GetCloseReason(ctx, issue.ID) + if err != nil { + return nil, fmt.Errorf("failed to get close reason: %w", err) + } + issue.CloseReason = closeReason + } + return &issue, nil } diff --git a/internal/types/types.go b/internal/types/types.go index 93d9597d..d2672afd 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -24,6 +24,7 @@ type Issue struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ClosedAt *time.Time `json:"closed_at,omitempty"` + CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing the issue ExternalRef *string `json:"external_ref,omitempty"` // e.g., "gh-9", "jira-ABC" CompactionLevel int `json:"compaction_level,omitempty"` CompactedAt *time.Time `json:"compacted_at,omitempty"`