Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 102 additions & 22 deletions pkg/cli/status_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -209,6 +207,7 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string,
On: onField,
RunStatus: runStatus,
RunConclusion: runConclusion,
ActionNeeded: actionNeeded,
})
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 54 additions & 0 deletions pkg/cli/status_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}