package cmd import ( "fmt" "os" "strconv" "text/tabwriter" "time" "code.gitea.io/sdk/gitea" "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" ) // ActionRun represents a workflow run type ActionRun struct { ID int64 `json:"id"` Title string `json:"title"` WorkflowID string `json:"workflow_id"` IndexInRepo int64 `json:"index_in_repo"` Event string `json:"event"` Status string `json:"status"` CommitSHA string `json:"commit_sha"` PrettyRef string `json:"prettyref"` Created string `json:"created"` Updated string `json:"updated"` Started string `json:"started"` } // ActionRunList represents a list of workflow runs type ActionRunList struct { TotalCount int `json:"total_count"` WorkflowRuns []ActionRun `json:"workflow_runs"` } // ActionTask represents a job/task within a workflow run type ActionTask struct { ID int64 `json:"id"` Name string `json:"name"` HeadBranch string `json:"head_branch"` HeadSHA string `json:"head_sha"` RunNumber int64 `json:"run_number"` Event string `json:"event"` DisplayTitle string `json:"display_title"` Status string `json:"status"` WorkflowID string `json:"workflow_id"` URL string `json:"url"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` RunStartedAt string `json:"run_started_at"` } // ActionTaskList represents a list of tasks/jobs type ActionTaskList struct { WorkflowRuns []ActionTask `json:"workflow_runs"` TotalCount int `json:"total_count"` } var actionsCmd = &cobra.Command{ Use: "actions", Aliases: []string{"action"}, Short: "Manage Forgejo Actions", Long: "View and manage workflows, runs, secrets, and variables for Forgejo Actions in your repositories.", } // Run commands (compatible with gh run) var runCmd = &cobra.Command{ Use: "run", Short: "View and manage workflow runs", Long: "List, view, and manage workflow runs.", } var runListCmd = &cobra.Command{ Use: "list", Short: "List recent workflow runs", Long: "List recent workflow runs for a repository.", RunE: runRunList, } var runViewCmd = &cobra.Command{ Use: "view ", Short: "View a workflow run", Long: "View details about a specific workflow run.", Args: cobra.ExactArgs(1), RunE: runRunView, } // Secret commands var actionsSecretCmd = &cobra.Command{ Use: "secret", Short: "Manage repository secrets", Long: "List, create, and delete secrets for Forgejo Actions.", } var actionsSecretListCmd = &cobra.Command{ Use: "list", Short: "List repository secrets", Long: "List all secrets for a repository.", RunE: runActionsSecretList, } var actionsSecretCreateCmd = &cobra.Command{ Use: "create ", Short: "Create or update a repository secret", Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.", Args: cobra.ExactArgs(1), RunE: runActionsSecretCreate, } var actionsSecretDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a repository secret", Long: "Delete a secret from Forgejo Actions.", Args: cobra.ExactArgs(1), RunE: runActionsSecretDelete, } // Variable commands var actionsVariableCmd = &cobra.Command{ Use: "variable", Short: "Manage repository variables", Long: "List, get, create, update, and delete variables for Forgejo Actions.", } var actionsVariableListCmd = &cobra.Command{ Use: "list", Short: "List repository variables", Long: "List all variables for a repository.", RunE: runActionsVariableList, } var actionsVariableGetCmd = &cobra.Command{ Use: "get ", Short: "Get a repository variable", Long: "Get the value of a specific repository variable.", Args: cobra.ExactArgs(1), RunE: runActionsVariableGet, } var actionsVariableCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a repository variable", Long: "Create a new variable for Forgejo Actions.", Args: cobra.ExactArgs(2), RunE: runActionsVariableCreate, } var actionsVariableUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a repository variable", Long: "Update an existing variable for Forgejo Actions.", Args: cobra.ExactArgs(2), RunE: runActionsVariableUpdate, } var actionsVariableDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a repository variable", Long: "Delete a variable from Forgejo Actions.", Args: cobra.ExactArgs(1), RunE: runActionsVariableDelete, } func init() { rootCmd.AddCommand(actionsCmd) // Add run commands (gh run compatible) actionsCmd.AddCommand(runCmd) runCmd.AddCommand(runListCmd) runCmd.AddCommand(runViewCmd) // Add secret commands actionsCmd.AddCommand(actionsSecretCmd) actionsSecretCmd.AddCommand(actionsSecretListCmd) actionsSecretCmd.AddCommand(actionsSecretCreateCmd) actionsSecretCmd.AddCommand(actionsSecretDeleteCmd) // Add variable commands actionsCmd.AddCommand(actionsVariableCmd) actionsVariableCmd.AddCommand(actionsVariableListCmd) actionsVariableCmd.AddCommand(actionsVariableGetCmd) actionsVariableCmd.AddCommand(actionsVariableCreateCmd) actionsVariableCmd.AddCommand(actionsVariableUpdateCmd) actionsVariableCmd.AddCommand(actionsVariableDeleteCmd) // Add flags for run commands addRepoFlags(runListCmd) runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") addRepoFlags(runViewCmd) 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) addRepoFlags(actionsSecretCreateCmd) addRepoFlags(actionsSecretDeleteCmd) // Add flags for variable commands addRepoFlags(actionsVariableListCmd) addRepoFlags(actionsVariableGetCmd) addRepoFlags(actionsVariableCreateCmd) addRepoFlags(actionsVariableUpdateCmd) addRepoFlags(actionsVariableDeleteCmd) } func addRepoFlags(cmd *cobra.Command) { cmd.Flags().StringP("repo", "R", "", "Repository in owner/name format (auto-detected from git if not specified)") } // Run command implementations func runRunList(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } limit, _ := cmd.Flags().GetInt("limit") // Call the API endpoint directly since SDK doesn't have it yet endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?limit=%d", owner, name, limit) var runList ActionRunList if err := client.GetJSON(endpoint, &runList); err != nil { return fmt.Errorf("failed to list runs: %w", err) } if len(runList.WorkflowRuns) == 0 { fmt.Println("No workflow runs found") return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) if _, err := fmt.Fprintln(w, "STATUS\tTITLE\tWORKFLOW\tEVENT\tID\tCREATED"); err != nil { return fmt.Errorf("failed to write header: %w", err) } for _, run := range runList.WorkflowRuns { createdTime, err := time.Parse(time.RFC3339, run.Created) if err != nil { createdTime = time.Now() } timeStr := formatTimeSince(createdTime) if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", formatStatus(run.Status), run.Title, run.WorkflowID, run.Event, run.ID, timeStr); err != nil { return fmt.Errorf("failed to write run: %w", err) } } if err := w.Flush(); err != nil { return fmt.Errorf("failed to flush output: %w", err) } return nil } func runRunView(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } runID, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return fmt.Errorf("invalid run ID: %w", err) } verbose, _ := cmd.Flags().GetBool("verbose") showLog, _ := cmd.Flags().GetBool("log") 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) var run ActionRun if err := client.GetJSON(endpoint, &run); err != nil { return fmt.Errorf("failed to get run: %w", err) } // Display run information fmt.Printf("Title: %s\n", run.Title) fmt.Printf("Workflow: %s\n", run.WorkflowID) fmt.Printf("Run: #%d\n", run.IndexInRepo) fmt.Printf("Status: %s\n", formatStatus(run.Status)) fmt.Printf("Event: %s\n", run.Event) fmt.Printf("Ref: %s\n", run.PrettyRef) commit := run.CommitSHA if len(commit) > 8 { commit = commit[:8] } fmt.Printf("Commit: %s\n", commit) if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { fmt.Printf("Created: %s\n", createdTime.Format("2006-01-02 15:04:05")) } if run.Started != "" { if startedTime, err := time.Parse(time.RFC3339, run.Started); err == nil { fmt.Printf("Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) } } if updatedTime, err := time.Parse(time.RFC3339, run.Updated); err == nil { fmt.Printf("Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } // Fetch jobs if needed for verbose, log, or job-specific views needsJobs := verbose || showLog || showLogFailed || jobID > 0 if !needsJobs { return nil } 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 !found { return fmt.Errorf("job %d not found in this run", jobID) } } // 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 } // 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 } // 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, 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) fmt.Printf("\n========================================\n") fmt.Printf("Job: %s (ID: %d)\n", task.Name, task.ID) fmt.Printf("Status: %s\n", formatStatus(task.Status)) fmt.Printf("========================================\n\n") // Use GetRawLog helper logContent, err := client.GetRawLog(logURL) if err != nil { 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() return nil } func formatStatus(status string) string { switch status { case "success": return "✓ success" case "failure": return "✗ failure" case "cancelled": return "- cancelled" case "skipped": return "○ skipped" case "in_progress", "running": return "● in progress" case "queued", "waiting": return "○ queued" default: return status } } func formatTimeSince(t time.Time) string { duration := time.Since(t) if duration < time.Minute { return "just now" } else if duration < time.Hour { mins := int(duration.Minutes()) if mins == 1 { return "1 minute ago" } return fmt.Sprintf("%d minutes ago", mins) } else if duration < 24*time.Hour { hours := int(duration.Hours()) if hours == 1 { return "1 hour ago" } return fmt.Sprintf("%d hours ago", hours) } days := int(duration.Hours() / 24) if days == 1 { return "1 day ago" } return fmt.Sprintf("%d days ago", days) } // Secret command implementations func runActionsSecretList(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } secrets, _, err := client.ListRepoActionSecret(owner, name, gitea.ListRepoActionSecretOption{}) if err != nil { return fmt.Errorf("failed to list secrets: %w", err) } if len(secrets) == 0 { fmt.Println("No secrets found") return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) if _, err := fmt.Fprintln(w, "NAME\tCREATED"); err != nil { return fmt.Errorf("failed to write header: %w", err) } for _, secret := range secrets { if _, err := fmt.Fprintf(w, "%s\t%s\n", secret.Name, secret.Created.Format("2006-01-02 15:04:05")); err != nil { return fmt.Errorf("failed to write secret: %w", err) } } if err := w.Flush(); err != nil { return fmt.Errorf("failed to flush output: %w", err) } return nil } func runActionsSecretCreate(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } secretName := args[0] // Read secret value from stdin fmt.Print("Enter secret value: ") var secretValue string _, err = fmt.Scanln(&secretValue) if err != nil { return fmt.Errorf("failed to read secret value: %w", err) } opt := gitea.CreateSecretOption{ Name: secretName, Data: secretValue, } _, err = client.CreateRepoActionSecret(owner, name, opt) if err != nil { return fmt.Errorf("failed to create secret: %w", err) } fmt.Printf("Secret '%s' created successfully\n", secretName) return nil } func runActionsSecretDelete(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } secretName := args[0] _, err = client.DeleteRepoActionSecret(owner, name, secretName) if err != nil { return fmt.Errorf("failed to delete secret: %w", err) } fmt.Printf("Secret '%s' deleted successfully\n", secretName) return nil } // Variable command implementations func runActionsVariableList(cmd *cobra.Command, args []string) error { // Note: The SDK doesn't have a ListRepoActionVariable method yet return fmt.Errorf("listing variables is not yet supported in the SDK") } func runActionsVariableGet(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } variableName := args[0] variable, _, err := client.GetRepoActionVariable(owner, name, variableName) if err != nil { return fmt.Errorf("failed to get variable: %w", err) } fmt.Printf("%s=%s\n", variable.Name, variable.Value) return nil } func runActionsVariableCreate(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } variableName := args[0] variableValue := args[1] _, err = client.CreateRepoActionVariable(owner, name, variableName, variableValue) if err != nil { return fmt.Errorf("failed to create variable: %w", err) } fmt.Printf("Variable '%s' created successfully\n", variableName) return nil } func runActionsVariableUpdate(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } variableName := args[0] variableValue := args[1] _, err = client.UpdateRepoActionVariable(owner, name, variableName, variableValue) if err != nil { return fmt.Errorf("failed to update variable: %w", err) } fmt.Printf("Variable '%s' updated successfully\n", variableName) return nil } func runActionsVariableDelete(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } client, err := api.NewClientFromConfig(cfg, "") if err != nil { return fmt.Errorf("failed to create client: %w", err) } repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return err } variableName := args[0] _, err = client.DeleteRepoActionVariable(owner, name, variableName) if err != nil { return fmt.Errorf("failed to delete variable: %w", err) } fmt.Printf("Variable '%s' deleted successfully\n", variableName) return nil }