diff --git a/cmd/actions.go b/cmd/actions.go index 53f5a58..a32670d 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -2,8 +2,10 @@ package cmd import ( "fmt" + "net/http" "os" "strconv" + "strings" "text/tabwriter" "time" @@ -16,17 +18,17 @@ import ( // 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"` + 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 @@ -37,19 +39,19 @@ type ActionRunList struct { // 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"` + 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 @@ -109,6 +111,30 @@ var runViewCmd = &cobra.Command{ RunE: runRunView, } +var runWatchCmd = &cobra.Command{ + Use: "watch ", + Short: "Watch a workflow run", + Long: "Poll a workflow run until it completes.", + Args: cobra.ExactArgs(1), + RunE: runRunWatch, +} + +var runRerunCmd = &cobra.Command{ + Use: "rerun ", + Short: "Rerun a workflow run", + Long: "Trigger a rerun for a specific workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunRerun, +} + +var runCancelCmd = &cobra.Command{ + Use: "cancel ", + Short: "Cancel a workflow run", + Long: "Cancel a running workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunCancel, +} + // Workflow commands var workflowCmd = &cobra.Command{ Use: "workflow", @@ -139,6 +165,22 @@ var workflowRunCmd = &cobra.Command{ RunE: runWorkflowRun, } +var workflowEnableCmd = &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.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowEnable, +} + +var workflowDisableCmd = &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.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowDisable, +} + // Secret commands var actionsSecretCmd = &cobra.Command{ Use: "secret", @@ -222,12 +264,17 @@ func init() { actionsCmd.AddCommand(runCmd) runCmd.AddCommand(runListCmd) runCmd.AddCommand(runViewCmd) + runCmd.AddCommand(runWatchCmd) + runCmd.AddCommand(runRerunCmd) + runCmd.AddCommand(runCancelCmd) // Add workflow commands (gh workflow compatible) actionsCmd.AddCommand(workflowCmd) workflowCmd.AddCommand(workflowListCmd) workflowCmd.AddCommand(workflowViewCmd) workflowCmd.AddCommand(workflowRunCmd) + workflowCmd.AddCommand(workflowEnableCmd) + workflowCmd.AddCommand(workflowDisableCmd) // Add secret commands actionsCmd.AddCommand(actionsSecretCmd) @@ -246,17 +293,27 @@ func init() { // Add flags for run commands addRepoFlags(runListCmd) runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") + runListCmd.Flags().Bool("json", false, "Output workflow runs as JSON") 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") + runViewCmd.Flags().Bool("json", false, "Output workflow run as JSON") + addRepoFlags(runWatchCmd) + runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") + addRepoFlags(runRerunCmd) + addRepoFlags(runCancelCmd) // Add flags for workflow commands addRepoFlags(workflowListCmd) workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") + workflowListCmd.Flags().Bool("json", false, "Output workflows as JSON") addRepoFlags(workflowViewCmd) + workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") addRepoFlags(workflowRunCmd) + addRepoFlags(workflowEnableCmd) + addRepoFlags(workflowDisableCmd) workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") workflowRunCmd.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)") @@ -307,6 +364,10 @@ func runRunList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list runs: %w", err) } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(runList.WorkflowRuns) + } + if len(runList.WorkflowRuns) == 0 { fmt.Println("No workflow runs found") return nil @@ -364,6 +425,7 @@ func runRunView(cmd *cobra.Command, args []string) error { showLog, _ := cmd.Flags().GetBool("log") jobIDStr, _ := cmd.Flags().GetString("job") showLogFailed, _ := cmd.Flags().GetBool("log-failed") + jsonOutput, _ := cmd.Flags().GetBool("json") var jobID int64 if jobIDStr != "" { @@ -374,6 +436,10 @@ func runRunView(cmd *cobra.Command, args []string) error { } } + if jsonOutput && (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) @@ -382,6 +448,48 @@ func runRunView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get run: %w", err) } + needsJobs := verbose || showLog || showLogFailed || jobID > 0 + + if jsonOutput { + 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 writeJSON(payload) + } + // Display run information fmt.Printf("Title: %s\n", run.Title) fmt.Printf("Workflow: %s\n", run.WorkflowID) @@ -409,7 +517,6 @@ func runRunView(cmd *cobra.Command, args []string) error { } // Fetch jobs if needed for verbose, log, or job-specific views - needsJobs := verbose || showLog || showLogFailed || jobID > 0 if !needsJobs { return nil } @@ -466,7 +573,7 @@ func runRunView(cmd *cobra.Command, args []string) error { // 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 { + if err := showJobLog(client, owner, name, task, showLogFailed); err != nil { fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) } } @@ -491,10 +598,122 @@ func runRunView(cmd *cobra.Command, args []string) error { 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) +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()) + 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.Printf("Status: %s\n", formatStatus(run.Status)) + lastStatus = run.Status + } + + if isRunComplete(run.Status) { + fmt.Printf("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()) + 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.Printf("✓ 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()) + 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.Printf("✓ 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.Printf("\n========================================\n") fmt.Printf("Job: %s (ID: %d)\n", task.Name, task.ID) @@ -540,6 +759,15 @@ func formatStatus(status string) string { } } +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) @@ -620,10 +848,17 @@ func runWorkflowList(cmd *cobra.Command, args []string) error { } if len(allWorkflows) == 0 { + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(allWorkflows) + } fmt.Println("No workflows found") return nil } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(allWorkflows) + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { return fmt.Errorf("failed to write header: %w", err) @@ -661,37 +896,31 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { } workflowIdentifier := args[0] - - // Find the workflow by listing from both .gitea/workflows and .forgejo/workflows - var workflow *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" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) { - workflow = &Workflow{ - Name: content.Name, - Path: content.Path, - State: "active", - } - break - } - } - - if workflow != nil { - break - } + workflow, err := findWorkflow(client, owner, name, workflowIdentifier) + if err != nil { + return err } - if workflow == nil { - return fmt.Errorf("workflow '%s' not found", workflowIdentifier) + jsonOutput, _ := cmd.Flags().GetBool("json") + + 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 jsonOutput { + payload := struct { + Workflow *Workflow `json:"workflow"` + LatestRun *ActionRun `json:"latest_run,omitempty"` + }{ + Workflow: workflow, + LatestRun: latestRun, + } + return writeJSON(payload) } // Display workflow information @@ -699,21 +928,12 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { fmt.Printf("Path: %s\n", workflow.Path) fmt.Printf("State: %s\n", workflow.State) - // 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 { - // If we can't get runs, just display workflow info without latest run - return nil - } - - if len(runList.WorkflowRuns) > 0 { - run := runList.WorkflowRuns[0] + if latestRun != nil { fmt.Printf("\nLatest run:\n") - fmt.Printf(" Status: %s\n", formatStatus(run.Status)) - fmt.Printf(" Event: %s\n", run.Event) - fmt.Printf(" Ref: %s\n", run.PrettyRef) - if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { + fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status)) + fmt.Printf(" Event: %s\n", latestRun.Event) + fmt.Printf(" Ref: %s\n", latestRun.PrettyRef) + if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil { fmt.Printf(" Created: %s\n", formatTimeSince(createdTime)) } } @@ -798,6 +1018,96 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error { 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()) + 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.Printf("✓ 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()) + 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.Printf("✓ Workflow '%s' disabled\n", workflow.Name) + return nil +} + func splitKeyValue(s string) []string { idx := -1 for i, c := range s { @@ -812,6 +1122,29 @@ func splitKeyValue(s string) []string { 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 { diff --git a/cmd/auth.go b/cmd/auth.go index d52732f..0b2ef06 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -7,9 +7,10 @@ import ( "strings" "syscall" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" + "github.com/spf13/viper" "golang.org/x/term" ) @@ -33,13 +34,31 @@ var authStatusCmd = &cobra.Command{ RunE: runAuthStatus, } +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove authentication for a Forgejo instance", + Long: "Remove authentication for a configured Forgejo instance.", + RunE: runAuthLogout, +} + +var authTokenCmd = &cobra.Command{ + Use: "token", + Short: "Print the stored authentication token", + Long: "Print the stored authentication token for a configured Forgejo instance.", + RunE: runAuthToken, +} + func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authStatusCmd) + authCmd.AddCommand(authLogoutCmd) + authCmd.AddCommand(authTokenCmd) authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") authLoginCmd.Flags().StringP("token", "t", "", "Personal access token") + authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") + authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") } func runAuthLogin(cmd *cobra.Command, args []string) error { @@ -87,9 +106,9 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { } cfg.SetHost(hostname, config.HostConfig{ - Hostname: hostname, - Token: token, - User: user.UserName, + Hostname: hostname, + Token: token, + User: user.UserName, GitProtocol: "https", }) @@ -121,3 +140,66 @@ func runAuthStatus(cmd *cobra.Command, args []string) error { return nil } + +func runAuthLogout(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + hostname, _ := cmd.Flags().GetString("hostname") + resolved, err := resolveAuthHostname(cfg, hostname) + if err != nil { + return err + } + + delete(cfg.Hosts, resolved) + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("✓ Logged out from %s\n", resolved) + return nil +} + +func runAuthToken(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + hostname, _ := cmd.Flags().GetString("hostname") + resolved, err := resolveAuthHostname(cfg, hostname) + if err != nil { + return err + } + + fmt.Println(cfg.Hosts[resolved].Token) + return nil +} + +func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) { + if hostname == "" { + hostname = viper.GetString("hostname") + } + if hostname == "" { + hostname = os.Getenv("FGJ_HOST") + } + if hostname == "" { + hostname = getDetectedHost() + } + if hostname == "" && len(cfg.Hosts) == 1 { + for host := range cfg.Hosts { + hostname = host + } + } + if hostname == "" { + hostname = "codeberg.org" + } + + if _, ok := cfg.Hosts[hostname]; !ok { + return "", fmt.Errorf("no configuration found for host %s", hostname) + } + + return hostname, nil +} diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..6ead4b8 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: "Generate shell completion scripts for fgj.", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + var out io.Writer = os.Stdout + switch args[0] { + case "bash": + return rootCmd.GenBashCompletion(out) + case "zsh": + return rootCmd.GenZshCompletion(out) + case "fish": + return rootCmd.GenFishCompletion(out, true) + case "powershell": + return rootCmd.GenPowerShellCompletionWithDesc(out) + default: + return fmt.Errorf("unsupported shell: %s", args[0]) + } + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} diff --git a/cmd/issue.go b/cmd/issue.go index 5537105..d582d01 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -76,8 +76,10 @@ func init() { issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") + issueListCmd.Flags().Bool("json", false, "Output issues as JSON") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueViewCmd.Flags().Bool("json", false, "Output issue as JSON") issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") @@ -133,17 +135,26 @@ func runIssueList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list issues: %w", err) } - if len(issues) == 0 { + nonPRIssues := make([]*gitea.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.PullRequest == nil { + nonPRIssues = append(nonPRIssues, issue) + } + } + + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(nonPRIssues) + } + + if len(nonPRIssues) == 0 { fmt.Printf("No %s issues in %s/%s\n", state, owner, name) return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") - for _, issue := range issues { - if issue.PullRequest == nil { - _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) - } + for _, issue := range nonPRIssues { + _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) } _ = w.Flush() @@ -177,6 +188,23 @@ func runIssueView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get issue: %w", err) } + var comments []*gitea.Comment + comments, _, err = client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) + if err != nil { + comments = nil + } + + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + payload := struct { + Issue *gitea.Issue `json:"issue"` + Comments []*gitea.Comment `json:"comments,omitempty"` + }{ + Issue: issue, + Comments: comments, + } + return writeJSON(payload) + } + fmt.Printf("Issue #%d\n", issue.Index) fmt.Printf("Title: %s\n", issue.Title) fmt.Printf("State: %s\n", issue.State) @@ -187,8 +215,7 @@ func runIssueView(cmd *cobra.Command, args []string) error { fmt.Printf("\n%s\n", issue.Body) } - comments, _, err := client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) - if err == nil && len(comments) > 0 { + if len(comments) > 0 { fmt.Printf("\nComments (%d):\n", len(comments)) for _, comment := range comments { fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", diff --git a/cmd/json.go b/cmd/json.go new file mode 100644 index 0000000..21f1b25 --- /dev/null +++ b/cmd/json.go @@ -0,0 +1,12 @@ +package cmd + +import ( + "encoding/json" + "os" +) + +func writeJSON(value any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(value) +} diff --git a/cmd/manpages.go b/cmd/manpages.go new file mode 100644 index 0000000..5fc7e10 --- /dev/null +++ b/cmd/manpages.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" +) + +var manpagesCmd = &cobra.Command{ + Use: "manpages", + Short: "Generate manpages", + Long: "Generate manpages for fgj commands.", + RunE: func(cmd *cobra.Command, args []string) error { + dir, _ := cmd.Flags().GetString("dir") + if dir == "" { + return fmt.Errorf("directory is required") + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create %s: %w", dir, err) + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", dir, err) + } + + header := &doc.GenManHeader{ + Title: "FGJ", + Section: "1", + } + + if err := doc.GenManTree(rootCmd, header, absDir); err != nil { + return fmt.Errorf("failed to generate manpages: %w", err) + } + + fmt.Printf("Manpages generated in %s\n", absDir) + return nil + }, +} + +func init() { + rootCmd.AddCommand(manpagesCmd) + manpagesCmd.Flags().String("dir", "", "Output directory for manpages") + _ = manpagesCmd.MarkFlagRequired("dir") +} diff --git a/cmd/pr.go b/cmd/pr.go index 9c15401..1f35638 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -8,9 +8,9 @@ import ( "text/tabwriter" "code.gitea.io/sdk/gitea" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" ) var prCmd = &cobra.Command{ @@ -59,8 +59,10 @@ func init() { prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") + prListCmd.Flags().Bool("json", false, "Output pull requests as JSON") prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prViewCmd.Flags().Bool("json", false, "Output pull request as JSON") prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request") @@ -111,6 +113,10 @@ func runPRList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list pull requests: %w", err) } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(prs) + } + if len(prs) == 0 { fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) return nil @@ -153,6 +159,10 @@ func runPRView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get pull request: %w", err) } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(pr) + } + fmt.Printf("Pull Request #%d\n", pr.Index) fmt.Printf("Title: %s\n", pr.Title) fmt.Printf("State: %s\n", pr.State) @@ -279,4 +289,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return nil } - diff --git a/cmd/release.go b/cmd/release.go index 8c04768..8ad9413 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -72,8 +72,10 @@ func init() { releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch") + releaseListCmd.Flags().Bool("json", false, "Output releases as JSON") releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseViewCmd.Flags().Bool("json", false, "Output release as JSON") releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") @@ -146,6 +148,10 @@ func runReleaseList(cmd *cobra.Command, args []string) error { releases = releases[:limit] } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(releases) + } + if len(releases) == 0 { fmt.Printf("No releases in %s/%s\n", owner, name) return nil @@ -186,6 +192,22 @@ func runReleaseView(cmd *cobra.Command, args []string) error { return err } + attachments, err := listReleaseAttachments(client, owner, name, release.ID) + if err != nil { + return err + } + + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + payload := struct { + Release *gitea.Release `json:"release"` + Assets []*gitea.Attachment `json:"assets,omitempty"` + }{ + Release: release, + Assets: attachments, + } + return writeJSON(payload) + } + fmt.Printf("Release %s\n", release.TagName) fmt.Printf("Title: %s\n", release.Title) fmt.Printf("Type: %s\n", releaseType(release)) @@ -206,10 +228,6 @@ func runReleaseView(cmd *cobra.Command, args []string) error { fmt.Printf("\n%s\n", release.Note) } - attachments, err := listReleaseAttachments(client, owner, name, release.ID) - if err != nil { - return err - } if len(attachments) > 0 { fmt.Printf("\nAssets (%d):\n", len(attachments)) for _, asset := range attachments { diff --git a/cmd/repo.go b/cmd/repo.go index 3264196..286efc7 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -8,9 +8,9 @@ import ( "text/tabwriter" "code.gitea.io/sdk/gitea" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" ) var repoCmd = &cobra.Command{ diff --git a/cmd/root.go b/cmd/root.go index c1a373b..6f5afa4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,9 +5,9 @@ import ( "os" "strings" + "codeberg.org/romaintb/fgj/internal/git" "github.com/spf13/cobra" "github.com/spf13/viper" - "codeberg.org/romaintb/fgj/internal/git" ) var cfgFile string diff --git a/go.mod b/go.mod index 66a6f73..5c70717 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/42wim/httpsig v1.2.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect @@ -21,6 +22,7 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index a28f421..f344c22 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,6 +39,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= diff --git a/internal/api/client.go b/internal/api/client.go index 93904c9..083499c 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -88,6 +88,13 @@ func (c *Client) GetJSON(path string, result any) error { // PostJSON performs a POST request to the specified path with JSON body func (c *Client) PostJSON(path string, body any, result any) error { + _, err := c.DoJSON(http.MethodPost, path, body, result) + return err +} + +// DoJSON performs an HTTP request with a JSON body and decodes the JSON response. +// Returns the HTTP status code and any error encountered. +func (c *Client) DoJSON(method string, path string, body any, result any) (int, error) { baseURL := "https://" + c.hostname url := baseURL + path @@ -95,14 +102,14 @@ func (c *Client) PostJSON(path string, body any, result any) error { if body != nil { bodyBytes, err := json.Marshal(body) if err != nil { - return fmt.Errorf("failed to marshal request body: %w", err) + return 0, fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(bodyBytes) } - req, err := http.NewRequest(http.MethodPost, url, bodyReader) + req, err := http.NewRequest(method, url, bodyReader) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return 0, fmt.Errorf("failed to create request: %w", err) } // Set authentication header @@ -110,12 +117,14 @@ func (c *Client) PostJSON(path string, body any, result any) error { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { - return fmt.Errorf("failed to perform request: %w", err) + return 0, fmt.Errorf("failed to perform request: %w", err) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil && err == nil { @@ -125,16 +134,16 @@ func (c *Client) PostJSON(path string, body any, result any) error { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + return resp.StatusCode, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } if result != nil && resp.StatusCode != http.StatusNoContent { if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - return fmt.Errorf("failed to decode response: %w", err) + return resp.StatusCode, fmt.Errorf("failed to decode response: %w", err) } } - return nil + return resp.StatusCode, nil } // GetRawLog performs a GET request and returns the raw response body as string diff --git a/internal/config/config.go b/internal/config/config.go index 9584efc..4a5dc23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,9 +14,9 @@ type Config struct { } type HostConfig struct { - Hostname string `yaml:"hostname"` - Token string `yaml:"token"` - User string `yaml:"user,omitempty"` + Hostname string `yaml:"hostname"` + Token string `yaml:"token"` + User string `yaml:"user,omitempty"` GitProtocol string `yaml:"git_protocol,omitempty"` }