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 details") runViewCmd.Flags().BoolP("log", "", false, "Show logs for all jobs") runViewCmd.Flags().Int64P("job", "j", 0, "Show logs for a specific job ID") // 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") jobID, _ := cmd.Flags().GetInt64("job") // 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")) } // 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) } // 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 } // 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")) } } 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) } } 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) } } } } return nil } func showJobLog(client *api.Client, owner, name string, runNumber int64, task ActionTask) 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 } 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 }