diff --git a/cmd/actions.go b/cmd/actions.go index ccceaab..e9dbba0 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -189,9 +189,10 @@ func init() { addRepoFlags(runListCmd) runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") addRepoFlags(runViewCmd) - runViewCmd.Flags().BoolP("verbose", "v", false, "Show job details") - runViewCmd.Flags().BoolP("log", "", false, "Show logs for all jobs") - runViewCmd.Flags().Int64P("job", "j", 0, "Show logs for a specific job ID") + runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps") + runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job") + runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run") + runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") // Add flags for secret commands addRepoFlags(actionsSecretListCmd) @@ -294,7 +295,17 @@ func runRunView(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") showLog, _ := cmd.Flags().GetBool("log") - jobID, _ := cmd.Flags().GetInt64("job") + jobIDStr, _ := cmd.Flags().GetString("job") + showLogFailed, _ := cmd.Flags().GetBool("log-failed") + + var jobID int64 + if jobIDStr != "" { + var err error + jobID, err = strconv.ParseInt(jobIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid job ID: %w", err) + } + } // Call the API endpoint directly since SDK doesn't have it yet endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) @@ -330,71 +341,90 @@ func runRunView(cmd *cobra.Command, args []string) error { fmt.Printf("Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } - // If --log or --job flag is provided, or --verbose, fetch jobs - if verbose || showLog || jobID > 0 { - tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name) - var taskList ActionTaskList - if err := client.GetJSON(tasksEndpoint, &taskList); err != nil { - return fmt.Errorf("failed to get tasks: %w", err) - } + // Fetch jobs if needed for verbose, log, or job-specific views + needsJobs := verbose || showLog || showLogFailed || jobID > 0 + if !needsJobs { + return nil + } - // Filter tasks for this run number - var runTasks []ActionTask - for _, task := range taskList.WorkflowRuns { - if task.RunNumber == run.IndexInRepo { - runTasks = append(runTasks, task) + tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name) + var taskList ActionTaskList + if err := client.GetJSON(tasksEndpoint, &taskList); err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + + // Filter tasks for this run number + var runTasks []ActionTask + for _, task := range taskList.WorkflowRuns { + if task.RunNumber == run.IndexInRepo { + runTasks = append(runTasks, task) + } + } + + if len(runTasks) == 0 { + fmt.Println("\nNo jobs found for this run") + return nil + } + + // If --job is specified, filter to that job + if jobID > 0 { + var found bool + for _, task := range runTasks { + if task.ID == jobID { + runTasks = []ActionTask{task} + found = true + break } } - - if len(runTasks) == 0 { - fmt.Println("\nNo jobs found for this run") - return nil + if !found { + return fmt.Errorf("job %d not found in this run", jobID) } + } - // Show jobs if verbose (and not showing logs) - if verbose && !showLog && jobID == 0 { - fmt.Println("\nJobs:") - for _, task := range runTasks { - fmt.Printf("\n %s - %s (ID: %d)\n", formatStatus(task.Status), task.Name, task.ID) - if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { - fmt.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) - } - if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { - fmt.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) - } + // Case 1: --verbose (show job steps/details without logs) + if verbose && !showLog && !showLogFailed { + fmt.Println("\nJobs:") + for _, task := range runTasks { + fmt.Printf("\n %s - %s (ID: %d)\n", formatStatus(task.Status), task.Name, task.ID) + if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { + fmt.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + } + if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { + fmt.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } - return nil } + return nil + } - // Show logs for specific job or all jobs - if showLog || jobID > 0 { - tasksToShow := runTasks - if jobID > 0 { - // Filter to specific job - tasksToShow = nil - for _, task := range runTasks { - if task.ID == jobID { - tasksToShow = []ActionTask{task} - break - } - } - if len(tasksToShow) == 0 { - return fmt.Errorf("job %d not found in this run", jobID) - } + // Case 2: --log or --log-failed (show logs) + if showLog || showLogFailed { + for _, task := range runTasks { + if err := showJobLog(client, owner, name, run.IndexInRepo, task, showLogFailed); err != nil { + fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) } + } + return nil + } - for _, task := range tasksToShow { - if err := showJobLog(client, owner, name, run.IndexInRepo, task); err != nil { - fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) - } - } + // Case 3: --job without --log or --verbose (show job details only) + if jobID > 0 { + task := runTasks[0] + fmt.Println("\nJob Details:") + fmt.Printf(" Name: %s\n", task.Name) + fmt.Printf(" ID: %d\n", task.ID) + fmt.Printf(" Status: %s\n", formatStatus(task.Status)) + if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { + fmt.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + } + if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { + fmt.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } } return nil } -func showJobLog(client *api.Client, owner, name string, runNumber int64, task ActionTask) error { +func showJobLog(client *api.Client, owner, name string, runNumber int64, task ActionTask, logFailed bool) error { // Fetch log from /repos/{owner}/{repo}/actions/runs/{run_number}/jobs/{job_id}/logs logURL := fmt.Sprintf("https://%s/%s/%s/actions/runs/%d/jobs/%d/logs", client.Hostname(), owner, name, runNumber, task.ID) @@ -410,6 +440,14 @@ func showJobLog(client *api.Client, owner, name string, runNumber int64, task Ac return err } + // If --log-failed, filter to only show failed steps + // For now, just show all logs (filtering failed steps would require parsing the log format) + if logFailed { + // TODO: Implement filtering for failed steps only + // This would require parsing the log format and identifying failed step markers + fmt.Println("Note: --log-failed filtering not yet implemented, showing all logs") + } + fmt.Print(logContent) fmt.Println() diff --git a/internal/api/client.go b/internal/api/client.go index 6ea067e..42067c6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -84,3 +84,39 @@ func (c *Client) GetJSON(path string, result any) error { return nil } + +// GetRawLog performs a GET request and returns the raw response body as string +func (c *Client) GetRawLog(url string) (string, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // Set authentication header + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to perform request: %w", err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close response body: %w", closeErr) + } + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + return string(bodyBytes), nil +}