From 464cfd060ca4af3870c9b3e4bdb881a2396c7122 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:46:59 +0000 Subject: [PATCH 1/5] Initial plan From d5780729a18b04a1ee5dced5fac6ac06c38c8a0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:57:12 +0000 Subject: [PATCH 2/5] Implement health command with metrics and tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- cmd/gh-aw/main.go | 3 + pkg/cli/health_command.go | 304 +++++++++++++++++++++++++++++++++ pkg/cli/health_command_test.go | 85 +++++++++ pkg/cli/health_metrics.go | 248 +++++++++++++++++++++++++++ pkg/cli/health_metrics_test.go | 288 +++++++++++++++++++++++++++++++ 5 files changed, 928 insertions(+) create mode 100644 pkg/cli/health_command.go create mode 100644 pkg/cli/health_command_test.go create mode 100644 pkg/cli/health_metrics.go create mode 100644 pkg/cli/health_metrics_test.go diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 434755dcad..d98580f62e 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -548,6 +548,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all mcpCmd := cli.NewMCPCommand() logsCmd := cli.NewLogsCommand() auditCmd := cli.NewAuditCommand() + healthCmd := cli.NewHealthCommand() mcpServerCmd := cli.NewMCPServerCommand() prCmd := cli.NewPRCommand() campaignCmd := campaign.NewCommand() @@ -582,6 +583,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all // Analysis Commands logsCmd.GroupID = "analysis" auditCmd.GroupID = "analysis" + healthCmd.GroupID = "analysis" campaignCmd.GroupID = "analysis" // Utilities @@ -607,6 +609,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all rootCmd.AddCommand(disableCmd) rootCmd.AddCommand(logsCmd) rootCmd.AddCommand(auditCmd) + rootCmd.AddCommand(healthCmd) rootCmd.AddCommand(mcpCmd) rootCmd.AddCommand(mcpServerCmd) rootCmd.AddCommand(prCmd) diff --git a/pkg/cli/health_command.go b/pkg/cli/health_command.go new file mode 100644 index 0000000000..2be0396782 --- /dev/null +++ b/pkg/cli/health_command.go @@ -0,0 +1,304 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/spf13/cobra" +) + +var healthLog = logger.New("cli:health") + +// HealthConfig holds configuration for health command execution +type HealthConfig struct { + WorkflowName string + Days int + Threshold float64 + Verbose bool + JSONOutput bool + RepoOverride string +} + +// NewHealthCommand creates the health command +func NewHealthCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "health [workflow]", + Short: "Display workflow health metrics and success rates", + Long: `Display workflow health metrics, success rates, and execution trends. + +Shows health metrics for workflows including: +- Success/failure rates over time period +- Trend indicators (↑ improving, → stable, ↓ degrading) +- Average execution duration +- Alerts when success rate drops below threshold + +When called without a workflow name, displays summary for all workflows. +When called with a specific workflow name, displays detailed metrics for that workflow. + +` + WorkflowIDExplanation + ` + +Examples: + ` + string(constants.CLIExtensionPrefix) + ` health # Summary of all workflows (last 7 days) + ` + string(constants.CLIExtensionPrefix) + ` health issue-monster # Detailed metrics for specific workflow + ` + string(constants.CLIExtensionPrefix) + ` health --days 30 # Summary for last 30 days + ` + string(constants.CLIExtensionPrefix) + ` health --threshold 90 # Alert if below 90% success rate + ` + string(constants.CLIExtensionPrefix) + ` health --json # Output in JSON format + ` + string(constants.CLIExtensionPrefix) + ` health issue-monster --days 90 # 90-day metrics for workflow`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + days, _ := cmd.Flags().GetInt("days") + threshold, _ := cmd.Flags().GetFloat64("threshold") + verbose, _ := cmd.Flags().GetBool("verbose") + jsonOutput, _ := cmd.Flags().GetBool("json") + repoOverride, _ := cmd.Flags().GetString("repo") + + var workflowName string + if len(args) > 0 { + workflowName = args[0] + } + + config := HealthConfig{ + WorkflowName: workflowName, + Days: days, + Threshold: threshold, + Verbose: verbose, + JSONOutput: jsonOutput, + RepoOverride: repoOverride, + } + + return RunHealth(config) + }, + } + + // Add flags + cmd.Flags().Int("days", 7, "Number of days to analyze (7, 30, or 90)") + cmd.Flags().Float64("threshold", 80.0, "Success rate threshold for warnings (percentage)") + addRepoFlag(cmd) + addJSONFlag(cmd) + + // Register completions + cmd.ValidArgsFunction = CompleteWorkflowNames + + return cmd +} + +// RunHealth executes the health command with the given configuration +func RunHealth(config HealthConfig) error { + healthLog.Printf("Running health check: workflow=%s, days=%d, threshold=%.1f", config.WorkflowName, config.Days, config.Threshold) + + // Validate days parameter + if config.Days != 7 && config.Days != 30 && config.Days != 90 { + return fmt.Errorf("invalid days value: %d. Must be 7, 30, or 90", config.Days) + } + + // Calculate start date + startDate := time.Now().AddDate(0, 0, -config.Days).Format("2006-01-02") + + if config.Verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching workflow runs since %s", startDate))) + } + + // Fetch workflow runs from GitHub + runs, err := fetchWorkflowRuns(config.WorkflowName, startDate, config.RepoOverride, config.Verbose) + if err != nil { + return fmt.Errorf("failed to fetch workflow runs: %w", err) + } + + if len(runs) == 0 { + if config.WorkflowName != "" { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No runs found for workflow '%s' in the last %d days", config.WorkflowName, config.Days))) + } else { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflow runs found in the last %d days", config.Days))) + } + return nil + } + + if config.WorkflowName != "" { + // Detailed view for specific workflow + return displayDetailedHealth(runs, config) + } + + // Summary view for all workflows + return displayHealthSummary(runs, config) +} + +// fetchWorkflowRuns fetches workflow runs from GitHub for the specified time period +func fetchWorkflowRuns(workflowName, startDate, repoOverride string, verbose bool) ([]WorkflowRun, error) { + healthLog.Printf("Fetching workflow runs: workflow=%s, startDate=%s", workflowName, startDate) + + opts := ListWorkflowRunsOptions{ + WorkflowName: workflowName, + StartDate: startDate, + Limit: 100, + RepoOverride: repoOverride, + Verbose: verbose, + } + + allRuns := make([]WorkflowRun, 0) + + // Fetch runs in batches + for i := 0; i < MaxIterations; i++ { + runs, totalCount, err := listWorkflowRunsWithPagination(opts) + if err != nil { + return nil, err + } + + if len(runs) == 0 { + break + } + + // Filter to only agentic workflow runs (those ending in .lock.yml) + for _, run := range runs { + if strings.HasSuffix(run.WorkflowPath, ".lock.yml") { + // Calculate duration if not set + if run.Duration == 0 && !run.StartedAt.IsZero() && !run.UpdatedAt.IsZero() { + run.Duration = run.UpdatedAt.Sub(run.StartedAt) + } + allRuns = append(allRuns, run) + } + } + + healthLog.Printf("Fetched batch %d: got %d runs, total agentic runs so far: %d", i+1, len(runs), len(allRuns)) + + // If we got fewer runs than requested, we've reached the end + if len(runs) < opts.Limit { + break + } + + // Update pagination for next batch + if len(runs) > 0 { + lastRun := runs[len(runs)-1] + opts.BeforeDate = lastRun.CreatedAt.Format(time.RFC3339) + } + + // Avoid fetching more than necessary + if totalCount > 0 && len(allRuns) >= totalCount { + break + } + } + + healthLog.Printf("Total workflow runs fetched: %d", len(allRuns)) + return allRuns, nil +} + +// displayHealthSummary displays a summary of health metrics for all workflows +func displayHealthSummary(runs []WorkflowRun, config HealthConfig) error { + healthLog.Printf("Displaying health summary: %d runs", len(runs)) + + // Group runs by workflow + groupedRuns := GroupRunsByWorkflow(runs) + + // Calculate health for each workflow + workflowHealths := make([]WorkflowHealth, 0, len(groupedRuns)) + for workflowName, workflowRuns := range groupedRuns { + health := CalculateWorkflowHealth(workflowName, workflowRuns, config.Threshold) + workflowHealths = append(workflowHealths, health) + } + + // Sort by success rate (lowest first to highlight issues) + // Using a simple bubble sort for simplicity + for i := 0; i < len(workflowHealths); i++ { + for j := i + 1; j < len(workflowHealths); j++ { + if workflowHealths[i].SuccessRate > workflowHealths[j].SuccessRate { + workflowHealths[i], workflowHealths[j] = workflowHealths[j], workflowHealths[i] + } + } + } + + // Calculate summary + summary := CalculateHealthSummary(workflowHealths, fmt.Sprintf("Last %d Days", config.Days), config.Threshold) + + // Output results + if config.JSONOutput { + return outputHealthJSON(summary) + } + + return outputHealthTable(summary, config.Threshold) +} + +// displayDetailedHealth displays detailed health metrics for a specific workflow +func displayDetailedHealth(runs []WorkflowRun, config HealthConfig) error { + healthLog.Printf("Displaying detailed health: workflow=%s, %d runs", config.WorkflowName, len(runs)) + + // Calculate health metrics + health := CalculateWorkflowHealth(config.WorkflowName, runs, config.Threshold) + + // Output results + if config.JSONOutput { + jsonBytes, err := json.MarshalIndent(health, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + // Display detailed table + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow Health: %s (Last %d Days)", config.WorkflowName, config.Days))) + fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Fprintln(os.Stderr, "") + + // Create detailed view + type DetailedHealth struct { + Metric string `console:"header:Metric"` + Value string `console:"header:Value"` + } + + details := []DetailedHealth{ + {"Total Runs", fmt.Sprintf("%d", health.TotalRuns)}, + {"Successful", fmt.Sprintf("%d", health.SuccessCount)}, + {"Failed", fmt.Sprintf("%d", health.FailureCount)}, + {"Success Rate", health.DisplayRate}, + {"Trend", health.Trend}, + {"Avg Duration", health.DisplayDur}, + } + + fmt.Fprint(os.Stderr, console.RenderStruct(details)) + fmt.Fprintln(os.Stderr, "") + + // Display warning if below threshold + if health.BelowThresh { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("⚠️ Success rate (%.1f%%) is below threshold (%.1f%%)", health.SuccessRate, config.Threshold))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Success rate (%.1f%%) is above threshold (%.1f%%)", health.SuccessRate, config.Threshold))) + } + + return nil +} + +// outputHealthJSON outputs health summary in JSON format +func outputHealthJSON(summary HealthSummary) error { + jsonBytes, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} + +// outputHealthTable outputs health summary as a formatted table +func outputHealthTable(summary HealthSummary, threshold float64) error { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow Health Summary (%s)", summary.Period))) + fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Fprintln(os.Stderr, "") + + // Render table + fmt.Fprint(os.Stderr, console.RenderStruct(summary.Workflows)) + fmt.Fprintln(os.Stderr, "") + + // Display summary message + if summary.BelowThreshold > 0 { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("⚠️ %d workflow(s) below %.0f%% success threshold", summary.BelowThreshold, threshold))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("ℹ Run '%s health ' for details", string(constants.CLIExtensionPrefix)))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ All workflows above %.0f%% success threshold", threshold))) + } + + return nil +} diff --git a/pkg/cli/health_command_test.go b/pkg/cli/health_command_test.go new file mode 100644 index 0000000000..adb6532549 --- /dev/null +++ b/pkg/cli/health_command_test.go @@ -0,0 +1,85 @@ +//go:build !integration + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHealthConfigValidation(t *testing.T) { + tests := []struct { + name string + config HealthConfig + shouldErr bool + }{ + { + name: "valid 7 days", + config: HealthConfig{ + Days: 7, + Threshold: 80.0, + }, + shouldErr: false, + }, + { + name: "valid 30 days", + config: HealthConfig{ + Days: 30, + Threshold: 80.0, + }, + shouldErr: false, + }, + { + name: "valid 90 days", + config: HealthConfig{ + Days: 90, + Threshold: 80.0, + }, + shouldErr: false, + }, + { + name: "invalid days value", + config: HealthConfig{ + Days: 15, + Threshold: 80.0, + }, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For now, just validate the days parameter directly + // since the full RunHealth needs GitHub API access + if tt.config.Days != 7 && tt.config.Days != 30 && tt.config.Days != 90 { + assert.True(t, tt.shouldErr, "Should error for invalid days value") + } else { + assert.False(t, tt.shouldErr, "Should not error for valid days value") + } + }) + } +} + +func TestHealthCommand(t *testing.T) { + cmd := NewHealthCommand() + + assert.NotNil(t, cmd, "Health command should be created") + assert.Equal(t, "health", cmd.Name(), "Command name should be 'health'") + assert.True(t, cmd.HasAvailableFlags(), "Command should have flags") + + // Check that required flags are registered + daysFlag := cmd.Flags().Lookup("days") + assert.NotNil(t, daysFlag, "Should have --days flag") + assert.Equal(t, "7", daysFlag.DefValue, "Default days should be 7") + + thresholdFlag := cmd.Flags().Lookup("threshold") + assert.NotNil(t, thresholdFlag, "Should have --threshold flag") + assert.Equal(t, "80", thresholdFlag.DefValue, "Default threshold should be 80") + + jsonFlag := cmd.Flags().Lookup("json") + assert.NotNil(t, jsonFlag, "Should have --json flag") + + repoFlag := cmd.Flags().Lookup("repo") + assert.NotNil(t, repoFlag, "Should have --repo flag") +} diff --git a/pkg/cli/health_metrics.go b/pkg/cli/health_metrics.go new file mode 100644 index 0000000000..e20b9a21b1 --- /dev/null +++ b/pkg/cli/health_metrics.go @@ -0,0 +1,248 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var healthMetricsLog = logger.New("cli:health_metrics") + +// WorkflowHealth represents health metrics for a single workflow +type WorkflowHealth struct { + WorkflowName string `json:"workflow_name" console:"header:Workflow"` + TotalRuns int `json:"total_runs" console:"-"` + SuccessCount int `json:"success_count" console:"-"` + FailureCount int `json:"failure_count" console:"-"` + SuccessRate float64 `json:"success_rate" console:"-"` + DisplayRate string `json:"-" console:"header:Success Rate"` + Trend string `json:"trend" console:"header:Trend"` + AvgDuration time.Duration `json:"avg_duration" console:"-"` + DisplayDur string `json:"-" console:"header:Avg Duration"` + BelowThresh bool `json:"below_threshold" console:"-"` +} + +// HealthSummary represents aggregated health metrics across all workflows +type HealthSummary struct { + Period string `json:"period"` + TotalWorkflows int `json:"total_workflows"` + HealthyWorkflows int `json:"healthy_workflows"` + Workflows []WorkflowHealth `json:"workflows"` + BelowThreshold int `json:"below_threshold"` +} + +// TrendDirection represents the trend of a workflow's health +type TrendDirection int + +const ( + TrendImproving TrendDirection = iota + TrendStable + TrendDegrading +) + +// String returns the visual indicator for the trend +func (t TrendDirection) String() string { + switch t { + case TrendImproving: + return "↑" + case TrendStable: + return "→" + case TrendDegrading: + return "↓" + default: + return "?" + } +} + +// CalculateWorkflowHealth calculates health metrics for a workflow from its runs +func CalculateWorkflowHealth(workflowName string, runs []WorkflowRun, threshold float64) WorkflowHealth { + healthMetricsLog.Printf("Calculating health for workflow: %s, runs: %d", workflowName, len(runs)) + + if len(runs) == 0 { + return WorkflowHealth{ + WorkflowName: workflowName, + DisplayRate: "N/A", + Trend: "→", + DisplayDur: "N/A", + } + } + + // Calculate success and failure counts + successCount := 0 + failureCount := 0 + var totalDuration time.Duration + + for _, run := range runs { + if run.Conclusion == "success" { + successCount++ + } else if isFailureConclusion(run.Conclusion) { + failureCount++ + } + totalDuration += run.Duration + } + + totalRuns := len(runs) + successRate := 0.0 + if totalRuns > 0 { + successRate = float64(successCount) / float64(totalRuns) * 100 + } + + // Calculate average duration + avgDuration := time.Duration(0) + if totalRuns > 0 { + avgDuration = totalDuration / time.Duration(totalRuns) + } + + // Calculate trend + trend := calculateTrend(runs) + + // Format display values + displayRate := fmt.Sprintf("%.0f%% (%d/%d)", successRate, successCount, totalRuns) + displayDur := formatDuration(avgDuration) + + belowThreshold := successRate < threshold + + health := WorkflowHealth{ + WorkflowName: workflowName, + TotalRuns: totalRuns, + SuccessCount: successCount, + FailureCount: failureCount, + SuccessRate: successRate, + DisplayRate: displayRate, + Trend: trend.String(), + AvgDuration: avgDuration, + DisplayDur: displayDur, + BelowThresh: belowThreshold, + } + + healthMetricsLog.Printf("Health calculated: workflow=%s, successRate=%.2f%%, trend=%s", workflowName, successRate, trend.String()) + + return health +} + +// calculateTrend determines the trend direction based on recent vs older runs +func calculateTrend(runs []WorkflowRun) TrendDirection { + if len(runs) < 4 { + // Not enough data to determine trend + return TrendStable + } + + // Split runs into two halves: recent and older + midpoint := len(runs) / 2 + recentRuns := runs[:midpoint] + olderRuns := runs[midpoint:] + + // Calculate success rates for each half + recentSuccess := calculateSuccessRate(recentRuns) + olderSuccess := calculateSuccessRate(olderRuns) + + // Determine trend based on difference + diff := recentSuccess - olderSuccess + + const improvementThreshold = 5.0 // 5% improvement + const degradationThreshold = -5.0 // 5% degradation + + if diff >= improvementThreshold { + return TrendImproving + } else if diff <= degradationThreshold { + return TrendDegrading + } + return TrendStable +} + +// calculateSuccessRate calculates the success rate for a set of runs +func calculateSuccessRate(runs []WorkflowRun) float64 { + if len(runs) == 0 { + return 0.0 + } + + successCount := 0 + for _, run := range runs { + if run.Conclusion == "success" { + successCount++ + } + } + + return float64(successCount) / float64(len(runs)) * 100 +} + +// formatDuration formats a duration in a human-readable format +func formatDuration(d time.Duration) string { + if d == 0 { + return "0s" + } + + // Round to seconds + seconds := int(d.Seconds()) + if seconds < 60 { + return fmt.Sprintf("%ds", seconds) + } + + minutes := seconds / 60 + remainingSeconds := seconds % 60 + + if minutes < 60 { + if remainingSeconds > 0 { + return fmt.Sprintf("%dm %ds", minutes, remainingSeconds) + } + return fmt.Sprintf("%dm", minutes) + } + + hours := minutes / 60 + remainingMinutes := minutes % 60 + + if remainingMinutes > 0 { + return fmt.Sprintf("%dh %dm", hours, remainingMinutes) + } + return fmt.Sprintf("%dh", hours) +} + +// CalculateHealthSummary calculates aggregated health metrics across all workflows +func CalculateHealthSummary(workflowHealths []WorkflowHealth, period string, threshold float64) HealthSummary { + healthMetricsLog.Printf("Calculating health summary: workflows=%d, period=%s", len(workflowHealths), period) + + healthyCount := 0 + belowThresholdCount := 0 + + for _, wh := range workflowHealths { + if wh.SuccessRate >= threshold { + healthyCount++ + } + if wh.BelowThresh { + belowThresholdCount++ + } + } + + summary := HealthSummary{ + Period: period, + TotalWorkflows: len(workflowHealths), + HealthyWorkflows: healthyCount, + Workflows: workflowHealths, + BelowThreshold: belowThresholdCount, + } + + healthMetricsLog.Printf("Health summary: total=%d, healthy=%d, below_threshold=%d", len(workflowHealths), healthyCount, belowThresholdCount) + + return summary +} + +// FilterWorkflowsByName filters workflow runs by workflow name +func FilterWorkflowsByName(runs []WorkflowRun, workflowName string) []WorkflowRun { + filtered := make([]WorkflowRun, 0) + for _, run := range runs { + if run.WorkflowName == workflowName { + filtered = append(filtered, run) + } + } + return filtered +} + +// GroupRunsByWorkflow groups workflow runs by workflow name +func GroupRunsByWorkflow(runs []WorkflowRun) map[string][]WorkflowRun { + grouped := make(map[string][]WorkflowRun) + for _, run := range runs { + grouped[run.WorkflowName] = append(grouped[run.WorkflowName], run) + } + return grouped +} diff --git a/pkg/cli/health_metrics_test.go b/pkg/cli/health_metrics_test.go new file mode 100644 index 0000000000..cf76383cbd --- /dev/null +++ b/pkg/cli/health_metrics_test.go @@ -0,0 +1,288 @@ +//go:build !integration + +package cli + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCalculateWorkflowHealth(t *testing.T) { + tests := []struct { + name string + workflowName string + runs []WorkflowRun + threshold float64 + expectedRate float64 + expectedTrend string + }{ + { + name: "all successful runs", + workflowName: "test-workflow", + runs: []WorkflowRun{ + {Conclusion: "success", Duration: 2 * time.Minute}, + {Conclusion: "success", Duration: 3 * time.Minute}, + {Conclusion: "success", Duration: 2 * time.Minute}, + }, + threshold: 80.0, + expectedRate: 100.0, + expectedTrend: "→", + }, + { + name: "mixed success and failure", + workflowName: "test-workflow", + runs: []WorkflowRun{ + {Conclusion: "success", Duration: 2 * time.Minute}, + {Conclusion: "failure", Duration: 1 * time.Minute}, + {Conclusion: "success", Duration: 3 * time.Minute}, + {Conclusion: "success", Duration: 2 * time.Minute}, + }, + threshold: 80.0, + expectedRate: 75.0, + // Don't check trend for small dataset + }, + { + name: "all failed runs", + workflowName: "test-workflow", + runs: []WorkflowRun{ + {Conclusion: "failure", Duration: 1 * time.Minute}, + {Conclusion: "failure", Duration: 2 * time.Minute}, + }, + threshold: 80.0, + expectedRate: 0.0, + expectedTrend: "→", + }, + { + name: "empty runs", + workflowName: "test-workflow", + runs: []WorkflowRun{}, + threshold: 80.0, + expectedRate: 0.0, + // Empty runs should not be checked for below threshold + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + health := CalculateWorkflowHealth(tt.workflowName, tt.runs, tt.threshold) + + assert.Equal(t, tt.workflowName, health.WorkflowName, "Workflow name should match") + + // Use InEpsilon for non-zero values, Equal for zero + if tt.expectedRate == 0 { + assert.Equal(t, tt.expectedRate, health.SuccessRate, "Success rate should match") + } else { + assert.InEpsilon(t, tt.expectedRate, health.SuccessRate, 0.01, "Success rate should match") + } + + if tt.expectedTrend != "" { + assert.Equal(t, tt.expectedTrend, health.Trend, "Trend should match") + } + + if len(tt.runs) > 0 { + assert.Equal(t, len(tt.runs), health.TotalRuns, "Total runs should match") + } + + // Check below threshold flag + if len(tt.runs) > 0 && tt.expectedRate < tt.threshold { + assert.True(t, health.BelowThresh, "Should be marked as below threshold") + } else if len(tt.runs) > 0 { + assert.False(t, health.BelowThresh, "Should not be marked as below threshold") + } + }) + } +} + +func TestCalculateTrend(t *testing.T) { + tests := []struct { + name string + runs []WorkflowRun + expected TrendDirection + }{ + { + name: "improving trend", + runs: []WorkflowRun{ + {Conclusion: "success"}, + {Conclusion: "success"}, + {Conclusion: "success"}, + {Conclusion: "success"}, + {Conclusion: "failure"}, + {Conclusion: "failure"}, + {Conclusion: "failure"}, + {Conclusion: "success"}, + }, + expected: TrendImproving, + }, + { + name: "degrading trend", + runs: []WorkflowRun{ + {Conclusion: "failure"}, + {Conclusion: "failure"}, + {Conclusion: "failure"}, + {Conclusion: "failure"}, + {Conclusion: "success"}, + {Conclusion: "success"}, + {Conclusion: "success"}, + {Conclusion: "success"}, + }, + expected: TrendDegrading, + }, + { + name: "stable trend", + runs: []WorkflowRun{ + {Conclusion: "success"}, + {Conclusion: "success"}, + {Conclusion: "failure"}, + {Conclusion: "failure"}, + {Conclusion: "success"}, + {Conclusion: "success"}, + {Conclusion: "failure"}, + {Conclusion: "failure"}, + }, + expected: TrendStable, + }, + { + name: "not enough data", + runs: []WorkflowRun{ + {Conclusion: "success"}, + {Conclusion: "success"}, + }, + expected: TrendStable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trend := calculateTrend(tt.runs) + assert.Equal(t, tt.expected, trend, "Trend direction should match expected") + }) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + { + name: "zero duration", + duration: 0, + expected: "0s", + }, + { + name: "seconds only", + duration: 45 * time.Second, + expected: "45s", + }, + { + name: "minutes only", + duration: 5 * time.Minute, + expected: "5m", + }, + { + name: "minutes and seconds", + duration: 2*time.Minute + 30*time.Second, + expected: "2m 30s", + }, + { + name: "hours only", + duration: 2 * time.Hour, + expected: "2h", + }, + { + name: "hours and minutes", + duration: 1*time.Hour + 15*time.Minute, + expected: "1h 15m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatDuration(tt.duration) + assert.Equal(t, tt.expected, result, "Formatted duration should match") + }) + } +} + +func TestGroupRunsByWorkflow(t *testing.T) { + runs := []WorkflowRun{ + {WorkflowName: "workflow-a", Conclusion: "success"}, + {WorkflowName: "workflow-b", Conclusion: "success"}, + {WorkflowName: "workflow-a", Conclusion: "failure"}, + {WorkflowName: "workflow-c", Conclusion: "success"}, + {WorkflowName: "workflow-b", Conclusion: "success"}, + } + + grouped := GroupRunsByWorkflow(runs) + + assert.Len(t, grouped, 3, "Should have 3 different workflows") + assert.Len(t, grouped["workflow-a"], 2, "workflow-a should have 2 runs") + assert.Len(t, grouped["workflow-b"], 2, "workflow-b should have 2 runs") + assert.Len(t, grouped["workflow-c"], 1, "workflow-c should have 1 run") +} + +func TestFilterWorkflowsByName(t *testing.T) { + runs := []WorkflowRun{ + {WorkflowName: "workflow-a", Conclusion: "success"}, + {WorkflowName: "workflow-b", Conclusion: "success"}, + {WorkflowName: "workflow-a", Conclusion: "failure"}, + {WorkflowName: "workflow-c", Conclusion: "success"}, + } + + filtered := FilterWorkflowsByName(runs, "workflow-a") + + assert.Len(t, filtered, 2, "Should filter to 2 runs for workflow-a") + for _, run := range filtered { + assert.Equal(t, "workflow-a", run.WorkflowName, "All filtered runs should be workflow-a") + } +} + +func TestCalculateHealthSummary(t *testing.T) { + workflowHealths := []WorkflowHealth{ + {WorkflowName: "workflow-a", SuccessRate: 90.0, BelowThresh: false}, + {WorkflowName: "workflow-b", SuccessRate: 75.0, BelowThresh: true}, + {WorkflowName: "workflow-c", SuccessRate: 85.0, BelowThresh: false}, + } + + summary := CalculateHealthSummary(workflowHealths, "Last 7 Days", 80.0) + + assert.Equal(t, "Last 7 Days", summary.Period, "Period should match") + assert.Equal(t, 3, summary.TotalWorkflows, "Total workflows should be 3") + assert.Equal(t, 2, summary.HealthyWorkflows, "Healthy workflows should be 2") + assert.Equal(t, 1, summary.BelowThreshold, "Below threshold count should be 1") + assert.Len(t, summary.Workflows, 3, "Workflows array should have 3 entries") +} + +func TestTrendDirectionString(t *testing.T) { + tests := []struct { + name string + trend TrendDirection + expected string + }{ + { + name: "improving", + trend: TrendImproving, + expected: "↑", + }, + { + name: "stable", + trend: TrendStable, + expected: "→", + }, + { + name: "degrading", + trend: TrendDegrading, + expected: "↓", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.trend.String() + assert.Equal(t, tt.expected, result, "Trend string representation should match") + }) + } +} From 0d6eecbd1456b9770cac48b7c58ca98817cd7d22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:03:40 +0000 Subject: [PATCH 3/5] Fix linter issue with float comparison in tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/health_metrics_test.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg/cli/health_metrics_test.go b/pkg/cli/health_metrics_test.go index cf76383cbd..84a325b1fb 100644 --- a/pkg/cli/health_metrics_test.go +++ b/pkg/cli/health_metrics_test.go @@ -69,13 +69,9 @@ func TestCalculateWorkflowHealth(t *testing.T) { health := CalculateWorkflowHealth(tt.workflowName, tt.runs, tt.threshold) assert.Equal(t, tt.workflowName, health.WorkflowName, "Workflow name should match") - - // Use InEpsilon for non-zero values, Equal for zero - if tt.expectedRate == 0 { - assert.Equal(t, tt.expectedRate, health.SuccessRate, "Success rate should match") - } else { - assert.InEpsilon(t, tt.expectedRate, health.SuccessRate, 0.01, "Success rate should match") - } + + // Use InDelta for all float comparisons to satisfy testifylint + assert.InDelta(t, tt.expectedRate, health.SuccessRate, 0.01, "Success rate should match") if tt.expectedTrend != "" { assert.Equal(t, tt.expectedTrend, health.Trend, "Trend should match") From bb9b233094fc17d811aebe5535a9a8f1468b5e6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:58:42 +0000 Subject: [PATCH 4/5] Add cost metrics to health command output - Add TokenUsage and EstimatedCost fields to WorkflowHealth struct - Calculate average tokens and cost in CalculateWorkflowHealth - Display cost metrics in summary and detailed views - Add formatTokens and formatCost helper functions - Add tests for new formatting functions - Show average cost per run and total cost in detailed view Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/health_command.go | 3 + pkg/cli/health_metrics.go | 103 +++++++++++++++++++++++++-------- pkg/cli/health_metrics_test.go | 72 +++++++++++++++++++++++ 3 files changed, 153 insertions(+), 25 deletions(-) diff --git a/pkg/cli/health_command.go b/pkg/cli/health_command.go index 2be0396782..26a2fcc2ce 100644 --- a/pkg/cli/health_command.go +++ b/pkg/cli/health_command.go @@ -257,6 +257,9 @@ func displayDetailedHealth(runs []WorkflowRun, config HealthConfig) error { {"Success Rate", health.DisplayRate}, {"Trend", health.Trend}, {"Avg Duration", health.DisplayDur}, + {"Avg Tokens", health.DisplayTokens}, + {"Avg Cost", fmt.Sprintf("$%s", health.DisplayCost)}, + {"Total Cost", fmt.Sprintf("$%.3f", health.TotalCost)}, } fmt.Fprint(os.Stderr, console.RenderStruct(details)) diff --git a/pkg/cli/health_metrics.go b/pkg/cli/health_metrics.go index e20b9a21b1..766cf216fa 100644 --- a/pkg/cli/health_metrics.go +++ b/pkg/cli/health_metrics.go @@ -11,16 +11,22 @@ var healthMetricsLog = logger.New("cli:health_metrics") // WorkflowHealth represents health metrics for a single workflow type WorkflowHealth struct { - WorkflowName string `json:"workflow_name" console:"header:Workflow"` - TotalRuns int `json:"total_runs" console:"-"` - SuccessCount int `json:"success_count" console:"-"` - FailureCount int `json:"failure_count" console:"-"` - SuccessRate float64 `json:"success_rate" console:"-"` - DisplayRate string `json:"-" console:"header:Success Rate"` - Trend string `json:"trend" console:"header:Trend"` - AvgDuration time.Duration `json:"avg_duration" console:"-"` - DisplayDur string `json:"-" console:"header:Avg Duration"` - BelowThresh bool `json:"below_threshold" console:"-"` + WorkflowName string `json:"workflow_name" console:"header:Workflow"` + TotalRuns int `json:"total_runs" console:"-"` + SuccessCount int `json:"success_count" console:"-"` + FailureCount int `json:"failure_count" console:"-"` + SuccessRate float64 `json:"success_rate" console:"-"` + DisplayRate string `json:"-" console:"header:Success Rate"` + Trend string `json:"trend" console:"header:Trend"` + AvgDuration time.Duration `json:"avg_duration" console:"-"` + DisplayDur string `json:"-" console:"header:Avg Duration"` + TotalTokens int `json:"total_tokens" console:"-"` + AvgTokens int `json:"avg_tokens" console:"-"` + DisplayTokens string `json:"-" console:"header:Avg Tokens"` + TotalCost float64 `json:"total_cost" console:"-"` + AvgCost float64 `json:"avg_cost" console:"-"` + DisplayCost string `json:"-" console:"header:Avg Cost ($)"` + BelowThresh bool `json:"below_threshold" console:"-"` } // HealthSummary represents aggregated health metrics across all workflows @@ -61,10 +67,12 @@ func CalculateWorkflowHealth(workflowName string, runs []WorkflowRun, threshold if len(runs) == 0 { return WorkflowHealth{ - WorkflowName: workflowName, - DisplayRate: "N/A", - Trend: "→", - DisplayDur: "N/A", + WorkflowName: workflowName, + DisplayRate: "N/A", + Trend: "→", + DisplayDur: "N/A", + DisplayTokens: "-", + DisplayCost: "-", } } @@ -72,6 +80,8 @@ func CalculateWorkflowHealth(workflowName string, runs []WorkflowRun, threshold successCount := 0 failureCount := 0 var totalDuration time.Duration + var totalTokens int + var totalCost float64 for _, run := range runs { if run.Conclusion == "success" { @@ -80,6 +90,8 @@ func CalculateWorkflowHealth(workflowName string, runs []WorkflowRun, threshold failureCount++ } totalDuration += run.Duration + totalTokens += run.TokenUsage + totalCost += run.EstimatedCost } totalRuns := len(runs) @@ -94,29 +106,45 @@ func CalculateWorkflowHealth(workflowName string, runs []WorkflowRun, threshold avgDuration = totalDuration / time.Duration(totalRuns) } + // Calculate average tokens and cost + avgTokens := 0 + avgCost := 0.0 + if totalRuns > 0 { + avgTokens = totalTokens / totalRuns + avgCost = totalCost / float64(totalRuns) + } + // Calculate trend trend := calculateTrend(runs) // Format display values displayRate := fmt.Sprintf("%.0f%% (%d/%d)", successRate, successCount, totalRuns) displayDur := formatDuration(avgDuration) + displayTokens := formatTokens(avgTokens) + displayCost := formatCost(avgCost) belowThreshold := successRate < threshold health := WorkflowHealth{ - WorkflowName: workflowName, - TotalRuns: totalRuns, - SuccessCount: successCount, - FailureCount: failureCount, - SuccessRate: successRate, - DisplayRate: displayRate, - Trend: trend.String(), - AvgDuration: avgDuration, - DisplayDur: displayDur, - BelowThresh: belowThreshold, + WorkflowName: workflowName, + TotalRuns: totalRuns, + SuccessCount: successCount, + FailureCount: failureCount, + SuccessRate: successRate, + DisplayRate: displayRate, + Trend: trend.String(), + AvgDuration: avgDuration, + DisplayDur: displayDur, + TotalTokens: totalTokens, + AvgTokens: avgTokens, + DisplayTokens: displayTokens, + TotalCost: totalCost, + AvgCost: avgCost, + DisplayCost: displayCost, + BelowThresh: belowThreshold, } - healthMetricsLog.Printf("Health calculated: workflow=%s, successRate=%.2f%%, trend=%s", workflowName, successRate, trend.String()) + healthMetricsLog.Printf("Health calculated: workflow=%s, successRate=%.2f%%, trend=%s, avgCost=$%.3f", workflowName, successRate, trend.String(), avgCost) return health } @@ -246,3 +274,28 @@ func GroupRunsByWorkflow(runs []WorkflowRun) map[string][]WorkflowRun { } return grouped } + +// formatTokens formats token count in a human-readable format +func formatTokens(tokens int) string { + if tokens == 0 { + return "-" + } + if tokens < 1000 { + return fmt.Sprintf("%d", tokens) + } + if tokens < 1000000 { + return fmt.Sprintf("%.1fK", float64(tokens)/1000) + } + return fmt.Sprintf("%.1fM", float64(tokens)/1000000) +} + +// formatCost formats cost in a human-readable format +func formatCost(cost float64) string { + if cost == 0 { + return "-" + } + if cost < 0.001 { + return "< 0.001" + } + return fmt.Sprintf("%.3f", cost) +} diff --git a/pkg/cli/health_metrics_test.go b/pkg/cli/health_metrics_test.go index 84a325b1fb..33ee9b7b27 100644 --- a/pkg/cli/health_metrics_test.go +++ b/pkg/cli/health_metrics_test.go @@ -282,3 +282,75 @@ func TestTrendDirectionString(t *testing.T) { }) } } + +func TestFormatTokens(t *testing.T) { + tests := []struct { + name string + tokens int + expected string + }{ + { + name: "zero tokens", + tokens: 0, + expected: "-", + }, + { + name: "small tokens", + tokens: 500, + expected: "500", + }, + { + name: "thousands", + tokens: 5000, + expected: "5.0K", + }, + { + name: "millions", + tokens: 2500000, + expected: "2.5M", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatTokens(tt.tokens) + assert.Equal(t, tt.expected, result, "Formatted tokens should match") + }) + } +} + +func TestFormatCost(t *testing.T) { + tests := []struct { + name string + cost float64 + expected string + }{ + { + name: "zero cost", + cost: 0.0, + expected: "-", + }, + { + name: "very small cost", + cost: 0.0001, + expected: "< 0.001", + }, + { + name: "small cost", + cost: 0.123, + expected: "0.123", + }, + { + name: "large cost", + cost: 5.678, + expected: "5.678", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatCost(tt.cost) + assert.Equal(t, tt.expected, result, "Formatted cost should match") + }) + } +} From 4eed7b3dc079e1385e3027413bf1e868fed4fc2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:27:01 +0000 Subject: [PATCH 5/5] Use console package helpers for table rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove manual separator lines (━━━━━) - Remove redundant icons (console.Format* functions add them automatically) - Simplify output formatting to rely on console helpers - Consistent with other CLI commands (list, status, audit) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/health_command.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/cli/health_command.go b/pkg/cli/health_command.go index 26a2fcc2ce..94def73055 100644 --- a/pkg/cli/health_command.go +++ b/pkg/cli/health_command.go @@ -239,9 +239,8 @@ func displayDetailedHealth(runs []WorkflowRun, config HealthConfig) error { return nil } - // Display detailed table + // Display header message fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow Health: %s (Last %d Days)", config.WorkflowName, config.Days))) - fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Fprintln(os.Stderr, "") // Create detailed view @@ -267,9 +266,9 @@ func displayDetailedHealth(runs []WorkflowRun, config HealthConfig) error { // Display warning if below threshold if health.BelowThresh { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("⚠️ Success rate (%.1f%%) is below threshold (%.1f%%)", health.SuccessRate, config.Threshold))) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Success rate (%.1f%%) is below threshold (%.1f%%)", health.SuccessRate, config.Threshold))) } else { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Success rate (%.1f%%) is above threshold (%.1f%%)", health.SuccessRate, config.Threshold))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Success rate (%.1f%%) is above threshold (%.1f%%)", health.SuccessRate, config.Threshold))) } return nil @@ -288,7 +287,6 @@ func outputHealthJSON(summary HealthSummary) error { // outputHealthTable outputs health summary as a formatted table func outputHealthTable(summary HealthSummary, threshold float64) error { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow Health Summary (%s)", summary.Period))) - fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Fprintln(os.Stderr, "") // Render table @@ -297,10 +295,10 @@ func outputHealthTable(summary HealthSummary, threshold float64) error { // Display summary message if summary.BelowThreshold > 0 { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("⚠️ %d workflow(s) below %.0f%% success threshold", summary.BelowThreshold, threshold))) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("ℹ Run '%s health ' for details", string(constants.CLIExtensionPrefix)))) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("%d workflow(s) below %.0f%% success threshold", summary.BelowThreshold, threshold))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Run '%s health ' for details", string(constants.CLIExtensionPrefix)))) } else { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ All workflows above %.0f%% success threshold", threshold))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("All workflows above %.0f%% success threshold", threshold))) } return nil