package cmd import ( "fmt" "net/http" "strconv" "strings" "time" "code.gitea.io/sdk/gitea" "github.com/spf13/cobra" "forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fj/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"` } // Workflow represents a workflow definition type Workflow struct { ID int64 `json:"id"` Name string `json:"name"` Path string `json:"path"` State string `json:"state"` } // WorkflowList represents a list of workflows type WorkflowList struct { Workflows []Workflow `json:"workflows"` TotalCount int `json:"total_count"` } // ContentsResponse represents a file/directory in the repository type ContentsResponse struct { Name string `json:"name"` Path string `json:"path"` Type string `json:"type"` Size int64 `json:"size"` } 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 and Workflow command trees are built via factory functions // (newRunCmd / newWorkflowCmd) so cmd/aliases.go can build an identical // top-level tree under rootCmd without duplicating Use/Short/Long/Example/ // flag declarations. Single source of truth — drift impossible. // newRunCmd builds the `run` subtree. parentLabel is interpolated into the // parent's Short/Long so the alias-tree variant can advertise itself as // "alias for 'actions run'" without diverging on the children. func newRunCmd(parentLabel string) *cobra.Command { cmd := &cobra.Command{ Use: "run", Short: "View and manage workflow runs" + parentLabel, Long: "List, view, and manage workflow runs." + parentLabel, } cmd.AddCommand(newRunListCmd()) cmd.AddCommand(newRunViewCmd()) cmd.AddCommand(newRunWatchCmd()) cmd.AddCommand(newRunRerunCmd()) cmd.AddCommand(newRunCancelCmd()) return cmd } func newRunListCmd() *cobra.Command { c := &cobra.Command{ Use: "list", Short: "List recent workflow runs", Long: "List recent workflow runs for a repository.", Example: ` # List recent workflow runs fj actions run list # List runs with a custom limit fj actions run list -L 50 # Output as JSON fj actions run list --json`, RunE: runRunList, } addRepoFlags(c) c.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") addJSONFlags(c, "Output workflow runs as JSON") return c } func newRunViewCmd() *cobra.Command { c := &cobra.Command{ Use: "view ", Short: "View a workflow run", Long: "View details about a specific workflow run.", Example: ` # View a workflow run fj actions run view 123 # View with job details fj actions run view 123 -v # View logs for a specific job fj actions run view 123 --job 456 --log # View only failed logs fj actions run view 123 --log-failed`, Args: cobra.ExactArgs(1), RunE: runRunView, } addRepoFlags(c) c.Flags().BoolP("verbose", "v", false, "Show job steps") c.Flags().BoolP("log", "", false, "View full log for either a run or specific job") c.Flags().StringP("job", "j", "", "View a specific job ID from a run") c.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") addJSONFlags(c, "Output workflow run as JSON") return c } func newRunWatchCmd() *cobra.Command { c := &cobra.Command{ Use: "watch ", Short: "Watch a workflow run", Long: "Poll a workflow run until it completes.", Example: ` # Watch a run until it completes fj actions run watch 123 # Watch with a custom polling interval fj actions run watch 123 -i 10s`, Args: cobra.ExactArgs(1), RunE: runRunWatch, } addRepoFlags(c) c.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") return c } func newRunRerunCmd() *cobra.Command { c := &cobra.Command{ Use: "rerun ", Short: "Rerun a workflow run", Long: "Trigger a rerun for a specific workflow run.", Example: ` # Rerun a failed workflow run fj actions run rerun 123`, Args: cobra.ExactArgs(1), RunE: runRunRerun, } addRepoFlags(c) return c } func newRunCancelCmd() *cobra.Command { c := &cobra.Command{ Use: "cancel ", Short: "Cancel a workflow run", Long: "Cancel a running workflow run.", Example: ` # Cancel a running workflow fj actions run cancel 123`, Args: cobra.ExactArgs(1), RunE: runRunCancel, } addRepoFlags(c) return c } // newWorkflowCmd builds the `workflow` subtree. parentLabel is interpolated // the same way as newRunCmd's, so the alias variant can self-identify. func newWorkflowCmd(parentLabel string) *cobra.Command { cmd := &cobra.Command{ Use: "workflow", Short: "Manage workflows" + parentLabel, Long: "List, view, and run workflows." + parentLabel, } cmd.AddCommand(newWorkflowListCmd()) cmd.AddCommand(newWorkflowViewCmd()) cmd.AddCommand(newWorkflowRunCmd()) cmd.AddCommand(newWorkflowEnableCmd()) cmd.AddCommand(newWorkflowDisableCmd()) return cmd } func newWorkflowListCmd() *cobra.Command { c := &cobra.Command{ Use: "list", Short: "List workflows", Long: "List all workflows in a repository.", Example: ` # List all workflows fj actions workflow list # List workflows as JSON fj actions workflow list --json # List workflows for a specific repo fj actions workflow list -R owner/repo`, RunE: runWorkflowList, } addRepoFlags(c) c.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") addJSONFlags(c, "Output workflows as JSON") return c } func newWorkflowViewCmd() *cobra.Command { c := &cobra.Command{ Use: "view ", Short: "View a workflow", Long: "View details about a specific workflow. You can specify the workflow by name or filename.", Example: ` # View a workflow by filename fj actions workflow view ci.yml # View as JSON fj actions workflow view ci.yml --json`, Args: cobra.ExactArgs(1), RunE: runWorkflowView, } addRepoFlags(c) addJSONFlags(c, "Output workflow as JSON") return c } func newWorkflowRunCmd() *cobra.Command { c := &cobra.Command{ Use: "run ", Short: "Run a workflow", Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.", Example: ` # Trigger a workflow on the default branch fj actions workflow run deploy.yml # Trigger on a specific branch with input parameters fj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`, Args: cobra.ExactArgs(1), RunE: runWorkflowRun, } addRepoFlags(c) c.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") c.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") c.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)") return c } func newWorkflowEnableCmd() *cobra.Command { c := &cobra.Command{ Use: "enable ", Short: "Enable a workflow", Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.", Example: ` # Enable a workflow fj actions workflow enable ci.yml`, Args: cobra.ExactArgs(1), RunE: runWorkflowEnable, } addRepoFlags(c) return c } func newWorkflowDisableCmd() *cobra.Command { c := &cobra.Command{ Use: "disable ", Short: "Disable a workflow", Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.", Example: ` # Disable a workflow fj actions workflow disable ci.yml`, Args: cobra.ExactArgs(1), RunE: runWorkflowDisable, } addRepoFlags(c) return c } // 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.", Example: ` # List all secrets fj actions secret list # List secrets for a specific repo fj actions secret list -R owner/repo`, 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.", Example: ` # Create a secret (will prompt for value) fj actions secret create DEPLOY_TOKEN # Create a secret for a specific repo fj actions secret create API_KEY -R owner/repo`, Args: cobra.ExactArgs(1), RunE: runActionsSecretCreate, } var actionsSecretDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a repository secret", Long: "Delete a secret from Forgejo Actions.", Example: ` # Delete a secret fj actions secret delete DEPLOY_TOKEN`, 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.", Example: ` # List all variables fj actions variable list # List variables for a specific repo fj actions variable list -R owner/repo`, RunE: runActionsVariableList, } var actionsVariableGetCmd = &cobra.Command{ Use: "get ", Short: "Get a repository variable", Long: "Get the value of a specific repository variable.", Example: ` # Get a variable value fj actions variable get ENVIRONMENT`, 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.", Example: ` # Create a variable fj actions variable create ENVIRONMENT production # Create a variable for a specific repo fj actions variable create NODE_VERSION 20 -R owner/repo`, 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.", Example: ` # Update a variable fj actions variable update ENVIRONMENT staging`, Args: cobra.ExactArgs(2), RunE: runActionsVariableUpdate, } var actionsVariableDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a repository variable", Long: "Delete a variable from Forgejo Actions.", Example: ` # Delete a variable fj actions variable delete ENVIRONMENT`, Args: cobra.ExactArgs(1), RunE: runActionsVariableDelete, } func init() { rootCmd.AddCommand(actionsCmd) // Run and Workflow trees come from the factory functions defined above // so cmd/aliases.go can build identical top-level trees under rootCmd. actionsCmd.AddCommand(newRunCmd("")) actionsCmd.AddCommand(newWorkflowCmd("")) // 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 secret commands addRepoFlags(actionsSecretListCmd) addRepoFlags(actionsSecretCreateCmd) addRepoFlags(actionsSecretDeleteCmd) // Add flags for variable commands addRepoFlags(actionsVariableListCmd) addJSONFlags(actionsVariableListCmd, "Output variables as JSON") 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, "", getDetectedHost(), getCwd()) 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 wantJSON(cmd) { return outputJSON(cmd, runList.WorkflowRuns) } if len(runList.WorkflowRuns) == 0 { fmt.Fprintln(ios.Out, "No workflow runs found") return nil } tp := ios.NewTablePrinter() tp.AddHeader("STATUS", "TITLE", "WORKFLOW", "EVENT", "ID", "CREATED") for _, run := range runList.WorkflowRuns { createdTime, err := time.Parse(time.RFC3339, run.Created) if err != nil { createdTime = time.Now() } timeStr := formatTimeSince(createdTime) tp.AddRow(formatStatus(run.Status), run.Title, run.WorkflowID, run.Event, fmt.Sprintf("%d", run.ID), timeStr) } return tp.Render() } 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, "", getDetectedHost(), getCwd()) 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") jsonRequested := wantJSON(cmd) 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) } } if jsonRequested && (showLog || showLogFailed) { return fmt.Errorf("--json cannot be used with --log or --log-failed") } // 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) } needsJobs := verbose || showLog || showLogFailed || jobID > 0 if jsonRequested { var runTasks []ActionTask if needsJobs { 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) } for _, task := range taskList.WorkflowRuns { if task.RunNumber == run.IndexInRepo { runTasks = append(runTasks, task) } } if jobID > 0 { var filtered []ActionTask for _, task := range runTasks { if task.ID == jobID { filtered = append(filtered, task) break } } if len(filtered) == 0 { return fmt.Errorf("job %d not found in this run", jobID) } runTasks = filtered } } payload := struct { Run ActionRun `json:"run"` Tasks []ActionTask `json:"tasks,omitempty"` }{ Run: run, Tasks: runTasks, } return outputJSON(cmd, payload) } // Display run information fmt.Fprintf(ios.Out, "Title: %s\n", run.Title) fmt.Fprintf(ios.Out, "Workflow: %s\n", run.WorkflowID) fmt.Fprintf(ios.Out, "Run: #%d\n", run.IndexInRepo) fmt.Fprintf(ios.Out, "Status: %s\n", formatStatus(run.Status)) fmt.Fprintf(ios.Out, "Event: %s\n", run.Event) fmt.Fprintf(ios.Out, "Ref: %s\n", run.PrettyRef) commit := run.CommitSHA if len(commit) > 8 { commit = commit[:8] } fmt.Fprintf(ios.Out, "Commit: %s\n", commit) if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { fmt.Fprintf(ios.Out, "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.Fprintf(ios.Out, "Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) } } if updatedTime, err := time.Parse(time.RFC3339, run.Updated); err == nil { fmt.Fprintf(ios.Out, "Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } // Fetch jobs if needed for verbose, log, or job-specific views 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.Fprintln(ios.Out, "\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.Fprintln(ios.Out, "\nJobs:") for _, task := range runTasks { fmt.Fprintf(ios.Out, "\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.Fprintf(ios.Out, " Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) } if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { fmt.Fprintf(ios.Out, " 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, task, showLogFailed); err != nil { fmt.Fprintf(ios.Out, "\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.Fprintln(ios.Out, "\nJob Details:") fmt.Fprintf(ios.Out, " Name: %s\n", task.Name) fmt.Fprintf(ios.Out, " ID: %d\n", task.ID) fmt.Fprintf(ios.Out, " Status: %s\n", formatStatus(task.Status)) if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { fmt.Fprintf(ios.Out, " Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) } if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { fmt.Fprintf(ios.Out, " Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } } return nil } func runRunWatch(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, "", getDetectedHost(), getCwd()) 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) } interval, _ := cmd.Flags().GetDuration("interval") if interval <= 0 { return fmt.Errorf("interval must be greater than 0") } endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) var lastStatus string for { var run ActionRun if err := client.GetJSON(endpoint, &run); err != nil { return fmt.Errorf("failed to get run: %w", err) } if run.Status != lastStatus { fmt.Fprintf(ios.Out, "Status: %s\n", formatStatus(run.Status)) lastStatus = run.Status } if isRunComplete(run.Status) { fmt.Fprintf(ios.Out, "Run #%d completed with status %s\n", run.IndexInRepo, formatStatus(run.Status)) return nil } time.Sleep(interval) } } func runRunRerun(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, "", getDetectedHost(), getCwd()) 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) } endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun", owner, name, runID) if err := client.PostJSON(endpoint, nil, nil); err != nil { return fmt.Errorf("failed to rerun workflow: %w", err) } fmt.Fprintf(ios.Out, "✓ Rerun requested for run %d\n", runID) return nil } func runRunCancel(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, "", getDetectedHost(), getCwd()) 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) } endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/cancel", owner, name, runID) if err := client.PostJSON(endpoint, nil, nil); err != nil { return fmt.Errorf("failed to cancel workflow run: %w", err) } fmt.Fprintf(ios.Out, "✓ Cancel requested for run %d\n", runID) return nil } func showJobLog(client *api.Client, owner, name string, task ActionTask, logFailed bool) error { // Fetch log from API: GET /api/v1/repos/{owner}/{repo}/actions/jobs/{job_id}/logs logURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/actions/jobs/%d/logs", client.Hostname(), owner, name, task.ID) fmt.Fprintf(ios.Out, "\n========================================\n") fmt.Fprintf(ios.Out, "Job: %s (ID: %d)\n", task.Name, task.ID) fmt.Fprintf(ios.Out, "Status: %s\n", formatStatus(task.Status)) fmt.Fprintf(ios.Out, "========================================\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.Fprintln(ios.Out, "Note: --log-failed filtering not yet implemented, showing all logs") } fmt.Fprint(ios.Out, logContent) fmt.Fprintln(ios.Out) 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 isRunComplete(status string) bool { switch status { case "success", "failure", "cancelled", "skipped": return true default: return false } } 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) } // Workflow command implementations func runWorkflowList(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, "", getDetectedHost(), getCwd()) 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") // List workflows from both .gitea/workflows and .forgejo/workflows var allWorkflows []Workflow for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) var contents []ContentsResponse if err := client.GetJSON(endpoint, &contents); err != nil { // Directory might not exist, continue continue } for _, content := range contents { if content.Type == "file" && (len(content.Name) > 4 && (content.Name[len(content.Name)-4:] == ".yml" || content.Name[len(content.Name)-5:] == ".yaml")) { workflow := Workflow{ Name: content.Name, Path: content.Path, State: "active", } allWorkflows = append(allWorkflows, workflow) if len(allWorkflows) >= limit { break } } } if len(allWorkflows) >= limit { break } } if len(allWorkflows) == 0 { if wantJSON(cmd) { return outputJSON(cmd, allWorkflows) } fmt.Fprintln(ios.Out, "No workflows found") return nil } if wantJSON(cmd) { return outputJSON(cmd, allWorkflows) } tp := ios.NewTablePrinter() tp.AddHeader("NAME", "STATE", "PATH") for _, workflow := range allWorkflows { tp.AddRow(workflow.Name, workflow.State, workflow.Path) } return tp.Render() } func runWorkflowView(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, "", getDetectedHost(), getCwd()) 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 } workflowIdentifier := args[0] workflow, err := findWorkflow(client, owner, name, workflowIdentifier) if err != nil { return err } var latestRun *ActionRun // Get the latest run for this workflow runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path) var runList ActionRunList if err := client.GetJSON(runsEndpoint, &runList); err == nil && len(runList.WorkflowRuns) > 0 { latestRun = &runList.WorkflowRuns[0] } if wantJSON(cmd) { payload := struct { Workflow *Workflow `json:"workflow"` LatestRun *ActionRun `json:"latest_run,omitempty"` }{ Workflow: workflow, LatestRun: latestRun, } return outputJSON(cmd, payload) } // Display workflow information fmt.Fprintf(ios.Out, "Name: %s\n", workflow.Name) fmt.Fprintf(ios.Out, "Path: %s\n", workflow.Path) fmt.Fprintf(ios.Out, "State: %s\n", workflow.State) if latestRun != nil { fmt.Fprintf(ios.Out, "\nLatest run:\n") fmt.Fprintf(ios.Out, " Status: %s\n", formatStatus(latestRun.Status)) fmt.Fprintf(ios.Out, " Event: %s\n", latestRun.Event) fmt.Fprintf(ios.Out, " Ref: %s\n", latestRun.PrettyRef) if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil { fmt.Fprintf(ios.Out, " Created: %s\n", formatTimeSince(createdTime)) } } return nil } func runWorkflowRun(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, "", getDetectedHost(), getCwd()) 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 } workflowIdentifier := args[0] ref, _ := cmd.Flags().GetString("ref") fields, _ := cmd.Flags().GetStringSlice("field") rawFields, _ := cmd.Flags().GetStringSlice("raw-field") // If no ref is specified, get the repository's default branch if ref == "" { repoInfo, _, err := client.GetRepo(owner, name) if err != nil { return fmt.Errorf("failed to get repository info: %w", err) } ref = repoInfo.DefaultBranch } // Build the inputs map inputs := make(map[string]string) // Process -f/--field flags for _, field := range fields { parts := splitKeyValue(field) if len(parts) == 2 { inputs[parts[0]] = parts[1] } } // Process -F/--raw-field flags (same as field for now, file reading can be added later) for _, field := range rawFields { parts := splitKeyValue(field) if len(parts) == 2 { inputs[parts[0]] = parts[1] } } // Prepare the dispatch request dispatchReq := map[string]any{ "ref": ref, } if len(inputs) > 0 { dispatchReq["inputs"] = inputs } // Trigger the workflow endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, name, workflowIdentifier) if err := client.PostJSON(endpoint, dispatchReq, nil); err != nil { return fmt.Errorf("failed to trigger workflow: %w", err) } fmt.Fprintf(ios.Out, "✓ Workflow '%s' triggered successfully\n", workflowIdentifier) fmt.Fprintf(ios.Out, " Branch/Tag: %s\n", ref) if len(inputs) > 0 { fmt.Fprintln(ios.Out, " Inputs:") for key, value := range inputs { fmt.Fprintf(ios.Out, " %s: %s\n", key, value) } } return nil } func runWorkflowEnable(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, "", getDetectedHost(), getCwd()) 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 } workflowIdentifier := args[0] workflow, err := findWorkflow(client, owner, name, workflowIdentifier) if err != nil { return err } endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/enable", owner, name, workflow.Name) // Try PUT first (correct method per GitHub/Gitea API spec) status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil) if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) { // Fall back to POST for older versions status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil) } if err != nil { if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { return fmt.Errorf("failed to enable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+. " + "Your instance does not support the workflow enable/disable API endpoints yet. " + "You can enable workflows via the web UI instead") } return fmt.Errorf("failed to enable workflow: %w", err) } fmt.Fprintf(ios.Out, "✓ Workflow '%s' enabled\n", workflow.Name) return nil } func runWorkflowDisable(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, "", getDetectedHost(), getCwd()) 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 } workflowIdentifier := args[0] workflow, err := findWorkflow(client, owner, name, workflowIdentifier) if err != nil { return err } endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/disable", owner, name, workflow.Name) // Try PUT first (correct method per GitHub/Gitea API spec) status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil) if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) { // Fall back to POST for older versions status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil) } if err != nil { if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { return fmt.Errorf("failed to disable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+. " + "Your instance does not support the workflow enable/disable API endpoints yet. " + "You can disable workflows via the web UI instead") } return fmt.Errorf("failed to disable workflow: %w", err) } fmt.Fprintf(ios.Out, "✓ Workflow '%s' disabled\n", workflow.Name) return nil } func splitKeyValue(s string) []string { idx := -1 for i, c := range s { if c == '=' { idx = i break } } if idx == -1 { return []string{s} } return []string{s[:idx], s[idx+1:]} } func findWorkflow(client *api.Client, owner, name, workflowIdentifier string) (*Workflow, error) { for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) var contents []ContentsResponse if err := client.GetJSON(endpoint, &contents); err != nil { continue } for _, content := range contents { if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) { return &Workflow{ Name: content.Name, Path: content.Path, State: "active", }, nil } } } return nil, fmt.Errorf("workflow '%s' not found", workflowIdentifier) } // 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, "", getDetectedHost(), getCwd()) 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.Fprintln(ios.Out, "No secrets found") return nil } tp := ios.NewTablePrinter() tp.AddHeader("NAME", "CREATED") for _, secret := range secrets { tp.AddRow(secret.Name, secret.Created.Format("2006-01-02 15:04:05")) } return tp.Render() } 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, "", getDetectedHost(), getCwd()) 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.Fprint(ios.ErrOut, "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.Fprintf(ios.Out, "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, "", getDetectedHost(), getCwd()) 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.Fprintf(ios.Out, "Secret '%s' deleted successfully\n", secretName) return nil } // Variable command implementations // ActionVariable represents a repository action variable from the API type ActionVariable struct { Name string `json:"name"` Value string `json:"data"` } func runActionsVariableList(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, "", getDetectedHost(), getCwd()) 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 } endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, name) var variables []ActionVariable if err := client.GetJSON(endpoint, &variables); err != nil { return fmt.Errorf("failed to list variables: %w", err) } if wantJSON(cmd) { return outputJSON(cmd, variables) } if len(variables) == 0 { fmt.Fprintln(ios.Out, "No variables found") return nil } tp := ios.NewTablePrinter() tp.AddHeader("NAME", "VALUE") for _, v := range variables { tp.AddRow(v.Name, v.Value) } return tp.Render() } 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, "", getDetectedHost(), getCwd()) 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.Fprintf(ios.Out, "%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, "", getDetectedHost(), getCwd()) 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.Fprintf(ios.Out, "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, "", getDetectedHost(), getCwd()) 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.Fprintf(ios.Out, "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, "", getDetectedHost(), getCwd()) 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.Fprintf(ios.Out, "Variable '%s' deleted successfully\n", variableName) return nil }