Files
beads/cmd/bd/status.go
Peter Chanthamynavong e110632afc fix(context): complete RepoContext migration for remaining sync files (#1114)
Follow-up to #1102 - migrates remaining git command locations to use
RepoContext API for correct repo resolution when BEADS_DIR is set.

Files migrated:
- sync_branch.go: getCurrentBranch, showSyncStatus, mergeSyncBranch
- sync_check.go: checkForcedPush
- sync_import.go: doSyncFromMain
- autoimport.go: readFromGitRef, checkGitForIssues
- status.go: getGitActivity
- import.go: attemptAutoMerge (gitRoot lookup)
- reinit_test.go: add ResetCaches for test isolation

Pattern used throughout:
- Try RepoContext first: rc.GitCmd() runs in beads repo
- Fallback to CWD for tests or repos without beads
- Graceful degradation maintains backwards compatibility
2026-01-15 19:23:07 -08:00

349 lines
10 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
// StatusOutput represents the complete status output
type StatusOutput struct {
Summary *types.Statistics `json:"summary"`
RecentActivity *RecentActivitySummary `json:"recent_activity,omitempty"`
}
// RecentActivitySummary represents activity from git history
type RecentActivitySummary struct {
HoursTracked int `json:"hours_tracked"`
CommitCount int `json:"commit_count"`
IssuesCreated int `json:"issues_created"`
IssuesClosed int `json:"issues_closed"`
IssuesUpdated int `json:"issues_updated"`
IssuesReopened int `json:"issues_reopened"`
TotalChanges int `json:"total_changes"`
}
var statusCmd = &cobra.Command{
Use: "status",
GroupID: "views",
Aliases: []string{"stats"},
Short: "Show issue database overview and statistics",
Long: `Show a quick snapshot of the issue database state and statistics.
This command provides a summary of issue counts by state (open, in_progress,
blocked, closed), ready work, extended statistics (tombstones, pinned issues,
average lead time), and recent activity over the last 24 hours from git history.
Similar to how 'git status' shows working tree state, 'bd status' gives you
a quick overview of your issue database without needing multiple queries.
Use cases:
- Quick project health check
- Onboarding for new contributors
- Integration with shell prompts or CI/CD
- Daily standup reference
Examples:
bd status # Show summary with activity
bd status --no-activity # Skip git activity (faster)
bd status --json # JSON format output
bd status --assigned # Show issues assigned to current user
bd stats # Alias for bd status`,
Run: func(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all")
showAssigned, _ := cmd.Flags().GetBool("assigned")
noActivity, _ := cmd.Flags().GetBool("no-activity")
jsonFormat, _ := cmd.Flags().GetBool("json")
// Override global jsonOutput if --json flag is set
if jsonFormat {
jsonOutput = true
}
// Get statistics
var stats *types.Statistics
var err error
// Check database freshness before reading (bd-2q6d, bd-c4rq)
// Skip check when using daemon (daemon auto-imports on staleness)
ctx := rootCtx
if daemonClient == nil {
if err := ensureDatabaseFresh(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
resp, rpcErr := daemonClient.Stats()
if rpcErr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", rpcErr)
os.Exit(1)
}
if err := json.Unmarshal(resp.Data, &stats); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
} else {
// Direct mode
ctx := rootCtx
stats, err = store.GetStatistics(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// Filter by assignee if requested (overrides stats with filtered counts)
if showAssigned {
stats = getAssignedStatistics(actor)
if stats == nil {
fmt.Fprintf(os.Stderr, "Error: failed to get assigned statistics\n")
os.Exit(1)
}
}
// Get recent activity from git history (last 24 hours) unless --no-activity
var recentActivity *RecentActivitySummary
if !noActivity {
recentActivity = getGitActivity(24)
}
output := &StatusOutput{
Summary: stats,
RecentActivity: recentActivity,
}
// JSON output
if jsonOutput {
outputJSON(output)
return
}
// Human-readable colorized output using semantic ui package
fmt.Printf("\n%s Issue Database Status\n\n", ui.RenderAccent("📊"))
fmt.Printf("Summary:\n")
fmt.Printf(" Total Issues: %d\n", stats.TotalIssues)
fmt.Printf(" Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf(" In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf(" Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
fmt.Printf(" Closed: %d\n", stats.ClosedIssues)
fmt.Printf(" Ready to Work: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
// Extended statistics (only show if non-zero)
hasExtended := stats.TombstoneIssues > 0 || stats.PinnedIssues > 0 ||
stats.EpicsEligibleForClosure > 0 || stats.AverageLeadTime > 0
if hasExtended {
fmt.Printf("\nExtended:\n")
if stats.TombstoneIssues > 0 {
fmt.Printf(" Deleted: %d (tombstones)\n", stats.TombstoneIssues)
}
if stats.PinnedIssues > 0 {
fmt.Printf(" Pinned: %d\n", stats.PinnedIssues)
}
if stats.EpicsEligibleForClosure > 0 {
fmt.Printf(" Epics Ready to Close: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure)))
}
if stats.AverageLeadTime > 0 {
fmt.Printf(" Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
}
if recentActivity != nil {
fmt.Printf("\nRecent Activity (last %d hours):\n", recentActivity.HoursTracked)
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
}
// Show hint for more details
fmt.Printf("\nFor more details, use 'bd list' to see individual issues.\n")
fmt.Println()
// Suppress showAll flag (it's the default behavior, included for CLI familiarity)
_ = showAll
},
}
// getGitActivity calculates activity stats from git log of issues.jsonl.
// GH#1110: Now uses RepoContext to ensure git commands run in beads repo.
func getGitActivity(hours int) *RecentActivitySummary {
activity := &RecentActivitySummary{
HoursTracked: hours,
}
// Run git log to get patches for the last N hours
since := fmt.Sprintf("%d hours ago", hours)
var cmd *exec.Cmd
if rc, err := beads.GetRepoContext(); err == nil {
cmd = rc.GitCmd(context.Background(), "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/issues.jsonl")
} else {
cmd = exec.Command("git", "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/issues.jsonl") // #nosec G204 -- bounded arguments for local git history inspection
}
output, err := cmd.Output()
if err != nil {
// Git log failed (might not be a git repo or no commits)
return nil
}
scanner := bufio.NewScanner(strings.NewReader(string(output)))
commitCount := 0
for scanner.Scan() {
line := scanner.Text()
// Empty lines separate commits
if line == "" {
continue
}
// Commit hash line
if !strings.Contains(line, "\t") {
commitCount++
continue
}
// numstat line format: "additions\tdeletions\tfilename"
parts := strings.Split(line, "\t")
if len(parts) < 3 {
continue
}
// For JSONL files, each added line is a new/updated issue
// We need to analyze the actual diff to understand what changed
}
// Get detailed diff to analyze changes
if rc, err := beads.GetRepoContext(); err == nil {
cmd = rc.GitCmd(context.Background(), "log", "--since="+since, "-p", ".beads/issues.jsonl")
} else {
cmd = exec.Command("git", "log", "--since="+since, "-p", ".beads/issues.jsonl") // #nosec G204 -- bounded arguments for local git history inspection
}
output, err = cmd.Output()
if err != nil {
return nil
}
scanner = bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
// Look for added lines in diff (lines starting with +)
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
continue
}
// Remove the + prefix
jsonLine := strings.TrimPrefix(line, "+")
// Skip empty lines
if strings.TrimSpace(jsonLine) == "" {
continue
}
// Try to parse as issue JSON
var issue types.Issue
if err := json.Unmarshal([]byte(jsonLine), &issue); err != nil {
continue
}
activity.TotalChanges++
// Analyze the change type based on timestamps and status
// Created recently if created_at is close to now
if time.Since(issue.CreatedAt) < time.Duration(hours)*time.Hour {
activity.IssuesCreated++
} else if issue.Status == types.StatusClosed && issue.ClosedAt != nil {
// Closed recently if closed_at is close to now
if time.Since(*issue.ClosedAt) < time.Duration(hours)*time.Hour {
activity.IssuesClosed++
} else {
activity.IssuesUpdated++
}
} else if issue.Status != types.StatusClosed {
// Check if this was a reopen (status changed from closed to open/in_progress)
// We'd need to look at the removed line to know for sure, but for now
// we'll just count it as an update
activity.IssuesUpdated++
}
}
activity.CommitCount = commitCount
return activity
}
// getAssignedStatistics returns statistics for issues assigned to a specific user
func getAssignedStatistics(assignee string) *types.Statistics {
if store == nil {
return nil
}
ctx := rootCtx
// Filter by assignee
assigneePtr := assignee
filter := types.IssueFilter{
Assignee: &assigneePtr,
}
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
return nil
}
stats := &types.Statistics{
TotalIssues: len(issues),
}
// Count by status
for _, issue := range issues {
switch issue.Status {
case types.StatusOpen:
stats.OpenIssues++
case types.StatusInProgress:
stats.InProgressIssues++
case types.StatusBlocked:
stats.BlockedIssues++
case types.StatusDeferred:
stats.DeferredIssues++
case types.StatusClosed:
stats.ClosedIssues++
}
}
// Get ready work count for this assignee
readyFilter := types.WorkFilter{
Assignee: &assigneePtr,
}
readyIssues, err := store.GetReadyWork(ctx, readyFilter)
if err == nil {
stats.ReadyIssues = len(readyIssues)
}
return stats
}
func init() {
statusCmd.Flags().Bool("all", false, "Show all issues (default behavior)")
statusCmd.Flags().Bool("assigned", false, "Show issues assigned to current user")
statusCmd.Flags().Bool("no-activity", false, "Skip git activity tracking (faster)")
// Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(statusCmd)
}