diff --git a/internal/actions/health.go b/internal/actions/health.go new file mode 100644 index 00000000..e71add23 --- /dev/null +++ b/internal/actions/health.go @@ -0,0 +1,501 @@ +package actions + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "stackit.dev/stackit/internal/app" + "stackit.dev/stackit/internal/engine" + "stackit.dev/stackit/internal/github" + "stackit.dev/stackit/internal/output" + "stackit.dev/stackit/internal/tui/style" +) + +// HealthOptions contains options for the health command +type HealthOptions struct { + JSON bool + Quiet bool +} + +// HealthReport contains the overall health status of the stack +type HealthReport struct { + Branches []BranchHealth `json:"branches"` + Recommendations []Recommendation `json:"recommendations"` + GitHubAvailable bool `json:"github_available"` +} + +// BranchHealth represents the health status of a single branch +type BranchHealth struct { + Name string `json:"name"` + Parent string `json:"parent"` + NeedsRestack bool `json:"needs_restack"` + CommitsBehind int `json:"commits_behind"` // Number of trunk commits the branch doesn't have + CI string `json:"ci"` // passing, failing, pending, unknown + CIError string `json:"ci_error,omitempty"` + PRStatus string `json:"pr_status"` // draft, open, approved, merged, closed, none + PRNumber *int `json:"pr_number,omitempty"` + PRURL string `json:"pr_url,omitempty"` + IsLocked bool `json:"is_locked"` + IsFrozen bool `json:"is_frozen"` +} + +// Recommendation represents a suggested action to improve stack health +type Recommendation struct { + Action string `json:"action"` + Reason string `json:"reason"` + Branch string `json:"branch,omitempty"` + Command string `json:"command,omitempty"` + Priority int `json:"priority"` // 1 = high, 2 = medium, 3 = low +} + +// CI status constants +const ( + CIStatusPassing = "passing" + CIStatusFailing = "failing" + CIStatusPending = "pending" + CIStatusUnknown = "unknown" +) + +// PR status constants +const ( + PRStatusDraft = "draft" + PRStatusOpen = "open" + PRStatusApproved = "approved" + PRStatusMerged = "merged" + PRStatusClosed = "closed" + PRStatusNone = "none" +) + +// StaleCommitThreshold is the number of commits behind trunk before a branch +// is considered "stale" and a sync recommendation is generated. +// This threshold balances being helpful (alerting users to old branches) without +// being noisy (in fast-moving repos, small drift is normal). 20 commits typically +// represents several days to a week of activity on an active trunk. +const StaleCommitThreshold = 20 + +// HealthAction analyzes the health of all tracked branches +func HealthAction(ctx *app.Context, opts HealthOptions) error { + eng := ctx.Engine + out := ctx.Output + + report := generateHealthReport(ctx, eng) + + if opts.JSON { + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + out.Info("%s", string(data)) + return nil + } + + // If quiet mode and no issues, output nothing + if opts.Quiet && len(report.Recommendations) == 0 { + return nil + } + + // Human-readable output + renderHealthReport(out, report) + return nil +} + +func generateHealthReport(ctx *app.Context, eng engine.Engine) *HealthReport { + report := &HealthReport{ + Branches: []BranchHealth{}, + Recommendations: []Recommendation{}, + GitHubAvailable: false, + } + + // Get all tracked branches + allBranches := eng.AllBranches() + var trackedBranches []engine.Branch + for _, branch := range allBranches { + if branch.IsTracked() && !branch.IsTrunk() { + trackedBranches = append(trackedBranches, branch) + } + } + + if len(trackedBranches) == 0 { + return report + } + + // Batch fetch PR/CI status from GitHub + branchNames := make([]string, len(trackedBranches)) + for i, branch := range trackedBranches { + branchNames[i] = branch.GetName() + } + + var checkStatuses map[string]*github.CheckStatus + if ctx.GitHubClient != nil { + owner, repo := ctx.GitHubClient.GetOwnerRepo() + if owner != "" && repo != "" { + report.GitHubAvailable = true + var err error + checkStatuses, err = ctx.GitHubClient.BatchGetPRChecksStatus(ctx.Context, branchNames) + if err != nil { + ctx.Output.Debug("Failed to fetch PR check statuses: %v", err) + } + } + } + + // Analyze each branch + for _, branch := range trackedBranches { + health := analyzeBranchHealth(eng, branch, checkStatuses) + report.Branches = append(report.Branches, health) + } + + // Sort branches by parent relationship (stack order) + sortBranchesByStackOrder(report.Branches) + + // Generate recommendations + report.Recommendations = generateRecommendations(report.Branches) + + return report +} + +func analyzeBranchHealth(eng engine.Engine, branch engine.Branch, checkStatuses map[string]*github.CheckStatus) BranchHealth { + branchName := branch.GetName() + health := BranchHealth{ + Name: branchName, + NeedsRestack: !branch.IsBranchUpToDate(), + IsLocked: branch.IsLocked(), + IsFrozen: branch.IsFrozen(), + CI: CIStatusUnknown, + PRStatus: PRStatusNone, + } + + // Get parent + parent := branch.GetParent() + if parent != nil { + health.Parent = parent.GetName() + } + + // Calculate commits behind trunk + health.CommitsBehind = calculateCommitsBehindTrunk(eng, branch) + + // Get PR info + prInfo, _ := branch.GetPrInfo() + if prInfo != nil && prInfo.Number() != nil { + prNum := *prInfo.Number() + health.PRNumber = &prNum + health.PRURL = prInfo.URL() + + // Determine PR status + switch prInfo.State() { + case "MERGED": + health.PRStatus = PRStatusMerged + case "CLOSED": + health.PRStatus = PRStatusClosed + default: + if prInfo.IsDraft() { + health.PRStatus = PRStatusDraft + } else { + health.PRStatus = PRStatusOpen + } + } + + // Check CI status from batch results + if checkStatuses != nil { + if status, ok := checkStatuses[branchName]; ok && status != nil { + health.CI = determineCIStatus(status) + if !status.Passing { + health.CIError = getFirstFailingCheck(status) + } + // Override PR status if approved + if status.IsApproved() && health.PRStatus == PRStatusOpen { + health.PRStatus = PRStatusApproved + } + } + } + } + + return health +} + +// calculateCommitsBehindTrunk returns the number of commits on trunk that the branch doesn't have. +// This measures how "stale" a branch is relative to trunk. +func calculateCommitsBehindTrunk(eng engine.Engine, branch engine.Branch) int { + trunk := eng.Trunk() + if trunk.GetName() == "" { + return 0 + } + branchName := branch.GetName() + trunkName := trunk.GetName() + + // Find merge base between branch and trunk + mergeBase, err := eng.GetMergeBase(branchName, trunkName) + if err != nil { + return 0 + } + + // Count commits from merge base to trunk (commits the branch doesn't have) + commits, err := eng.Git().GetCommitRangeSHAs(mergeBase, trunkName) + if err != nil { + return 0 + } + + return len(commits) +} + +func determineCIStatus(status *github.CheckStatus) string { + if status == nil { + return CIStatusUnknown + } + if status.Pending { + return CIStatusPending + } + if status.Passing { + return CIStatusPassing + } + return CIStatusFailing +} + +func getFirstFailingCheck(status *github.CheckStatus) string { + for _, check := range status.Checks { + if check.Conclusion == "FAILURE" || check.Conclusion == "TIMED_OUT" || check.Conclusion == "CANCELED" { + return check.Name + } + } + return "" +} + +func sortBranchesByStackOrder(branches []BranchHealth) { + // Build parent -> children map for ordering + childrenOf := make(map[string][]string) + for _, b := range branches { + if b.Parent != "" { + childrenOf[b.Parent] = append(childrenOf[b.Parent], b.Name) + } + } + + // Find roots (branches whose parent is trunk or not in our list) + branchSet := make(map[string]bool) + for _, b := range branches { + branchSet[b.Name] = true + } + + var roots []string + for _, b := range branches { + if b.Parent == "" || !branchSet[b.Parent] { + roots = append(roots, b.Name) + } + } + sort.Strings(roots) + + // DFS to get ordered list with cycle detection + var ordered []string + visited := make(map[string]bool) + var dfs func(name string) + dfs = func(name string) { + if visited[name] { + return // Prevent infinite loop on cycles + } + visited[name] = true + ordered = append(ordered, name) + children := childrenOf[name] + sort.Strings(children) + for _, child := range children { + dfs(child) + } + } + for _, root := range roots { + dfs(root) + } + + // Create name -> health map + healthMap := make(map[string]BranchHealth) + for _, b := range branches { + healthMap[b.Name] = b + } + + // Reorder branches slice in place, handling case where ordered may have + // fewer entries than branches (due to cycles or orphans in the graph) + reordered := make([]BranchHealth, 0, len(branches)) + for _, name := range ordered { + if h, ok := healthMap[name]; ok { + reordered = append(reordered, h) + delete(healthMap, name) + } + } + // Append any branches not reached by DFS (shouldn't happen, but be safe) + for _, h := range healthMap { + reordered = append(reordered, h) + } + copy(branches, reordered) +} + +func generateRecommendations(branches []BranchHealth) []Recommendation { + var recs []Recommendation + + // Check for branches that need restack + needsRestackCount := 0 + for _, b := range branches { + if b.NeedsRestack { + needsRestackCount++ + } + } + if needsRestackCount > 0 { + recs = append(recs, Recommendation{ + Action: "restack", + Reason: fmt.Sprintf("%d branch(es) need restacking", needsRestackCount), + Command: "stackit restack", + Priority: 2, + }) + } + + // Check for stale branches (more than StaleCommitThreshold commits behind trunk) + var staleBranches []string + maxBehind := 0 + for _, b := range branches { + if b.CommitsBehind > StaleCommitThreshold { + staleBranches = append(staleBranches, b.Name) + if b.CommitsBehind > maxBehind { + maxBehind = b.CommitsBehind + } + } + } + if len(staleBranches) > 0 { + reason := fmt.Sprintf("%d branch(es) are significantly behind trunk (up to %d commits)", len(staleBranches), maxBehind) + recs = append(recs, Recommendation{ + Action: "sync", + Reason: reason, + Command: "stackit sync", + Priority: 2, + }) + } + + // Check for failing CI + for _, b := range branches { + if b.CI == CIStatusFailing { + reason := fmt.Sprintf("%s has failing CI", b.Name) + if b.CIError != "" { + reason = fmt.Sprintf("%s: %s", reason, b.CIError) + } + recs = append(recs, Recommendation{ + Action: "fix_ci", + Reason: reason, + Branch: b.Name, + Priority: 1, + }) + } + } + + // Check for branches ready to merge + for _, b := range branches { + if b.PRStatus == PRStatusApproved && b.CI == CIStatusPassing { + recs = append(recs, Recommendation{ + Action: "merge", + Reason: fmt.Sprintf("%s is approved and CI passing", b.Name), + Branch: b.Name, + Command: fmt.Sprintf("stackit merge %s", b.Name), + Priority: 3, + }) + } + } + + // Check for branches without PRs + noPRCount := 0 + for _, b := range branches { + if b.PRStatus == PRStatusNone && !b.IsLocked && !b.IsFrozen { + noPRCount++ + } + } + if noPRCount > 0 { + recs = append(recs, Recommendation{ + Action: "submit", + Reason: fmt.Sprintf("%d branch(es) have no PR", noPRCount), + Command: "stackit submit", + Priority: 3, + }) + } + + // Sort by priority + sort.Slice(recs, func(i, j int) bool { + return recs[i].Priority < recs[j].Priority + }) + + return recs +} + +func renderHealthReport(out output.Output, report *HealthReport) { + out.Info("%s", style.ColorCyan("Stack Health Report")) + out.Info("%s", style.ColorDim("───────────────────")) + + if len(report.Branches) == 0 { + out.Info("No tracked branches found.") + return + } + + // Show branch health + for _, b := range report.Branches { + icon := style.IconCIPassing() // green dot + issues := []string{} + + if b.NeedsRestack { + icon = style.IconCIPending() // yellow dot + issues = append(issues, "needs restack") + } + switch b.CI { + case CIStatusFailing: + icon = style.IconCIFailing() // red dot + ciMsg := "CI failing" + if b.CIError != "" { + ciMsg = fmt.Sprintf("CI failing: %s", b.CIError) + } + issues = append(issues, ciMsg) + case CIStatusPending: + issues = append(issues, "CI pending") + } + if b.CommitsBehind > StaleCommitThreshold { + issues = append(issues, fmt.Sprintf("%d commits behind", b.CommitsBehind)) + } + if b.PRStatus == PRStatusApproved && b.CI == CIStatusPassing { + icon = style.IconReviewApproved() // green checkmark + issues = append(issues, "ready to merge") + } + + statusStr := "" + if len(issues) > 0 { + statusStr = fmt.Sprintf(" (%s)", strings.Join(issues, ", ")) + } + + prInfo := "" + if b.PRNumber != nil { + prInfo = fmt.Sprintf(" #%d", *b.PRNumber) + switch b.PRStatus { + case PRStatusDraft: + prInfo += " (draft)" + case PRStatusApproved: + prInfo += " (approved)" + } + } + + out.Info("%s %s%s%s", icon, style.ColorBranchName(b.Name, false), prInfo, statusStr) + } + + // Show recommendations + if len(report.Recommendations) > 0 { + out.Newline() + out.Info("%s", style.ColorCyan("Recommendations:")) + for _, rec := range report.Recommendations { + var icon string + switch rec.Priority { + case 1: + icon = style.IconCIFailing() // red dot for high priority + case 2: + icon = style.IconCIPending() // yellow dot for medium + default: + icon = style.IconInfo() // blue dot for low priority + } + out.Info("%s %s", icon, rec.Reason) + if rec.Command != "" { + out.Info(" %s %s", style.ColorDim("→"), style.ColorCyan(rec.Command)) + } + } + } else { + out.Newline() + out.Info("%s Stack is healthy!", style.IconCIPassing()) + } +} diff --git a/internal/actions/health_test.go b/internal/actions/health_test.go new file mode 100644 index 00000000..754e744a --- /dev/null +++ b/internal/actions/health_test.go @@ -0,0 +1,257 @@ +package actions + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSortBranchesByStackOrder(t *testing.T) { + t.Parallel() + + t.Run("sorts linear stack correctly", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "c", Parent: "b"}, + {Name: "a", Parent: "main"}, + {Name: "b", Parent: "a"}, + } + + sortBranchesByStackOrder(branches) + + require.Equal(t, "a", branches[0].Name) + require.Equal(t, "b", branches[1].Name) + require.Equal(t, "c", branches[2].Name) + }) + + t.Run("handles multiple roots", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "feature-b", Parent: "main"}, + {Name: "feature-a", Parent: "main"}, + {Name: "feature-a-child", Parent: "feature-a"}, + } + + sortBranchesByStackOrder(branches) + + // Roots should be sorted alphabetically, then children follow + require.Equal(t, "feature-a", branches[0].Name) + require.Equal(t, "feature-a-child", branches[1].Name) + require.Equal(t, "feature-b", branches[2].Name) + }) + + t.Run("handles cycle without infinite loop", func(t *testing.T) { + t.Parallel() + + // Create a cycle: a -> b -> c -> a + branches := []BranchHealth{ + {Name: "a", Parent: "c"}, + {Name: "b", Parent: "a"}, + {Name: "c", Parent: "b"}, + } + + // This should not hang - the visited set should prevent infinite loop + sortBranchesByStackOrder(branches) + + // All branches should be present (order may vary due to cycle) + names := make(map[string]bool) + for _, b := range branches { + names[b.Name] = true + } + require.True(t, names["a"]) + require.True(t, names["b"]) + require.True(t, names["c"]) + }) + + t.Run("handles self-referential parent", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "a", Parent: "a"}, // Self-referential + {Name: "b", Parent: "main"}, + } + + sortBranchesByStackOrder(branches) + + // Should not hang, all branches should be present + require.Len(t, branches, 2) + }) + + t.Run("handles empty input", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{} + sortBranchesByStackOrder(branches) + require.Empty(t, branches) + }) + + t.Run("handles orphan branches", func(t *testing.T) { + t.Parallel() + + // Branches with parents not in the list + branches := []BranchHealth{ + {Name: "orphan-a", Parent: "deleted-branch"}, + {Name: "orphan-b", Parent: "another-deleted"}, + {Name: "child-of-orphan", Parent: "orphan-a"}, + } + + sortBranchesByStackOrder(branches) + + // Orphans are treated as roots, sorted alphabetically + require.Equal(t, "orphan-a", branches[0].Name) + require.Equal(t, "child-of-orphan", branches[1].Name) + require.Equal(t, "orphan-b", branches[2].Name) + }) +} + +func TestGenerateRecommendations(t *testing.T) { + t.Parallel() + + t.Run("recommends restack when branches need it", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "a", NeedsRestack: true}, + {Name: "b", NeedsRestack: true}, + {Name: "c", NeedsRestack: false}, + } + + recs := generateRecommendations(branches) + + var restackRec *Recommendation + for i := range recs { + if recs[i].Action == "restack" { + restackRec = &recs[i] + break + } + } + require.NotNil(t, restackRec) + require.Contains(t, restackRec.Reason, "2 branch(es)") + require.Equal(t, "stackit restack", restackRec.Command) + }) + + t.Run("recommends sync for stale branches", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "a", CommitsBehind: StaleCommitThreshold + 1}, + {Name: "b", CommitsBehind: StaleCommitThreshold + 10}, + {Name: "c", CommitsBehind: 5}, // Not stale + } + + recs := generateRecommendations(branches) + + var syncRec *Recommendation + for i := range recs { + if recs[i].Action == "sync" { + syncRec = &recs[i] + break + } + } + require.NotNil(t, syncRec) + require.Contains(t, syncRec.Reason, "2 branch(es)") + require.Contains(t, syncRec.Reason, "30 commits") // max behind = StaleCommitThreshold + 10 + }) + + t.Run("recommends fix_ci for failing branches", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "a", CI: CIStatusFailing, CIError: "test-suite"}, + {Name: "b", CI: CIStatusPassing}, + } + + recs := generateRecommendations(branches) + + var ciRec *Recommendation + for i := range recs { + if recs[i].Action == "fix_ci" { + ciRec = &recs[i] + break + } + } + require.NotNil(t, ciRec) + require.Equal(t, "a", ciRec.Branch) + require.Contains(t, ciRec.Reason, "test-suite") + require.Equal(t, 1, ciRec.Priority) // High priority + }) + + t.Run("recommends merge for approved branches with passing CI", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "ready", PRStatus: PRStatusApproved, CI: CIStatusPassing}, + {Name: "not-ready", PRStatus: PRStatusOpen, CI: CIStatusPassing}, + } + + recs := generateRecommendations(branches) + + var mergeRec *Recommendation + for i := range recs { + if recs[i].Action == "merge" { + mergeRec = &recs[i] + break + } + } + require.NotNil(t, mergeRec) + require.Equal(t, "ready", mergeRec.Branch) + require.Equal(t, "stackit merge ready", mergeRec.Command) + }) + + t.Run("recommends submit for branches without PRs", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "no-pr", PRStatus: PRStatusNone}, + {Name: "has-pr", PRStatus: PRStatusOpen}, + {Name: "locked-no-pr", PRStatus: PRStatusNone, IsLocked: true}, // Should be excluded + } + + recs := generateRecommendations(branches) + + var submitRec *Recommendation + for i := range recs { + if recs[i].Action == "submit" { + submitRec = &recs[i] + break + } + } + require.NotNil(t, submitRec) + require.Contains(t, submitRec.Reason, "1 branch(es)") // Only no-pr, not locked + }) + + t.Run("sorts recommendations by priority", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "a", PRStatus: PRStatusNone}, // Priority 3 (submit) + {Name: "b", CI: CIStatusFailing}, // Priority 1 (fix_ci) + {Name: "c", NeedsRestack: true}, // Priority 2 (restack) + {Name: "d", PRStatus: PRStatusApproved, CI: CIStatusPassing}, // Priority 3 (merge) + {Name: "e", CommitsBehind: StaleCommitThreshold + 1}, // Priority 2 (sync) + } + + recs := generateRecommendations(branches) + + // First should be high priority (1) + require.Equal(t, 1, recs[0].Priority) + // Verify overall ordering + for i := 1; i < len(recs); i++ { + require.GreaterOrEqual(t, recs[i].Priority, recs[i-1].Priority) + } + }) + + t.Run("returns empty for healthy stack", func(t *testing.T) { + t.Parallel() + + branches := []BranchHealth{ + {Name: "a", PRStatus: PRStatusOpen, CI: CIStatusPassing, NeedsRestack: false, CommitsBehind: 5}, + {Name: "b", PRStatus: PRStatusOpen, CI: CIStatusPassing, NeedsRestack: false, CommitsBehind: 3}, + } + + recs := generateRecommendations(branches) + require.Empty(t, recs) + }) +} diff --git a/internal/cli/health.go b/internal/cli/health.go new file mode 100644 index 00000000..8e3b7cc4 --- /dev/null +++ b/internal/cli/health.go @@ -0,0 +1,49 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "stackit.dev/stackit/internal/actions" + "stackit.dev/stackit/internal/app" + "stackit.dev/stackit/internal/cli/common" +) + +// newHealthCmd creates the health command +func newHealthCmd() *cobra.Command { + var ( + jsonOutput bool + quiet bool + ) + + cmd := &cobra.Command{ + Use: "health", + Short: "Check the health status of your stack", + Long: `Analyze the health of all tracked branches in your stack. + +Reports on: +- Branches that need restacking +- CI status (passing, failing, pending) +- PR review status (draft, open, approved, merged) +- Branches that are falling behind trunk +- Recommendations for improving stack health + +Examples: + stackit health # Show human-readable health report + stackit health --json # Output health report as JSON (for scripts/tools) + stackit health --quiet # Only output if there are issues`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return common.Run(cmd, func(ctx *app.Context) error { + return actions.HealthAction(ctx, actions.HealthOptions{ + JSON: jsonOutput, + Quiet: quiet, + }) + }) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output health report as JSON") + cmd.Flags().BoolVar(&quiet, "quiet", false, "Only output if there are health issues") + + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0eeb9088..666e6cc9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -88,6 +88,7 @@ Commit: ` + commit + ` rootCmd.AddCommand(branch.NewDeleteCmd()) rootCmd.AddCommand(newDoctorCmd()) rootCmd.AddCommand(navigation.NewDownCmd()) + rootCmd.AddCommand(newHealthCmd()) rootCmd.AddCommand(branch.NewFoldCmd()) rootCmd.AddCommand(stack.NewForeachCmd()) rootCmd.AddCommand(branch.NewFreezeCmd()) diff --git a/internal/tui/style/formatter.go b/internal/tui/style/formatter.go index 6139befe..ad396e5a 100644 --- a/internal/tui/style/formatter.go +++ b/internal/tui/style/formatter.go @@ -226,6 +226,11 @@ func IconLocked() string { return lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("🔒") } +// IconInfo returns a blue dot for informational/low-priority items +func IconInfo() string { + return lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Render("●") +} + // ColorPRNumberByState colors PR number based on state func ColorPRNumberByState(prNumber int, state string, isDraft bool) string { prefix := fmt.Sprintf("#%d", prNumber)