diff --git a/pkg/cli/status_command.go b/pkg/cli/status_command.go index 035a4bdcffc..4d63da3177e 100644 --- a/pkg/cli/status_command.go +++ b/pkg/cli/status_command.go @@ -12,6 +12,8 @@ import ( "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" "github.com/githubnext/gh-aw/pkg/parser" + "github.com/githubnext/gh-aw/pkg/styles" + "github.com/githubnext/gh-aw/pkg/tty" "github.com/githubnext/gh-aw/pkg/workflow" ) @@ -28,6 +30,7 @@ type WorkflowStatus struct { On any `json:"on,omitempty" console:"-"` RunStatus string `json:"run_status,omitempty" console:"header:Run Status,omitempty"` RunConclusion string `json:"run_conclusion,omitempty" console:"header:Run Conclusion,omitempty"` + ActionNeeded string `json:"action_needed,omitempty" console:"header:Action Needed,omitempty"` } func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string) error { @@ -125,20 +128,15 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, // Check if compiled (.lock.yml file is in .github/workflows) lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" - compiled := "N/A" timeRemaining := "N/A" - if _, err := os.Stat(lockFile); err == nil { - // Check if up to date - mdStat, _ := os.Stat(file) - lockStat, _ := os.Stat(lockFile) - if mdStat.ModTime().After(lockStat.ModTime()) { - compiled = "No" - } else { - compiled = "Yes" - } + // Determine staleness and action needed + staleInfo := checkWorkflowStaleness(file, lockFile) + compiled := staleInfo.compiled + actionNeeded := staleInfo.actionNeeded - // Extract stop-time from lock file + // Extract stop-time from lock file if it exists + if _, err := os.Stat(lockFile); err == nil { if stopTime := workflow.ExtractStopTimeFromLockFile(lockFile); stopTime != "" { timeRemaining = calculateTimeRemaining(stopTime) } @@ -209,6 +207,7 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, On: onField, RunStatus: runStatus, RunConclusion: runConclusion, + ActionNeeded: actionNeeded, }) } @@ -238,20 +237,15 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, // Check if compiled (.lock.yml file is in .github/workflows) lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" - compiled := "N/A" timeRemaining := "N/A" - if _, err := os.Stat(lockFile); err == nil { - // Check if up to date - mdStat, _ := os.Stat(file) - lockStat, _ := os.Stat(lockFile) - if mdStat.ModTime().After(lockStat.ModTime()) { - compiled = "No" - } else { - compiled = "Yes" - } + // Determine staleness and action needed + staleInfo := checkWorkflowStaleness(file, lockFile) + compiled := colorCodeCompiled(staleInfo.compiled) // Apply color for console output + actionNeeded := staleInfo.actionNeeded - // Extract stop-time from lock file + // Extract stop-time from lock file if it exists + if _, err := os.Stat(lockFile); err == nil { if stopTime := workflow.ExtractStopTimeFromLockFile(lockFile); stopTime != "" { timeRemaining = calculateTimeRemaining(stopTime) } @@ -318,12 +312,30 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, Labels: labels, RunStatus: runStatus, RunConclusion: runConclusion, + ActionNeeded: actionNeeded, }) } // Render the table using struct-based rendering fmt.Print(console.RenderStruct(statuses)) + // Add summary footer showing compilation status + needsCompilation := 0 + for _, s := range statuses { + if s.ActionNeeded != "" { + needsCompilation++ + } + } + + if needsCompilation > 0 { + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Workflows needing compilation: %d", needsCompilation))) + fmt.Fprintf(os.Stderr, " Run: %s\n", console.FormatCommandMessage("gh aw compile")) + } else if len(statuses) > 0 { + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("All workflows are up-to-date")) + } + return nil } @@ -366,6 +378,74 @@ func calculateTimeRemaining(stopTimeStr string) string { } } +// stalenessInfo holds information about workflow staleness +type stalenessInfo struct { + compiled string // "Yes", "No", "N/A", or "Stale" + actionNeeded string // Command to run if action is needed +} + +// colorCodeCompiled adds color formatting to the compiled status for console output +func colorCodeCompiled(compiled string) string { + // Only apply colors if output is a TTY + if !tty.IsStdoutTerminal() { + return compiled + } + + switch compiled { + case "Yes": + // Green for up-to-date + return styles.Success.Render(compiled) + case "Stale": + // Yellow for stale (needs recompilation) + return styles.Warning.Render(compiled) + case "No": + // Red for never compiled + return styles.Error.Render(compiled) + default: + // No color for N/A or other statuses + return compiled + } +} + +// checkWorkflowStaleness determines if a workflow needs compilation and returns action needed +func checkWorkflowStaleness(mdFile, lockFile string) stalenessInfo { + // Check if lock file exists + if _, err := os.Stat(lockFile); os.IsNotExist(err) { + // Never compiled + baseName := filepath.Base(mdFile) + return stalenessInfo{ + compiled: "No", + actionNeeded: fmt.Sprintf("gh aw compile %s", baseName), + } + } + + // Lock file exists - check if up to date using timestamp comparison + mdStat, err := os.Stat(mdFile) + if err != nil { + return stalenessInfo{compiled: "N/A", actionNeeded: ""} + } + + lockStat, err := os.Stat(lockFile) + if err != nil { + return stalenessInfo{compiled: "N/A", actionNeeded: ""} + } + + if mdStat.ModTime().After(lockStat.ModTime()) { + // Stale - source modified after lock file + baseName := filepath.Base(mdFile) + return stalenessInfo{ + compiled: "Stale", + actionNeeded: fmt.Sprintf("gh aw compile %s", baseName), + } + } + + // Up to date + return stalenessInfo{ + compiled: "Yes", + actionNeeded: "", + } +} + // StatusWorkflows shows status of workflows // getMarkdownWorkflowFiles finds all markdown files in .github/workflows directory func getMarkdownWorkflowFiles() ([]string, error) { diff --git a/pkg/cli/status_command_test.go b/pkg/cli/status_command_test.go index b46ea2900ec..453ccf00349 100644 --- a/pkg/cli/status_command_test.go +++ b/pkg/cli/status_command_test.go @@ -513,3 +513,57 @@ func TestWorkflowStatus_ConsoleRenderingWithRunStatus(t *testing.T) { } } } + +// TestWorkflowStatus_JSONMarshalingWithActionNeeded tests that action_needed is included in JSON output +func TestWorkflowStatus_JSONMarshalingWithActionNeeded(t *testing.T) { + // Test that WorkflowStatus with action_needed can be marshaled to JSON + status := WorkflowStatus{ + Workflow: "test-workflow", + EngineID: "copilot", + Compiled: "No", + Status: "active", + ActionNeeded: "gh aw compile test-workflow.md", + } + + jsonBytes, err := json.Marshal(status) + if err != nil { + t.Fatalf("Failed to marshal WorkflowStatus: %v", err) + } + + // Verify JSON contains action_needed field + var unmarshaled map[string]any + if err := json.Unmarshal(jsonBytes, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if unmarshaled["action_needed"] != "gh aw compile test-workflow.md" { + t.Errorf("Expected action_needed='gh aw compile test-workflow.md', got %v", unmarshaled["action_needed"]) + } +} + +// TestWorkflowStatus_JSONMarshalingWithoutActionNeeded tests that empty action_needed is omitted +func TestWorkflowStatus_JSONMarshalingWithoutActionNeeded(t *testing.T) { + // Test that WorkflowStatus without action_needed omits the field + status := WorkflowStatus{ + Workflow: "test-workflow", + EngineID: "copilot", + Compiled: "Yes", + Status: "active", + // ActionNeeded is empty + } + + jsonBytes, err := json.Marshal(status) + if err != nil { + t.Fatalf("Failed to marshal WorkflowStatus: %v", err) + } + + // Verify JSON omits empty action_needed field (due to omitempty) + var unmarshaled map[string]any + if err := json.Unmarshal(jsonBytes, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if _, exists := unmarshaled["action_needed"]; exists { + t.Errorf("Expected action_needed to be omitted when empty, but it was present with value: %v", unmarshaled["action_needed"]) + } +}