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
349 lines
10 KiB
Go
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)
|
|
}
|