diff --git a/pkg/cli/mcp_server.go b/pkg/cli/mcp_server.go index 6cb40f6989..9bfb019848 100644 --- a/pkg/cli/mcp_server.go +++ b/pkg/cli/mcp_server.go @@ -167,27 +167,31 @@ Note: Output can be filtered using the jq parameter.`, default: } - // Build command arguments - always use JSON for MCP - cmdArgs := []string{"status", "--json"} - if args.Pattern != "" { - cmdArgs = append(cmdArgs, args.Pattern) - } - mcpLog.Printf("Executing status tool: pattern=%s, jqFilter=%s", args.Pattern, args.JqFilter) - // Execute the CLI command - cmd := execCmd(ctx, cmdArgs...) - output, err := cmd.CombinedOutput() + // Call GetWorkflowStatuses directly instead of spawning subprocess + statuses, err := GetWorkflowStatuses(args.Pattern, "", "", "") if err != nil { return nil, nil, &jsonrpc.Error{ Code: jsonrpc.CodeInternalError, - Message: "failed to execute status command", - Data: mcpErrorData(map[string]any{"error": err.Error(), "output": string(output)}), + Message: "failed to get workflow statuses", + Data: mcpErrorData(map[string]any{"error": err.Error()}), + } + } + + // Marshal to JSON + jsonBytes, err := json.Marshal(statuses) + if err != nil { + return nil, nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "failed to marshal workflow statuses", + Data: mcpErrorData(map[string]any{"error": err.Error()}), } } + outputStr := string(jsonBytes) + // Apply jq filter if provided - outputStr := string(output) if args.JqFilter != "" { filteredOutput, jqErr := ApplyJqFilter(outputStr, args.JqFilter) if jqErr != nil { diff --git a/pkg/cli/status_command.go b/pkg/cli/status_command.go index b6d9a7f04c..d98a5f4444 100644 --- a/pkg/cli/status_command.go +++ b/pkg/cli/status_command.go @@ -33,200 +33,47 @@ type WorkflowStatus struct { RunConclusion string `json:"run_conclusion,omitempty" console:"header:Run Conclusion,omitempty"` } -func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string, repoOverride string) error { - statusLog.Printf("Checking workflow status: pattern=%s, jsonOutput=%v, ref=%s, labelFilter=%s, repo=%s", pattern, jsonOutput, ref, labelFilter, repoOverride) - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checking status of workflow files")) - if pattern != "" { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Filtering by pattern: %s", pattern))) - } - } +// GetWorkflowStatuses retrieves workflow status information and returns it as a slice. +// This function is designed for programmatic access (e.g., from MCP server). +// For CLI usage, use StatusWorkflows which handles output formatting. +func GetWorkflowStatuses(pattern string, ref string, labelFilter string, repoOverride string) ([]WorkflowStatus, error) { + statusLog.Printf("Getting workflow statuses: pattern=%s, ref=%s, labelFilter=%s, repo=%s", pattern, ref, labelFilter, repoOverride) mdFiles, err := getMarkdownWorkflowFiles("") if err != nil { statusLog.Printf("Failed to get markdown workflow files: %v", err) - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) - return nil + return nil, fmt.Errorf("failed to get markdown workflow files: %w", err) } statusLog.Printf("Found %d markdown workflow files", len(mdFiles)) if len(mdFiles) == 0 { - if jsonOutput { - // Output empty array for JSON - output := []WorkflowStatus{} - jsonBytes, _ := json.MarshalIndent(output, "", " ") - fmt.Println(string(jsonBytes)) - return nil - } - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflow files found.")) - return nil - } - - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d markdown workflow files", len(mdFiles)))) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Fetching GitHub workflow status...")) + return []WorkflowStatus{}, nil } // Get GitHub workflows data statusLog.Print("Fetching GitHub workflow status") - githubWorkflows, err := fetchGitHubWorkflows(repoOverride, verbose && !jsonOutput) + githubWorkflows, err := fetchGitHubWorkflows(repoOverride, false) if err != nil { statusLog.Printf("Failed to fetch GitHub workflows: %v", err) - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Failed to fetch GitHub workflows: %v", err))) - } - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not fetch GitHub workflow status: %v", err))) - } githubWorkflows = make(map[string]*GitHubWorkflow) } else { statusLog.Printf("Successfully fetched %d GitHub workflows", len(githubWorkflows)) - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully fetched %d GitHub workflows", len(githubWorkflows)))) - } } // Fetch latest workflow runs for ref if specified var latestRunsByWorkflow map[string]*WorkflowRun if ref != "" { - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Fetching latest runs for ref: %s", ref))) - } - latestRunsByWorkflow, err = fetchLatestRunsByRef(ref, repoOverride, verbose && !jsonOutput) + latestRunsByWorkflow, err = fetchLatestRunsByRef(ref, repoOverride, false) if err != nil { statusLog.Printf("Failed to fetch workflow runs for ref %s: %v", ref, err) - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Failed to fetch workflow runs for ref: %v", err))) - } - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not fetch workflow runs for ref '%s': %v", ref, err))) - } latestRunsByWorkflow = make(map[string]*WorkflowRun) } else { statusLog.Printf("Successfully fetched %d workflow runs for ref %s", len(latestRunsByWorkflow), ref) - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully fetched %d workflow runs for ref", len(latestRunsByWorkflow)))) - } - } - } - - // Build table configuration or JSON output - if jsonOutput { - // Build JSON output - var statuses []WorkflowStatus - for _, file := range mdFiles { - base := filepath.Base(file) - name := strings.TrimSuffix(base, ".md") - - // Skip if pattern specified and doesn't match - if pattern != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(pattern)) { - continue - } - - // Extract engine ID from workflow file - agent := extractEngineIDFromFile(file) - - // Check if compiled (.lock.yml file is in .github/workflows) - lockFile := stringutil.MarkdownToLockFile(file) - 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" - } - - // Extract stop-time from lock file - if stopTime := workflow.ExtractStopTimeFromLockFile(lockFile); stopTime != "" { - timeRemaining = calculateTimeRemaining(stopTime) - } - } - - // Get GitHub workflow status - status := "Unknown" - if workflow, exists := githubWorkflows[name]; exists { - if workflow.State == "disabled_manually" { - status = "disabled" - } else { - status = workflow.State - } - } - - // Extract "on" field and labels from frontmatter for JSON output - var onField any - var labels []string - if content, err := os.ReadFile(file); err == nil { - if result, err := parser.ExtractFrontmatterFromContent(string(content)); err == nil { - if result.Frontmatter != nil { - onField = result.Frontmatter["on"] - // Extract labels field if present - if labelsField, ok := result.Frontmatter["labels"]; ok { - if labelsArray, ok := labelsField.([]any); ok { - for _, label := range labelsArray { - if labelStr, ok := label.(string); ok { - labels = append(labels, labelStr) - } - } - } - } - } - } - } - - // Skip if label filter specified and workflow doesn't have the label - if labelFilter != "" { - hasLabel := false - for _, label := range labels { - if strings.EqualFold(label, labelFilter) { - hasLabel = true - break - } - } - if !hasLabel { - continue - } - } - - // Get run status for ref if available - var runStatus, runConclusion string - if latestRunsByWorkflow != nil { - if run, exists := latestRunsByWorkflow[name]; exists { - runStatus = run.Status - runConclusion = run.Conclusion - } - } - - // Build status object - statuses = append(statuses, WorkflowStatus{ - Workflow: name, - EngineID: agent, - Compiled: compiled, - Status: status, - TimeRemaining: timeRemaining, - Labels: labels, - On: onField, - RunStatus: runStatus, - RunConclusion: runConclusion, - }) - } - - // Output JSON - jsonBytes, err := json.MarshalIndent(statuses, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) } - fmt.Println(string(jsonBytes)) - return nil } - // Build status list for text output + // Build status list var statuses []WorkflowStatus - for _, file := range mdFiles { base := filepath.Base(file) name := strings.TrimSuffix(base, ".md") @@ -270,20 +117,14 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, } } - // Get run status for ref if available - var runStatus, runConclusion string - if latestRunsByWorkflow != nil { - if run, exists := latestRunsByWorkflow[name]; exists { - runStatus = run.Status - runConclusion = run.Conclusion - } - } - - // Extract labels from frontmatter + // Extract "on" field and labels from frontmatter + var onField any var labels []string if content, err := os.ReadFile(file); err == nil { if result, err := parser.ExtractFrontmatterFromContent(string(content)); err == nil { if result.Frontmatter != nil { + onField = result.Frontmatter["on"] + // Extract labels field if present if labelsField, ok := result.Frontmatter["labels"]; ok { if labelsArray, ok := labelsField.([]any); ok { for _, label := range labelsArray { @@ -311,6 +152,15 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, } } + // Get run status for ref if available + var runStatus, runConclusion string + if latestRunsByWorkflow != nil { + if run, exists := latestRunsByWorkflow[name]; exists { + runStatus = run.Status + runConclusion = run.Conclusion + } + } + // Build status object statuses = append(statuses, WorkflowStatus{ Workflow: name, @@ -319,17 +169,67 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, Status: status, TimeRemaining: timeRemaining, Labels: labels, + On: onField, RunStatus: runStatus, RunConclusion: runConclusion, }) } + return statuses, nil +} + +func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string, repoOverride string) error { + statusLog.Printf("Checking workflow status: pattern=%s, jsonOutput=%v, ref=%s, labelFilter=%s, repo=%s", pattern, jsonOutput, ref, labelFilter, repoOverride) + if verbose && !jsonOutput { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checking status of workflow files")) + if pattern != "" { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Filtering by pattern: %s", pattern))) + } + } + + // Verbose logging for network operations + if verbose && !jsonOutput { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Fetching GitHub workflow status...")) + } + + // Get workflow statuses + statuses, err := GetWorkflowStatuses(pattern, ref, labelFilter, repoOverride) + if err != nil { + statusLog.Printf("Failed to get workflow statuses: %v", err) + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) + return nil + } + + // Additional verbose output after successful fetch + if verbose && !jsonOutput && len(statuses) > 0 { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully fetched status for %d workflows", len(statuses)))) + } + + // Handle output + if jsonOutput { + // Output JSON + jsonBytes, err := json.MarshalIndent(statuses, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + // Handle empty result for text output + if len(statuses) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflow files found.")) + return nil + } + // Render the table using struct-based rendering fmt.Print(console.RenderStruct(statuses)) return nil } +// Removed duplicate code - now everything goes through GetWorkflowStatuses + // calculateTimeRemaining calculates and formats the time remaining until stop-time func calculateTimeRemaining(stopTimeStr string) string { if stopTimeStr == "" { diff --git a/pkg/cli/status_mcp_integration_test.go b/pkg/cli/status_mcp_integration_test.go new file mode 100644 index 0000000000..dfc328607f --- /dev/null +++ b/pkg/cli/status_mcp_integration_test.go @@ -0,0 +1,118 @@ +//go:build !integration + +package cli + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetWorkflowStatuses_MCPIntegration verifies that the GetWorkflowStatuses function +// returns valid data that can be marshaled to JSON +func TestGetWorkflowStatuses_MCPIntegration(t *testing.T) { + // This test requires being run from the repository root + // since it needs .github/workflows directory + statuses, err := GetWorkflowStatuses("", "", "", "") + + // We expect either: + // - No error and a valid (possibly empty) slice + // - An error if we're not in a repository with workflows + if err != nil { + // If we're not in a repo with workflows, that's ok + t.Logf("GetWorkflowStatuses returned error (expected if not in repo): %v", err) + return + } + + // Verify we got a slice (even if empty) + require.NotNil(t, statuses, "GetWorkflowStatuses should return a non-nil slice") + + // Verify that the result can be marshaled to JSON + jsonBytes, err := json.Marshal(statuses) + require.NoError(t, err, "Should be able to marshal statuses to JSON") + require.NotEmpty(t, jsonBytes, "JSON output should not be empty") + + // Verify that the JSON is valid by unmarshaling it back + var unmarshaled []WorkflowStatus + err = json.Unmarshal(jsonBytes, &unmarshaled) + require.NoError(t, err, "Should be able to unmarshal JSON back to WorkflowStatus slice") + + t.Logf("GetWorkflowStatuses returned %d workflows", len(statuses)) +} + +// TestGetWorkflowStatuses_WithPattern tests filtering by pattern +func TestGetWorkflowStatuses_WithPattern(t *testing.T) { + // Get all statuses first + allStatuses, err := GetWorkflowStatuses("", "", "", "") + if err != nil { + t.Skipf("Skipping test: not in a repository with workflows: %v", err) + return + } + + if len(allStatuses) == 0 { + t.Skip("Skipping test: no workflows found") + return + } + + // Use the first workflow's name as a pattern + firstWorkflowName := allStatuses[0].Workflow + pattern := firstWorkflowName[:min(3, len(firstWorkflowName))] // Use first 3 chars as pattern + + // Get filtered statuses + filteredStatuses, err := GetWorkflowStatuses(pattern, "", "", "") + require.NoError(t, err, "GetWorkflowStatuses with pattern should not error") + + // Verify that filtered results are a subset + assert.LessOrEqual(t, len(filteredStatuses), len(allStatuses), + "Filtered results should be <= all results") + + // Verify that all filtered results contain the pattern + for _, status := range filteredStatuses { + assert.Contains(t, status.Workflow, pattern, + "Filtered workflow should contain pattern") + } + + t.Logf("Pattern '%s' matched %d of %d workflows", pattern, len(filteredStatuses), len(allStatuses)) +} + +// TestGetWorkflowStatuses_MCPJSONStructure verifies the JSON structure +func TestGetWorkflowStatuses_MCPJSONStructure(t *testing.T) { + statuses, err := GetWorkflowStatuses("", "", "", "") + if err != nil { + t.Skipf("Skipping test: not in a repository with workflows: %v", err) + return + } + + if len(statuses) == 0 { + t.Skip("Skipping test: no workflows found") + return + } + + // Take the first workflow and verify its structure + firstStatus := statuses[0] + + // Verify required fields are present + assert.NotEmpty(t, firstStatus.Workflow, "Workflow name should not be empty") + assert.NotEmpty(t, firstStatus.EngineID, "Engine ID should not be empty") + assert.NotEmpty(t, firstStatus.Compiled, "Compiled status should not be empty") + assert.NotEmpty(t, firstStatus.Status, "Status should not be empty") + assert.NotEmpty(t, firstStatus.TimeRemaining, "Time remaining should not be empty") + + // Verify JSON marshaling + jsonBytes, err := json.Marshal(firstStatus) + require.NoError(t, err, "Should marshal to JSON") + + // Verify JSON structure + var jsonMap map[string]any + err = json.Unmarshal(jsonBytes, &jsonMap) + require.NoError(t, err, "Should unmarshal JSON") + + // Verify expected fields + assert.Contains(t, jsonMap, "workflow", "JSON should contain 'workflow' field") + assert.Contains(t, jsonMap, "engine_id", "JSON should contain 'engine_id' field") + assert.Contains(t, jsonMap, "compiled", "JSON should contain 'compiled' field") + assert.Contains(t, jsonMap, "status", "JSON should contain 'status' field") + assert.Contains(t, jsonMap, "time_remaining", "JSON should contain 'time_remaining' field") +}